diff --git a/cocoscreator_projects/YouleNexus/assets/framework/presentation/frame-renderer.ts b/cocoscreator_projects/YouleNexus/assets/framework/presentation/frame-renderer.ts new file mode 100644 index 0000000..406b318 --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/presentation/frame-renderer.ts @@ -0,0 +1,67 @@ +import type { FrameScheduler } from './ui-contracts.ts'; + +class FrameRendererFault extends Error { + readonly errors: readonly unknown[]; + + constructor(errors: readonly unknown[]) { + super('FrameRenderer render and disposal failed'); + this.name = 'FrameRendererFault'; + this.errors = Object.freeze([...errors]); + } +} + +export class FrameRenderer { + private pending: (() => void) | null = null; + private generation = 0; + private disposed = false; + + public constructor( + private readonly scheduler: FrameScheduler, + private readonly render: () => void, + private readonly onFault: (error: unknown) => void, + ) {} + + public request(): void { + if (this.disposed || this.pending !== null) return; + + const generation = this.generation; + this.pending = this.scheduler.request(() => { + if (this.disposed || generation !== this.generation) return; + this.pending = null; + this.generation += 1; + try { + this.render(); + } catch (error) { + let fault: unknown = error; + try { + this.dispose(); + } catch (cancelError) { + fault = new FrameRendererFault([error, cancelError]); + } + this.onFault(fault); + } + }); + } + + public flush(): void { + if (this.disposed) return; + + const cancel = this.pending; + this.pending = null; + this.generation += 1; + const generation = this.generation; + if (cancel !== null) cancel(); + if (this.disposed || generation !== this.generation) return; + this.render(); + } + + public dispose(): void { + if (this.disposed) return; + this.disposed = true; + + const cancel = this.pending; + this.pending = null; + this.generation += 1; + if (cancel !== null) cancel(); + } +} diff --git a/cocoscreator_projects/framework-tests/presentation/frame-renderer.test.ts b/cocoscreator_projects/framework-tests/presentation/frame-renderer.test.ts new file mode 100644 index 0000000..f7980f6 --- /dev/null +++ b/cocoscreator_projects/framework-tests/presentation/frame-renderer.test.ts @@ -0,0 +1,263 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { FrameRenderer } from '../../YouleNexus/assets/framework/presentation/frame-renderer.ts'; + +test('coalesces frames and invalidates callbacks even if scheduler delivers late', () => { + const callbacks: (() => void)[] = []; + let cancelled = 0; + let rendered = 0; + const renderer = new FrameRenderer({ + request(cb) { callbacks.push(cb); return () => { cancelled++; }; }, + }, () => { rendered++; }, e => { throw e; }); + renderer.request(); renderer.request(); + assert.equal(callbacks.length, 1); + renderer.flush(); + assert.equal(rendered, 1); + callbacks[0]!(); + assert.equal(rendered, 1); + renderer.request(); + renderer.dispose(); renderer.dispose(); + callbacks[1]!(); + assert.equal(rendered, 1); + assert.equal(cancelled, 2); +}); + +test('releases the current frame before render can request the next frame', () => { + const callbacks: (() => void)[] = []; + let rendered = 0; + let renderer!: FrameRenderer; + renderer = new FrameRenderer({ + request(callback) { callbacks.push(callback); return () => {}; }, + }, () => { + rendered += 1; + renderer.request(); + }, error => { throw error; }); + + renderer.request(); + callbacks[0]!(); + + assert.equal(rendered, 1); + assert.equal(callbacks.length, 2); + + callbacks[1]!(); + assert.equal(rendered, 2); + assert.equal(callbacks.length, 3); +}); + +test('dispose during render prevents a reentrant frame request', () => { + const callbacks: (() => void)[] = []; + let rendered = 0; + let renderer!: FrameRenderer; + renderer = new FrameRenderer({ + request(callback) { callbacks.push(callback); return () => {}; }, + }, () => { + rendered += 1; + renderer.dispose(); + renderer.request(); + }, error => { throw error; }); + + renderer.request(); + callbacks[0]!(); + + assert.equal(rendered, 1); + assert.equal(callbacks.length, 1); + renderer.flush(); + assert.equal(rendered, 1); +}); + +test('a throwing dispose cancellation still makes every callback stale', () => { + const callbacks: (() => void)[] = []; + const cancelError = new Error('cancel failed'); + let rendered = 0; + const renderer = new FrameRenderer({ + request(callback) { + callbacks.push(callback); + return () => { throw cancelError; }; + }, + }, () => { rendered += 1; }, error => { throw error; }); + + renderer.request(); + let thrown: unknown; + try { + renderer.dispose(); + } catch (error) { + thrown = error; + } + + assert.equal(thrown, cancelError); + callbacks[0]!(); + assert.equal(rendered, 0); + renderer.request(); + assert.equal(callbacks.length, 1); +}); + +test('a throwing flush cancellation invalidates the old frame before rethrowing', () => { + const callbacks: (() => void)[] = []; + const cancelError = new Error('cancel failed'); + let rendered = 0; + const renderer = new FrameRenderer({ + request(callback) { + callbacks.push(callback); + return () => { throw cancelError; }; + }, + }, () => { rendered += 1; }, error => { throw error; }); + + renderer.request(); + let thrown: unknown; + try { + renderer.flush(); + } catch (error) { + thrown = error; + } + + assert.equal(thrown, cancelError); + callbacks[0]!(); + assert.equal(rendered, 0); + renderer.request(); + assert.equal(callbacks.length, 2); +}); + +test('flush does not render when cancellation disposes the renderer', () => { + let rendered = 0; + let renderer!: FrameRenderer; + renderer = new FrameRenderer({ + request() { return () => { renderer.dispose(); }; }, + }, () => { rendered += 1; }, error => { throw error; }); + + renderer.request(); + renderer.flush(); + + assert.equal(rendered, 0); +}); + +test('a cancellation that reenters flush supersedes the outer render', () => { + let rendered = 0; + let renderer!: FrameRenderer; + renderer = new FrameRenderer({ + request() { return () => { renderer.flush(); }; }, + }, () => { rendered += 1; }, error => { throw error; }); + + renderer.request(); + renderer.flush(); + + assert.equal(rendered, 1); +}); + +test('reports an asynchronous render error once and terminates future frames', () => { + const callbacks: (() => void)[] = []; + const renderError = new Error('render failed'); + const faults: unknown[] = []; + let rendered = 0; + const renderer = new FrameRenderer({ + request(callback) { callbacks.push(callback); return () => {}; }, + }, () => { + rendered += 1; + throw renderError; + }, error => { faults.push(error); }); + + renderer.request(); + let thrown: unknown; + try { + callbacks[0]!(); + } catch (error) { + thrown = error; + } + + assert.equal(thrown, undefined); + assert.deepEqual(faults, [renderError]); + callbacks[0]!(); + renderer.request(); + assert.equal(rendered, 1); + assert.deepEqual(faults, [renderError]); + assert.equal(callbacks.length, 1); +}); + +test('aggregates an asynchronous render error with reentrant cancellation failure', () => { + const callbacks: (() => void)[] = []; + const renderError = new Error('render failed'); + const cancelError = new Error('cancel failed'); + const faults: unknown[] = []; + let rendered = 0; + let renderer!: FrameRenderer; + renderer = new FrameRenderer({ + request(callback) { + callbacks.push(callback); + return () => { throw cancelError; }; + }, + }, () => { + rendered += 1; + renderer.request(); + throw renderError; + }, error => { faults.push(error); }); + + renderer.request(); + let thrown: unknown; + try { + callbacks[0]!(); + } catch (error) { + thrown = error; + } + + assert.equal(thrown, undefined); + assert.equal(faults.length, 1); + const fault = faults[0]; + assert.ok(fault instanceof Error); + assert.ok('errors' in fault); + assert.deepEqual(fault.errors, [renderError, cancelError]); + callbacks[1]!(); + assert.equal(rendered, 1); + assert.equal(faults.length, 1); +}); + +test('flush rethrows a synchronous render error without reporting an async fault', () => { + const callbacks: (() => void)[] = []; + const renderError = new Error('render failed'); + const faults: unknown[] = []; + let rendered = 0; + const renderer = new FrameRenderer({ + request(callback) { callbacks.push(callback); return () => {}; }, + }, () => { + rendered += 1; + throw renderError; + }, error => { faults.push(error); }); + + renderer.request(); + let thrown: unknown; + try { + renderer.flush(); + } catch (error) { + thrown = error; + } + + assert.equal(thrown, renderError); + assert.deepEqual(faults, []); + callbacks[0]!(); + assert.equal(rendered, 1); +}); + +test('does not swallow an onFault callback error', () => { + const callbacks: (() => void)[] = []; + const renderError = new Error('render failed'); + const callbackError = new Error('fault callback failed'); + const reported: unknown[] = []; + const renderer = new FrameRenderer({ + request(callback) { callbacks.push(callback); return () => {}; }, + }, () => { throw renderError; }, error => { + reported.push(error); + throw callbackError; + }); + + renderer.request(); + let thrown: unknown; + try { + callbacks[0]!(); + } catch (error) { + thrown = error; + } + + assert.equal(thrown, callbackError); + assert.deepEqual(reported, [renderError]); + renderer.request(); + assert.equal(callbacks.length, 1); +});