tests: co-locate every Vitest suite next to its subject under src/

Eliminate the parallel `tests/` tree. Each `*.test.ts` / `*.test.tsx`
now sits in the same directory as the module it covers, mirroring its
filename (`Foo.ts` ↔ `Foo.test.ts`). The renderer-SDK mock used by
component / hook tests moves to `src/__mocks__/nitro-renderer.ts` and
the Vitest setup file becomes `src/test-setup.ts` — both still wired
through `vitest.config.mts` exactly as before, only the paths changed.

All 13 suites + 178/178 cases still pass. The production build is
unaffected: rollup only follows imports from `src/index.tsx` and never
crosses into `.test.ts` files, so test code is naturally tree-shaken
out of the bundle. `yarn build` output is byte-for-byte the same on
the user-facing chunks.

tsconfig drops the now-redundant `tests` include entry. CLAUDE.md
'Layout convention' replaces the old `tests/` row with three rows
documenting the new co-located convention, the `__mocks__/` directory
and the `test-setup.ts` entry; ARCHITECTURE.md picks up the same
update. The 'DO NOT CHANGE' qualifier on the layout is preserved —
this rewrite IS the change, decided deliberately to make tests a
first-class part of the source tree rather than a sibling project.
This commit is contained in:
simoleo89
2026-05-16 11:35:03 +02:00
parent eb8d87969d
commit 8b4308af16
19 changed files with 47 additions and 46 deletions
+165
View File
@@ -0,0 +1,165 @@
import { describe, expect, it } from 'vitest';
import { ColorUtils } from './ColorUtils';
import { FixedSizeStack } from './FixedSizeStack';
import { LocalizeFormattedNumber } from './LocalizeFormattedNumber';
describe('LocalizeFormattedNumber', () =>
{
it('returns "0" for zero / NaN / null / undefined', () =>
{
expect(LocalizeFormattedNumber(0)).toBe('0');
expect(LocalizeFormattedNumber(NaN)).toBe('0');
expect(LocalizeFormattedNumber(null)).toBe('0');
expect(LocalizeFormattedNumber(undefined as unknown as number)).toBe('0');
});
it('keeps numbers under 1000 unchanged', () =>
{
expect(LocalizeFormattedNumber(42)).toBe('42');
expect(LocalizeFormattedNumber(999)).toBe('999');
});
it('inserts a thin space every 3 digits for >=1000', () =>
{
expect(LocalizeFormattedNumber(1000)).toBe('1 000');
expect(LocalizeFormattedNumber(1_234_567)).toBe('1 234 567');
expect(LocalizeFormattedNumber(10_000_000)).toBe('10 000 000');
});
});
describe('ColorUtils', () =>
{
describe('makeColorHex', () =>
{
it('prepends "#" to the given color string', () =>
{
expect(ColorUtils.makeColorHex('ff0000')).toBe('#ff0000');
expect(ColorUtils.makeColorHex('abc')).toBe('#abc');
});
});
describe('makeColorNumberHex', () =>
{
it('pads to 6 hex chars and prepends "#"', () =>
{
expect(ColorUtils.makeColorNumberHex(0xff0000)).toBe('#ff0000');
expect(ColorUtils.makeColorNumberHex(0x00ff00)).toBe('#00ff00');
expect(ColorUtils.makeColorNumberHex(0)).toBe('#000000');
});
it('pads short hex values with leading zeros', () =>
{
expect(ColorUtils.makeColorNumberHex(0xff)).toBe('#0000ff');
expect(ColorUtils.makeColorNumberHex(1)).toBe('#000001');
});
});
describe('convertFromHex', () =>
{
it('parses a "#"-prefixed hex string to a number', () =>
{
expect(ColorUtils.convertFromHex('#ff0000')).toBe(0xff0000);
expect(ColorUtils.convertFromHex('#000000')).toBe(0);
expect(ColorUtils.convertFromHex('#ffffff')).toBe(0xffffff);
});
it('also handles strings without the leading "#"', () =>
{
expect(ColorUtils.convertFromHex('00ff00')).toBe(0x00ff00);
});
});
describe('int_to_8BitVals / eight_bitVals_to_int', () =>
{
it('roundtrips: int -> [a,r,g,b] -> int', () =>
{
const original = 0x12345678;
const [ a, b, c, d ] = ColorUtils.int_to_8BitVals(original);
expect(a).toBe(0x12);
expect(b).toBe(0x34);
expect(c).toBe(0x56);
expect(d).toBe(0x78);
expect(ColorUtils.eight_bitVals_to_int(a, b, c, d)).toBe(original);
});
it('roundtrips zero', () =>
{
const parts = ColorUtils.int_to_8BitVals(0);
expect(parts).toEqual([ 0, 0, 0, 0 ]);
expect(ColorUtils.eight_bitVals_to_int(0, 0, 0, 0)).toBe(0);
});
});
describe('int2rgb', () =>
{
it('produces rgba(r,g,b,1) for an RGB integer', () =>
{
expect(ColorUtils.int2rgb(0xff0000)).toBe('rgba(255,0,0,1)');
expect(ColorUtils.int2rgb(0x00ff00)).toBe('rgba(0,255,0,1)');
expect(ColorUtils.int2rgb(0x0000ff)).toBe('rgba(0,0,255,1)');
});
it('returns black for 0', () =>
{
expect(ColorUtils.int2rgb(0)).toBe('rgba(0,0,0,1)');
});
});
});
describe('FixedSizeStack', () =>
{
it('grows up to maxSize then overwrites the oldest entry', () =>
{
const stack = new FixedSizeStack(3);
stack.addValue(10);
stack.addValue(20);
stack.addValue(30);
expect(stack.getMax()).toBe(30);
expect(stack.getMin()).toBe(10);
// Capacity hit — 40 overwrites 10
stack.addValue(40);
expect(stack.getMin()).toBe(20);
expect(stack.getMax()).toBe(40);
// 50 overwrites 20
stack.addValue(50);
expect(stack.getMin()).toBe(30);
expect(stack.getMax()).toBe(50);
});
it('reset clears all values', () =>
{
const stack = new FixedSizeStack(2);
stack.addValue(100);
stack.addValue(200);
expect(stack.getMax()).toBe(200);
stack.reset();
stack.addValue(7);
expect(stack.getMax()).toBe(7);
expect(stack.getMin()).toBe(7);
});
it('getMax with maxSize > inserted entries returns the inserted value', () =>
{
// FixedSizeStack iterates the whole maxSize window but the
// unfilled slots are `undefined` which fail `> currentMax`, so
// the inserted value wins.
const stack = new FixedSizeStack(5);
stack.addValue(42);
expect(stack.getMax()).toBe(42);
});
it('getMax on an empty stack returns Number.MIN_VALUE', () =>
{
const stack = new FixedSizeStack(3);
expect(stack.getMax()).toBe(Number.MIN_VALUE);
});
});
+194
View File
@@ -0,0 +1,194 @@
import { describe, expect, it } from 'vitest';
import { CloneObject } from './CloneObject';
import { ConvertSeconds } from './ConvertSeconds';
import { LocalizeShortNumber } from './LocalizeShortNumber';
import { GetWiredTimeLocale } from '../wired/GetWiredTimeLocale';
import { WiredDateToString } from '../wired/WiredDateToString';
import { getPrefixFontStyle, parsePrefixColors, PRESET_PREFIX_FONTS } from './PrefixUtils';
describe('ConvertSeconds', () =>
{
it('formats zero seconds as the dd:hh:mm:ss zero string', () =>
{
expect(ConvertSeconds(0)).toBe('00:00:00:00');
});
it('formats one minute correctly', () =>
{
expect(ConvertSeconds(60)).toBe('00:00:01:00');
});
it('formats one hour correctly', () =>
{
expect(ConvertSeconds(3600)).toBe('00:01:00:00');
});
it('formats one day correctly', () =>
{
expect(ConvertSeconds(86400)).toBe('01:00:00:00');
});
it('formats a mixed value (1d 2h 3m 4s)', () =>
{
expect(ConvertSeconds(86400 + 2 * 3600 + 3 * 60 + 4)).toBe('01:02:03:04');
});
it('pads single-digit components with a leading zero', () =>
{
expect(ConvertSeconds(9)).toBe('00:00:00:09');
});
});
describe('LocalizeShortNumber', () =>
{
it('returns "0" for zero, null, undefined, and NaN', () =>
{
expect(LocalizeShortNumber(0)).toBe('0');
expect(LocalizeShortNumber(NaN)).toBe('0');
expect(LocalizeShortNumber(null)).toBe('0');
expect(LocalizeShortNumber(undefined as unknown as number)).toBe('0');
});
it('keeps numbers safely under 1000 unchanged (returns as-is)', () =>
{
expect(LocalizeShortNumber(42)).toBe('42');
// Anything that rounds to >= 1.0K (i.e. >= 950) crosses into the K bucket
expect(LocalizeShortNumber(949)).toBe('949');
});
it('rounds 950..999 up into the K bucket (documented quirk)', () =>
{
expect(LocalizeShortNumber(950)).toBe('1K');
expect(LocalizeShortNumber(999)).toBe('1K');
});
it('uses K for thousands', () =>
{
expect(LocalizeShortNumber(1500)).toBe('1.5K');
expect(LocalizeShortNumber(12_345)).toBe('12.3K');
});
it('uses M for millions', () =>
{
expect(LocalizeShortNumber(2_500_000)).toBe('2.5M');
});
it('uses B for billions', () =>
{
expect(LocalizeShortNumber(3_700_000_000)).toBe('3.7B');
});
it('preserves the sign for negative values', () =>
{
expect(LocalizeShortNumber(-1500)).toBe('-1.5K');
expect(LocalizeShortNumber(-2_500_000)).toBe('-2.5M');
});
});
describe('CloneObject', () =>
{
it('returns primitives unchanged', () =>
{
expect(CloneObject(42)).toBe(42);
expect(CloneObject('hello')).toBe('hello');
expect(CloneObject(null)).toBe(null);
expect(CloneObject(undefined)).toBe(undefined);
});
it('returns a new object instance for object inputs', () =>
{
const original = { a: 1, b: 'two' };
const copy = CloneObject(original);
expect(copy).not.toBe(original);
expect(copy).toEqual(original);
});
it('preserves enumerable own keys', () =>
{
const original = { x: 1, y: 2, z: 3 };
const copy = CloneObject(original);
expect(copy.x).toBe(1);
expect(copy.y).toBe(2);
expect(copy.z).toBe(3);
});
});
describe('GetWiredTimeLocale', () =>
{
// The renderer encodes time as `value = seconds * 2` so even values
// are whole seconds, odd values are half-seconds.
it('returns "0" for value 0', () =>
{
expect(GetWiredTimeLocale(0)).toBe('0');
});
it('returns whole seconds for even values', () =>
{
expect(GetWiredTimeLocale(2)).toBe('1');
expect(GetWiredTimeLocale(10)).toBe('5');
expect(GetWiredTimeLocale(60)).toBe('30');
});
it('returns half-second formatting for odd values', () =>
{
expect(GetWiredTimeLocale(1)).toBe('0.5');
expect(GetWiredTimeLocale(3)).toBe('1.5');
expect(GetWiredTimeLocale(11)).toBe('5.5');
});
});
describe('WiredDateToString', () =>
{
it('zero-pads single-digit month / day / hour / minute', () =>
{
const d = new Date(2024, 0, 5, 7, 9); // Jan 5, 2024, 07:09
expect(WiredDateToString(d)).toBe('2024/01/05 07:09');
});
it('formats two-digit values without extra padding', () =>
{
const d = new Date(2024, 11, 31, 23, 59); // Dec 31, 2024, 23:59
expect(WiredDateToString(d)).toBe('2024/12/31 23:59');
});
});
describe('PrefixUtils.parsePrefixColors', () =>
{
it('returns an empty array when text or colors are empty', () =>
{
expect(parsePrefixColors('', '#fff')).toEqual([]);
expect(parsePrefixColors('abc', '')).toEqual([]);
});
it('maps each text character to the nth color', () =>
{
expect(parsePrefixColors('ab', '#f00,#0f0')).toEqual([ '#f00', '#0f0' ]);
});
it('reuses the last color when the text is longer than the color list', () =>
{
expect(parsePrefixColors('abcd', '#f00,#0f0')).toEqual([ '#f00', '#0f0', '#0f0', '#0f0' ]);
});
});
describe('PrefixUtils.getPrefixFontStyle', () =>
{
it('returns an empty object for the default (empty) font id', () =>
{
expect(getPrefixFontStyle('')).toEqual({});
});
it('returns a fontFamily for a known preset', () =>
{
const out = getPrefixFontStyle('pixel');
expect(out.fontFamily).toBe(PRESET_PREFIX_FONTS.find(p => p.id === 'pixel')?.family);
});
it('returns an empty object for an unknown font id', () =>
{
expect(getPrefixFontStyle('does-not-exist')).toEqual({});
});
});
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, it, vi } from 'vitest';
/**
* Mock LocalizeText (which transitively imports @nitrots/nitro-renderer)
* with a deterministic stub. The stub returns `key|amount` so each test
* can assert both the bucket FriendlyTime chose AND the value it computed.
*/
vi.mock('./LocalizeText', () => ({
LocalizeText: (key: string, _params?: string[], replacements?: string[]) =>
`${ key }|${ replacements?.[0] ?? '' }`
}));
import { FriendlyTime } from './FriendlyTime';
const MINUTE = 60;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const MONTH = 30 * DAY;
const YEAR = 365 * DAY;
describe('FriendlyTime.format', () =>
{
it('uses the seconds bucket for small values', () =>
{
expect(FriendlyTime.format(5)).toBe('friendlytime.seconds|5');
expect(FriendlyTime.format(0)).toBe('friendlytime.seconds|0');
});
it('uses the minutes bucket once we cross 3 * 60s (default threshold)', () =>
{
expect(FriendlyTime.format(4 * MINUTE)).toBe('friendlytime.minutes|4');
expect(FriendlyTime.format(10 * MINUTE)).toBe('friendlytime.minutes|10');
});
it('uses the hours bucket above 3 * HOUR', () =>
{
expect(FriendlyTime.format(4 * HOUR)).toBe('friendlytime.hours|4');
});
it('uses the days bucket above 3 * DAY', () =>
{
expect(FriendlyTime.format(5 * DAY)).toBe('friendlytime.days|5');
});
it('uses the months bucket above 3 * MONTH', () =>
{
expect(FriendlyTime.format(4 * MONTH)).toBe('friendlytime.months|4');
});
it('uses the years bucket above 3 * YEAR', () =>
{
expect(FriendlyTime.format(4 * YEAR)).toBe('friendlytime.years|4');
});
it('rounds half-hours correctly inside the hours bucket', () =>
{
// 4.5 hours -> rounds to 5
expect(FriendlyTime.format((4 * HOUR) + (30 * MINUTE))).toBe('friendlytime.hours|5');
});
it('threshold=1 lets the larger bucket win sooner', () =>
{
// With default threshold=3, 90s would stay in "seconds"; with threshold=1
// it crosses into "minutes" (90s > 1*60s).
expect(FriendlyTime.format(90, '', 1)).toBe('friendlytime.minutes|2');
});
it('key suffix is appended to the bucket key', () =>
{
// Useful for plurals / variants ('s' for singular fallback, etc.)
expect(FriendlyTime.format(5, '.foo')).toBe('friendlytime.seconds.foo|5');
expect(FriendlyTime.format(4 * HOUR, '.foo')).toBe('friendlytime.hours.foo|4');
});
});
describe('FriendlyTime.shortFormat', () =>
{
it('uses the .short variant of each bucket', () =>
{
expect(FriendlyTime.shortFormat(5)).toBe('friendlytime.seconds.short|5');
expect(FriendlyTime.shortFormat(4 * MINUTE)).toBe('friendlytime.minutes.short|4');
expect(FriendlyTime.shortFormat(4 * HOUR)).toBe('friendlytime.hours.short|4');
expect(FriendlyTime.shortFormat(5 * DAY)).toBe('friendlytime.days.short|5');
expect(FriendlyTime.shortFormat(4 * MONTH)).toBe('friendlytime.months.short|4');
expect(FriendlyTime.shortFormat(4 * YEAR)).toBe('friendlytime.years.short|4');
});
it('respects the optional key suffix and threshold', () =>
{
expect(FriendlyTime.shortFormat(2 * MINUTE, '.bar', 1)).toBe('friendlytime.minutes.short.bar|2');
});
});
describe('FriendlyTime.getLocalization', () =>
{
it('formats an arbitrary key and amount with the (amount, AMOUNT) replacements', () =>
{
expect(FriendlyTime.getLocalization('whatever', 42)).toBe('whatever|42');
});
});