openpencil/tests/engine/editor/pages/loading.test.ts

139 lines
4.4 KiB
TypeScript
Raw Normal View History

feat(app): prepare documents atomically per tab (#592) * feat(app): show atomic document loading progress - Preserve the existing full-canvas pencil loader while adding phase, detail, accessible status, and honest determinate progress - Keep one generation-safe load owner across FIG decoding, graph preparation, page population, fonts, fallbacks, layout, viewport fitting, and first-render fade - Prevent nested page setup and viewport cleanup from revealing partially prepared documents - Cover obsolete sessions, font-resolution ownership, and staged loader UI * refactor(app): scope editor preparation per tab - Replace the shared loading boolean with one reactive preparation snapshot and one imperative controller per editor store - Keep Core page work progress-only and inject canvas suspension from the app boundary - Route FIG, storage, recovery, DOM import, and page switching through reusable tab-local preparation handles - Abort only the closing tab's operation and cover generation safety, multi-tab isolation, progress UI, and disposal * fix(editor): commit prepared pages atomically - Prepare population, fonts, fallbacks, and layout without changing the visible page - Reject cancelled and stale prepared pages before committing viewport, selection, and page events - Keep the preparation overlay until the committed scene version is presented - Cover call order, cancellation, stale generations, and presentation acknowledgement * fix(app): stage imported documents before commit - Prepare imported graphs in an isolated Core editor before replacing the live document - Share font loading while keeping live selection, graph, renderers, and history untouched during staging - Preserve the previous graph when staging is cancelled or fails and remove the duplicate pre-font layout pass * refactor(app): namespace preparation UI - Move canvas and tab preparation presentations into focused subfolders with concise component names - Share progress and phase presentation helpers across preparation surfaces - Show tab-local preparation status without covering the active canvas for background work * fix(app): cancel preparation work at source - Publish typed per-store preparation lifecycle events with explicit completion, cancellation, and failure outcomes - Propagate tab-local AbortSignals through FIG parsing, population workers, and browser font downloads - Keep cancellable font requests outside shared in-flight caches while retaining globally completed font registrations - Stop FIG manifest previews from replacing the live graph before atomic document commit * fix(app): cancel storage and DOM preparation - Propagate preparation signals through S3 downloads, byte progress, local-cache boundaries, and DOM/CSS conversion checkpoints - Reuse merged diagnostics and localized toasts for document, storage, and presentation failures - Replace manual font concurrency and presentation timers with es-toolkit limitAsync and withTimeout - Guard stalled first presentation and fix the merged recovery dialog title bindings * fix(app): stage reload and font retry - Prepare reload graphs in isolation and preserve the current document on read, decode, font, or layout failure - Restore page and viewport state only after atomic graph commit with cancellable reload reads - Run font Retry as a tab-local preparation with cache reset, final layout, picture invalidation, and presentation acknowledgement - Keep completed document pixels visible while Retry reports activity in the tab * fix(app): enforce exclusive preparation outcomes - Complete document, storage, recovery, and DOM preparations only after successful commit - Keep failed and cancelled handles terminal so lifecycle events cannot report contradictory outcomes - Preserve external AbortError identity across storage timeouts and cancel streamed readers without returning partial bytes - Cover credential-free pre-abort, mid-stream cancellation, progress cutoff, and terminal outcome exclusivity * feat(diagnostics): record preparation outcomes - Persist completed, cancelled, and failed preparation lifecycles through the validated diagnostics recorder - Store only operation kind, outcome, cancellation or failure category, terminal phase, and coarse duration bucket - Exclude document subjects, font families, storage identities, URLs, raw durations, messages, and stack traces * chore(app): keep browser font tests with typography split - Remove the browser font transport test inherited from a mixed cancellation commit; the source and coverage remain on the typography branch and safety snapshot * test(vue): assert injected render suspension - Exercise shouldSuspendRender instead of removed Core loading state\n- Preserve the contract that rendering resumes without a version change * test(app): complete atomic preparation contracts - Acknowledge first presentation in headless file-open tests\n- Assert the cancellable font-loading signature at the Tauri fallback boundary * fix(app): preserve preparation cancellation - Stage imported graphs before mutating live tabs and propagate aborts through page, DOM, font, and storage work\n- Use the accessible progress primitive and clamp determinate values\n- Cover fallback-font cancellation and yield pending-open test polling to the task queue * test(text): await fallback font request cancellation Start the mocked remote font request before aborting so the test proves that the active request receives the preparation signal.
2026-08-30 09:21:49 +00:00
import { expect, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/scene-graph'
import { createEditor, type PageSwitchProgress } from '#core/editor'
test('core page preparation only reports progress and never owns app suspension', async () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
if (!page) throw new Error('Expected default page')
graph.createNode('TEXT', page.id, {
text: 'Loading',
fontFamily: 'Loader Test',
fontWeight: 400
})
const progress: PageSwitchProgress[] = []
let release: (() => void) | null = null
const fontReady = new Promise<void>((resolve) => {
release = resolve
})
const editor = createEditor({
graph,
skipInitialGraphSetup: true,
loadFont: async () => {
await fontReady
return null
}
})
const switching = editor.switchPage(page.id, {
onProgress: (next) => progress.push(next)
})
await Promise.resolve()
expect(progress.some((entry) => entry.phase === 'resolving-fonts')).toBe(true)
release?.()
await switching
expect(progress).toContainEqual(
expect.objectContaining({ phase: 'resolving-fonts', completed: 1, total: 1 })
)
})
test('prepares a target page without exposing it before commit', async () => {
const graph = new SceneGraph()
const firstPage = graph.getPages()[0]
if (!firstPage) throw new Error('Expected default page')
const secondPage = graph.addPage('Prepared page')
graph.createNode('TEXT', secondPage.id, {
text: 'Loading',
fontFamily: 'Loader Test',
fontWeight: 400
})
let release: (() => void) | null = null
const fontReady = new Promise<void>((resolve) => {
release = resolve
})
const editor = createEditor({
graph,
skipInitialGraphSetup: true,
loadFont: async () => {
await fontReady
return null
}
})
const pageChanges: string[] = []
editor.onEditorEvent('page:changed', (pageId) => pageChanges.push(pageId))
const preparing = editor.preparePage(secondPage.id)
await Promise.resolve()
expect(editor.state.currentPageId).toBe(firstPage.id)
expect(pageChanges).toEqual([])
release?.()
const prepared = await preparing
expect(editor.state.currentPageId).toBe(firstPage.id)
expect(prepared).not.toBeNull()
if (!prepared) throw new Error('Expected prepared page')
expect(editor.commitPageSwitch(prepared)).toBe(true)
expect(editor.state.currentPageId).toBe(secondPage.id)
expect(pageChanges).toEqual([secondPage.id])
})
test('aborted page preparation preserves the visible page', async () => {
const graph = new SceneGraph()
const firstPage = graph.getPages()[0]
if (!firstPage) throw new Error('Expected default page')
const secondPage = graph.addPage('Cancelled page')
graph.createNode('TEXT', secondPage.id, {
text: 'Loading',
fontFamily: 'Loader Test',
fontWeight: 400
})
let release: (() => void) | null = null
const fontReady = new Promise<void>((resolve) => {
release = resolve
})
const editor = createEditor({
graph,
skipInitialGraphSetup: true,
loadFont: async () => {
await fontReady
return null
}
})
const abort = new AbortController()
const preparing = editor.preparePage(secondPage.id, { signal: abort.signal })
await Promise.resolve()
abort.abort()
release?.()
await expect(preparing).rejects.toHaveProperty('name', 'AbortError')
expect(editor.state.currentPageId).toBe(firstPage.id)
})
test('obsolete prepared pages cannot commit after a newer preparation', async () => {
const graph = new SceneGraph()
const firstPage = graph.getPages()[0]
if (!firstPage) throw new Error('Expected default page')
const secondPage = graph.addPage('Second')
const thirdPage = graph.addPage('Third')
const editor = createEditor({ graph, skipInitialGraphSetup: true })
const second = await editor.preparePage(secondPage.id)
const third = await editor.preparePage(thirdPage.id)
expect(second).not.toBeNull()
expect(third).not.toBeNull()
if (!second || !third) throw new Error('Expected prepared pages')
expect(editor.commitPageSwitch(second)).toBe(false)
expect(editor.state.currentPageId).toBe(firstPage.id)
expect(editor.commitPageSwitch(third)).toBe(true)
expect(editor.state.currentPageId).toBe(thirdPage.id)
})
test('page viewport cleanup remains independent from app preparation state', () => {
const editor = createEditor()
expect(() => editor.clearPageViewports()).not.toThrow()
})