diff --git a/ui/e2e/specs/scene-template-review.spec.ts b/ui/e2e/specs/scene-template-review.spec.ts index 29ecd1f6f..ac26801aa 100644 --- a/ui/e2e/specs/scene-template-review.spec.ts +++ b/ui/e2e/specs/scene-template-review.spec.ts @@ -179,6 +179,17 @@ async function confirmLibraryScene(dialog: Locator, title: string): Promise { + await composer.getByRole('button', { name: 'From HocusPocus' }).click() + const explorer = page.getByRole('dialog').filter({ has: page.getByTestId('asset-explorer') }).last() + await expect(explorer).toBeVisible() + const card = explorer.locator(`button[title="${filename}"]`) + await expect(card).toBeVisible() + await card.click() + await explorer.getByRole('button', { name: 'Choose', exact: true }).click() + await expect(composer.getByText(filename).first()).toBeVisible() +} + test('opens cinema-establishing in the real editor, saves exact scene JSON, and reopens the saved scene', async ({ page }) => { const session = await prepareReviewPage(page) const state = reviewRouteState() @@ -275,9 +286,9 @@ test('Library template bindings survive the real editor save and reopen without await page.getByRole('button', { name: 'Templates · create with my Library assets' }).click() const composer = page.getByRole('dialog', { name: 'Create scene from Library' }) await expect(composer.getByLabel('Visual BPM')).toBeDisabled() - await composer.getByRole('button', { name: 'Select hero(1).svg', exact: true }).click() + await chooseComposerLibraryFile(page, composer, 'hero(1).svg') await composer.getByRole('button', { name: 'Background (required)', exact: true }).click() - await composer.getByRole('button', { name: 'Select plate(1).svg', exact: true }).click() + await chooseComposerLibraryFile(page, composer, 'plate(1).svg') await composer.getByRole('checkbox').check() await composer.getByRole('button', { name: 'Create and open in editor', exact: true }).click() await expect(composer).toHaveCount(0) diff --git a/ui/src/features/sceneTemplates/TemplateAssetPicker.tsx b/ui/src/features/sceneTemplates/TemplateAssetPicker.tsx index b1fc4828e..5b336fcde 100644 --- a/ui/src/features/sceneTemplates/TemplateAssetPicker.tsx +++ b/ui/src/features/sceneTemplates/TemplateAssetPicker.tsx @@ -1,188 +1,82 @@ -import { useEffect, useMemo, useState } from 'react' -import { fetchAssets, type AssetCatalogItem } from '../../api/assets' +import { useEffect, useRef, useState } from 'react' +import { fetchAsset, type AssetCatalogItem } from '../../api/assets' +import { AssetInput } from '../asset-picker/AssetInput.tsx' +import { catalogItemToOutput } from '../asset-picker/adapters.ts' import { useUiTranslation } from '../../i18n' -import { catalogLocation } from './catalogLocation' +import { acceptForSlotKinds, commitTemplateSlotChoice } from './templateSlotPick.ts' -const PAGE_SIZE = 12 -const TABS = [ - { id: 'image', labelKey: 'composer.tabImages' }, - { id: 'model3d', labelKey: 'composer.tabGlb' }, -] as const -type PickerKind = (typeof TABS)[number]['id'] +type PickerKind = 'image' | 'model3d' export interface TemplateAssetPickerProps { workspace: string kinds: readonly PickerKind[] selectedId?: string + selected?: AssetCatalogItem onPick: (asset: AssetCatalogItem) => void disabledReason?: (asset: AssetCatalogItem) => string | undefined -} - -function previewUrlFor(asset: AssetCatalogItem, workspace: string): string | null { - if (asset.kind !== 'image') return null - try { return catalogLocation(asset, workspace).url } - catch { return null } + optional?: boolean + onClear?: () => void } export function TemplateAssetPicker({ workspace, kinds, selectedId, + selected, onPick, disabledReason, + optional, + onClear, }: TemplateAssetPickerProps) { const { t } = useUiTranslation('scene3d') - const allowedKinds = useMemo(() => new Set(kinds), [kinds]) - const [kind, setKind] = useState(kinds[0] || 'image') - const [search, setSearch] = useState('') - const [offset, setOffset] = useState(0) - const [retry, setRetry] = useState(0) - const [previewErrors, setPreviewErrors] = useState>({}) - const [result, setResult] = useState<{ query: string; assets: AssetCatalogItem[]; total: number; error: string }>({ query: '', assets: [], total: 0, error: '' }) - - const activeKind = allowedKinds.has(kind) ? kind : (kinds[0] || 'image') - const queryKey = `${workspace}\u0000${activeKind}\u0000${search}\u0000${offset}\u0000${retry}` - + const generationRef = useRef(0) + const workspaceRef = useRef(workspace) + const [issue, setIssue] = useState('') useEffect(() => { - const controller = new AbortController() - let active = true - - fetchAssets({ - workspace, - kind: activeKind, - search: search.trim() || undefined, - limit: PAGE_SIZE, - offset, - signal: controller.signal, - }).then(result => { - if (!active || controller.signal.aborted) return - setResult({ query: queryKey, assets: result.assets, total: result.total, error: '' }) - }).catch(reason => { - if (!active || controller.signal.aborted) return - setResult({ query: queryKey, assets: [], total: 0, error: reason instanceof Error ? reason.message : t('composer.loadAssetsFailed') }) - }) - - return () => { - active = false - controller.abort() - } - }, [activeKind, offset, queryKey, search, t, workspace]) + if (workspaceRef.current !== workspace) generationRef.current += 1 + workspaceRef.current = workspace + }, [workspace]) - const loading = result.query !== queryKey - const error = result.query === queryKey ? result.error : '' - const visibleAssets = result.query === queryKey && !error ? result.assets : [] - const total = result.query === queryKey && !error ? result.total : 0 - const hasPrevious = offset > 0 - const hasNext = offset + PAGE_SIZE < total - - const reasonFor = (asset: AssetCatalogItem): string | undefined => { - if (asset.kind !== activeKind) return t('composer.incompatibleKind') - return disabledReason?.(asset) - } + const value = selected ? catalogItemToOutput(selected, workspace) ?? undefined : undefined + const slotId = selectedId || kinds.join('-') return ( -
-
- -
- {TABS.map(tab => { - const enabled = allowedKinds.has(tab.id) - return ( - - ) - })} -
-
- - {loading &&

{t('composer.loadingAssets')}

} - {error && !loading && ( -
- {t('composer.libraryLoadFailed', { error })} - -
- )} - - {!loading && !error && result.query === queryKey && visibleAssets.length === 0 && ( -

{t('composer.noAssets')}

- )} - - {visibleAssets.length > 0 && ( -
    - {visibleAssets.map(asset => { - const disabled = reasonFor(asset) - const previewUrl = previewUrlFor(asset, workspace) - const previewKey = `${queryKey}|${asset.id}` - const previewFailed = previewErrors[previewKey] === true - return ( -
  • - -
  • - ) - })} -
- )} - -
- {total ? t('composer.pageRange', { from: offset + 1, to: Math.min(offset + PAGE_SIZE, total), total }) : t('composer.noResults')} -
- - -
-
-
+
+ { + const capture = { generation: generationRef.current, workspaceId: workspaceRef.current, slotId } + void commitTemplateSlotChoice( + { generation: generationRef.current, workspaceId: workspaceRef.current, slotId }, + capture, + item, + id => fetchAsset(id), + asset => disabledReason?.(asset), + ).then(commit => { + if (commit.action === 'ignore') return + if (commit.action === 'reject') { + setIssue(commit.reasonKey === 'missing-id' ? t('composer.needsCatalogIdentity') : commit.message) + return + } + setIssue('') + if (commit.action === 'clear') { + onClear?.() + return + } + onPick(commit.item) + }).catch(error => { + setIssue(error instanceof Error ? error.message : t('composer.loadAssetsFailed')) + }) + }} + /> + {issue &&

{issue}

} +
) } diff --git a/ui/src/features/sceneTemplates/TemplateComposerDialog.tsx b/ui/src/features/sceneTemplates/TemplateComposerDialog.tsx index c90ade511..286dde971 100644 --- a/ui/src/features/sceneTemplates/TemplateComposerDialog.tsx +++ b/ui/src/features/sceneTemplates/TemplateComposerDialog.tsx @@ -116,7 +116,7 @@ function TemplateComposerForm({ template, workspace, onClose, onApply }: Props & {selections[item.id] && <>

ID: {selections[item.id]!.id}

} )}

{t('composer.assignHelp', { slot: t(SLOT_COPY[slot.id]) })}

- catalogBindingIssue(item, workspace, slot)} onPick={item => { setSelections(current => ({ ...current, [slot.id]: item })); setError('') }} /> + catalogBindingIssue(item, workspace, slot)} onPick={item => { setSelections(current => ({ ...current, [slot.id]: item })); setError('') }} onClear={() => setSelections(current => ({ ...current, [slot.id]: undefined }))} />
diff --git a/ui/src/features/sceneTemplates/templateSlotPick.ts b/ui/src/features/sceneTemplates/templateSlotPick.ts new file mode 100644 index 000000000..88a2b308f --- /dev/null +++ b/ui/src/features/sceneTemplates/templateSlotPick.ts @@ -0,0 +1,50 @@ +import type { ApiOutput } from '../../api/outputs' +import type { AssetCatalogItem } from '../../api/assets' + +export type TemplateSlotCapture = { + generation: number + workspaceId: string + slotId: string +} + +export type TemplateSlotLive = { + generation: number + workspaceId: string + slotId: string +} + +export type TemplateSlotCommit = + | { action: 'ignore' } + | { action: 'clear' } + | { action: 'reject'; reasonKey: 'missing-id' } + | { action: 'reject'; reasonKey: 'incompatible'; message: string } + | { action: 'apply'; item: AssetCatalogItem } + +export async function commitTemplateSlotChoice( + live: TemplateSlotLive, + capture: TemplateSlotCapture, + item: ApiOutput | null, + loadAsset: (id: string) => Promise, + bindingIssue: (asset: AssetCatalogItem) => string | undefined, +): Promise { + if (live.generation !== capture.generation) return { action: 'ignore' } + if (live.workspaceId !== capture.workspaceId) return { action: 'ignore' } + if (live.slotId !== capture.slotId) return { action: 'ignore' } + if (!item) return { action: 'clear' } + const id = typeof item.asset_id === 'string' ? item.asset_id.trim() : '' + if (!id) return { action: 'reject', reasonKey: 'missing-id' } + const asset = await loadAsset(id) + if (live.generation !== capture.generation) return { action: 'ignore' } + if (live.workspaceId !== capture.workspaceId) return { action: 'ignore' } + if (live.slotId !== capture.slotId) return { action: 'ignore' } + const issue = bindingIssue(asset) + if (issue) return { action: 'reject', reasonKey: 'incompatible', message: issue } + return { action: 'apply', item: asset } +} + +export function acceptForSlotKinds(kinds: readonly ('image' | 'model3d')[]): string { + const parts: string[] = [] + if (kinds.includes('image')) parts.push('image/*') + if (kinds.includes('model3d')) parts.push('.glb,model/gltf-binary') + return parts.join(',') +} diff --git a/ui/src/i18n/locales/en/scene3d.json b/ui/src/i18n/locales/en/scene3d.json index 11ad84299..6dcd76cda 100644 --- a/ui/src/i18n/locales/en/scene3d.json +++ b/ui/src/i18n/locales/en/scene3d.json @@ -1087,6 +1087,7 @@ "retry": "Retry", "loadAssetsFailed": "Could not load assets.", "incompatibleKind": "Type incompatible with this filter.", + "needsCatalogIdentity": "This slot needs a Library asset with a durable id. A local file is not enough.", "pickerAria": "Template asset picker", "kindImage": "Image", "kindGlb": "GLB", diff --git a/ui/src/i18n/locales/es/scene3d.json b/ui/src/i18n/locales/es/scene3d.json index 0341e4f6a..87c1b16d4 100644 --- a/ui/src/i18n/locales/es/scene3d.json +++ b/ui/src/i18n/locales/es/scene3d.json @@ -1087,6 +1087,7 @@ "retry": "Reintentar", "loadAssetsFailed": "No se pudieron cargar los assets.", "incompatibleKind": "Tipo incompatible con este filtro.", + "needsCatalogIdentity": "Este hueco necesita un asset de Library con id durable. Un archivo local no basta.", "pickerAria": "Selector de assets de plantilla", "kindImage": "Imagen", "kindGlb": "GLB", diff --git a/ui/tests/musicMotionComposer.test.tsx b/ui/tests/musicMotionComposer.test.tsx index 0edbcf0d5..c478f5511 100644 --- a/ui/tests/musicMotionComposer.test.tsx +++ b/ui/tests/musicMotionComposer.test.tsx @@ -63,18 +63,36 @@ const responseFor = (value: unknown, status = 200) => new Response(JSON.stringif const installAssetFetch = () => { const calls: string[] = [] - globalThis.fetch = (async input => { + const previous = globalThis.fetch + const mock: typeof fetch = (async (input, init) => { const url = String(input) calls.push(url) - if (url.includes('/api/v1/assets?')) return responseFor({ assets, total: assets.length }) + if (url.includes('/api/v1/assets?')) { + const query = new URL(url, 'http://localhost').searchParams + if (query.get('workspace') && query.get('workspace') !== WORKSPACE) { + if (typeof previous === 'function') return previous(input, init) + } + return responseFor({ assets, total: assets.length }) + } if (url.includes('/api/v1/assets/')) { - const id = decodeURIComponent(url.slice(url.lastIndexOf('/') + 1)) + const id = decodeURIComponent(url.slice(url.lastIndexOf('/') + 1).split('?')[0]) const selected = assets.find(item => item.id === id) - return selected ? responseFor(selected) : responseFor({ detail: 'missing' }, 404) + if (!selected) { + if (typeof previous === 'function') return previous(input, init) + return responseFor({ detail: 'missing' }, 404) + } + return responseFor(selected) } + if (typeof previous === 'function') return previous(input, init) throw new Error(`Unexpected request: ${url}`) }) as typeof fetch - return calls + globalThis.fetch = mock + return { + calls, + restore() { + if (globalThis.fetch === mock) globalThis.fetch = previous + }, + } } async function renderDialog(props: { onApply?: (scene: Scene) => boolean } = {}) { @@ -84,20 +102,27 @@ async function renderDialog(props: { onApply?: (scene: Scene) => boolean } = {}) return { ...view, screen, fireEvent, waitFor, cleanup } } +function clickLast(view: Awaited>, name: string | RegExp, exact = false) { + const buttons = view.screen.getAllByRole('button', exact && typeof name === 'string' ? { name, exact: true } : { name }) + view.fireEvent.click(buttons[buttons.length - 1]) +} + async function chooseAsset( view: Awaited>, slotLabel: RegExp, filename: string, ) { - view.fireEvent.click(view.screen.getByRole('button', { name: slotLabel })) - const assetButton = await view.screen.findByRole('button', { name: `Select ${filename}` }) - view.fireEvent.click(assetButton) + clickLast(view, slotLabel) + clickLast(view, /From HocusPocus/) + await view.waitFor(() => assert.ok(document.querySelector(`button[title="${filename}"]`))) + view.fireEvent.click(document.querySelector(`button[title="${filename}"]`) as HTMLButtonElement) + clickLast(view, 'Choose', true) + await view.waitFor(() => assert.ok(view.screen.getAllByText(filename).length >= 1)) } test('expone claves y descripciones musicales, exige dos sujetos, conserva IDs y no ofrece referencia aprobada', { concurrency: false }, async () => { - const originalFetch = globalThis.fetch const originalClipboard = navigator.clipboard - const calls = installAssetFetch() + const { calls, restore } = installAssetFetch() let clipboardText = '' Object.defineProperty(navigator, 'clipboard', { configurable: true, @@ -153,14 +178,13 @@ test('expone claves y descripciones musicales, exige dos sujetos, conserva IDs y assert.equal(calls.some(url => /generate|model3d\/generate/i.test(url)), false) view.cleanup() } finally { - globalThis.fetch = originalFetch + restore() Object.defineProperty(navigator, 'clipboard', { configurable: true, value: originalClipboard }) } }) test('mantiene BPM e intensidad desactivados en las 24 nuevas coreografías', { concurrency: false }, async () => { - const originalFetch = globalThis.fetch - const calls = installAssetFetch() + const { calls, restore } = installAssetFetch() try { const view = await renderDialog() const selector = view.screen.getByRole('combobox', { name: 'Action / template' }) as HTMLSelectElement @@ -173,13 +197,12 @@ test('mantiene BPM e intensidad desactivados en las 24 nuevas coreografías', { assert.equal(calls.some(url => /generate|model3d\/generate/i.test(url)), false) view.cleanup() } finally { - globalThis.fetch = originalFetch + restore() } }) test('cambiar entre una plantilla musical y una legacy reinicia selecciones sin romper el compositor', { concurrency: false }, async () => { - const originalFetch = globalThis.fetch - installAssetFetch() + const installed = installAssetFetch() try { const view = await renderDialog() const selector = view.screen.getByRole('combobox', { name: 'Action / template' }) as HTMLSelectElement @@ -187,21 +210,21 @@ test('cambiar entre una plantilla musical y una legacy reinicia selecciones sin view.fireEvent.change(selector, { target: { value: 'music-orbit-duel' } }) await view.waitFor(() => assert.equal(selector.value, 'music-orbit-duel')) await chooseAsset(view, /Subject 1.*required/i, fixture.subject1.filename) - const selected = view.screen.getByRole('button', { name: `Select ${fixture.subject1.filename}` }) - assert.equal(selected.getAttribute('aria-pressed'), 'true') + assert.ok(view.screen.getAllByText(fixture.subject1.filename).length >= 1) view.fireEvent.change(selector, { target: { value: 'cinema-establishing' } }) await view.waitFor(() => assert.equal(selector.value, 'cinema-establishing')) assert.equal(view.screen.queryByText('subject_1 · image'), null) + assert.equal(view.screen.queryAllByText(fixture.subject1.filename).length, 0) assert.equal((view.screen.getByRole('button', { name: /Create and open in editor/i }) as HTMLButtonElement).disabled, true) view.fireEvent.change(selector, { target: { value: 'music-orbit-duel' } }) await view.waitFor(() => assert.equal(selector.value, 'music-orbit-duel')) - const reset = await view.screen.findByRole('button', { name: `Select ${fixture.subject1.filename}` }) - assert.equal(reset.getAttribute('aria-pressed'), 'false') + assert.equal(view.screen.queryAllByText(fixture.subject1.filename).length, 0) assert.ok(view.screen.getByText('subject_1 · image')) + assert.ok(view.screen.getAllByText('Unassigned').length >= 1) view.cleanup() } finally { - globalThis.fetch = originalFetch + installed.restore() } }) diff --git a/ui/tests/templateAssetPicker.test.tsx b/ui/tests/templateAssetPicker.test.tsx index 6a3fde134..7c33097be 100644 --- a/ui/tests/templateAssetPicker.test.tsx +++ b/ui/tests/templateAssetPicker.test.tsx @@ -2,8 +2,8 @@ import assert from 'node:assert/strict' import test from 'node:test' import React from 'react' import { JSDOM } from 'jsdom' -import type { AssetCatalogItem } from '../src/api/assets.ts' +Object.assign(globalThis, { React }) const dom = new JSDOM('', { url: 'http://localhost/' }) Object.assign(globalThis, { window: dom.window, @@ -11,7 +11,6 @@ Object.assign(globalThis, { HTMLElement: dom.window.HTMLElement, HTMLButtonElement: dom.window.HTMLButtonElement, HTMLInputElement: dom.window.HTMLInputElement, - HTMLImageElement: dom.window.HTMLImageElement, Event: dom.window.Event, MouseEvent: dom.window.MouseEvent, MutationObserver: dom.window.MutationObserver, @@ -19,188 +18,15 @@ Object.assign(globalThis, { }) Object.defineProperty(globalThis, 'navigator', { configurable: true, value: dom.window.navigator }) -const asset = (overrides: Partial = {}): AssetCatalogItem => ({ - id: 'asset-hero', kind: 'image', filename: 'hero.png', size_bytes: 12, - created_at: 1, completed_at: 2, metadata_status: 'canonical', workspace_ids: ['default'], - locations: [{ workspace_id: 'default', filename: 'hero.png', url: '/api/v1/file/hero.png?workspace=default' }], - url: '/api/v1/file/hero.png?workspace=default', - origin: { tool: 'test' }, execution: {}, model: { provider: 'local', id: 'fixture' }, - prompt_preview: 'hero', ...overrides, -}) - -async function renderPicker(props: { workspace?: string; kinds?: readonly ('image' | 'model3d')[]; selectedId?: string; onPick: (item: AssetCatalogItem) => void; disabledReason?: (item: AssetCatalogItem) => string | undefined }) { - const { render, screen, waitFor, fireEvent, cleanup } = await import('@testing-library/react') +test('template slot picker exposes From my computer and From HocusPocus', { concurrency: false }, async () => { + const { render, screen, fireEvent, cleanup } = await import('@testing-library/react') const { TemplateAssetPicker } = await import('../src/features/sceneTemplates/TemplateAssetPicker.tsx') - const view = render() - return { ...view, screen, waitFor, fireEvent, cleanup, TemplateAssetPicker } -} - -test('selecciona el asset completo por id durable y sólo consulta el catálogo', { concurrency: false }, async () => { - const selected: { value?: AssetCatalogItem } = {} - const originalFetch = globalThis.fetch - const requests: string[] = [] - const hero = asset() - globalThis.fetch = (async input => { - const url = String(input) - requests.push(url) - return new Response(JSON.stringify({ assets: [hero], total: 1 }), { headers: { 'content-type': 'application/json' } }) - }) as typeof fetch - try { - const view = await renderPicker({ onPick: item => { selected.value = item } }) - await view.screen.findByRole('button', { name: 'Select hero.png' }) - const unavailableGlb = view.screen.getByRole('tab', { name: /GLB \/ 3D/ }) as HTMLButtonElement - assert.equal(unavailableGlb.disabled, true) - assert.match(unavailableGlb.textContent || '', /unavailable/) - await view.waitFor(() => assert.equal(requests.length, 1)) - const query = new URL(requests[0], 'http://localhost').searchParams - assert.equal(query.get('workspace'), 'default') - assert.equal(query.get('kind'), 'image') - assert.equal(query.get('limit'), '12') - assert.equal(query.get('offset'), '0') - assert.ok(requests.every(url => url.includes('/api/v1/assets?'))) - assert.equal(requests.some(url => url.includes('/generate') || url.includes('/model')), false) - view.fireEvent.click(view.screen.getByRole('button', { name: 'Select hero.png' })) - assert.equal(selected.value?.id, hero.id) - assert.equal(selected.value?.filename, hero.filename) - assert.deepEqual(selected.value?.locations, hero.locations) - view.cleanup() - } finally { - globalThis.fetch = originalFetch - } -}) - -test('cancela y oculta resultados obsoletos al cambiar de workspace', { concurrency: false }, async () => { - const originalFetch = globalThis.fetch - const pending = new Map void; reject: (reason: unknown) => void }>() - let aborts = 0 - globalThis.fetch = ((input, init) => { - const workspace = new URL(String(input), 'http://localhost').searchParams.get('workspace') || '' - return new Promise((resolve, reject) => { - pending.set(workspace, { resolve, reject }) - init?.signal?.addEventListener('abort', () => { - aborts += 1 - reject(new DOMException('Aborted', 'AbortError')) - }, { once: true }) - }) - }) as typeof fetch - try { - const { render, screen, waitFor, cleanup } = await import('@testing-library/react') - const { TemplateAssetPicker } = await import('../src/features/sceneTemplates/TemplateAssetPicker.tsx') - const view = render( undefined} />) - await waitFor(() => assert.ok(pending.has('one'))) - view.rerender( undefined} />) - await waitFor(() => assert.ok(pending.has('two'))) - assert.equal(aborts, 1) - pending.get('one')?.resolve(new Response(JSON.stringify({ assets: [asset({ id: 'stale', filename: 'stale.png' })], total: 1 }))) - pending.get('two')?.resolve(new Response(JSON.stringify({ assets: [asset({ id: 'current', filename: 'current.png', locations: [{ workspace_id: 'two', filename: 'current.png', url: '/api/v1/file/current.png?workspace=two' }] })], total: 1 }))) - await screen.findByRole('button', { name: 'Select current.png' }) - assert.equal(screen.queryByRole('button', { name: 'Select stale.png' }), null) - cleanup() - } finally { - globalThis.fetch = originalFetch - } -}) - -test('cambiar el tipo invalida la lista anterior y deja visible el tipo incompatible', { concurrency: false }, async () => { - const originalFetch = globalThis.fetch - const image = asset() - const model = asset({ id: 'asset-model', kind: 'model3d', filename: 'ship.glb', locations: [] }) - globalThis.fetch = (async input => { - const kind = new URL(String(input), 'http://localhost').searchParams.get('kind') - return new Response(JSON.stringify({ assets: [kind === 'model3d' ? model : image], total: 1 })) - }) as typeof fetch - try { - const { screen, waitFor, fireEvent, cleanup } = await renderPicker({ kinds: ['image', 'model3d'], onPick: () => undefined }) - await screen.findByRole('button', { name: 'Select hero.png' }) - const modelTab = screen.getByRole('tab', { name: /GLB \/ 3D/ }) - fireEvent.click(modelTab) - assert.equal(screen.queryByRole('button', { name: 'Select hero.png' }), null) - await screen.findByRole('button', { name: 'Select ship.glb' }) - await waitFor(() => assert.equal(screen.queryByRole('button', { name: 'Select hero.png' }), null)) - const imageTab = screen.getByRole('tab', { name: /Images/ }) - assert.equal((imageTab as HTMLButtonElement).disabled, false) - cleanup() - } finally { - globalThis.fetch = originalFetch - } -}) - -test('muestra una razón de bloqueo y conserva la identidad aunque falle el preview', { concurrency: false }, async () => { - const originalFetch = globalThis.fetch - const hero = asset() - globalThis.fetch = (async () => new Response(JSON.stringify({ assets: [hero], total: 1 }))) as typeof fetch - let picked = false try { - const view = await renderPicker({ - selectedId: hero.id, - onPick: () => { picked = true }, - disabledReason: () => 'Falta metadata canónica', - }) - const card = await view.screen.findByRole('button', { name: 'Select hero.png' }) as HTMLButtonElement - assert.equal(card.disabled, true) - assert.match(card.textContent || '', /Falta metadata canónica/) - assert.equal(card.getAttribute('aria-pressed'), 'true') - view.fireEvent.click(card) - assert.equal(picked, false) - - // Render an enabled copy to exercise the image error path without changing selectedId. - const Picker = view.TemplateAssetPicker - view.rerender( undefined} />) - const image = await view.screen.findByRole('img', { name: 'Preview of hero.png' }) - view.fireEvent.error(image) - assert.ok(view.screen.getByText('Preview unavailable')) - assert.equal((view.screen.getByRole('button', { name: 'Select hero.png' }) as HTMLButtonElement).getAttribute('aria-pressed'), 'true') - view.cleanup() + render( undefined} />) + assert.ok(screen.getByRole('button', { name: /From my computer/ })) + fireEvent.click(screen.getByRole('button', { name: /From HocusPocus/ })) + assert.ok(await screen.findByTestId('asset-explorer')) } finally { - globalThis.fetch = originalFetch - } -}) - -test('rechaza URLs remotas, blob y ubicaciones de otro workspace para previews', { concurrency: false }, async () => { - const originalFetch = globalThis.fetch - const unsafe = asset({ - locations: [ - { workspace_id: 'other', filename: 'hero.png', url: 'https://evil.example/hero.png' }, - { workspace_id: 'default', filename: 'hero.png', url: 'blob:http://localhost/unsafe' }, - ], - url: 'https://evil.example/hero.png', - }) - globalThis.fetch = (async () => new Response(JSON.stringify({ assets: [unsafe], total: 1 }))) as typeof fetch - try { - const { screen, cleanup } = await renderPicker({ onPick: () => undefined }) - await screen.findByRole('button', { name: 'Select hero.png' }) - assert.equal(screen.queryByRole('img'), null) - assert.ok(screen.getByText('Preview unavailable in this workspace')) cleanup() - } finally { - globalThis.fetch = originalFetch } }) - -test('no carga una preview cuyo query apunta a otro workspace aunque la ubicación diga el activo', { concurrency: false }, async () => { - const originalFetch = globalThis.fetch - const mismatched = asset({ locations: [{ workspace_id: 'default', filename: 'hero.png', url: '/api/v1/file/hero.png?workspace=other' }] }) - globalThis.fetch = (async () => new Response(JSON.stringify({ assets: [mismatched], total: 1 }))) as typeof fetch - try { - const view = await renderPicker({ onPick: () => undefined }) - await view.screen.findByRole('button', { name: 'Select hero.png' }) - assert.equal(view.screen.queryByRole('img'), null) - view.cleanup() - } finally { globalThis.fetch = originalFetch } -}) - -test('muestra y selecciona hero(1).png con la codificación del backend', { concurrency: false }, async () => { - const originalFetch = globalThis.fetch - const source = '/api/v1/file/hero%281%29.png?workspace=film%282%29' - const item = asset({ filename: 'hero(1).png', locations: [{ workspace_id: 'film(2)', filename: 'hero(1).png', url: source }] }) - globalThis.fetch = (async () => new Response(JSON.stringify({ assets: [item], total: 1 }))) as typeof fetch - let picked = '' - try { - const view = await renderPicker({ workspace: 'film(2)', onPick: value => { picked = value.id } }) - const button = await view.screen.findByRole('button', { name: 'Select hero(1).png' }) - assert.equal(view.screen.getByRole('img').getAttribute('src'), source) - view.fireEvent.click(button) - assert.equal(picked, item.id) - view.cleanup() - } finally { globalThis.fetch = originalFetch } -}) diff --git a/ui/tests/templateComposerDialog.test.tsx b/ui/tests/templateComposerDialog.test.tsx index 7903985a5..9109757fd 100644 --- a/ui/tests/templateComposerDialog.test.tsx +++ b/ui/tests/templateComposerDialog.test.tsx @@ -6,6 +6,8 @@ import type { AssetCatalogItem } from '../src/api/assets.ts' import type { Scene } from '../src/types/index.ts' import { ALL_SCENE_TEMPLATES, CANDIDATE_SCENE_TEMPLATES } from '../src/features/sceneTemplates/catalog.ts' +Object.assign(globalThis, { React }) + const dom = new JSDOM('', { url: 'http://localhost/' }) Object.assign(globalThis, { window: dom.window, @@ -56,29 +58,46 @@ function installAssetFetch({ } = {}) { const fixture = assetsFor(workspace, heroMetadata) const calls: Array<{ url: string; init?: RequestInit }> = [] - globalThis.fetch = (async (input, init) => { + const heroDetailFetches = { count: 0 } + const previous = globalThis.fetch + const mock: typeof fetch = (async (input, init) => { const url = String(input) calls.push({ url, init }) if (url.includes('/api/v1/assets?')) { const query = new URL(url, 'http://localhost').searchParams + if (query.get('workspace') && query.get('workspace') !== workspace) { + if (typeof previous === 'function') return previous(input, init) + } const kind = query.get('kind') - const assets = fixture.all.filter(item => !kind || item.kind === kind) + const assets = fixture.all.filter(item => !kind || item.kind === kind || kind.split(',').includes(item.kind)) return responseFor({ assets, total: assets.length }) } if (url.includes('/api/v1/assets/')) { - const id = decodeURIComponent(url.slice(url.lastIndexOf('/') + 1)) + const id = decodeURIComponent(url.slice(url.lastIndexOf('/') + 1).split('?')[0]) const selected = fixture.all.find(item => item.id === id) - if (!selected) return responseFor({ detail: 'missing' }, 404) - if (detailMode === '404' && selected.id === fixture.hero.id) return responseFor({ detail: 'missing' }, 404) - if (detailMode === 'source-change' && selected.id === fixture.hero.id) { + if (!selected) { + if (typeof previous === 'function') return previous(input, init) + return responseFor({ detail: 'missing' }, 404) + } + if (selected.id === fixture.hero.id) heroDetailFetches.count += 1 + if (detailMode === '404' && selected.id === fixture.hero.id && heroDetailFetches.count > 1) return responseFor({ detail: 'missing' }, 404) + if (detailMode === 'source-change' && selected.id === fixture.hero.id && heroDetailFetches.count > 1) { const filename = 'hero-replaced.png' return responseFor({ ...selected, filename, locations: [{ workspace_id: workspace, filename, url: `/api/v1/file/${filename}?workspace=${workspace}` }], url: `/api/v1/file/${filename}?workspace=${workspace}` }) } return responseFor(selected) } + if (typeof previous === 'function') return previous(input, init) throw new Error(`Unexpected non-catalog request: ${url}`) }) as typeof fetch - return { fixture, calls } + globalThis.fetch = mock + return { + fixture, + calls, + restore() { + if (globalThis.fetch === mock) globalThis.fetch = previous + }, + } } async function renderDialog(props: { workspace?: string; onClose?: () => void; onApply?: (scene: Scene) => boolean } = {}) { @@ -88,18 +107,28 @@ async function renderDialog(props: { workspace?: string; onClose?: () => void; o return { ...view, screen, fireEvent, waitFor, cleanup, TemplateComposerDialog } } +function clickLast(view: Awaited>, name: string | RegExp, exact = false) { + const buttons = view.screen.getAllByRole('button', exact && typeof name === 'string' ? { name, exact: true } : { name }) + view.fireEvent.click(buttons[buttons.length - 1]) +} + +async function pickLibraryFile(view: Awaited>, filename: string) { + clickLast(view, /From HocusPocus/) + await view.waitFor(() => assert.ok(document.querySelector(`button[title="${filename}"]`))) + view.fireEvent.click(document.querySelector(`button[title="${filename}"]`) as HTMLButtonElement) + clickLast(view, 'Choose', true) + await view.waitFor(() => assert.ok(view.screen.getAllByText(filename).length >= 1)) +} + async function selectHeroAndPlate(view: Awaited>) { - const hero = await view.screen.findByRole('button', { name: /Select hero-/ }) - view.fireEvent.click(hero) + await pickLibraryFile(view, 'hero-default.png') view.fireEvent.click(view.screen.getByRole('button', { name: 'Background (required)' })) - const plate = await view.screen.findByRole('button', { name: /Select plate-/ }) - view.fireEvent.click(plate) + await pickLibraryFile(view, 'plate-default.png') view.fireEvent.click(view.screen.getByRole('checkbox', { name: /I have saved what I need/ })) } test('expone 24 referencias más 24 movimientos y habilita BPM/intensidad sólo en plantillas rítmicas', { concurrency: false }, async () => { - const originalFetch = globalThis.fetch - installAssetFetch() + const installed = installAssetFetch() try { const view = await renderDialog() const selector = view.screen.getByRole('combobox', { name: 'Action / template' }) as HTMLSelectElement @@ -115,13 +144,12 @@ test('expone 24 referencias más 24 movimientos y habilita BPM/intensidad sólo assert.equal((view.screen.getByRole('spinbutton', { name: 'Pulse intensity' }) as HTMLInputElement).disabled, false) view.cleanup() } finally { - globalThis.fetch = originalFetch + installed.restore() } }) test('selecciona hero y fondo canónicos y aplica provided_only con lineage de catálogo', { concurrency: false }, async () => { - const originalFetch = globalThis.fetch - const { fixture, calls } = installAssetFetch() + const { fixture, calls, restore } = installAssetFetch() const applied: Scene[] = [] let closed = 0 try { @@ -143,49 +171,52 @@ test('selecciona hero y fondo canónicos y aplica provided_only con lineage de c assert.ok(calls.some(call => call.url.endsWith(`/assets/${fixture.plate.id}`))) view.cleanup() } finally { - globalThis.fetch = originalFetch + restore() } }) test('mantiene assets heredados visibles pero no seleccionables ni aplicables', { concurrency: false }, async () => { - const originalFetch = globalThis.fetch - installAssetFetch({ heroMetadata: 'legacy' }) + const installed = installAssetFetch({ heroMetadata: 'legacy' }) let applied = 0 try { const view = await renderDialog({ onApply: () => { applied += 1; return true } }) - const hero = await view.screen.findByRole('button', { name: /Select hero-default\.png/ }) as HTMLButtonElement - assert.equal(hero.disabled, true) - assert.match(hero.textContent || '', /metadatos canónicos/i) + clickLast(view, /From HocusPocus/) + await view.waitFor(() => assert.ok(document.querySelector('button[title="hero-default.png"]'))) + view.fireEvent.click(document.querySelector('button[title="hero-default.png"]') as HTMLButtonElement) + clickLast(view, 'Choose', true) + const alert = await view.screen.findByRole('alert') + assert.match(alert.textContent || '', /metadatos canónicos/i) + assert.equal(view.screen.queryAllByText('hero-default.png').length, 0) assert.equal((view.screen.getByRole('button', { name: 'Create and open in editor' }) as HTMLButtonElement).disabled, true) assert.equal(applied, 0) view.cleanup() } finally { - globalThis.fetch = originalFetch + installed.restore() } }) test('cambia de plantilla y workspace reinicia bindings, y cerrar cancela sin aplicar', { concurrency: false }, async () => { - const originalFetch = globalThis.fetch - installAssetFetch() + const installed = installAssetFetch() + let otherFetch: ReturnType | undefined let closed = 0 let applied = 0 try { const view = await renderDialog({ onClose: () => { closed += 1 }, onApply: () => { applied += 1; return true } }) - const hero = await view.screen.findByRole('button', { name: /Select hero-default\.png/ }) - view.fireEvent.click(hero) - assert.equal(hero.getAttribute('aria-pressed'), 'true') + await pickLibraryFile(view, 'hero-default.png') const selector = view.screen.getByRole('combobox', { name: 'Action / template' }) view.fireEvent.change(selector, { target: { value: 'music-pulse' } }) - const resetHero = await view.screen.findByRole('button', { name: /Select hero-default\.png/ }) - assert.equal(resetHero.getAttribute('aria-pressed'), 'false') + await view.waitFor(() => assert.equal(view.screen.queryAllByText('hero-default.png').length, 0)) + assert.ok(view.screen.getAllByText('Unassigned').length >= 1) const other = assetsFor('other') - installAssetFetch({ workspace: 'other' }) + otherFetch = installAssetFetch({ workspace: 'other' }) view.rerender( { closed += 1 }} onApply={() => { applied += 1; return true }} />) - const otherHero = await view.screen.findByRole('button', { name: /Select hero-other\.png/ }) - assert.equal(otherHero.getAttribute('aria-pressed'), 'false') - assert.equal(view.screen.queryByRole('button', { name: /Select hero-default\.png/ }), null) + await view.waitFor(() => assert.equal(view.screen.queryAllByText('hero-default.png').length, 0)) + clickLast(view, /From HocusPocus/) + await view.waitFor(() => assert.ok(document.querySelector('button[title="hero-other.png"]'))) + assert.equal(document.querySelector('button[title="hero-default.png"]'), null) + clickLast(view, 'Cancel', true) assert.equal(other.hero.id, 'asset-hero-other') view.fireEvent.click(view.screen.getByRole('button', { name: 'Close' })) @@ -193,15 +224,17 @@ test('cambia de plantilla y workspace reinicia bindings, y cerrar cancela sin ap assert.equal(applied, 0) view.cleanup() } finally { - globalThis.fetch = originalFetch + otherFetch?.restore() + installed.restore() } }) test('404 y cambio de fuente al revalidar impiden aplicar y nunca generan assets', { concurrency: false }, async () => { - const originalFetch = globalThis.fetch + const restores: Array<() => void> = [] try { for (const detailMode of ['404', 'source-change']) { - const { calls } = installAssetFetch({ detailMode }) + const { calls, restore } = installAssetFetch({ detailMode }) + restores.push(restore) let applied = 0 const view = await renderDialog({ onApply: () => { applied += 1; return true } }) await selectHeroAndPlate(view) @@ -214,6 +247,6 @@ test('404 y cambio de fuente al revalidar impiden aplicar y nunca generan assets view.cleanup() } } finally { - globalThis.fetch = originalFetch + for (const restore of restores.reverse()) restore() } }) diff --git a/ui/tests/templateSlotPick.test.mjs b/ui/tests/templateSlotPick.test.mjs new file mode 100644 index 000000000..20c00e409 --- /dev/null +++ b/ui/tests/templateSlotPick.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { acceptForSlotKinds, commitTemplateSlotChoice } from '../src/features/sceneTemplates/templateSlotPick.ts' + +const live = { generation: 1, workspaceId: 'ws', slotId: 'hero' } +const capture = { generation: 1, workspaceId: 'ws', slotId: 'hero' } +const catalog = { + id: 'asset-hero', + kind: 'image', + filename: 'hero.png', + metadata_status: 'canonical', +} + +const output = { + name: 'hero.png', + type: 'image', + mode: null, + size: 1, + created_at: 1, + url: '/api/v1/file/hero.png?workspace=ws', + asset_id: 'asset-hero', + workspace_id: 'ws', +} + +test('catalog pick loads the durable id and applies when the slot still matches', async () => { + const result = await commitTemplateSlotChoice(live, capture, output, async id => { + assert.equal(id, 'asset-hero') + return catalog + }, () => undefined) + assert.equal(result.action, 'apply') + if (result.action === 'apply') assert.equal(result.item.id, 'asset-hero') +}) + +test('local uploads without asset_id are not treated as library bindings', async () => { + const result = await commitTemplateSlotChoice(live, capture, { ...output, asset_id: undefined }, async () => catalog, () => undefined) + assert.equal(result.action, 'reject') + if (result.action === 'reject') assert.equal(result.reasonKey, 'missing-id') +}) + +test('cancel clears; workspace, slot or generation mismatch is ignored', async () => { + assert.equal((await commitTemplateSlotChoice(live, capture, null, async () => catalog, () => undefined)).action, 'clear') + assert.equal((await commitTemplateSlotChoice({ ...live, generation: 4 }, capture, output, async () => catalog, () => undefined)).action, 'ignore') + assert.equal((await commitTemplateSlotChoice({ ...live, workspaceId: 'other' }, capture, output, async () => catalog, () => undefined)).action, 'ignore') + assert.equal((await commitTemplateSlotChoice({ ...live, slotId: 'plate' }, capture, output, async () => catalog, () => undefined)).action, 'ignore') +}) + +test('bindingIssue blocks a catalog item that the slot cannot use', async () => { + const result = await commitTemplateSlotChoice(live, capture, output, async () => catalog, () => 'El slot hero no admite image.') + assert.equal(result.action, 'reject') + if (result.action === 'reject') { + assert.equal(result.reasonKey, 'incompatible') + assert.equal(result.message, 'El slot hero no admite image.') + } +}) + +test('accept lists images and glb only for the slot kinds', () => { + assert.equal(acceptForSlotKinds(['image']), 'image/*') + assert.match(acceptForSlotKinds(['image', 'model3d']), /glb/) +})