hephaestus/heph-pwa/test/parsers.test.mjs
Erich Blume 9a487cbe3b
All checks were successful
Build / validate (pull_request) Successful in 6m57s
feat(heph-tui,heph-pwa): humanized recurrence + indented/counted/scrolling project sidebar
Bundles the cosmetic/UI-polish backlog for the agenda surfaces. All read-side;
no schema or sync change (see hub-spoke-data-evolution).

- humanize_rrule (hephd::datespec): inverse of parse_recurrence — renders an
  RRULE as 'every other week', 'weekdays', 'yearly on Apr 15', etc.; falls back
  to the raw rule for unmodeled parts (COUNT/UNTIL/ordinal BYDAY). Mirrored in
  the PWA's datespec.js. Shown in the TUI recurs detail line and PWA task/qa
  previews instead of the raw FREQ= string.
- project.overview RPC + Store::project_overview: each project's parent (via the
  existing 'parent' links) and direct outstanding-task count, a read-only query.
- TUI sidebar: subprojects indented by depth, per-project counts, wider pane,
  and ListState + scrollbar so it scrolls instead of clipping on overflow.

Tests: humanize parity (Rust + JS), round-trip through parse_recurrence,
raw-passthrough; project_overview count/parent; sidebar tree ordering + cycle
safety.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:44:43 -07:00

184 lines
6.8 KiB
JavaScript

// Parity tests for the JS parser ports — the exact cases from hephd's
// quickadd.rs / datespec.rs unit tests. Run: `node --test heph-pwa/test/`.
import test from "node:test";
import assert from "node:assert/strict";
import {
parseDate,
parseRecurrence,
humanizeRecurrence,
toEpochMs,
} from "../src/datespec.js";
import { parse } from "../src/quickadd.js";
const d = (y, m, day) => new Date(y, m - 1, day);
const ms = (y, m, day) => toEpochMs(d(y, m, day));
// datespec.rs uses 2026-06-02 (a Tuesday) as `today`.
const DTODAY = d(2026, 6, 2);
test("parse_date keywords and offsets", () => {
assert.deepEqual(parseDate("today", DTODAY), d(2026, 6, 2));
assert.deepEqual(parseDate("tomorrow", DTODAY), d(2026, 6, 3));
assert.deepEqual(parseDate("yesterday", DTODAY), d(2026, 6, 1));
assert.deepEqual(parseDate("+3d", DTODAY), d(2026, 6, 5));
assert.deepEqual(parseDate("+2w", DTODAY), d(2026, 6, 16));
assert.deepEqual(parseDate("+1m", DTODAY), d(2026, 7, 2));
assert.deepEqual(parseDate("+5", DTODAY), d(2026, 6, 7));
});
test("parse_date weekdays are soonest on/after today", () => {
assert.deepEqual(parseDate("tue", DTODAY), d(2026, 6, 2)); // today
assert.deepEqual(parseDate("fri", DTODAY), d(2026, 6, 5));
assert.deepEqual(parseDate("mon", DTODAY), d(2026, 6, 8)); // wraps
});
test("parse_date iso and errors", () => {
assert.deepEqual(parseDate("2026-12-25", DTODAY), d(2026, 12, 25));
assert.throws(() => parseDate("someday", DTODAY));
assert.throws(() => parseDate("", DTODAY));
});
test("recurrence presets and raw", () => {
assert.equal(parseRecurrence("daily"), "FREQ=DAILY");
assert.equal(parseRecurrence("weekly"), "FREQ=WEEKLY");
assert.equal(parseRecurrence("monthly"), "FREQ=MONTHLY");
assert.equal(parseRecurrence("yearly"), "FREQ=YEARLY");
assert.equal(parseRecurrence("weekdays"), "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR");
assert.equal(parseRecurrence("FREQ=DAILY;INTERVAL=2"), "FREQ=DAILY;INTERVAL=2");
});
test("recurrence natural language", () => {
assert.equal(parseRecurrence("every day"), "FREQ=DAILY");
assert.equal(parseRecurrence("every 3 days"), "FREQ=DAILY;INTERVAL=3");
assert.equal(parseRecurrence("every 2 weeks"), "FREQ=WEEKLY;INTERVAL=2");
assert.equal(parseRecurrence("every 6 months"), "FREQ=MONTHLY;INTERVAL=6");
assert.equal(parseRecurrence("every fri"), "FREQ=WEEKLY;BYDAY=FR");
assert.equal(parseRecurrence("every other wed"), "FREQ=WEEKLY;INTERVAL=2;BYDAY=WE");
assert.equal(parseRecurrence("every other day"), "FREQ=DAILY;INTERVAL=2");
assert.equal(parseRecurrence("every workday at 08:00"), "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR");
assert.equal(parseRecurrence("every April 15"), "FREQ=YEARLY;BYMONTH=4;BYMONTHDAY=15");
assert.equal(parseRecurrence("every 5th"), "FREQ=MONTHLY;BYMONTHDAY=5");
assert.equal(parseRecurrence("every 22nd"), "FREQ=MONTHLY;BYMONTHDAY=22");
assert.throws(() => parseRecurrence("every blue moon"));
});
// Parity with hephd's `humanize_rrule` unit tests (datespec.rs).
test("humanize inverts the natural-language forms", () => {
const cases = [
["FREQ=DAILY", "daily"],
["FREQ=DAILY;INTERVAL=2", "every other day"],
["FREQ=DAILY;INTERVAL=3", "every 3 days"],
["FREQ=WEEKLY", "weekly"],
["FREQ=WEEKLY;INTERVAL=2", "every other week"],
["FREQ=MONTHLY", "monthly"],
["FREQ=MONTHLY;INTERVAL=6", "every 6 months"],
["FREQ=YEARLY", "yearly"],
["FREQ=WEEKLY;BYDAY=FR", "every Fri"],
["FREQ=WEEKLY;INTERVAL=2;BYDAY=WE", "every other Wed"],
["FREQ=WEEKLY;INTERVAL=3;BYDAY=WE", "every 3 weeks on Wed"],
["FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", "weekdays"],
["FREQ=WEEKLY;BYDAY=MO,WE,FR", "weekly on Mon, Wed, Fri"],
["FREQ=YEARLY;BYMONTH=4;BYMONTHDAY=15", "yearly on Apr 15"],
["FREQ=MONTHLY;BYMONTHDAY=5", "monthly on the 5th"],
["FREQ=MONTHLY;BYMONTHDAY=22", "monthly on the 22nd"],
["FREQ=MONTHLY;BYMONTHDAY=1", "monthly on the 1st"],
["FREQ=MONTHLY;BYMONTHDAY=13", "monthly on the 13th"],
];
for (const [rrule, want] of cases) {
assert.equal(humanizeRecurrence(rrule), want, `humanizing ${rrule}`);
}
});
test("humanize round-trips the re-typeable forms through parseRecurrence", () => {
for (const input of [
"every 3 days",
"every other day",
"every other wed",
"weekdays",
"every fri",
"every 6 months",
"every 2 weeks",
]) {
const rrule = parseRecurrence(input);
assert.equal(parseRecurrence(humanizeRecurrence(rrule)), rrule, `round-trip ${input}`);
}
});
test("humanize falls back to raw for unmodeled rules", () => {
for (const raw of [
"FREQ=DAILY;COUNT=5",
"FREQ=WEEKLY;UNTIL=20261231T000000Z",
"FREQ=MONTHLY;BYDAY=2MO",
"FREQ=MONTHLY;BYMONTHDAY=-1",
"not an rrule at all",
]) {
assert.equal(humanizeRecurrence(raw), raw, `passthrough ${raw}`);
}
});
// quickadd.rs uses 2026-06-03 as `today` with projects Work + Camano Chores.
const QTODAY = d(2026, 6, 3);
const PROJECTS = [
{ id: "work", title: "Work" },
{ id: "camano", title: "Camano Chores" },
];
const p = (input) => parse(input, QTODAY, PROJECTS);
test("plain title", () => {
const r = p("Buy milk");
assert.equal(r.title, "Buy milk");
assert.equal(r.attention, null);
assert.equal(r.doDate, null);
assert.equal(r.recurrence, null);
assert.equal(r.projectId, null);
});
test("priority maps to attention", () => {
assert.equal(p("Email boss p1").attention, "red");
assert.equal(p("Email boss p2").attention, "orange");
assert.equal(p("Email boss p3").attention, "blue");
assert.equal(p("Email boss p4").attention, "white");
assert.equal(p("Email boss p1").title, "Email boss");
});
test("relative date is extracted", () => {
const r = p("Call dentist tomorrow");
assert.equal(r.title, "Call dentist");
assert.equal(r.doDate, ms(2026, 6, 4));
});
test("single + multi-word projects resolve", () => {
assert.equal(p("Standup #Work").projectId, "work");
assert.equal(p("Standup #Work").title, "Standup");
const r = p("Sweep deck #Camano Chores");
assert.equal(r.title, "Sweep deck");
assert.equal(r.projectId, "camano");
});
test("unresolved tag stays in title", () => {
const r = p("Buy #groceries milk");
assert.equal(r.title, "Buy #groceries milk");
assert.equal(r.projectId, null);
});
test("recurrence phrase is extracted", () => {
const r = p("Water plants every 3 days");
assert.equal(r.title, "Water plants");
assert.equal(r.recurrence, "FREQ=DAILY;INTERVAL=3");
});
test("everything at once", () => {
const r = p("Plan trip p2 friday #Work every week");
assert.equal(r.title, "Plan trip");
assert.equal(r.attention, "orange");
assert.equal(r.doDate, ms(2026, 6, 5));
assert.equal(r.projectId, "work");
assert.equal(r.recurrence, "FREQ=WEEKLY");
});
test("non-recurrence every stays in title", () => {
const r = p("Review every report");
assert.equal(r.title, "Review every report");
assert.equal(r.recurrence, null);
});