aboutsummaryrefslogtreecommitdiff
path: root/src/lib/shared/frequency.test.ts
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-09-30 22:13:07 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-09-30 22:13:07 +0200
commita725d4c7d28fee701b6997cc46cffddefbcd26bf (patch)
tree1e0899dc9fa67f8c74d7e4b7023881959d571d4f /src/lib/shared/frequency.test.ts
parenta17b24b8e95bf85a8522fc0c2cbbdb8da31bf018 (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.ts125
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);
+ });
+});