Files
pad/internal/models/terminal_test.go
T
xarmian a4990d7cbb fix(project): a collection's own abandoned_options decide what the changelog, standup and reports count as shipped (BUG-2347) (#1464)
* fix(project): a collection's own abandoned_options decide what the changelog, standup and reports count as shipped (BUG-2347)

The completed-work views excluded abandoned closes by a global name list
(NegativeTerminals). A collection's own abandon word was invisible to it:
live, `pad project changelog` listed PLAN-2326 (plans status `overturned`)
as shipped, along with archived marketing posts and docs.

A select field gains optional `abandoned_options`, validated as a subset of
its terminal_options on collection create/update. One resolver
(PositiveTerminalValuesForDoneField) serves the changelog, standup, the CLI
copy and report throughput: completed = terminal - abandoned, with the global
names as the fallback when a field declares none. Templates seed it.

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N

* fix(store): workspace import refuses abandoned_options that are not terminal, like the collection doors (BUG-2347, codex round 1 on #1464)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N

* test(store): the import test edits the parsed schema, not its text, so it holds on Postgres JSONB (BUG-2347)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-23 12:52:46 -04:00

377 lines
13 KiB
Go

package models
import (
"reflect"
"strings"
"testing"
)
func TestDoneFieldKey_DefaultsToStatus(t *testing.T) {
schema := CollectionSchema{
Fields: []FieldDef{
{Key: "status", Type: "select", Options: []string{"open", "done"}},
},
}
settings := CollectionSettings{}
if got := DoneFieldKey(schema, settings); got != "status" {
t.Fatalf("expected default done field to be 'status', got %q", got)
}
}
func TestDoneFieldKey_HonorsBoardGroupBy(t *testing.T) {
schema := CollectionSchema{
Fields: []FieldDef{
{Key: "status", Type: "select", Options: []string{"open", "done"}},
{Key: "resolution", Type: "select", Options: []string{"fixed", "wontfix"}},
},
}
settings := CollectionSettings{BoardGroupBy: "resolution"}
if got := DoneFieldKey(schema, settings); got != "resolution" {
t.Fatalf("expected done field to follow board_group_by, got %q", got)
}
}
func TestDoneFieldKey_FallsBackWhenFieldMissing(t *testing.T) {
// BoardGroupBy names a field that doesn't exist on the schema —
// fall back to status rather than emitting a broken JSON path.
schema := CollectionSchema{
Fields: []FieldDef{
{Key: "status", Type: "select", Options: []string{"open"}},
},
}
settings := CollectionSettings{BoardGroupBy: "ghost"}
if got := DoneFieldKey(schema, settings); got != "status" {
t.Fatalf("expected fallback to 'status' for missing field, got %q", got)
}
}
func TestDoneFieldKey_FallsBackForNonSelectField(t *testing.T) {
// If board_group_by somehow points at a non-select field, done-
// detection can't meaningfully operate on it — fall back to status.
schema := CollectionSchema{
Fields: []FieldDef{
{Key: "status", Type: "select", Options: []string{"open"}},
{Key: "notes", Type: "text"},
},
}
settings := CollectionSettings{BoardGroupBy: "notes"}
if got := DoneFieldKey(schema, settings); got != "status" {
t.Fatalf("expected fallback to 'status' for non-select field, got %q", got)
}
}
func TestDoneFieldKey_RejectsUnsafeKeys(t *testing.T) {
// Schema keys aren't validated on write; a malicious or buggy writer
// could in principle store a key like `status'); DROP TABLE --`. The
// resolver must refuse to resolve to such keys because callers
// interpolate the returned value into SQL JSON paths. Falls back to
// the literal 'status' — which is always safe.
cases := []string{
"status'); DROP",
"field with spaces",
"name.with.dots",
"key-with-dashes",
"1leading-digit",
"",
}
schema := CollectionSchema{
Fields: []FieldDef{
{Key: "status", Type: "select", Options: []string{"open"}},
},
}
for _, c := range cases {
settings := CollectionSettings{BoardGroupBy: c}
if got := DoneFieldKey(schema, settings); got != "status" {
t.Errorf("unsafe key %q: expected fallback to 'status', got %q", c, got)
}
}
}
func TestDoneFieldKey_RejectsMultiSelect(t *testing.T) {
// multi_select stores values as JSON arrays, but both the Go-side
// membership check and the SQL IN(...) filter assume scalar string
// matching. Accepting multi_select as a done field would silently
// miss items whose terminal value is one of several in the array, so
// DoneFieldKey rejects it and falls back to 'status'.
schema := CollectionSchema{
Fields: []FieldDef{
{Key: "status", Type: "select", Options: []string{"open", "done"}},
{Key: "labels", Type: "multi_select", Options: []string{"p0", "done"}},
},
}
settings := CollectionSettings{BoardGroupBy: "labels"}
if got := DoneFieldKey(schema, settings); got != "status" {
t.Fatalf("expected multi_select board_group_by to fall back to 'status', got %q", got)
}
}
func TestTerminalValuesForDoneField_UsesFieldTerminals(t *testing.T) {
schema := CollectionSchema{
Fields: []FieldDef{
{
Key: "resolution",
Type: "select",
Options: []string{"fixed", "wontfix", "open"},
TerminalOptions: []string{"fixed", "wontfix"},
},
},
}
settings := CollectionSettings{BoardGroupBy: "resolution"}
key, values := TerminalValuesForDoneField(schema, settings)
if key != "resolution" {
t.Fatalf("expected key 'resolution', got %q", key)
}
if !reflect.DeepEqual(values, []string{"fixed", "wontfix"}) {
t.Fatalf("expected resolution terminals, got %v", values)
}
}
func TestTerminalValuesForDoneField_FallsBackToDefaults(t *testing.T) {
// Resolved field exists but has no terminal_options set — fall back
// to the global default list so collections without schema-declared
// terminals continue to function.
schema := CollectionSchema{
Fields: []FieldDef{
{Key: "status", Type: "select", Options: []string{"open", "done"}},
},
}
settings := CollectionSettings{}
key, values := TerminalValuesForDoneField(schema, settings)
if key != "status" {
t.Fatalf("expected key 'status', got %q", key)
}
if !reflect.DeepEqual(values, DefaultTerminalStatuses) {
t.Fatalf("expected DefaultTerminalStatuses, got %v", values)
}
}
func TestTerminalValuesForDoneField_StatusTerminalsStillWork(t *testing.T) {
// Back-compat: a collection whose status field has TerminalOptions set
// should still produce those terminals when board_group_by is empty.
schema := CollectionSchema{
Fields: []FieldDef{
{
Key: "status",
Type: "select",
Options: []string{"open", "done", "cancelled"},
TerminalOptions: []string{"done", "cancelled"},
},
},
}
settings := CollectionSettings{}
key, values := TerminalValuesForDoneField(schema, settings)
if key != "status" {
t.Fatalf("expected key 'status', got %q", key)
}
if !reflect.DeepEqual(values, []string{"done", "cancelled"}) {
t.Fatalf("expected status terminals, got %v", values)
}
}
func TestTerminalPlaceholdersForDoneField_LowercasesArgs(t *testing.T) {
schema := CollectionSchema{
Fields: []FieldDef{
{
Key: "resolution",
Type: "select",
Options: []string{"Fixed", "WontFix", "Open"},
TerminalOptions: []string{"Fixed", "WontFix"},
},
},
}
settings := CollectionSettings{BoardGroupBy: "resolution"}
key, placeholders, args := TerminalPlaceholdersForDoneField(schema, settings)
if key != "resolution" {
t.Fatalf("expected key 'resolution', got %q", key)
}
if placeholders != "?,?" {
t.Fatalf("expected '?,?', got %q", placeholders)
}
if !reflect.DeepEqual(args, []any{"fixed", "wontfix"}) {
t.Fatalf("expected lowercased args, got %v", args)
}
}
func TestIsTerminalItem_TrueWhenFieldMatches(t *testing.T) {
schema := CollectionSchema{
Fields: []FieldDef{
{
Key: "resolution",
Type: "select",
Options: []string{"fixed", "wontfix", "open"},
TerminalOptions: []string{"fixed", "wontfix"},
},
},
}
settings := CollectionSettings{BoardGroupBy: "resolution"}
if !IsTerminalItem(map[string]any{"resolution": "fixed"}, schema, settings) {
t.Fatal("expected item with resolution=fixed to be terminal")
}
if IsTerminalItem(map[string]any{"resolution": "open"}, schema, settings) {
t.Fatal("expected item with resolution=open NOT to be terminal")
}
}
func TestIsTerminalItem_FalseWhenFieldMissing(t *testing.T) {
schema := CollectionSchema{
Fields: []FieldDef{
{Key: "status", Type: "select", Options: []string{"open", "done"}},
},
}
settings := CollectionSettings{}
// No "status" key in the item's fields map — treat as not terminal.
if IsTerminalItem(map[string]any{}, schema, settings) {
t.Fatal("expected item with no status field to be non-terminal")
}
}
func TestIsTerminalItem_CaseInsensitive(t *testing.T) {
// Queries in the SQL layer lowercase both sides; the Go-side helper
// should match that semantics so behavior is consistent regardless of
// which path a caller takes.
schema := CollectionSchema{
Fields: []FieldDef{
{
Key: "status",
Type: "select",
Options: []string{"Open", "Done"},
TerminalOptions: []string{"Done"},
},
},
}
settings := CollectionSettings{}
if !IsTerminalItem(map[string]any{"status": "done"}, schema, settings) {
t.Fatal("expected case-insensitive match to terminal option")
}
if !IsTerminalItem(map[string]any{"status": "DONE"}, schema, settings) {
t.Fatal("expected case-insensitive match to terminal option (uppercase)")
}
}
func TestTerminalStatusesFromSchema_CompatShim(t *testing.T) {
// The legacy API should still pick up status-field terminals.
schema := CollectionSchema{
Fields: []FieldDef{
{
Key: "status",
Type: "select",
Options: []string{"open", "done"},
TerminalOptions: []string{"done"},
},
},
}
got := TerminalStatusesFromSchema(schema)
if !reflect.DeepEqual(got, []string{"done"}) {
t.Fatalf("expected ['done'], got %v", got)
}
}
func TestIsTerminalStatusDefault(t *testing.T) {
if !IsTerminalStatusDefault("done") {
t.Fatal("expected 'done' to be default-terminal")
}
if !IsTerminalStatusDefault("DONE") {
t.Fatal("expected case-insensitive default-terminal match")
}
if IsTerminalStatusDefault("in-progress") {
t.Fatal("expected 'in-progress' to NOT be default-terminal")
}
}
func TestIsNegativeTerminal(t *testing.T) {
if !IsNegativeTerminal("disabled") {
t.Fatal("expected 'disabled' to be a negative terminal (BUG-1049)")
}
if !IsNegativeTerminal("Rejected") {
t.Fatal("expected case-insensitive negative-terminal match")
}
if IsNegativeTerminal("done") {
t.Fatal("expected 'done' to remain a positive terminal")
}
}
func TestPositiveTerminalValuesForDoneField_DropsNegatives(t *testing.T) {
// Stock Conventions: the only schema-declared terminal is "disabled",
// which is a non-shipping close. Completed-work queries must see an
// empty positive set rather than treating the disabled rule as work.
schema := CollectionSchema{
Fields: []FieldDef{
{
Key: "status",
Type: "select",
Options: []string{"active", "draft", "disabled"},
TerminalOptions: []string{"disabled"},
},
},
}
key, values := PositiveTerminalValuesForDoneField(schema, CollectionSettings{})
if key != "status" {
t.Fatalf("expected key 'status', got %q", key)
}
if len(values) != 0 {
t.Fatalf("expected no positive terminals for Conventions, got %v", values)
}
}
func TestPositiveTerminalValuesForDoneField_KeepsDone(t *testing.T) {
schema := CollectionSchema{
Fields: []FieldDef{
{
Key: "status",
Type: "select",
Options: []string{"open", "done", "cancelled"},
TerminalOptions: []string{"done", "cancelled"},
},
},
}
_, values := PositiveTerminalValuesForDoneField(schema, CollectionSettings{})
if !reflect.DeepEqual(values, []string{"done"}) {
t.Fatalf("expected only 'done' (cancelled is negative), got %v", values)
}
}
func TestCollectionCompletedWorkValues_ParsesJSON(t *testing.T) {
key, values := CollectionCompletedWorkValues(
`{"fields":[{"key":"status","type":"select","options":["open","shipped"],"terminal_options":["shipped"]}]}`,
`{}`,
)
if key != "status" {
t.Fatalf("expected key 'status', got %q", key)
}
if !reflect.DeepEqual(values, []string{"shipped"}) {
t.Fatalf("expected [shipped], got %v", values)
}
}
// BUG-2347: a declared abandoned_options REPLACES the global names for that
// field rather than adding to them: the collection's own vocabulary is not
// second-guessed. Undeclared, the global names still apply.
func TestPositiveTerminalValuesHonourDeclaredAbandonedOptions(t *testing.T) {
field := FieldDef{Key: "status", Type: "select",
Options: []string{"open", "completed", "overturned", "cancelled"},
TerminalOptions: []string{"completed", "overturned", "cancelled"},
AbandonedOptions: []string{"Overturned"},
}
_, got := PositiveTerminalValuesForDoneField(CollectionSchema{Fields: []FieldDef{field}}, CollectionSettings{})
if strings.Join(got, ",") != "completed,cancelled" {
t.Fatalf("declared: got %v, want [completed cancelled] (case-insensitive, declared list only)", got)
}
field.AbandonedOptions = nil
_, got = PositiveTerminalValuesForDoneField(CollectionSchema{Fields: []FieldDef{field}}, CollectionSettings{})
if strings.Join(got, ",") != "completed,overturned" {
t.Fatalf("fallback: got %v, want [completed overturned]", got)
}
}
func TestValidateAbandonedOptions(t *testing.T) {
ok := CollectionSchema{Fields: []FieldDef{{Key: "status", TerminalOptions: []string{"done", "cancelled"}, AbandonedOptions: []string{"Cancelled"}}}}
if err := ValidateAbandonedOptions(ok); err != nil {
t.Fatalf("subset refused: %v", err)
}
bad := CollectionSchema{Fields: []FieldDef{{Key: "status", TerminalOptions: []string{"done"}, AbandonedOptions: []string{"cancelled"}}}}
if err := ValidateAbandonedOptions(bad); err == nil {
t.Fatal("a non-terminal abandoned value was accepted")
}
}