migration 0011_item_links_event_date.sql: ADD event_date date + partial
index (idempotent). Day granularity by design per the PER spec; the
column lands NULL on every existing row, no backfill.
store:
- ItemLink gains an EventDate *time.Time (every read path scans it).
- AddLinkDated(ctx, item, refType, refID, rel, note, date, metadata)
upserts with COALESCE(new, old) for note + event_date so partial
callers don't clobber prior state.
- DatedLinks(item) returns event_date IS NOT NULL ordered DESC.
web:
- per.go: parsePER strips a trailing .YYMMDD (rejects invalid dates like
Feb 30); collisionTag yields a/b/.../z/aa/ab/...; computePERs walks
DatedLinks output and assigns render-time collision tags inside each
date group. Tags are never stored.
- handleDetail: 404 retry with PER stripped — /i/mfin.house1.260515
resolves to the house1 item with HighlightDate=2026-05-15.
- documents_section.tmpl: add-form (ref_type/date/ref_id/note),
date-sorted rows with computed PER, ref-type badge, remove × with
anti-forgery item-id check, highlight row when HighlightDate matches.
- POST /i/{path}/links/add and /links/remove handlers; HTMX swap on the
fragment, redirect for non-HTMX callers.
mcp:
- add_link accepts event_date: "YYYY-MM-DD" (parsed strict, hands back
fmt.Errorf on bad form). linkView.event_date surfaces it on responses.
- Existing add_link callers without event_date keep working unchanged.
docs:
- docs/standards/per.md gains an Implementation section pointing at
item_links.event_date + ref_types + render-time collision policy.
- docs/design.md adds a Documents/dated artifacts section with the
schema delta, conflict policy, and URL routing rules.
tests:
- per_test.go: parsePER (valid/invalid dates, non-numeric, wrong
length); collisionTag (1..53); computePERs (bare-then-.a, skips
undated, multi-date grouping).
111 lines
3.3 KiB
Go
111 lines
3.3 KiB
Go
package web
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/m/projax/store"
|
|
)
|
|
|
|
func TestParsePER(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
wantBase string
|
|
wantDate string // empty == nil
|
|
}{
|
|
{"dev.projax", "dev.projax", ""},
|
|
{"dev.projax.260515", "dev.projax", "2026-05-15"},
|
|
{"mfin.house1.260515", "mfin.house1", "2026-05-15"},
|
|
// Six-digit but not a valid date → leave unchanged.
|
|
{"foo.260230", "foo.260230", ""}, // Feb 30 doesn't exist
|
|
{"foo.260000", "foo.260000", ""}, // month 00
|
|
{"foo.261301", "foo.261301", ""}, // month 13
|
|
{"foo.999999", "foo.999999", ""}, // not a real date
|
|
// Wrong length → leave unchanged.
|
|
{"foo.bar", "foo.bar", ""},
|
|
{"foo.12345", "foo.12345", ""},
|
|
{"foo.1234567", "foo.1234567", ""},
|
|
// Empty trailing segment.
|
|
{"foo.", "foo.", ""},
|
|
// No dot at all.
|
|
{"260515", "260515", ""},
|
|
}
|
|
for _, tc := range cases {
|
|
gotBase, gotDate := parsePER(tc.in)
|
|
if gotBase != tc.wantBase {
|
|
t.Errorf("parsePER(%q) base = %q, want %q", tc.in, gotBase, tc.wantBase)
|
|
}
|
|
if tc.wantDate == "" {
|
|
if gotDate != nil {
|
|
t.Errorf("parsePER(%q) date = %v, want nil", tc.in, gotDate)
|
|
}
|
|
continue
|
|
}
|
|
want, _ := time.Parse("2006-01-02", tc.wantDate)
|
|
if gotDate == nil || !gotDate.Equal(want) {
|
|
t.Errorf("parsePER(%q) date = %v, want %v", tc.in, gotDate, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCollisionTag(t *testing.T) {
|
|
cases := []struct {
|
|
n int
|
|
want string
|
|
}{
|
|
{0, ""},
|
|
{1, "a"},
|
|
{2, "b"},
|
|
{26, "z"},
|
|
{27, "aa"},
|
|
{28, "ab"},
|
|
{52, "az"},
|
|
{53, "ba"},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := collisionTag(tc.n); got != tc.want {
|
|
t.Errorf("collisionTag(%d) = %q, want %q", tc.n, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestComputePERsBareThenAB(t *testing.T) {
|
|
d := time.Date(2026, 5, 15, 0, 0, 0, 0, time.UTC)
|
|
d2 := time.Date(2026, 5, 16, 0, 0, 0, 0, time.UTC)
|
|
links := []*store.ItemLink{
|
|
// 2026-05-15 group: 2 entries (sorted by created_at ASC; first bare, second .a)
|
|
{ID: "x1", EventDate: &d, CreatedAt: time.Date(2026, 5, 15, 9, 0, 0, 0, time.UTC)},
|
|
{ID: "x2", EventDate: &d, CreatedAt: time.Date(2026, 5, 15, 10, 0, 0, 0, time.UTC)},
|
|
// 2026-05-16 group: 1 entry (bare).
|
|
{ID: "x3", EventDate: &d2, CreatedAt: time.Date(2026, 5, 16, 9, 0, 0, 0, time.UTC)},
|
|
}
|
|
// Caller invariant: DatedLinks returns event_date DESC, created_at ASC.
|
|
// Test data above is created_at ASC; reverse the date groups to match.
|
|
desc := []*store.ItemLink{links[2], links[0], links[1]}
|
|
rows := computePERs("mfin.house1", desc)
|
|
if len(rows) != 3 {
|
|
t.Fatalf("expected 3 rows, got %d", len(rows))
|
|
}
|
|
if rows[0].PER != "mfin.house1.260516" {
|
|
t.Errorf("row 0 PER = %q", rows[0].PER)
|
|
}
|
|
if rows[1].PER != "mfin.house1.260515" || rows[1].Tag != "" {
|
|
t.Errorf("row 1 should be bare, got PER=%q tag=%q", rows[1].PER, rows[1].Tag)
|
|
}
|
|
if rows[2].PER != "mfin.house1.260515.a" || rows[2].Tag != "a" {
|
|
t.Errorf("row 2 should be .a, got PER=%q tag=%q", rows[2].PER, rows[2].Tag)
|
|
}
|
|
}
|
|
|
|
func TestComputePERsSkipsUndated(t *testing.T) {
|
|
d := time.Date(2026, 5, 15, 0, 0, 0, 0, time.UTC)
|
|
links := []*store.ItemLink{
|
|
{ID: "with", EventDate: &d, CreatedAt: time.Now()},
|
|
{ID: "without", EventDate: nil, CreatedAt: time.Now()},
|
|
}
|
|
rows := computePERs("dev.x", links)
|
|
if len(rows) != 1 || rows[0].Link.ID != "with" {
|
|
t.Errorf("undated link should be skipped, got %v", rows)
|
|
}
|
|
}
|