Compare commits

..

3 Commits

Author SHA1 Message Date
xiaoxia 65a80f2b01 fix(lint): Prettier format VideoPlayer
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 47s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m23s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m48s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m58s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m3s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 32s
AI Code Review / AI Code Review (pull_request) Successful in 2m52s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 50s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m4s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m42s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 7m12s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 23s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 47s
2026-07-29 22:30:12 +08:00
xiaoxia effd1dfdd9 test(products): 补充 useVideoPlayer smoke test
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 39s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m49s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m47s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 47s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m22s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m6s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m29s
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 50s
AI Code Review / AI Code Review (pull_request) Failing after 5m30s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m15s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m1s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 30s
2026-07-29 22:05:04 +08:00
xiaoxia 1cc091e8d8 refactor(products): VideoPlayer 改用 useVideoPlayer Hook 消除重复代码 2026-07-29 22:04:43 +08:00
3 changed files with 17 additions and 330 deletions
@@ -1,4 +1,4 @@
import React, { useRef, useState, useEffect, useCallback } from "react"
import React, { useEffect } from "react"
import {
VideoCameraOutlined,
PlayCircleOutlined,
@@ -11,6 +11,7 @@ import {
import { Button } from "@/components/ui"
import type { ProductItem } from "../types"
import { formatTime, formatSize } from "../utils"
import { useVideoPlayer } from "../hooks/useVideoPlayer"
interface VideoPlayerProps {
product: ProductItem
@@ -27,52 +28,19 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
onShare,
onViewDetail,
}) => {
const videoRef = useRef<HTMLVideoElement>(null)
const progressRef = useRef<HTMLDivElement>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(product.duration)
const {
videoRef,
progressRef,
isPlaying,
currentTime,
duration,
progress,
togglePlay,
handleSeek,
} = useVideoPlayer()
const hasVideo = !!product.videoUrl
/** 播放/暂停 */
const handlePlayPause = useCallback(() => {
const video = videoRef.current
if (!video) return
if (isPlaying) {
video.pause()
} else {
video.play().catch(() => {})
}
setIsPlaying(!isPlaying)
}, [isPlaying])
/** 视频事件监听 */
useEffect(() => {
const video = videoRef.current
if (!video) return
const onTime = () => setCurrentTime(video.currentTime)
const onDur = () => setDuration(video.duration || product.duration)
const onEnd = () => setIsPlaying(false)
video.addEventListener("timeupdate", onTime)
video.addEventListener("loadedmetadata", onDur)
video.addEventListener("ended", onEnd)
return () => {
video.removeEventListener("timeupdate", onTime)
video.removeEventListener("loadedmetadata", onDur)
video.removeEventListener("ended", onEnd)
}
}, [product.duration])
/** 进度条点击 */
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current) return
const rect = progressRef.current.getBoundingClientRect()
const percent = (e.clientX - rect.left) / rect.width
const newTime = percent * duration
setCurrentTime(newTime)
if (videoRef.current) videoRef.current.currentTime = newTime
}
const displayDuration = duration || product.duration
/** ESC 关闭 */
useEffect(() => {
@@ -83,8 +51,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
return () => window.removeEventListener("keydown", handleKey)
}, [onClose])
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
return (
<div className="xx-player-overlay" onClick={onClose}>
<div className="xx-player-container" onClick={(e) => e.stopPropagation()}>
@@ -114,7 +80,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
)}
{/* 播放/暂停按钮 */}
<button className="xx-player-play-btn" onClick={handlePlayPause}>
<button className="xx-player-play-btn" onClick={togglePlay}>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
@@ -125,12 +91,12 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
{/* 进度条 */}
<div className="xx-player-progress-wrap">
<div ref={progressRef} className="xx-player-progress" onClick={handleProgressClick}>
<div ref={progressRef} className="xx-player-progress" onClick={handleSeek}>
<div className="xx-player-progress-bar" style={{ width: `${progress}%` }} />
</div>
<div className="xx-player-time">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
<span>{formatTime(displayDuration)}</span>
</div>
</div>
</div>
@@ -22,6 +22,7 @@ import "@/pages/products/components/VideoPlayer"
// Hooks
import "@/pages/products/hooks/useProductList"
import "@/pages/products/hooks/useProductActions"
import "@/pages/products/hooks/useVideoPlayer"
describe("ProductLibrary module smoke test", () => {
it("should load all product modules", () => {
-280
View File
@@ -1,280 +0,0 @@
"""VerificationCode 单元测试."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
import pytest
from domain.verification_code import VerificationCode
class TestVerificationCodeCreate:
"""create() 工厂方法测试."""
def test_create_basic(self):
vc = VerificationCode.create("test@example.com", "email_login")
assert vc.id is not None
assert len(vc.id) == 32
assert vc.recipient == "test@example.com"
assert vc.code_type == "email_login"
assert len(vc.code) == 6
assert vc.code.isdigit()
assert vc.used_at is None
assert vc.attempts == 0
assert vc.created_at is not None
assert vc.expires_at > vc.created_at
def test_create_recipient_stripped(self):
vc = VerificationCode.create(" test@example.com ", "email_login")
assert vc.recipient == "test@example.com"
def test_create_custom_code(self):
vc = VerificationCode.create("test@example.com", "email_login", custom_code="123456")
assert vc.code == "123456"
def test_create_custom_ttl(self):
fixed_now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
with patch("domain.verification_code.datetime") as mock_dt:
mock_dt.now.return_value = fixed_now
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
vc = VerificationCode.create("test@example.com", "email_login", ttl_seconds=60)
assert vc.expires_at == fixed_now + timedelta(seconds=60)
def test_create_default_ttl_300(self):
fixed_now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
with patch("domain.verification_code.datetime") as mock_dt:
mock_dt.now.return_value = fixed_now
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
vc = VerificationCode.create("test@example.com", "email_login")
assert vc.expires_at == fixed_now + timedelta(seconds=300)
def test_create_unique_ids(self):
vc1 = VerificationCode.create("a@b.com", "email_login")
vc2 = VerificationCode.create("a@b.com", "email_login")
assert vc1.id != vc2.id
def test_create_unique_codes(self):
codes = set()
for _ in range(20):
vc = VerificationCode.create("a@b.com", "email_login")
codes.add(vc.code)
# 20个随机6位码几乎肯定不都一样
assert len(codes) > 1
def test_create_phone_recipient(self):
vc = VerificationCode.create("13800138000", "phone_login")
assert vc.recipient == "13800138000"
assert vc.code_type == "phone_login"
def test_create_all_code_types(self):
for ct in ["email_bind", "phone_bind", "email_login", "phone_login", "reset_password"]:
vc = VerificationCode.create("test@example.com", ct)
assert vc.code_type == ct
class TestVerificationCodeIsExpired:
"""is_expired 属性测试."""
def test_not_expired_future(self):
future = datetime.now(timezone.utc) + timedelta(hours=1)
vc = VerificationCode(
id="1",
recipient="a@b.com",
code="123456",
code_type="email_login",
expires_at=future,
)
assert vc.is_expired is False
def test_expired_past(self):
past = datetime.now(timezone.utc) - timedelta(hours=1)
vc = VerificationCode(
id="1",
recipient="a@b.com",
code="123456",
code_type="email_login",
expires_at=past,
)
assert vc.is_expired is True
def test_expired_boundary_exact(self):
# 用mock固定时间,expires_at等于当前时间不算过期
fixed_now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
with patch("domain.verification_code.datetime") as mock_dt:
mock_dt.now.return_value = fixed_now
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
vc = VerificationCode(
id="1",
recipient="a@b.com",
code="123456",
code_type="email_login",
expires_at=fixed_now,
)
assert vc.is_expired is False
class TestVerificationCodeIsUsed:
"""is_used 属性测试."""
def test_not_used_default(self):
vc = VerificationCode.create("a@b.com", "email_login")
assert vc.is_used is False
def test_is_used_after_mark(self):
vc = VerificationCode.create("a@b.com", "email_login")
vc.mark_used()
assert vc.is_used is True
class TestVerificationCodeIsValid:
"""is_valid 属性测试."""
def test_valid_fresh(self):
future = datetime.now(timezone.utc) + timedelta(hours=1)
vc = VerificationCode(
id="1",
recipient="a@b.com",
code="123456",
code_type="email_login",
expires_at=future,
)
assert vc.is_valid is True
def test_invalid_expired(self):
past = datetime.now(timezone.utc) - timedelta(hours=1)
vc = VerificationCode(
id="1",
recipient="a@b.com",
code="123456",
code_type="email_login",
expires_at=past,
)
assert vc.is_valid is False
def test_invalid_used(self):
future = datetime.now(timezone.utc) + timedelta(hours=1)
vc = VerificationCode(
id="1",
recipient="a@b.com",
code="123456",
code_type="email_login",
expires_at=future,
)
vc.mark_used()
assert vc.is_valid is False
def test_invalid_expired_and_used(self):
past = datetime.now(timezone.utc) - timedelta(hours=1)
vc = VerificationCode(
id="1",
recipient="a@b.com",
code="123456",
code_type="email_login",
expires_at=past,
)
vc.mark_used()
assert vc.is_valid is False
class TestVerificationCodeMarkUsed:
"""mark_used 方法测试."""
def test_mark_used_sets_timestamp(self):
vc = VerificationCode.create("a@b.com", "email_login")
assert vc.used_at is None
before = datetime.now(timezone.utc)
vc.mark_used()
after = datetime.now(timezone.utc)
assert vc.used_at is not None
assert before <= vc.used_at <= after
def test_mark_used_twice_overwrites(self):
vc = VerificationCode.create("a@b.com", "email_login")
vc.mark_used()
first = vc.used_at
# 时间足够短,一般不会不同,但确保可以重复调用
vc.mark_used()
assert vc.used_at is not None
class TestVerificationCodeIncrementAttempts:
"""increment_attempts 方法测试."""
def test_default_zero(self):
vc = VerificationCode.create("a@b.com", "email_login")
assert vc.attempts == 0
def test_increment_once(self):
vc = VerificationCode.create("a@b.com", "email_login")
vc.increment_attempts()
assert vc.attempts == 1
def test_increment_multiple(self):
vc = VerificationCode.create("a@b.com", "email_login")
for _i in range(5):
vc.increment_attempts()
assert vc.attempts == 5
class TestVerificationCodeBasics:
"""基础构造和 slots 测试."""
def test_direct_construction(self):
now = datetime.now(timezone.utc)
vc = VerificationCode(
id="abc123",
recipient="test@test.com",
code="000000",
code_type="email_bind",
expires_at=now + timedelta(minutes=5),
used_at=None,
attempts=0,
created_at=now,
)
assert vc.id == "abc123"
assert vc.recipient == "test@test.com"
assert vc.code == "000000"
def test_slots_no_extra_attrs(self):
vc = VerificationCode.create("a@b.com", "email_login")
with pytest.raises((AttributeError, TypeError)):
vc.new_field = "value" # type: ignore[attr-defined]
def test_equality_same_id(self):
now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
vc1 = VerificationCode(
id="same",
recipient="a@b.com",
code="111",
code_type="email_login",
expires_at=now,
created_at=now,
)
vc2 = VerificationCode(
id="same",
recipient="a@b.com",
code="111",
code_type="email_login",
expires_at=now,
created_at=now,
)
assert vc1 == vc2
def test_equality_different_id(self):
now = datetime.now(timezone.utc)
vc1 = VerificationCode(
id="id1",
recipient="a@b.com",
code="111",
code_type="email_login",
expires_at=now,
)
vc2 = VerificationCode(
id="id2",
recipient="a@b.com",
code="111",
code_type="email_login",
expires_at=now,
)
assert vc1 != vc2