Skip to content

Commit 5dceb7e

Browse files
dmealingclaude
andcommitted
test(conformance): Phase B Unit 6 — retire divergent per-port normalization unit tests
Wire-type normalization (uuid/float/double/jsonb/timestamp/timestamptz/date/ time) and projection reads (passthrough + sum/avg/min/max/count) are now gated end-to-end by the SHARED persistence-conformance round-trip corpus on all five ports. The per-port normalization UNIT tests had drifted in coverage (TS ~9 cases, Java/Kotlin 4, Python 3) and are now subsumed by those round-trips: - REAL/DOUBLE plain-decimal strings -> queries/measurement-floats.yaml - INTEGER->number / BIGINT->string / NUMERIC->no-trailing-zeros -> queries/projection-aggregates.yaml - uuid/jsonb/timestamp(tz)/date/time -> queries/normalization-wire-types.yaml Delete the subsumed happy-path cases from each port's unit test. KEEP the out-of-band float guard (canonicalFloat / normalize_value must throw on exponential-notation values) as a standalone unit test in every port that had one (TS / Java / Kotlin / Python) — a throwing value cannot be expressed as a successful round-trip seed. C# never had a separate normalization unit test (runner + helper only), so nothing changed there. Each port's Normalization HELPER module is left intact (the runner uses it). No shared fixtures changed. Document the new gating + retirement in spec/conformance-tests.md. Tests: TS 47 pass; Java QueryScenario 14 + ApiContract 20 + NormalizationFloat 2; Kotlin QueryScenario 14 + ApiContract 20 + NormalizationFloat 2; Python 35 pass (all via Testcontainers Postgres). Docker was available. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3a9226d commit 5dceb7e

5 files changed

Lines changed: 43 additions & 87 deletions

File tree

server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/NormalizationFloatTest.kt

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,18 @@
11
package com.metaobjects.integration.kotlin
22

33
import kotlin.test.Test
4-
import kotlin.test.assertEquals
54
import kotlin.test.assertFailsWith
65

76
/**
8-
* R6: REAL/DOUBLE wire normalization (mirror of the TS `normalization.test.ts` and Java
9-
* `NormalizationFloatTest` cases). [Normalization.normalizeValue] routes Float/Double through
10-
* the canonical-float stringifier: in-band values become plain-decimal strings (trailing zeros
11-
* + bare decimal point stripped); out-of-band (exponential) values fail loudly so a bad fixture
12-
* surfaces immediately instead of silently corrupting the cross-port byte-equality compare.
7+
* R6: out-of-band REAL/DOUBLE guard — the ONLY normalization case that cannot be a shared
8+
* round-trip scenario (you can't seed a value that must THROW). [Normalization.normalizeValue]
9+
* routes Float/Double through the canonical-float stringifier; out-of-band (exponential) values
10+
* fail loudly so a bad fixture surfaces immediately instead of silently corrupting the cross-port
11+
* byte-equality compare. The happy-path float/integer/bigint/numeric cases formerly here are now
12+
* gated end-to-end by the shared persistence-conformance round-trip corpus (REAL/DOUBLE →
13+
* queries/measurement-floats.yaml; INTEGER/BIGINT/NUMERIC → queries/projection-aggregates.yaml).
1314
*/
1415
class NormalizationFloatTest {
15-
@Test fun `in-band float is plain decimal string`() {
16-
assertEquals("1.5", Normalization.normalizeValue(1.5f)) // REAL (float4)
17-
assertEquals("0.25", Normalization.normalizeValue(0.25)) // DOUBLE (float8)
18-
}
19-
@Test fun `integer-valued double strips trailing zero and point`() {
20-
assertEquals("2", Normalization.normalizeValue(2.0)) // "2.0" -> "2." -> "2"
21-
}
2216
@Test fun `out-of-band double throws`() {
2317
assertFailsWith<IllegalArgumentException> { Normalization.normalizeValue(1.0e-10) } // "1.0E-10"
2418
}

server/java/integration-tests/src/test/java/com/metaobjects/integration/NormalizationFloatTest.java

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,18 @@
22

33
import org.junit.jupiter.api.Test;
44

5-
import static org.junit.jupiter.api.Assertions.assertEquals;
65
import static org.junit.jupiter.api.Assertions.assertThrows;
76

87
/**
9-
* R6: REAL/DOUBLE wire normalization (mirror of the TS {@code normalization.test.ts} cases).
10-
* {@link Normalization#normalizeValue} routes Float/Double through the canonical-float
11-
* stringifier: in-band values become plain-decimal strings (trailing zeros + bare decimal
12-
* point stripped); out-of-band (exponential) values fail loudly so a bad fixture surfaces
13-
* immediately instead of silently corrupting the cross-port byte-equality compare.
8+
* R6: out-of-band REAL/DOUBLE guard — the ONLY normalization case that cannot be a shared
9+
* round-trip scenario (you can't seed a value that must THROW). {@link Normalization#normalizeValue}
10+
* routes Float/Double through the canonical-float stringifier; out-of-band (exponential) values
11+
* fail loudly so a bad fixture surfaces immediately instead of silently corrupting the cross-port
12+
* byte-equality compare. The happy-path float/integer/bigint/numeric cases formerly here are now
13+
* gated end-to-end by the shared persistence-conformance round-trip corpus (REAL/DOUBLE →
14+
* queries/measurement-floats.yaml; INTEGER/BIGINT/NUMERIC → queries/projection-aggregates.yaml).
1415
*/
1516
class NormalizationFloatTest {
16-
@Test void inBandFloat_isPlainDecimalString() {
17-
assertEquals("1.5", Normalization.normalizeValue(1.5f)); // REAL (float4)
18-
assertEquals("0.25", Normalization.normalizeValue(0.25d)); // DOUBLE (float8)
19-
}
20-
@Test void integerValuedDouble_stripsTrailingZeroAndPoint() {
21-
assertEquals("2", Normalization.normalizeValue(2.0d)); // "2.0" -> "2." -> "2"
22-
}
2317
@Test void outOfBandDouble_throws() {
2418
assertThrows(IllegalArgumentException.class,
2519
() -> Normalization.normalizeValue(1.0e-10d)); // Double.toString -> "1.0E-10"

server/python/tests/integration/test_normalization.py

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1-
"""R6: REAL/DOUBLE wire-normalization unit tests.
2-
3-
Parity with the other ports' standalone float-normalization tests
4-
(``NormalizationFloatTest.java`` / ``NormalizationFloatTest.kt`` /
5-
``normalization.test.ts``). ``_canonical_float`` renders in-band dyadic values as
6-
plain-decimal strings (trailing zeros + a bare decimal point stripped) and raises on
7-
out-of-band (exponential) values so a bad fixture surfaces loudly instead of silently
8-
corrupting the cross-port byte-equality compare. These run without a database.
1+
"""R6: out-of-band REAL/DOUBLE guard.
2+
3+
The ONLY normalization case that cannot be a shared round-trip scenario (you can't
4+
seed a value that must RAISE). ``_canonical_float`` raises on out-of-band (exponential)
5+
values so a bad fixture surfaces loudly instead of silently corrupting the cross-port
6+
byte-equality compare. Runs without a database.
7+
8+
The happy-path float/integer/bigint/numeric normalization cases formerly here are now
9+
gated end-to-end by the shared persistence-conformance round-trip corpus:
10+
REAL/DOUBLE → ``queries/measurement-floats.yaml``; INTEGER/BIGINT/NUMERIC →
11+
``queries/projection-aggregates.yaml``. See
12+
``fixtures/persistence-conformance/normalization.md``.
913
"""
1014

1115
from __future__ import annotations
@@ -15,18 +19,6 @@
1519
from .normalization import _canonical_float, normalize_value
1620

1721

18-
def test_in_band_float_is_plain_decimal_string():
19-
assert _canonical_float(1.5) == "1.5" # REAL (float4)
20-
assert _canonical_float(0.25) == "0.25" # DOUBLE (float8)
21-
# and via the public route floats take through _canonical_float
22-
assert normalize_value(1.5) == "1.5"
23-
24-
25-
def test_integer_valued_float_strips_trailing_zero_and_point():
26-
assert _canonical_float(2.0) == "2" # repr "2.0" -> "2." -> "2"
27-
assert normalize_value(2.0) == "2"
28-
29-
3022
def test_out_of_band_float_raises():
3123
# repr(1e-10) == "1e-10" -> exponential -> loud failure, not silent corruption.
3224
with pytest.raises(ValueError):

server/typescript/packages/integration-tests/test/normalization.test.ts

Lines changed: 10 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,17 @@
1-
// Unit tests for normalization helpers — no DB required.
1+
// Out-of-band float guard — the ONLY normalization case that cannot be a shared
2+
// round-trip scenario (you can't seed a value that must THROW). The happy-path
3+
// float/integer/bigint normalization cases formerly here are now gated end-to-end
4+
// by the shared persistence-conformance round-trip corpus:
5+
// - REAL/DOUBLE plain-decimal strings → queries/measurement-floats.yaml
6+
// - INTEGER stays a JSON number → queries/projection-aggregates.yaml (min/max)
7+
// - BIGINT → string → queries/projection-aggregates.yaml (weekCount/totalMinutes)
8+
// - NUMERIC no-trailing-zeros string → queries/projection-aggregates.yaml (avgMinutes)
9+
// See fixtures/persistence-conformance/normalization.md.
210

311
import { describe, test, expect } from "bun:test";
412
import { normalizeValue, canonicalFloat } from "../src/normalization.ts";
513

6-
describe("normalizeValue — floats (REAL/DOUBLE)", () => {
7-
test("1.5 → '1.5'", () => {
8-
expect(normalizeValue(1.5)).toBe("1.5");
9-
});
10-
11-
test("0.125 → '0.125'", () => {
12-
expect(normalizeValue(0.125)).toBe("0.125");
13-
});
14-
15-
test("-3.25 → '-3.25'", () => {
16-
expect(normalizeValue(-3.25)).toBe("-3.25");
17-
});
18-
19-
test("1234.5 → '1234.5'", () => {
20-
expect(normalizeValue(1234.5)).toBe("1234.5");
21-
});
22-
23-
test("trailing zeros are stripped: 1.500 → '1.5'", () => {
24-
// JS String() already produces '1.5' for 1.5, but canonicalFloat handles the strip
25-
expect(canonicalFloat(1.5)).toBe("1.5");
26-
});
27-
});
28-
29-
describe("normalizeValue — integers", () => {
30-
test("100 stays as number 100", () => {
31-
expect(normalizeValue(100)).toBe(100);
32-
});
33-
34-
test("0 stays as number 0", () => {
35-
expect(normalizeValue(0)).toBe(0);
36-
});
37-
});
38-
39-
describe("normalizeValue — bigint", () => {
40-
test("123n → '123' (string)", () => {
41-
expect(normalizeValue(123n)).toBe("123");
42-
});
43-
});
44-
45-
describe("canonicalFloat — out-of-band guard", () => {
14+
describe("canonicalFloat — out-of-band guard (not round-trippable)", () => {
4615
test("1.5e-10 throws (exponential notation)", () => {
4716
expect(() => canonicalFloat(1.5e-10)).toThrow(
4817
"canonicalFloat: 1.5e-10 is outside the plain-decimal band (exponential notation)",

spec/conformance-tests.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,13 @@ that ledger is loader-corpus only.
204204
Two further corpora cover the **runtime** tier and are exercised by **all five ports, including Kotlin**, on demand against Testcontainers Postgres via `scripts/integration-test.sh` (these are **not** in the default `test` path for the JVM/.NET ports — see the hardening review):
205205

206206
- **`fixtures/persistence-conformance/`** — CRUD / filter / projection round-trips. The **query** scenarios run on every port; each port provisions its test DB by **executing the committed, TS-produced `canonical/schema.postgres.sql`** (no port synthesizes schema) and then exercises its runtime data-access layer. Per ADR-0015 schema migrations are TS-owned, so the **migration** scenarios are run by **TS only**, and the cross-port query corpus is **Postgres-only** (Derby was dropped for it).
207+
208+
**Wire-type normalization + projection reads are gated by these shared round-trips on all five ports** (see `fixtures/persistence-conformance/normalization.md` for the per-type canonical rules):
209+
- **REAL/DOUBLE** → plain-decimal strings, no trailing zeros, no exponential notation (`queries/measurement-floats.yaml`).
210+
- **uuid** (lowercase canonicalization), **jsonb** (key-sorting), **TIMESTAMPTZ** (seeded with a non-UTC offset, read back as UTC `Z` — proving normalization, not mere formatting), plain **TIMESTAMP** (no `Z`), **DATE**, **TIME** (`queries/normalization-wire-types.yaml`, with companion coverage in `queries/asset-uuid-roundtrip.yaml`).
211+
- **projection reads** — passthrough-view read (`queries/projection-passthrough.yaml`) and `sum`/`avg`/`min`/`max`/`count` aggregates over a VIEW, including the aggregate-over-zero-rows NULL semantics (`queries/projection-aggregates.yaml`, `queries/projection-aggregate.yaml`). These also pin INTEGER→number, BIGINT→string, and NUMERIC→no-trailing-zeros-string.
212+
213+
Because this end-to-end round-trip enforces the canonical wire shape identically across ports, the **divergent per-port normalization _unit_ tests were retired** (they had drifted in coverage — TS ~9 cases, Java/Kotlin 4, Python 3 — and are now subsumed). Each port keeps its `Normalization` **helper module** (the runner depends on it). The **only** unit assertion kept per port is the out-of-band float guard (`canonicalFloat`/`normalize_value` must **throw/raise** on exponential-notation values) — a value that must throw cannot be expressed as a successful round-trip seed, so it stays a standalone unit test (TS `normalization.test.ts`, Java/Kotlin `NormalizationFloatTest`, Python `test_normalization.py`). C# never had a separate normalization unit test (only the runner + helper), so nothing was removed there.
207214
- **`fixtures/api-contract-conformance/`** — emitted CRUD routes answered over real HTTP (URL grammar + wire format).
208215

209216
A `fixtures/render-conformance/template-generator/` sub-corpus additionally exercises the codegen *walk* (file-per-entity inventory + render) in TS, C#, Java, and Python.

0 commit comments

Comments
 (0)