97aee3e4b3
All detection pure/on-device; lookups send only the id/topic (never transcript): - src/lib/enrich pure modules: dates (absolute + relative, anchored to lectureDate), citations (DOI/arXiv/ISBN/author-year), ics (RFC5545), bib (BibTeX/RIS), links (legit + Anna's Archive/LibGen search URLs) — 60 tests. - lookup.ts: CORS-friendly metadata clients (Crossref/OpenAlex/Open Library) + Wikipedia summary; AbortController timeouts, session cache, never throws. - UI: transcript "Dates & references" (add to calendar .ics, look up references + open DOI/OpenLibrary/Google Books/Wikipedia/Anna's Archive/LibGen); per-course Bibliography screen with BibTeX/RIS export. Not going to app stores, so Anna's Archive/LibGen links are included per request (search URLs only — never fetched/proxied). 275 tests green, 0 tsc errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
101 lines
3.4 KiB
TypeScript
101 lines
3.4 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { toIcs } from './ics';
|
|
|
|
const utc = (y: number, mo: number, d: number, h = 0, mi = 0) =>
|
|
Date.UTC(y, mo, d, h, mi, 0, 0);
|
|
|
|
describe('toIcs', () => {
|
|
it('produces a valid empty calendar for []', () => {
|
|
const ics = toIcs([]);
|
|
expect(ics).toContain('BEGIN:VCALENDAR');
|
|
expect(ics).toContain('VERSION:2.0');
|
|
expect(ics).toContain('END:VCALENDAR');
|
|
expect(ics).not.toContain('BEGIN:VEVENT');
|
|
});
|
|
|
|
it('uses CRLF line endings', () => {
|
|
const ics = toIcs([]);
|
|
expect(ics).toContain('\r\n');
|
|
expect(ics.endsWith('\r\n')).toBe(true);
|
|
// No bare LF without a preceding CR.
|
|
expect(/[^\r]\n/.test(ics)).toBe(false);
|
|
});
|
|
|
|
it('emits an all-day VEVENT with VALUE=DATE', () => {
|
|
const ics = toIcs([
|
|
{ title: 'Final exam', dateMs: utc(2026, 5, 20), allDay: true },
|
|
]);
|
|
expect(ics).toContain('BEGIN:VEVENT');
|
|
expect(ics).toContain('DTSTART;VALUE=DATE:20260620');
|
|
expect(ics).toContain('SUMMARY:Final exam');
|
|
});
|
|
|
|
it('emits a timed VEVENT with a UTC DTSTART', () => {
|
|
const ics = toIcs([
|
|
{ title: 'Meeting', dateMs: utc(2026, 5, 20, 14, 30), allDay: false },
|
|
]);
|
|
expect(ics).toContain('DTSTART:20260620T143000Z');
|
|
});
|
|
|
|
it('derives DTSTAMP from the event dateMs', () => {
|
|
const ics = toIcs([
|
|
{ title: 'X', dateMs: utc(2026, 5, 20, 9, 0), allDay: true },
|
|
]);
|
|
expect(ics).toContain('DTSTAMP:20260620T090000Z');
|
|
});
|
|
|
|
it('UID is deterministic (no randomness) and stable across calls', () => {
|
|
const ev = { title: 'Exam', dateMs: utc(2026, 5, 20), allDay: true };
|
|
const a = toIcs([ev]);
|
|
const b = toIcs([ev]);
|
|
expect(a).toBe(b);
|
|
const uid = a.split('\r\n').find((l) => l.startsWith('UID:'));
|
|
expect(uid).toBeDefined();
|
|
expect(uid).toContain('@wisp');
|
|
});
|
|
|
|
it('changes UID when title or date changes', () => {
|
|
const a = toIcs([{ title: 'A', dateMs: utc(2026, 5, 20), allDay: true }]);
|
|
const b = toIcs([{ title: 'B', dateMs: utc(2026, 5, 20), allDay: true }]);
|
|
const uidA = a.split('\r\n').find((l) => l.startsWith('UID:'));
|
|
const uidB = b.split('\r\n').find((l) => l.startsWith('UID:'));
|
|
expect(uidA).not.toBe(uidB);
|
|
});
|
|
|
|
it('escapes comma, semicolon, backslash and newline in SUMMARY', () => {
|
|
const ics = toIcs([
|
|
{
|
|
title: 'a, b; c \\ d\ne',
|
|
dateMs: utc(2026, 5, 20),
|
|
allDay: true,
|
|
},
|
|
]);
|
|
const line = ics
|
|
.replace(/\r\n /g, '') // unfold first
|
|
.split('\r\n')
|
|
.find((l) => l.startsWith('SUMMARY:'));
|
|
expect(line).toBe('SUMMARY:a\\, b\\; c \\\\ d\\ne');
|
|
});
|
|
|
|
it('folds lines longer than 75 octets with a leading space', () => {
|
|
const long = 'X'.repeat(200);
|
|
const ics = toIcs([{ title: long, dateMs: utc(2026, 5, 20), allDay: true }]);
|
|
for (const line of ics.split('\r\n')) {
|
|
// Each physical line must be <=75 octets.
|
|
expect(Buffer.byteLength(line, 'utf8')).toBeLessThanOrEqual(75);
|
|
}
|
|
// Unfolding restores the SUMMARY content.
|
|
const unfolded = ics.replace(/\r\n /g, '');
|
|
expect(unfolded).toContain(`SUMMARY:${long}`);
|
|
});
|
|
|
|
it('emits one VEVENT per event', () => {
|
|
const ics = toIcs([
|
|
{ title: 'A', dateMs: utc(2026, 5, 20), allDay: true },
|
|
{ title: 'B', dateMs: utc(2026, 5, 21), allDay: true },
|
|
]);
|
|
const count = ics.split('BEGIN:VEVENT').length - 1;
|
|
expect(count).toBe(2);
|
|
});
|
|
});
|