diff --git a/docs/specs/agent-environments/write-form-calls/spec.md b/docs/specs/agent-environments/write-form-calls/spec.md index ba78d9d6dc..f3fee2d1da 100644 --- a/docs/specs/agent-environments/write-form-calls/spec.md +++ b/docs/specs/agent-environments/write-form-calls/spec.md @@ -30,7 +30,8 @@ natural-language tasks ask for, or withheld them: matches, which keeps the recorded value as a partial page's total does. - **Sorted collections.** An update that writes the field its type's creates are sorted by moves its entity to where the new value sorts, in replay and serving (a bulk update moving each entity in turn), and its plan carries that - `insert`. When a read listing the whole collection fails after an update and the type's creates are not sorted by a + `insert`. When a read listing the whole collection in collection order (not sorted by its query) fails after an + update and the type's creates are not sorted by a field the update writes, replay tries the collection sorted by such a field when the entities no exercised write changed already follow it, as serving moves only written entities, and unless a create was placed by a sort that excludes it or a whole-collection read in collection order (not sorted by its query) that placed a create did @@ -38,7 +39,7 @@ natural-language tasks ask for, or withheld them: - **Unloaded answers.** A create's later item read is taken as its observation when it differs from the answer only in values the answer had not loaded: empty, zero, `false` or Go's zero time, or objects whose members are each the same or loaded so; the plan points to those values (`observation.read.unloaded`), which a served create's answer keeps as - the recorded answer showed them while the state holds the read's. An unsent update field that changes only between + the recorded answer showed them, unless the call sent one the plan stores as sent, while the state holds the read's. An unsent update field that changes only between `null`, `[]` and `{}` is unchanged. Write answers, bulk answers and replay's checks of them included, spell empty lists and maps the call did not send as their recorded answers did, and a sent one as sent. - **Unruled fields.** A later create that stored a field no rule writes otherwise than the plan keeps it is diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index e1cb714089..967cf82ec1 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -760,7 +760,8 @@ precision, including microseconds. A list read only adds fields the answer lacks and still checks it. Serving answers a create in its answer's shape and clock precision, with empty lists and maps the call did not send spelled (`null`, `[]` or `{}`) as its answer spelled them and the -values it had not loaded as it answered them, and stores the fields of both, +values it had not loaded as it answered them (unless the call sent one the plan +stores as sent), and stores the fields of both, as the item read-back shows them, with its clock precision. Updates answer empty lists and maps they did not send as their answers spelled them too; a member a call sent is answered as sent. A `reference` is an object @@ -856,7 +857,7 @@ all request completions are known; otherwise it is null. An update that writes the field its type's creates are sorted by moves its entity to where the new value sorts (its own `insert`); a bulk update moves each entity it writes in turn, as replay applies them. When a read listing the whole -collection fails after an update and the type's creates are not sorted by a field +collection in its own order, not one its query sorts, fails after an update and the type's creates are not sorted by a field that update writes, the view tries the collection sorted by such a field, when the entities no write it exercised changed already follow it, as serving moves only the entities a write changes, and unless diff --git a/src/application/environment/environmentStateFormAnswer.test.ts b/src/application/environment/environmentStateFormAnswer.test.ts index 1f9024f3a7..9bdced859d 100644 --- a/src/application/environment/environmentStateFormAnswer.test.ts +++ b/src/application/environment/environmentStateFormAnswer.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + ACTIVE_ON_READ_ROWS, BULK_RENAMED_LABEL_ROWS, ECHOED_EMPTY_ROWS, formLabelRows, @@ -146,6 +147,18 @@ describe("served creates and updates modelled on form recordings", () => { }); }); + it("answers a sent value its create does not store as the recorded answer did, and stores the read's", () => { + const users = servedFrom(ACTIVE_ON_READ_ROWS); + expect( + users.answer("post_users", { body: { name: "Cy", active: false } }).body, + ).toEqual({ id: 2, name: "Cy", active: false }); + expect(users.answer("get_users_by_id", { id: "2" }).body).toEqual({ + id: 2, + name: "Cy", + active: true, + }); + }); + it("lists a created tag's id in the list envelope's ids, in list order", () => { const tags = servedFrom(LISTED_ID_ROWS); tags.answer("post_tags", { body: { name: "Lamp" } }); diff --git a/src/application/environment/environmentStateWriteAnswer.ts b/src/application/environment/environmentStateWriteAnswer.ts index 9354e43e47..f4c1533ce4 100644 --- a/src/application/environment/environmentStateWriteAnswer.ts +++ b/src/application/environment/environmentStateWriteAnswer.ts @@ -43,6 +43,7 @@ import { collectionMatch, decodedSegment, isObject, + pointerTokens, scalarText, type JsonObject, type StateEntity, @@ -375,7 +376,18 @@ export const writeAnswer = ( rule.rule === "clock" ? [rule.field] : [], ) : [], - unloaded: plan.observation?.read?.unloaded ?? [], + // A member the call sent and the plan stores as sent is answered as sent; any other keeps the recorded answer. + unloaded: (plan.observation?.read?.unloaded ?? []).filter( + (path) => { + const [field] = pointerTokens(path); + return !( + rendered.sent?.includes(field!) === true && + plan.rules.some( + (rule) => rule.field === field && rule.rule === "echo", + ) + ); + }, + ), }, optionalFields(plan), ); diff --git a/src/domain/analysisView/EnvironmentStateWriteSimulation.ts b/src/domain/analysisView/EnvironmentStateWriteSimulation.ts index fb62818bfb..40280ce770 100644 --- a/src/domain/analysisView/EnvironmentStateWriteSimulation.ts +++ b/src/domain/analysisView/EnvironmentStateWriteSimulation.ts @@ -104,6 +104,23 @@ const keptInOrder = ( ); }); +/** Whether a list read's query sets a parameter the view's query model reads as a sort or its direction. */ +const sortedByQuery = ( + view: Extract, + record: StateRecordFacts, +): boolean => { + const sent = new Set( + [...new URLSearchParams(record.query ?? "").keys()].map((key) => + key.toLowerCase(), + ), + ); + return Object.entries(view.query?.parameters ?? {}).some( + ([parameter, { role }]) => + (role === "sort" || role === "direction") && + sent.has(parameter.toLowerCase()), + ); +}; + const APPLY = { create: applyCreate, update: applyUpdate, @@ -882,19 +899,7 @@ export class EnvironmentStateWriteSimulation { record: StateRecordFacts, name: string, ): void { - const sent = new Set( - [...new URLSearchParams(record.query ?? "").keys()].map((key) => - key.toLowerCase(), - ), - ); - if ( - Object.entries(view.query?.parameters ?? {}).some( - ([parameter, { role }]) => - (role === "sort" || role === "direction") && - sent.has(parameter.toLowerCase()), - ) - ) - return; + if (sortedByQuery(view, record)) return; const draft = this.#drafts.get(name)!; const kept = this.#wholeListing(view, record, (items) => draft.candidates.filter( @@ -919,7 +924,8 @@ export class EnvironmentStateWriteSimulation { exercised: ReadonlySet, ): CheckOutcome | undefined { const line = this.#line(view, record); - if (line === undefined) return undefined; + // A read its query sorts shows the query's order, which may break ties otherwise than the collection. + if (line === undefined || sortedByQuery(view, record)) return undefined; const written = new Set( [...exercised].flatMap((name) => { const { plan } = this.#drafts.get(name)!; diff --git a/src/domain/analysisView/environmentStateFormWrites.test.ts b/src/domain/analysisView/environmentStateFormWrites.test.ts index 63fa375b9e..1c4f3f0d20 100644 --- a/src/domain/analysisView/environmentStateFormWrites.test.ts +++ b/src/domain/analysisView/environmentStateFormWrites.test.ts @@ -15,6 +15,7 @@ import { NOTE_DEFAULT_ROWS, RENAMED_LABEL_ROWS, SHELF_SORTED_ROWS, + SHELF_TIE_ROWS, TITLE_ORDER_ROWS, UNLOADED_ANSWER_ROWS, UNSORTED_SEED_ROWS, @@ -169,6 +170,10 @@ describe("collections kept sorted", () => { expect(plan.insert).toBeNull(); }); + it("learns no sort from a read its query sorts, whose ties need not follow the collection's order", () => { + expect(planOf(SHELF_TIE_ROWS, "put_books_by_id").insert).toBeNull(); + }); + it("serves a bulk update whose recordings echo the empty values each call sent", () => { expect(planOf(BULK_EMPTY_TAG_ROWS, "patch_labels")).toMatchObject({ served: true, diff --git a/src/domain/analysisView/environmentStateWriteApply.ts b/src/domain/analysisView/environmentStateWriteApply.ts index 5e15eac04e..a775c4cbe0 100644 --- a/src/domain/analysisView/environmentStateWriteApply.ts +++ b/src/domain/analysisView/environmentStateWriteApply.ts @@ -590,7 +590,7 @@ export const writtenResponse = ( (rendered, path) => { const tokens = pointerTokens(path); const kept = memberAt(shown, tokens); - return kept === undefined || written.sent?.includes(tokens[0]!) === true + return kept === undefined ? rendered : withMemberAt(rendered, tokens, kept); }, diff --git a/tests/fixtures/environmentStateFormWrites.ts b/tests/fixtures/environmentStateFormWrites.ts index c5575c782c..ce78d7e220 100644 --- a/tests/fixtures/environmentStateFormWrites.ts +++ b/tests/fixtures/environmentStateFormWrites.ts @@ -439,3 +439,47 @@ export const UNSORTED_SEED_ROWS: readonly TimedCaptureRow[] = (() => { ["GET", "/api/folders", [alpha, bravo, renamed]], ]; })(); + +/** Books kept in id order, which a `?sort=shelf` read breaks ties of by name, then a rename that read shows reordered. */ +export const SHELF_TIE_ROWS: readonly TimedCaptureRow[] = (() => { + const shelved = (id: number, name: string): Entity => ({ + id, + name, + shelf: "a", + }); + const [alpha, bravo] = [shelved(1, "Alpha"), shelved(2, "Bravo")]; + const zulu = shelved(1, "Zulu"); + return [ + ["GET", "/api/books", [alpha, bravo]], + ["GET", "/api/books?sort=-id", [bravo, alpha]], + ["GET", "/api/books/1", alpha], + ["GET", "/api/books/2", bravo], + ["PUT", "/api/books/1", zulu, 200, { name: "Zulu", shelf: "a" }], + ["GET", "/api/books/1", zulu], + ["GET", "/api/books?sort=shelf", [bravo, zulu]], + ["GET", "/api/books", [zulu, bravo]], + ]; +})(); + +/** A user create that sends `active: false` and is answered so, whose read shows the account active. */ +export const ACTIVE_ON_READ_ROWS: readonly TimedCaptureRow[] = (() => { + const user = (id: number, name: string, active: boolean): Entity => ({ + id, + name, + active, + }); + const root = user(1, "Root", true); + return [ + ["GET", "/api/users", [root]], + ["GET", "/api/users/1", root], + [ + "POST", + "/api/users", + user(2, "Ada", false), + 201, + { name: "Ada", active: false }, + ], + ["GET", "/api/users/2", user(2, "Ada", true)], + ["GET", "/api/users", [root, user(2, "Ada", true)]], + ]; +})();