Files
feuerwerk/tests/ui/tooltip.test.ts
T
adminandClaude Opus 5.5 7a952cb20d Start paused, step one frame and break from a behavior
Finding out what a game does in its first frames, or right at one
spot of a flow, needed a quick hand on the pause button. Now the
editor can start a play paused, step it frame by frame and let a
behavior pause it.

- The pause button can be pressed in edit mode. Then Play starts
  paused, and every further Play does too until it is released. A
  reload of the page clears it.
- A third button, Next frame, right of Pause, or Ctrl+Shift+Enter.
  It works only while paused and runs exactly one frame of 1/60 s,
  whatever the frame took on screen. Hierarchy and inspector follow.
- The Break node pauses the play like the button, at the end of the
  frame it ran in. The status line names the behavior's object. It
  is the editor's pause, not Pause game.
- Break reaches the editor through a service only the editor hands
  to a play. In play.html and Run in editor the node does nothing.
- Restart game keeps the running play's state. Start paused only
  counts when Play is pressed.
- Wiki Concepts, Behaviors, Keyboard: the buttons, the node and the
  shortcut.

Not tried: sound, free-running videos and a running recording while
stepping. Sound and videos stay held during the pause.

Tests:
- tsc, eslint and Prettier are clean.
- Unit pause, node-catalog, tooltip: 465 green. svg-icons now
  expects the step icon too.
- e2e 18 and 46 on stein2 (melektaus-atx was off): ALL PASSED,
  63 checks, 7 new in 18j. A step moves the box by 1.667 px at
  100 px/s, Break stops the play with "Break in Breaker".
- Counter-checks: a session that ignores the step turns 18j red. A
  Break that does not report its object turns the unit test red.
- Gate on stein2: tsc, eslint and Prettier clean, 305 files,
  4722 tests, 6 more than before. One was red at first, the icon
  list of the top bar did not know step yet. Fixed in
  svg-icons.test.ts and run again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 06:02:35 +02:00

399 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Tooltips (#209): where the sign goes, how long the pointer has to rest, what takes it away again,
* and what the buttons without a caption say.
*/
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { CanvasTextMetrics, Container } from 'pixi.js';
import { CommandStack } from '../../src/core/commands/CommandStack';
import { rowActionTip } from '../../src/editor/inspector/fieldFactory';
import { toolbarTip } from '../../src/editor/panels/toolbarTips';
import { anchorName } from '../../src/editor/pixelart/ResizeCanvasDialog';
import { TabButton } from '../../src/editor/TabButton';
import {
Button,
placeTooltip,
SortableList,
TOOLTIP_DELAY_MS,
TOOLTIP_GAP,
TooltipLayer,
tooltips,
withShortcut,
} from '../../src/ui';
import { makeSpriteScene } from '../helpers';
vi.mock('../../src/ui/svg', () => ({ drawSvg: () => undefined, tintSvg: (svg: string) => svg }));
/** Seven pixels a letter, so a text has a width a test can reason about. */
beforeAll(() => {
CanvasTextMetrics.measureText = ((text: string) => ({
width: text.length * 7,
height: 14,
lines: [text],
lineWidths: [text.length * 7],
})) as never;
});
const SCREEN = { w: 800, h: 600 };
describe('where the sign goes', () => {
it('is under the control, flush with its left edge', () => {
expect(placeTooltip({ x: 100, y: 10, w: 24, h: 24 }, { w: 80, h: 20 }, SCREEN)).toEqual({
x: 100,
y: 34 + TOOLTIP_GAP,
});
});
it('turns to the left at the right border: flush with the right edge of the control', () => {
const at = placeTooltip({ x: 770, y: 10, w: 24, h: 24 }, { w: 80, h: 20 }, SCREEN);
expect(at.x).toBe(770 + 24 - 80);
expect(at.y).toBe(34 + TOOLTIP_GAP);
});
it('turns upwards at the bottom border', () => {
const at = placeTooltip({ x: 100, y: 570, w: 24, h: 24 }, { w: 80, h: 20 }, SCREEN);
expect(at).toEqual({ x: 100, y: 570 - TOOLTIP_GAP - 20 });
});
it('still fits exactly against the border without turning', () => {
expect(placeTooltip({ x: 720, y: 10, w: 24, h: 24 }, { w: 80, h: 20 }, SCREEN).x).toBe(720);
expect(placeTooltip({ x: 100, y: 550, w: 24, h: 24 }, { w: 80, h: 20 }, SCREEN).y).toBe(580);
});
it('is pushed back inside where it fits neither way', () => {
const wide = placeTooltip({ x: 10, y: 10, w: 24, h: 24 }, { w: 900, h: 20 }, SCREEN);
expect(wide.x).toBe(0);
const narrowScreen = placeTooltip({ x: 60, y: 10, w: 24, h: 24 }, { w: 80, h: 20 }, { w: 100, h: 600 });
expect(narrowScreen.x).toBe(4);
const low = placeTooltip({ x: 10, y: 5, w: 24, h: 590 }, { w: 80, h: 20 }, SCREEN);
expect(low.y).toBe(0);
});
it('lands on whole pixels', () => {
const at = placeTooltip({ x: 10.5, y: 10.25, w: 24, h: 24 }, { w: 80, h: 20 }, SCREEN);
expect(Number.isInteger(at.x) && Number.isInteger(at.y)).toBe(true);
});
});
describe('the wait and what ends it', () => {
let overlay: Container;
let layer: TooltipLayer;
const owner = {};
const anchor = { x: 100, y: 10, w: 24, h: 24 };
const says = (text: string) => () => ({ text, anchor });
beforeEach(() => {
vi.useFakeTimers();
overlay = new Container();
layer = new TooltipLayer(() => ({ overlay, screen: { width: 800, height: 600 } }));
});
afterEach(() => vi.useRealTimers());
it('shows nothing before the pointer has rested, and the sign after', () => {
layer.point(owner, '', says('Menu'), { x: 105, y: 15 });
vi.advanceTimersByTime(TOOLTIP_DELAY_MS - 1);
expect(layer.shown).toBeNull();
expect(overlay.children.length).toBe(0);
vi.advanceTimersByTime(1);
expect(layer.shown?.text).toBe('Menu');
expect(overlay.children.length).toBe(1);
});
it('sizes the sign from its text and puts it under the control', () => {
layer.point(owner, '', says('Menu'), { x: 105, y: 15 });
vi.advanceTimersByTime(TOOLTIP_DELAY_MS);
expect(layer.shown?.box).toEqual({ x: 100, y: 34 + TOOLTIP_GAP, w: 4 * 7 + 12, h: 14 + 6 });
expect(layer.shown?.anchor).toEqual(anchor);
});
it('starts the wait over with every real move', () => {
layer.point(owner, '', says('Menu'), { x: 105, y: 15 });
vi.advanceTimersByTime(400);
layer.point(owner, '', says('Menu'), { x: 106, y: 15 });
vi.advanceTimersByTime(400);
expect(layer.shown).toBeNull();
vi.advanceTimersByTime(100);
expect(layer.shown?.text).toBe('Menu');
});
it('does not start over for a move that went nowhere', () => {
layer.point(owner, '', says('Menu'), { x: 105, y: 15 });
vi.advanceTimersByTime(400);
layer.point(owner, '', says('Menu'), { x: 105, y: 15 });
vi.advanceTimersByTime(100);
expect(layer.shown?.text).toBe('Menu');
});
it('keeps the sign while the pointer moves on the same control', () => {
layer.point(owner, '', says('Menu'), { x: 105, y: 15 });
vi.advanceTimersByTime(TOOLTIP_DELAY_MS);
layer.point(owner, '', says('Menu'), { x: 110, y: 20 });
expect(layer.shown?.text).toBe('Menu');
// And without a second wait running behind it.
expect(vi.getTimerCount()).toBe(0);
});
it('asks for the text when the wait is over, not when it began', () => {
let text = 'Undo Move object';
layer.point(owner, '', () => ({ text, anchor }), { x: 105, y: 15 });
text = 'Undo Rename object';
vi.advanceTimersByTime(TOOLTIP_DELAY_MS);
expect(layer.shown?.text).toBe('Undo Rename object');
});
it('shows nothing for a control that has nothing to say by then', () => {
layer.point(owner, '', () => null, { x: 105, y: 15 });
vi.advanceTimersByTime(TOOLTIP_DELAY_MS);
expect(layer.shown).toBeNull();
layer.point({}, '', says(''), { x: 105, y: 15 });
vi.advanceTimersByTime(TOOLTIP_DELAY_MS);
expect(layer.shown).toBeNull();
});
it('takes the sign away when the pointer leaves', () => {
layer.point(owner, '', says('Menu'), { x: 105, y: 15 });
vi.advanceTimersByTime(TOOLTIP_DELAY_MS);
layer.leave(owner);
expect(layer.shown).toBeNull();
expect(overlay.children.length).toBe(0);
});
it('drops a wait when the pointer leaves before it is over', () => {
layer.point(owner, '', says('Menu'), { x: 105, y: 15 });
layer.leave(owner);
expect(vi.getTimerCount()).toBe(0);
vi.advanceTimersByTime(TOOLTIP_DELAY_MS * 2);
expect(layer.shown).toBeNull();
});
it('ignores a leave from a control that is not the one under the pointer', () => {
layer.point(owner, '', says('Menu'), { x: 105, y: 15 });
vi.advanceTimersByTime(TOOLTIP_DELAY_MS);
layer.leave({});
expect(layer.shown?.text).toBe('Menu');
});
it('takes the sign away on a press and stays quiet on that control', () => {
layer.point(owner, '', says('Menu'), { x: 105, y: 15 });
vi.advanceTimersByTime(TOOLTIP_DELAY_MS);
layer.dismiss();
expect(layer.shown).toBeNull();
layer.point(owner, '', says('Menu'), { x: 107, y: 15 });
vi.advanceTimersByTime(TOOLTIP_DELAY_MS * 2);
expect(layer.shown).toBeNull();
});
it('a press during the wait cancels it too', () => {
layer.point(owner, '', says('Menu'), { x: 105, y: 15 });
vi.advanceTimersByTime(200);
layer.dismiss();
vi.advanceTimersByTime(TOOLTIP_DELAY_MS);
expect(layer.shown).toBeNull();
});
it('speaks again once the pointer has left and come back', () => {
layer.point(owner, '', says('Menu'), { x: 105, y: 15 });
layer.dismiss();
layer.leave(owner);
layer.point(owner, '', says('Menu'), { x: 105, y: 15 });
vi.advanceTimersByTime(TOOLTIP_DELAY_MS);
expect(layer.shown?.text).toBe('Menu');
});
it('moves on to the next control, with a wait of its own', () => {
const other = {};
layer.point(owner, '', says('Undo'), { x: 105, y: 15 });
vi.advanceTimersByTime(TOOLTIP_DELAY_MS);
layer.point(other, '', says('Redo'), { x: 130, y: 15 });
expect(layer.shown).toBeNull();
vi.advanceTimersByTime(TOOLTIP_DELAY_MS);
expect(layer.shown?.text).toBe('Redo');
});
it('tells the parts of one control apart by their key', () => {
layer.point(owner, 'eye:0', says('Hide'), { x: 105, y: 15 });
vi.advanceTimersByTime(TOOLTIP_DELAY_MS);
layer.point(owner, 'cross:0', says('Remove'), { x: 130, y: 15 });
expect(layer.shown).toBeNull();
vi.advanceTimersByTime(TOOLTIP_DELAY_MS);
expect(layer.shown?.text).toBe('Remove');
});
it('lies above whatever was put on the overlay since', () => {
layer.point(owner, '', says('Menu'), { x: 105, y: 15 });
vi.advanceTimersByTime(TOOLTIP_DELAY_MS);
layer.leave(owner);
const menu = overlay.addChild(new Container());
layer.point(owner, '', says('Menu'), { x: 105, y: 15 });
vi.advanceTimersByTime(TOOLTIP_DELAY_MS);
expect(overlay.children.indexOf(menu)).toBe(0);
expect(overlay.children.length).toBe(2);
});
});
describe('what a button says', () => {
it('is the tooltip it was given, asked anew each time', () => {
let step = 'Move object';
const button = new Button('', { width: 24, tooltip: () => `Undo ${step}` });
expect(button.tooltipText()).toBe('Undo Move object');
step = 'Paint tiles';
expect(button.tooltipText()).toBe('Undo Paint tiles');
expect(new Button('', { width: 24, tooltip: 'Menu' }).tooltipText()).toBe('Menu');
});
it('is nothing for a button whose caption says it all', () => {
const button = new Button('Fit');
button.resize(button.measure().w, 24);
expect(button.tooltipText()).toBe('');
});
it('is the whole name where a tab had to cut it short', () => {
const tab = new TabButton('A very long name of an asset');
tab.resize(80, 24);
expect(tab.shownText).not.toBe(tab.text);
expect(tab.tooltipText()).toBe('A very long name of an asset');
tab.resize(400, 24);
expect(tab.tooltipText()).toBe('');
});
it('reports a rest to the shared layer and a leave when the pointer goes', () => {
const point = vi.spyOn(tooltips, 'point').mockImplementation(() => undefined);
const leave = vi.spyOn(tooltips, 'leave').mockImplementation(() => undefined);
const button = new Button('', { width: 24, tooltip: 'Menu' });
button.resize(24, 24);
button.position.set(40, 8);
button.emit('pointerover', { global: { x: 45, y: 12 } } as never);
expect(point).toHaveBeenCalledTimes(1);
const [owner, key, target] = point.mock.calls[0]!;
expect(owner).toBe(button);
expect(key).toBe('');
expect(target()).toEqual({ text: 'Menu', anchor: { x: 40, y: 8, w: 24, h: 24 } });
button.emit('pointerout', {} as never);
expect(leave).toHaveBeenCalledWith(button);
// A button with nothing to say asks for no sign at all.
point.mockClear();
const plain = new Button('Fit');
plain.resize(60, 24);
plain.emit('pointermove', { global: { x: 5, y: 5 } } as never);
expect(point).not.toHaveBeenCalled();
point.mockRestore();
leave.mockRestore();
});
});
describe('the marks of a list row', () => {
it('say what a press does, each with the box of its own mark', () => {
const point = vi.spyOn(tooltips, 'point').mockImplementation(() => undefined);
const leave = vi.spyOn(tooltips, 'leave').mockImplementation(() => undefined);
const list = new SortableList({
onPick: () => undefined,
onMove: () => undefined,
onToggleHidden: () => undefined,
onDuplicate: () => undefined,
onRemove: () => undefined,
onAdd: () => undefined,
onRename: () => undefined,
title: 'Layers',
});
list.setItems(
[
{ id: 'a', label: 'A' },
{ id: 'b', label: 'B', hidden: true },
],
[],
);
list.resize(200, list.measure().h);
const head = list.measure().h - 60;
const over = (x: number, y: number) => {
point.mockClear();
leave.mockClear();
list.emit('pointermove', { global: { x, y } } as never);
const call = point.mock.calls[0];
return call ? { key: call[1], ...call[2]()! } : null;
};
// Rows are 30 high; from the right: cross 24, duplicate 22, eye 26.
expect(over(190, head + 15)).toEqual({
key: 'cross:0',
text: 'Remove',
anchor: { x: 176, y: head, w: 24, h: 30 },
});
expect(over(165, head + 15)).toMatchObject({ key: 'duplicate:0', text: 'Duplicate' });
expect(over(140, head + 15)).toMatchObject({ key: 'eye:0', text: 'Hide' });
expect(over(140, head + 45)).toMatchObject({
key: 'eye:1',
text: 'Show',
anchor: { x: 128, y: head + 30, w: 26, h: 30 },
});
expect(over(190, head / 2)).toMatchObject({ key: 'add:-1', text: 'Add' });
// The name and the band have words of their own.
expect(over(40, head + 15)).toBeNull();
expect(leave).toHaveBeenCalledWith(list);
expect(over(40, head / 2)).toBeNull();
point.mockRestore();
leave.mockRestore();
});
});
describe('what the buttons without a caption say', () => {
const idle = { undo: null, redo: null, editing: true, paused: false, startPaused: false };
it('writes a shortcut behind the name, and nothing where there is none', () => {
expect(withShortcut('Undo', 'Ctrl+Z')).toBe('Undo (Ctrl+Z)');
expect(withShortcut('Menu')).toBe('Menu');
});
it('names the step undo and redo would take', () => {
expect(toolbarTip('undo', { ...idle, undo: 'Paint tiles' })).toBe('Undo Paint tiles (Ctrl+Z)');
expect(toolbarTip('redo', { ...idle, redo: 'Move object' })).toBe('Redo Move object (Ctrl+Y)');
expect(toolbarTip('undo', idle)).toBe('Undo (Ctrl+Z)');
expect(toolbarTip('redo', idle)).toBe('Redo (Ctrl+Y)');
});
it('follows the run: play becomes stop, pause becomes resume', () => {
expect(toolbarTip('menu', idle)).toBe('Menu');
expect(toolbarTip('play', idle)).toBe('Play (Ctrl+Enter)');
expect(toolbarTip('play', { ...idle, editing: false })).toBe('Stop (Esc)');
expect(toolbarTip('pause', { ...idle, editing: false })).toBe('Pause (Ctrl+Enter)');
expect(toolbarTip('pause', { ...idle, editing: false, paused: true })).toBe('Resume (Ctrl+Enter)');
expect(toolbarTip('step', { ...idle, editing: false, paused: true })).toBe(
'Next frame (Ctrl+Shift+Enter)',
);
});
it('before Play, pause says whether Play will start paused', () => {
expect(toolbarTip('pause', idle)).toBe('Start paused');
expect(toolbarTip('pause', { ...idle, startPaused: true })).toBe(
'Play starts paused – click to start running',
);
});
it('takes the names from the command stack, the last step first', () => {
const { project } = makeSpriteScene();
const stack = new CommandStack(project);
const step = (label: string) => ({ label, execute: () => undefined, undo: () => undefined });
expect(stack.undoLabel).toBeNull();
expect(stack.redoLabel).toBeNull();
stack.execute(step('First'));
stack.execute(step('Second'));
expect(stack.undoLabel).toBe('Second');
stack.undo();
expect(stack.undoLabel).toBe('First');
expect(stack.redoLabel).toBe('Second');
});
it('gives a row action the name of the one thing it does, else that of its group', () => {
const action = (label: string) => ({ label, onSelect: () => undefined });
expect(rowActionTip('Reset', [action('Reset to the asset')])).toBe('Reset to the asset');
expect(rowActionTip('Reset', [action('Reset to the asset'), action('Reset all')])).toBe('Reset');
expect(rowActionTip('Reset', [])).toBe('Reset');
});
it('names the nine anchor squares', () => {
expect(anchorName({ x: 0, y: 0 })).toBe('Top left');
expect(anchorName({ x: 0.5, y: 0 })).toBe('Top centre');
expect(anchorName({ x: 1, y: 0.5 })).toBe('Middle right');
expect(anchorName({ x: 0.5, y: 0.5 })).toBe('Centre');
expect(anchorName({ x: 0, y: 1 })).toBe('Bottom left');
});
});