Milestone 0/1: shell, calendar/psalter/hours scaffold, Prime
Deploy / deploy (push) Failing after 1m8s
Deploy / deploy (push) Failing after 1m8s
Client-side-first PWA (Vite/TS, no backend) per the approved plan: day- navigable shell listing all 8 hours, Prime fully resolves via the calendar -> psalter -> ordo pipeline, the other 7 hours are registered but flagged not-implemented. Sanctoral/temporal calendar data uses a day -> id indirection layer (saints, easter-offsets, fixed-date-calendar) so reassigning a feast to a different day is a data edit, not a code change. Docker (Caddy-serving-static) + Gitea CI workflow scaffolded to match the eec/drip/bookshop operational pattern. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { weekdayOf } from '../../src/calendar/weekday';
|
||||
import { resolveDay } from '../../src/calendar';
|
||||
|
||||
describe('weekdayOf', () => {
|
||||
it('resolves a known Sunday', () => {
|
||||
// 2026-08-09 is a Sunday.
|
||||
expect(weekdayOf('2026-08-09')).toBe('sunday');
|
||||
});
|
||||
|
||||
it('resolves each day of a full week correctly', () => {
|
||||
const expected = [
|
||||
['2026-08-09', 'sunday'],
|
||||
['2026-08-10', 'monday'],
|
||||
['2026-08-11', 'tuesday'],
|
||||
['2026-08-12', 'wednesday'],
|
||||
['2026-08-13', 'thursday'],
|
||||
['2026-08-14', 'friday'],
|
||||
['2026-08-15', 'saturday'],
|
||||
] as const;
|
||||
for (const [date, weekday] of expected) {
|
||||
expect(weekdayOf(date)).toBe(weekday);
|
||||
}
|
||||
});
|
||||
|
||||
it('is not affected by the local timezone offset', () => {
|
||||
// Regression guard: resolving via `new Date(isoDate)` without pinning to
|
||||
// UTC would give different weekdays depending on the host's timezone.
|
||||
expect(weekdayOf('2026-01-01')).toBe('thursday');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDay', () => {
|
||||
it('stubs season and occurring until milestone 4', () => {
|
||||
const day = resolveDay('2026-08-09');
|
||||
expect(day.date).toBe('2026-08-09');
|
||||
expect(day.weekday).toBe('sunday');
|
||||
expect(day.occurring).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveOrdo } from '../../src/hours';
|
||||
|
||||
describe('resolveOrdo("prime", ...)', () => {
|
||||
it('is implemented and resolves a psalm from the weekday-variable slot', () => {
|
||||
const ordo = resolveOrdo('prime', '2026-08-09');
|
||||
expect(ordo.notImplemented).toBeUndefined();
|
||||
expect(ordo.hourId).toBe('prime');
|
||||
|
||||
const psalmParts = ordo.parts.filter((p) => p.kind === 'psalm');
|
||||
expect(psalmParts.length).toBeGreaterThan(0);
|
||||
expect(psalmParts[0]).toMatchObject({ kind: 'psalm', psalmNumber: 1 });
|
||||
});
|
||||
|
||||
it('resolves fixed parts (hymn, chapter, prayer) as pending until propers data exists', () => {
|
||||
const ordo = resolveOrdo('prime', '2026-08-09');
|
||||
const hymn = ordo.parts.find((p) => p.kind === 'hymn');
|
||||
expect(hymn).toBeDefined();
|
||||
if (hymn && hymn.kind === 'hymn') {
|
||||
expect(hymn.text.status.en).toBe('missing');
|
||||
}
|
||||
});
|
||||
|
||||
it('surfaces verified/draft/missing status per language, per verse', () => {
|
||||
const ordo = resolveOrdo('prime', '2026-08-09');
|
||||
const psalm = ordo.parts.find((p) => p.kind === 'psalm');
|
||||
expect(psalm && psalm.kind === 'psalm' ? psalm.verses.length : 0).toBe(3);
|
||||
if (psalm && psalm.kind === 'psalm') {
|
||||
expect(psalm.verses[0]?.status.en).toBe('draft');
|
||||
expect(psalm.verses[2]?.status.en).toBe('missing');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOrdo for not-yet-built hours', () => {
|
||||
it('flags compline as not implemented rather than throwing', () => {
|
||||
const ordo = resolveOrdo('compline', '2026-08-09');
|
||||
expect(ordo.notImplemented).toBe(true);
|
||||
expect(ordo.parts).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mountShell } from '../../src/ui/shell';
|
||||
import { setDate, setSelectedHour, setLanguages } from '../../src/app-state';
|
||||
|
||||
describe('shell', () => {
|
||||
// app-state is a module-level singleton, so each test must reset every
|
||||
// field it might have mutated — not just the one it cares about — or
|
||||
// state leaks across tests in this file.
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div id="app"></div>';
|
||||
setDate('2026-08-09');
|
||||
setSelectedHour('prime');
|
||||
setLanguages(['en']);
|
||||
window.history.replaceState(null, '', '/vu/2026-08-09/prime');
|
||||
});
|
||||
|
||||
it('renders the day nav, all 8 hours, and Prime content by default', () => {
|
||||
const root = document.getElementById('app')!;
|
||||
mountShell(root);
|
||||
|
||||
expect(root.querySelector('.day-nav-date')?.textContent).toContain('August 9, 2026');
|
||||
|
||||
const hourNames = Array.from(root.querySelectorAll('.hour-name')).map((el) => el.textContent);
|
||||
expect(hourNames).toEqual([
|
||||
'Prime',
|
||||
'Compline',
|
||||
'Terce',
|
||||
'Sext',
|
||||
'None',
|
||||
'Lauds',
|
||||
'Vespers',
|
||||
'Matins',
|
||||
]);
|
||||
|
||||
expect(root.querySelector('.hour-item.is-selected .hour-name')?.textContent).toBe('Prime');
|
||||
expect(root.querySelector('.hour-view h2')?.textContent).toBe('Prime');
|
||||
expect(root.querySelectorAll('.ordo-part-psalm').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('switches to a not-yet-built hour and shows the pending message', () => {
|
||||
const root = document.getElementById('app')!;
|
||||
mountShell(root);
|
||||
|
||||
const complineBtn = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-hour]')).find(
|
||||
(btn) => btn.dataset.hour === 'compline',
|
||||
);
|
||||
complineBtn?.click();
|
||||
|
||||
expect(root.querySelector('.hour-view h2')?.textContent).toBe('Compline');
|
||||
expect(root.querySelector('.hour-view-pending')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('moving to the next day updates the displayed date', () => {
|
||||
const root = document.getElementById('app')!;
|
||||
mountShell(root);
|
||||
|
||||
root.querySelector<HTMLButtonElement>('[data-action="next"]')?.click();
|
||||
expect(root.querySelector('.day-nav-date')?.textContent).toContain('August 10, 2026');
|
||||
});
|
||||
|
||||
it('switching to two-language mode renders two columns per verse', () => {
|
||||
const root = document.getElementById('app')!;
|
||||
mountShell(root);
|
||||
|
||||
const bilingualBtn = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-langs]')).find(
|
||||
(btn) => btn.dataset.langs === 'en,la',
|
||||
);
|
||||
bilingualBtn?.click();
|
||||
|
||||
const firstVerseColumns = root.querySelector('.psalm-verse .lang-columns');
|
||||
expect(firstVerseColumns?.classList.contains('lang-columns-2')).toBe(true);
|
||||
expect(firstVerseColumns?.querySelectorAll('.lang-column').length).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user