Split GraphCanvas (1076 -> 831 lines) into EdgeLayer and graphMenus; the port and marker menus were the same list and are now one function. Value specs of the inspector and the list tab share valueSpecs.ts, and the remaining copies (compound commands, type glyphs, typed asset lookup, name rule, play/editor world setup, marquee) are merged. Four copies had already drifted apart; merging them fixes: - "Disconnect N links" in the port menu is one undo step, not N - "+ Add collider" is hidden on a placed container instance, as in the hierarchy menu, instead of always failing - a bool property coerced from the text "false" becomes false - a port command on an unknown node type reports instead of crashing Comment-only edits leave the bundle byte-identical. README corrected in 35 places (step limit, jump-run clock, suite duration, 409 body, ...). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
136 lines
4.4 KiB
TypeScript
136 lines
4.4 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
clamp,
|
|
centreY,
|
|
formatBytes,
|
|
formatNumber,
|
|
NUMBER_DECIMALS,
|
|
roundTo,
|
|
snapToStep,
|
|
zoomFactor,
|
|
} from '../../src/ui/math';
|
|
|
|
describe('clamp', () => {
|
|
it('keeps a value inside the range', () => {
|
|
expect(clamp(5, 0, 10)).toBe(5);
|
|
expect(clamp(-1, 0, 10)).toBe(0);
|
|
expect(clamp(11, 0, 10)).toBe(10);
|
|
});
|
|
|
|
it('lets the lower bound win when the range is inverted', () => {
|
|
// A popup wider than the screen: pinning it to the left edge beats pinning it to the right.
|
|
expect(clamp(5, 10, 0)).toBe(10);
|
|
});
|
|
});
|
|
|
|
describe('centreY', () => {
|
|
it('centres on whole pixels', () => {
|
|
expect(centreY(30, 20)).toBe(5);
|
|
expect(centreY(31, 20)).toBe(6);
|
|
});
|
|
});
|
|
|
|
describe('formatNumber', () => {
|
|
it('shows three decimals by default', () => {
|
|
expect(NUMBER_DECIMALS).toBe(3);
|
|
expect(formatNumber(100)).toBe('100.000');
|
|
expect(formatNumber(0.5)).toBe('0.500');
|
|
expect(formatNumber(-12)).toBe('-12.000');
|
|
});
|
|
|
|
it('never hides a fractional value behind a whole number', () => {
|
|
// A step-1 field showing 100.234 as "100" would write the rounded value back on commit.
|
|
expect(formatNumber(100.234)).toBe('100.234');
|
|
expect(formatNumber(0.001)).toBe('0.001');
|
|
});
|
|
|
|
it('rounds to the last shown place', () => {
|
|
expect(formatNumber(1.2345)).toBe('1.234');
|
|
expect(formatNumber(1.2356)).toBe('1.236');
|
|
});
|
|
|
|
it('drops the minus sign from a value that displays as zero', () => {
|
|
expect(formatNumber(-0)).toBe('0.000');
|
|
expect(formatNumber(-0.0001)).toBe('0.000');
|
|
});
|
|
|
|
it('honours an explicit precision', () => {
|
|
expect(formatNumber(1.25, 1)).toBe('1.3');
|
|
expect(formatNumber(1.25, 0)).toBe('1');
|
|
});
|
|
|
|
it('shows a field of integral values without a decimal point at all', () => {
|
|
// What `NumberFieldOptions.decimals: 0` buys: a pixel index reads "33", not "33.000".
|
|
expect(formatNumber(33, 0)).toBe('33');
|
|
expect(formatNumber(-4, 0)).toBe('-4');
|
|
// The minus-zero rule holds at every precision, not just the default one.
|
|
expect(formatNumber(-0.4, 0)).toBe('0');
|
|
});
|
|
});
|
|
|
|
describe('roundTo', () => {
|
|
it('keeps a value at the precision its field shows', () => {
|
|
// The field rounds, not just formats, so a `decimals: 0` field never shows "641" over a stored 640.5.
|
|
expect(roundTo(640.5, 0)).toBe(641);
|
|
expect(roundTo(32.4, 0)).toBe(32);
|
|
expect(roundTo(1.2345)).toBe(1.234);
|
|
});
|
|
|
|
it('removes binary-float noise', () => {
|
|
expect(roundTo(0.30000000000000004)).toBe(0.3);
|
|
});
|
|
|
|
it('passes non-finite values through', () => {
|
|
expect(roundTo(NaN)).toBeNaN();
|
|
expect(roundTo(Infinity)).toBe(Infinity);
|
|
});
|
|
});
|
|
|
|
describe('snapToStep', () => {
|
|
it('lands on whole numbers for a step-1 field', () => {
|
|
// A scrub adds fractional device pixels; without snapping X would land on 100.667 instead of 101.
|
|
expect(snapToStep(100.667, 1)).toBe(101);
|
|
expect(snapToStep(100.333, 1)).toBe(100);
|
|
expect(snapToStep(-3.5, 1)).toBe(-3);
|
|
});
|
|
|
|
it('uses the increment as the grid, so a fine drag keeps its precision', () => {
|
|
// Alt halves the step to 0.1: snapping must not swallow the fine adjustment.
|
|
expect(snapToStep(1.24, 0.1)).toBe(1.2);
|
|
expect(snapToStep(1.26, 0.1)).toBe(1.3);
|
|
expect(snapToStep(3.0000000000000004, 0.1)).toBe(3);
|
|
});
|
|
|
|
it('passes values through when there is no grid', () => {
|
|
expect(snapToStep(1.234, 0)).toBe(1.234);
|
|
expect(snapToStep(1.234, -1)).toBe(1.234);
|
|
expect(snapToStep(NaN, 1)).toBeNaN();
|
|
});
|
|
});
|
|
|
|
describe('zoomFactor', () => {
|
|
it('zooms in for a negative delta and out for a positive one', () => {
|
|
expect(zoomFactor(-100)).toBeGreaterThan(1);
|
|
expect(zoomFactor(100)).toBeLessThan(1);
|
|
expect(zoomFactor(0)).toBe(1);
|
|
});
|
|
|
|
it('returns exactly to the starting zoom after scrolling back', () => {
|
|
// Why exponential: the scene view and the graph editor both rely on this cancelling out.
|
|
expect(1 * zoomFactor(-120) * zoomFactor(120)).toBeCloseTo(1, 12);
|
|
});
|
|
});
|
|
|
|
describe('formatBytes', () => {
|
|
it('names a small file in bytes, so a sprite never reads as "0 KB"', () => {
|
|
expect(formatBytes(400)).toBe('400 B');
|
|
expect(formatBytes(0)).toBe('0 B');
|
|
});
|
|
|
|
it('switches to KB and MB at the round thresholds', () => {
|
|
expect(formatBytes(1024)).toBe('1 KB');
|
|
expect(formatBytes(1024 * 1024)).toBe('1.0 MB');
|
|
expect(formatBytes(1536 * 1024)).toBe('1.5 MB');
|
|
});
|
|
});
|