Files
projax/web/per_test.go
mAi e055e4607e feat(phase 3c per-events): event_date on item_links, Documents UI, PER URL resolver, MCP date-aware add_link
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).
2026-05-15 18:35:21 +02:00

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)
}
}