diff --git a/docs/specs/agent-environments/write-form-calls/spec.md b/docs/specs/agent-environments/write-form-calls/spec.md index b59e21f266..4ebb9ad1e7 100644 --- a/docs/specs/agent-environments/write-form-calls/spec.md +++ b/docs/specs/agent-environments/write-form-calls/spec.md @@ -23,18 +23,20 @@ natural-language tasks ask for, or withheld them: - **Blank members.** A create member a recorded call sent empty is not required. A call that leaves it out is applied as sending that value (`body.blank`), so the entity, the answer and every rule reading the member come out as for the recorded call. A member a later call left out takes the default it showed instead. A call may send a member as - any recorded call sent it empty, such as `null` where another stored a string, and one leaving it out is applied as - the earliest did. Updates are unchanged. + any recorded call sent it empty, such as `null` where another stored a string, even once a default is learned, and + one leaving it out without a default is applied as the earliest did. Updates are unchanged. - **Listed ids.** A list view names top-level envelope members that list two or more listed entities' identity values in list order (`listed_ids`). A rendered list sets them to its own entities' values, unless it is a page of more 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, and its plan carries that `insert`. When a read listing the whole - collection fails after an update and no create of the type was placed by a sort, replay tries the collection sorted - by a field the update writes; on a match, the type's creates and updates take that order. + 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 + field the update writes, replay tries the collection sorted by such a field, unless a create was placed by a sort + that excludes it; on a match, the type's creates and updates take that order. - **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. An unsent update field that changes only between `null`, `[]` and `{}` is unchanged. + or loaded so. An unsent update field that changes only between `null`, `[]` and `{}` is unchanged. Write answers + spell empty lists and maps as their recorded answers did. - **Unruled fields.** A later create that stored a field no rule writes otherwise than the plan keeps it is `unexplained-change` at the create, so its recorded entity is held and later reads check other writes as before. - **Skill.** The two scratch creates differ in what they send, the second only what a person must give, however they diff --git a/docs/specs/agent-environments/write-form-calls/verification.md b/docs/specs/agent-environments/write-form-calls/verification.md index 225734634a..e0e5614687 100644 --- a/docs/specs/agent-environments/write-form-calls/verification.md +++ b/docs/specs/agent-environments/write-form-calls/verification.md @@ -24,8 +24,13 @@ the agent recordings. for `null` takes its item read; an update answer spelling an empty list `[]` is unchanged; a later create storing a derived field otherwise is withheld at the create and later deletes stay confirmed. - Serving: a label created from its name alone stores the empty members as sent and is inserted by name, and one - without the required name is refused; renamed labels and folders move to where their new names sort; a created tag's - id appears in the list envelope's `all` in list order. + without the required name is refused; renamed labels and folders move to where their new names sort, and a bulk + rename of two labels moves each; a created tag's id appears in the list envelope's `all` in list order; a note sent + `null` as one recording did is accepted after a later call taught `""` as its default, which a note left out takes; + a created and an updated task answer `assignees: []` as their recordings did while reads show `null`. +- Review cases: a member's recorded empty value stays accepted once a default is learned; a rename teaches title order + where the creates fit both id and title order; moving written entities in turn sorts `[a, b, c, d]` with `b→h` and + `c→i` as `[a, d, h, i]`. The plan and serving tests for these fail on the branch's first head `0ceff6fa`. ## Write plans over recorded captures diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index 12cec3f237..fda6316b28 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -757,8 +757,10 @@ no longer confirms the create; one that disagrees or omits an answer field its item view renders still checks it. Clock agreement uses the coarser recorded 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 and stores the fields of both with -the item read-back's clock precision. A `reference` is an object holding fields of the +in its answer's shape and clock precision, with empty lists and maps spelled +(`null`, `[]` or `{}`) as its answer spelled them, and stores the fields of both with +the item read-back's clock precision. Updates answer empty lists and maps as +their answers spelled them too. A `reference` is an object holding fields of the entity a sent member names, as the state holds it (a parent beside `parentId`): the member is accepted with any value, serving looks the entity up and refuses a call naming one the environment does not hold or holds in more than one @@ -774,9 +776,10 @@ required, and an echoed member a call left out takes the value that call stored (`body.defaults`). A member a create sent empty (`""`, `null`, `false`, `[]` or `{}`), as a form sends a field left as it was, is not required: a call that leaves it out is applied as sending that value (`body.blank`), unless a call -that left it out showed a default. A call may send it as any recorded call sent -it empty, and one leaving it out is applied as the earliest did, as when one -create sent `false` and another `null`. A clock rule widens to `trimmed` only when no held entity or +that left it out showed a default, which it then keeps. A call may send it as any +recorded call sent it empty, even after a default is learned, and one leaving it +out is applied as the earliest did, as when one create sent `false` and another +`null`. A clock rule widens to `trimmed` only when no held entity or earlier call showed that clock at a fixed width. A later create that disagrees leaves the plan as it was. A create whose observed reference differs from the entity the plan rebuilds, whether or not it sent the reference member, is @@ -846,11 +849,13 @@ contradicted it; otherwise its `reason` is the first of its build reason entities (sorted by a field the collection's recorded order shows, else last), and `write_clock` is the end of the last timed record, to the next second. 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`). When a read listing the whole -collection fails after an update and no create of the type was placed by a -sort, the view tries the collection sorted by a field that update writes; on a -match, the type's creates and updates take that order, as Grafana keeps -folders by title though no earlier read could tell that from creation order. +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 +that update writes, the view tries the collection sorted by such a field, unless +a create was placed by a sort that excludes it; on a match, the type's creates +and updates take that order, as Grafana keeps folders by title though no earlier +read could tell that from creation order, or from id order where both ascend. Values in the state are recorded data, so the view refuses the same parents as the manifest, including those inspected with `sensitive_values`, and a diff --git a/src/application/environment/environmentStateBulkAnswer.ts b/src/application/environment/environmentStateBulkAnswer.ts index 8bf7ced883..3f7cf2510c 100644 --- a/src/application/environment/environmentStateBulkAnswer.ts +++ b/src/application/environment/environmentStateBulkAnswer.ts @@ -32,7 +32,7 @@ import { generator, observedEntity, pathValues, - resortedIndex, + sortedOrder, unloadable, heldEntity, updateTarget, @@ -184,19 +184,18 @@ export const answerBulk = ( ); if (!answer.ok) return answer; if (update) { - const updated = serving.store.updateAll(written.value); + const updated = serving.store.updateAll( + written.value, + sortedOrder( + serving, + plan, + written.value.map((after, index) => ({ + before: targets.value[index]!.entity, + after, + })), + ), + ); if (!updated.ok) return updated; - // In a sorted collection each entity whose sorted field changed moves, one after another. - for (const [index, entity] of written.value.entries()) { - const at = resortedIndex(serving, plan, { - entity: targets.value[index]!.entity, - fields: entity.fields, - }); - if (at !== undefined) { - const moved = serving.store.update(entity, at); - if (!moved.ok) return moved; - } - } } else serving.store.removeAll(written.value); serving.store.commitWrite(values.taken); return answer; diff --git a/src/application/environment/environmentStateFormAnswer.test.ts b/src/application/environment/environmentStateFormAnswer.test.ts index 885c9303a1..365104400f 100644 --- a/src/application/environment/environmentStateFormAnswer.test.ts +++ b/src/application/environment/environmentStateFormAnswer.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from "vitest"; import { + BULK_RENAMED_LABEL_ROWS, formLabelRows, LISTED_ID_ROWS, + NOTE_DEFAULT_ROWS, RENAMED_LABEL_ROWS, TITLE_ORDER_ROWS, + UNLOADED_ANSWER_ROWS, } from "../../../tests/fixtures/environmentStateFormWrites.js"; import { servedFrom } from "../../../tests/fixtures/environmentStateServing.js"; import type { JsonValue } from "../../domain/jsonValue.js"; @@ -66,6 +69,60 @@ describe("served creates and updates modelled on form recordings", () => { ]); }); + it("moves every entity a bulk rename writes to where its new name sorts", () => { + const labels = servedFrom(BULK_RENAMED_LABEL_ROWS); + const created = (name: string) => { + const body = labels.answer("post_labels", { body: { name } }).body; + if (typeof body !== "object" || body === null || Array.isArray(body)) + throw new Error("Expected an object body"); + return body["id"]!; + }; + created("Attic"); + const garden = created("Garden"); + expect(field(labels.answer("get_labels").body, "name")).toEqual([ + "Attic", + "Garden", + "Kitchen", + "Tools", + ]); + labels.answer("patch_labels", { + body: [ + { id: garden, name: "Vane" }, + { id: 1, name: "Wharf" }, + ], + }); + expect(field(labels.answer("get_labels").body, "name")).toEqual([ + "Attic", + "Tools", + "Vane", + "Wharf", + ]); + }); + + it("accepts a member sent empty as one recording did, and stores the default for one left out", () => { + const notes = servedFrom(NOTE_DEFAULT_ROWS); + expect( + notes.answer("post_notes", { body: { name: "Blank", note: null } }).body, + ).toMatchObject({ name: "Blank", note: null }); + expect( + notes.answer("post_notes", { body: { name: "Left out" } }).body, + ).toMatchObject({ name: "Left out", note: "" }); + }); + + it("answers writes with empty lists spelled as their recorded answers spelled them", () => { + const tasks = servedFrom(UNLOADED_ANSWER_ROWS); + expect( + tasks.answer("post_tasks", { body: { title: "Coil the ropes" } }).body, + ).toMatchObject({ title: "Coil the ropes", assignees: [] }); + expect( + tasks.answer("patch_tasks_by_id", { id: "1", body: { done: true } }).body, + ).toMatchObject({ done: true, assignees: [] }); + expect(tasks.answer("get_tasks_by_id", { id: "1" }).body).toMatchObject({ + done: true, + assignees: null, + }); + }); + 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/environmentStateStore.ts b/src/application/environment/environmentStateStore.ts index 59415679eb..e7d1442bb3 100644 --- a/src/application/environment/environmentStateStore.ts +++ b/src/application/environment/environmentStateStore.ts @@ -306,48 +306,18 @@ export class EnvironmentStateStore { return ok(undefined); } - /** Replace a held entity's values, in place or at `index` of its collection's other entities, unless the state would outgrow what `data/add` loads. */ - update( - entity: StateEntity, - index?: number, - ): Result { - const held = this.#entities.get( - compositeKey([entity.type, entity.collection, entity.id]), - ); - if (index === undefined || held === undefined) - return this.updateAll([entity]); - const entities = this.#content.entities.filter( - (candidate) => candidate !== held, - ); - const members = entities.filter( - (candidate) => - candidate.type === entity.type && - candidate.collection === entity.collection, - ); - if (members.length === 0) return this.updateAll([entity]); - const bytes = this.#grown(jsonByteLength(entity) - jsonByteLength(held)); - if (!bytes.ok) return bytes; - const anchor = - index < members.length - ? entities.indexOf(members[index]!) - : entities.indexOf(members.at(-1)!) + 1; - this.#install( - { - ...this.#content, - entities: [ - ...entities.slice(0, anchor), - entity, - ...entities.slice(anchor), - ], - }, - bytes.value, - ); - return ok(undefined); + /** Replace a held entity's values in place, unless the state would outgrow what `data/add` loads. */ + update(entity: StateEntity): Result { + return this.updateAll([entity]); } - /** Replace held entities in one step, unless the state would outgrow what `data/add` loads; a refusal changes nothing. */ + /** + * Replace held entities in one step, unless the state would outgrow what `data/add` loads; a refusal changes nothing. + * `order` puts held entities, the replacements among them, in that order in the places they hold. + */ updateAll( entities: readonly StateEntity[], + order: readonly StateEntity[] = [], ): Result { const replaced = new Map(); for (const entity of entities) { @@ -363,11 +333,17 @@ export class EnvironmentStateStore { delta += jsonByteLength(entity) - jsonByteLength(held); const bytes = this.#grown(delta); if (!bytes.ok) return bytes; + const key = (entity: StateEntity) => + compositeKey([entity.type, entity.collection, entity.id]); + const placed = new Set(order.map(key)); + let next = 0; this.#install( { ...this.#content, - entities: this.#content.entities.map( - (candidate) => replaced.get(candidate) ?? candidate, + entities: this.#content.entities.map((candidate) => + placed.has(key(candidate)) + ? order[next++]! + : (replaced.get(candidate) ?? candidate), ), }, bytes.value, diff --git a/src/application/environment/environmentStateWriteAnswer.ts b/src/application/environment/environmentStateWriteAnswer.ts index 75fe52e54b..2ce24420d9 100644 --- a/src/application/environment/environmentStateWriteAnswer.ts +++ b/src/application/environment/environmentStateWriteAnswer.ts @@ -12,6 +12,7 @@ import { } from "../../domain/analysisView/environmentStateWriteApply.js"; import { insertIndex, + resortedMembers, nextInteger, } from "../../domain/analysisView/environmentStateWriteExplain.js"; import { callRenewal } from "../../domain/analysisView/environmentStateWriteGenerated.js"; @@ -42,7 +43,6 @@ import { collectionMatch, decodedSegment, isObject, - same, scalarText, type JsonObject, type StateEntity, @@ -602,38 +602,31 @@ const answerUpdate = ( }, ); if (!answer.ok) return answer; - const updated = serving.store.update( - updatedEntity(entity, written.value), - resortedIndex(serving, plan, { entity, fields: written.value }), + const after = updatedEntity(entity, written.value); + const updated = serving.store.updateAll( + [after], + sortedOrder(serving, plan, [{ before: entity, after }]), ); if (!updated.ok) return updated; serving.store.commitWrite(values.taken); return answer; }; -/** Where an update moves its entity among the others of a collection sorted by a field it changed; undefined to keep it in place. */ -export const resortedIndex = ( +/** The collection's members in the order a sorted collection holds them after these writes; empty when the plan keeps them in place. */ +export const sortedOrder = ( serving: StateServing, plan: StateWritePlan, - written: { readonly entity: StateEntity; readonly fields: JsonObject }, -): number | undefined => { - const { insert } = plan; - const { entity, fields } = written; - if ( - insert?.rule !== "sorted" || - same( - ownMember(entity.fields, insert.field), - ownMember(fields, insert.field), - ) - ) - return undefined; - return insertIndex( - insert, - serving.store - .inCollection(entity.type, entity.collection) - .filter((member) => member.id !== entity.id) - .map((member) => member.fields), - fields, + writes: readonly { + readonly before: StateEntity; + readonly after: StateEntity; + }[], +): StateEntity[] => { + const [first] = writes; + if (plan.insert?.rule !== "sorted" || first === undefined) return []; + return resortedMembers( + plan.insert, + serving.store.inCollection(first.before.type, first.before.collection), + writes, ); }; diff --git a/src/domain/analysisView/EnvironmentStateWriteSimulation.ts b/src/domain/analysisView/EnvironmentStateWriteSimulation.ts index 8c7a29210e..abec0c5df6 100644 --- a/src/domain/analysisView/EnvironmentStateWriteSimulation.ts +++ b/src/domain/analysisView/EnvironmentStateWriteSimulation.ts @@ -789,15 +789,15 @@ export class EnvironmentStateWriteSimulation { /** * The successful check with the collection sorted by a field an update the read exercised writes, when the read - * lists the whole collection in that order and no create of the type was placed otherwise; the type's creates then - * insert by it, and its updates move their entity by it. + * lists the whole collection in that order and no create of the type was placed by a sort that excludes it; the + * type's creates then insert by it, and its updates move their entity by it. A sort the creates chose by a field the + * update did not write does not stop this, since both may have fit every earlier read. */ #retrySort( view: Extract, record: StateRecordFacts, exercised: ReadonlySet, ): CheckOutcome | undefined { - if (this.#sortOf(view.type) !== undefined) return undefined; const line = this.#line(view, record); const idField = this.#types.get(view.type)?.id_field; if (line === undefined || idField === undefined) return undefined; @@ -814,6 +814,10 @@ export class EnvironmentStateWriteSimulation { : []; }), ); + // A sort by a field the update wrote has moved its entity already; one by another field may be a guess both fit. + const current = this.#sortOf(view.type); + if (current?.rule === "sorted" && written.has(current.field)) + return undefined; const creates = [...this.#drafts.values()].filter( (draft) => draft.plan.effect === "create" && @@ -841,7 +845,9 @@ export class EnvironmentStateWriteSimulation { ), ); }); - for (const rule of rules) { + for (const rule of rules.filter( + (candidate) => current === undefined || !same(candidate, current), + )) { // A create placed by another sort shows the collection is not kept in this order. if ( creates.some( diff --git a/src/domain/analysisView/environmentStateFormWrites.test.ts b/src/domain/analysisView/environmentStateFormWrites.test.ts index cdcc77af85..38819bde61 100644 --- a/src/domain/analysisView/environmentStateFormWrites.test.ts +++ b/src/domain/analysisView/environmentStateFormWrites.test.ts @@ -7,14 +7,17 @@ import { import { DERIVED_TEXT_ROWS, formLabelRows, + ID_OR_TITLE_ROWS, LISTED_ID_ROWS, MINIMAL_CONTACT_ROWS, + NOTE_DEFAULT_ROWS, RENAMED_LABEL_ROWS, TITLE_ORDER_ROWS, UNLOADED_ANSWER_ROWS, } from "../../../tests/fixtures/environmentStateFormWrites.js"; import { environmentStateViewOf } from "../../../tests/fixtures/environmentStateWrites.js"; import { mergedCreatePlan } from "./environmentStateWriteCreateCalls.js"; +import { resortedMembers } from "./environmentStateWriteExplain.js"; import type { WriteBody } from "./environmentStateWriteRules.js"; const planOf = (rows: readonly TimedCaptureRow[], tool: string) => { @@ -77,6 +80,19 @@ describe("create members a form sent empty", () => { }); }); +describe("empty values and defaults", () => { + it("keeps accepting a recorded empty value after a later call that left the member out teaches its default", () => { + expect(planOf(NOTE_DEFAULT_ROWS, "post_notes")).toMatchObject({ + served: true, + body: { + required: ["name"], + defaults: [{ member: "note", value: "" }], + blank: [{ member: "note", sent: null }], + }, + }); + }); +}); + describe("a create with only what a person must give", () => { it("serves a create whose minimal call left string members `null` and an empty flag spelled another way", () => { expect(planOf(MINIMAL_CONTACT_ROWS, "post_contacts")).toMatchObject({ @@ -114,6 +130,38 @@ describe("collections kept sorted", () => { }); }); + it("learns the title order from a rename when the creates fit both id and title order", () => { + const byTitle = { rule: "sorted", field: "title", direction: "ascending" }; + expect(planOf(ID_OR_TITLE_ROWS, "put_folders_by_id")).toMatchObject({ + served: true, + insert: byTitle, + }); + expect(planOf(ID_OR_TITLE_ROWS, "post_folders")).toMatchObject({ + insert: byTitle, + }); + }); + + it("moves each written entity in turn, against the others' values before their own writes", () => { + const entity = (id: string, name: string) => ({ id, fields: { name } }); + const [a, b, c, d] = ["a", "b", "c", "d"].map((name, index) => + entity(String(index + 1), name), + ) as [ReturnType, ...ReturnType[]]; + const order = resortedMembers( + { rule: "sorted", field: "name", direction: "ascending" }, + [a, b!, c!, d!], + [ + { before: b!, after: entity("2", "h") }, + { before: c!, after: entity("3", "i") }, + ], + ); + expect(order.map(({ fields }) => fields["name"])).toEqual([ + "a", + "d", + "h", + "i", + ]); + }); + it("renders a list envelope's ids of the listed entities from the state", () => { const view = environmentStateViewOf( timedCaptureEvidence(LISTED_ID_ROWS), diff --git a/src/domain/analysisView/environmentStateWriteApply.ts b/src/domain/analysisView/environmentStateWriteApply.ts index 2c1f139e80..6e0e63c18c 100644 --- a/src/domain/analysisView/environmentStateWriteApply.ts +++ b/src/domain/analysisView/environmentStateWriteApply.ts @@ -13,14 +13,15 @@ import { type JsonObject, type StateEntity, } from "./environmentStateRules.js"; -import type { - FieldRule, - GeneratedRule, - JsonKind, - StateWritePlan, - WriteBody, - WriteCall, - WriteResponse, +import { + emptySpellings, + type FieldRule, + type GeneratedRule, + type JsonKind, + type StateWritePlan, + type WriteBody, + type WriteCall, + type WriteResponse, } from "./environmentStateWriteRules.js"; import { stampProblem, @@ -327,12 +328,13 @@ const copiedShapes = ( }), ); -/** A create's call with the members it left out that recorded calls sent empty, as the earliest sent them. */ +/** A create's call with the members it left out that recorded calls sent empty, as the earliest sent them; one a call that left it out showed a default for keeps that default. */ const withBlanks = (body: WriteBody, call: WriteCall): WriteCall => { const given = call.body ?? {}; const left = (body.blank ?? []).filter( ({ member }, index, blank) => !Object.hasOwn(given, member) && + !(body.defaults ?? []).some((entry) => entry.member === member) && blank.findIndex((entry) => entry.member === member) === index, ); return left.length === 0 @@ -566,9 +568,13 @@ export const writtenResponse = ( Object.fromEntries( fields.map((field) => { const value = ownMember(written.fields!, field)!; + const answered = ownMember(shown, field); + // An answer spells an empty list or map as its recording did, whichever spelling the state holds. + if (answered !== undefined && emptySpellings(answered, value)) + return [field, answered]; const clock = written.clocks?.includes(field) === true - ? readClock(ownMember(shown, field)) + ? readClock(answered) : undefined; return [ field, diff --git a/src/domain/analysisView/environmentStateWriteCreateCalls.ts b/src/domain/analysisView/environmentStateWriteCreateCalls.ts index 1ed03a980f..d1cbe42555 100644 --- a/src/domain/analysisView/environmentStateWriteCreateCalls.ts +++ b/src/domain/analysisView/environmentStateWriteCreateCalls.ts @@ -192,11 +192,6 @@ export const mergedCreatePlan = ( ? undefined : joinedDefaults(plan.body.defaults ?? [], defaults); if (body === undefined || joined === undefined) return undefined; - // A member a call left out stores its default, which a value sent empty elsewhere does not override. - const { blank: sentBlank = [], ...rest } = body; - const blank = sentBlank.filter( - ({ member }) => !joined.some((entry) => entry.member === member), - ); // The plan's rules keep their order; rules the later call adds follow. return { rules: [ @@ -206,38 +201,22 @@ export const mergedCreatePlan = ( }), ...[...merged.values()].filter((rule) => !held.has(rule.field)), ], - body: { - ...rest, - ...(joined.length === 0 ? {} : { defaults: joined }), - ...(blank.length === 0 ? {} : { blank }), - }, + body: joined.length === 0 ? body : { ...body, defaults: joined }, }; }; -/** - * Members both calls sent and either sent empty, with each empty value sent, the earliest first; a member only one call - * sent takes its default from the call that left it out instead. - */ +/** Every empty value recorded calls sent a member, the earliest first. */ const joinedBlanks = ( held: readonly Blank[], added: readonly Blank[], - calls: { - readonly sentBefore: ReadonlySet; - readonly later: JsonObject; - }, ): Blank[] => { - const blank: Blank[] = []; - const members = new Set(held.map(({ member }) => member)); - for (const member of members) { - if (!Object.hasOwn(calls.later, member)) continue; - const entries = held.filter((entry) => entry.member === member); - const sent = calls.later[member]!; - if (isBlankValue(sent) && !entries.some((entry) => same(entry.sent, sent))) - entries.push({ member, sent }); - blank.push(...entries); - } + const blank = [...held]; for (const entry of added) - if (calls.sentBefore.has(entry.member) && !members.has(entry.member)) + if ( + !blank.some( + ({ member, sent }) => member === entry.member && same(sent, entry.sent), + ) + ) blank.push(entry); return blank; }; @@ -267,13 +246,9 @@ const mergedBody = ( const added = contract.accepted.filter( (member) => !body.accepted.includes(member), ); - const blank = joinedBlanks(body.blank ?? [], contract.blank ?? [], { - sentBefore: new Set([...body.accepted, ...fixed.keys()]), - later: later.body, - }); - const { blank: _blank, ...rest } = body; + const blank = joinedBlanks(body.blank ?? [], contract.blank ?? []); return { - ...rest, + ...body, accepted: [...body.accepted, ...added], fixed: [ ...body.fixed, diff --git a/src/domain/analysisView/environmentStateWriteExplain.ts b/src/domain/analysisView/environmentStateWriteExplain.ts index d30eb7b43d..4f854ebc9f 100644 --- a/src/domain/analysisView/environmentStateWriteExplain.ts +++ b/src/domain/analysisView/environmentStateWriteExplain.ts @@ -680,6 +680,41 @@ export const insertIndex = ( return index < 0 ? members.length : index; }; +/** + * A sorted collection's members after writes move each entity whose sorted field changed to where its new value sorts, + * one after another, as replay applies them; other written entities keep their places. + */ +export const resortedMembers = < + Entity extends { readonly id: string; readonly fields: JsonObject }, +>( + rule: Extract, + members: readonly Entity[], + writes: readonly { readonly before: Entity; readonly after: Entity }[], +): Entity[] => { + const order = [...members]; + for (const { before, after } of writes) { + const at = order.findIndex((member) => member.id === before.id); + if (at < 0) continue; + order.splice(at, 1); + const moved = !same( + ownMember(before.fields, rule.field), + ownMember(after.fields, rule.field), + ); + order.splice( + moved + ? insertIndex( + rule, + order.map((member) => member.fields), + after.fields, + ) + : at, + 0, + after, + ); + } + return order; +}; + /** The object in a document value, at the root or in one member, that holds most of a body's members. */ export const documentPointerOf = ( value: JsonValue, diff --git a/tests/fixtures/environmentStateFormWrites.ts b/tests/fixtures/environmentStateFormWrites.ts index 250dc8d0ee..66c8bebc9e 100644 --- a/tests/fixtures/environmentStateFormWrites.ts +++ b/tests/fixtures/environmentStateFormWrites.ts @@ -256,3 +256,68 @@ export const MINIMAL_CONTACT_ROWS: readonly TimedCaptureRow[] = (() => { ["GET", "/api/contacts", [seed, full, minimal]], ]; })(); + +/** The form label creates, then a bulk rename naming each label and its new name in an array, read back. */ +export const BULK_RENAMED_LABEL_ROWS: readonly TimedCaptureRow[] = (() => { + const rows = formLabelRows(); + const [attic, garden] = [ + label(4, "Attic", { color: "#00aa44" }), + label(3, "Garden", { description: "Seeds" }), + ]; + const zinc = { ...KITCHEN, name: "Zinc" }; + return [ + ...rows, + ["PATCH", "/api/labels", [zinc], 200, [{ id: 1, name: "Zinc" }]], + ["GET", "/api/labels/1", zinc], + ["GET", "/api/labels", [attic, garden, TOOLS, zinc]], + ]; +})(); + +/** Notes created with text, then sent `null`, then left out and stored `""`, each read back. */ +export const NOTE_DEFAULT_ROWS: readonly TimedCaptureRow[] = (() => { + const note = (id: number, name: string, text: string | null) => ({ + id, + name, + note: text, + }); + const seed = note(1, "Seed", "kept"); + const first = note(2, "First", "text"); + const second = note(3, "Second", null); + const third = note(4, "Third", ""); + return [ + ["GET", "/api/notes", [seed]], + ["GET", "/api/notes/1", seed], + ["POST", "/api/notes", first, 201, { name: "First", note: "text" }], + ["GET", "/api/notes/2", first], + ["GET", "/api/notes", [seed, first]], + ["POST", "/api/notes", second, 201, { name: "Second", note: null }], + ["GET", "/api/notes/3", second], + ["GET", "/api/notes", [seed, first, second]], + ["POST", "/api/notes", third, 201, { name: "Third" }], + ["GET", "/api/notes/4", third], + ["GET", "/api/notes", [seed, first, second, third]], + ]; +})(); + +/** + * Two seeded folders and two creates whose ids and titles both ascend, so either could be the order kept, then a + * rename that sorts first by title. + */ +export const ID_OR_TITLE_ROWS: readonly TimedCaptureRow[] = (() => { + const [alpha, bravo] = [folder(1, "Alpha"), folder(2, "Bravo")]; + const [charlie, delta] = [folder(3, "Charlie"), folder(4, "Delta")]; + const renamed = folder(4, "Aardvark", 2); + return [ + ["GET", "/api/folders", [alpha, bravo]], + ["GET", "/api/folders/1", alpha], + ["POST", "/api/folders", charlie, 200, { title: "Charlie" }], + ["GET", "/api/folders/3", charlie], + ["GET", "/api/folders", [alpha, bravo, charlie]], + ["POST", "/api/folders", delta, 200, { title: "Delta" }], + ["GET", "/api/folders/4", delta], + ["GET", "/api/folders", [alpha, bravo, charlie, delta]], + ["PUT", "/api/folders/4", renamed, 200, { title: "Aardvark", version: 1 }], + ["GET", "/api/folders/4", renamed], + ["GET", "/api/folders", [renamed, alpha, bravo, charlie]], + ]; +})();