fix(filters): project dim narrows /admin/bulk + timeline multi-value kind
m reported /timeline filters don't narrow, then clarified that the project-filter dim added in Phase 5i Slice A (kahn,13923aa) "doesn't work ANYWHERE." Systematic reproduction: /tree?project=admin → narrows ✓ /timeline?project=admin → narrows ✓ /calendar?project=admin → narrows ✓ /dashboard?project=admin → narrows ✓ /admin/bulk?project=admin → SILENT NO-OP ✗ Plus a small parser bug on /timeline's ?kind=… handling that mirrors the calendar bug fixed in6f0a318. ## Root causes (1) `bulkMatches` in web/bulk.go is a near-clone of `TreeFilter.Matches` that the Phase 5i Slice A author updated only on Matches itself — the clone never picked up the ProjectPath block. Filter parses fine, gets threaded into filterFlat, and silently ignored. `/admin/bulk?project=…` sees every item. (2) Timeline's own `?kind=event,doc` parser used `r.URL.Query().Get("kind")` + comma-split — same shape calendar carried before commit6f0a318. When the chip strip's `<select multiple>` submits `?kind=event&kind=doc`, only the first value lands in q.Kinds. The user picks two kinds, sees only one applied. ## Fix bulkMatches gets the ProjectPath block copied verbatim from TreeFilter.Matches — same predicate, same IncludeDescendants gate, same multi-parent "ANY path qualifies" semantics. timeline.parseTimelineQuery's ?kind handling drops the bespoke Get+Split+dedup-map and uses `parseValues(r.URL.Query(), "kind")` — the helper already added to web/server.go covers both URL shapes transparently (`?kind=a,b` and `?kind=a&kind=b`). ## Tests web/project_filter_test.go (new, 6 tests): - TestProjectFilterNarrowsTree - TestProjectFilterNarrowsTimeline - TestProjectFilterNarrowsCalendar - TestProjectFilterNarrowsDashboard - TestProjectFilterNarrowsBulk ← was failing pre-fix - TestProjectFilterDescendantsToggle - TestTimelineKindMultiValueSurvives ← was failing pre-fix The fixture seeds a three-row subtree under dev/ (root + child + outside sibling) and asserts each surface narrows to root + child while excluding the outside sibling. The descendants toggle test flips `?project_descendants=0` and confirms the child drops out. web/timeline_filter_test.go (new, 3 tests): URL-driven tag narrowing, multi-value kind parsing, and chip-strip HTMX form target wiring. These are the immediate "reproduce first" probes athena's brief asked for; they all PASSED on the pre-fix code (the filter narrowing was fine on URL paths; the bug was elsewhere) — they stay as defence-in- depth against future regressions. ## Surfaces double-checked (not broken) - /graph?project=… dims non-matching nodes instead of narrowing per graph.go's explicit comment "the graph deliberately shows the full DAG; the filter dims non-matches via opacity unless isolate=1 hides them." Working as documented. - The chip strip + project-picker template + Views-page hidden inputs all preserve the project value across chip changes — verified by template rendering probes. Full web suite green (76 tests). Pre-existing db/TestBackfillTagsFromArea unchanged. Net: +442 / -12.
This commit is contained in:
23
web/bulk.go
23
web/bulk.go
@@ -118,6 +118,29 @@ func bulkMatches(f TreeFilter, it *store.Item, itemLinkKinds map[string]struct{}
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Phase 5i Slice A: project scope. Same predicate as TreeFilter.Matches —
|
||||
// at least one of the item's paths must equal ProjectPath, with the
|
||||
// IncludeDescendants toggle gating the prefix-match for the subtree.
|
||||
// bulkMatches was a near-clone of Matches() that wasn't updated when
|
||||
// the project dim landed, so /admin/bulk silently ignored ?project=…
|
||||
// (and the chip's hidden-input round-trip too).
|
||||
if f.ProjectPath != "" {
|
||||
prefix := f.ProjectPath + "."
|
||||
hit := false
|
||||
for _, p := range it.Paths {
|
||||
if p == f.ProjectPath {
|
||||
hit = true
|
||||
break
|
||||
}
|
||||
if f.IncludeDescendants && strings.HasPrefix(p, prefix) {
|
||||
hit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hit {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if f.Q != "" {
|
||||
q := strings.ToLower(f.Q)
|
||||
hit := strings.Contains(strings.ToLower(it.Title), q) ||
|
||||
|
||||
266
web/project_filter_test.go
Normal file
266
web/project_filter_test.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// projectFixture seeds a subtree shaped:
|
||||
//
|
||||
// dev/ (existing)
|
||||
// <stamp>-root (root of the test subtree)
|
||||
// <stamp>-child (descendant of root)
|
||||
// <stamp>-outside (sibling of root under dev — NOT a descendant)
|
||||
//
|
||||
// Returns the slugs + primary paths. Callers defer the row cleanup.
|
||||
type projectFixture struct {
|
||||
rootSlug, childSlug, outsideSlug string
|
||||
rootPath, childPath, outsidePath string
|
||||
rootID, childID, outsideID string
|
||||
}
|
||||
|
||||
func seedProjectFixture(t *testing.T, pool *pgxpool.Pool) projectFixture {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
var dev string
|
||||
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
|
||||
t.Fatalf("dev: %v", err)
|
||||
}
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
||||
fx := projectFixture{
|
||||
rootSlug: "proj-root-" + stamp,
|
||||
childSlug: "proj-child-" + stamp,
|
||||
outsideSlug: "proj-outside-" + stamp,
|
||||
}
|
||||
// root + outside both live directly under dev.
|
||||
if err := pool.QueryRow(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_ids)
|
||||
values (array['project']::text[], $1, $1, ARRAY[$2]::uuid[])
|
||||
returning id`,
|
||||
fx.rootSlug, dev,
|
||||
).Scan(&fx.rootID); err != nil {
|
||||
t.Fatalf("seed root: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_ids)
|
||||
values (array['project']::text[], $1, $1, ARRAY[$2]::uuid[])
|
||||
returning id`,
|
||||
fx.outsideSlug, dev,
|
||||
).Scan(&fx.outsideID); err != nil {
|
||||
t.Fatalf("seed outside: %v", err)
|
||||
}
|
||||
// child lives under root.
|
||||
if err := pool.QueryRow(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_ids)
|
||||
values (array['project']::text[], $1, $1, ARRAY[$2]::uuid[])
|
||||
returning id`,
|
||||
fx.childSlug, fx.rootID,
|
||||
).Scan(&fx.childID); err != nil {
|
||||
t.Fatalf("seed child: %v", err)
|
||||
}
|
||||
fx.rootPath = "dev." + fx.rootSlug
|
||||
fx.childPath = fx.rootPath + "." + fx.childSlug
|
||||
fx.outsidePath = "dev." + fx.outsideSlug
|
||||
return fx
|
||||
}
|
||||
|
||||
func cleanupProjectFixture(pool *pgxpool.Pool, fx projectFixture) {
|
||||
pool.Exec(context.Background(), `delete from projax.items where id in ($1, $2, $3)`, fx.rootID, fx.childID, fx.outsideID)
|
||||
}
|
||||
|
||||
// TestProjectFilterNarrowsTree exercises the / (tree) handler — applyTreeFilter
|
||||
// passes the project filter through TreeFilter.Matches, so ?project=<root>
|
||||
// must show only root + descendants.
|
||||
func TestProjectFilterNarrowsTree(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
fx := seedProjectFixture(t, pool)
|
||||
defer cleanupProjectFixture(pool, fx)
|
||||
h := srv.Routes()
|
||||
|
||||
_, body := get(t, h, "/?project="+fx.rootPath)
|
||||
if !strings.Contains(body, fx.rootPath) {
|
||||
t.Errorf("tree ?project=<root> missing root path %q", fx.rootPath)
|
||||
}
|
||||
if !strings.Contains(body, fx.childPath) {
|
||||
t.Errorf("tree ?project=<root> missing child path %q (descendants default ON)", fx.childPath)
|
||||
}
|
||||
if strings.Contains(body, fx.outsidePath) {
|
||||
t.Errorf("tree ?project=<root> leaked outside path %q", fx.outsidePath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectFilterNarrowsTimeline — buildTimeline funnels items via
|
||||
// q.Filter.Matches before fan-out, so ?project=<root> must drop the
|
||||
// creation row for the outside sibling but keep root + child.
|
||||
func TestProjectFilterNarrowsTimeline(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
fx := seedProjectFixture(t, pool)
|
||||
defer cleanupProjectFixture(pool, fx)
|
||||
h := srv.Routes()
|
||||
|
||||
_, body := get(t, h, "/timeline?refresh=1&project="+fx.rootPath)
|
||||
if !strings.Contains(body, fx.rootPath) {
|
||||
t.Errorf("timeline ?project=<root> missing root creation row")
|
||||
}
|
||||
if !strings.Contains(body, fx.childPath) {
|
||||
t.Errorf("timeline ?project=<root> missing child creation row")
|
||||
}
|
||||
if strings.Contains(body, fx.outsidePath) {
|
||||
t.Errorf("timeline ?project=<root> leaked outside creation row %q", fx.outsidePath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectFilterNarrowsCalendar — buildCalendar funnels items via
|
||||
// q.Filter.Matches; rows surface from dated item_links. Seed a dated link
|
||||
// on each fixture item, then verify scoping by ?project=<root>.
|
||||
func TestProjectFilterNarrowsCalendar(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
fx := seedProjectFixture(t, pool)
|
||||
defer cleanupProjectFixture(pool, fx)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
rootNote := "cal-root-" + fx.rootSlug
|
||||
childNote := "cal-child-" + fx.childSlug
|
||||
outsideNote := "cal-outside-" + fx.outsideSlug
|
||||
if _, err := pool.Exec(ctx,
|
||||
`insert into projax.item_links (item_id, ref_type, ref_id, rel, note, event_date)
|
||||
values ($1, 'document', $2, 'contains', $3, current_date),
|
||||
($4, 'document', $5, 'contains', $6, current_date),
|
||||
($7, 'document', $8, 'contains', $9, current_date)`,
|
||||
fx.rootID, "https://example.com/cal-root", rootNote,
|
||||
fx.childID, "https://example.com/cal-child", childNote,
|
||||
fx.outsideID, "https://example.com/cal-outside", outsideNote,
|
||||
); err != nil {
|
||||
t.Fatalf("seed dated links: %v", err)
|
||||
}
|
||||
defer pool.Exec(context.Background(), `delete from projax.item_links where item_id in ($1, $2, $3)`, fx.rootID, fx.childID, fx.outsideID)
|
||||
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/calendar?refresh=1&project="+fx.rootPath)
|
||||
if !strings.Contains(body, rootNote) {
|
||||
t.Errorf("calendar ?project=<root> missing root note %q", rootNote)
|
||||
}
|
||||
if !strings.Contains(body, childNote) {
|
||||
t.Errorf("calendar ?project=<root> missing child note %q (descendants ON)", childNote)
|
||||
}
|
||||
if strings.Contains(body, outsideNote) {
|
||||
t.Errorf("calendar ?project=<root> leaked outside note %q", outsideNote)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectFilterNarrowsDashboard — dashboard filters items via Matches
|
||||
// when q.Filter.Active() is true. The Stale-projects card is the most
|
||||
// reliable surface to verify since it iterates the full item set on
|
||||
// every render.
|
||||
func TestProjectFilterNarrowsDashboard(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
fx := seedProjectFixture(t, pool)
|
||||
defer cleanupProjectFixture(pool, fx)
|
||||
h := srv.Routes()
|
||||
|
||||
_, body := get(t, h, "/dashboard?project="+fx.rootPath)
|
||||
if !strings.Contains(body, fx.rootPath) {
|
||||
t.Errorf("dashboard ?project=<root> missing root path %q", fx.rootPath)
|
||||
}
|
||||
if strings.Contains(body, fx.outsidePath) {
|
||||
t.Errorf("dashboard ?project=<root> leaked outside path %q", fx.outsidePath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectFilterNarrowsBulk reproduces the actual bug: /admin/bulk's
|
||||
// bulkMatches was a near-clone of TreeFilter.Matches that never picked up
|
||||
// the Phase 5i Slice A ProjectPath block, so ?project=<root> silently
|
||||
// ignored the filter. Pre-fix the outside item leaked into the bulk list.
|
||||
func TestProjectFilterNarrowsBulk(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
fx := seedProjectFixture(t, pool)
|
||||
defer cleanupProjectFixture(pool, fx)
|
||||
h := srv.Routes()
|
||||
|
||||
_, body := get(t, h, "/admin/bulk?project="+fx.rootPath)
|
||||
if !strings.Contains(body, fx.rootPath) {
|
||||
t.Errorf("bulk ?project=<root> missing root path %q", fx.rootPath)
|
||||
}
|
||||
if !strings.Contains(body, fx.childPath) {
|
||||
t.Errorf("bulk ?project=<root> missing child path %q (descendants ON)", fx.childPath)
|
||||
}
|
||||
if strings.Contains(body, fx.outsidePath) {
|
||||
t.Errorf("BUG: /admin/bulk ?project=<root> leaked outside path %q — bulkMatches missing the ProjectPath gate", fx.outsidePath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectFilterDescendantsToggle pins m's Q5 pick: the toggle is
|
||||
// exposed explicitly. With project_descendants=0 the filter narrows to
|
||||
// the single root item only — the child path must drop out.
|
||||
func TestProjectFilterDescendantsToggle(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
fx := seedProjectFixture(t, pool)
|
||||
defer cleanupProjectFixture(pool, fx)
|
||||
h := srv.Routes()
|
||||
|
||||
// Default (descendants on) — child included.
|
||||
_, on := get(t, h, "/?project="+fx.rootPath)
|
||||
if !strings.Contains(on, fx.childPath) {
|
||||
t.Errorf("descendants=on should include child path %q", fx.childPath)
|
||||
}
|
||||
|
||||
// Toggled off — child dropped, root still in.
|
||||
_, off := get(t, h, "/?project="+fx.rootPath+"&project_descendants=0")
|
||||
if !strings.Contains(off, fx.rootPath) {
|
||||
t.Errorf("descendants=off should still include root %q", fx.rootPath)
|
||||
}
|
||||
if strings.Contains(off, fx.childPath) {
|
||||
t.Errorf("descendants=off leaked child path %q — IncludeDescendants gate not honoured", fx.childPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimelineKindMultiValueSurvives mirrors the earlier calendar-filter
|
||||
// fix: <select multiple> chip submission emits `?kind=event&kind=doc`,
|
||||
// and the timeline's previous q.Get("kind") + comma-split dropped every
|
||||
// value past the first. parseValues threads BOTH URL shapes through.
|
||||
func TestTimelineKindMultiValueSurvives(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
// We probe at the parse level via the page render: a `?kind=event&kind=doc`
|
||||
// URL must round-trip both kinds into q.Kinds, so the kind multi-select
|
||||
// in the rendered form preserves BOTH as selected options.
|
||||
_, body := get(t, h, "/timeline?kind=event&kind=doc")
|
||||
// The timeline's chip-strip <select> emits `<option value="x" selected>`
|
||||
// only when q.Kinds contains "x". Pre-fix only the first value
|
||||
// survived, so the second option lost its selected attr. The template
|
||||
// has whitespace padding between value and selected so we anchor on
|
||||
// the `value="X"` + `selected` pair within a small window — the
|
||||
// `</option>` for the same X then closes the option.
|
||||
checkSelected := func(kind string) {
|
||||
idx := strings.Index(body, `<option value="`+kind+`"`)
|
||||
if idx < 0 {
|
||||
t.Errorf("rendered form missing <option value=%q>", kind)
|
||||
return
|
||||
}
|
||||
// Slice until the following </option>; the selected attribute, if
|
||||
// present, lives in that window.
|
||||
end := strings.Index(body[idx:], `</option>`)
|
||||
if end < 0 {
|
||||
t.Errorf("rendered form malformed near <option value=%q>", kind)
|
||||
return
|
||||
}
|
||||
window := body[idx : idx+end]
|
||||
if !strings.Contains(window, "selected") {
|
||||
t.Errorf("?kind=event&kind=doc lost %q selection: window=%q", kind, window)
|
||||
}
|
||||
}
|
||||
checkSelected("event")
|
||||
checkSelected("doc")
|
||||
}
|
||||
@@ -149,20 +149,19 @@ func parseTimelineQuery(r *http.Request, now time.Time) TimelineQuery {
|
||||
q.From = startOfDay(now)
|
||||
}
|
||||
}
|
||||
if v := strings.TrimSpace(r.URL.Query().Get("kind")); v != "" {
|
||||
seen := map[string]bool{}
|
||||
for _, k := range strings.Split(v, ",") {
|
||||
k = strings.TrimSpace(strings.ToLower(k))
|
||||
switch k {
|
||||
case timelineKindTodo, timelineKindEvent, timelineKindDoc, timelineKindCreation:
|
||||
if !seen[k] {
|
||||
seen[k] = true
|
||||
q.Kinds = append(q.Kinds, k)
|
||||
}
|
||||
}
|
||||
// Accept both `?kind=event,doc` (comma-joined) and
|
||||
// `?kind=event&kind=doc` (HTMX multi-select submission). The earlier
|
||||
// q.Get + comma-split flavour dropped everything past the first value
|
||||
// when the chip strip's <select multiple> submitted — same pre-5d
|
||||
// shape calendar's parser carried before commit 6f0a318. parseValues
|
||||
// (web/server.go) merges both URL styles into a single slice.
|
||||
for _, k := range parseValues(r.URL.Query(), "kind") {
|
||||
switch k {
|
||||
case timelineKindTodo, timelineKindEvent, timelineKindDoc, timelineKindCreation:
|
||||
q.Kinds = append(q.Kinds, k)
|
||||
}
|
||||
sort.Strings(q.Kinds)
|
||||
}
|
||||
sort.Strings(q.Kinds)
|
||||
return q
|
||||
}
|
||||
|
||||
|
||||
142
web/timeline_filter_test.go
Normal file
142
web/timeline_filter_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestTimelineFilterNarrowsByTag reproduces m's bug report: `/timeline?tag=work`
|
||||
// should narrow the spine to only work-tagged items. Pre-fix the page rendered
|
||||
// every dated row regardless of filter.
|
||||
func TestTimelineFilterNarrowsByTag(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
||||
tagWork := "tl-bug-work-" + stamp
|
||||
tagHome := "tl-bug-home-" + stamp
|
||||
var dev string
|
||||
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
|
||||
t.Fatalf("dev: %v", err)
|
||||
}
|
||||
|
||||
type seed struct {
|
||||
slug, note, tag string
|
||||
}
|
||||
seeds := []seed{
|
||||
{slug: "tl-work-" + stamp, note: "tl-work-note-" + stamp, tag: tagWork},
|
||||
{slug: "tl-home-" + stamp, note: "tl-home-note-" + stamp, tag: tagHome},
|
||||
}
|
||||
var ids []string
|
||||
for _, s := range seeds {
|
||||
var id string
|
||||
if err := pool.QueryRow(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_ids, tags)
|
||||
values (array['project']::text[], $1, $2, ARRAY[$3]::uuid[], ARRAY[$4]::text[])
|
||||
returning id`,
|
||||
s.slug, s.slug, dev, s.tag,
|
||||
).Scan(&id); err != nil {
|
||||
t.Fatalf("seed %s: %v", s.slug, err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
if _, err := pool.Exec(ctx,
|
||||
`insert into projax.item_links (item_id, ref_type, ref_id, rel, note, event_date)
|
||||
values ($1, 'document', $2, 'contains', $3, current_date)`,
|
||||
id, "https://example.com/tl-"+s.slug, s.note,
|
||||
); err != nil {
|
||||
t.Fatalf("seed link %s: %v", s.slug, err)
|
||||
}
|
||||
}
|
||||
for _, id := range ids {
|
||||
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
||||
}
|
||||
|
||||
// Unfiltered: both notes should show.
|
||||
_, all := get(t, h, "/timeline?refresh=1")
|
||||
if !strings.Contains(all, seeds[0].note) || !strings.Contains(all, seeds[1].note) {
|
||||
t.Fatalf("baseline timeline missing seeded notes; body excerpt: %s", truncate(all, 600))
|
||||
}
|
||||
|
||||
// Filtered: ?tag=tagWork should drop the home note.
|
||||
_, scoped := get(t, h, "/timeline?refresh=1&tag="+tagWork)
|
||||
if !strings.Contains(scoped, seeds[0].note) {
|
||||
t.Errorf("filtered timeline missing work note %q", seeds[0].note)
|
||||
}
|
||||
if strings.Contains(scoped, seeds[1].note) {
|
||||
t.Errorf("BUG: /timeline?tag=%s leaked home note %q — filter didn't narrow",
|
||||
tagWork, seeds[1].note)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimelineFilterByKindMultiValue exercises the kind chip via HTMX-style
|
||||
// repeated-param submission (?kind=todo&kind=doc). Pre-fix the timeline's
|
||||
// own ?kind parser used q.Get("kind") which dropped everything past the
|
||||
// first value — same root cause as the calendar's pre-5d kind bug.
|
||||
func TestTimelineFilterByKindMultiValue(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
||||
var dev string
|
||||
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
|
||||
t.Fatalf("dev: %v", err)
|
||||
}
|
||||
slug := "tl-kind-" + stamp
|
||||
noteText := "tl-kind-doc-note-" + stamp
|
||||
var id string
|
||||
if err := pool.QueryRow(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_ids)
|
||||
values (array['project']::text[], $1, $2, ARRAY[$3]::uuid[])
|
||||
returning id`,
|
||||
slug, slug, dev,
|
||||
).Scan(&id); err != nil {
|
||||
t.Fatalf("seed item: %v", err)
|
||||
}
|
||||
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
||||
if _, err := pool.Exec(ctx,
|
||||
`insert into projax.item_links (item_id, ref_type, ref_id, rel, note, event_date)
|
||||
values ($1, 'document', $2, 'contains', $3, current_date)`,
|
||||
id, "https://example.com/tl-kind-"+stamp, noteText,
|
||||
); err != nil {
|
||||
t.Fatalf("seed link: %v", err)
|
||||
}
|
||||
|
||||
// HTMX-style repeated kind params: doc AND event selected.
|
||||
// The seeded item only produces a doc row; the event slot is empty
|
||||
// for this test (no linked calendar). Both kinds must parse so the
|
||||
// doc row survives.
|
||||
_, body := get(t, h, "/timeline?refresh=1&kind=doc&kind=event")
|
||||
if !strings.Contains(body, noteText) {
|
||||
t.Errorf("expected ?kind=doc&kind=event to include the seeded doc note %q, body excerpt: %s",
|
||||
noteText, truncate(body, 600))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimelineFilterStripFormHasCorrectTarget asserts the chip strip's HTMX
|
||||
// wiring is intact — `hx-get="/timeline"`, `hx-target="#timeline-section"`,
|
||||
// `hx-trigger="change from:select"`. A future template edit that drops one
|
||||
// of these would silently break in-place chip swapping.
|
||||
func TestTimelineFilterStripFormHasCorrectTarget(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/timeline")
|
||||
for _, want := range []string{
|
||||
`id="timeline-filter"`,
|
||||
`hx-get="/timeline"`,
|
||||
`hx-target="#timeline-section"`,
|
||||
`hx-trigger="change from:select"`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("timeline filter form missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user