game-maker / suite (push) Successful in 8m24s
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>
202 lines
6.9 KiB
TypeScript
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' },
|
|
]);
|
|
});
|
|
});
|