diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-30 22:13:07 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-30 22:13:07 +0200 |
| commit | a725d4c7d28fee701b6997cc46cffddefbcd26bf (patch) | |
| tree | 1e0899dc9fa67f8c74d7e4b7023881959d571d4f /src/lib/shared/frequency.test.ts | |
| parent | a17b24b8e95bf85a8522fc0c2cbbdb8da31bf018 (diff) | |
Allow granular routine frequencies like 5/2w
Diffstat (limited to 'src/lib/shared/frequency.test.ts')
| -rw-r--r-- | src/lib/shared/frequency.test.ts | 125 |
1 files changed, 125 insertions, 0 deletions
diff --git a/src/lib/shared/frequency.test.ts b/src/lib/shared/frequency.test.ts new file mode 100644 index 0000000..e33dfd7 --- /dev/null +++ b/src/lib/shared/frequency.test.ts @@ -0,0 +1,125 @@ +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); + }); +}); |
