mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
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) 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
90 lines
3.3 KiB
Go
90 lines
3.3 KiB
Go
package models
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
)
|
|
|
|
// BUG-3202. DecodeFieldsJSON must change only how an accepted number is
|
|
// represented: it accepts and refuses the same blobs json.Unmarshal does, and a
|
|
// decode-then-encode leaves every number byte-identical.
|
|
func TestDecodeFieldsJSON_RoundTripsNumbersExactly(t *testing.T) {
|
|
const blob = `{"big":9007199254740993,"neg":-9007199254740995,"f":1.0,"e":1e3,"nested":{"n":[18446744073709551617,0.1]}}`
|
|
m, err := DecodeFieldsJSON([]byte(blob))
|
|
if err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
out, err := json.Marshal(m)
|
|
if err != nil {
|
|
t.Fatalf("encode: %v", err)
|
|
}
|
|
// json.Marshal sorts keys; compare against the same blob with sorted keys.
|
|
const want = `{"big":9007199254740993,"e":1e3,"f":1.0,"neg":-9007199254740995,"nested":{"n":[18446744073709551617,0.1]}}`
|
|
if string(out) != want {
|
|
t.Fatalf("round trip changed the numbers:\n got %s\nwant %s", out, want)
|
|
}
|
|
|
|
// CONTROL: the plain decode this replaces does round, so the assertion
|
|
// above can fail.
|
|
var plain map[string]any
|
|
_ = json.Unmarshal([]byte(blob), &plain)
|
|
if pOut, _ := json.Marshal(plain); string(pOut) == want {
|
|
t.Fatal("control: a plain Unmarshal round trip preserved the numbers, so this test measures nothing")
|
|
}
|
|
}
|
|
|
|
func TestDecodeFieldsJSON_AcceptsWhatUnmarshalAccepts(t *testing.T) {
|
|
cases := []string{
|
|
`{}`, `null`, `{"a":1}`, ` {"a":1} `, `{"a":1e308}`, `{"a":-0}`, `{"a":[1,{"b":2}]}`,
|
|
// refused by both
|
|
`{"a":1e400}`, `{"a":[1e400]}`, `{"a":{"b":-1e999}}`, `{"a":1} x`, `{"a":1}{}`, `{"a":`, `[1]`, `"s"`, ``,
|
|
}
|
|
for _, c := range cases {
|
|
var plain map[string]any
|
|
plainErr := json.Unmarshal([]byte(c), &plain)
|
|
_, err := DecodeFieldsJSON([]byte(c))
|
|
if (plainErr == nil) != (err == nil) {
|
|
t.Errorf("%q: json.Unmarshal err=%v, DecodeFieldsJSON err=%v; they must agree", c, plainErr, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// CanonicalJSONNumbers: equal values get one spelling, different values keep
|
|
// different ones, including two integers above 2^53 that share a float64.
|
|
func TestCanonicalJSONNumbers(t *testing.T) {
|
|
same := [][]string{
|
|
{"1000", "1e3", "1.0e3", "1000.0", "1E+3", "10000e-1", "0.1e4"},
|
|
{"0", "-0", "0.0", "0e5"},
|
|
{"-12.5", "-125e-1", "-1.25e1"},
|
|
{"9007199254740993", "9007199254740993.000"},
|
|
}
|
|
for _, group := range same {
|
|
want := CanonicalJSONNumbers(json.Number(group[0]))
|
|
for _, s := range group[1:] {
|
|
if got := CanonicalJSONNumbers(json.Number(s)); got != want {
|
|
t.Errorf("%s canonicalises to %v, but %s to %v; they are the same value", s, got, group[0], want)
|
|
}
|
|
}
|
|
}
|
|
diff := [][2]string{
|
|
{"9007199254740993", "9007199254740992"},
|
|
{"0.1", "0.10000000000000001"},
|
|
{"1000", "-1000"},
|
|
{"1e3", "1e4"},
|
|
}
|
|
for _, p := range diff {
|
|
if CanonicalJSONNumbers(json.Number(p[0])) == CanonicalJSONNumbers(json.Number(p[1])) {
|
|
t.Errorf("%s and %s are different values but canonicalise equal", p[0], p[1])
|
|
}
|
|
}
|
|
// A float64 from a plain decode meets its json.Number twin.
|
|
if CanonicalJSONNumbers(float64(1000)) != CanonicalJSONNumbers(json.Number("1e3")) {
|
|
t.Error("float64 1000 and json.Number 1e3 must canonicalise equal")
|
|
}
|
|
// The output is valid JSON.
|
|
b, err := json.Marshal(CanonicalJSONNumbers(map[string]any{"n": json.Number("1000.0")}))
|
|
if err != nil || !json.Valid(b) {
|
|
t.Errorf("canonical form does not marshal to valid JSON: %s %v", b, err)
|
|
}
|
|
}
|