Files
feuerwerk/tests/ui/math.test.ts
T
root 1b16a55ffc Round number fields to the precision they display
`decimals` only reached `formatNumber`, so it changed the label and nothing else: typing 640.5 into
a field with `decimals: 0` stored 640.5 and displayed 641. The two existing users got away with it
because rounding happened outside the widget (`normalizeFrameRect`, the slice dialog), which their
comments had to point out.

`NumberField.clampValue` now rounds before it clamps, and every path that sets a value goes through
it (typing, scrubbing, arrow keys, the constructor), so the stored number matches the shown one and
`min`/`max` stay exact. The rounding is `roundTo` in math.ts, which `snapToStep` already did inline.

With that safe, camera `View width`/`View height` and text `Font size` become integers – they were
showing 640.000 and 32.000. Everything else that looked integral is not: positions, sprite and
collider sizes and zoom are all scrubbed, and `z` is a summed coordinate (walk.ts), not a layer.

Verified remotely against the RTX 4070 host: 270 checks, all passed (step 10 gained a check that the
field text has no decimal point and survives a reopen). `npm run check` green, 407 tests.
2026-09-09 10:11:06 +02:00

125 lines
4.1 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
clamp,
centreY,
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', () => {
// The bug this replaces: a field with step 1 showed a stored 100.234 as "100", and
// committing that text wrote the rounded value back into the project.
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', () => {
// Why the field rounds instead of only formatting: a `decimals: 0` field used to display
// "641" while the project still held 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', () => {
// The bug: a scrub added fractional device pixels to an already fractional value, so
// dragging X produced values like 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);
});
});