Files
projax/web/detail_order_test.go
mAi 1af0990108 feat(detail): reorder fields general→specific, divider before auxiliary
m's report: detail page (/i/{path}) shows Tasks / Issues / Documents
above the edit form, and the form's 9 flat fields read as a wall of
labels rather than a flow. He wants the form first, fields grouped, then
auxiliary read-only sections below a clear visual break.

Reordered top-to-bottom flow:

  h1 + meta
  ▸ form
      General        — Title → Slug → Parents → Status
      Classification — Tags → Management
      Flags          — pinned + archived (inline pair)
      Content        — markdown textarea
      Public listing <details>            (stays inside form: save coherence)
      Timeline behaviour <details>        (stays inside form: save coherence)
      Save / Cancel actions
  ◂ /form
  <hr class="aux-divider">
  ▸ section.aux-sections "Related"
      Tasks <details>      (was above form)
      Issues <details>     (was above form)
      Documents <details>  (was above form)
      reset section state link

web/templates/detail.tmpl:
- Three <section class="form-group"> blocks each with a <h2
  class="form-group-heading"> ID-anchored for aria-labelledby + the
  ordering test. The headings render as small uppercase muted labels —
  visual hierarchy without screaming "FORM".
- Form-bound collapsibles (Public Listing + Timeline behaviour) stay
  inside the form; moving them out would require a separate POST
  endpoint, which the brief explicitly puts out of scope.
- Tasks / Issues / Documents collapsibles moved out of the form, into a
  new <section class="aux-sections"> after a thematic <hr>.
- Reset-section-state link relocated to .aux-reset under the auxiliary
  section since that's where most collapsible state lives now.
- All data-section / data-item-id / proj-section class hooks preserved
  exactly — Phase 4e smart-default + localStorage state semantics
  unchanged.

web/static/style.css:
- .detail-form: column flex, gap 20px between groups for breathing room.
- .form-group-heading: 0.78em uppercase muted with dotted-border-bottom
  separator — looks like an admin-form group header without being
  shouty.
- .form-group-flags: row-flex so pinned + archived sit inline.
- .aux-divider: full-width 1px solid border-top with 32px margin above,
  16px below — the explicit "this is where editable ends" break.
- .aux-sections + .aux-heading + .aux-reset: matched flex layout +
  small "Related" header so the change-of-mode reads without
  squinting.

Tests:
- TestDetailFieldsRenderInOrder (new) — strict-greater index walk
  through every documented anchor: General → Title → Slug → Parents →
  Status → Classification → Tags → Management → Flags → pinned →
  archived → Content → content_md → Save → aux-divider → Related →
  Documents. Catches any future regression that re-tangles the order.
- TestDetailFormGroupHeadings (new) — pins the five visible group
  headings (General / Classification / Flags / Content / Related) so
  a string-cleanup pass can't silently strip them.
- TestDetailAuxSectionsAfterForm (new) — Documents <details> lives
  AFTER the detail form's </form>, while Public listing stays INSIDE
  the form for save-coherence. Skips the sidebar's logout-form </form>
  by anchoring on the detail-form's action="/i/dev" start tag.
- TestDetailIncludesSectionToggleScript / TestDetailSectionsWrappedInDetails /
  TestDetailDocumentsClosedDefaultsWhenManyItems still pass — the
  Phase 4e collapsible semantics are untouched.

Net: +298 / -92.
2026-05-26 13:15:39 +02:00

135 lines
4.6 KiB
Go

package web_test
import (
"strings"
"testing"
)
// TestDetailFieldsRenderInOrder pins m's requested top-to-bottom flow on
// the /i/{path} edit form: form-then-auxiliaries, with form fields
// grouped general → specific. The test slices the rendered body into the
// documented anchor strings (form labels, group headings, divider, aux
// heading, first auxiliary <details>) and confirms each anchor's first
// index is strictly greater than the previous one.
//
// Anchors deliberately picked to be robust:
// - Form field markup (name="title" / name="slug" / etc.) — won't drift
// unless the form is re-architected.
// - Group-heading IDs (hdr-general / hdr-classification / hdr-flags /
// hdr-content / hdr-aux) — emitted by the Phase-5i template.
// - The aux-divider <hr> — the explicit visual break m asked for.
//
// If a future refactor moves fields around inside a group, this test
// still passes as long as the cross-group order holds.
func TestDetailFieldsRenderInOrder(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/i/dev")
if code != 200 {
t.Fatalf("GET /i/dev → %d", code)
}
anchors := []struct {
label string
needle string
}{
{"General heading", `id="hdr-general"`},
{"Title field", `name="title"`},
{"Slug field", `name="slug"`},
{"Parents field", `name="parent_ids"`},
{"Status field", `name="status"`},
{"Classification heading", `id="hdr-classification"`},
{"Tags field", `name="tags"`},
{"Management field", `name="management"`},
{"Flags heading", `id="hdr-flags"`},
{"pinned field", `name="pinned"`},
{"archived field", `name="archived"`},
{"Content heading", `id="hdr-content"`},
{"Content textarea", `name="content_md"`},
{"Save button", `<button type="submit">Save</button>`},
{"Auxiliary divider", `class="aux-divider"`},
{"Auxiliary heading", `id="hdr-aux"`},
{"Documents section", `data-section="documents"`},
}
prevIdx := -1
prevLabel := "(start)"
for _, a := range anchors {
idx := strings.Index(body, a.needle)
if idx < 0 {
t.Errorf("%s anchor %q not found in body", a.label, a.needle)
continue
}
if idx <= prevIdx {
t.Errorf("order violation: %s (idx %d) must come AFTER %s (idx %d)",
a.label, idx, prevLabel, prevIdx)
}
prevIdx = idx
prevLabel = a.label
}
}
// TestDetailFormGroupHeadings proves the three group subheadings render
// with the expected human-readable copy. Hardcoded so a future "clean
// up the strings" pass doesn't silently strip the visual hierarchy m
// asked for.
func TestDetailFormGroupHeadings(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/i/dev")
for _, want := range []string{
`>General</h2>`,
`>Classification</h2>`,
`>Flags</h2>`,
`>Content</h2>`,
`>Related</h2>`,
} {
if !strings.Contains(body, want) {
t.Errorf("detail page missing group heading %q", want)
}
}
}
// TestDetailAuxSectionsAfterForm proves the read-only auxiliary
// <details> sections (Tasks / Issues / Documents) live BELOW the
// form's </form> tag — that's the load-bearing visual contract from
// m's report. Public-listing + Timeline-behaviour stay INSIDE the form
// (form-bound, saved by the main Save) — this test asserts only the
// purely read-only sections moved.
func TestDetailAuxSectionsAfterForm(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/i/dev")
// The layout has a <form action="/logout"> in the sidebar — its </form>
// would match first. Anchor on the detail form's start tag, then look
// for </form> AFTER that point so we're measuring the right boundary.
formStart := strings.Index(body, `<form method="post" action="/i/dev"`)
if formStart < 0 {
t.Fatalf("detail form start tag not found in body")
}
formEnd := strings.Index(body[formStart:], "</form>")
if formEnd < 0 {
t.Fatalf("</form> tag not found after detail form start")
}
formEnd += formStart
docs := strings.Index(body, `data-section="documents"`)
if docs < 0 {
t.Fatalf("documents section not found")
}
if docs <= formEnd {
t.Errorf("Documents section (idx %d) must appear AFTER </form> (idx %d)", docs, formEnd)
}
// Public listing stays inside the form — confirm the contract holds.
publicSection := strings.Index(body, `data-section="public"`)
if publicSection < 0 {
t.Fatalf("public section not found")
}
if publicSection >= formEnd {
t.Errorf("Public listing section (idx %d) should remain INSIDE the form (before </form> at idx %d) for save coherence",
publicSection, formEnd)
}
}