Full document, spreadsheet, slideshow, and diagram tooling
1import { describe, it, expect, beforeEach } from 'vitest';
2
3/**
4 * Tests for the Version Panel pure functions.
5 *
6 * We test only the exported pure functions (formatRelativeTime,
7 * parseMetadata, computeDiffStats) — not the DOM-dependent
8 * createVersionPanel which requires a full browser environment.
9 */
10
11import {
12 formatRelativeTime,
13 parseMetadata,
14 computeDiffStats,
15} from '../src/version-panel.js';
16
17describe('formatRelativeTime', () => {
18 it('returns "Just now" for a time less than 60s ago', () => {
19 const now = new Date();
20 const result = formatRelativeTime(now.toISOString());
21 expect(result).toBe('Just now');
22 });
23
24 it('returns "N min ago" for times within the hour', () => {
25 const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000);
26 const result = formatRelativeTime(fiveMinAgo.toISOString());
27 expect(result).toBe('5 min ago');
28 });
29
30 it('returns "1 min ago" for exactly 1 minute', () => {
31 const oneMinAgo = new Date(Date.now() - 60 * 1000);
32 const result = formatRelativeTime(oneMinAgo.toISOString());
33 expect(result).toBe('1 min ago');
34 });
35
36 it('returns "N hours ago" for times within 24h', () => {
37 const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
38 const result = formatRelativeTime(twoHoursAgo.toISOString());
39 expect(result).toBe('2 hours ago');
40 });
41
42 it('returns "1 hour ago" for exactly 1 hour (no plural)', () => {
43 const oneHourAgo = new Date(Date.now() - 1 * 60 * 60 * 1000);
44 const result = formatRelativeTime(oneHourAgo.toISOString());
45 expect(result).toBe('1 hour ago');
46 });
47
48 it('returns "Yesterday" for times 24-48h ago', () => {
49 const yesterday = new Date(Date.now() - 30 * 60 * 60 * 1000);
50 const result = formatRelativeTime(yesterday.toISOString());
51 expect(result).toBe('Yesterday');
52 });
53
54 it('returns "Mon DD" for older dates', () => {
55 const oldDate = new Date('2025-03-15T10:00:00Z');
56 const result = formatRelativeTime(oldDate.toISOString());
57 expect(result).toBe('Mar 15');
58 });
59
60 it('handles dates without Z suffix (server format)', () => {
61 const now = new Date();
62 // Server returns UTC without Z — e.g. "2025-03-15 10:00:00"
63 const serverFormat = now.toISOString().replace('T', ' ').replace('Z', '');
64 const result = formatRelativeTime(serverFormat);
65 expect(result).toBe('Just now');
66 });
67
68 it('returns "Unknown" for empty string', () => {
69 expect(formatRelativeTime('')).toBe('Unknown');
70 });
71
72 it('returns "Unknown" for invalid date string', () => {
73 expect(formatRelativeTime('not-a-date')).toBe('Unknown');
74 });
75
76 it('returns "Unknown" for null-like empty input', () => {
77 expect(formatRelativeTime('')).toBe('Unknown');
78 });
79
80 it('handles January dates correctly', () => {
81 const jan = new Date('2025-01-05T10:00:00Z');
82 const result = formatRelativeTime(jan.toISOString());
83 expect(result).toBe('Jan 5');
84 });
85
86 it('handles December dates correctly', () => {
87 const dec = new Date('2025-12-25T10:00:00Z');
88 const result = formatRelativeTime(dec.toISOString());
89 expect(result).toBe('Dec 25');
90 });
91});
92
93describe('parseMetadata', () => {
94 it('parses valid JSON string', () => {
95 const result = parseMetadata('{"author":"Alice","wordCount":42}');
96 expect(result).toEqual({ author: 'Alice', wordCount: 42 });
97 });
98
99 it('returns empty object for null input', () => {
100 expect(parseMetadata(null)).toEqual({});
101 });
102
103 it('returns empty object for empty string', () => {
104 expect(parseMetadata('')).toEqual({});
105 });
106
107 it('returns empty object for invalid JSON', () => {
108 expect(parseMetadata('not json')).toEqual({});
109 });
110
111 it('returns empty object for JSON array', () => {
112 expect(parseMetadata('[1, 2, 3]')).toEqual({});
113 });
114
115 it('returns empty object for JSON primitive', () => {
116 expect(parseMetadata('"just a string"')).toEqual({});
117 });
118
119 it('returns empty object for JSON number', () => {
120 expect(parseMetadata('42')).toEqual({});
121 });
122
123 it('handles nested objects', () => {
124 const result = parseMetadata('{"a":{"b":1}}');
125 expect(result).toEqual({ a: { b: 1 } });
126 });
127
128 it('handles empty JSON object', () => {
129 expect(parseMetadata('{}')).toEqual({});
130 });
131
132 it('preserves all properties in metadata', () => {
133 const result = parseMetadata('{"author":"Bob","wordCount":100,"timestamp":12345,"custom":"value"}');
134 expect(result['author']).toBe('Bob');
135 expect(result['wordCount']).toBe(100);
136 expect(result['timestamp']).toBe(12345);
137 expect(result['custom']).toBe('value');
138 });
139});
140
141describe('computeDiffStats', () => {
142 it('returns positive delta when words added', () => {
143 const result = computeDiffStats({ wordCount: 100 }, { wordCount: 80 });
144 expect(result.delta).toBe(20);
145 expect(result.label).toBe('+20');
146 });
147
148 it('returns negative delta when words removed', () => {
149 const result = computeDiffStats({ wordCount: 50 }, { wordCount: 80 });
150 expect(result.delta).toBe(-30);
151 expect(result.label).toBe('-30');
152 });
153
154 it('returns zero delta when no change', () => {
155 const result = computeDiffStats({ wordCount: 42 }, { wordCount: 42 });
156 expect(result.delta).toBe(0);
157 expect(result.label).toBe('0');
158 });
159
160 it('handles first version (no previous)', () => {
161 const result = computeDiffStats({ wordCount: 50 }, undefined);
162 expect(result.delta).toBe(50);
163 expect(result.label).toBe('+50');
164 });
165
166 it('handles missing wordCount in current', () => {
167 const result = computeDiffStats({}, { wordCount: 10 });
168 expect(result.delta).toBe(-10);
169 expect(result.label).toBe('-10');
170 });
171
172 it('handles both undefined', () => {
173 const result = computeDiffStats(undefined, undefined);
174 expect(result.delta).toBe(0);
175 expect(result.label).toBe('+0');
176 });
177
178 it('handles previous without wordCount property', () => {
179 const result = computeDiffStats({ wordCount: 10 }, {});
180 expect(result.delta).toBe(10);
181 expect(result.label).toBe('+10');
182 });
183
184 it('handles zero word count', () => {
185 const result = computeDiffStats({ wordCount: 0 }, { wordCount: 0 });
186 expect(result.delta).toBe(0);
187 expect(result.label).toBe('0');
188 });
189
190 it('handles transition from zero to non-zero', () => {
191 const result = computeDiffStats({ wordCount: 5 }, { wordCount: 0 });
192 expect(result.delta).toBe(5);
193 expect(result.label).toBe('+5');
194 });
195
196 it('handles large numbers', () => {
197 const result = computeDiffStats({ wordCount: 100000 }, { wordCount: 50000 });
198 expect(result.delta).toBe(50000);
199 expect(result.label).toBe('+50000');
200 });
201});
202
203// =====================================================================
204// Edge cases
205// =====================================================================
206
207describe('formatRelativeTime — edge cases', () => {
208 it('returns "Unknown" for undefined-like input', () => {
209 expect(formatRelativeTime(null as unknown as string)).toBe('Unknown');
210 });
211
212 it('returns Unknown for timezone offset format (only Z-suffix supported)', () => {
213 // formatRelativeTime appends Z to non-Z strings, making +05:00Z invalid
214 const result = formatRelativeTime('2025-06-15T10:00:00+05:00');
215 expect(result).toBe('Unknown');
216 });
217
218 it('returns correct format for exactly 59 seconds ago', () => {
219 const almost1Min = new Date(Date.now() - 59 * 1000);
220 expect(formatRelativeTime(almost1Min.toISOString())).toBe('Just now');
221 });
222
223 it('returns "N min ago" at exactly 60 seconds', () => {
224 const exactly1Min = new Date(Date.now() - 60 * 1000);
225 expect(formatRelativeTime(exactly1Min.toISOString())).toBe('1 min ago');
226 });
227
228 it('returns "N hours ago" at exactly 60 minutes', () => {
229 const exactly1Hr = new Date(Date.now() - 60 * 60 * 1000);
230 expect(formatRelativeTime(exactly1Hr.toISOString())).toBe('1 hour ago');
231 });
232
233 it('returns "Yesterday" at exactly 24 hours', () => {
234 const exactly24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
235 expect(formatRelativeTime(exactly24h.toISOString())).toBe('Yesterday');
236 });
237
238 it('returns "Mon DD" at exactly 48 hours', () => {
239 const exactly48h = new Date(Date.now() - 48 * 60 * 60 * 1000);
240 const result = formatRelativeTime(exactly48h.toISOString());
241 // Should be "Mon DD" format, not "Yesterday"
242 expect(result).toMatch(/^[A-Z][a-z]{2} \d{1,2}$/);
243 });
244
245 it('handles very old date (year 2000)', () => {
246 const result = formatRelativeTime('2000-07-04T12:00:00Z');
247 expect(result).toBe('Jul 4');
248 });
249});
250
251describe('parseMetadata — edge cases', () => {
252 it('returns empty object for JSON boolean', () => {
253 expect(parseMetadata('true')).toEqual({});
254 expect(parseMetadata('false')).toEqual({});
255 });
256
257 it('returns empty object for JSON null', () => {
258 expect(parseMetadata('null')).toEqual({});
259 });
260
261 it('handles object with array values', () => {
262 const result = parseMetadata('{"tags":["a","b"]}');
263 expect(result).toEqual({ tags: ['a', 'b'] });
264 });
265
266 it('handles deeply nested objects', () => {
267 const result = parseMetadata('{"a":{"b":{"c":{"d":1}}}}');
268 expect((result['a'] as Record<string, unknown>)['b']).toBeDefined();
269 });
270});
271
272describe('computeDiffStats — edge cases', () => {
273 it('handles wordCount as null (treated as 0 via ??)', () => {
274 const result = computeDiffStats({ wordCount: null as unknown as number }, { wordCount: 10 });
275 expect(result.delta).toBe(-10);
276 });
277
278 it('handles negative word counts gracefully', () => {
279 const result = computeDiffStats({ wordCount: -5 }, { wordCount: 10 });
280 expect(result.delta).toBe(-15);
281 expect(result.label).toBe('-15');
282 });
283
284 it('handles first version with zero words', () => {
285 const result = computeDiffStats({ wordCount: 0 }, undefined);
286 expect(result.delta).toBe(0);
287 expect(result.label).toBe('+0');
288 });
289
290 it('handles transition from non-zero to zero', () => {
291 const result = computeDiffStats({ wordCount: 0 }, { wordCount: 50 });
292 expect(result.delta).toBe(-50);
293 expect(result.label).toBe('-50');
294 });
295});