docs(filters): user manual — guide, cookbook, generated field reference
The filter manual (docs/user/filters/), folded in from the parked plan
now that the UI matches the backend (group ALL/ANY header toggle,
2344f568):
- index.md — the guide: the three homes of filters (drawer/saved
filters, smart collections, dashboard sections), the books/episodes
scope door, rule anatomy, value picking (chips with x, live counts
under other rules), GROUPS via the new header toggle (+ the row
chips as the fine-tuning tool), the live match count, today's
defaults (reversed listening order, family-lens scoping,
un-downloaded episodes), saving and shareable views.
- cookbook.md — seven exact-clicks recipes (born-reversed briefing,
OR-across-authors, big-unread-fantasy, downloaded-unplayed,
new-arrivals, dashboard shelf, shareable views). Every claim
cross-checked against the generated reference — which caught the
one wrong operator label in my first draft ("within last" vs the
real "within the last"), the drift protection working as designed.
- fields.md — GENERATED from FilterFieldsPayload by
TestFieldReferenceDocIsCurrent: the drift test fails the build if
the registry ever changes without regenerating the docs (user
decision: docs forced to match working code; FILTER_DOCS_REGEN=1
regenerates).
- searching-and-filtering.md cross-links to the folder.
This commit is contained in:
1 parent
2344f5686a
commit
1ed1602e70
5 files changed
+1090
No files matched your search
@@ -0,0 +1,157 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The user manual's field reference (docs/user/filters/fields.md) is
|
||||
// GENERATED from FilterFieldsPayload — the single source of truth the
|
||||
// real menus render from — and pinned by this test: if the registry
|
||||
// changes without regenerating the docs, this fails. Docs cannot drift
|
||||
// from working code (2026-11-01, user decision).
|
||||
//
|
||||
// Regenerate with: FILTER_DOCS_REGEN=1 go test ./internal/services -run TestFieldReferenceDocIsCurrent
|
||||
|
||||
const fieldRefPath = "../../docs/user/filters/fields.md"
|
||||
|
||||
func TestFieldReferenceDocIsCurrent(t *testing.T) {
|
||||
doc := GenerateFieldReferenceDoc()
|
||||
if os.Getenv("FILTER_DOCS_REGEN") == "1" {
|
||||
if err := os.WriteFile(fieldRefPath, []byte(doc), 0o644); err != nil {
|
||||
t.Fatalf("write field reference: %v", err)
|
||||
}
|
||||
t.Log("rewrote " + fieldRefPath)
|
||||
return
|
||||
}
|
||||
existing, err := os.ReadFile(fieldRefPath)
|
||||
if err != nil {
|
||||
t.Fatalf("docs/user/filters/fields.md is missing — regenerate with FILTER_DOCS_REGEN=1 go test ./internal/services -run TestFieldReferenceDocIsCurrent: %v", err)
|
||||
}
|
||||
if string(existing) != doc {
|
||||
t.Fatal("docs/user/filters/fields.md is STALE — the filter registry changed without regenerating the docs. " +
|
||||
"Run FILTER_DOCS_REGEN=1 go test ./internal/services -run TestFieldReferenceDocIsCurrent and commit the refreshed file.")
|
||||
}
|
||||
}
|
||||
|
||||
// kindPhrase — what the value cell looks like for a user, per value kind.
|
||||
func kindPhrase(kind string) string {
|
||||
switch kind {
|
||||
case VKText:
|
||||
return "Type words."
|
||||
case VKNumber:
|
||||
return "Type a number."
|
||||
case VKDays:
|
||||
return "Type a number of days."
|
||||
case VKId:
|
||||
return "Pick from a dropdown of real names — picks become removable chips, and you can pick several."
|
||||
case VKFixed:
|
||||
return "Choose from a fixed list of options."
|
||||
case VKNone:
|
||||
return "No value needed — the question is the whole rule."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// opPhrase — what each operator asks, in user words.
|
||||
func opPhrase(op, kind string) string {
|
||||
switch op {
|
||||
case "contains":
|
||||
return "contains these words"
|
||||
case "not_contains":
|
||||
return "does NOT contain these words"
|
||||
case "is":
|
||||
if kind == VKId {
|
||||
return "is one of the ones you pick"
|
||||
}
|
||||
return "matches exactly"
|
||||
case "is_not", "isn't":
|
||||
return "matches anything but this"
|
||||
case "in":
|
||||
return "is one of the ones you pick (any one is enough)"
|
||||
case "not_in":
|
||||
return "is none of the ones you pick"
|
||||
case "in_any":
|
||||
return "is in at least one of the collections you pick"
|
||||
case "none_of":
|
||||
return "is in none of the collections you pick"
|
||||
case "has":
|
||||
return "has a value"
|
||||
case "missing":
|
||||
return "is empty or missing"
|
||||
case "is_true":
|
||||
return "is switched on"
|
||||
case "is_false":
|
||||
return "is switched off"
|
||||
case "between":
|
||||
return "falls between two numbers"
|
||||
case "at_least":
|
||||
return "is at least this"
|
||||
case "at_most":
|
||||
return "is at most this"
|
||||
}
|
||||
return "—"
|
||||
}
|
||||
|
||||
func GenerateFieldReferenceDoc() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("<!-- GENERATED by TestFieldReferenceDocIsCurrent (internal/services/filter_docs_test.go)\n")
|
||||
b.WriteString(" from the filter registry — the same source the real menus render. DO NOT EDIT by hand:\n")
|
||||
b.WriteString(" regenerate with FILTER_DOCS_REGEN=1 go test ./internal/services -run TestFieldReferenceDocIsCurrent -->\n\n")
|
||||
b.WriteString("# Every filter field\n\n")
|
||||
b.WriteString("This is the complete list of everything the filters can ask about, generated straight from the\n")
|
||||
b.WriteString("same source the app's menus render — it cannot drift from what you see in the UI.\n\n")
|
||||
b.WriteString("Filters come in two worlds:\n\n")
|
||||
b.WriteString("- **Books** — rules about books and your reading of them.\n")
|
||||
b.WriteString("- **Episodes** — rules about podcast episodes (feeds, downloaded, played).\n\n")
|
||||
b.WriteString("Every rule is **a field + a question + your values**. The \"Ask\" column is the question;\n")
|
||||
b.WriteString("the \"Your values\" column is what you provide. The **default** question is what a new rule\n")
|
||||
b.WriteString("starts with.\n\n")
|
||||
|
||||
for _, scope := range []string{ScopeBooks, ScopeEpisodes} {
|
||||
scopeTitle := "Books"
|
||||
if scope == ScopeEpisodes {
|
||||
scopeTitle = "Podcast episodes"
|
||||
}
|
||||
fmt.Fprintf(&b, "## %s\n\n", scopeTitle)
|
||||
|
||||
fields := FilterFieldsPayload(scope)
|
||||
groups := []string{}
|
||||
byGroup := map[string][]FilterFieldWire{}
|
||||
for _, f := range fields {
|
||||
if _, seen := byGroup[f.Group]; !seen {
|
||||
groups = append(groups, f.Group)
|
||||
}
|
||||
byGroup[f.Group] = append(byGroup[f.Group], f)
|
||||
}
|
||||
sort.Strings(groups)
|
||||
for _, g := range groups {
|
||||
fmt.Fprintf(&b, "### %s\n\n", g)
|
||||
for _, f := range byGroup[g] {
|
||||
fmt.Fprintf(&b, "**%s** — %s\n\n", f.Label, kindPhrase(f.ValueKind))
|
||||
lines := make([]string, 0, len(f.Ops))
|
||||
for _, op := range f.Ops {
|
||||
label := op.Label
|
||||
if label == "" {
|
||||
label = op.Key
|
||||
}
|
||||
def := ""
|
||||
if op.Key == f.DefaultOp {
|
||||
def = " *(default)*"
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("| %s | %s%s |", label, opPhrase(op.Key, f.ValueKind), def))
|
||||
}
|
||||
b.WriteString("| Ask | Means |\n| --- | --- |\n")
|
||||
b.WriteString(strings.Join(lines, "\n"))
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
b.WriteString("---\n\n")
|
||||
b.WriteString("*Anything here that doesn't match what the app actually shows is a bug — this page is generated\n")
|
||||
b.WriteString("from the app's own menus and a test fails if they ever disagree.*\n")
|
||||
return b.String()
|
||||
}
|
||||
Reference in new issue
Block a user