mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 07:16:13 +00:00
feat(environment): decide a list's whole-collection membership per line
- A list view was `all` only when every checked line listed its whole collection, so one filtered line kept every line to its recorded members and created entities never appeared. Each checked line that only pages and lists its whole collection in state order is now named in `all_record_ordinals` on a recorded-membership view, and answers, is checked and places creates as an `all` line does. - `numberOfElements` and similar number-of-items members count the listed entities. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f2b95351d4
commit
54d3d5e65a
6 files changed
+86
-45
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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<string>,
|
||||
): 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);
|
||||
|
||||
@@ -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<number>; readonly counters: string[] } => {
|
||||
const field = context.idField(plan.type);
|
||||
let all = true;
|
||||
const whole = new Set<number>();
|
||||
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<number>;
|
||||
},
|
||||
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),
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user