mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
a4990d7cbb
* 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
377 lines
13 KiB
Go
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")
|
|
}
|
|
}
|