Files
pad/internal/items/coerce_test.go
T
xarmian 25d76af0ba fix(fields): field writes keep every number's literal, so an integer above 2^53 is no longer rounded by a write that never named it (BUG-3202) (#1545)
* fix(fields): field writes keep every number's literal, so an integer above 2^53 is no longer rounded by a write that never named it (BUG-3202)

Every door that writes an item's fields decoded the blob into
map[string]any, turning each JSON number into a float64, so the re-encode
rounded integers above 2^53 (9007199254740993 stored as ...992). The stored
blob was decoded that way too, so a fields_patch to `status` rewrote every
large integer in the row.

- models.DecodeFieldsJSON / DecodeJSONKeepingNumbers decode with UseNumber.
  They accept and refuse exactly what json.Unmarshal does (trailing data,
  out-of-float64-range numbers); only the representation changes.
- Used at every fields decode that feeds a write: create, full-fields
  update, the fields object on the wire, fields_patch (request and store
  merge), the carried-blank-relation normalisation, the Append/Set metadata
  helpers, move, bulk move, cross-workspace copy and its preflight, and the
  move/copy field_overrides (models.FieldValues).
- items.validateFieldType accepts a json.Number that parses as a finite
  number. items.coerceValue and the CLI's parseFieldFlag keep a numeric
  string that is a JSON number literal as that literal.
- Read-only decoders are unchanged (display, hydration, guards).

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

* fix(fields): compare field values by value, so an equal number in another spelling is no change and two integers that share a float64 are (BUG-3202)

Field writes now keep a number's literal, so one value can be stored as
different text (1e3 vs 1000). Three places compared field values:

- item.updated's change detector compared the fields blob's TEXT whenever
  it was not masking a string done key: an equal-value rewrite, or a
  key-reorder-only rewrite, emitted item.updated.
- the activity `changes` diff and the BUG-3163 carry check compared
  float64s: equal spellings were fine, but two different integers above
  2^53 compared equal, so such a change went unrecorded and a changed
  reserved value passed as carried.

models.CanonicalJSONNumbers gives every number one exact canonical
spelling. All three decode keeping literals and compare canonical forms.

Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
2026-09-25 14:22:09 -04:00

218 lines
8.6 KiB
Go

package items
import (
"encoding/json"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// The schema every case below is coerced against.
func coerceSchema() models.CollectionSchema {
return models.CollectionSchema{Fields: []models.FieldDef{
{Key: "cost", Type: "number"},
{Key: "spec", Type: "json"},
{Key: "tags", Type: "multi_select"},
{Key: "done", Type: "checkbox"},
{Key: "note", Type: "text"},
{Key: "due", Type: "date"},
}}
}
// The defect this exists for: the remote /mcp door builds its field map with
// `dst[key] = val`, so a declared number field arrives as the STRING "42" and
// validateFieldType refuses it — the field is unwritable on that transport
// (BUG-2850). Every assertion here is about the native TYPE that reaches the
// store, because "the write succeeded" is what the CLI door already did.
func TestCoerceFieldsTypesDeclaredStrings(t *testing.T) {
in := map[string]any{
"cost": "42",
"spec": `[{"name":"a"}]`,
"tags": `["x","y"]`,
"done": "true",
}
out := CoerceFields(in, coerceSchema())
// A JSON NUMBER, not a string. Since BUG-3202 that is a json.Number
// holding the caller's literal, so an integer above 2^53 keeps its
// digits; before, it was a float64.
if got, ok := out["cost"].(json.Number); !ok || got != "42" {
t.Fatalf("cost: want json.Number(42), got %[1]T(%[1]v)", out["cost"])
}
if _, ok := out["spec"].([]any); !ok {
t.Fatalf("spec: want []any, got %[1]T(%[1]v)", out["spec"])
}
if _, ok := out["tags"].([]any); !ok {
t.Fatalf("tags: want []any, got %[1]T(%[1]v)", out["tags"])
}
if got, ok := out["done"].(bool); !ok || !got {
t.Fatalf("done: want bool(true), got %[1]T(%[1]v)", out["done"])
}
}
// Types that are already strings must stay strings. A "coerce everything that
// parses" implementation would turn a text field holding "42" into a number and
// corrupt data that was never broken — this is the guard against fixing the bug
// by over-reaching.
func TestCoerceFieldsLeavesStringTypedFieldsAlone(t *testing.T) {
out := CoerceFields(map[string]any{
"note": "42",
"due": "2026-09-02",
}, coerceSchema())
if got, ok := out["note"].(string); !ok || got != "42" {
t.Fatalf("note: want string(\"42\"), got %[1]T(%[1]v)", out["note"])
}
if got, ok := out["due"].(string); !ok || got != "2026-09-02" {
t.Fatalf("due: want the date string, got %[1]T(%[1]v)", out["due"])
}
}
// A value that will not parse is handed to the validator UNCHANGED, so the
// existing "must be a number" error still fires. Coercion must not invent an
// error path, and must not swallow a bad value into something plausible.
func TestCoerceFieldsLeavesUnparseableValuesForTheValidator(t *testing.T) {
out := CoerceFields(map[string]any{
"cost": "not-a-number",
"spec": "{definitely not json",
}, coerceSchema())
if got, ok := out["cost"].(string); !ok || got != "not-a-number" {
t.Fatalf("cost: want the original string, got %[1]T(%[1]v)", out["cost"])
}
if err := ValidateFields(out, coerceSchema()); err == nil {
t.Fatal("expected the validator to still refuse the un-coercible value")
}
}
// NaN and ±Inf parse as floats and then cannot be marshalled: encoding/json
// fails, the downstream json.Marshal(fields) error is ignored, and the ENTIRE
// fields payload is silently dropped. They must fall through as strings so the
// validator refuses them loudly instead.
func TestCoerceFieldsRefusesNonFiniteNumbers(t *testing.T) {
for _, raw := range []string{"NaN", "Inf", "-Inf", "+Inf"} {
out := CoerceFields(map[string]any{"cost": raw}, coerceSchema())
if f, ok := out["cost"].(float64); ok {
t.Fatalf("%q was coerced to float64(%v); non-finite values must stay strings", raw, f)
}
}
}
// Non-string values pass through untouched. This is not a re-typing pass over
// well-formed input — a caller already sending 42 keeps sending 42, and an
// int must not become a float64 behind their back.
func TestCoerceFieldsPassesNonStringsThrough(t *testing.T) {
in := map[string]any{"cost": 42, "spec": []any{"already", "parsed"}, "done": true}
out := CoerceFields(in, coerceSchema())
if got, ok := out["cost"].(int); !ok || got != 42 {
t.Fatalf("cost: want int(42) untouched, got %[1]T(%[1]v)", out["cost"])
}
if _, ok := out["spec"].([]any); !ok {
t.Fatalf("spec: want []any untouched, got %T", out["spec"])
}
if got, ok := out["done"].(bool); !ok || !got {
t.Fatalf("done: want bool(true) untouched, got %[1]T(%[1]v)", out["done"])
}
}
// Keys the schema does not declare are left exactly as they arrived. This is
// TODAY'S behaviour and the other half of BUG-2850 — the disposition (refuse /
// warn / keep) is a product decision still open. The test pins what the code
// does so the decision, when it lands, is a deliberate change to a stated
// behaviour rather than a silent one.
func TestCoerceFieldsLeavesUndeclaredKeysUntouched(t *testing.T) {
out := CoerceFields(map[string]any{"materials_cost": "42"}, coerceSchema())
if got, ok := out["materials_cost"].(string); !ok || got != "42" {
t.Fatalf("undeclared key: want the string untouched, got %[1]T(%[1]v)", out["materials_cost"])
}
}
// CoerceFields returns a NEW map. Two call sites re-marshal the map they pass
// in, so a function that quietly rewrote its argument would change what gets
// stored from behind a name that does not say so.
func TestCoerceFieldsDoesNotMutateItsInput(t *testing.T) {
in := map[string]any{"cost": "42"}
_ = CoerceFields(in, coerceSchema())
if got, ok := in["cost"].(string); !ok || got != "42" {
t.Fatalf("input was mutated: cost is now %[1]T(%[1]v)", in["cost"])
}
}
// Reserved keys are system-written metadata, not a user's stray field, so
// naming them as "undeclared" on every write that carries one would be noise
// the reader learns to ignore (BUG-2850).
//
// Excluded via models.IsReservedItemField rather than a second list here: that
// set exists so callers ask instead of re-listing, and its doc comment records
// what re-listing cost the last time someone did it.
func TestUndeclaredFieldKeysExcludesReservedMetadata(t *testing.T) {
got := UndeclaredFieldKeys(map[string]any{
"implementation_notes": "written by pad item note",
"decision_log": "written by pad item decide",
"github_pr": "written by pad github link",
"convention": "system",
"materials_cost": 42,
}, coerceSchema())
if len(got) != 1 || got[0] != "materials_cost" {
t.Fatalf("undeclared = %v, want only [materials_cost] — reserved metadata must not be reported", got)
}
}
// Declared keys are never reported, and the result is sorted so the warning
// text is stable across runs (map iteration order is not).
func TestUndeclaredFieldKeysIsSortedAndSkipsDeclared(t *testing.T) {
got := UndeclaredFieldKeys(map[string]any{
"cost": 1,
"zebra": 1,
"alpha": 1,
}, coerceSchema())
if len(got) != 2 || got[0] != "alpha" || got[1] != "zebra" {
t.Fatalf("undeclared = %v, want [alpha zebra] sorted, with the declared 'cost' absent", got)
}
}
// BUG-3202: a numeric string keeps its literal, so a value past float64's
// integer precision reaches the store with every digit. The forms ParseFloat
// accepts that are NOT JSON number literals keep the old float path.
func TestCoerceFieldsKeepsNumberLiterals(t *testing.T) {
cases := map[string]any{
"9007199254740993": json.Number("9007199254740993"),
"-12.50": json.Number("-12.50"),
"1e3": json.Number("1e3"),
" 42": " 42", // ParseFloat refuses the space: stays a string, as before
"0x1p4": float64(16), // a hex float ParseFloat accepts and JSON does not: the old float path
"1e400": "1e400", // out of range: left for the validator to refuse
"NaN": "NaN",
}
for in, want := range cases {
got := CoerceFields(map[string]any{"cost": in}, coerceSchema())["cost"]
if got != want {
t.Errorf("coerce %q: got %[2]T(%[2]v), want %[3]T(%[3]v)", in, got, want)
}
}
}
// BUG-3202: the write doors now hand the validator json.Number values. One
// from the decoder is always a valid literal, but a json.Number can be built
// from any string, so the validator parses it rather than trusting the type.
func TestValidateFieldsNumberAcceptsOnlyValidJSONNumbers(t *testing.T) {
schema := models.CollectionSchema{Fields: []models.FieldDef{{Key: "cost", Type: "number"}}}
for in, ok := range map[json.Number]bool{
"9007199254740993": true,
"-1.5e3": true,
"abc": false,
"1e400": false,
"": false,
} {
err := ValidateFields(map[string]any{"cost": in}, schema)
if (err == nil) != ok {
t.Errorf("json.Number(%q): err=%v, want accepted=%v", in, err, ok)
}
}
}