diff --git a/src/application/environment/environmentStateAnswer.ts b/src/application/environment/environmentStateAnswer.ts index 4e44dc58e5..16079fe9fe 100644 --- a/src/application/environment/environmentStateAnswer.ts +++ b/src/application/environment/environmentStateAnswer.ts @@ -1,7 +1,9 @@ import { stateBodyJson } from "../../domain/analysisView/EnvironmentStateAccumulator.js"; import { collectionPathOf, + allLines, listItems, + listsAll, pagesOnly, renderStateEntity, renderStateList, @@ -422,17 +424,17 @@ const answerList = ( return fromRecording(source, tool, args, "body-unusable"); const collection = collectionPathOf(base, false); const type = store.type(view.type); - const members: readonly StateEntity[] = - view.membership === "all" - ? store.inCollection(view.type, collection) - : recorded.flatMap((item) => { - const id = scalarText(item[type?.id_field ?? ""]); - const entity = - id === undefined - ? undefined - : store.entity(view.type, collection, id); - return entity === undefined ? [] : [entity]; - }); + const all = listsAll(view, base.ordinal); + const members: readonly StateEntity[] = all + ? store.inCollection(view.type, collection) + : recorded.flatMap((item) => { + const id = scalarText(item[type?.id_field ?? ""]); + const entity = + id === undefined + ? undefined + : store.entity(view.type, collection, id); + return entity === undefined ? [] : [entity]; + }); const volatile = new Set(type?.volatile_fields); const items: JsonObject[] = []; for (const entity of members) { @@ -449,7 +451,7 @@ const answerList = ( view: "list", type: view.type, collection, - membership: view.membership, + membership: all ? "all" : "recorded", listed: items.length, }, ); @@ -486,8 +488,8 @@ const answerCheckedLine = ( ); const base = named ?? - (view.shape === "list" && view.membership === "all" - ? unfilteredLine(serving.source, tool, view.checked_record_ordinals, args) + (view.shape === "list" + ? unfilteredLine(serving.source, tool, allLines(view), args) : undefined); if (base === undefined) return fromRecording(serving.source, tool, args, "unrecorded-request"); diff --git a/src/domain/analysisView/EnvironmentStateWriteSimulation.ts b/src/domain/analysisView/EnvironmentStateWriteSimulation.ts index c2755a5d4f..5fd587e45b 100644 --- a/src/domain/analysisView/EnvironmentStateWriteSimulation.ts +++ b/src/domain/analysisView/EnvironmentStateWriteSimulation.ts @@ -5,6 +5,7 @@ import { isObject, isStateWrite, lastSegmentValue, + listsAll, normalizedPath, type StateDocument, type StateEntity, @@ -336,7 +337,10 @@ export class EnvironmentStateWriteSimulation { }; return item ? checkItemRead(this.#state, view, record, check) - : checkListRead(this.#state, view, record, check); + : checkListRead(this.#state, view, record, { + ...check, + all: listsAll(view, base), + }); }); } @@ -473,8 +477,9 @@ export class EnvironmentStateWriteSimulation { record: StateRecordFacts, exercised: ReadonlySet, ): boolean { - const collection = this.#line(view, record)?.collection; - if (view.membership !== "all" || collection === undefined) return false; + const line = this.#line(view, record); + if (line === undefined || !listsAll(view, line.ordinal)) return false; + const { collection } = line; const members = this.#state.members(view.type, collection); const key = (entity: StateEntity) => entityTarget(entity.type, entity.collection, entity.id); diff --git a/src/domain/analysisView/environmentStateGate.ts b/src/domain/analysisView/environmentStateGate.ts index c1ba4a7bb3..530de7f165 100644 --- a/src/domain/analysisView/environmentStateGate.ts +++ b/src/domain/analysisView/environmentStateGate.ts @@ -159,14 +159,14 @@ export const checkItemPlan = ( }; }; -/** Whether every latest recording lists its whole collection in state order, and which counters equal the item count. */ +/** The latest recordings that list their whole collection in state order, and which counters equal the item count. */ const listFacts = ( context: StateGateContext, plan: StatePlan, union: FieldUnion, -): { readonly all: boolean; readonly counters: string[] } => { +): { readonly whole: ReadonlySet; readonly counters: string[] } => { const field = context.idField(plan.type); - let all = true; + const whole = new Set(); let counters: string[] | undefined; for (const record of plan.latest) context.withBody(record.ordinal, (body) => { @@ -174,29 +174,27 @@ const listFacts = ( body === undefined ? undefined : listItems(body, plan.pointer); if (body === undefined || items === undefined) return; items.forEach((item) => union.add(item)); - // Once a list cannot be the whole collection, its membership is not compared again; a line - // that only pages its list can still hold all of it. - if (all && pagesOnly(record.query)) { - const ids = items.map((item) => scalarText(item[field]) ?? ""); - const expected = context - .inCollection(plan.type, collectionPathOf(record, false)) - .map((entity) => entity.id); - if (!same(ids, expected)) all = false; - } else all = false; const parent = plan.pointer === "" || !isObject(body) ? {} : body; const counted = Object.keys(parent).filter( (name) => isItemTotal(name) && countOf(parent[name]) !== undefined, ); - // A total that differs from the number listed, or a next page, shows the list is partial. + const names = counted.filter((name) => parent[name] === items.length); + counters = (counters ?? names).filter((name) => names.includes(name)); + // Only a line that pages at most can hold its whole collection; a total that differs from + // the number listed, or a next page, shows it is partial. if ( + !pagesOnly(record.query) || counted.some((name) => countOf(parent[name]) !== items.length) || Object.keys(parent).some((name) => continues(name, parent[name])) ) - all = false; - const names = counted.filter((name) => parent[name] === items.length); - counters = (counters ?? names).filter((name) => names.includes(name)); + return; + const ids = items.map((item) => scalarText(item[field]) ?? ""); + const expected = context + .inCollection(plan.type, collectionPathOf(record, false)) + .map((entity) => entity.id); + if (same(ids, expected)) whole.add(record.ordinal); }); - return { all, counters: (counters ?? []).sort(compare) }; + return { whole, counters: (counters ?? []).sort(compare) }; }; const nameTokens = (name: string): string[] => @@ -204,17 +202,21 @@ const nameTokens = (name: string): string[] => .split(/[_\-\s]|(?<=[a-z0-9])(?=[A-Z])/u) .map((token) => token.toLowerCase()); -/** Item totals, including compact `totalcount`/`itemcount`; page counts and account IDs are excluded. */ +const LISTED_NOUNS = new Set(["elements", "entries", "items", "results"]); + +/** Item totals, including compact `totalcount`/`itemcount` and `numberOfElements`; page counts and account IDs are excluded. */ const isItemTotal = (name: string): boolean => { const tokens = nameTokens(name); return ( - tokens.some( + (tokens.some( (token) => token === "total" || token === "count" || token === "totalcount" || token === "itemcount", - ) && + ) || + (tokens.includes("number") && + tokens.some((token) => LISTED_NOUNS.has(token)))) && !tokens.some( (token) => token === "page" || token === "pages" || token === "per", ) @@ -255,7 +257,7 @@ const listReproduces = ( readonly fields: string[]; readonly pointer: string; readonly counters: string[]; - readonly all: boolean; + readonly whole: ReadonlySet; }, record: StateRecordFacts, ): boolean => { @@ -267,7 +269,7 @@ const listReproduces = ( const observed = new Map( items.map((item) => [scalarText(item[field]) ?? "", item]), ); - const members = view.all + const members = view.whole.has(record.ordinal) ? context.inCollection(plan.type, collectionPathOf(record, false)) : items.flatMap((item) => { const entity = context.entity( @@ -295,15 +297,16 @@ export const checkListPlan = ( plan: StatePlan, ): StateView => { const union = new FieldUnion(); - const { all, counters } = listFacts(context, plan, union); + const { whole, counters } = listFacts(context, plan, union); context.retain(union.bytes); const view = { tool: plan.tool.name, fields: union.fields, pointer: plan.pointer, counters, - all, + whole, }; + const all = plan.latest.every((record) => whole.has(record.ordinal)); if ( !plan.latest.every((record) => listReproduces(context, plan, view, record)) ) @@ -316,6 +319,13 @@ export const checkListPlan = ( fields: union.fields, required_fields: union.required, membership: all ? "all" : "recorded", + ...(all || whole.size === 0 + ? {} + : { + all_record_ordinals: plan.latest + .map((record) => record.ordinal) + .filter((ordinal) => whole.has(ordinal)), + }), counters, checked_record_ordinals: plan.latest.map((record) => record.ordinal), }; diff --git a/src/domain/analysisView/environmentStateRules.ts b/src/domain/analysisView/environmentStateRules.ts index 160c117c7b..a989607669 100644 --- a/src/domain/analysisView/environmentStateRules.ts +++ b/src/domain/analysisView/environmentStateRules.ts @@ -101,9 +101,22 @@ export type StateItemView = EntityViewPlan & { export type StateListView = EntityViewPlan & { readonly shape: "list"; readonly membership: "all" | "recorded"; + /** With recorded membership, the checked lines that each list their whole collection. */ + readonly all_record_ordinals?: number[] | undefined; readonly counters: string[]; }; +/** Whether a list view answers on a checked line with every entity of the collection. */ +export const listsAll = (view: StateListView, ordinal: number): boolean => + view.membership === "all" || + (view.all_record_ordinals?.includes(ordinal) ?? false); + +/** The checked lines a list view answers with every entity of the collection. */ +export const allLines = (view: StateListView): readonly number[] => + view.membership === "all" + ? view.checked_record_ordinals + : (view.all_record_ordinals ?? []); + export type StateView = | StateItemView | StateListView diff --git a/src/domain/analysisView/environmentStateView.ts b/src/domain/analysisView/environmentStateView.ts index d9dd78d9f3..ecc3ec53ad 100644 --- a/src/domain/analysisView/environmentStateView.ts +++ b/src/domain/analysisView/environmentStateView.ts @@ -58,7 +58,13 @@ const viewSchema = z.discriminatedUnion("shape", [ membership: z .enum(["all", "recorded"]) .describe( - "all: every entity of the collection in state order; recorded: the entities each recorded response listed.", + "all: every entity of the collection in state order; recorded: the entities each recorded response listed, except on the lines all_record_ordinals names.", + ), + all_record_ordinals: z + .array(z.number().int().nonnegative()) + .optional() + .describe( + "With recorded membership, the checked lines that each listed every entity of their collection in state order, with only paging parameters; a call answered on one lists the whole collection. Absent when none does.", ), counters: z .array(z.string()) diff --git a/src/domain/analysisView/environmentStateWriteChecks.ts b/src/domain/analysisView/environmentStateWriteChecks.ts index ea26974b24..1c228a60c4 100644 --- a/src/domain/analysisView/environmentStateWriteChecks.ts +++ b/src/domain/analysisView/environmentStateWriteChecks.ts @@ -333,12 +333,17 @@ const checkRecordedList = ( return { pass: listCheck(view, check, members), targets, unheld }; }; -/** A list read on a checked line, compared with the answer the served environment renders on that line's base recording from the collection it names. */ +/** A list read on a checked line, compared with the answer the served environment renders on that line's base recording from the collection it names: every entity when that line lists them all. */ export const checkListRead = ( state: SimulatedState, view: StateListView, record: StateRecordFacts, - check: ReadBodies & { readonly idField: string; readonly collection: string }, + check: ReadBodies & { + readonly idField: string; + readonly collection: string; + /** Whether the base line lists every entity of its collection. */ + readonly all: boolean; + }, ): CheckOutcome | undefined => { if (!succeeded(record)) return undefined; const { body, base } = check; @@ -353,7 +358,7 @@ export const checkListRead = ( ) return undefined; const bodies = { ...check, body, base, items, baseItems }; - return view.membership === "all" + return check.all ? checkAllList(state, view, bodies) : checkRecordedList(state, view, bodies); };