mirror of
https://github.com/morluto/rea.git
synced 2026-11-03 05:16:16 +00:00
* feat(environment): explain values an application derives from other fields
Prompt-built environments withheld creates whose responses carry values the
application derives deterministically, which the write model kept as
constants or could not match across calls:
- A composed string is literal text plus parts read from the fields other
rules write: a field's value, its slug, or a clock's calendar digits. A
create composes a string only when another entity of the type spells it
the same way and none spells it otherwise, and never from two sources
serving could tell apart; an update composes an unsent change from members
stored as sent and unchanged fields that also spell its value before the
call. A composed field no longer pins the members it reads.
- A trimmed clock format drops trailing fraction zeros, as Go spells
nanoseconds. It is learned from the type's values, a later create or a
later update, and spreads to the plan's other clocks unless a value shows
a fixed width.
- A large integer no entity of the type holds, among distinct ones of the
same length and kind, is a generated unique value, served as a seeded
integer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(environment): record the derived-values verification
Gates, tests, write plans over 69 recorded captures and served write tasks on the baseline and on this branch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): keep clock spellings, linked ids and word boundaries honest
- A later update's clocks take the plan's spelling, sibling-trimmed
clocks included, wherever the call's values fit it, so an update whose
later call trims only one of two clocks is served.
- A held value that ends its fraction in a zero keeps a create's clock
at a fixed width, whatever order the held spellings come in.
- A large integer that another field of the created entity also holds
is linked, as a repeated token is: it is never unique, and the create
is withheld as unexplained-change.
- Composed parts are delimited by whole characters through the shared
joinsBefore/joinsAfter, so an astral letter joins a match to its word.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(environment): keep a same-width fixed clock out of trimming
A held .123456780 beside a held .12345678 keeps the create's clock at
nine digits in either order; the cases fail on 8ce1127b5.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): check composed parts and clock fits before they shape a plan
- A composed string whose part has nothing to spell refuses the create
or update through the apply Result instead of keeping a stale value.
- A composition learned on a later call is rebuilt from each earlier
call's fields, so an earlier call that left the value otherwise
contradicts it.
- An update adopts its fitted clock spellings only after the
unknown-entity and contradiction checks, so a call on an entity an
unmodelled write left unknown no longer trims a served plan's clock.
- Sibling trimming on a later create also checks that call's fields,
so a sibling clock it shows with a trailing zero stays fixed-width.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(environment): split the derived-values view tests by rule
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): keep fixed-width, zone and generated-integer evidence through a plan
- A plan keeps the first value that showed each clock at a fixed width,
from held entities and every accepted create or update. No later fit
or sibling trim widens that field, and a later call that shows it
shorter is withheld.
- Two stamped clocks are one composed source only when they share a
zone offset. A held entity that holds the target field as anything
the parts do not spell, a non-string included, vetoes the
composition. The token index is built once, and only when a string is
left to compose.
- A generated integer that another field repeats as a number at any
depth is linked. Compare treats two integers of one JSON kind and
length under a unique rule as generated values.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(setup): share registration prewarm host preparation
* fix(environment): retain write evidence and match trimmed response clocks
* fix(environment): withhold creates whose generated integer another string repeats
A unique integer whose digits show in another top-level string that no
template or composed rule rebuilds from it (an echoed serialText, or a
token like folder-<id>) withholds the create as unexplained-change,
since serving would seed the integer and keep the string. A templated
copy is still served.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): hold later creates and compare to the composed and integer forms
- A later create whose response spells a composed field otherwise than
the plan rebuilds it is withheld as unexplained-change.
- A unique field learned from a generated integer accepts only an
integer of the same length and kind from a later create, as serving
seeds one.
- Compare counts a composed field as equal when each side's response
spells it from its own fields, which compare under their own rules.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): preserve composed parts and unique generator families
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
1026 lines
29 KiB
TypeScript
1026 lines
29 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import type { JsonValue } from "../../domain/jsonValue.js";
|
|
import type { FieldRule } from "../../domain/analysisView/environmentStateWriteRules.js";
|
|
import { compareAnswers } from "./environmentCompareDiff.js";
|
|
import { IdMap, learnIds } from "./environmentCompareIds.js";
|
|
import { bookmark } from "../../../tests/fixtures/environmentStateDerivedValues.js";
|
|
|
|
const compare = (
|
|
live: unknown,
|
|
env: unknown,
|
|
map = new IdMap(),
|
|
status: [number, number] = [200, 200],
|
|
rules: readonly FieldRule[] = [],
|
|
) =>
|
|
compareAnswers(
|
|
{ status: status[0], body: { kind: "json", value: live as JsonValue } },
|
|
{ status: status[1], body: { kind: "json", value: env as JsonValue } },
|
|
map,
|
|
{ rules },
|
|
);
|
|
|
|
describe("early-year clock formats", () => {
|
|
it.each(["0000", "0001", "0099", "0100"])(
|
|
"classifies a year-%s clock spelling difference by format",
|
|
(year) => {
|
|
const result = compare(
|
|
{ created_at: `${year}-01-01T00:00:00Z` },
|
|
{ created_at: `${year}-01-01 00:00:00.000Z` },
|
|
);
|
|
expect(
|
|
result.differences.map(({ category, pointer }) => [category, pointer]),
|
|
).toEqual([["clock-format", "/created_at"]]);
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("composed response values", () => {
|
|
const rules: readonly FieldRule[] = [
|
|
{
|
|
field: "date_added",
|
|
rule: "clock",
|
|
format: { kind: "iso", separator: "T", fraction: 0, zone: "Z" },
|
|
},
|
|
{
|
|
field: "date_modified",
|
|
rule: "clock",
|
|
format: { kind: "iso", separator: "T", fraction: 0, zone: "Z" },
|
|
},
|
|
{
|
|
field: "web_archive_snapshot_url",
|
|
rule: "composed",
|
|
parts: [
|
|
{ text: "https://web.archive.org/web/" },
|
|
{ from: "date_added", as: "clock", pattern: "YYYYMMDDhhmmss" },
|
|
{ text: "/" },
|
|
{ from: "url", as: "value" },
|
|
],
|
|
},
|
|
];
|
|
const live = bookmark(
|
|
1,
|
|
"https://example.test/page",
|
|
"A bookmark",
|
|
"2026-10-20T09:00:00Z",
|
|
);
|
|
const env = bookmark(
|
|
1,
|
|
"https://example.test/page",
|
|
"A bookmark",
|
|
"2026-10-17T18:00:00Z",
|
|
);
|
|
|
|
it("compares coherent archive URLs through their clock parts without counting source fields twice", () => {
|
|
const result = compare(live, env, new IdMap(), [201, 201], rules);
|
|
expect(result.differences).toEqual([]);
|
|
expect(result.normalised).toMatchObject({ clocks: 2, generated: 1 });
|
|
});
|
|
|
|
it("compares a composed ID through the source field's mapping even when the address comes first", () => {
|
|
const map = new IdMap();
|
|
map.learn(101, 1);
|
|
const result = compare(
|
|
{ url: "/folders/101", id: 101 },
|
|
{ url: "/folders/1", id: 1 },
|
|
map,
|
|
[201, 201],
|
|
[
|
|
{
|
|
field: "url",
|
|
rule: "composed",
|
|
parts: [{ text: "/folders/" }, { from: "id", as: "value" }],
|
|
},
|
|
],
|
|
);
|
|
expect(result.differences).toEqual([]);
|
|
expect(result.normalised).toMatchObject({ ids: 1, generated: 1 });
|
|
});
|
|
|
|
it.each(["live", "env", "both"] as const)(
|
|
"reports an incoherent %s composition, including equal but stale URLs",
|
|
(side) => {
|
|
const invalid = "https://web.archive.org/web/stale";
|
|
const result = compare(
|
|
side === "env" ? live : { ...live, web_archive_snapshot_url: invalid },
|
|
side === "live" ? env : { ...env, web_archive_snapshot_url: invalid },
|
|
new IdMap(),
|
|
[201, 201],
|
|
rules,
|
|
);
|
|
expect(result.differences).toMatchObject([
|
|
{ category: "value", pointer: "/web_archive_snapshot_url" },
|
|
]);
|
|
expect(result.normalised.generated).toBe(0);
|
|
},
|
|
);
|
|
|
|
it("preserves actual input differences although each address correctly spells its own response", () => {
|
|
const different = bookmark(
|
|
1,
|
|
"https://example.test/other",
|
|
"A bookmark",
|
|
"2026-10-17T18:00:00Z",
|
|
);
|
|
const result = compare(live, different, new IdMap(), [201, 201], rules);
|
|
expect(result.differences.map(({ pointer }) => pointer).sort()).toEqual([
|
|
"/url",
|
|
"/web_archive_snapshot_url",
|
|
]);
|
|
expect(result.normalised.generated).toBe(0);
|
|
});
|
|
|
|
it("does not normalize a source's unproven generated-looking business value", () => {
|
|
const result = compare(
|
|
{ url: "/folders/token00001", token: "token00001" },
|
|
{ url: "/folders/token00002", token: "token00002" },
|
|
new IdMap(),
|
|
[201, 201],
|
|
[
|
|
{
|
|
field: "url",
|
|
rule: "composed",
|
|
parts: [{ text: "/folders/" }, { from: "token", as: "value" }],
|
|
},
|
|
],
|
|
);
|
|
expect(result.differences.map(({ pointer }) => pointer).sort()).toEqual([
|
|
"/token",
|
|
"/url",
|
|
]);
|
|
});
|
|
|
|
it("compares the projected slug while retaining a difference in its source title", () => {
|
|
const result = compare(
|
|
{
|
|
title: "Hello!",
|
|
date_added: "2026-10-20T09:00:00Z",
|
|
url: "/snapshot/20261020/hello",
|
|
},
|
|
{
|
|
title: "Hello",
|
|
date_added: "2026-10-17T18:00:00Z",
|
|
url: "/snapshot/20261017/hello",
|
|
},
|
|
new IdMap(),
|
|
[201, 201],
|
|
[
|
|
rules[0]!,
|
|
{
|
|
field: "url",
|
|
rule: "composed",
|
|
parts: [
|
|
{ text: "/snapshot/" },
|
|
{ from: "date_added", as: "clock", pattern: "YYYYMMDD" },
|
|
{ text: "/" },
|
|
{ from: "title", as: "slug" },
|
|
],
|
|
},
|
|
],
|
|
);
|
|
expect(result.differences).toMatchObject([
|
|
{ category: "value", pointer: "/title" },
|
|
]);
|
|
expect(result.normalised).toMatchObject({ clocks: 1, generated: 1 });
|
|
});
|
|
});
|
|
|
|
describe("trimmed response clocks", () => {
|
|
const rules: readonly FieldRule[] = [
|
|
{
|
|
field: "updatedAt",
|
|
rule: "clock",
|
|
format: {
|
|
kind: "iso",
|
|
separator: "T",
|
|
zone: "Z",
|
|
fraction: 8,
|
|
trimmed: true,
|
|
},
|
|
},
|
|
];
|
|
|
|
it.each([".12345678", ".123", ""])(
|
|
"normalizes a learned trimmed width %s against the served millisecond width",
|
|
(fraction) => {
|
|
const result = compare(
|
|
{ updatedAt: `2026-03-04T05:06:07${fraction}Z` },
|
|
{ updatedAt: "2026-03-04T05:06:08.321Z" },
|
|
new IdMap(),
|
|
[200, 200],
|
|
rules,
|
|
);
|
|
expect(result.differences).toEqual([]);
|
|
expect(result.normalised.clocks).toBe(1);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["2026-03-04T05:06:07.123456789Z", "2026-03-04T05:06:08.321Z"],
|
|
["2026-03-04T05:06:07.123456780Z", "2026-03-04T05:06:08.321Z"],
|
|
["2026-03-04T05:06:07.120Z", "2026-03-04T05:06:08.320Z"],
|
|
["2026-03-04T05:06:07.123+01:00", "2026-03-04T05:06:08.321Z"],
|
|
["2026-03-04 05:06:07.123Z", "2026-03-04T05:06:08.321Z"],
|
|
])(
|
|
"reports a pair outside the learned trimmed format (%s, %s)",
|
|
(live, env) => {
|
|
const result = compare(
|
|
{ updatedAt: live },
|
|
{ updatedAt: env },
|
|
new IdMap(),
|
|
[200, 200],
|
|
rules,
|
|
);
|
|
expect(result.differences).toMatchObject([
|
|
{ category: "clock-format", pointer: "/updatedAt" },
|
|
]);
|
|
expect(result.normalised.clocks).toBe(0);
|
|
},
|
|
);
|
|
|
|
it("keeps trimmed normalization within the confirmed response entity", () => {
|
|
const answer = (fraction: string) => ({
|
|
status: 200,
|
|
body: {
|
|
kind: "json" as const,
|
|
value: {
|
|
data: { updatedAt: `2026-03-04T05:06:07.${fraction}Z` },
|
|
updatedAt: `2026-03-04T05:06:07.${fraction}Z`,
|
|
},
|
|
},
|
|
});
|
|
const result = compareAnswers(
|
|
answer("12345678"),
|
|
answer("123"),
|
|
new IdMap(),
|
|
{ at: "/data", rules },
|
|
);
|
|
expect(result.differences).toMatchObject([
|
|
{ category: "clock-format", pointer: "/updatedAt" },
|
|
]);
|
|
expect(result.normalised.clocks).toBe(1);
|
|
});
|
|
});
|
|
|
|
const registerGeneratedValueComparisonCases = () => {
|
|
it.each([
|
|
{
|
|
name: "normalizes mapped ids and clocks while comparing unproven generated-looking values literally",
|
|
rules: [],
|
|
generated: 0,
|
|
differences: [
|
|
["value", "/token"],
|
|
["value", "/uuid"],
|
|
],
|
|
},
|
|
{
|
|
name: "agrees on mapped ids, clocks and confirmed generated values, and counts each",
|
|
rules: [
|
|
{ field: "token", rule: "identity", generator: "token" },
|
|
{ field: "uuid", rule: "unique" },
|
|
],
|
|
generated: 2,
|
|
differences: [],
|
|
},
|
|
] as const)("$name", ({ rules, generated, differences }) => {
|
|
const map = new IdMap();
|
|
map.learn(101, 1);
|
|
const result = compare(
|
|
{
|
|
id: 101,
|
|
created_at: "2026-10-20T10:00:00Z",
|
|
token: "3f2a9c8d7e",
|
|
uuid: "6f1c2b9e-2a3d-4f5e-8a9b-0c1d2e3f4a5b",
|
|
},
|
|
{
|
|
id: 1,
|
|
created_at: "2026-10-17T18:00:03Z",
|
|
token: "9b8e7d6c5a",
|
|
uuid: "0a1b2c3d-4e5f-4a6b-9c7d-8e9f0a1b2c3d",
|
|
},
|
|
map,
|
|
[200, 200],
|
|
rules,
|
|
);
|
|
expect(
|
|
result.differences.map(({ category, pointer }) => [category, pointer]),
|
|
).toEqual(differences);
|
|
expect(result.normalised).toEqual({
|
|
ids: 1,
|
|
clocks: 1,
|
|
generated,
|
|
large_bodies: 0,
|
|
unobserved_bodies: 0,
|
|
});
|
|
});
|
|
|
|
it("normalizes string generation only at fields identified by an executed write plan", () => {
|
|
const result = compareAnswers(
|
|
{
|
|
status: 201,
|
|
body: { kind: "json", value: { token: "token00001", sku: "product2" } },
|
|
},
|
|
{
|
|
status: 201,
|
|
body: { kind: "json", value: { token: "token00002", sku: "product1" } },
|
|
},
|
|
new IdMap(),
|
|
{ rules: [{ field: "token", rule: "identity", generator: "token" }] },
|
|
);
|
|
expect(result.differences).toMatchObject([
|
|
{ category: "value", pointer: "/sku" },
|
|
]);
|
|
expect(result.normalised.generated).toBe(1);
|
|
});
|
|
};
|
|
|
|
describe("composed response fields", () => {
|
|
const rules: readonly FieldRule[] = [
|
|
{
|
|
field: "date_added",
|
|
rule: "clock",
|
|
format: { kind: "iso", separator: "T", fraction: 0, zone: "Z" },
|
|
},
|
|
{ field: "url", rule: "echo" },
|
|
{
|
|
field: "web_archive_snapshot_url",
|
|
rule: "composed",
|
|
parts: [
|
|
{ text: "https://web.archive.org/web/" },
|
|
{ from: "date_added", as: "clock", pattern: "YYYYMMDDhhmmss" },
|
|
{ text: "/" },
|
|
{ from: "url", as: "value" },
|
|
],
|
|
},
|
|
];
|
|
const bookmark = (stamp: string, snapshot: string) => ({
|
|
date_added: `2026-10-01T09:00:${stamp}Z`,
|
|
url: "https://quarry.example.test/ledger",
|
|
web_archive_snapshot_url: `https://web.archive.org/web/${snapshot}/https://quarry.example.test/ledger`,
|
|
});
|
|
|
|
it("normalizes a composed field each side spells from its own fields", () => {
|
|
const result = compare(
|
|
bookmark("00", "20261001090000"),
|
|
bookmark("05", "20261001090005"),
|
|
new IdMap(),
|
|
[200, 200],
|
|
rules,
|
|
);
|
|
expect(result.differences).toEqual([]);
|
|
expect(result.normalised).toMatchObject({ clocks: 1, generated: 1 });
|
|
});
|
|
|
|
it("reports a composed field one side does not spell from its own fields", () => {
|
|
const result = compare(
|
|
bookmark("00", "20261001090000"),
|
|
bookmark("05", "20261001090009"),
|
|
new IdMap(),
|
|
[200, 200],
|
|
rules,
|
|
);
|
|
expect(
|
|
result.differences.map(({ category, pointer }) => [category, pointer]),
|
|
).toEqual([["value", "/web_archive_snapshot_url"]]);
|
|
});
|
|
});
|
|
|
|
describe("compareAnswers", () => {
|
|
registerGeneratedValueComparisonCases();
|
|
|
|
it("categorises status, missing and extra fields, values and clock formats", () => {
|
|
const result = compare(
|
|
{ name: "A", gone: 1, at: "2026-10-20T10:00:00.123+00:00" },
|
|
{ name: "B", added: true, at: "2026-10-20T10:00:00Z" },
|
|
new IdMap(),
|
|
[409, 204],
|
|
);
|
|
expect(result.differences.map((d) => [d.category, d.pointer])).toEqual([
|
|
["status", ""],
|
|
["value", "/name"],
|
|
["missing-field", "/gone"],
|
|
["clock-format", "/at"],
|
|
["extra-field", "/added"],
|
|
]);
|
|
});
|
|
|
|
it("reports array length and order and compares paired items", () => {
|
|
const result = compare(
|
|
[
|
|
{ name: "B", n: 2 },
|
|
{ name: "A", n: 1 },
|
|
{ name: "C", n: 3 },
|
|
],
|
|
[
|
|
{ name: "A", n: 1 },
|
|
{ name: "B", n: 5 },
|
|
],
|
|
);
|
|
expect(result.differences.map((d) => [d.category, d.pointer])).toEqual([
|
|
["length", ""],
|
|
["order", ""],
|
|
["value", "/0/n"],
|
|
]);
|
|
});
|
|
|
|
it("treats an epoch number as a clock only under a time-named field", () => {
|
|
expect(
|
|
compare({ updated: 1760000000 }, { updated: 1760000999 }).differences,
|
|
).toEqual([]);
|
|
expect(
|
|
compare({ size: 1760000000 }, { size: 1760000999 }).differences.map(
|
|
(d) => d.category,
|
|
),
|
|
).toEqual(["value"]);
|
|
});
|
|
});
|
|
|
|
describe("mapped fields and literal collections", () => {
|
|
it("compares an object keyed by mapped ids under the environment's keys", () => {
|
|
const map = new IdMap();
|
|
map.learn("liveTable", "envTable");
|
|
expect(
|
|
compare(
|
|
{ tables: { liveTable: { name: "T" } } },
|
|
{ tables: { envTable: { name: "T" } } },
|
|
map,
|
|
).differences,
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("compares ordinary fields by name, even one named like a learned id", () => {
|
|
const map = new IdMap();
|
|
map.learn("books", "tags");
|
|
expect(
|
|
compare({ books: 1, tags: 2 }, { books: 1, tags: 2 }, map).differences,
|
|
).toEqual([]);
|
|
// A real rename shows, rather than being taken for a mapped key.
|
|
expect(
|
|
compare({ books: 1 }, { tags: 1 }, map).differences.map((d) => [
|
|
d.category,
|
|
d.pointer,
|
|
]),
|
|
).toEqual([
|
|
["missing-field", "/books"],
|
|
["extra-field", "/tags"],
|
|
]);
|
|
});
|
|
|
|
it("normalizes known string references but leaves ordinary numeric and string values literal", () => {
|
|
const map = new IdMap();
|
|
map.learn(101, 1);
|
|
learnIds({ ref: "live-ref" }, { ref: "env-ref" }, map, {
|
|
heldIds: new Set(["env-ref"]),
|
|
});
|
|
expect(
|
|
compare(
|
|
{ ref: "live-ref", price: 101, label: "live-ref" },
|
|
{ ref: "env-ref", price: 1, label: "env-ref" },
|
|
map,
|
|
).differences.map((difference) => difference.pointer),
|
|
).toEqual(["/price", "/label"]);
|
|
map.learn("live-key", "env-key");
|
|
expect(
|
|
compare({ ids: ["live-key"] }, { ids: ["env-key"] }, map).differences,
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("still compares an object keyed by ids under the environment's keys when some ids are the same on both sides", () => {
|
|
const map = new IdMap();
|
|
map.learn("101", "2");
|
|
expect(
|
|
compare(
|
|
{ counts: { "1": 3, "101": 5 } },
|
|
{ counts: { "1": 3, "2": 5 } },
|
|
map,
|
|
).differences,
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("reports a reordered set of scalars as order alone and keeps nested numeric measurements literal", () => {
|
|
expect(
|
|
compare(
|
|
{ access: ["a", "b", "c"] },
|
|
{ access: ["c", "a", "b"] },
|
|
).differences.map((d) => [d.category, d.pointer]),
|
|
).toEqual([["order", "/access"]]);
|
|
expect(
|
|
compare({ stats: [[1792450800, 3]] }, { stats: [[1792285200, 3]] })
|
|
.differences,
|
|
).toEqual([
|
|
{
|
|
category: "value",
|
|
pointer: "/stats/0/0",
|
|
live: "1792450800",
|
|
env: "1792285200",
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("normalization context", () => {
|
|
it.each([
|
|
{ live: 1800000000, env: 1700000000, pointer: "" },
|
|
{ live: [1800000000], env: [1700000000], pointer: "/0" },
|
|
{
|
|
live: { samples: [1800000000] },
|
|
env: { samples: [1700000000] },
|
|
pointer: "/samples/0",
|
|
},
|
|
{
|
|
live: { updated_at: [{ samples: 1800000000 }] },
|
|
env: { updated_at: [{ samples: 1700000000 }] },
|
|
pointer: "/updated_at/0/samples",
|
|
},
|
|
])(
|
|
"keeps epoch-shaped numbers literal at $pointer without a time field",
|
|
({ live, env, pointer }) => {
|
|
const result = compare(live, env);
|
|
expect(result.differences).toEqual([
|
|
{ category: "value", pointer, live: "1800000000", env: "1700000000" },
|
|
]);
|
|
expect(result.normalised.clocks).toBe(0);
|
|
},
|
|
);
|
|
|
|
it("preserves the time field through nested numeric clock arrays", () => {
|
|
const result = compare(
|
|
{ updated_at: [[1700000000]] },
|
|
{ updated_at: [[1800000000]] },
|
|
);
|
|
expect(result.differences).toEqual([]);
|
|
expect(result.normalised.clocks).toBe(1);
|
|
});
|
|
|
|
it.each([
|
|
["product1", "product2"],
|
|
["product1", "productX"],
|
|
["3f2a9c8d7e", "9b8e7d6c5a"],
|
|
[
|
|
"6f1c2b9e-2a3d-4f5e-8a9b-0c1d2e3f4a5b",
|
|
"0a1b2c3d-4e5f-4a6b-9c7d-8e9f0a1b2c3d",
|
|
],
|
|
])(
|
|
"keeps literal business strings %s versus %s without generation facts",
|
|
(live, env) => {
|
|
const result = compare(
|
|
{ sku: live, items: [live] },
|
|
{ sku: env, items: [env] },
|
|
);
|
|
expect(
|
|
result.differences.map(({ category, pointer }) => [category, pointer]),
|
|
).toEqual([
|
|
["value", "/sku"],
|
|
["value", "/items/0"],
|
|
]);
|
|
expect(result.normalised.generated).toBe(0);
|
|
},
|
|
);
|
|
|
|
it("scopes confirmed renewal rules to their response entity and exact array pointers", () => {
|
|
const first = "6f1c2b9e-2a3d-4f5e-8a9b-0c1d2e3f4a5b";
|
|
const second = "0a1b2c3d-4e5f-4a6b-9c7d-8e9f0a1b2c3d";
|
|
const answer = (token: string) => ({
|
|
status: 200,
|
|
body: {
|
|
kind: "json" as const,
|
|
value: {
|
|
data: { values: [{ token, sku: token }] },
|
|
values: [{ token }],
|
|
},
|
|
},
|
|
});
|
|
const result = compareAnswers(answer(first), answer(second), new IdMap(), {
|
|
at: "/data",
|
|
rules: [{ field: "values", rule: "renewed", pointers: ["/*/token"] }],
|
|
});
|
|
expect(result.normalised.generated).toBe(1);
|
|
expect(
|
|
result.differences.map(({ category, pointer }) => [category, pointer]),
|
|
).toEqual([
|
|
["value", "/data/values/0/sku"],
|
|
["value", "/values/0/token"],
|
|
]);
|
|
});
|
|
|
|
it("does not extend generated-string normalization to numeric sequences", () => {
|
|
const result = compare(
|
|
{ code: 17 },
|
|
{ code: 18 },
|
|
new IdMap(),
|
|
[200, 200],
|
|
[{ field: "code", rule: "sequence", scope: "type" }],
|
|
);
|
|
expect(result.differences).toEqual([
|
|
{ category: "value", pointer: "/code", live: "17", env: "18" },
|
|
]);
|
|
expect(result.normalised.generated).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("generated response identities", () => {
|
|
const first = "6f1c2b9e-2a3d-4f5e-8a9b-0c1d2e3f4a5b";
|
|
const second = "0a1b2c3d-4e5f-4a6b-9c7d-8e9f0a1b2c3d";
|
|
const fresh = "123e4567-e89b-42d3-a456-426614174000";
|
|
const other = "223e4567-e89b-42d3-a456-426614174000";
|
|
const rules: readonly FieldRule[] = [
|
|
{ field: "left", rule: "renewed", pointers: ["/*"] },
|
|
{ field: "right", rule: "renewed", pointers: ["/*"] },
|
|
];
|
|
const answer = (left: string, right: string) => ({
|
|
status: 200,
|
|
body: { kind: "json" as const, value: { left: [left], right: [right] } },
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "consistent fresh renaming",
|
|
liveLeft: fresh,
|
|
liveRight: fresh,
|
|
envLeft: first,
|
|
envRight: first,
|
|
differs: false,
|
|
generated: 2,
|
|
},
|
|
{
|
|
name: "case-equivalent UUID aliases",
|
|
liveLeft: fresh.toUpperCase(),
|
|
liveRight: fresh,
|
|
envLeft: first,
|
|
envRight: first.toUpperCase(),
|
|
differs: false,
|
|
generated: 2,
|
|
},
|
|
{
|
|
name: "split alias",
|
|
liveLeft: fresh,
|
|
liveRight: other,
|
|
envLeft: first,
|
|
envRight: first,
|
|
differs: true,
|
|
generated: 1,
|
|
},
|
|
{
|
|
name: "merged identities",
|
|
liveLeft: fresh,
|
|
liveRight: fresh,
|
|
envLeft: first,
|
|
envRight: second,
|
|
differs: true,
|
|
generated: 1,
|
|
},
|
|
{
|
|
name: "split after an unchanged array",
|
|
liveLeft: first,
|
|
liveRight: fresh,
|
|
envLeft: first,
|
|
envRight: first,
|
|
differs: true,
|
|
generated: 0,
|
|
},
|
|
{
|
|
name: "merge after an unchanged array",
|
|
liveLeft: first,
|
|
liveRight: first,
|
|
envLeft: first,
|
|
envRight: second,
|
|
differs: true,
|
|
generated: 0,
|
|
},
|
|
])(
|
|
"preserves $name within one confirmed renewal response",
|
|
({ liveLeft, liveRight, envLeft, envRight, differs, generated }) => {
|
|
const result = compareAnswers(
|
|
answer(liveLeft, liveRight),
|
|
answer(envLeft, envRight),
|
|
new IdMap(),
|
|
{ rules },
|
|
);
|
|
expect(result.normalised.generated).toBe(generated);
|
|
expect(result.differences).toEqual(
|
|
differs
|
|
? [
|
|
{
|
|
category: "value",
|
|
pointer: "/right/0",
|
|
live: JSON.stringify(liveRight),
|
|
env: JSON.stringify(envRight),
|
|
},
|
|
]
|
|
: [],
|
|
);
|
|
},
|
|
);
|
|
|
|
it("retains order-only comparison for reordered generated scalar sets", () => {
|
|
const result = compare(
|
|
{ left: [first, second] },
|
|
{ left: [second, first] },
|
|
new IdMap(),
|
|
[200, 200],
|
|
rules,
|
|
);
|
|
expect(result.differences).toEqual([
|
|
{ category: "order", pointer: "/left" },
|
|
]);
|
|
expect(result.normalised.generated).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("clock field names", () => {
|
|
it("keeps numeric format identifiers literal and recognizes clock field forms", () => {
|
|
expect(
|
|
compare({ format: 1196444237 }, { format: 1448695129 }).differences,
|
|
).toMatchObject([{ category: "value", pointer: "/format" }]);
|
|
expect(
|
|
compare({ updated_at: 1760000000 }, { updated_at: 1760000999 })
|
|
.differences,
|
|
).toEqual([]);
|
|
expect(
|
|
compare({ createdAt: 1760000000 }, { createdAt: 1760000999 }).differences,
|
|
).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("comparison body coverage", () => {
|
|
it("compares a live body over the replay limit by status only and counts it", () => {
|
|
const result = compareAnswers(
|
|
{ status: 200, body: { kind: "cut", text: '{"data":[' } },
|
|
{ status: 200, body: { kind: "none" } },
|
|
new IdMap(),
|
|
);
|
|
expect(result.differences).toEqual([]);
|
|
expect(result.normalised.large_bodies).toBe(1);
|
|
});
|
|
|
|
it("compares a live body the probe did not record by status only and counts it", () => {
|
|
const unrecorded = {
|
|
status: 200,
|
|
body: { kind: "none" as const },
|
|
unobserved: true,
|
|
};
|
|
const result = compareAnswers(
|
|
unrecorded,
|
|
{ status: 200, body: { kind: "json", value: { id: 1 } } },
|
|
new IdMap(),
|
|
);
|
|
expect(result.differences).toEqual([]);
|
|
expect(result.normalised.unobserved_bodies).toBe(1);
|
|
expect(
|
|
compareAnswers(
|
|
unrecorded,
|
|
{ status: 404, body: { kind: "none" } },
|
|
new IdMap(),
|
|
).differences,
|
|
).toEqual([{ category: "status", pointer: "", live: "200", env: "404" }]);
|
|
});
|
|
});
|
|
|
|
describe("scalar sets of mapped ids", () => {
|
|
it("differ only in order once live ids are mapped", () => {
|
|
const map = new IdMap();
|
|
map.learn(101, 1);
|
|
map.learn(102, 2);
|
|
const compared = compareAnswers(
|
|
{ status: 200, body: { kind: "json", value: { ids: [101, 102] } } },
|
|
{ status: 200, body: { kind: "json", value: { ids: [2, 1] } } },
|
|
map,
|
|
);
|
|
expect(compared.differences).toEqual([
|
|
{ category: "order", pointer: "/ids" },
|
|
]);
|
|
expect(compared.normalised.ids).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("ids two collections reuse", () => {
|
|
it("compares an entity's own id within the call's collection and any other id by the probe's mapping", () => {
|
|
const map = new IdMap();
|
|
map.learn(101, 1, "/api/books");
|
|
map.learn(101, 2, "/api/authors");
|
|
const answer = (body: JsonValue) => ({
|
|
status: 200,
|
|
body: { kind: "json" as const, value: body },
|
|
});
|
|
expect(
|
|
compareAnswers(answer({ id: 101 }), answer({ id: 2 }), map, {
|
|
scope: "/api/authors",
|
|
}).differences,
|
|
).toEqual([]);
|
|
expect(
|
|
compareAnswers(answer({ book_id: 101 }), answer({ book_id: 2 }), map)
|
|
.differences,
|
|
).toEqual([
|
|
{ category: "value", pointer: "/book_id", live: "101", env: "2" },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("identity responses two collections reuse", () => {
|
|
it("compares a create's bare id and created member within the call's collection", () => {
|
|
const books = new IdMap();
|
|
books.learn(103, 1, "/api/books");
|
|
learnIds(103, 3, books, {
|
|
heldIds: new Set(),
|
|
scope: "/api/authors",
|
|
identity: true,
|
|
});
|
|
const created = new IdMap();
|
|
created.learn(103, 1, "/api/books");
|
|
learnIds({ created: 103 }, { created: 3 }, created, {
|
|
heldIds: new Set(),
|
|
scope: "/api/authors",
|
|
entityAt: "/created",
|
|
identity: true,
|
|
});
|
|
const answer = (body: JsonValue) => ({
|
|
status: 201,
|
|
body: { kind: "json" as const, value: body },
|
|
});
|
|
expect(books.env(103)).toBe(1);
|
|
expect(books.env(103, "/api/authors")).toBe(3);
|
|
expect(created.env(103, "/api/authors")).toBe(3);
|
|
expect(
|
|
compareAnswers(answer(103), answer(3), books, {
|
|
scope: "/api/authors",
|
|
identity: true,
|
|
}).differences,
|
|
).toEqual([]);
|
|
expect(
|
|
compareAnswers(
|
|
answer({ created: 103 }),
|
|
answer({ created: 3 }),
|
|
created,
|
|
{
|
|
scope: "/api/authors",
|
|
at: "/created",
|
|
identity: true,
|
|
},
|
|
).differences,
|
|
).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("wrapped answers", () => {
|
|
it("keeps collection identity context separate from a wrapped array's clock field context", () => {
|
|
const map = new IdMap();
|
|
map.learn(101, 1, "/api/books");
|
|
map.learn(101, 2, "/api/authors");
|
|
const result = compareAnswers(
|
|
{
|
|
status: 200,
|
|
body: {
|
|
kind: "json",
|
|
value: { data: [{ id: 101, updated_at: [1700000000] }] },
|
|
},
|
|
},
|
|
{
|
|
status: 200,
|
|
body: {
|
|
kind: "json",
|
|
value: { data: [{ id: 2, updated_at: [1800000000] }] },
|
|
},
|
|
},
|
|
map,
|
|
{ scope: "/api/authors", at: "/data" },
|
|
);
|
|
expect(result.differences).toEqual([]);
|
|
expect(result.normalised).toMatchObject({ ids: 1, clocks: 1 });
|
|
});
|
|
|
|
it("compares the identity of an entity inside an envelope within the call's collection", () => {
|
|
const map = new IdMap();
|
|
map.learn(101, 1, "/api/books");
|
|
learnIds(
|
|
{ data: [{ id: 101, name: "Bob" }] },
|
|
{ data: [{ id: 2, name: "Bob" }] },
|
|
map,
|
|
{ heldIds: new Set(), scope: "/api/authors", entityAt: "/data" },
|
|
);
|
|
expect(map.env(101, "/api/authors")).toBe(2);
|
|
expect(
|
|
compareAnswers(
|
|
{
|
|
status: 200,
|
|
body: { kind: "json", value: { data: { id: 101, name: "Bob" } } },
|
|
},
|
|
{
|
|
status: 200,
|
|
body: { kind: "json", value: { data: { id: 2, name: "Bob" } } },
|
|
},
|
|
map,
|
|
{ scope: "/api/authors", at: "/data" },
|
|
).differences,
|
|
).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("raw ids a mapping gives to other entities", () => {
|
|
const answer = (body: JsonValue) => ({
|
|
status: 200,
|
|
body: { kind: "json" as const, value: body },
|
|
});
|
|
|
|
it("reports an entity's own identity equal on both sides that its collection's mapping gives to another entity", () => {
|
|
const map = new IdMap();
|
|
map.learn(101, 1, "/api/books");
|
|
map.learn(102, 101, "/api/books");
|
|
const books = { scope: "/api/books" };
|
|
expect(
|
|
compareAnswers(answer({ id: 101 }), answer({ id: 101 }), map, books)
|
|
.differences,
|
|
).toEqual([{ category: "value", pointer: "/id", live: "101", env: "101" }]);
|
|
expect(
|
|
compareAnswers(answer({ id: 102 }), answer({ id: 101 }), map, books)
|
|
.differences,
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("leaves an equal reference equal, as the probe's mapping cannot tell which collection it names", () => {
|
|
const map = new IdMap();
|
|
// Another collection's create reassigned live 2, while the seeded project 2 is the same on both sides.
|
|
map.learn(2, 3);
|
|
map.learn(5, 2);
|
|
expect(
|
|
compareAnswers(
|
|
answer({ project_id: "2" }),
|
|
answer({ project_id: "2" }),
|
|
map,
|
|
).differences,
|
|
).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("generated integer identities", () => {
|
|
const unique: readonly FieldRule[] = [{ field: "id", rule: "unique" }];
|
|
|
|
it.each([
|
|
{
|
|
name: "numbers of one length",
|
|
live: 1_729_791_234_567_890,
|
|
env: 1_000_000_000_123_456,
|
|
rules: unique,
|
|
generated: 1,
|
|
},
|
|
{
|
|
name: "digit strings of one length",
|
|
live: "1729791234567890",
|
|
env: "1000000000123456",
|
|
rules: unique,
|
|
generated: 1,
|
|
},
|
|
{
|
|
name: "numbers of different lengths",
|
|
live: 1_729_791_234_567_890,
|
|
env: 100_000_000_012_345,
|
|
rules: unique,
|
|
generated: 0,
|
|
},
|
|
{
|
|
name: "a number and a digit string",
|
|
live: 1_729_791_234_567_890,
|
|
env: "1000000000123456",
|
|
rules: unique,
|
|
generated: 0,
|
|
},
|
|
{
|
|
name: "short integers",
|
|
live: 17,
|
|
env: 18,
|
|
rules: unique,
|
|
generated: 0,
|
|
},
|
|
{
|
|
name: "integers no rule generates",
|
|
live: 1_729_791_234_567_890,
|
|
env: 1_000_000_000_123_456,
|
|
rules: [],
|
|
generated: 0,
|
|
},
|
|
])(
|
|
"matches $name only when a unique rule generates both",
|
|
({ live, env, rules, generated }) => {
|
|
const result = compare(
|
|
{ id: live },
|
|
{ id: env },
|
|
new IdMap(),
|
|
[200, 200],
|
|
rules,
|
|
);
|
|
expect(result.normalised.generated).toBe(generated);
|
|
expect(result.differences).toEqual(
|
|
generated === 1
|
|
? []
|
|
: [
|
|
{
|
|
category: "value",
|
|
pointer: "/id",
|
|
live: JSON.stringify(live),
|
|
env: JSON.stringify(env),
|
|
},
|
|
],
|
|
);
|
|
},
|
|
);
|
|
});
|