import { describe, it, expect } from 'vitest'; import { Frequency } from './frequency'; // Branding is compile-time: construction and forgery must not type-check. // Checked by `npm run check`, inert at runtime. function _assertUnforgeable(): void { // @ts-expect-error — Frequency's constructor is private new Frequency('5/2w'); // @ts-expect-error — the private brand makes object literals non-assignable const forged: Frequency = { count: 5, length: 2, unit: 'w' }; void forged; } void _assertUnforgeable; describe('Frequency grammar', () => { it('accepts interval specs', () => { for (const spec of ['1d', '2w', '3m', '1y', '7d', '07d', '180d']) { expect(Frequency.safeParse(spec)?.toSpec()).toBe(spec); } }); it('accepts quota specs with an explicit period length', () => { for (const spec of ['5/2w', '5/1w', '1/1d', '2/3m', '1/1y', '5/14d']) { expect(Frequency.safeParse(spec)?.toSpec()).toBe(spec); } }); it('normalizes case and surrounding whitespace only', () => { expect(Frequency.safeParse(' 5/2W ')?.toSpec()).toBe('5/2w'); expect(Frequency.safeParse('2W')?.toSpec()).toBe('2w'); expect(Frequency.safeParse('07D')?.toSpec()).toBe('07d'); }); it('rejects invalid specs', () => { const invalid = [ '', '2', 'w', '2x', '-1d', '+2w', '1.5d', '0d', '00d', '5/w', '5/14', '5/2', '2w/5', '0/2w', '5/0w', '5 /2w', '1/2/3w', '2w', ]; for (const spec of invalid) { expect(Frequency.safeParse(spec), spec).toBeNull(); expect(() => Frequency.parse(spec), spec).toThrow(); } }); }); describe('spec round-trip', () => { it('keeps the spec exactly as written', () => { for (const spec of ['5/2w', '2w', '07d', '5/02w', '1/1y']) { expect(Frequency.parse(spec).toSpec()).toBe(spec); } }); }); describe('parsed fields', () => { it('extracts interval parts', () => { const freq = Frequency.parse('2w'); expect(freq.count).toBeNull(); expect(freq.length).toBe(2); expect(freq.unit).toBe('w'); }); it('extracts quota parts', () => { const freq = Frequency.parse('5/2w'); expect(freq.count).toBe(5); expect(freq.length).toBe(2); expect(freq.unit).toBe('w'); }); }); describe('branding', () => { it('returns branded instances', () => { expect(Frequency.parse('5/2w')).toBeInstanceOf(Frequency); expect(Frequency.safeParse('2w')).toBeInstanceOf(Frequency); }); }); describe('format', () => { it('keeps day-based interval labels (legacy display)', () => { expect(Frequency.parse('1d').format()).toBe('chaque jour'); expect(Frequency.parse('7d').format()).toBe('chaque semaine'); expect(Frequency.parse('1w').format()).toBe('chaque semaine'); expect(Frequency.parse('30d').format()).toBe('chaque mois'); expect(Frequency.parse('365d').format()).toBe('chaque année'); expect(Frequency.parse('2w').format()).toBe('tous les 14 jours'); expect(Frequency.parse('14d').format()).toBe('tous les 14 jours'); expect(Frequency.parse('180d').format()).toBe('tous les 180 jours'); }); it('labels quotas as completions per period', () => { expect(Frequency.parse('5/2w').format()).toBe('5 fois par 2 semaines'); expect(Frequency.parse('5/1w').format()).toBe('5 fois par semaine'); expect(Frequency.parse('3/1d').format()).toBe('3 fois par jour'); expect(Frequency.parse('2/3m').format()).toBe('2 fois par 3 mois'); expect(Frequency.parse('1/1y').format()).toBe('1 fois par an'); expect(Frequency.parse('4/2y').format()).toBe('4 fois par 2 ans'); expect(Frequency.parse('5/14d').format()).toBe('5 fois par 14 jours'); }); }); describe('periodDays', () => { it('extracts day counts for compute only', () => { expect(Frequency.parse('1d').periodDays()).toBe(1); expect(Frequency.parse('2w').periodDays()).toBe(14); expect(Frequency.parse('5/2w').periodDays()).toBe(14); expect(Frequency.parse('5/1m').periodDays()).toBe(30); expect(Frequency.parse('2/3w').periodDays()).toBe(21); expect(Frequency.parse('1y').periodDays()).toBe(365); }); });