/** * 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 }; 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)'); }); 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'); }); });