feat(agc): 「生成任务」改造成常驻画布的可折叠侧栏(分栏 / 计数 / 自动展开 / 折叠不影响任务)
- ResourceCanvasAssetGenerationTasksPanelView 由非模态浮层改造成任务侧栏:展开时是贴边 `<aside>`(`role="region"`、`aria-label="生成任务"`、不铺遮罩、不做焦点陷阱、不进模态遮挡判据), 折叠时只留一个可点的贴边把手(`aria-label="展开生成任务"`)并带在途数量角标 - 位置取舍:右侧是「智能创作」对话面板、底部是栏目工具栏、顶部是栏目标题栏,所以挂在**左侧、标题栏 之下、工具栏之上**(`top-16 / bottom-24 / left-3 / w-[300px]`)且是**覆盖式**而不是把画布挤窄—— 画布视口数学与资源卡排布都不被 reflow 动到,收起即完全让出画布;不再保留原来的浮层形态,没有平行入口 - 两个分栏:「排队/生成中」与「已完成」各带条数,只做客户端分组;「已完成」条数封顶 20 条(超出给出 还剩多少条的说明),列表自身 `overflow-y-auto`,侧栏高度有界 - 入口按钮常驻可见并显示在途数量(`生成任务 · N`,无在途时仍是「生成任务」,`aria-label="生成任务"` 保留), 把手上的角标同步;提交受理后侧栏自动展开(对齐网页端排队提交后主动弹任务栏) - index.tsx:新增在途数量派生;侧栏常驻渲染并受同一个开合开关控制,折叠只影响这个视图, 任务本身仍活在账本与本地队列里 - 测试:任务侧栏用例改为侧栏形态并补「两个分栏 + 条数」「高度有界 + 列表滚动 + 已完成封顶 20」 「折叠后把手带上在途计数(0 / 2)」;AppSurface 补「提交后自动展开」「折叠期间任务照常推进、 在途计数归零、再展开仍能看到结果」
This commit is contained in:
+181
-78
@@ -1,4 +1,4 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, ListChecks, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
@@ -10,35 +10,104 @@ import {
|
||||
sortResourceCanvasAssetGenerationTasks,
|
||||
} from './resourceCanvasAssetGenerationTaskModel';
|
||||
|
||||
/** 「已完成」分栏的展示上限:触顶后只提示还有多少条,不无限拉长侧栏。 */
|
||||
export const RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT = 20;
|
||||
|
||||
export type ResourceCanvasAssetGenerationTasksPanelViewProps = {
|
||||
tasks: readonly ResourceCanvasAssetGenerationTask[];
|
||||
onClose: () => void;
|
||||
/** 侧栏是否展开;折叠时只留贴边把手。 */
|
||||
open: boolean;
|
||||
onToggleOpen: () => void;
|
||||
/** 定位到该任务产出的素材卡(宿主复用既有 `pendingResourceFocusRef` 聚焦链)。 */
|
||||
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void;
|
||||
};
|
||||
|
||||
function taskRow(
|
||||
task: ResourceCanvasAssetGenerationTask,
|
||||
nowMillis: number,
|
||||
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void,
|
||||
) {
|
||||
const focusable = task.status === 'completed' && Boolean(task.assetId);
|
||||
return (
|
||||
<li
|
||||
key={task.taskId}
|
||||
className="rounded-xl px-2 py-2"
|
||||
style={{ background: 'var(--platform-panel-fill)' }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<strong className="text-xs font-bold">{task.assetName}</strong>
|
||||
<span className="text-[11px] opacity-70">{task.actionLabel}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px]">
|
||||
<span className="rounded-full border border-black/10 px-2 py-0.5">
|
||||
{RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS[task.status]}
|
||||
</span>
|
||||
<span>{task.phaseDetail}</span>
|
||||
<span className="opacity-70">
|
||||
{`已耗时 ${resourceCanvasAssetGenerationElapsedLabel(
|
||||
resourceCanvasAssetGenerationTaskElapsedMillis(task, nowMillis),
|
||||
)}`}
|
||||
</span>
|
||||
</div>
|
||||
{task.error ? (
|
||||
<p className="mt-1 text-[11px]" role="alert">
|
||||
{task.error}
|
||||
</p>
|
||||
) : null}
|
||||
{focusable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 text-[11px] underline"
|
||||
aria-label={`定位素材 ${task.assetName}`}
|
||||
onClick={() => onFocusTask(task)}
|
||||
>
|
||||
定位到素材
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 「生成任务」浮层面板:列出每条图片类生成任务的状态、后端阶段文案、已耗时与素材名。
|
||||
* 画布上的「生成任务」侧栏(常驻、可折叠、非模态)。
|
||||
*
|
||||
* **它是非模态的**:不铺全屏遮罩、不做焦点陷阱、不进 `isResourceCanvasFloatingPanelOpen` 的
|
||||
* 遮挡判据——生成在后台跑,面板开着的时候画布必须照样能看能用。这也是它和两块生成浮层
|
||||
* (提交表单,`ThemedModal` 模态)的关键差别。
|
||||
* 形态对齐网页端美术画布的任务侧栏:贴边的独立 `<aside>`,展开时是一列任务(画布照常可交互),
|
||||
* 折叠时只留一个可点的贴边把手(带在途数量角标)。它**不是**模态浮层:不铺全屏遮罩、不做焦点
|
||||
* 陷阱、不进 `isResourceCanvasFloatingPanelOpen` / `resourceCanvasHostGenerationPanelOpen` 的
|
||||
* 遮挡判据;折叠也不影响任务推进——任务活在账本与本地队列里,与这个视图的生命周期无关。
|
||||
*
|
||||
* 阶段文案直接渲染后端账本给的 `phaseDetail`,面板不自己拼阶段、不做百分比进度。
|
||||
* 位置取舍:AGC 栏目画布的右侧是「智能创作」对话面板、底部是栏目工具栏、顶部是栏目标题栏,所以
|
||||
* 侧栏挂在**左侧、标题栏之下、工具栏之上**,并且是**覆盖式**而不是把画布挤窄(画布的视口数学与
|
||||
* 资源卡排布都不被 reflow 动到,收起即完全让出画布)。宽度 300px 落在 280–340px 区间内。
|
||||
*
|
||||
* 两个分栏(「排队/生成中」与「已完成」)只做客户端分组,数据源仍是同一份任务列表;「已完成」
|
||||
* 条数封顶且列表自身滚动,侧栏高度有界。
|
||||
*/
|
||||
export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
tasks,
|
||||
onClose,
|
||||
open,
|
||||
onToggleOpen,
|
||||
onFocusTask,
|
||||
}: ResourceCanvasAssetGenerationTasksPanelViewProps) {
|
||||
const [nowMillis, setNowMillis] = useState(() => Date.now());
|
||||
const hasLiveTask = tasks.some(
|
||||
const inFlightCount = tasks.filter(
|
||||
(task) => !resourceCanvasAssetGenerationTaskIsTerminal(task),
|
||||
);
|
||||
).length;
|
||||
const hasLiveTask = inFlightCount > 0;
|
||||
const ordered = useMemo(
|
||||
() => sortResourceCanvasAssetGenerationTasks(tasks),
|
||||
[tasks],
|
||||
);
|
||||
const active = ordered.filter(
|
||||
(task) => !resourceCanvasAssetGenerationTaskIsTerminal(task),
|
||||
);
|
||||
const done = ordered.filter((task) =>
|
||||
resourceCanvasAssetGenerationTaskIsTerminal(task),
|
||||
);
|
||||
const visibleDone = done.slice(
|
||||
0,
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT,
|
||||
);
|
||||
|
||||
// 已耗时是前端计时(后端只给时间戳):只在还有未终态任务时走秒表,全部收口后停掉。
|
||||
useEffect(() => {
|
||||
@@ -49,88 +118,122 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
return () => clearInterval(timer);
|
||||
}, [hasLiveTask]);
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="platform-theme platform-theme--light fixed top-1/2 left-0 z-40 flex -translate-y-1/2 flex-col items-center gap-1 rounded-r-xl border border-l-0 border-black/10 px-1.5 py-3 shadow-lg"
|
||||
style={{
|
||||
background: 'var(--platform-subpanel-fill)',
|
||||
color: 'var(--platform-text-strong)',
|
||||
}}
|
||||
aria-label="展开生成任务"
|
||||
aria-expanded={false}
|
||||
data-resource-generation-task-count={inFlightCount}
|
||||
onClick={onToggleOpen}
|
||||
>
|
||||
<ListChecks size={15} aria-hidden="true" />
|
||||
<span className="text-[11px] font-bold [writing-mode:vertical-rl]">
|
||||
生成任务
|
||||
</span>
|
||||
{inFlightCount > 0 ? (
|
||||
<span
|
||||
className="rounded-full border border-black/10 px-1 text-[11px] font-black"
|
||||
aria-label={`在途生成任务 ${inFlightCount}`}
|
||||
>
|
||||
{inFlightCount}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronRight size={13} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="platform-theme platform-theme--light fixed inset-x-3 bottom-20 z-40 max-h-[60vh] overflow-auto rounded-2xl border border-black/10 p-3 shadow-lg sm:inset-x-auto sm:right-4 sm:w-96"
|
||||
<aside
|
||||
className="platform-theme platform-theme--light fixed top-16 bottom-24 left-3 z-40 flex w-[300px] max-w-[80vw] flex-col rounded-2xl border border-black/10 shadow-lg"
|
||||
style={{
|
||||
background: 'var(--platform-subpanel-fill)',
|
||||
color: 'var(--platform-text-strong)',
|
||||
}}
|
||||
role="region"
|
||||
aria-label="生成任务"
|
||||
data-resource-generation-task-count={inFlightCount}
|
||||
>
|
||||
<header className="flex items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-black">生成任务</h2>
|
||||
<header className="flex items-center justify-between gap-2 border-b border-black/10 px-3 py-2">
|
||||
<h2 className="flex items-center gap-1 text-sm font-black">
|
||||
<ListChecks size={15} aria-hidden="true" />
|
||||
生成任务
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded-full"
|
||||
aria-label="收起生成任务"
|
||||
onClick={onToggleOpen}
|
||||
>
|
||||
<ChevronLeft size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto px-3 py-2"
|
||||
data-resource-generation-task-scroll=""
|
||||
>
|
||||
{ordered.length === 0 ? (
|
||||
<p className="text-xs" role="status">
|
||||
还没有生成任务
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<section aria-label="排队与生成中">
|
||||
<h3 className="flex items-center justify-between text-xs font-bold">
|
||||
<span>排队/生成中</span>
|
||||
<span>{active.length}</span>
|
||||
</h3>
|
||||
{active.length === 0 ? (
|
||||
<p className="mt-1 text-[11px] opacity-70">没有进行中的任务</p>
|
||||
) : (
|
||||
<ul className="mt-1 flex flex-col gap-2">
|
||||
{active.map((task) => taskRow(task, nowMillis, onFocusTask))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
<section aria-label="已完成" className="mt-3">
|
||||
<h3 className="flex items-center justify-between text-xs font-bold">
|
||||
<span>已完成</span>
|
||||
<span>{done.length}</span>
|
||||
</h3>
|
||||
{done.length === 0 ? (
|
||||
<p className="mt-1 text-[11px] opacity-70">还没有完成的任务</p>
|
||||
) : (
|
||||
<>
|
||||
<ul className="mt-1 flex flex-col gap-2">
|
||||
{visibleDone.map((task) =>
|
||||
taskRow(task, nowMillis, onFocusTask),
|
||||
)}
|
||||
</ul>
|
||||
{done.length > visibleDone.length ? (
|
||||
<p className="mt-1 text-[11px] opacity-70">
|
||||
{`仅显示最近 ${RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT} 条,另有 ${
|
||||
done.length - visibleDone.length
|
||||
} 条较早记录`}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<footer className="flex items-center justify-end border-t border-black/10 px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded-full"
|
||||
aria-label="关闭生成任务"
|
||||
onClick={onClose}
|
||||
onClick={onToggleOpen}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
{ordered.length === 0 ? (
|
||||
<p className="mt-2 text-xs" role="status">
|
||||
还没有生成任务
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-2 flex flex-col gap-2">
|
||||
{ordered.map((task) => {
|
||||
const focusable =
|
||||
task.status === 'completed' && Boolean(task.assetId);
|
||||
return (
|
||||
<li
|
||||
key={task.taskId}
|
||||
className="rounded-xl px-2 py-2"
|
||||
style={{ background: 'var(--platform-panel-fill)' }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<strong className="text-xs font-bold">
|
||||
{task.assetName}
|
||||
</strong>
|
||||
<span className="text-[11px] opacity-70">
|
||||
{task.actionLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px]">
|
||||
<span className="rounded-full border px-2 py-0.5">
|
||||
{
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS[
|
||||
task.status
|
||||
]
|
||||
}
|
||||
</span>
|
||||
<span>{task.phaseDetail}</span>
|
||||
<span className="opacity-70">
|
||||
{`已耗时 ${resourceCanvasAssetGenerationElapsedLabel(
|
||||
resourceCanvasAssetGenerationTaskElapsedMillis(
|
||||
task,
|
||||
nowMillis,
|
||||
),
|
||||
)}`}
|
||||
</span>
|
||||
</div>
|
||||
{task.error ? (
|
||||
<p className="mt-1 text-[11px]" role="alert">
|
||||
{task.error}
|
||||
</p>
|
||||
) : null}
|
||||
{focusable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 text-[11px] underline"
|
||||
aria-label={`定位素材 ${task.assetName}`}
|
||||
onClick={() => onFocusTask(task)}
|
||||
>
|
||||
定位到素材
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</footer>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6728,6 +6728,13 @@ export default function ProjectDevelopmentView({
|
||||
activePageCategory,
|
||||
};
|
||||
resourceWorkbenchNoticeRef.current = resourceWorkbenchNotice;
|
||||
/** 入口按钮与折叠把手上显示的在途数量:只数当前项目的未终态任务。 */
|
||||
const resourceAssetGenerationInFlightCount =
|
||||
resourceAssetGenerationTasks.filter(
|
||||
(task) =>
|
||||
task.projectId === manifest.projectId &&
|
||||
!resourceCanvasAssetGenerationTaskIsTerminal(task),
|
||||
).length;
|
||||
|
||||
const replaceResourceAssetGenerationTask = useCallback(
|
||||
(next: ResourceCanvasAssetGenerationTask) => {
|
||||
@@ -7416,14 +7423,17 @@ export default function ProjectDevelopmentView({
|
||||
</button>
|
||||
) : null}
|
||||
{/*
|
||||
「生成任务」入口:生成在后台跑,面板关掉之后进度、阶段与失败原因都只在这
|
||||
里可见(非模态浮层,画布照常可用)。
|
||||
「生成任务」入口:常驻可见,开合画布上的任务侧栏;有在途任务时带上数量,
|
||||
用户关掉侧栏后一眼就能看出还有几条在跑。
|
||||
*/}
|
||||
<button
|
||||
type="button"
|
||||
className="game-workbench-resource-panel-button"
|
||||
aria-label="生成任务"
|
||||
aria-expanded={resourceAssetGenerationTasksPanelOpen}
|
||||
data-resource-generation-task-count={
|
||||
resourceAssetGenerationInFlightCount
|
||||
}
|
||||
onClick={() =>
|
||||
setResourceAssetGenerationTasksPanelOpen(
|
||||
(current) => !current,
|
||||
@@ -7431,8 +7441,8 @@ export default function ProjectDevelopmentView({
|
||||
}
|
||||
>
|
||||
<ListChecks size={15} aria-hidden="true" />
|
||||
{resourceAssetGenerationTasks.length > 0
|
||||
? `生成任务 ${resourceAssetGenerationTasks.length}`
|
||||
{resourceAssetGenerationInFlightCount > 0
|
||||
? `生成任务 · ${resourceAssetGenerationInFlightCount}`
|
||||
: '生成任务'}
|
||||
</button>
|
||||
{pendingResourceEdits.length > 0 ||
|
||||
@@ -8441,18 +8451,20 @@ export default function ProjectDevelopmentView({
|
||||
/>
|
||||
) : null}
|
||||
{/*
|
||||
「生成任务」面板:非模态。它**不**参与 `isResourceCanvasFloatingPanelOpen` 的模态遮挡
|
||||
判据——生成在后台跑,面板开着时画布必须照样能看能用。
|
||||
「生成任务」侧栏:常驻、可折叠、非模态。它**不**参与 `isResourceCanvasFloatingPanelOpen`
|
||||
的模态遮挡判据——生成在后台跑,侧栏展开时画布必须照样能看能用;折叠只影响这个视图,
|
||||
任务本身活在账本与本地队列里。
|
||||
*/}
|
||||
{resourceAssetGenerationTasksPanelOpen ? (
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={resourceAssetGenerationTasks.filter(
|
||||
(task) => task.projectId === manifest.projectId,
|
||||
)}
|
||||
onClose={() => setResourceAssetGenerationTasksPanelOpen(false)}
|
||||
onFocusTask={focusResourceAssetGenerationTask}
|
||||
/>
|
||||
) : null}
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={resourceAssetGenerationTasks.filter(
|
||||
(task) => task.projectId === manifest.projectId,
|
||||
)}
|
||||
open={resourceAssetGenerationTasksPanelOpen}
|
||||
onToggleOpen={() =>
|
||||
setResourceAssetGenerationTasksPanelOpen((current) => !current)
|
||||
}
|
||||
onFocusTask={focusResourceAssetGenerationTask}
|
||||
/>
|
||||
|
||||
{resourcePanelOpen ? (
|
||||
<ResourceCanvasPanelView
|
||||
|
||||
@@ -10800,7 +10800,12 @@ export function registerProjectAgentStatusTests() {
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await waitFor(
|
||||
() => expect(within(taskPanel).getAllByText('已完成')).toHaveLength(2),
|
||||
() =>
|
||||
expect(
|
||||
within(screen.getByRole('region', { name: '已完成' })).getAllByRole(
|
||||
'listitem',
|
||||
),
|
||||
).toHaveLength(2),
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
expect(within(taskPanel).getAllByText('生成已完成。')).toHaveLength(2);
|
||||
@@ -11312,6 +11317,71 @@ export function registerProjectAgentStatusTests() {
|
||||
).not.toBeNull();
|
||||
}, 20_000);
|
||||
|
||||
it('keeps a generation progressing while the sidebar is collapsed', async () => {
|
||||
// 前两轮轮询先保持「在途」,让折叠后的在途计数可观测,之后才收口。
|
||||
let listPolls = 0;
|
||||
const panel = await renderAssetGenerationSubmitView({
|
||||
projectId: 'workbench-sidebar-collapsed',
|
||||
projectPath: '/tmp/workbench-sidebar-collapsed',
|
||||
startLocalAsset: async ({ taskId }) => ({
|
||||
taskId,
|
||||
projectId: 'workbench-sidebar-collapsed',
|
||||
kind: 'ui-prototype',
|
||||
assetName: '待提交设计图',
|
||||
status: 'running',
|
||||
phaseDetail: '正在生成。',
|
||||
createdAtMillis: 1,
|
||||
startedAtMillis: 1,
|
||||
finishedAtMillis: null,
|
||||
assetId: null,
|
||||
error: null,
|
||||
}),
|
||||
listRecords: (started) => {
|
||||
listPolls += 1;
|
||||
return [...started.values()].map((task) =>
|
||||
listPolls >= 3
|
||||
? {
|
||||
...task,
|
||||
status: 'completed',
|
||||
phaseDetail: '生成已完成。',
|
||||
assetId: 'sidebar-collapsed-asset',
|
||||
finishedAtMillis: 2,
|
||||
}
|
||||
: task,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: '生成 UI 设计图' }),
|
||||
);
|
||||
|
||||
// 提交后侧栏自动展开(对齐网页端:排队提交后主动弹任务栏)。
|
||||
const sidebar = await screen.findByRole('region', { name: '生成任务' });
|
||||
expect(within(sidebar).getByText('待提交设计图')).not.toBeNull();
|
||||
|
||||
// 折叠侧栏:折叠只影响这个视图,任务仍在后台推进。
|
||||
fireEvent.click(
|
||||
within(sidebar).getByRole('button', { name: '收起生成任务' }),
|
||||
);
|
||||
expect(screen.queryByRole('region', { name: '生成任务' })).toBeNull();
|
||||
const handle = screen.getByRole('button', { name: '展开生成任务' });
|
||||
expect(handle.dataset.resourceGenerationTaskCount).toBe('1');
|
||||
// 收口后折叠把手上的在途计数跟着归零 —— 折叠期间进度照常更新。
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
screen.getByRole('button', { name: '展开生成任务' }).dataset
|
||||
.resourceGenerationTaskCount,
|
||||
).toBe('0'),
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '展开生成任务' }));
|
||||
const reopened = await screen.findByRole('region', { name: '生成任务' });
|
||||
expect(within(reopened).getByText('生成已完成。')).not.toBeNull();
|
||||
}, 20_000);
|
||||
|
||||
it('routes the audio column entries to the existing audio generation chain', async () => {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'workbench-bottom-toolbar-audio',
|
||||
|
||||
+158
-100
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { cleanup, render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
createResourceCanvasAssetGenerationTask,
|
||||
type ResourceCanvasAssetGenerationTask,
|
||||
} from '../src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel';
|
||||
import { ResourceCanvasAssetGenerationTasksPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView';
|
||||
import {
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT,
|
||||
ResourceCanvasAssetGenerationTasksPanelView,
|
||||
} from '../src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView';
|
||||
import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
|
||||
|
||||
afterEach(() => {
|
||||
@@ -48,106 +51,171 @@ function task(
|
||||
};
|
||||
}
|
||||
|
||||
describe('「生成任务」面板', () => {
|
||||
test('是非模态面板:没有全屏遮罩,也没有 aria-modal 与焦点陷阱', () => {
|
||||
const { container } = render(
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={[task({ taskId: 't1' })]}
|
||||
onClose={() => undefined}
|
||||
onFocusTask={() => undefined}
|
||||
/>,
|
||||
);
|
||||
function renderSidebar(
|
||||
tasks: readonly ResourceCanvasAssetGenerationTask[],
|
||||
options: { open?: boolean } = {},
|
||||
) {
|
||||
const onToggleOpen = vi.fn();
|
||||
const onFocusTask = vi.fn();
|
||||
const view = render(
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={tasks}
|
||||
open={options.open ?? true}
|
||||
onToggleOpen={onToggleOpen}
|
||||
onFocusTask={onFocusTask}
|
||||
/>,
|
||||
);
|
||||
return { onToggleOpen, onFocusTask, ...view };
|
||||
}
|
||||
|
||||
describe('「生成任务」侧栏', () => {
|
||||
test('展开时是非模态侧栏:没有全屏遮罩,也没有 aria-modal 与焦点陷阱', () => {
|
||||
const { container } = renderSidebar([task({ taskId: 't1' })]);
|
||||
expect(screen.getByRole('region', { name: '生成任务' })).not.toBeNull();
|
||||
expect(container.querySelector('[aria-modal="true"]')).toBeNull();
|
||||
expect(container.querySelector('.fixed.inset-0')).toBeNull();
|
||||
});
|
||||
|
||||
test('状态、阶段文案、已耗时与素材名逐条呈现,阶段文案来自后端记录', async () => {
|
||||
render(
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={[
|
||||
task({ taskId: 't1', assetName: '主界面设计图' }),
|
||||
task({
|
||||
taskId: 't2',
|
||||
assetName: '背包界面设计图',
|
||||
dispatched: true,
|
||||
status: 'running',
|
||||
// 前端的任何常量都不会产出「正在处理。」,出现它只可能来自后端记录。
|
||||
phaseDetail: '正在处理。',
|
||||
startedAtMillis: 2_000,
|
||||
}),
|
||||
task({
|
||||
taskId: 't3',
|
||||
assetName: '结算界面设计图',
|
||||
dispatched: true,
|
||||
status: 'completed',
|
||||
phaseDetail: '生成已完成。',
|
||||
assetId: 'asset-3',
|
||||
finishedAtMillis: 13_000,
|
||||
}),
|
||||
task({
|
||||
taskId: 't4',
|
||||
assetName: '设置界面设计图',
|
||||
dispatched: true,
|
||||
status: 'failed',
|
||||
phaseDetail: '生成失败:远端拒绝',
|
||||
error: '远端拒绝',
|
||||
}),
|
||||
]}
|
||||
onClose={() => undefined}
|
||||
onFocusTask={() => undefined}
|
||||
/>,
|
||||
);
|
||||
test('两个分栏各带条数,状态 / 阶段 / 已耗时 / 素材名逐条呈现', () => {
|
||||
renderSidebar([
|
||||
task({ taskId: 't1', assetName: '主界面设计图' }),
|
||||
task({
|
||||
taskId: 't2',
|
||||
assetName: '背包界面设计图',
|
||||
dispatched: true,
|
||||
status: 'running',
|
||||
// 前端的任何常量都不会产出「正在处理。」,出现它只可能来自后端记录。
|
||||
phaseDetail: '正在处理。',
|
||||
startedAtMillis: 2_000,
|
||||
}),
|
||||
task({
|
||||
taskId: 't3',
|
||||
assetName: '结算界面设计图',
|
||||
dispatched: true,
|
||||
status: 'completed',
|
||||
phaseDetail: '生成已完成。',
|
||||
assetId: 'asset-3',
|
||||
finishedAtMillis: 13_000,
|
||||
}),
|
||||
task({
|
||||
taskId: 't4',
|
||||
assetName: '设置界面设计图',
|
||||
dispatched: true,
|
||||
status: 'failed',
|
||||
phaseDetail: '生成失败:远端拒绝',
|
||||
error: '远端拒绝',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(screen.getByText('主界面设计图')).not.toBeNull();
|
||||
expect(screen.getByText('排队中')).not.toBeNull();
|
||||
expect(screen.getByText('生成中')).not.toBeNull();
|
||||
expect(screen.getByText('已完成')).not.toBeNull();
|
||||
expect(screen.getByText('失败')).not.toBeNull();
|
||||
expect(screen.getByText('正在处理。')).not.toBeNull();
|
||||
expect(screen.getByText('生成已完成。')).not.toBeNull();
|
||||
// 每条任务的阶段只渲染后端给的那一句:面板里没有第二个来源,也没有硬编码的假阶段。
|
||||
const runningRow = screen.getByText('背包界面设计图').closest('li');
|
||||
expect(runningRow?.textContent).toContain('正在处理。');
|
||||
expect(runningRow?.textContent).not.toContain('生成中…');
|
||||
expect(screen.getByRole('alert').textContent).toBe('远端拒绝');
|
||||
expect(screen.getByText('生成任务').textContent).toBe('生成任务');
|
||||
const activeSection = screen.getByRole('region', { name: '排队与生成中' });
|
||||
expect(within(activeSection).getByText('2')).not.toBeNull();
|
||||
expect(within(activeSection).getByText('主界面设计图')).not.toBeNull();
|
||||
expect(within(activeSection).getByText('排队中')).not.toBeNull();
|
||||
expect(within(activeSection).getByText('正在处理。')).not.toBeNull();
|
||||
expect(within(activeSection).getByText('生成中')).not.toBeNull();
|
||||
|
||||
const user = userEvent.setup();
|
||||
// 已耗时的秒表由前端计时,但只在还有未终态任务时走;这里断言它至少渲染了。
|
||||
const doneSection = screen.getByRole('region', { name: '已完成' });
|
||||
expect(within(doneSection).getByText('2')).not.toBeNull();
|
||||
expect(within(doneSection).getByText('生成已完成。')).not.toBeNull();
|
||||
expect(within(doneSection).getByRole('alert').textContent).toBe('远端拒绝');
|
||||
expect(
|
||||
screen.getAllByText(/^已耗时 \d+ (秒|分 \d+ 秒)$/).length,
|
||||
).toBeGreaterThan(0);
|
||||
void user;
|
||||
});
|
||||
|
||||
test('展开状态下收起与关闭都走同一个折叠开关', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onToggleOpen } = renderSidebar([task({ taskId: 't1' })]);
|
||||
await user.click(screen.getByRole('button', { name: '收起生成任务' }));
|
||||
expect(onToggleOpen).toHaveBeenCalledTimes(1);
|
||||
await user.click(screen.getByRole('button', { name: '关闭生成任务' }));
|
||||
expect(onToggleOpen).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('侧栏高度有界、列表自己滚动,「已完成」条数封顶', () => {
|
||||
const doneTasks = Array.from(
|
||||
{ length: RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT + 5 },
|
||||
(_, index) =>
|
||||
task({
|
||||
taskId: `done-${index}`,
|
||||
assetName: `历史设计图 ${index}`,
|
||||
dispatched: true,
|
||||
status: 'completed',
|
||||
phaseDetail: '生成已完成。',
|
||||
assetId: `asset-${index}`,
|
||||
finishedAtMillis: 2_000,
|
||||
}),
|
||||
);
|
||||
const { container } = renderSidebar(doneTasks);
|
||||
|
||||
const aside = container.querySelector('aside') as HTMLElement;
|
||||
// 有界高度:上下都给死了,不靠内容撑高。
|
||||
expect(aside.className).toContain('top-16');
|
||||
expect(aside.className).toContain('bottom-24');
|
||||
const scroller = container.querySelector(
|
||||
'[data-resource-generation-task-scroll]',
|
||||
) as HTMLElement;
|
||||
expect(scroller.className).toContain('overflow-y-auto');
|
||||
expect(scroller.className).toContain('min-h-0');
|
||||
|
||||
expect(
|
||||
within(screen.getByRole('region', { name: '已完成' })).getAllByRole(
|
||||
'listitem',
|
||||
),
|
||||
).toHaveLength(RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT);
|
||||
expect(
|
||||
screen.getByText(
|
||||
`仅显示最近 ${RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT} 条,另有 5 条较早记录`,
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
test('折叠时只留贴边把手,把手上的在途计数跟着任务走(0 / 2)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const collapsed = renderSidebar([], { open: false });
|
||||
const handle = screen.getByRole('button', { name: '展开生成任务' });
|
||||
expect(handle.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(handle.dataset.resourceGenerationTaskCount).toBe('0');
|
||||
expect(screen.queryByRole('region', { name: '生成任务' })).toBeNull();
|
||||
await user.click(handle);
|
||||
expect(collapsed.onToggleOpen).toHaveBeenCalledTimes(1);
|
||||
cleanup();
|
||||
|
||||
const running = task({
|
||||
taskId: 't-running',
|
||||
dispatched: true,
|
||||
status: 'running',
|
||||
phaseDetail: '正在生成。',
|
||||
});
|
||||
const queued = task({ taskId: 't-queued' });
|
||||
const { container } = renderSidebar([running, queued], { open: false });
|
||||
const withTwo = container.querySelector(
|
||||
'button[data-resource-generation-task-count]',
|
||||
) as HTMLElement;
|
||||
expect(withTwo.dataset.resourceGenerationTaskCount).toBe('2');
|
||||
expect(screen.getByLabelText('在途生成任务 2').textContent).toBe('2');
|
||||
});
|
||||
|
||||
test('只有已完成且拿到资源 id 的任务能定位到素材卡', async () => {
|
||||
const onFocusTask = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={[
|
||||
task({
|
||||
taskId: 't1',
|
||||
assetName: '主界面设计图',
|
||||
dispatched: true,
|
||||
status: 'completed',
|
||||
phaseDetail: '生成已完成。',
|
||||
assetId: 'asset-1',
|
||||
finishedAtMillis: 5_000,
|
||||
}),
|
||||
task({
|
||||
taskId: 't2',
|
||||
assetName: '背包界面设计图',
|
||||
dispatched: true,
|
||||
status: 'running',
|
||||
phaseDetail: '正在生成。',
|
||||
}),
|
||||
]}
|
||||
onClose={() => undefined}
|
||||
onFocusTask={onFocusTask}
|
||||
/>,
|
||||
);
|
||||
const { onFocusTask } = renderSidebar([
|
||||
task({
|
||||
taskId: 't1',
|
||||
assetName: '主界面设计图',
|
||||
dispatched: true,
|
||||
status: 'completed',
|
||||
phaseDetail: '生成已完成。',
|
||||
assetId: 'asset-1',
|
||||
finishedAtMillis: 5_000,
|
||||
}),
|
||||
task({
|
||||
taskId: 't2',
|
||||
assetName: '背包界面设计图',
|
||||
dispatched: true,
|
||||
status: 'running',
|
||||
phaseDetail: '正在生成。',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '定位素材 背包界面设计图' }),
|
||||
@@ -162,18 +230,8 @@ describe('「生成任务」面板', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('没有任务时给出空状态,关闭按钮始终可用', async () => {
|
||||
const onClose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={[]}
|
||||
onClose={onClose}
|
||||
onFocusTask={() => undefined}
|
||||
/>,
|
||||
);
|
||||
test('没有任务时给出空状态', () => {
|
||||
renderSidebar([]);
|
||||
expect(screen.getByRole('status').textContent).toBe('还没有生成任务');
|
||||
await user.click(screen.getByRole('button', { name: '关闭生成任务' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user