Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: document diff, extended RRULE, wireframe tests (0.45.0) (#383)

scott 70f94c40 9eeaae02

+2388 -29
+7
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.45.0] — 2026-04-15 11 + 12 + ### Added 13 + - Docs: document compare/diff view — word-level LCS diff with inline additions/deletions, slide-in panel (Cmd+Shift+D) (#652) 14 + - Calendar: extended RRULE support — interval (every N periods), byDay (specific weekdays), bySetPos (Nth weekday of month), exception dates (#662) 15 + - Diagrams: wireframe renderer unit tests — 59 tests covering all 16 wireframe components (#670) 16 + 10 17 ## [0.44.0] — 2026-04-15 11 18 12 19 ### Added
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.44.0", 3 + "version": "0.45.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+207 -21
src/calendar/helpers.ts
··· 3 3 */ 4 4 5 5 export type RecurrenceType = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly'; 6 + export type Weekday = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU'; 7 + 8 + /** Map Weekday codes to JS Date.getDay() values (0=Sun, 6=Sat). */ 9 + export const WEEKDAY_TO_JS: Record<Weekday, number> = { 10 + SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6, 11 + }; 12 + 13 + /** Map JS Date.getDay() values to Weekday codes. */ 14 + export const JS_TO_WEEKDAY: Weekday[] = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']; 6 15 7 16 export interface Recurrence { 8 17 type: RecurrenceType; 18 + /** Repeat every N periods (default 1) */ 19 + interval?: number; 9 20 /** End date for recurrence (YYYY-MM-DD), or empty for no end */ 10 21 until?: string; 22 + /** Days of week for weekly/monthly recurrence */ 23 + byDay?: Weekday[]; 24 + /** Week-of-month position for monthly (1=first, 2=second, -1=last) */ 25 + bySetPos?: number; 26 + /** Dates to exclude from series (YYYY-MM-DD) */ 27 + exDates?: string[]; 11 28 } 12 29 13 30 export interface CalendarEvent { ··· 149 166 * Expand recurring events into concrete instances within a date range. 150 167 * Non-recurring events are passed through unchanged. 151 168 * Recurring events produce virtual copies with adjusted dates. 169 + * 170 + * Supports extended RRULE features: interval, byDay, bySetPos, exDates. 152 171 */ 153 172 export function expandRecurringEvents( 154 173 events: CalendarEvent[], ··· 163 182 continue; 164 183 } 165 184 166 - const { type, until } = evt.recurrence; 185 + const rec = evt.recurrence; 186 + const { type, until, byDay, bySetPos, exDates } = rec; 187 + const interval = Math.max(1, rec.interval ?? 1); 167 188 const effectiveEnd = until && until < rangeEnd ? until : rangeEnd; 168 189 const eventDuration = evt.endDate 169 190 ? daysBetween(evt.date, evt.endDate) 170 191 : 0; 192 + const exDateSet = exDates && exDates.length > 0 ? new Set(exDates) : null; 171 193 194 + // Branch: weekly + byDay needs special expansion 195 + if (type === 'weekly' && byDay && byDay.length > 0) { 196 + expandWeeklyByDay(evt, rec, interval, effectiveEnd, eventDuration, exDateSet, rangeStart, rangeEnd, result); 197 + continue; 198 + } 199 + 200 + // Branch: monthly + byDay needs Nth-weekday-of-month logic 201 + if (type === 'monthly' && byDay && byDay.length > 0) { 202 + expandMonthlyByDay(evt, rec, interval, effectiveEnd, eventDuration, exDateSet, rangeStart, rangeEnd, result); 203 + continue; 204 + } 205 + 206 + // Standard path: daily, weekly (no byDay), monthly (no byDay), yearly 172 207 let current = parseEventDate(evt.date); 173 - const startDay = current.getDate(); // preserve original day-of-month for monthly recurrence 208 + const startDay = current.getDate(); 174 209 const endDate = parseEventDate(effectiveEnd); 175 210 const startRange = parseEventDate(rangeStart); 176 211 177 - // Cap at 365 iterations to prevent runaway loops 178 212 let iterations = 0; 179 213 while (current <= endDate && iterations < 365) { 180 214 const dateStr = formatDate(current); 181 215 182 - // Only include if the instance falls within the visible range 183 216 const instanceEnd = eventDuration > 0 184 217 ? formatDate(addDays(current, eventDuration)) 185 218 : dateStr; 186 219 187 220 if (instanceEnd >= rangeStart && dateStr <= rangeEnd) { 188 - const instance: CalendarEvent = { 189 - ...evt, 190 - date: dateStr, 191 - ...(eventDuration > 0 ? { endDate: instanceEnd } : {}), 192 - }; 193 - result.push(instance); 221 + if (!exDateSet || !exDateSet.has(dateStr)) { 222 + result.push({ 223 + ...evt, 224 + date: dateStr, 225 + ...(eventDuration > 0 ? { endDate: instanceEnd } : {}), 226 + }); 227 + } 194 228 } 195 229 196 - // Advance to next occurrence (pass startDay for monthly to recover from clamping) 197 - current = nextOccurrence(current, type, startDay); 230 + current = nextOccurrence(current, type, interval, startDay); 198 231 iterations++; 199 232 200 - // Skip instances before our visible range 201 233 if (current < startRange && iterations < 365) { 202 234 continue; 203 235 } ··· 207 239 return result; 208 240 } 209 241 242 + /** 243 + * Expand weekly recurrence with specific byDay weekdays. 244 + * For each active week, emits instances for every matching weekday. 245 + */ 246 + function expandWeeklyByDay( 247 + evt: CalendarEvent, 248 + rec: Recurrence, 249 + interval: number, 250 + effectiveEnd: string, 251 + eventDuration: number, 252 + exDateSet: Set<string> | null, 253 + rangeStart: string, 254 + rangeEnd: string, 255 + result: CalendarEvent[], 256 + ): void { 257 + const byDay = rec.byDay!; 258 + const targetDays = byDay.map(d => WEEKDAY_TO_JS[d]).sort((a, b) => a - b); 259 + const evtStart = parseEventDate(evt.date); 260 + const endDate = parseEventDate(effectiveEnd); 261 + 262 + // Find the Monday (or Sunday) of the event's start week — use getWeekStart (Sunday-based) 263 + let weekStart = getWeekStart(evtStart); 264 + let weekIndex = 0; 265 + let iterations = 0; 266 + 267 + while (weekStart <= endDate && iterations < 365) { 268 + if (weekIndex % interval === 0) { 269 + for (const dayNum of targetDays) { 270 + const candidate = addDays(weekStart, dayNum); 271 + // Must be >= event start date and <= effective end 272 + if (candidate < evtStart || candidate > endDate) continue; 273 + const dateStr = formatDate(candidate); 274 + if (dateStr > rangeEnd || dateStr < rangeStart) continue; 275 + if (exDateSet && exDateSet.has(dateStr)) continue; 276 + 277 + const instanceEnd = eventDuration > 0 278 + ? formatDate(addDays(candidate, eventDuration)) 279 + : dateStr; 280 + result.push({ 281 + ...evt, 282 + date: dateStr, 283 + ...(eventDuration > 0 ? { endDate: instanceEnd } : {}), 284 + }); 285 + } 286 + } 287 + weekStart = addDays(weekStart, 7); 288 + weekIndex++; 289 + iterations++; 290 + } 291 + } 292 + 293 + /** 294 + * Expand monthly recurrence with byDay + bySetPos (Nth weekday of month). 295 + * e.g., "first Monday", "last Friday", "second Wednesday". 296 + */ 297 + function expandMonthlyByDay( 298 + evt: CalendarEvent, 299 + rec: Recurrence, 300 + interval: number, 301 + effectiveEnd: string, 302 + eventDuration: number, 303 + exDateSet: Set<string> | null, 304 + rangeStart: string, 305 + rangeEnd: string, 306 + result: CalendarEvent[], 307 + ): void { 308 + const weekday = WEEKDAY_TO_JS[rec.byDay![0]!]; 309 + const pos = rec.bySetPos ?? 1; // default to first 310 + const evtStart = parseEventDate(evt.date); 311 + const endDate = parseEventDate(effectiveEnd); 312 + 313 + let currentMonth = evtStart.getMonth(); 314 + let currentYear = evtStart.getFullYear(); 315 + let iterations = 0; 316 + let monthIndex = 0; 317 + 318 + while (iterations < 365) { 319 + if (monthIndex % interval === 0) { 320 + const candidate = nthWeekdayOfMonth(currentYear, currentMonth, weekday, pos); 321 + if (candidate) { 322 + if (candidate > endDate) break; 323 + if (candidate >= evtStart) { 324 + const dateStr = formatDate(candidate); 325 + if (dateStr >= rangeStart && dateStr <= rangeEnd) { 326 + if (!exDateSet || !exDateSet.has(dateStr)) { 327 + const instanceEnd = eventDuration > 0 328 + ? formatDate(addDays(candidate, eventDuration)) 329 + : dateStr; 330 + result.push({ 331 + ...evt, 332 + date: dateStr, 333 + ...(eventDuration > 0 ? { endDate: instanceEnd } : {}), 334 + }); 335 + } 336 + } 337 + } 338 + } 339 + } 340 + 341 + // Advance to next month 342 + currentMonth++; 343 + if (currentMonth > 11) { 344 + currentMonth = 0; 345 + currentYear++; 346 + } 347 + monthIndex++; 348 + iterations++; 349 + } 350 + } 351 + 352 + /** 353 + * Find the Nth occurrence of a weekday in a given month. 354 + * pos > 0: 1=first, 2=second, etc. 355 + * pos < 0: -1=last, -2=second to last, etc. 356 + */ 357 + function nthWeekdayOfMonth(year: number, month: number, weekday: number, pos: number): Date | null { 358 + if (pos > 0) { 359 + // Find first occurrence, then add (pos-1) weeks 360 + const first = new Date(year, month, 1); 361 + const firstDow = first.getDay(); 362 + let dayOfMonth = 1 + ((weekday - firstDow + 7) % 7); 363 + dayOfMonth += (pos - 1) * 7; 364 + const maxDay = daysInMonth(year, month); 365 + if (dayOfMonth > maxDay) return null; 366 + return new Date(year, month, dayOfMonth); 367 + } else { 368 + // Find last occurrence, then subtract (-pos-1) weeks 369 + const maxDay = daysInMonth(year, month); 370 + const last = new Date(year, month, maxDay); 371 + const lastDow = last.getDay(); 372 + let dayOfMonth = maxDay - ((lastDow - weekday + 7) % 7); 373 + dayOfMonth -= ((-pos) - 1) * 7; 374 + if (dayOfMonth < 1) return null; 375 + return new Date(year, month, dayOfMonth); 376 + } 377 + } 378 + 210 379 function daysBetween(startStr: string, endStr: string): number { 211 380 const a = parseEventDate(startStr).getTime(); 212 381 const b = parseEventDate(endStr).getTime(); ··· 219 388 return result; 220 389 } 221 390 222 - function nextOccurrence(current: Date, type: RecurrenceType, startDay?: number): Date { 391 + function nextOccurrence(current: Date, type: RecurrenceType, interval: number, startDay?: number): Date { 223 392 const next = new Date(current); 224 393 switch (type) { 225 394 case 'daily': 226 - next.setDate(next.getDate() + 1); 395 + next.setDate(next.getDate() + interval); 227 396 break; 228 397 case 'weekly': 229 - next.setDate(next.getDate() + 7); 398 + next.setDate(next.getDate() + 7 * interval); 230 399 break; 231 400 case 'monthly': { 232 401 const targetDay = startDay ?? next.getDate(); 233 - next.setDate(1); // prevent day overflow (e.g. Jan 31 → Mar 3) 234 - next.setMonth(next.getMonth() + 1); 402 + next.setDate(1); // prevent day overflow (e.g. Jan 31 -> Mar 3) 403 + next.setMonth(next.getMonth() + interval); 235 404 const maxDay = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate(); 236 405 next.setDate(Math.min(targetDay, maxDay)); 237 406 break; ··· 239 408 case 'yearly': { 240 409 const targetDay = startDay ?? next.getDate(); 241 410 const targetMonth = next.getMonth(); 242 - next.setDate(1); // prevent day overflow (e.g. Feb 29 → Mar 1) 243 - next.setFullYear(next.getFullYear() + 1); 411 + next.setDate(1); // prevent day overflow (e.g. Feb 29 -> Mar 1) 412 + next.setFullYear(next.getFullYear() + interval); 244 413 next.setMonth(targetMonth); 245 414 const maxDay = new Date(next.getFullYear(), targetMonth + 1, 0).getDate(); 246 415 next.setDate(Math.min(targetDay, maxDay)); 247 416 break; 248 417 } 249 418 default: 250 - next.setDate(next.getDate() + 1); 419 + next.setDate(next.getDate() + interval); 251 420 } 252 421 return next; 253 422 } ··· 258 427 { type: 'weekly', label: 'Weekly' }, 259 428 { type: 'monthly', label: 'Monthly' }, 260 429 { type: 'yearly', label: 'Yearly' }, 430 + ]; 431 + 432 + /** All weekdays in order for UI display. */ 433 + export const ALL_WEEKDAYS: Weekday[] = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']; 434 + 435 + /** Human-readable labels for weekday codes. */ 436 + export const WEEKDAY_LABELS: Record<Weekday, string> = { 437 + MO: 'Mon', TU: 'Tue', WE: 'Wed', TH: 'Thu', FR: 'Fri', SA: 'Sat', SU: 'Sun', 438 + }; 439 + 440 + /** Ordinal labels for bySetPos values. */ 441 + export const SETPOS_OPTIONS: Array<{ value: number; label: string }> = [ 442 + { value: 1, label: 'First' }, 443 + { value: 2, label: 'Second' }, 444 + { value: 3, label: 'Third' }, 445 + { value: 4, label: 'Fourth' }, 446 + { value: -1, label: 'Last' }, 261 447 ]; 262 448 263 449 /** ISO 8601 week number (Monday-based, week 1 contains Jan 4). */
+43
src/calendar/index.html
··· 232 232 </select> 233 233 </div> 234 234 235 + <div class="event-modal-field" id="recurrence-interval-field" style="display:none"> 236 + <label for="event-recurrence-interval">Every</label> 237 + <div class="event-modal-row" style="gap:8px;align-items:center"> 238 + <input type="number" id="event-recurrence-interval" class="event-modal-input" min="1" max="99" value="1" style="width:64px"> 239 + <span id="recurrence-interval-unit" class="recurrence-interval-unit">weeks</span> 240 + </div> 241 + </div> 242 + 243 + <div class="event-modal-field" id="recurrence-byday-field" style="display:none"> 244 + <label>On days</label> 245 + <div class="recurrence-byday-picker" id="recurrence-byday-picker"> 246 + <button type="button" class="recurrence-day-btn" data-day="MO">Mo</button> 247 + <button type="button" class="recurrence-day-btn" data-day="TU">Tu</button> 248 + <button type="button" class="recurrence-day-btn" data-day="WE">We</button> 249 + <button type="button" class="recurrence-day-btn" data-day="TH">Th</button> 250 + <button type="button" class="recurrence-day-btn" data-day="FR">Fr</button> 251 + <button type="button" class="recurrence-day-btn" data-day="SA">Sa</button> 252 + <button type="button" class="recurrence-day-btn" data-day="SU">Su</button> 253 + </div> 254 + </div> 255 + 256 + <div class="event-modal-field" id="recurrence-setpos-field" style="display:none"> 257 + <label for="event-recurrence-setpos">Which</label> 258 + <div class="event-modal-row" style="gap:8px;align-items:center"> 259 + <select id="event-recurrence-setpos" class="event-modal-input" style="width:auto"> 260 + <option value="1">First</option> 261 + <option value="2">Second</option> 262 + <option value="3">Third</option> 263 + <option value="4">Fourth</option> 264 + <option value="-1">Last</option> 265 + </select> 266 + <select id="event-recurrence-setpos-day" class="event-modal-input" style="width:auto"> 267 + <option value="MO">Monday</option> 268 + <option value="TU">Tuesday</option> 269 + <option value="WE">Wednesday</option> 270 + <option value="TH">Thursday</option> 271 + <option value="FR">Friday</option> 272 + <option value="SA">Saturday</option> 273 + <option value="SU">Sunday</option> 274 + </select> 275 + </div> 276 + </div> 277 + 235 278 <div class="event-modal-field" id="recurrence-until-field" style="display:none"> 236 279 <label for="event-recurrence-until">Repeat until</label> 237 280 <input type="date" id="event-recurrence-until" class="event-modal-input">
+54 -7
src/calendar/main.ts
··· 152 152 const modalRecurrence = document.getElementById('event-recurrence') as HTMLSelectElement; 153 153 const modalRecurrenceUntil = document.getElementById('event-recurrence-until') as HTMLInputElement; 154 154 const recurrenceUntilField = document.getElementById('recurrence-until-field') as HTMLElement; 155 + const recurrenceIntervalField = document.getElementById('recurrence-interval-field') as HTMLElement; 156 + const modalRecurrenceInterval = document.getElementById('event-recurrence-interval') as HTMLInputElement; 157 + const recurrenceIntervalUnit = document.getElementById('recurrence-interval-unit') as HTMLElement; 158 + const recurrenceByDayField = document.getElementById('recurrence-byday-field') as HTMLElement; 159 + const recurrenceByDayPicker = document.getElementById('recurrence-byday-picker') as HTMLElement; 160 + const recurrenceSetPosField = document.getElementById('recurrence-setpos-field') as HTMLElement; 161 + const modalRecurrenceSetPos = document.getElementById('event-recurrence-setpos') as HTMLSelectElement; 162 + const modalRecurrenceSetPosDay = document.getElementById('event-recurrence-setpos-day') as HTMLSelectElement; 155 163 156 164 // Populate timezone picker 157 165 for (const tz of COMMON_TIMEZONES) { ··· 1346 1354 // Recurrence fields 1347 1355 const rec = (evt as CalendarEvent).recurrence; 1348 1356 modalRecurrence.value = rec?.type ?? 'none'; 1357 + modalRecurrenceInterval.value = String(rec?.interval ?? 1); 1349 1358 modalRecurrenceUntil.value = rec?.until ?? ''; 1350 - updateRecurrenceUntilVisibility(); 1359 + // byDay picker 1360 + recurrenceByDayPicker.querySelectorAll('.recurrence-day-btn').forEach(btn => { 1361 + const day = (btn as HTMLElement).dataset.day; 1362 + (btn as HTMLElement).classList.toggle('active', rec?.byDay?.includes(day as any) ?? false); 1363 + }); 1364 + // bySetPos 1365 + modalRecurrenceSetPos.value = String(rec?.bySetPos ?? 1); 1366 + if (rec?.byDay?.[0]) modalRecurrenceSetPosDay.value = rec.byDay[0]; 1367 + updateRecurrenceFieldsVisibility(); 1351 1368 1352 1369 // Reminder fields 1353 1370 modalReminders = [...((evt as CalendarEvent).reminders ?? [])]; ··· 1378 1395 modalEndTime.parentElement!.style.display = hidden ? 'none' : ''; 1379 1396 } 1380 1397 1381 - function updateRecurrenceUntilVisibility(): void { 1382 - recurrenceUntilField.style.display = modalRecurrence.value !== 'none' ? '' : 'none'; 1398 + function updateRecurrenceFieldsVisibility(): void { 1399 + const recType = modalRecurrence.value; 1400 + const isRecurring = recType !== 'none'; 1401 + recurrenceUntilField.style.display = isRecurring ? '' : 'none'; 1402 + recurrenceIntervalField.style.display = isRecurring ? '' : 'none'; 1403 + recurrenceByDayField.style.display = recType === 'weekly' ? '' : 'none'; 1404 + recurrenceSetPosField.style.display = recType === 'monthly' ? '' : 'none'; 1405 + // Update interval unit label 1406 + const unitMap: Record<string, string> = { daily: 'days', weekly: 'weeks', monthly: 'months', yearly: 'years' }; 1407 + recurrenceIntervalUnit.textContent = unitMap[recType] ?? 'periods'; 1383 1408 } 1384 1409 1385 1410 function saveEvent(): void { 1386 1411 const now = Date.now(); 1387 1412 const endDateVal = modalEndDate.value; 1388 1413 const recType = modalRecurrence.value as RecurrenceType; 1389 - const recurrence: Recurrence | undefined = recType !== 'none' 1390 - ? { type: recType, ...(modalRecurrenceUntil.value ? { until: modalRecurrenceUntil.value } : {}) } 1391 - : undefined; 1414 + let recurrence: Recurrence | undefined; 1415 + if (recType !== 'none') { 1416 + recurrence = { type: recType }; 1417 + const interval = parseInt(modalRecurrenceInterval.value, 10); 1418 + if (interval > 1) recurrence.interval = interval; 1419 + if (modalRecurrenceUntil.value) recurrence.until = modalRecurrenceUntil.value; 1420 + if (recType === 'weekly') { 1421 + const byDay: string[] = []; 1422 + recurrenceByDayPicker.querySelectorAll('.recurrence-day-btn.active').forEach(btn => { 1423 + byDay.push((btn as HTMLElement).dataset.day!); 1424 + }); 1425 + if (byDay.length > 0) recurrence.byDay = byDay as any; 1426 + } 1427 + if (recType === 'monthly' && modalRecurrenceSetPosDay.value) { 1428 + recurrence.byDay = [modalRecurrenceSetPosDay.value as any]; 1429 + recurrence.bySetPos = parseInt(modalRecurrenceSetPos.value, 10); 1430 + } 1431 + } 1392 1432 const event: CalendarEvent = { 1393 1433 id: editingEventId ?? crypto.randomUUID(), 1394 1434 title: modalTitle.value.trim() || 'Untitled', ··· 1451 1491 const modalCloseBtn = document.getElementById('btn-event-close') as HTMLButtonElement | null; 1452 1492 if (modalCloseBtn) modalCloseBtn.addEventListener('click', closeModal); 1453 1493 modalAllDay.addEventListener('change', updateTimeFieldsVisibility); 1454 - modalRecurrence.addEventListener('change', updateRecurrenceUntilVisibility); 1494 + modalRecurrence.addEventListener('change', updateRecurrenceFieldsVisibility); 1495 + 1496 + // byDay picker toggle buttons 1497 + recurrenceByDayPicker.querySelectorAll('.recurrence-day-btn').forEach(btn => { 1498 + btn.addEventListener('click', () => { 1499 + (btn as HTMLElement).classList.toggle('active'); 1500 + }); 1501 + }); 1455 1502 modalBackdrop.addEventListener('click', (e) => { 1456 1503 if (e.target === modalBackdrop) closeModal(); 1457 1504 });
+183
src/css/app.css
··· 7885 7885 } 7886 7886 7887 7887 /* ======================================================== 7888 + Diff Panel (slide-in from right, compare versions) 7889 + ======================================================== */ 7890 + 7891 + .diff-panel { 7892 + position: fixed; 7893 + top: 0; 7894 + right: -380px; 7895 + width: 380px; 7896 + height: 100vh; 7897 + background: var(--color-surface); 7898 + border-left: 1px solid var(--color-border); 7899 + box-shadow: var(--shadow-lg); 7900 + display: flex; 7901 + flex-direction: column; 7902 + z-index: var(--z-panel); 7903 + transition: right var(--transition-med); 7904 + overflow: hidden; 7905 + } 7906 + 7907 + .diff-panel.open { 7908 + right: 0; 7909 + } 7910 + 7911 + .diff-panel-header { 7912 + display: flex; 7913 + align-items: center; 7914 + justify-content: space-between; 7915 + padding: var(--space-sm) var(--space-md); 7916 + border-bottom: 1px solid var(--color-border); 7917 + flex-shrink: 0; 7918 + } 7919 + 7920 + .diff-panel-header h3 { 7921 + margin: 0; 7922 + font-size: 0.875rem; 7923 + font-weight: 600; 7924 + font-family: var(--font-body); 7925 + color: var(--color-text); 7926 + } 7927 + 7928 + .diff-panel-close { 7929 + font-size: 1.25rem; 7930 + color: var(--color-text-muted); 7931 + } 7932 + 7933 + .diff-panel-controls { 7934 + display: flex; 7935 + align-items: flex-end; 7936 + gap: var(--space-sm); 7937 + padding: var(--space-sm) var(--space-md); 7938 + border-bottom: 1px solid var(--color-border); 7939 + flex-shrink: 0; 7940 + flex-wrap: wrap; 7941 + } 7942 + 7943 + .diff-panel-select-group { 7944 + display: flex; 7945 + flex-direction: column; 7946 + gap: 2px; 7947 + flex: 1; 7948 + min-width: 0; 7949 + } 7950 + 7951 + .diff-panel-label { 7952 + font-size: 0.625rem; 7953 + font-weight: 600; 7954 + text-transform: uppercase; 7955 + letter-spacing: 0.05em; 7956 + color: var(--color-text-faint); 7957 + font-family: var(--font-body); 7958 + } 7959 + 7960 + .diff-panel-select { 7961 + font-size: 0.75rem; 7962 + font-family: var(--font-body); 7963 + padding: 4px 6px; 7964 + border: 1px solid var(--color-border); 7965 + border-radius: var(--radius-sm); 7966 + background: var(--color-bg); 7967 + color: var(--color-text); 7968 + min-width: 0; 7969 + width: 100%; 7970 + cursor: pointer; 7971 + } 7972 + 7973 + .diff-panel-select:focus-visible { 7974 + outline: 2px solid var(--color-teal); 7975 + outline-offset: -1px; 7976 + } 7977 + 7978 + .diff-panel-arrow { 7979 + color: var(--color-text-faint); 7980 + font-size: 0.875rem; 7981 + padding-bottom: 4px; 7982 + flex-shrink: 0; 7983 + } 7984 + 7985 + .diff-panel-run { 7986 + flex-shrink: 0; 7987 + white-space: nowrap; 7988 + } 7989 + 7990 + .diff-panel-stats { 7991 + display: flex; 7992 + gap: var(--space-md); 7993 + padding: var(--space-xs) var(--space-md); 7994 + border-bottom: 1px solid var(--color-border); 7995 + font-size: 0.75rem; 7996 + font-family: var(--font-body); 7997 + font-weight: 600; 7998 + flex-shrink: 0; 7999 + } 8000 + 8001 + .diff-stat-add { 8002 + color: var(--color-success); 8003 + } 8004 + 8005 + .diff-stat-del { 8006 + color: var(--color-danger); 8007 + } 8008 + 8009 + .diff-panel-body { 8010 + flex: 1; 8011 + overflow-y: auto; 8012 + padding: var(--space-md); 8013 + } 8014 + 8015 + .diff-panel-empty { 8016 + color: var(--color-text-faint); 8017 + font-size: 0.8125rem; 8018 + font-family: var(--font-body); 8019 + text-align: center; 8020 + padding: var(--space-xl) var(--space-md); 8021 + } 8022 + 8023 + .diff-panel-content { 8024 + font-family: var(--font-display); 8025 + font-size: 0.875rem; 8026 + line-height: 1.7; 8027 + color: var(--color-text); 8028 + word-wrap: break-word; 8029 + } 8030 + 8031 + .diff-equal { 8032 + color: var(--color-text); 8033 + } 8034 + 8035 + .diff-insert { 8036 + background: oklch(0.92 0.08 155); 8037 + color: oklch(0.28 0.06 155); 8038 + padding: 1px 2px; 8039 + border-radius: 2px; 8040 + } 8041 + 8042 + .diff-delete { 8043 + background: oklch(0.92 0.08 25); 8044 + color: oklch(0.38 0.08 25); 8045 + text-decoration: line-through; 8046 + padding: 1px 2px; 8047 + border-radius: 2px; 8048 + } 8049 + 8050 + @media (prefers-color-scheme: dark) { 8051 + :root:not([data-theme="light"]) { 8052 + .diff-insert { 8053 + background: oklch(0.28 0.06 155); 8054 + color: oklch(0.85 0.08 155); 8055 + } 8056 + 8057 + .diff-delete { 8058 + background: oklch(0.28 0.06 25); 8059 + color: oklch(0.78 0.08 25); 8060 + } 8061 + } 8062 + } 8063 + 8064 + @media print { 8065 + .diff-panel { 8066 + display: none !important; 8067 + } 8068 + } 8069 + 8070 + /* ======================================================== 7888 8071 Print Preview Dialog 7889 8072 ======================================================== */ 7890 8073
+284
src/docs/diff-panel.ts
··· 1 + /** 2 + * Diff Panel — slide-in panel for comparing two document versions. 3 + * 4 + * Follows the same pattern as version-panel.ts: 5 + * - Fixed position right panel with CSS transition 6 + * - Toggle/open/close methods 7 + * - z-index 900 (--z-panel) 8 + * 9 + * Fetches version list from the API, lets the user pick two versions, 10 + * decrypts both snapshots, extracts TipTap JSON, and renders an inline diff. 11 + */ 12 + 13 + import { formatRelativeTime } from '../version-panel.js'; 14 + import { diffDocuments, renderDiffHtml } from './doc-diff.js'; 15 + 16 + // --- Types --- 17 + 18 + export interface DiffPanelConfig { 19 + docId: string; 20 + cryptoKey: CryptoKey; 21 + apiUrl?: string; 22 + container?: HTMLElement; 23 + } 24 + 25 + export interface DiffPanel { 26 + toggle: () => void; 27 + open: () => void; 28 + close: () => void; 29 + isOpen: () => boolean; 30 + destroy: () => void; 31 + } 32 + 33 + interface VersionData { 34 + id: string; 35 + document_id: string; 36 + created_at: string; 37 + metadata: Record<string, unknown> | null; 38 + } 39 + 40 + // --- Factory --- 41 + 42 + export function createDiffPanel(config: DiffPanelConfig): DiffPanel { 43 + const { 44 + docId, 45 + apiUrl = '', 46 + container = document.body, 47 + } = config; 48 + 49 + // Build DOM 50 + const panel = document.createElement('div'); 51 + panel.className = 'diff-panel'; 52 + panel.setAttribute('role', 'dialog'); 53 + panel.setAttribute('aria-label', 'Compare versions'); 54 + 55 + panel.innerHTML = ` 56 + <div class="diff-panel-header"> 57 + <h3>Compare Versions</h3> 58 + <button class="btn-icon diff-panel-close" title="Close (Esc)" aria-label="Close">&times;</button> 59 + </div> 60 + <div class="diff-panel-controls"> 61 + <div class="diff-panel-select-group"> 62 + <label class="diff-panel-label" for="diff-version-a">Base</label> 63 + <select id="diff-version-a" class="diff-panel-select" aria-label="Base version"></select> 64 + </div> 65 + <span class="diff-panel-arrow" aria-hidden="true">&rarr;</span> 66 + <div class="diff-panel-select-group"> 67 + <label class="diff-panel-label" for="diff-version-b">Compare</label> 68 + <select id="diff-version-b" class="diff-panel-select" aria-label="Compare version"></select> 69 + </div> 70 + <button class="btn-primary btn-sm diff-panel-run">Compare</button> 71 + </div> 72 + <div class="diff-panel-stats" style="display:none"></div> 73 + <div class="diff-panel-body"> 74 + <div class="diff-panel-empty">Select two versions to compare</div> 75 + </div> 76 + `; 77 + 78 + container.appendChild(panel); 79 + 80 + const closeBtn = panel.querySelector('.diff-panel-close') as HTMLButtonElement; 81 + const selectA = panel.querySelector('#diff-version-a') as HTMLSelectElement; 82 + const selectB = panel.querySelector('#diff-version-b') as HTMLSelectElement; 83 + const compareBtn = panel.querySelector('.diff-panel-run') as HTMLButtonElement; 84 + const statsEl = panel.querySelector('.diff-panel-stats') as HTMLDivElement; 85 + const bodyEl = panel.querySelector('.diff-panel-body') as HTMLDivElement; 86 + 87 + let isOpen_ = false; 88 + let versions: VersionData[] = []; 89 + 90 + // --- Event handlers --- 91 + closeBtn.addEventListener('click', close); 92 + compareBtn.addEventListener('click', runDiff); 93 + 94 + function handleKeydown(e: KeyboardEvent): void { 95 + if (e.key === 'Escape' && isOpen_) { 96 + e.preventDefault(); 97 + close(); 98 + } 99 + } 100 + document.addEventListener('keydown', handleKeydown); 101 + 102 + // --- Methods --- 103 + function toggle(): void { 104 + if (isOpen_) close(); 105 + else open(); 106 + } 107 + 108 + function open(): void { 109 + isOpen_ = true; 110 + panel.classList.add('open'); 111 + loadVersions(); 112 + } 113 + 114 + function close(): void { 115 + isOpen_ = false; 116 + panel.classList.remove('open'); 117 + } 118 + 119 + async function loadVersions(): Promise<void> { 120 + selectA.innerHTML = '<option value="">Loading...</option>'; 121 + selectB.innerHTML = '<option value="">Loading...</option>'; 122 + bodyEl.innerHTML = '<div class="diff-panel-empty">Loading versions...</div>'; 123 + statsEl.style.display = 'none'; 124 + 125 + try { 126 + const res = await fetch(`${apiUrl}/api/documents/${docId}/versions`); 127 + if (!res.ok) throw new Error('Failed to fetch'); 128 + versions = (await res.json()) as VersionData[]; 129 + 130 + if (versions.length < 2) { 131 + bodyEl.innerHTML = '<div class="diff-panel-empty">Need at least two versions to compare</div>'; 132 + selectA.innerHTML = '<option value="">Not enough versions</option>'; 133 + selectB.innerHTML = '<option value="">Not enough versions</option>'; 134 + return; 135 + } 136 + 137 + // Populate dropdowns — versions are newest-first from server 138 + selectA.innerHTML = ''; 139 + selectB.innerHTML = ''; 140 + 141 + for (const v of versions) { 142 + const meta = (v.metadata && typeof v.metadata === 'object') ? v.metadata : {}; 143 + const timeStr = formatRelativeTime(v.created_at); 144 + const author = (typeof meta['author'] === 'string' ? meta['author'] : 'Unknown'); 145 + const name = (typeof meta['name'] === 'string' ? meta['name'] : null); 146 + const label = name ? `${name} (${timeStr})` : `${timeStr} — ${author}`; 147 + 148 + const optA = document.createElement('option'); 149 + optA.value = v.id; 150 + optA.textContent = label; 151 + selectA.appendChild(optA); 152 + 153 + const optB = document.createElement('option'); 154 + optB.value = v.id; 155 + optB.textContent = label; 156 + selectB.appendChild(optB); 157 + } 158 + 159 + // Default: compare second-newest (base) vs newest (compare) 160 + if (versions.length >= 2) { 161 + selectA.value = versions[1]!.id; 162 + selectB.value = versions[0]!.id; 163 + } 164 + 165 + bodyEl.innerHTML = '<div class="diff-panel-empty">Select two versions and click Compare</div>'; 166 + } catch { 167 + bodyEl.innerHTML = '<div class="diff-panel-empty">Failed to load versions</div>'; 168 + } 169 + } 170 + 171 + async function fetchAndDecryptVersion(versionId: string): Promise<Uint8Array> { 172 + const res = await fetch(`${apiUrl}/api/documents/${docId}/versions/${versionId}`); 173 + if (!res.ok) throw new Error('Version not found'); 174 + const encrypted = new Uint8Array(await res.arrayBuffer()); 175 + 176 + const { decrypt } = await import('../lib/crypto.js'); 177 + return decrypt(encrypted, config.cryptoKey); 178 + } 179 + 180 + async function yDocToJson(data: Uint8Array): Promise<Record<string, unknown> | null> { 181 + const Y = await import('yjs'); 182 + const tempDoc = new Y.Doc(); 183 + Y.applyUpdate(tempDoc, data); 184 + 185 + // Extract text from the Yjs XML fragment 186 + const fragment = tempDoc.getXmlFragment('default'); 187 + const xmlStr = fragment.toString(); 188 + tempDoc.destroy(); 189 + 190 + if (!xmlStr || xmlStr === '<UNDEFINED></UNDEFINED>') return null; 191 + 192 + // Strip HTML tags and wrap as a simple TipTap-like doc for diffing 193 + const textContent = xmlStr.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); 194 + if (!textContent) return null; 195 + 196 + return { 197 + type: 'doc', 198 + content: [{ 199 + type: 'paragraph', 200 + content: [{ type: 'text', text: textContent }], 201 + }], 202 + }; 203 + } 204 + 205 + async function runDiff(): Promise<void> { 206 + const idA = selectA.value; 207 + const idB = selectB.value; 208 + 209 + if (!idA || !idB) { 210 + bodyEl.innerHTML = '<div class="diff-panel-empty">Please select two versions</div>'; 211 + return; 212 + } 213 + 214 + if (idA === idB) { 215 + bodyEl.innerHTML = '<div class="diff-panel-empty">Select two different versions to compare</div>'; 216 + return; 217 + } 218 + 219 + bodyEl.innerHTML = '<div class="diff-panel-empty">Computing diff...</div>'; 220 + statsEl.style.display = 'none'; 221 + compareBtn.disabled = true; 222 + 223 + try { 224 + const [dataA, dataB] = await Promise.all([ 225 + fetchAndDecryptVersion(idA), 226 + fetchAndDecryptVersion(idB), 227 + ]); 228 + 229 + const [jsonA, jsonB] = await Promise.all([ 230 + yDocToJson(dataA), 231 + yDocToJson(dataB), 232 + ]); 233 + 234 + const blocks = diffDocuments(jsonA, jsonB); 235 + 236 + // Compute stats 237 + let inserts = 0; 238 + let deletes = 0; 239 + for (const b of blocks) { 240 + if (b.type === 'insert') inserts += b.content.split(/\s+/).length; 241 + if (b.type === 'delete') deletes += b.content.split(/\s+/).length; 242 + } 243 + 244 + if (inserts === 0 && deletes === 0) { 245 + statsEl.style.display = 'none'; 246 + bodyEl.innerHTML = '<div class="diff-panel-empty">No differences found</div>'; 247 + } else { 248 + statsEl.style.display = ''; 249 + statsEl.innerHTML = ` 250 + <span class="diff-stat-add">+${inserts} word${inserts !== 1 ? 's' : ''}</span> 251 + <span class="diff-stat-del">&minus;${deletes} word${deletes !== 1 ? 's' : ''}</span> 252 + `; 253 + 254 + const diffHtml = renderDiffHtml(blocks); 255 + bodyEl.innerHTML = `<div class="diff-panel-content">${diffHtml}</div>`; 256 + } 257 + } catch (err) { 258 + const msg = err instanceof Error ? err.message : 'Unknown error'; 259 + bodyEl.innerHTML = `<div class="diff-panel-empty">Failed to compute diff: ${escapeHtml(msg)}</div>`; 260 + statsEl.style.display = 'none'; 261 + } finally { 262 + compareBtn.disabled = false; 263 + } 264 + } 265 + 266 + function escapeHtml(str: string): string { 267 + const div = document.createElement('div'); 268 + div.textContent = str; 269 + return div.innerHTML; 270 + } 271 + 272 + function destroy(): void { 273 + document.removeEventListener('keydown', handleKeydown); 274 + panel.remove(); 275 + } 276 + 277 + return { 278 + toggle, 279 + open, 280 + close, 281 + isOpen: () => isOpen_, 282 + destroy, 283 + }; 284 + }
+225
src/docs/doc-diff.ts
··· 1 + /** 2 + * Document Diff — pure utility module for comparing TipTap JSON documents. 3 + * 4 + * No DOM dependencies. Uses a word-level LCS (Longest Common Subsequence) 5 + * algorithm to produce inline diffs showing additions and deletions. 6 + */ 7 + 8 + // --- Types --- 9 + 10 + export interface DiffBlock { 11 + type: 'equal' | 'insert' | 'delete'; 12 + content: string; 13 + } 14 + 15 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 16 + type TipTapNode = Record<string, any>; 17 + 18 + // --- Text extraction --- 19 + 20 + /** 21 + * Recursively extract plain text from a TipTap JSON document. 22 + * Block-level nodes (paragraphs, headings, list items) are separated by newlines. 23 + */ 24 + export function extractText(doc: TipTapNode | null | undefined): string { 25 + if (!doc) return ''; 26 + 27 + const blocks: string[] = []; 28 + 29 + function walk(node: TipTapNode): void { 30 + // Text leaf node 31 + if (node.type === 'text' && typeof node.text === 'string') { 32 + // Append to the last block (or create one) 33 + if (blocks.length === 0) blocks.push(''); 34 + blocks[blocks.length - 1] += node.text; 35 + return; 36 + } 37 + 38 + // Block-level nodes that should start a new text block 39 + const blockTypes = new Set([ 40 + 'paragraph', 'heading', 'listItem', 'taskItem', 41 + 'codeBlock', 'blockquote', 42 + ]); 43 + 44 + if (blockTypes.has(node.type) && blocks.length > 0 && blocks[blocks.length - 1] !== '') { 45 + blocks.push(''); 46 + } 47 + 48 + // Recurse into children 49 + if (Array.isArray(node.content)) { 50 + for (const child of node.content) { 51 + walk(child); 52 + } 53 + } 54 + } 55 + 56 + walk(doc); 57 + 58 + // Filter out empty trailing blocks and join with newlines 59 + return blocks.filter(b => b !== '').join('\n'); 60 + } 61 + 62 + // --- LCS-based word diff --- 63 + 64 + /** 65 + * Tokenize text into words, splitting on whitespace. 66 + */ 67 + function tokenize(text: string): string[] { 68 + if (!text.trim()) return []; 69 + return text.split(/\s+/); 70 + } 71 + 72 + /** 73 + * Compute the LCS (Longest Common Subsequence) table using standard O(nm) DP. 74 + * Returns the DP table for backtracking. 75 + */ 76 + function lcsTable(a: string[], b: string[]): number[][] { 77 + const m = a.length; 78 + const n = b.length; 79 + 80 + // Create (m+1) x (n+1) table initialized to 0 81 + const dp: number[][] = []; 82 + for (let i = 0; i <= m; i++) { 83 + dp[i] = new Array(n + 1).fill(0); 84 + } 85 + 86 + for (let i = 1; i <= m; i++) { 87 + for (let j = 1; j <= n; j++) { 88 + if (a[i - 1] === b[j - 1]) { 89 + dp[i]![j] = dp[i - 1]![j - 1]! + 1; 90 + } else { 91 + dp[i]![j] = Math.max(dp[i - 1]![j]!, dp[i]![j - 1]!); 92 + } 93 + } 94 + } 95 + 96 + return dp; 97 + } 98 + 99 + /** 100 + * Backtrack through the LCS table to produce a sequence of diff operations. 101 + * Returns raw (ungrouped) operations: 'equal', 'delete', 'insert'. 102 + */ 103 + function backtrack( 104 + dp: number[][], 105 + a: string[], 106 + b: string[], 107 + ): Array<{ type: 'equal' | 'insert' | 'delete'; word: string }> { 108 + const ops: Array<{ type: 'equal' | 'insert' | 'delete'; word: string }> = []; 109 + let i = a.length; 110 + let j = b.length; 111 + 112 + while (i > 0 || j > 0) { 113 + if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) { 114 + ops.push({ type: 'equal', word: a[i - 1]! }); 115 + i--; 116 + j--; 117 + } else if (j > 0 && (i === 0 || dp[i]![j - 1]! >= dp[i - 1]![j]!)) { 118 + ops.push({ type: 'insert', word: b[j - 1]! }); 119 + j--; 120 + } else { 121 + ops.push({ type: 'delete', word: a[i - 1]! }); 122 + i--; 123 + } 124 + } 125 + 126 + return ops.reverse(); 127 + } 128 + 129 + /** 130 + * Group consecutive same-type operations into DiffBlocks. 131 + * Consecutive tokens of the same type are joined with spaces. 132 + */ 133 + function groupOps( 134 + ops: Array<{ type: 'equal' | 'insert' | 'delete'; word: string }>, 135 + ): DiffBlock[] { 136 + if (ops.length === 0) return []; 137 + 138 + const blocks: DiffBlock[] = []; 139 + let currentType = ops[0]!.type; 140 + let currentWords: string[] = [ops[0]!.word]; 141 + 142 + for (let i = 1; i < ops.length; i++) { 143 + const op = ops[i]!; 144 + if (op.type === currentType) { 145 + currentWords.push(op.word); 146 + } else { 147 + blocks.push({ type: currentType, content: currentWords.join(' ') }); 148 + currentType = op.type; 149 + currentWords = [op.word]; 150 + } 151 + } 152 + 153 + blocks.push({ type: currentType, content: currentWords.join(' ') }); 154 + return blocks; 155 + } 156 + 157 + /** 158 + * Compare two plain text strings at word level and produce diff blocks. 159 + */ 160 + export function diffWords(textA: string, textB: string): DiffBlock[] { 161 + const wordsA = tokenize(textA); 162 + const wordsB = tokenize(textB); 163 + 164 + if (wordsA.length === 0 && wordsB.length === 0) return []; 165 + 166 + if (wordsA.length === 0) { 167 + return [{ type: 'insert', content: wordsB.join(' ') }]; 168 + } 169 + 170 + if (wordsB.length === 0) { 171 + return [{ type: 'delete', content: wordsA.join(' ') }]; 172 + } 173 + 174 + const dp = lcsTable(wordsA, wordsB); 175 + const ops = backtrack(dp, wordsA, wordsB); 176 + return groupOps(ops); 177 + } 178 + 179 + // --- Document-level diff --- 180 + 181 + /** 182 + * Compare two TipTap JSON documents and produce diff blocks. 183 + * Extracts text from each document, then runs word-level diff. 184 + */ 185 + export function diffDocuments( 186 + docA: TipTapNode | null | undefined, 187 + docB: TipTapNode | null | undefined, 188 + ): DiffBlock[] { 189 + const textA = extractText(docA); 190 + const textB = extractText(docB); 191 + return diffWords(textA, textB); 192 + } 193 + 194 + // --- HTML rendering --- 195 + 196 + /** 197 + * Escape HTML special characters to prevent XSS. 198 + */ 199 + function escapeHtml(str: string): string { 200 + return str 201 + .replace(/&/g, '&amp;') 202 + .replace(/</g, '&lt;') 203 + .replace(/>/g, '&gt;') 204 + .replace(/"/g, '&quot;') 205 + .replace(/'/g, '&#39;'); 206 + } 207 + 208 + /** 209 + * Render diff blocks as styled HTML string. 210 + * 211 + * Each block is wrapped in a <span> with the appropriate CSS class: 212 + * - .diff-equal — unchanged text 213 + * - .diff-insert — added text (green) 214 + * - .diff-delete — removed text (red, strikethrough) 215 + * 216 + * Newlines within content are converted to <br> tags. 217 + */ 218 + export function renderDiffHtml(blocks: DiffBlock[]): string { 219 + if (blocks.length === 0) return ''; 220 + 221 + return blocks.map(block => { 222 + const escaped = escapeHtml(block.content).replace(/\n/g, '<br>'); 223 + return `<span class="diff-${block.type}">${escaped}</span>`; 224 + }).join(' '); 225 + }
+11
src/docs/main.ts
··· 54 54 import { htmlToMarkdown as turndownHtmlToMarkdown } from './markdown-export.js'; 55 55 import { createMarkdownToggle, TOGGLE_MODE } from './markdown-toggle.js'; 56 56 import { createVersionPanel } from '../version-panel.js'; 57 + import { createDiffPanel } from './diff-panel.js'; 57 58 import { OfflineManager } from '../lib/offline.js'; 58 59 import { mountOfflineIndicator } from '../lib/offline-indicator.js'; 59 60 import { ZenModeState, ZEN_STORAGE_KEY, ZEN_CLASS } from './zen-mode.js'; ··· 596 597 if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'h') { 597 598 e.preventDefault(); 598 599 docsVersionPanel.toggle(); 600 + } 601 + }); 602 + 603 + // --- Diff Panel (slide-in, Cmd+Shift+D) --- 604 + const docsDiffPanel = createDiffPanel({ docId, cryptoKey }); 605 + 606 + document.addEventListener('keydown', (e) => { 607 + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'd') { 608 + e.preventDefault(); 609 + docsDiffPanel.toggle(); 599 610 } 600 611 }); 601 612
+359
tests/doc-diff.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + 3 + /** 4 + * Tests for document diff utility. 5 + * 6 + * Pure logic tests for: 7 + * - Text extraction from TipTap JSON 8 + * - LCS-based word-level diff 9 + * - Diff block grouping 10 + * - HTML rendering of diffs 11 + */ 12 + 13 + import { 14 + extractText, 15 + diffWords, 16 + diffDocuments, 17 + renderDiffHtml, 18 + type DiffBlock, 19 + } from '../src/docs/doc-diff.js'; 20 + 21 + // --- extractText --- 22 + 23 + describe('extractText', () => { 24 + it('extracts text from a simple paragraph', () => { 25 + const doc = { 26 + type: 'doc', 27 + content: [ 28 + { 29 + type: 'paragraph', 30 + content: [ 31 + { type: 'text', text: 'Hello world' }, 32 + ], 33 + }, 34 + ], 35 + }; 36 + expect(extractText(doc)).toBe('Hello world'); 37 + }); 38 + 39 + it('extracts text from multiple paragraphs', () => { 40 + const doc = { 41 + type: 'doc', 42 + content: [ 43 + { 44 + type: 'paragraph', 45 + content: [{ type: 'text', text: 'First paragraph' }], 46 + }, 47 + { 48 + type: 'paragraph', 49 + content: [{ type: 'text', text: 'Second paragraph' }], 50 + }, 51 + ], 52 + }; 53 + expect(extractText(doc)).toBe('First paragraph\nSecond paragraph'); 54 + }); 55 + 56 + it('handles nested content (bold, italic)', () => { 57 + const doc = { 58 + type: 'doc', 59 + content: [ 60 + { 61 + type: 'paragraph', 62 + content: [ 63 + { type: 'text', text: 'Hello ' }, 64 + { type: 'text', text: 'bold', marks: [{ type: 'bold' }] }, 65 + { type: 'text', text: ' world' }, 66 + ], 67 + }, 68 + ], 69 + }; 70 + expect(extractText(doc)).toBe('Hello bold world'); 71 + }); 72 + 73 + it('returns empty string for empty document', () => { 74 + const doc = { type: 'doc', content: [] }; 75 + expect(extractText(doc)).toBe(''); 76 + }); 77 + 78 + it('returns empty string for null/undefined', () => { 79 + expect(extractText(null)).toBe(''); 80 + expect(extractText(undefined)).toBe(''); 81 + }); 82 + 83 + it('handles document with no content array', () => { 84 + const doc = { type: 'doc' }; 85 + expect(extractText(doc)).toBe(''); 86 + }); 87 + 88 + it('handles headings', () => { 89 + const doc = { 90 + type: 'doc', 91 + content: [ 92 + { 93 + type: 'heading', 94 + attrs: { level: 1 }, 95 + content: [{ type: 'text', text: 'Title' }], 96 + }, 97 + { 98 + type: 'paragraph', 99 + content: [{ type: 'text', text: 'Body text' }], 100 + }, 101 + ], 102 + }; 103 + expect(extractText(doc)).toBe('Title\nBody text'); 104 + }); 105 + 106 + it('handles bullet lists', () => { 107 + const doc = { 108 + type: 'doc', 109 + content: [ 110 + { 111 + type: 'bulletList', 112 + content: [ 113 + { 114 + type: 'listItem', 115 + content: [ 116 + { 117 + type: 'paragraph', 118 + content: [{ type: 'text', text: 'Item one' }], 119 + }, 120 + ], 121 + }, 122 + { 123 + type: 'listItem', 124 + content: [ 125 + { 126 + type: 'paragraph', 127 + content: [{ type: 'text', text: 'Item two' }], 128 + }, 129 + ], 130 + }, 131 + ], 132 + }, 133 + ], 134 + }; 135 + expect(extractText(doc)).toBe('Item one\nItem two'); 136 + }); 137 + }); 138 + 139 + // --- diffWords --- 140 + 141 + describe('diffWords', () => { 142 + it('returns equal blocks for identical text', () => { 143 + const result = diffWords('hello world', 'hello world'); 144 + expect(result).toEqual([ 145 + { type: 'equal', content: 'hello world' }, 146 + ]); 147 + }); 148 + 149 + it('detects inserted words', () => { 150 + const result = diffWords('hello world', 'hello beautiful world'); 151 + expect(result).toEqual([ 152 + { type: 'equal', content: 'hello' }, 153 + { type: 'insert', content: 'beautiful' }, 154 + { type: 'equal', content: 'world' }, 155 + ]); 156 + }); 157 + 158 + it('detects deleted words', () => { 159 + const result = diffWords('hello beautiful world', 'hello world'); 160 + expect(result).toEqual([ 161 + { type: 'equal', content: 'hello' }, 162 + { type: 'delete', content: 'beautiful' }, 163 + { type: 'equal', content: 'world' }, 164 + ]); 165 + }); 166 + 167 + it('handles complete replacement', () => { 168 + const result = diffWords('alpha beta', 'gamma delta'); 169 + expect(result).toEqual([ 170 + { type: 'delete', content: 'alpha beta' }, 171 + { type: 'insert', content: 'gamma delta' }, 172 + ]); 173 + }); 174 + 175 + it('handles empty old text', () => { 176 + const result = diffWords('', 'hello world'); 177 + expect(result).toEqual([ 178 + { type: 'insert', content: 'hello world' }, 179 + ]); 180 + }); 181 + 182 + it('handles empty new text', () => { 183 + const result = diffWords('hello world', ''); 184 + expect(result).toEqual([ 185 + { type: 'delete', content: 'hello world' }, 186 + ]); 187 + }); 188 + 189 + it('handles both empty', () => { 190 + const result = diffWords('', ''); 191 + expect(result).toEqual([]); 192 + }); 193 + 194 + it('handles single word change', () => { 195 + const result = diffWords('the cat sat', 'the dog sat'); 196 + expect(result).toEqual([ 197 + { type: 'equal', content: 'the' }, 198 + { type: 'delete', content: 'cat' }, 199 + { type: 'insert', content: 'dog' }, 200 + { type: 'equal', content: 'sat' }, 201 + ]); 202 + }); 203 + 204 + it('handles multiple scattered changes', () => { 205 + const result = diffWords('a b c d e', 'a x c y e'); 206 + expect(result).toEqual([ 207 + { type: 'equal', content: 'a' }, 208 + { type: 'delete', content: 'b' }, 209 + { type: 'insert', content: 'x' }, 210 + { type: 'equal', content: 'c' }, 211 + { type: 'delete', content: 'd' }, 212 + { type: 'insert', content: 'y' }, 213 + { type: 'equal', content: 'e' }, 214 + ]); 215 + }); 216 + 217 + it('groups consecutive same-type tokens', () => { 218 + const result = diffWords('a b c', 'a b c d e'); 219 + expect(result).toEqual([ 220 + { type: 'equal', content: 'a b c' }, 221 + { type: 'insert', content: 'd e' }, 222 + ]); 223 + }); 224 + }); 225 + 226 + // --- diffDocuments --- 227 + 228 + describe('diffDocuments', () => { 229 + const makeDoc = (text: string) => ({ 230 + type: 'doc', 231 + content: [ 232 + { 233 + type: 'paragraph', 234 + content: [{ type: 'text', text }], 235 + }, 236 + ], 237 + }); 238 + 239 + it('returns all equal for identical documents', () => { 240 + const doc = makeDoc('hello world'); 241 + const result = diffDocuments(doc, doc); 242 + expect(result).toEqual([ 243 + { type: 'equal', content: 'hello world' }, 244 + ]); 245 + }); 246 + 247 + it('detects added text', () => { 248 + const docA = makeDoc('hello world'); 249 + const docB = makeDoc('hello beautiful world'); 250 + const result = diffDocuments(docA, docB); 251 + expect(result).toEqual([ 252 + { type: 'equal', content: 'hello' }, 253 + { type: 'insert', content: 'beautiful' }, 254 + { type: 'equal', content: 'world' }, 255 + ]); 256 + }); 257 + 258 + it('detects removed text', () => { 259 + const docA = makeDoc('hello beautiful world'); 260 + const docB = makeDoc('hello world'); 261 + const result = diffDocuments(docA, docB); 262 + expect(result).toEqual([ 263 + { type: 'equal', content: 'hello' }, 264 + { type: 'delete', content: 'beautiful' }, 265 + { type: 'equal', content: 'world' }, 266 + ]); 267 + }); 268 + 269 + it('handles mixed changes', () => { 270 + const docA = makeDoc('the quick brown fox'); 271 + const docB = makeDoc('the slow brown dog'); 272 + const result = diffDocuments(docA, docB); 273 + expect(result).toContainEqual({ type: 'equal', content: 'the' }); 274 + expect(result).toContainEqual({ type: 'delete', content: 'quick' }); 275 + expect(result).toContainEqual({ type: 'insert', content: 'slow' }); 276 + expect(result).toContainEqual({ type: 'equal', content: 'brown' }); 277 + expect(result).toContainEqual({ type: 'delete', content: 'fox' }); 278 + expect(result).toContainEqual({ type: 'insert', content: 'dog' }); 279 + }); 280 + 281 + it('handles empty documents', () => { 282 + const emptyDoc = { type: 'doc', content: [] }; 283 + const result = diffDocuments(emptyDoc, emptyDoc); 284 + expect(result).toEqual([]); 285 + }); 286 + 287 + it('handles null documents', () => { 288 + const doc = makeDoc('hello'); 289 + expect(diffDocuments(null, doc)).toEqual([ 290 + { type: 'insert', content: 'hello' }, 291 + ]); 292 + expect(diffDocuments(doc, null)).toEqual([ 293 + { type: 'delete', content: 'hello' }, 294 + ]); 295 + }); 296 + }); 297 + 298 + // --- renderDiffHtml --- 299 + 300 + describe('renderDiffHtml', () => { 301 + it('renders equal blocks as plain spans', () => { 302 + const blocks: DiffBlock[] = [{ type: 'equal', content: 'hello' }]; 303 + const html = renderDiffHtml(blocks); 304 + expect(html).toContain('diff-equal'); 305 + expect(html).toContain('hello'); 306 + }); 307 + 308 + it('renders insert blocks with diff-insert class', () => { 309 + const blocks: DiffBlock[] = [{ type: 'insert', content: 'added' }]; 310 + const html = renderDiffHtml(blocks); 311 + expect(html).toContain('diff-insert'); 312 + expect(html).toContain('added'); 313 + }); 314 + 315 + it('renders delete blocks with diff-delete class', () => { 316 + const blocks: DiffBlock[] = [{ type: 'delete', content: 'removed' }]; 317 + const html = renderDiffHtml(blocks); 318 + expect(html).toContain('diff-delete'); 319 + expect(html).toContain('removed'); 320 + }); 321 + 322 + it('escapes HTML in content', () => { 323 + const blocks: DiffBlock[] = [ 324 + { type: 'equal', content: '<script>alert("xss")</script>' }, 325 + ]; 326 + const html = renderDiffHtml(blocks); 327 + expect(html).not.toContain('<script>'); 328 + expect(html).toContain('&lt;script&gt;'); 329 + }); 330 + 331 + it('renders mixed blocks in order', () => { 332 + const blocks: DiffBlock[] = [ 333 + { type: 'equal', content: 'hello' }, 334 + { type: 'delete', content: 'old' }, 335 + { type: 'insert', content: 'new' }, 336 + { type: 'equal', content: 'world' }, 337 + ]; 338 + const html = renderDiffHtml(blocks); 339 + const eqPos = html.indexOf('hello'); 340 + const delPos = html.indexOf('old'); 341 + const insPos = html.indexOf('new'); 342 + const eq2Pos = html.indexOf('world'); 343 + expect(eqPos).toBeLessThan(delPos); 344 + expect(delPos).toBeLessThan(insPos); 345 + expect(insPos).toBeLessThan(eq2Pos); 346 + }); 347 + 348 + it('returns empty string for empty blocks array', () => { 349 + expect(renderDiffHtml([])).toBe(''); 350 + }); 351 + 352 + it('preserves newlines as <br> tags', () => { 353 + const blocks: DiffBlock[] = [ 354 + { type: 'equal', content: 'line one\nline two' }, 355 + ]; 356 + const html = renderDiffHtml(blocks); 357 + expect(html).toContain('<br>'); 358 + }); 359 + });
+427
tests/extended-rrule.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + expandRecurringEvents, 4 + formatDate, 5 + type CalendarEvent, 6 + type Recurrence, 7 + type Weekday, 8 + } from '../src/calendar/helpers.js'; 9 + 10 + /** 11 + * Extended RRULE tests — interval, byDay, bySetPos, exDates. 12 + * 13 + * Covers: 14 + * 1. Interval (every N periods) 15 + * 2. Weekly byDay (specific weekdays) 16 + * 3. Monthly byDay + bySetPos (Nth weekday of month) 17 + * 4. Exception dates (exDates) 18 + * 5. Backward compatibility (interval=1, no byDay/exDates) 19 + * 6. Edge cases (interval=0, empty byDay) 20 + */ 21 + 22 + // --- Helpers --- 23 + 24 + function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent { 25 + return { 26 + id: 'test-1', 27 + title: 'Test Event', 28 + date: '2026-01-05', // Monday 29 + startTime: '09:00', 30 + endTime: '10:00', 31 + allDay: false, 32 + color: '#3a8a7a', 33 + description: '', 34 + createdAt: 0, 35 + updatedAt: 0, 36 + ...overrides, 37 + }; 38 + } 39 + 40 + function expandDates(event: CalendarEvent, rangeStart: string, rangeEnd: string): string[] { 41 + const instances = expandRecurringEvents([event], rangeStart, rangeEnd); 42 + return instances.map(e => e.date).sort(); 43 + } 44 + 45 + // --------------------------------------------------------------------------- 46 + // 1. Interval 47 + // --------------------------------------------------------------------------- 48 + 49 + describe('interval support', () => { 50 + it('daily with interval=2 skips every other day', () => { 51 + const evt = makeEvent({ 52 + date: '2026-01-01', 53 + recurrence: { type: 'daily', interval: 2, until: '2026-01-10' }, 54 + }); 55 + const dates = expandDates(evt, '2026-01-01', '2026-01-10'); 56 + expect(dates).toEqual([ 57 + '2026-01-01', '2026-01-03', '2026-01-05', '2026-01-07', '2026-01-09', 58 + ]); 59 + }); 60 + 61 + it('weekly with interval=2 repeats every 2 weeks', () => { 62 + const evt = makeEvent({ 63 + date: '2026-01-05', // Monday 64 + recurrence: { type: 'weekly', interval: 2, until: '2026-02-16' }, 65 + }); 66 + const dates = expandDates(evt, '2026-01-05', '2026-02-16'); 67 + // Jan 5, Jan 19, Feb 2, Feb 16 68 + expect(dates).toEqual([ 69 + '2026-01-05', '2026-01-19', '2026-02-02', '2026-02-16', 70 + ]); 71 + }); 72 + 73 + it('monthly with interval=3 repeats every 3 months', () => { 74 + const evt = makeEvent({ 75 + date: '2026-01-15', 76 + recurrence: { type: 'monthly', interval: 3, until: '2026-12-31' }, 77 + }); 78 + const dates = expandDates(evt, '2026-01-01', '2026-12-31'); 79 + // Jan 15, Apr 15, Jul 15, Oct 15 80 + expect(dates).toEqual([ 81 + '2026-01-15', '2026-04-15', '2026-07-15', '2026-10-15', 82 + ]); 83 + }); 84 + 85 + it('yearly with interval=2 repeats every 2 years', () => { 86 + const evt = makeEvent({ 87 + date: '2026-03-10', 88 + recurrence: { type: 'yearly', interval: 2, until: '2032-12-31' }, 89 + }); 90 + const dates = expandDates(evt, '2026-01-01', '2032-12-31'); 91 + // 2026, 2028, 2030, 2032 92 + expect(dates).toEqual([ 93 + '2026-03-10', '2028-03-10', '2030-03-10', '2032-03-10', 94 + ]); 95 + }); 96 + 97 + it('interval=1 works same as before (backward compat)', () => { 98 + const evtInterval1 = makeEvent({ 99 + date: '2026-01-01', 100 + recurrence: { type: 'daily', interval: 1, until: '2026-01-05' }, 101 + }); 102 + const evtNoInterval = makeEvent({ 103 + date: '2026-01-01', 104 + recurrence: { type: 'daily', until: '2026-01-05' }, 105 + }); 106 + const dates1 = expandDates(evtInterval1, '2026-01-01', '2026-01-05'); 107 + const datesNone = expandDates(evtNoInterval, '2026-01-01', '2026-01-05'); 108 + expect(dates1).toEqual(datesNone); 109 + expect(dates1).toEqual([ 110 + '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04', '2026-01-05', 111 + ]); 112 + }); 113 + 114 + it('interval=0 is treated as interval=1', () => { 115 + const evt = makeEvent({ 116 + date: '2026-01-01', 117 + recurrence: { type: 'daily', interval: 0, until: '2026-01-03' }, 118 + }); 119 + const dates = expandDates(evt, '2026-01-01', '2026-01-03'); 120 + expect(dates).toEqual(['2026-01-01', '2026-01-02', '2026-01-03']); 121 + }); 122 + }); 123 + 124 + // --------------------------------------------------------------------------- 125 + // 2. Weekly byDay (specific weekdays) 126 + // --------------------------------------------------------------------------- 127 + 128 + describe('weekly byDay', () => { 129 + it('weekly on Mon, Wed, Fri generates all matching days', () => { 130 + // 2026-01-05 is a Monday. Range covers 2 full weeks. 131 + const evt = makeEvent({ 132 + date: '2026-01-05', 133 + recurrence: { 134 + type: 'weekly', 135 + byDay: ['MO', 'WE', 'FR'], 136 + until: '2026-01-18', 137 + }, 138 + }); 139 + const dates = expandDates(evt, '2026-01-05', '2026-01-18'); 140 + // Week 1: Mon 5, Wed 7, Fri 9 141 + // Week 2: Mon 12, Wed 14, Fri 16 142 + expect(dates).toEqual([ 143 + '2026-01-05', '2026-01-07', '2026-01-09', 144 + '2026-01-12', '2026-01-14', '2026-01-16', 145 + ]); 146 + }); 147 + 148 + it('weekly byDay with interval=2 skips every other week', () => { 149 + const evt = makeEvent({ 150 + date: '2026-01-05', 151 + recurrence: { 152 + type: 'weekly', 153 + interval: 2, 154 + byDay: ['MO', 'FR'], 155 + until: '2026-02-01', 156 + }, 157 + }); 158 + const dates = expandDates(evt, '2026-01-05', '2026-02-01'); 159 + // Week of Jan 5 (week 1, active): Mon 5, Fri 9 160 + // Week of Jan 12 (week 2, skip) 161 + // Week of Jan 19 (week 3, active): Mon 19, Fri 23 162 + expect(dates).toEqual([ 163 + '2026-01-05', '2026-01-09', 164 + '2026-01-19', '2026-01-23', 165 + ]); 166 + }); 167 + 168 + it('empty byDay falls back to event start day', () => { 169 + const evt = makeEvent({ 170 + date: '2026-01-05', // Monday 171 + recurrence: { 172 + type: 'weekly', 173 + byDay: [], 174 + until: '2026-01-19', 175 + }, 176 + }); 177 + const dates = expandDates(evt, '2026-01-05', '2026-01-19'); 178 + // Should behave like normal weekly: Jan 5, 12, 19 179 + expect(dates).toEqual(['2026-01-05', '2026-01-12', '2026-01-19']); 180 + }); 181 + 182 + it('weekday preset (Mon-Fri) skips weekends', () => { 183 + const evt = makeEvent({ 184 + date: '2026-01-05', // Monday 185 + recurrence: { 186 + type: 'weekly', 187 + byDay: ['MO', 'TU', 'WE', 'TH', 'FR'], 188 + until: '2026-01-11', // Sunday 189 + }, 190 + }); 191 + const dates = expandDates(evt, '2026-01-05', '2026-01-11'); 192 + // Mon-Fri of that week only (Sat 10 and Sun 11 excluded) 193 + expect(dates).toEqual([ 194 + '2026-01-05', '2026-01-06', '2026-01-07', '2026-01-08', '2026-01-09', 195 + ]); 196 + }); 197 + }); 198 + 199 + // --------------------------------------------------------------------------- 200 + // 3. Monthly byDay + bySetPos (Nth weekday of month) 201 + // --------------------------------------------------------------------------- 202 + 203 + describe('monthly byDay + bySetPos', () => { 204 + it('first Monday of every month', () => { 205 + const evt = makeEvent({ 206 + date: '2026-01-05', // first Monday of Jan 2026 207 + recurrence: { 208 + type: 'monthly', 209 + byDay: ['MO'], 210 + bySetPos: 1, 211 + until: '2026-06-30', 212 + }, 213 + }); 214 + const dates = expandDates(evt, '2026-01-01', '2026-06-30'); 215 + // First Mondays: Jan 5, Feb 2, Mar 2, Apr 6, May 4, Jun 1 216 + expect(dates).toEqual([ 217 + '2026-01-05', '2026-02-02', '2026-03-02', 218 + '2026-04-06', '2026-05-04', '2026-06-01', 219 + ]); 220 + }); 221 + 222 + it('last Friday of every month', () => { 223 + const evt = makeEvent({ 224 + date: '2026-01-30', // last Friday of Jan 2026 225 + recurrence: { 226 + type: 'monthly', 227 + byDay: ['FR'], 228 + bySetPos: -1, 229 + until: '2026-06-30', 230 + }, 231 + }); 232 + const dates = expandDates(evt, '2026-01-01', '2026-06-30'); 233 + // Last Fridays: Jan 30, Feb 27, Mar 27, Apr 24, May 29, Jun 26 234 + expect(dates).toEqual([ 235 + '2026-01-30', '2026-02-27', '2026-03-27', 236 + '2026-04-24', '2026-05-29', '2026-06-26', 237 + ]); 238 + }); 239 + 240 + it('second Wednesday of every month', () => { 241 + const evt = makeEvent({ 242 + date: '2026-01-14', // second Wednesday of Jan 2026 243 + recurrence: { 244 + type: 'monthly', 245 + byDay: ['WE'], 246 + bySetPos: 2, 247 + until: '2026-04-30', 248 + }, 249 + }); 250 + const dates = expandDates(evt, '2026-01-01', '2026-04-30'); 251 + // 2nd Wednesdays: Jan 14, Feb 11, Mar 11, Apr 8 252 + expect(dates).toEqual([ 253 + '2026-01-14', '2026-02-11', '2026-03-11', '2026-04-08', 254 + ]); 255 + }); 256 + 257 + it('monthly byDay+bySetPos with interval=2', () => { 258 + const evt = makeEvent({ 259 + date: '2026-01-05', // first Monday of Jan 2026 260 + recurrence: { 261 + type: 'monthly', 262 + interval: 2, 263 + byDay: ['MO'], 264 + bySetPos: 1, 265 + until: '2026-12-31', 266 + }, 267 + }); 268 + const dates = expandDates(evt, '2026-01-01', '2026-12-31'); 269 + // Every 2 months: Jan, Mar, May, Jul, Sep, Nov 270 + // First Mondays: Jan 5, Mar 2, May 4, Jul 6, Sep 7, Nov 2 271 + expect(dates).toEqual([ 272 + '2026-01-05', '2026-03-02', '2026-05-04', 273 + '2026-07-06', '2026-09-07', '2026-11-02', 274 + ]); 275 + }); 276 + }); 277 + 278 + // --------------------------------------------------------------------------- 279 + // 4. Exception dates (exDates) 280 + // --------------------------------------------------------------------------- 281 + 282 + describe('exception dates (exDates)', () => { 283 + it('skips dates listed in exDates', () => { 284 + const evt = makeEvent({ 285 + date: '2026-01-05', 286 + recurrence: { 287 + type: 'weekly', 288 + until: '2026-02-02', 289 + exDates: ['2026-01-12', '2026-01-26'], 290 + }, 291 + }); 292 + const dates = expandDates(evt, '2026-01-05', '2026-02-02'); 293 + // Normal: Jan 5, 12, 19, 26, Feb 2 294 + // After exDates: Jan 5, 19, Feb 2 295 + expect(dates).toEqual(['2026-01-05', '2026-01-19', '2026-02-02']); 296 + }); 297 + 298 + it('exDates on daily recurrence', () => { 299 + const evt = makeEvent({ 300 + date: '2026-01-01', 301 + recurrence: { 302 + type: 'daily', 303 + until: '2026-01-07', 304 + exDates: ['2026-01-03', '2026-01-05'], 305 + }, 306 + }); 307 + const dates = expandDates(evt, '2026-01-01', '2026-01-07'); 308 + expect(dates).toEqual([ 309 + '2026-01-01', '2026-01-02', '2026-01-04', '2026-01-06', '2026-01-07', 310 + ]); 311 + }); 312 + 313 + it('exDates with byDay weekly', () => { 314 + const evt = makeEvent({ 315 + date: '2026-01-05', 316 + recurrence: { 317 + type: 'weekly', 318 + byDay: ['MO', 'WE', 'FR'], 319 + until: '2026-01-16', 320 + exDates: ['2026-01-07', '2026-01-12'], // skip Wed 7, Mon 12 321 + }, 322 + }); 323 + const dates = expandDates(evt, '2026-01-05', '2026-01-16'); 324 + // All: Mon 5, Wed 7, Fri 9, Mon 12, Wed 14, Fri 16 325 + // After exDates: Mon 5, Fri 9, Wed 14, Fri 16 326 + expect(dates).toEqual([ 327 + '2026-01-05', '2026-01-09', '2026-01-14', '2026-01-16', 328 + ]); 329 + }); 330 + 331 + it('empty exDates array does not filter anything', () => { 332 + const evt = makeEvent({ 333 + date: '2026-01-01', 334 + recurrence: { type: 'daily', until: '2026-01-03', exDates: [] }, 335 + }); 336 + const dates = expandDates(evt, '2026-01-01', '2026-01-03'); 337 + expect(dates).toEqual(['2026-01-01', '2026-01-02', '2026-01-03']); 338 + }); 339 + }); 340 + 341 + // --------------------------------------------------------------------------- 342 + // 5. Backward compatibility 343 + // --------------------------------------------------------------------------- 344 + 345 + describe('backward compatibility', () => { 346 + it('recurrence without interval/byDay/exDates works unchanged', () => { 347 + const evt = makeEvent({ 348 + date: '2026-01-01', 349 + recurrence: { type: 'daily', until: '2026-01-05' }, 350 + }); 351 + const dates = expandDates(evt, '2026-01-01', '2026-01-05'); 352 + expect(dates).toEqual([ 353 + '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04', '2026-01-05', 354 + ]); 355 + }); 356 + 357 + it('simple weekly recurrence still works', () => { 358 + const evt = makeEvent({ 359 + date: '2026-01-05', 360 + recurrence: { type: 'weekly', until: '2026-01-26' }, 361 + }); 362 + const dates = expandDates(evt, '2026-01-05', '2026-01-26'); 363 + expect(dates).toEqual(['2026-01-05', '2026-01-12', '2026-01-19', '2026-01-26']); 364 + }); 365 + 366 + it('simple monthly recurrence still works', () => { 367 + const evt = makeEvent({ 368 + date: '2026-01-15', 369 + recurrence: { type: 'monthly', until: '2026-04-30' }, 370 + }); 371 + const dates = expandDates(evt, '2026-01-01', '2026-04-30'); 372 + expect(dates).toEqual(['2026-01-15', '2026-02-15', '2026-03-15', '2026-04-15']); 373 + }); 374 + 375 + it('non-recurring events pass through unchanged', () => { 376 + const evt = makeEvent({ date: '2026-01-10' }); 377 + const result = expandRecurringEvents([evt], '2026-01-01', '2026-01-31'); 378 + expect(result).toHaveLength(1); 379 + expect(result[0]!.date).toBe('2026-01-10'); 380 + }); 381 + }); 382 + 383 + // --------------------------------------------------------------------------- 384 + // 6. Edge cases 385 + // --------------------------------------------------------------------------- 386 + 387 + describe('edge cases', () => { 388 + it('monthly byDay without bySetPos defaults to first occurrence', () => { 389 + const evt = makeEvent({ 390 + date: '2026-01-05', // Monday 391 + recurrence: { 392 + type: 'monthly', 393 + byDay: ['MO'], 394 + // no bySetPos — should default to 1 (first) 395 + until: '2026-03-31', 396 + }, 397 + }); 398 + const dates = expandDates(evt, '2026-01-01', '2026-03-31'); 399 + // First Mondays: Jan 5, Feb 2, Mar 2 400 + expect(dates).toEqual(['2026-01-05', '2026-02-02', '2026-03-02']); 401 + }); 402 + 403 + it('multi-day recurring event with interval preserves duration', () => { 404 + const evt = makeEvent({ 405 + date: '2026-01-05', 406 + endDate: '2026-01-07', // 2-day event 407 + recurrence: { type: 'weekly', interval: 2, until: '2026-02-01' }, 408 + }); 409 + const instances = expandRecurringEvents([evt], '2026-01-01', '2026-02-01'); 410 + // Jan 5-7, Jan 19-21 (next would be Feb 2, but until is Feb 1) 411 + expect(instances).toHaveLength(2); 412 + expect(instances[0]!.date).toBe('2026-01-05'); 413 + expect(instances[0]!.endDate).toBe('2026-01-07'); 414 + expect(instances[1]!.date).toBe('2026-01-19'); 415 + expect(instances[1]!.endDate).toBe('2026-01-21'); 416 + }); 417 + 418 + it('daily interval respects until boundary', () => { 419 + const evt = makeEvent({ 420 + date: '2026-01-01', 421 + recurrence: { type: 'daily', interval: 3, until: '2026-01-08' }, 422 + }); 423 + const dates = expandDates(evt, '2026-01-01', '2026-01-10'); 424 + // Jan 1, 4, 7 (next would be 10, but until is 8) 425 + expect(dates).toEqual(['2026-01-01', '2026-01-04', '2026-01-07']); 426 + }); 427 + });
+587
tests/wireframe-renderers.test.ts
··· 1 + // @vitest-environment jsdom 2 + import { describe, it, expect, beforeEach } from 'vitest'; 3 + import { 4 + appendBrowserWindow, 5 + appendMobileFrame, 6 + appendModal, 7 + appendCard, 8 + appendButton, 9 + appendInputField, 10 + appendSelect, 11 + appendCheckbox, 12 + appendRadio, 13 + appendToggle, 14 + appendNavbar, 15 + appendTable, 16 + appendProgressBar, 17 + appendAvatar, 18 + appendAlert, 19 + appendTabs, 20 + } from '../src/diagrams/wireframe-renderers.js'; 21 + 22 + const NS = 'http://www.w3.org/2000/svg'; 23 + 24 + function makeG(): SVGGElement { 25 + return document.createElementNS(NS, 'g') as SVGGElement; 26 + } 27 + 28 + /** Collect all direct children of a given tag name from g */ 29 + function childrenOfTag(g: SVGGElement, tag: string): Element[] { 30 + return Array.from(g.children).filter(c => c.tagName === tag); 31 + } 32 + 33 + // --------------------------------------------------------------------------- 34 + // appendBrowserWindow 35 + // --------------------------------------------------------------------------- 36 + describe('appendBrowserWindow', () => { 37 + it('creates the expected number of child elements', () => { 38 + const g = makeG(); 39 + appendBrowserWindow(g, 300, 200, '#ffffff', '#333'); 40 + // outer rect + title bar rect + bottom edge rect + line + 3 traffic lights + url bar = 8 41 + expect(g.children.length).toBe(8); 42 + }); 43 + 44 + it('outer rect uses full width/height and rounded corners', () => { 45 + const g = makeG(); 46 + appendBrowserWindow(g, 300, 200, '#fff', '#333', '3'); 47 + const outer = g.children[0] as SVGRectElement; 48 + expect(outer.getAttribute('width')).toBe('300'); 49 + expect(outer.getAttribute('height')).toBe('200'); 50 + expect(outer.getAttribute('rx')).toBe('6'); 51 + expect(outer.getAttribute('stroke-width')).toBe('3'); 52 + }); 53 + 54 + it('has three traffic-light circles with correct colors', () => { 55 + const g = makeG(); 56 + appendBrowserWindow(g, 300, 200, '#fff', '#000'); 57 + const circles = childrenOfTag(g, 'circle'); 58 + expect(circles.length).toBe(3); 59 + expect(circles[0].getAttribute('fill')).toBe('#ff5f57'); 60 + expect(circles[1].getAttribute('fill')).toBe('#febc2e'); 61 + expect(circles[2].getAttribute('fill')).toBe('#28c840'); 62 + }); 63 + 64 + it('positions the URL bar proportionally to width', () => { 65 + const g = makeG(); 66 + appendBrowserWindow(g, 400, 200, '#fff', '#000'); 67 + // URL bar is the last child (rect at index 7) 68 + const urlBar = g.children[7]; 69 + expect(urlBar.getAttribute('x')).toBe(String(400 * 0.2)); 70 + expect(urlBar.getAttribute('width')).toBe(String(400 * 0.6)); 71 + }); 72 + }); 73 + 74 + // --------------------------------------------------------------------------- 75 + // appendMobileFrame 76 + // --------------------------------------------------------------------------- 77 + describe('appendMobileFrame', () => { 78 + it('creates the expected number of child elements', () => { 79 + const g = makeG(); 80 + appendMobileFrame(g, 180, 360, '#fff', '#333'); 81 + // outer rect + status bar rect + status bar bottom + notch + home indicator = 5 82 + expect(g.children.length).toBe(5); 83 + }); 84 + 85 + it('outer rect has rx=16 for rounded device corners', () => { 86 + const g = makeG(); 87 + appendMobileFrame(g, 180, 360, '#fff', '#333'); 88 + const outer = g.children[0]; 89 + expect(outer.getAttribute('rx')).toBe('16'); 90 + }); 91 + 92 + it('notch is centered horizontally and 30% of width', () => { 93 + const g = makeG(); 94 + const w = 200; 95 + appendMobileFrame(g, w, 400, '#fff', '#333'); 96 + const notch = g.children[3]; // fourth child 97 + const notchW = w * 0.3; 98 + expect(notch.getAttribute('width')).toBe(String(notchW)); 99 + expect(notch.getAttribute('x')).toBe(String((w - notchW) / 2)); 100 + expect(notch.getAttribute('fill')).toBe('#333'); 101 + }); 102 + 103 + it('home indicator is near the bottom of the frame', () => { 104 + const g = makeG(); 105 + appendMobileFrame(g, 180, 400, '#fff', '#333'); 106 + const indicator = g.children[4]; 107 + expect(indicator.getAttribute('y')).toBe(String(400 - 8)); 108 + expect(indicator.getAttribute('width')).toBe('40'); 109 + }); 110 + }); 111 + 112 + // --------------------------------------------------------------------------- 113 + // appendModal 114 + // --------------------------------------------------------------------------- 115 + describe('appendModal', () => { 116 + it('creates shadow, body, header line, and close X (5 elements)', () => { 117 + const g = makeG(); 118 + appendModal(g, 300, 200, '#fff', '#333'); 119 + // shadow rect + modal rect + header line + 2 close lines = 5 120 + expect(g.children.length).toBe(5); 121 + }); 122 + 123 + it('shadow rect is offset by 4px', () => { 124 + const g = makeG(); 125 + appendModal(g, 300, 200, '#fff', '#333'); 126 + const shadow = g.children[0]; 127 + expect(shadow.getAttribute('x')).toBe('4'); 128 + expect(shadow.getAttribute('y')).toBe('4'); 129 + expect(shadow.getAttribute('fill')).toBe('rgba(0,0,0,0.1)'); 130 + }); 131 + 132 + it('close X lines are positioned near the right edge', () => { 133 + const g = makeG(); 134 + const w = 300; 135 + appendModal(g, w, 200, '#fff', '#333'); 136 + const cx = w - 20; 137 + const line1 = g.children[3]; 138 + expect(line1.getAttribute('x1')).toBe(String(cx - 5)); 139 + expect(line1.getAttribute('x2')).toBe(String(cx + 5)); 140 + }); 141 + }); 142 + 143 + // --------------------------------------------------------------------------- 144 + // appendCard 145 + // --------------------------------------------------------------------------- 146 + describe('appendCard', () => { 147 + it('creates the expected number of child elements', () => { 148 + const g = makeG(); 149 + appendCard(g, 200, 250, '#fff', '#333'); 150 + // shadow + card body + image area rect + image bottom rect + circle icon + polygon icon = 6 151 + expect(g.children.length).toBe(6); 152 + }); 153 + 154 + it('image placeholder area is 40% of card height', () => { 155 + const g = makeG(); 156 + const h = 250; 157 + appendCard(g, 200, h, '#fff', '#333'); 158 + const imgRect = g.children[2]; // third child 159 + expect(imgRect.getAttribute('height')).toBe(String(h * 0.4)); 160 + }); 161 + 162 + it('image icon includes a circle and a polygon', () => { 163 + const g = makeG(); 164 + appendCard(g, 200, 250, '#fff', '#333'); 165 + const circles = childrenOfTag(g, 'circle'); 166 + const polygons = childrenOfTag(g, 'polygon'); 167 + expect(circles.length).toBe(1); 168 + expect(polygons.length).toBe(1); 169 + }); 170 + }); 171 + 172 + // --------------------------------------------------------------------------- 173 + // appendButton 174 + // --------------------------------------------------------------------------- 175 + describe('appendButton', () => { 176 + it('creates a rect and a text element', () => { 177 + const g = makeG(); 178 + appendButton(g, 100, 36, '#4472c4', '#333'); 179 + expect(g.children.length).toBe(2); 180 + expect(g.children[0].tagName).toBe('rect'); 181 + expect(g.children[1].tagName).toBe('text'); 182 + }); 183 + 184 + it('uses blue fill (#4472c4) when fill is white (#ffffff)', () => { 185 + const g = makeG(); 186 + appendButton(g, 100, 36, '#ffffff', '#333'); 187 + expect(g.children[0].getAttribute('fill')).toBe('#4472c4'); 188 + }); 189 + 190 + it('uses the provided fill when it is not white', () => { 191 + const g = makeG(); 192 + appendButton(g, 100, 36, '#ff0000', '#333'); 193 + expect(g.children[0].getAttribute('fill')).toBe('#ff0000'); 194 + }); 195 + 196 + it('text content is "Button"', () => { 197 + const g = makeG(); 198 + appendButton(g, 100, 36, '#ffffff', '#333'); 199 + expect(g.children[1].textContent).toBe('Button'); 200 + }); 201 + }); 202 + 203 + // --------------------------------------------------------------------------- 204 + // appendInputField 205 + // --------------------------------------------------------------------------- 206 + describe('appendInputField', () => { 207 + it('creates a rect and placeholder text', () => { 208 + const g = makeG(); 209 + appendInputField(g, 200, 32, '#fff', '#ccc'); 210 + expect(g.children.length).toBe(2); 211 + expect(g.children[1].textContent).toBe('Placeholder...'); 212 + }); 213 + 214 + it('rect uses provided dimensions and rx=3', () => { 215 + const g = makeG(); 216 + appendInputField(g, 200, 32, '#fff', '#ccc'); 217 + const rect = g.children[0]; 218 + expect(rect.getAttribute('width')).toBe('200'); 219 + expect(rect.getAttribute('height')).toBe('32'); 220 + expect(rect.getAttribute('rx')).toBe('3'); 221 + }); 222 + }); 223 + 224 + // --------------------------------------------------------------------------- 225 + // appendSelect 226 + // --------------------------------------------------------------------------- 227 + describe('appendSelect', () => { 228 + it('creates rect, text, and chevron polyline', () => { 229 + const g = makeG(); 230 + appendSelect(g, 200, 32, '#fff', '#ccc'); 231 + // rect + text + polyline = 3 232 + expect(g.children.length).toBe(3); 233 + expect(childrenOfTag(g, 'polyline').length).toBe(1); 234 + }); 235 + 236 + it('text content is "Select..."', () => { 237 + const g = makeG(); 238 + appendSelect(g, 200, 32, '#fff', '#ccc'); 239 + const texts = childrenOfTag(g, 'text'); 240 + expect(texts[0].textContent).toBe('Select...'); 241 + }); 242 + 243 + it('chevron is positioned near the right edge', () => { 244 + const g = makeG(); 245 + const w = 200; 246 + appendSelect(g, w, 32, '#fff', '#ccc'); 247 + const chevron = childrenOfTag(g, 'polyline')[0]; 248 + const points = chevron.getAttribute('points')!; 249 + // The center of the chevron is at w - 16 250 + expect(points).toContain(String(w - 16)); 251 + }); 252 + }); 253 + 254 + // --------------------------------------------------------------------------- 255 + // appendCheckbox 256 + // --------------------------------------------------------------------------- 257 + describe('appendCheckbox', () => { 258 + it('creates checkbox rect, checkmark polyline, and label text', () => { 259 + const g = makeG(); 260 + appendCheckbox(g, 120, 20, '#fff', '#333'); 261 + // rect + polyline + text = 3 262 + expect(g.children.length).toBe(3); 263 + expect(g.children[0].tagName).toBe('rect'); 264 + expect(g.children[1].tagName).toBe('polyline'); 265 + expect(g.children[2].tagName).toBe('text'); 266 + }); 267 + 268 + it('box size is clamped to min(h, 16)', () => { 269 + const g = makeG(); 270 + appendCheckbox(g, 120, 12, '#fff', '#333'); 271 + const rect = g.children[0]; 272 + expect(rect.getAttribute('width')).toBe('12'); 273 + expect(rect.getAttribute('height')).toBe('12'); 274 + }); 275 + 276 + it('label text says "Option"', () => { 277 + const g = makeG(); 278 + appendCheckbox(g, 120, 20, '#fff', '#333'); 279 + expect(g.children[2].textContent).toBe('Option'); 280 + }); 281 + }); 282 + 283 + // --------------------------------------------------------------------------- 284 + // appendRadio 285 + // --------------------------------------------------------------------------- 286 + describe('appendRadio', () => { 287 + it('creates outer circle, inner circle, and label text', () => { 288 + const g = makeG(); 289 + appendRadio(g, 120, 20, '#fff', '#333'); 290 + expect(g.children.length).toBe(3); 291 + expect(g.children[0].tagName).toBe('circle'); 292 + expect(g.children[1].tagName).toBe('circle'); 293 + expect(g.children[2].tagName).toBe('text'); 294 + }); 295 + 296 + it('inner circle radius is half of outer radius', () => { 297 + const g = makeG(); 298 + const h = 20; 299 + appendRadio(g, 120, h, '#fff', '#333'); 300 + const outerR = Math.min(h, 16) / 2; 301 + const inner = g.children[1]; 302 + expect(inner.getAttribute('r')).toBe(String(outerR * 0.5)); 303 + }); 304 + 305 + it('inner circle is filled with #4472c4 (active indicator)', () => { 306 + const g = makeG(); 307 + appendRadio(g, 120, 20, '#fff', '#333'); 308 + expect(g.children[1].getAttribute('fill')).toBe('#4472c4'); 309 + }); 310 + }); 311 + 312 + // --------------------------------------------------------------------------- 313 + // appendToggle 314 + // --------------------------------------------------------------------------- 315 + describe('appendToggle', () => { 316 + it('creates a track rect and a toggle circle', () => { 317 + const g = makeG(); 318 + appendToggle(g, 50, 24, '#fff', '#333'); 319 + expect(g.children.length).toBe(2); 320 + expect(g.children[0].tagName).toBe('rect'); 321 + expect(g.children[1].tagName).toBe('circle'); 322 + }); 323 + 324 + it('track has fully rounded corners (rx = trackH/2)', () => { 325 + const g = makeG(); 326 + const h = 24; 327 + appendToggle(g, 50, h, '#fff', '#333'); 328 + const trackH = Math.min(h, 20); 329 + expect(g.children[0].getAttribute('rx')).toBe(String(trackH / 2)); 330 + }); 331 + 332 + it('track fill is green (#4caf50) indicating "on" state', () => { 333 + const g = makeG(); 334 + appendToggle(g, 50, 24, '#fff', '#333'); 335 + expect(g.children[0].getAttribute('fill')).toBe('#4caf50'); 336 + }); 337 + }); 338 + 339 + // --------------------------------------------------------------------------- 340 + // appendNavbar 341 + // --------------------------------------------------------------------------- 342 + describe('appendNavbar', () => { 343 + it('creates background, logo rect, and text elements', () => { 344 + const g = makeG(); 345 + appendNavbar(g, 400, 48, '#ffffff', '#333'); 346 + // bg rect + logo rect + Brand text + Home text + About text + Contact text = 6 347 + expect(g.children.length).toBe(6); 348 + }); 349 + 350 + it('uses dark fill (#2c3e50) when fill is white', () => { 351 + const g = makeG(); 352 + appendNavbar(g, 400, 48, '#ffffff', '#333'); 353 + expect(g.children[0].getAttribute('fill')).toBe('#2c3e50'); 354 + }); 355 + 356 + it('uses the provided fill when it is not white', () => { 357 + const g = makeG(); 358 + appendNavbar(g, 400, 48, '#1a1a2e', '#333'); 359 + expect(g.children[0].getAttribute('fill')).toBe('#1a1a2e'); 360 + }); 361 + 362 + it('includes "Brand" and nav link text elements', () => { 363 + const g = makeG(); 364 + appendNavbar(g, 400, 48, '#ffffff', '#333'); 365 + const texts = childrenOfTag(g, 'text'); 366 + const contents = texts.map(t => t.textContent); 367 + expect(contents).toContain('Brand'); 368 + expect(contents).toContain('Home'); 369 + expect(contents).toContain('About'); 370 + expect(contents).toContain('Contact'); 371 + }); 372 + }); 373 + 374 + // --------------------------------------------------------------------------- 375 + // appendTable 376 + // --------------------------------------------------------------------------- 377 + describe('appendTable', () => { 378 + it('creates outer rect, header rows, and divider lines', () => { 379 + const g = makeG(); 380 + appendTable(g, 300, 200, '#fff', '#333'); 381 + // outer rect + header rect + header bottom rect + 2 col dividers + row dividers 382 + // rowH = min(200/4, 28) = 28, rows = min(floor(200/28), 5) = min(7,5) = 5 383 + // col dividers: 2 (cols=3, i=1,2) 384 + // row dividers: 4 (rows=5, i=1..4) 385 + // total: 1 + 2 + 2 + 4 = 9 386 + expect(g.children.length).toBe(9); 387 + }); 388 + 389 + it('has 3 columns (2 column dividers)', () => { 390 + const g = makeG(); 391 + appendTable(g, 300, 200, '#fff', '#333'); 392 + const lines = childrenOfTag(g, 'line'); 393 + // Column dividers have y2=200 (full height), row dividers have x2=300 (full width) 394 + const colDividers = lines.filter(l => l.getAttribute('y2') === '200'); 395 + expect(colDividers.length).toBe(2); 396 + }); 397 + 398 + it('header row is filled with light gray (#f5f5f5)', () => { 399 + const g = makeG(); 400 + appendTable(g, 300, 200, '#fff', '#333'); 401 + const headerRect = g.children[1]; // second child 402 + expect(headerRect.getAttribute('fill')).toBe('#f5f5f5'); 403 + }); 404 + }); 405 + 406 + // --------------------------------------------------------------------------- 407 + // appendProgressBar 408 + // --------------------------------------------------------------------------- 409 + describe('appendProgressBar', () => { 410 + it('creates background track and filled portion', () => { 411 + const g = makeG(); 412 + appendProgressBar(g, 200, 20, '#4472c4', '#333'); 413 + expect(g.children.length).toBe(2); 414 + }); 415 + 416 + it('filled portion is 65% of total width', () => { 417 + const g = makeG(); 418 + const w = 200; 419 + appendProgressBar(g, w, 20, '#4472c4', '#333'); 420 + const filled = g.children[1]; 421 + expect(filled.getAttribute('width')).toBe(String(w * 0.65)); 422 + }); 423 + 424 + it('uses blue fill for the filled portion when fill is white', () => { 425 + const g = makeG(); 426 + appendProgressBar(g, 200, 20, '#ffffff', '#333'); 427 + expect(g.children[1].getAttribute('fill')).toBe('#4472c4'); 428 + }); 429 + 430 + it('bar height is clamped to min(h, 12)', () => { 431 + const g = makeG(); 432 + appendProgressBar(g, 200, 8, '#4472c4', '#333'); 433 + const track = g.children[0]; 434 + expect(track.getAttribute('height')).toBe('8'); 435 + }); 436 + }); 437 + 438 + // --------------------------------------------------------------------------- 439 + // appendAvatar 440 + // --------------------------------------------------------------------------- 441 + describe('appendAvatar', () => { 442 + it('creates outer circle, head circle, and body ellipse', () => { 443 + const g = makeG(); 444 + appendAvatar(g, 60, 60, '#e0e0e0', '#333'); 445 + expect(g.children.length).toBe(3); 446 + expect(g.children[0].tagName).toBe('circle'); 447 + expect(g.children[1].tagName).toBe('circle'); 448 + expect(g.children[2].tagName).toBe('ellipse'); 449 + }); 450 + 451 + it('uses gray fill (#e0e0e0) when fill is white', () => { 452 + const g = makeG(); 453 + appendAvatar(g, 60, 60, '#ffffff', '#333'); 454 + expect(g.children[0].getAttribute('fill')).toBe('#e0e0e0'); 455 + }); 456 + 457 + it('radius is half the smaller dimension', () => { 458 + const g = makeG(); 459 + appendAvatar(g, 80, 60, '#e0e0e0', '#333'); 460 + const r = Math.min(80, 60) / 2; 461 + expect(g.children[0].getAttribute('r')).toBe(String(r)); 462 + }); 463 + }); 464 + 465 + // --------------------------------------------------------------------------- 466 + // appendAlert 467 + // --------------------------------------------------------------------------- 468 + describe('appendAlert', () => { 469 + it('creates background rect, warning icon text, and text placeholder rect', () => { 470 + const g = makeG(); 471 + appendAlert(g, 300, 40, '#fff3cd', '#ffc107'); 472 + // rect + text + rect = 3 473 + expect(g.children.length).toBe(3); 474 + }); 475 + 476 + it('uses warning yellow fill (#fff3cd) when fill is white', () => { 477 + const g = makeG(); 478 + appendAlert(g, 300, 40, '#ffffff', '#ffc107'); 479 + expect(g.children[0].getAttribute('fill')).toBe('#fff3cd'); 480 + }); 481 + 482 + it('warning icon text contains the warning symbol', () => { 483 + const g = makeG(); 484 + appendAlert(g, 300, 40, '#fff3cd', '#ffc107'); 485 + const texts = childrenOfTag(g, 'text'); 486 + expect(texts[0].textContent).toContain('\u26A0'); 487 + }); 488 + 489 + it('text placeholder rect width is 60% of container', () => { 490 + const g = makeG(); 491 + const w = 300; 492 + appendAlert(g, w, 40, '#fff3cd', '#ffc107'); 493 + const placeholder = g.children[2]; 494 + expect(placeholder.getAttribute('width')).toBe(String(w * 0.6)); 495 + }); 496 + }); 497 + 498 + // --------------------------------------------------------------------------- 499 + // appendTabs 500 + // --------------------------------------------------------------------------- 501 + describe('appendTabs', () => { 502 + it('creates container rect, active tab rect, active indicator, texts, and divider', () => { 503 + const g = makeG(); 504 + appendTabs(g, 300, 40, '#fff', '#333'); 505 + // container rect + active tab rect + active indicator line + Tab1 text + Tab2 text + Tab3 text + bottom divider line = 7 506 + expect(g.children.length).toBe(7); 507 + }); 508 + 509 + it('active tab indicator line is colored #4472c4', () => { 510 + const g = makeG(); 511 + appendTabs(g, 300, 40, '#fff', '#333'); 512 + // The active indicator line is the third child (index 2) 513 + const indicator = g.children[2]; 514 + expect(indicator.tagName).toBe('line'); 515 + expect(indicator.getAttribute('stroke')).toBe('#4472c4'); 516 + }); 517 + 518 + it('tab width is one-third of container width', () => { 519 + const g = makeG(); 520 + const w = 300; 521 + appendTabs(g, w, 40, '#fff', '#333'); 522 + const activeTab = g.children[1]; // active tab rect 523 + expect(activeTab.getAttribute('width')).toBe(String(w / 3)); 524 + }); 525 + 526 + it('includes Tab 1, Tab 2, and Tab 3 text', () => { 527 + const g = makeG(); 528 + appendTabs(g, 300, 40, '#fff', '#333'); 529 + const texts = childrenOfTag(g, 'text'); 530 + const contents = texts.map(t => t.textContent); 531 + expect(contents).toContain('Tab 1'); 532 + expect(contents).toContain('Tab 2'); 533 + expect(contents).toContain('Tab 3'); 534 + }); 535 + }); 536 + 537 + // --------------------------------------------------------------------------- 538 + // Edge cases 539 + // --------------------------------------------------------------------------- 540 + describe('edge cases', () => { 541 + it('renderers handle very small dimensions without throwing', () => { 542 + const renderers = [ 543 + appendBrowserWindow, appendMobileFrame, appendModal, appendCard, 544 + appendButton, appendInputField, appendSelect, appendCheckbox, 545 + appendRadio, appendToggle, appendNavbar, appendTable, 546 + appendProgressBar, appendAvatar, appendAlert, appendTabs, 547 + ]; 548 + for (const fn of renderers) { 549 + const g = makeG(); 550 + expect(() => fn(g, 10, 10, '#fff', '#333')).not.toThrow(); 551 + expect(g.children.length).toBeGreaterThan(0); 552 + } 553 + }); 554 + 555 + it('custom stroke width is applied to the outer element', () => { 556 + const g = makeG(); 557 + appendBrowserWindow(g, 300, 200, '#fff', '#333', '5'); 558 + expect(g.children[0].getAttribute('stroke-width')).toBe('5'); 559 + }); 560 + 561 + it('appendTable adapts row count to small heights', () => { 562 + const g = makeG(); 563 + appendTable(g, 100, 30, '#fff', '#333'); 564 + // rowH = min(30/4, 28) = 7.5; rows = min(floor(30/7.5), 5) = min(4, 5) = 4 565 + // Elements: outer rect + 2 header rects + 2 col dividers + 3 row dividers = 8 566 + const lines = childrenOfTag(g, 'line'); 567 + // Should have at least column dividers 568 + expect(lines.length).toBeGreaterThanOrEqual(2); 569 + }); 570 + 571 + it('appendToggle clamps track dimensions to maximums', () => { 572 + const g = makeG(); 573 + appendToggle(g, 100, 50, '#fff', '#333'); 574 + // trackW = min(100, 40) = 40, trackH = min(50, 20) = 20 575 + const track = g.children[0]; 576 + expect(track.getAttribute('width')).toBe('40'); 577 + expect(track.getAttribute('height')).toBe('20'); 578 + }); 579 + 580 + it('appendCheckbox clamps box size when h > 16', () => { 581 + const g = makeG(); 582 + appendCheckbox(g, 120, 40, '#fff', '#333'); 583 + const rect = g.children[0]; 584 + expect(rect.getAttribute('width')).toBe('16'); 585 + expect(rect.getAttribute('height')).toBe('16'); 586 + }); 587 + });