Files
feuerwerk/tests/editor/console-log.test.ts
T
rootandClaude Fable 5.1 0e5e5ef925
game-maker / suite (push) Successful in 8m24s
Shaders draw in the sprite's own pass: a Mesh with the graph's fragment, no filter (#77)
A sprite's picture is now a Mesh (unit quad, sibling of its Graphics node, which keeps
only the hit area); a worn shader replaces Pixi's fragment in that draw, so there is no
intermediate texture and no extra render pass. Measured on the HD 630: 400 shaded
sprites hold 60 fps (was 34), 800 hold 56 (was 20).

Two Pixi facts the code leans on: a mesh's texture is bound only for Pixi's own shader,
so GraphShader.wear hands it over; and MeshPipe caches whether a mesh batches and
re-checks it only in a frame without structure changes, so switching a shader on or
off calls mesh.unload().

The console panel mirrors console.group/groupCollapsed/groupEnd and fills the %s %d
%o %c directives, so Pixi's deprecation warning shows its head, not only its stack.
E2E 12 guards against the addChild-on-Graphics warning, 28 counts shaders instead of
filters.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 15:31:08 +02:00

202 lines
6.9 KiB
TypeScript

/**
* The console panel's source: the ring buffer, the way `console.*` arguments become one line,
* and which notices are kept. The panel itself needs a browser and is covered by e2e step 27.
*/
import { describe, expect, it, vi } from 'vitest';
import {
captureConsole,
ConsoleLog,
type ConsoleLike,
consoleLog,
formatArgs,
formatTime,
LOG_CAPACITY,
} from '../../src/editor/console/log';
import { EditorState } from '../../src/editor/state';
function fakeConsole(): ConsoleLike {
return {
log: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
group: vi.fn(),
groupCollapsed: vi.fn(),
groupEnd: vi.fn(),
};
}
describe('the log buffer', () => {
it('keeps lines newest last, each stamped with the time of its call', () => {
let clock = new Date(2026, 8, 12, 9, 5, 3).getTime();
const log = new ConsoleLog(() => clock);
log.push('info', 'first');
clock += 61_000;
log.push('error', 'second');
expect(log.lines).toEqual([
{ level: 'info', text: 'first', time: new Date(2026, 8, 12, 9, 5, 3).getTime() },
{ level: 'error', text: 'second', time: new Date(2026, 8, 12, 9, 6, 4).getTime() },
]);
expect(log.lines.map((l) => formatTime(l.time))).toEqual(['09:05:03', '09:06:04']);
});
it('gives every line of a split message the one time it arrived', () => {
// A clock that moves on every read: asked once per line, the second would fall into the next day.
let reads = 0;
const log = new ConsoleLog(() => new Date(2026, 8, 12, 23, 59, 59).getTime() + reads++ * 1000);
log.push('error', 'boom\n at frame');
expect(log.lines.map((l) => formatTime(l.time))).toEqual(['23:59:59', '23:59:59']);
expect(reads).toBe(1);
});
it('drops the oldest lines past the capacity', () => {
const log = new ConsoleLog();
for (let i = 0; i < LOG_CAPACITY + 10; i += 1) log.push('info', `line ${i}`);
expect(log.lines).toHaveLength(LOG_CAPACITY);
expect(log.lines[0]?.text).toBe('line 10');
expect(log.lines.at(-1)?.text).toBe(`line ${LOG_CAPACITY + 9}`);
});
it('splits an embedded newline into its own line', () => {
const log = new ConsoleLog();
log.push('error', 'boom\n at frame');
expect(log.lines.map((l) => l.text)).toEqual(['boom', ' at frame']);
});
it('notifies on a push and on a clear, but not on an empty clear', () => {
const log = new ConsoleLog();
const seen = vi.fn();
log.onChange(seen);
log.push('info', 'one');
log.clear();
log.clear();
expect(seen).toHaveBeenCalledTimes(2);
expect(log.lines).toEqual([]);
});
});
describe('formatting the arguments of a console call', () => {
it('joins them with a space and leaves strings alone', () => {
expect(formatArgs(['[behavior Crate]', 'hit', 3])).toBe('[behavior Crate] hit 3');
});
it('shows an Error as its first stack line, not the whole stack', () => {
const error = new Error('gone wrong');
error.stack = 'Error: gone wrong\n at one\n at two';
expect(formatArgs([error])).toBe('Error: gone wrong');
});
it('writes an object as JSON and a circular one as a placeholder', () => {
expect(formatArgs([{ x: 1, y: [2, 3] }])).toBe('{"x":1,"y":[2,3]}');
const circular: Record<string, unknown> = {};
circular.self = circular;
expect(formatArgs([circular])).toBe('[unserialisable]');
});
it('keeps null and undefined visible', () => {
expect(formatArgs([null, undefined, false])).toBe('null undefined false');
});
it('fills the directives of a leading format string the way the browser does', () => {
// Pixi's deprecation head: two `%c` styles that the panel cannot show, then the message.
expect(
formatArgs([
'%cPixiJS Deprecation Warning: %c%s',
'color:#614108',
'font-weight:normal',
'addChild: no',
]),
).toBe('PixiJS Deprecation Warning: addChild: no');
expect(formatArgs(['%d of %i at %f%%', 7.9, '3', 0.5, 'left'])).toBe('7 of 3 at 0.5% left');
expect(formatArgs(['%o and %O', { a: 1 }, [2]])).toBe('{"a":1} and [2]');
expect(formatArgs(['%s only', 'one'])).toBe('one only');
});
it('leaves a directive standing that has no argument, and a % elsewhere alone', () => {
expect(formatArgs(['%s and %s', 'one'])).toBe('one and %s');
expect(formatArgs(['100% done', 'x'])).toBe('100% done x');
});
});
describe('capturing console', () => {
it('mirrors every level into the log and still calls the original', () => {
const target = fakeConsole();
const log = new ConsoleLog();
const undo = captureConsole(target, log);
target.log('a');
target.info('b');
target.debug('c');
target.warn('d');
target.error('e');
expect(log.lines.map((l) => ({ level: l.level, text: l.text }))).toEqual([
{ level: 'info', text: 'a' },
{ level: 'info', text: 'b' },
{ level: 'info', text: 'c' },
{ level: 'warn', text: 'd' },
{ level: 'error', text: 'e' },
]);
undo();
target.log('after');
expect(log.lines).toHaveLength(5);
});
it('keeps a group head and indents what follows until the group ends', () => {
const target = fakeConsole();
const log = new ConsoleLog();
captureConsole(target, log);
target.groupCollapsed(
'%cPixiJS Deprecation Warning: %c%s',
'css',
'css',
'addChild: no\nDeprecated since v8',
);
target.warn(' at one\n at two');
target.group('inner');
target.log('deep');
target.groupEnd();
target.groupEnd();
target.groupEnd();
target.log('after');
expect(log.lines.map((l) => `${l.level}|${l.text}`)).toEqual([
'info|PixiJS Deprecation Warning: addChild: no',
'info|Deprecated since v8',
'warn| at one',
'warn| at two',
'info| inner',
'info| deep',
'info|after',
]);
});
it('forgets an open group on clear', () => {
const log = new ConsoleLog();
log.openGroup('head');
log.clear();
log.push('info', 'flat');
expect(log.lines.map((l) => l.text)).toEqual(['flat']);
});
it('leaves the original reachable, so devtools and the check scripts still see the call', () => {
const target = fakeConsole();
const original = target.warn;
captureConsole(target, new ConsoleLog())();
expect(target.warn).toBe(original);
});
});
describe('notices in the console', () => {
it('keeps a plain notice, marks a failure red and stays quiet for a status line', () => {
const state = new EditorState();
consoleLog.clear();
state.setNotice('Only image files can be dropped into the editor.');
state.setNotice('Saving failed: 500', 'error');
state.setNotice('Playing · edits are not saved', 'quiet');
state.setNotice('');
expect(consoleLog.lines.map((l) => ({ level: l.level, text: l.text }))).toEqual([
{ level: 'info', text: 'Only image files can be dropped into the editor.' },
{ level: 'error', text: 'Saving failed: 500' },
]);
});
});