import { describe, expect, it, jest } from 'bun:test' import { noop } from '@vueuse/core' import type { JSXPreviewNode } from '@open-pencil/design-jsx' import { createJSXPreviewController, type PreviewArtifact } from '@/app/ai/preview/controller' import { readPreviewInput, type RenderPreviewInput } from '@/app/ai/preview/input' function artifact() { return { shown: 0, disposed: 0, show() { this.shown++ }, dispose() { this.disposed++ } } } function fixture( build?: ( tree: JSXPreviewNode, input: RenderPreviewInput, signal: AbortSignal ) => Promise ) { let current = 1 const artifacts: ReturnType[] = [] const trees: JSXPreviewNode[] = [] const controller = createJSXPreviewController({ capture: () => current, isCurrent: (target) => target === current, async build(_target, tree, input, signal) { trees.push(tree) if (build) return build(tree, input, signal) const resource = artifact() artifacts.push(resource) return resource } }) return { controller, artifacts, trees, changeTarget: () => { current++ } } } const opening = '{"x":100,"y":80,"jsx":"' describe('JSX tool preview controller', () => { it('decodes incomplete JSON without inventing a partial number', () => { expect(readPreviewInput(opening)?.jsx).toBe('') expect(readPreviewInput('{"jsx":"","x":1e')).toEqual({ jsx: '' }) expect(readPreviewInput('{"jsx":12}')).toBeNull() }) it('coalesces deltas and publishes immutable progressive snapshots', async () => { const { controller, artifacts, trees } = fixture() try { controller.start('call') controller.delta('call', opening) controller.delta('call', 'Hello') await controller.flush('call') expect(artifacts).toHaveLength(1) expect(artifacts[0]?.shown).toBe(1) expect(trees[0]?.children).toMatchObject([{ type: 'text', children: ['Hello'] }]) controller.delta('call', ' world') await controller.flush('call') expect(artifacts[0]?.disposed).toBe(1) expect(artifacts[1]?.shown).toBe(1) expect(trees[0]?.children).toMatchObject([{ type: 'text', children: ['Hello'] }]) controller.finish('call') expect(artifacts[1]?.disposed).toBe(1) } finally { controller.clear() } }) it('previews a lone delta once the interval passes, without an explicit flush', async () => { jest.useFakeTimers() try { const shown = Promise.withResolvers() const { controller, trees } = fixture(async () => ({ show: () => shown.resolve(true), dispose: noop })) controller.start('call') controller.delta('call', opening) jest.advanceTimersByTime(119) expect(trees).toHaveLength(0) jest.advanceTimersByTime(1) expect(trees).toHaveLength(1) await shown.promise } finally { jest.useRealTimers() } }) it('holds the last valid picture while an attribute is incomplete', async () => { const { controller, artifacts } = fixture() try { controller.start('call') controller.delta('call', opening) await controller.flush('call') controller.delta('call', ' { const { controller, artifacts } = fixture() const abort = new AbortController() try { controller.start('call', abort.signal) controller.delta('call', opening) await controller.flush('call') abort.abort() expect(artifacts[0]?.disposed).toBe(1) controller.delta('call', '') await controller.flush('call') expect(artifacts).toHaveLength(1) } finally { controller.clear() } }) it('disposes a late async result after final execution starts', async () => { const pending = Promise.withResolvers() const resource = artifact() const { controller } = fixture(async () => pending.promise) try { controller.start('call') controller.delta('call', opening) const rendering = controller.flush('call') controller.finish('call') pending.resolve(resource) await rendering expect(resource.shown).toBe(0) expect(resource.disposed).toBe(1) } finally { controller.clear() } }) it('shows a slow build, then renders the newer revision, one build at a time', async () => { const pending = Promise.withResolvers() const slow = artifact() const next = artifact() const results = [pending.promise, Promise.resolve(next)] const { controller, trees } = fixture(async () => results.shift() ?? null) try { controller.start('call') controller.delta('call', opening) const rendering = controller.flush('call') controller.delta('call', '') await controller.flush('call') expect(trees).toHaveLength(1) pending.resolve(slow) await rendering expect(slow.shown).toBe(1) await controller.flush('call') expect(trees).toHaveLength(2) expect(slow.disposed).toBe(1) expect(next.shown).toBe(1) } finally { controller.clear() } }) it('rebuilds after a document edit and keeps following the call', async () => { const { controller, artifacts, trees } = fixture() try { controller.start('call') controller.delta('call', opening) await controller.flush('call') controller.invalidate() expect(artifacts[0]?.disposed).toBe(1) await controller.flush('call') expect(trees).toHaveLength(2) expect(artifacts[1]?.shown).toBe(1) controller.delta('call', '') await controller.flush('call') expect(trees).toHaveLength(3) } finally { controller.clear() } }) it('discards async work after changing the target editor/page', async () => { const pending = Promise.withResolvers() const resource = artifact() const { controller, changeTarget } = fixture(async () => pending.promise) try { controller.start('call') controller.delta('call', opening) const rendering = controller.flush('call') changeTarget() pending.resolve(resource) await rendering expect(resource.shown).toBe(0) expect(resource.disposed).toBe(1) } finally { controller.clear() } }) it('isolates concurrent calls and removes only the completed preview', async () => { const { controller, artifacts } = fixture() try { for (const id of ['one', 'two']) { controller.start(id) controller.delta(id, opening) await controller.flush(id) } controller.finish('one') expect(artifacts[0]?.disposed).toBe(1) expect(artifacts[1]?.disposed).toBe(0) } finally { controller.clear() } expect(artifacts[1]?.disposed).toBe(1) }) it('bounds input and treats rendering errors as preview-only failures', async () => { const { controller, trees } = fixture(async () => { throw new Error('preview unavailable') }) try { controller.start('oversized') controller.delta('oversized', 'x'.repeat(256_001)) await controller.flush('oversized') expect(trees).toHaveLength(0) controller.start('call') controller.delta('call', opening) await expect(controller.flush('call')).resolves.toBeUndefined() controller.delta('call', '') await controller.flush('call') expect(trees).toHaveLength(1) } finally { controller.clear() } }) })