chore(generate): 删除已废弃组件的单测,清理孤儿 CSS 规则
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
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 / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web 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 / Frontend Lint (pull_request) Successful in 1m39s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m52s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m1s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m57s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m32s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 4m37s
AI Code Review / AI Code Review (pull_request) Successful in 6m56s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 9m13s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 15m8s
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 / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m56s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 42s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m8s

- 删除 CanvasPreviewGrid.audio.test.tsx / FrontendPreviewPlayer.audio.test.tsx / step5-preview.smoke.test.tsx(对应组件已删除)
- 清理 generate.css 中无意义的空 .xx-title-sidebar 规则及其响应式块
- tsc/eslint 均零错误
This commit is contained in:
2026-09-23 21:55:37 +08:00
parent eeebdbfdc9
commit 355e5117a0
5 changed files with 1 additions and 417 deletions
@@ -90,7 +90,7 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
)
return (
<div className="xx-form-section xx-title-sidebar">
<div className="xx-form-section">
<h3>📝 选择标题</h3>
{!isBatch ? (
-12
View File
@@ -2880,11 +2880,6 @@
Issue #1677 多视频批量生成
================================================================ */
/* ── 标题设置区域(全宽区块,不再受侧边栏约束) ── */
.xx-title-sidebar {
/* no max-height / overflow constraints */
}
/* ── 数量选择弹窗 ── */
.xx-modal-mask {
position: fixed;
@@ -3224,13 +3219,6 @@
border-radius: 4px;
}
/* ── 响应式:窄屏标题区域 ── */
@media (max-width: 960px) {
.xx-title-sidebar {
max-height: none;
}
}
.xx-batch-titles {
display: flex;
flex-direction: column;
@@ -1,176 +0,0 @@
/**
* CanvasPreviewGrid 单测(Issue #1741 / #1750)
*
* 验证:
* - N=3 时每个变体都拿到各自的 voiceAudioUrls[i](独立配音模式,null=TTS 合成中)
* - 每个变体都拿到各自的后端真实片段 variantClips[i](serverClips),playToken 为 0 基变体序号
* - 每个变体都收到 activePlayToken / onPlayTokenChange(播放互斥接线)
* - 某个实例上报播放 → 所有实例的 activePlayToken 变为该实例(其他实例收到 token≠自身,自动暂停)
* - 实例上报暂停(null)→ 播放权释放
* - #1750 变体计划申请失败:渲染 role=alert 错误占位 ×N(严禁假数据/不渲染播放器),
* 「重试」按钮仅变体 0 卡片出现且点击触发 onRetryClips
* - #1750 加载态:渲染加载占位,不渲染播放器
*/
import { describe, it, expect, beforeEach, vi } from "vitest"
import { render, fireEvent, screen } from "@testing-library/react"
import type { AssetItem } from "@/api/assets"
import type { EditPlanClip } from "@/api/template-editor"
const playerCalls = vi.hoisted(() => [] as Array<Record<string, unknown>>)
vi.mock("@/pages/generate/components/FrontendPreviewPlayer", () => ({
default: (props: Record<string, unknown>) => {
playerCalls.push(props)
const token = props.playToken as number
const active = props.activePlayToken as number | null
const change = props.onPlayTokenChange as (t: number | null) => void
return (
<div data-testid={`player-${token}`}>
<span data-testid={`voice-${token}`}>{props.voiceAudioUrl ? "has-voice" : "no-voice"}</span>
<span data-testid={`token-${token}`}>{active == null ? "none" : String(active)}</span>
<button type="button" onClick={() => change(token)}>
play-{token}
</button>
<button type="button" onClick={() => change(null)}>
pause-{token}
</button>
</div>
)
},
}))
import CanvasPreviewGrid from "@/pages/generate/components/CanvasPreviewGrid"
function makeAsset(id: string): AssetItem {
return {
id,
library_id: "lib-1",
name: `${id}.mp4`,
storage_key: `media/${id}.mp4`,
file_url: `https://cdn.example.com/${id}.mp4`,
mime_type: "video/mp4",
metadata: { duration: 10 },
duration: 10,
}
}
function makeClips(variant: number): EditPlanClip[] {
return ["a1", "a2"].map((assetId, order) => ({
id: `clip-v${variant}-${assetId}`,
plan_id: `plan-${variant}`,
clip_type: "main",
order,
asset_id: assetId,
text_content: "",
start_time: 0,
duration: 5,
transition_effect: "none",
transition_duration: 0,
playback_speed: 1,
status: "ready",
config: {},
}))
}
const titleSettings = {
size: 36,
font: "思源黑体",
color: "#fff",
position: "bottom" as const,
bold: false,
italic: false,
stroke: true,
shadow: false,
posX: null,
posY: null,
}
const variantClips: EditPlanClip[][] = [makeClips(0), makeClips(1), makeClips(2)]
function renderGrid(overrides: Record<string, unknown> = {}) {
playerCalls.length = 0
return render(
<CanvasPreviewGrid
count={3}
assets={[makeAsset("a1"), makeAsset("a2"), makeAsset("a3")]}
videoRatio="9:16"
titles={["标题1", "标题2", "标题3"]}
titleSettings={titleSettings}
variantClips={variantClips}
voiceAudioUrls={["https://cdn.example.com/v0.mp3", null, "https://cdn.example.com/v2.mp3"]}
selectedIds={[0, 1, 2]}
onToggleSelect={() => {}}
{...overrides}
/>,
)
}
describe("CanvasPreviewGrid 配音、真实片段与播放互斥 (#1741/#1750)", () => {
beforeEach(() => {
playerCalls.length = 0
})
it("N=3 时每个变体都拿到各自的配音 URL 与后端真实片段,playToken 为 0 基序号", () => {
renderGrid()
expect(playerCalls).toHaveLength(3)
playerCalls.forEach((p, i) => {
expect(p.playToken).toBe(i)
expect(p.serverClips).toBe(variantClips[i])
expect(p.variantTitle).toBe(`标题${i + 1}`)
})
// 独立配音:变体 0/2 有各自 URL;变体 1 为 null(TTS 合成中)→ 传 undefined
expect(playerCalls[0].voiceAudioUrl).toBe("https://cdn.example.com/v0.mp3")
expect(playerCalls[1].voiceAudioUrl).toBeUndefined()
expect(playerCalls[2].voiceAudioUrl).toBe("https://cdn.example.com/v2.mp3")
// 配音状态徽标
expect(screen.getByTestId("voice-0").textContent).toBe("has-voice")
expect(screen.getByTestId("voice-1").textContent).toBe("no-voice")
expect(screen.getByTestId("voice-2").textContent).toBe("has-voice")
})
it("每个变体都接线 activePlayToken / onPlayTokenChange", () => {
renderGrid()
playerCalls.forEach((p) => {
expect(p.activePlayToken).toBeNull()
expect(typeof p.onPlayTokenChange).toBe("function")
})
})
it("点击变体2播放:所有实例 activePlayToken 变为 1(0 基,其他实例自动暂停)", () => {
renderGrid()
fireEvent.click(screen.getByTestId("player-1").querySelector("button")!)
expect(screen.getByTestId("token-0").textContent).toBe("1")
expect(screen.getByTestId("token-1").textContent).toBe("1")
expect(screen.getByTestId("token-2").textContent).toBe("1")
})
it("正在播放实例上报暂停后,播放权释放(token 回 null)", () => {
renderGrid()
fireEvent.click(screen.getByTestId("player-2").querySelector("button")!)
expect(screen.getByTestId("token-0").textContent).toBe("2")
fireEvent.click(screen.getByTestId("player-2").querySelectorAll("button")[1])
expect(screen.getByTestId("token-2").textContent).toBe("none")
})
it("clipsError:每张卡片显示错误占位(role=alert)且不渲染任何播放器,重试按钮仅一个并触发回调", () => {
const onRetry = vi.fn()
renderGrid({ clipsError: true, onRetryClips: onRetry })
expect(screen.getAllByRole("alert")).toHaveLength(3)
expect(screen.getAllByText("预览加载失败,请重试")).toHaveLength(3)
// 严禁假数据:错误态不渲染播放器
expect(playerCalls).toHaveLength(0)
// 重试按钮仅变体 0 卡片出现
const retryButtons = screen.getAllByText("重试")
expect(retryButtons).toHaveLength(1)
fireEvent.click(retryButtons[0])
expect(onRetry).toHaveBeenCalledTimes(1)
})
it("clipsLoading:渲染加载占位,不渲染播放器", () => {
renderGrid({ clipsLoading: true })
expect(screen.getAllByText("独立选片中…")).toHaveLength(3)
expect(screen.queryAllByRole("alert")).toHaveLength(0)
expect(playerCalls).toHaveLength(0)
})
})
@@ -1,213 +0,0 @@
/**
* FrontendPreviewPlayer 音频行为单测(Issue #1741 / #1750)
*
* useSegmentScheduler 用 mock 控制播放态,专注验证本组件的音频逻辑:
* - 有配音时 video 保持 muted(素材原声不与配音混音)
* - 无配音时 video 不 muted(素材原声兜底,保证任何情况下播放有声)
* - 静音按钮:默认有声;点击后切 muted,aria-label 与图标切换
* - 批量播放互斥(#1750 playToken 为 0 基变体序号):activePlayToken 变为其他实例且本实例在播放时,调用 pause
* - 点击播放/暂停时上报播放权(onPlayTokenChange)
* - #1750:serverClips 是唯一片段来源,缺失时不渲染任何 video(无本地模拟 fallback)
*/
import { describe, it, expect, beforeEach, vi } from "vitest"
import { render, screen, fireEvent } from "@testing-library/react"
import FrontendPreviewPlayer from "@/pages/generate/components/FrontendPreviewPlayer"
import type { AssetItem } from "@/api/assets"
import type { EditPlanClip } from "@/api/template-editor"
const mocks = vi.hoisted(() => ({
isPlaying: false,
pause: vi.fn(),
togglePlayPause: vi.fn(),
}))
vi.mock("@/pages/generate/hooks/useSegmentScheduler", () => ({
// segments 由组件 buildPlaybackSegments 产出(唯一来源 serverClips);
// canPlay 与真实 hook 一致:无片段时不可播放
useSegmentScheduler: vi.fn((segments: unknown[]) => ({
isPlaying: mocks.isPlaying,
currentTime: 0,
totalDuration: 20,
currentSegmentIndex: 0,
canPlay: segments.length > 0,
togglePlayPause: mocks.togglePlayPause,
seekTo: vi.fn(),
pause: mocks.pause,
videoRefs: { current: [] as (HTMLVideoElement | null)[] },
})),
}))
vi.mock("@/pages/generate/hooks/useCanvasPlayer", () => ({
useCanvasPlayer: () => ({
state: {
isPlaying: false,
isReady: false,
isBuffering: false,
currentTime: 0,
duration: 0,
errorMessage: "",
hasDecodeError: false,
},
controls: { play: vi.fn(), pause: vi.fn(), seek: vi.fn() },
}),
}))
beforeEach(() => {
mocks.isPlaying = false
mocks.pause.mockClear()
mocks.togglePlayPause.mockClear()
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
})
function makeAsset(id: string): AssetItem {
return {
id,
library_id: "lib-1",
name: `${id}.mp4`,
storage_key: `media/${id}.mp4`,
file_url: `https://cdn.example.com/${id}.mp4`,
mime_type: "video/mp4",
metadata: { duration: 10, width: 1080, height: 1920 },
duration: 10,
}
}
function makeClip(assetId: string, order: number): EditPlanClip {
return {
id: `clip-${assetId}`,
plan_id: "plan-1",
clip_type: "main",
order,
asset_id: assetId,
text_content: "",
start_time: 0,
duration: 5,
transition_effect: "none",
transition_duration: 0,
playback_speed: 1,
status: "ready",
config: {},
}
}
const serverClips = [makeClip("a1", 0), makeClip("a2", 1)]
const baseProps = {
assets: [makeAsset("a1"), makeAsset("a2")],
videoRatio: "9:16",
ready: true,
serverClips,
}
function videos(): HTMLVideoElement[] {
return Array.from(document.querySelectorAll("video"))
}
describe("FrontendPreviewPlayer 音频行为 (#1741/#1750)", () => {
it("有配音时 video 保持 muted(素材原声不与配音混音)", () => {
render(<FrontendPreviewPlayer {...baseProps} voiceAudioUrl="https://cdn.example.com/tts.mp3" />)
expect(videos()).toHaveLength(2)
videos().forEach((v) => expect(v.muted).toBe(true))
})
it("无配音时 video 不 muted(素材原声兜底)", () => {
render(<FrontendPreviewPlayer {...baseProps} />)
videos().forEach((v) => expect(v.muted).toBe(false))
})
it("无配音时点静音按钮,video 切换为 muted;再点恢复", () => {
render(<FrontendPreviewPlayer {...baseProps} />)
const vs = videos()
expect(vs[0].muted).toBe(false)
fireEvent.click(screen.getByRole("button", { name: "静音" }))
videos().forEach((v) => expect(v.muted).toBe(true))
fireEvent.click(screen.getByRole("button", { name: "取消静音" }))
videos().forEach((v) => expect(v.muted).toBe(false))
})
it("批量播放互斥:token 变为其他实例且本实例在播放时调用 pause", () => {
mocks.isPlaying = true
const { rerender } = render(
<FrontendPreviewPlayer
{...baseProps}
playToken={1}
activePlayToken={1}
onPlayTokenChange={() => {}}
compact
/>,
)
expect(mocks.pause).not.toHaveBeenCalled()
// 播放权切给实例 2(0 基 token)
rerender(
<FrontendPreviewPlayer
{...baseProps}
playToken={1}
activePlayToken={2}
onPlayTokenChange={() => {}}
compact
/>,
)
expect(mocks.pause).toHaveBeenCalledTimes(1)
// token 切回自己:不重复暂停
rerender(
<FrontendPreviewPlayer
{...baseProps}
playToken={1}
activePlayToken={1}
onPlayTokenChange={() => {}}
compact
/>,
)
expect(mocks.pause).toHaveBeenCalledTimes(1)
})
it("未播放时 token 变化不触发暂停(effect 仅在本实例播放时生效)", () => {
// mocks.isPlaying = false(beforeEach 重置)
const { rerender } = render(
<FrontendPreviewPlayer {...baseProps} playToken={0} activePlayToken={0} compact />,
)
rerender(<FrontendPreviewPlayer {...baseProps} playToken={0} activePlayToken={1} compact />)
expect(mocks.pause).not.toHaveBeenCalled()
})
it("暂停状态下点击播放按钮:上报播放权为自身 playToken 并触发播放", () => {
const onToken = vi.fn()
render(
<FrontendPreviewPlayer {...baseProps} playToken={2} onPlayTokenChange={onToken} compact />,
)
// 暂停态有两个图标播放按钮(中央大按钮 + 控制条按钮),均调 handleTogglePlay,点中央那个
const playButtons = screen.getAllByRole("button").filter((b) => !b.getAttribute("aria-label"))
expect(playButtons.length).toBeGreaterThanOrEqual(1)
fireEvent.click(playButtons[0])
expect(onToken).toHaveBeenCalledWith(2)
expect(mocks.togglePlayPause).toHaveBeenCalledTimes(1)
})
it("每个卡片都渲染独立静音按钮", () => {
render(
<div>
<FrontendPreviewPlayer {...baseProps} playToken={0} compact />
<FrontendPreviewPlayer {...baseProps} playToken={1} compact />
<FrontendPreviewPlayer {...baseProps} playToken={2} compact />
</div>,
)
expect(screen.getAllByRole("button", { name: "静音" })).toHaveLength(3)
})
it("#1750 serverClips 缺失时不渲染任何 video(无本地模拟 fallback,显示无可播放素材)", () => {
render(<FrontendPreviewPlayer {...baseProps} serverClips={undefined} />)
expect(videos()).toHaveLength(0)
expect(screen.getByText("暂无可播放素材")).toBeInTheDocument()
})
})
@@ -1,15 +0,0 @@
/**
* Step4+5 preview smoke test (legacy — preview components removed)
* 保留最小测试确保 vitest related 模式仍能匹配 generate 目录改动
*/
import { describe, it, expect } from "vitest"
import "@/pages/generate/hooks/useStepNavigation"
import "@/pages/generate/components/GenerateStepContent"
import "@/pages/generate/GeneratePage"
describe("Generate page smoke test", () => {
it("should load generate modules", () => {
expect(true).toBe(true)
})
})