From af1bfc2ee8708fde7f61177f91e958062d7a80b1 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:49:11 +0800 Subject: [PATCH] fix(environment): check recorded member and sequence values --- .../environmentStateVariedCalls.test.ts | 56 +++++++++++++++++++ .../environmentStateWriteCalls.ts | 21 ++++++- .../environmentStateWriteCreate.ts | 7 ++- 3 files changed, 78 insertions(+), 6 deletions(-) diff --git a/src/domain/analysisView/environmentStateVariedCalls.test.ts b/src/domain/analysisView/environmentStateVariedCalls.test.ts index 3e1be911de..0bd201c3bc 100644 --- a/src/domain/analysisView/environmentStateVariedCalls.test.ts +++ b/src/domain/analysisView/environmentStateVariedCalls.test.ts @@ -80,4 +80,60 @@ describe("writes whose calls send different members", () => { expect(plan).toMatchObject({ served: true }); expect(plan.body?.defaults).toEqual([{ member: "owner", value: null }]); }); + + it("recognizes a sent array when learning a later omitted-member default", () => { + const a = { id: 1, name: "A", tags: ["x"] }; + const b = { id: 2, name: "B", tags: ["y"] }; + const changedA = { ...a, name: "AA" }; + const changedB = { ...b, name: "BB", tags: [] }; + const rows: readonly TimedCaptureRow[] = [ + ["GET", "/api/tags", [a, b]], + ["GET", "/api/tags/1", a], + ["GET", "/api/tags/2", b], + ["PUT", "/api/tags/1", changedA, 200, { name: "AA", tags: ["x"] }], + ["GET", "/api/tags/1", changedA], + ["PUT", "/api/tags/2", changedB, 200, { name: "BB" }], + ["GET", "/api/tags/2", changedB], + ["GET", "/api/tags", [changedA, changedB]], + ]; + expect(planOf(rows, "put_tags_by_id")).toMatchObject({ served: true }); + }); + + it("withholds a sent sequence value contradicted by its recorded answer", () => { + const rows: readonly TimedCaptureRow[] = [ + ...NUMBERED_UNLESS_SENT_ROWS.map((row) => + row[0] === "GET" && row[1] === "/api/bundles" && Array.isArray(row[2]) + ? ([ + row[0], + row[1], + (row[2] as { id: number; name: string }[]).map( + ({ id, name }) => ({ id, name }), + ), + ] as TimedCaptureRow) + : row, + ), + [ + "POST", + "/api/bundles", + { id: 5, name: "Echo", order: 10 }, + 201, + { name: "Echo", order: 9 }, + ], + [ + "GET", + "/api/bundles", + [ + { id: 1, name: "Alpha" }, + { id: 2, name: "Bravo" }, + { id: 3, name: "Charlie" }, + { id: 4, name: "Delta" }, + { id: 5, name: "Echo" }, + ], + ], + ]; + expect(planOf(rows, "post_bundles")).toMatchObject({ + served: false, + reason: "unexplained-change", + }); + }); }); diff --git a/src/domain/analysisView/environmentStateWriteCalls.ts b/src/domain/analysisView/environmentStateWriteCalls.ts index 925ad8d31d..4575b1367a 100644 --- a/src/domain/analysisView/environmentStateWriteCalls.ts +++ b/src/domain/analysisView/environmentStateWriteCalls.ts @@ -105,6 +105,7 @@ const sameObserved = (left: ObservedValue, right: ObservedValue): boolean => export interface UpdateCall { readonly shown: ReadonlyMap; readonly sent: ReadonlyMap; + readonly sentMembers: ReadonlySet; readonly kept: readonly string[]; } @@ -337,7 +338,7 @@ export const defaultsDisagree = ( ({ member, value }) => calls.earlier.some( (call) => - !call.sent.has(member) && + !call.sentMembers.has(member) && call.shown.has(member) && !same(call.shown.get(member), observedValue(value)), ) || @@ -376,8 +377,22 @@ export const rememberCall = ( ]), ); for (const field of unstamped) shown.delete(field); - const facts = { shown, sent: scalars(call.sent), kept }; - retain(64 + jsonTextCodeUnits([...facts.shown, ...facts.sent, kept]) * 2); + const facts = { + shown, + sent: scalars(call.sent), + sentMembers: new Set(Object.keys(call.sent)), + kept, + }; + retain( + 64 + + jsonTextCodeUnits([ + ...facts.shown, + ...facts.sent, + [...facts.sentMembers], + kept, + ]) * + 2, + ); draft.updates.push(facts); for (const field of steadyFields(draft.plan.rules, call.before, call.after)) draft.steady.add(field); diff --git a/src/domain/analysisView/environmentStateWriteCreate.ts b/src/domain/analysisView/environmentStateWriteCreate.ts index 6d9e364b21..cbe067dd4a 100644 --- a/src/domain/analysisView/environmentStateWriteCreate.ts +++ b/src/domain/analysisView/environmentStateWriteCreate.ts @@ -509,13 +509,14 @@ const followsSequences = ( draft: WriteDraft, created: Pick & { readonly sent: JsonObject; + readonly observed: JsonObject; }, ): boolean => sequencesOf(draft, created.collection).every(({ rule, key }) => { if (rule.sent === true && Object.hasOwn(created.sent, rule.field)) - return same( - ownMember(created.fields, rule.field), - created.sent[rule.field], + return ( + !Object.hasOwn(created.observed, rule.field) || + same(ownMember(created.observed, rule.field), created.sent[rule.field]) ); const scope = [ ...context.state.ofType(created.type),