Skip to content

Commit 08d732b

Browse files
committed
feat(lib): add duration parser and formatter utility
Add parseDuration(), formatDuration(), and requireDuration() to src/lib/duration.ts with comprehensive test coverage (45 tests). Parses human-readable duration strings (e.g. '4h', '30m', '1h30m', '2h15m30s') into milliseconds and formats them back. Integrates with the existing localValidationError helper for consistent CLI error envelopes (VALIDATION_ERROR, exit 5). Features: - Parse: h/m/s units, compound segments, fractional values - Validate: duplicate units, zero/empty, 24h ceiling - Format: concise human-readable output - Bounded: requireDuration() with min/max constraints - Zero new production dependencies
1 parent 3ab8136 commit 08d732b

2 files changed

Lines changed: 473 additions & 0 deletions

File tree

src/lib/duration.test.ts

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { formatDuration, parseDuration, requireDuration } from './duration.js';
3+
import { ApiError } from './errors.js';
4+
5+
describe('parseDuration', () => {
6+
describe('valid inputs', () => {
7+
it('parses hours', () => {
8+
expect(parseDuration('4h')).toBe(4 * 3_600_000);
9+
});
10+
11+
it('parses minutes', () => {
12+
expect(parseDuration('30m')).toBe(30 * 60_000);
13+
});
14+
15+
it('parses seconds', () => {
16+
expect(parseDuration('45s')).toBe(45 * 1_000);
17+
});
18+
19+
it('parses compound h+m', () => {
20+
expect(parseDuration('1h30m')).toBe(1 * 3_600_000 + 30 * 60_000);
21+
});
22+
23+
it('parses compound h+m+s', () => {
24+
expect(parseDuration('2h15m30s')).toBe(2 * 3_600_000 + 15 * 60_000 + 30 * 1_000);
25+
});
26+
27+
it('parses compound m+s', () => {
28+
expect(parseDuration('5m30s')).toBe(5 * 60_000 + 30 * 1_000);
29+
});
30+
31+
it('parses fractional hours', () => {
32+
expect(parseDuration('1.5h')).toBe(5_400_000);
33+
});
34+
35+
it('parses fractional minutes', () => {
36+
expect(parseDuration('2.5m')).toBe(150_000);
37+
});
38+
39+
it('parses fractional seconds', () => {
40+
expect(parseDuration('1.5s')).toBe(1_500);
41+
});
42+
43+
it('is case-insensitive', () => {
44+
expect(parseDuration('4H')).toBe(4 * 3_600_000);
45+
expect(parseDuration('30M')).toBe(30 * 60_000);
46+
expect(parseDuration('45S')).toBe(45 * 1_000);
47+
expect(parseDuration('1H30M')).toBe(1 * 3_600_000 + 30 * 60_000);
48+
});
49+
50+
it('trims whitespace', () => {
51+
expect(parseDuration(' 4h ')).toBe(4 * 3_600_000);
52+
});
53+
54+
it('parses 1s (minimum practical duration)', () => {
55+
expect(parseDuration('1s')).toBe(1_000);
56+
});
57+
58+
it('parses 24h (maximum allowed)', () => {
59+
expect(parseDuration('24h')).toBe(24 * 3_600_000);
60+
});
61+
});
62+
63+
describe('invalid inputs', () => {
64+
it('rejects empty string', () => {
65+
expect(() => parseDuration('')).toThrow(ApiError);
66+
});
67+
68+
it('rejects whitespace-only string', () => {
69+
expect(() => parseDuration(' ')).toThrow(ApiError);
70+
});
71+
72+
it('rejects bare number without unit', () => {
73+
expect(() => parseDuration('120')).toThrow(ApiError);
74+
});
75+
76+
it('rejects unit without number', () => {
77+
expect(() => parseDuration('h')).toThrow(ApiError);
78+
});
79+
80+
it('rejects unsupported units', () => {
81+
expect(() => parseDuration('4d')).toThrow(ApiError);
82+
expect(() => parseDuration('2w')).toThrow(ApiError);
83+
});
84+
85+
it('rejects spaces between segments', () => {
86+
expect(() => parseDuration('1h 30m')).toThrow(ApiError);
87+
});
88+
89+
it('rejects duplicate units', () => {
90+
expect(() => parseDuration('1h2h')).toThrow(ApiError);
91+
expect(() => parseDuration('1m2m')).toThrow(ApiError);
92+
expect(() => parseDuration('1s2s')).toThrow(ApiError);
93+
});
94+
95+
it('rejects zero duration', () => {
96+
expect(() => parseDuration('0h')).toThrow(ApiError);
97+
expect(() => parseDuration('0m')).toThrow(ApiError);
98+
expect(() => parseDuration('0s')).toThrow(ApiError);
99+
expect(() => parseDuration('0h0m0s')).toThrow(ApiError);
100+
});
101+
102+
it('rejects duration exceeding 24 hours', () => {
103+
expect(() => parseDuration('25h')).toThrow(ApiError);
104+
expect(() => parseDuration('24h1s')).toThrow(ApiError);
105+
expect(() => parseDuration('1500m')).toThrow(ApiError);
106+
});
107+
108+
it('rejects non-string-like inputs via the grammar', () => {
109+
expect(() => parseDuration('abc')).toThrow(ApiError);
110+
expect(() => parseDuration('--4h')).toThrow(ApiError);
111+
expect(() => parseDuration('4h-')).toThrow(ApiError);
112+
});
113+
});
114+
115+
describe('error messages', () => {
116+
it('includes the field name in the error', () => {
117+
try {
118+
parseDuration('', 'maxDuration');
119+
expect.fail('should have thrown');
120+
} catch (err) {
121+
expect(err).toBeInstanceOf(ApiError);
122+
const apiErr = err as ApiError;
123+
expect(apiErr.code).toBe('VALIDATION_ERROR');
124+
expect(apiErr.exitCode).toBe(5);
125+
expect(apiErr.nextAction).toContain('--max-duration');
126+
}
127+
});
128+
129+
it('uses the default field name when not specified', () => {
130+
try {
131+
parseDuration('');
132+
expect.fail('should have thrown');
133+
} catch (err) {
134+
expect(err).toBeInstanceOf(ApiError);
135+
const apiErr = err as ApiError;
136+
expect(apiErr.nextAction).toContain('--duration');
137+
}
138+
});
139+
140+
it('includes the formatted excess duration in the message', () => {
141+
try {
142+
parseDuration('25h');
143+
expect.fail('should have thrown');
144+
} catch (err) {
145+
expect(err).toBeInstanceOf(ApiError);
146+
const apiErr = err as ApiError;
147+
expect(apiErr.nextAction).toContain('24 hours');
148+
}
149+
});
150+
151+
it('mentions duplicate unit in the message', () => {
152+
try {
153+
parseDuration('1h2h');
154+
expect.fail('should have thrown');
155+
} catch (err) {
156+
expect(err).toBeInstanceOf(ApiError);
157+
const apiErr = err as ApiError;
158+
expect(apiErr.nextAction).toContain('duplicate');
159+
expect(apiErr.nextAction).toContain('"h"');
160+
}
161+
});
162+
});
163+
});
164+
165+
describe('formatDuration', () => {
166+
it('formats hours only', () => {
167+
expect(formatDuration(3_600_000)).toBe('1h');
168+
expect(formatDuration(7_200_000)).toBe('2h');
169+
});
170+
171+
it('formats minutes only', () => {
172+
expect(formatDuration(60_000)).toBe('1m');
173+
expect(formatDuration(1_800_000)).toBe('30m');
174+
});
175+
176+
it('formats seconds only', () => {
177+
expect(formatDuration(1_000)).toBe('1s');
178+
expect(formatDuration(45_000)).toBe('45s');
179+
});
180+
181+
it('formats hours + minutes', () => {
182+
expect(formatDuration(5_400_000)).toBe('1h 30m');
183+
});
184+
185+
it('formats hours + minutes + seconds', () => {
186+
expect(formatDuration(8_130_000)).toBe('2h 15m 30s');
187+
});
188+
189+
it('formats minutes + seconds', () => {
190+
expect(formatDuration(90_000)).toBe('1m 30s');
191+
});
192+
193+
it('formats sub-second as "<1s"', () => {
194+
expect(formatDuration(500)).toBe('<1s');
195+
expect(formatDuration(1)).toBe('<1s');
196+
expect(formatDuration(999)).toBe('<1s');
197+
});
198+
199+
it('formats zero as "0s"', () => {
200+
expect(formatDuration(0)).toBe('0s');
201+
});
202+
203+
it('formats negative as "0s"', () => {
204+
expect(formatDuration(-1000)).toBe('0s');
205+
});
206+
207+
it('truncates fractional seconds (floor)', () => {
208+
// 1500ms = 1s + 500ms remainder → "1s" (floor to whole seconds)
209+
expect(formatDuration(1_500)).toBe('1s');
210+
});
211+
212+
it('formats large durations', () => {
213+
expect(formatDuration(24 * 3_600_000)).toBe('24h');
214+
expect(formatDuration(23 * 3_600_000 + 59 * 60_000 + 59 * 1_000)).toBe('23h 59m 59s');
215+
});
216+
});
217+
218+
describe('requireDuration', () => {
219+
it('returns milliseconds for valid input', () => {
220+
expect(requireDuration('4h')).toBe(4 * 3_600_000);
221+
});
222+
223+
it('rejects below minimum', () => {
224+
expect(() => requireDuration('30s', 'maxDuration', { min: 60_000 })).toThrow(ApiError);
225+
try {
226+
requireDuration('30s', 'maxDuration', { min: 60_000 });
227+
expect.fail('should have thrown');
228+
} catch (err) {
229+
const apiErr = err as ApiError;
230+
expect(apiErr.nextAction).toContain('at least');
231+
expect(apiErr.nextAction).toContain('1m');
232+
}
233+
});
234+
235+
it('rejects above maximum', () => {
236+
expect(() => requireDuration('5h', 'maxDuration', { max: 4 * 3_600_000 })).toThrow(ApiError);
237+
try {
238+
requireDuration('5h', 'maxDuration', { max: 4 * 3_600_000 });
239+
expect.fail('should have thrown');
240+
} catch (err) {
241+
const apiErr = err as ApiError;
242+
expect(apiErr.nextAction).toContain('must not exceed');
243+
expect(apiErr.nextAction).toContain('4h');
244+
}
245+
});
246+
247+
it('accepts value at minimum boundary', () => {
248+
expect(requireDuration('1m', 'timeout', { min: 60_000 })).toBe(60_000);
249+
});
250+
251+
it('accepts value at maximum boundary', () => {
252+
expect(requireDuration('4h', 'timeout', { max: 4 * 3_600_000 })).toBe(4 * 3_600_000);
253+
});
254+
255+
it('accepts value within both bounds', () => {
256+
expect(requireDuration('2h', 'timeout', { min: 60_000, max: 4 * 3_600_000 })).toBe(7_200_000);
257+
});
258+
259+
it('propagates parse errors', () => {
260+
expect(() => requireDuration('abc')).toThrow(ApiError);
261+
});
262+
});

0 commit comments

Comments
 (0)