diff --git a/src/components/beatcanvas/use-beatcanvas-react-flow-adapter.test.ts b/src/components/beatcanvas/use-beatcanvas-react-flow-adapter.test.ts index 39184a4..677d94e 100644 --- a/src/components/beatcanvas/use-beatcanvas-react-flow-adapter.test.ts +++ b/src/components/beatcanvas/use-beatcanvas-react-flow-adapter.test.ts @@ -52,6 +52,33 @@ const makeDraft = ( sourceGenerationId: null, }); +for (const type of ['image', 'video'] as const) { + test(`${type} preview and selected thumbnail agree before and after restoring manual selection`, () => { + const draft = makeDraft(type, '16:9'); + const outputs: CanvasOutputCard[] = [0, 1, 2].map((index) => ({ + ...draft, + id: `take-${index}`, + kind: 'output', + url: index === 2 ? null : `https://example.com/${index}.${type === 'image' ? 'png' : 'mp4'}`, + status: index === 2 ? 'processing' : 'succeeded', + sourceConfigCardId: draft.id, + generationRunId: `run-${index}`, + generationSnapshot: { + ...draft, + capturedAt: `2026-10-18T00:0${index}:00.000Z`, + }, + })); + for (const pinnedOutputId of [null, outputs[0].id, 'missing']) { + const restored = JSON.parse(JSON.stringify({ ...draft, pinnedOutputId })); + const presentation = buildGenerationCardPresentation({ card: restored, outputs }); + const selected = presentation.takes.find((take) => take.isPinned); + assert.equal(selected?.id, pinnedOutputId === outputs[0].id ? outputs[0].id : outputs[1].id); + assert.equal(presentation.latestOutputUrl, selected?.url); + assert.equal(presentation.status, 'processing'); + } + }); +} + test('draft frame size follows portrait and landscape ratios', () => { assert.deepEqual(resolveDraftShapeSize(null, makeDraft('video', '9:16')), { w: 236, diff --git a/src/components/beatcanvas/use-beatcanvas-react-flow-adapter.ts b/src/components/beatcanvas/use-beatcanvas-react-flow-adapter.ts index 92807ab..3f4e62a 100644 --- a/src/components/beatcanvas/use-beatcanvas-react-flow-adapter.ts +++ b/src/components/beatcanvas/use-beatcanvas-react-flow-adapter.ts @@ -20,7 +20,10 @@ import type { CanvasDraftCard, CanvasOutputCard, } from '@/core/beatcanvas/canvas-types'; -import { buildGenerationTakes } from '@/core/beatcanvas/generation-history'; +import { + buildGenerationTakes, + resolvePinnedGenerationOutputId, +} from '@/core/beatcanvas/generation-history'; import { isCanvasDraftCard, isCanvasOutputCard, @@ -186,21 +189,13 @@ export const buildGenerationCardPresentation = ({ ) || left.id.localeCompare(right.id) ); const latestOutput = orderedOutputs[orderedOutputs.length - 1] ?? null; - const pinnedOutput = card.pinnedOutputId - ? orderedOutputs.find( - (output) => - output.id === card.pinnedOutputId && - output.status === 'succeeded' && - Boolean(output.url || output.resultText) - ) ?? null - : null; - const latestSucceededOutput = [...orderedOutputs] - .reverse() - .find( - (output) => - output.status === 'succeeded' && - Boolean(output.url || output.resultText) - ); + const selectedOutputId = resolvePinnedGenerationOutputId({ + outputs: orderedOutputs, + pinnedOutputId: card.pinnedOutputId, + }); + const selectedOutput = orderedOutputs.find( + (output) => output.id === selectedOutputId && output.status === 'succeeded' + ); const isAnalysis = card.generationMode === 'analysis'; const analysisReportCount = isAnalysis ? orderedOutputs.filter( @@ -213,14 +208,10 @@ export const buildGenerationCardPresentation = ({ isAnalysis, latestOutputUrl: isAnalysis ? null - : pinnedOutput?.url ?? latestSucceededOutput?.url ?? card.url ?? null, - latestOutputText: - isAnalysis - ? null - : pinnedOutput?.resultText ?? - latestSucceededOutput?.resultText ?? - card.resultText ?? - null, + : (selectedOutput?.url ?? card.url ?? null), + latestOutputText: isAnalysis + ? null + : (selectedOutput?.resultText ?? card.resultText ?? null), analysisReportCount, takes: buildGenerationTakes({ outputs: orderedOutputs, @@ -1224,6 +1215,7 @@ export function useBeatCanvasReactFlowAdapter({ setCanvasCard(completedOutputCard); setCanvasCard({ ...draftCard, + pinnedOutputId: outputCardId, url: isAnalysisOutput ? null : (url ?? null), resultText: isAnalysisOutput ? null : resultText, }); diff --git a/src/core/beatcanvas/generation-history.test.ts b/src/core/beatcanvas/generation-history.test.ts index 028242a..db12546 100644 --- a/src/core/beatcanvas/generation-history.test.ts +++ b/src/core/beatcanvas/generation-history.test.ts @@ -50,6 +50,42 @@ const makeOutput = ( }, }); +for (const type of ['image', 'video'] as const) { + test(`${type} defaults to the newest usable take regardless of input order`, () => { + const older = { ...makeOutput('old', '2026-10-18T00:00:00.000Z'), type }; + const newer = { ...makeOutput('new', '2026-10-18T00:01:00.000Z'), type }; + const pending = { ...makeOutput('pending', '2026-10-18T00:02:00.000Z', 'processing'), type }; + const empty = { ...makeOutput('empty', '2026-10-18T00:03:00.000Z'), type, url: null }; + const failed = { ...makeOutput('failed', '2026-10-18T00:04:00.000Z', 'failed'), type }; + for (const outputs of [[older, pending, newer, empty, failed], [failed, empty, newer, pending, older]]) { + const originalOrder = outputs.map((output) => output.id); + for (const pinnedOutputId of [null, 'missing', failed.id, empty.id, pending.id]) { + assert.equal(resolvePinnedGenerationOutputId({ outputs, pinnedOutputId }), newer.id); + const takes = buildGenerationTakes({ outputs, pinnedOutputId }); + assert.equal(takes.find((take) => take.isPinned)?.id, newer.id); + assert.equal(takes.find((take) => take.id === newer.id)?.takeNumber, 2); + } + assert.equal(resolvePinnedGenerationOutputId({ outputs, pinnedOutputId: older.id }), older.id); + assert.deepEqual(outputs.map((output) => output.id), originalOrder); + } + const draft = { ...older, id: 'draft-1', kind: 'generation' as const, pinnedOutputId: null }; + const cards = Object.fromEntries([draft, older, newer, pending, failed].map((card) => [card.id, card])); + assert.equal(resolveConcreteCanvasMediaCard({ cards, card: draft })?.id, newer.id); + }); +} + +test('fallback handles empty histories, only failed outputs and equal timestamps deterministically', () => { + const time = '2026-10-18T00:00:00.000Z'; + assert.equal(resolvePinnedGenerationOutputId({ outputs: [] }), null); + assert.equal(resolvePinnedGenerationOutputId({ outputs: [makeOutput('failed', time, 'failed')] }), null); + for (const status of ['succeeded', 'pending'] as const) { + const a = makeOutput('a', time, status); + const b = makeOutput('b', time, status); + assert.equal(resolvePinnedGenerationOutputId({ outputs: [a, b] }), 'b'); + assert.equal(resolvePinnedGenerationOutputId({ outputs: [b, a] }), 'b'); + } +}); + test('resolves a generation node to its pinned concrete output asset', () => { const older = makeOutput('output-old', '2026-08-16T00:00:00.000Z'); const newer = makeOutput('output-new', '2026-08-16T00:02:00.000Z'); diff --git a/src/core/beatcanvas/generation-history.ts b/src/core/beatcanvas/generation-history.ts index 6cc7fbd..cbdca54 100644 --- a/src/core/beatcanvas/generation-history.ts +++ b/src/core/beatcanvas/generation-history.ts @@ -40,21 +40,26 @@ export const resolvePinnedGenerationOutputId = ({ outputs: CanvasOutputCard[]; pinnedOutputId?: string | null; }) => { + const newestFirst = [...outputs] + .filter((output) => output.status !== 'failed') + .sort( + (left, right) => + right.generationSnapshot.capturedAt.localeCompare( + left.generationSnapshot.capturedAt + ) || right.id.localeCompare(left.id) + ); + const hasResult = (output: CanvasOutputCard) => + output.status === 'succeeded' && Boolean(output.url || output.resultText); if ( pinnedOutputId && - outputs.some((output) => output.id === pinnedOutputId) + newestFirst.some((output) => output.id === pinnedOutputId && hasResult(output)) ) { return pinnedOutputId; } return ( - outputs.find( - (output) => - output.status === 'succeeded' && - Boolean(output.url || output.resultText) - ) - ?.id ?? - outputs[0]?.id ?? + newestFirst.find(hasResult)?.id ?? + newestFirst[0]?.id ?? null ); }; diff --git a/tests/e2e/workspace.spec.ts b/tests/e2e/workspace.spec.ts index 648ddc9..bedf5a9 100644 --- a/tests/e2e/workspace.spec.ts +++ b/tests/e2e/workspace.spec.ts @@ -22,6 +22,71 @@ async function project(request: APIRequestContext, name: string) { return (await response.json()).id as string; } +for (const type of ['image', 'video'] as const) { + test(`Canvas ${type} defaults to latest take and persists manual history selection`, async ({ page, request }) => { + const projectId = await project(request, `Latest ${type} take QA`); + const fixture = await readFile(type === 'image' ? 'public/logo.png' : 'tests/fixtures/tiny-black.mp4'); + const urls: string[] = []; + const assetIds: string[] = []; + for (const index of [1, 2]) { + const uploaded = await request.post(`/api/app/projects/${projectId}/assets`, { + headers, + multipart: { file: { + name: `take-${index}.${type === 'image' ? 'png' : 'mp4'}`, + mimeType: type === 'image' ? 'image/png' : 'video/mp4', + buffer: Buffer.concat([fixture, Buffer.from(`take-${index}`)]), + } }, + }); + expect(uploaded.ok()).toBeTruthy(); + const { asset } = await uploaded.json(); + urls.push(asset.publicUrl); + assetIds.push(asset.id); + } + const draft = { + id: 'qa-latest-draft', kind: 'generation', type, name: 'Latest take QA', url: null, + prompt: 'Local version selection test', referenceCardIds: [], workflowTemplateId: null, + status: 'idle', error: null, modelId: type === 'image' ? 'gpt-image-2.5-flare' : 'seedance-2.0', + aspectRatio: '16:9', outputQuality: '720p', duration: '8s', mode: 'quality', + variant: 'standard', quality: 'standard', sourceGenerationId: null, + }; + const outputs = [0, 1, 2].map((index) => ({ + ...draft, id: `qa-take-${index}`, kind: 'output', + assetId: assetIds[index] ?? null, + status: index === 2 ? 'processing' : 'succeeded', url: urls[index] ?? null, + sourceConfigCardId: draft.id, generationRunId: `qa-run-${index}`, + generationSnapshot: { ...draft, capturedAt: `2026-10-18T00:0${index}:00.000Z` }, + })); + const applied = await mcp(request, 'bdesign_canvas_apply', { projectId, operations: [draft, ...outputs].map((card) => ({ + type: 'upsert_card', card, frame: { x: 200, y: 160, w: 420, h: 236 }, + })) }); + expect(applied.structuredContent.result.ok, JSON.stringify(applied)).toBe(true); + const snapshot = await (await request.get(`/api/app/projects/${projectId}/snapshot`)).json(); + expect(snapshot.document.cards).toHaveLength(4); + await page.goto(`/canvas/${projectId}`); + const node = page.locator(`[data-card-id="${draft.id}"]`); + await expect(node).toBeVisible(); + await expect(node.getByRole('button', { name: 'Take 2', exact: true })).toHaveAttribute('aria-pressed', 'true'); + await expect(node.getByRole('button', { name: 'Take 1', exact: true })).toHaveAttribute('aria-pressed', 'false'); + if (type === 'image') { + await expect(node.locator('img').first()).toHaveAttribute('src', urls[1]); + } else { + await node.getByRole('button', { name: /^View result:/ }).click(); + await expect(page.locator('video').first()).toHaveAttribute('src', urls[1]); + await page.getByRole('dialog').getByRole('button').click(); + } + await node.getByRole('button', { name: 'Take 1', exact: true }).click(); + await expect(node.getByRole('button', { name: 'Take 1', exact: true })).toHaveAttribute('aria-pressed', 'true'); + await page.getByRole('dialog').getByRole('button').click(); + await expect.poll(async () => { + const state = await (await request.get(`/api/app/projects/${projectId}/snapshot`)).json(); + return state.document.cards.find((card: { id: string }) => card.id === draft.id)?.pinnedOutputId; + }).toBe(outputs[0].id); + await page.reload(); + await expect(node.getByRole('button', { name: 'Take 1', exact: true })).toHaveAttribute('aria-pressed', 'true'); + await page.screenshot({ path: `test-results/latest-${type}-take.png` }); + }); +} + test('Canvas UI commands and MCP edits share durable state; unchanged polling is lightweight', async ({ page, request }) => { const id = await project(request, 'Canvas concurrency QA'); const initial = { id: 'qa-draft', kind: 'generation', type: 'image', name: 'QA draft', prompt: 'Original prompt', modelId: 'gpt-image-2.5-flare' }; diff --git a/tests/fixtures/tiny-black.mp4 b/tests/fixtures/tiny-black.mp4 new file mode 100644 index 0000000..a20c060 Binary files /dev/null and b/tests/fixtures/tiny-black.mp4 differ