* feat(environment): serve the writes callers make without recorded form fields
- A create member a recording sent empty ("", null, false, [] or {}) is no
longer required; a call that leaves it out is applied as sending that value
(body.blank).
- List views render envelope members that list the listed entities' ids
(Paperless's `all`), so creates in such collections are confirmed.
- Updates move their entity in collections kept sorted by a field they write,
and a failed whole-collection read after an update can teach that order.
- A later create that stores an unruled field otherwise is withheld at the
create, and an item read may fill values a create's answer had not loaded.
- An unsent field respelled between null, [] and {} is unchanged.
- The skill asks for one create with every field and one with only what a
person must give.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs(skill): show how to read signed-in pages before recording
Agents guessed capture keys (artifacts, dom, final) to see pages behind
sign-in, which inspect_web_page cannot reach.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(environment): accept every empty spelling recorded creates sent
A create that fills only what a person must give sends `null` where the
full create sent a string, and `null` or `false` for the same flag. The
merged plan now accepts each recorded empty value, and a call that leaves
the member out is applied as the earliest call sent it.
Also adds the spec folder for this change.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs(skill): keep the vary-fields step neutral on UI or API writes
Agents read "the form" as a cue to write through the UI, where some
applications' request bodies are not exposed; a body-unusable UI write
now re-records once with request steps.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs(environment): record verification for blank members and sorted writes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(environment): address review of blank members and sorted writes
- Bulk updates move their entities in turn against the others' earlier
values and install the final order once, as replay does.
- A member's recorded empty values stay accepted once a later call teaches
its default; a left-out member with a default keeps the default.
- Write answers spell empty lists and maps as their recorded answers did.
- A rename can teach a sort when the creates chose one by a field the
update did not write, such as ids that ascend with titles.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* feat(environment): serve the writes that later reads explain
- An item read whose object is marked deleted (deleted, isDeleted or
is_deleted true) agrees with an absent entity, as EspoCRM answers a
deleted record; such an object never seeds or adopts an entity.
- maxSize, max_results and maxResults are page sizes, for whole-collection
list lines and the list query's limit role.
- An item view also shows the fields only reads after its type's first write
show, when no initial entity holds them (Vikunja's task subscription),
without requiring them; write answers leave such a field out for entities
without it, and it no longer withholds the write as incomplete-entities.
- The skill asks updates to change the fields a person would change, and
expects a deleted object to read back as 404 or marked deleted.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(environment): answer sent empty members as sent, and keep a placed create's order
- A write answer spells an empty list or map the call did not send as its
recorded answer did; one the call sent is answered as sent, so a create
sending `[]` is not answered `null` because the first recorded create was.
- A read listing the whole collection that placed a create leaves the create
only the sorted inserts whose order that read follows, so a later rename
cannot teach a sort an earlier read ruled out.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(environment): answer bulk updates without fields only later reads show
A bulk update's answer renders each entity with the same allowance a single
update's answer has: a field only reads after the type's first write showed,
which no initial entity holds, is left out for an entity without it, so a
bulk update of a seeded task is no longer refused for lacking it.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs(environment): correct how the EspoCRM contact update task passes
The agent trajectories show gpt-oss passes it by creating a replacement contact
and deleting the original, since the served update refuses emailAddress; the
update itself is refused in both builds.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(environment): answer a read of a served-deleted entity marked deleted
An application that soft-deletes still answers a deleted record by id, marked
deleted. Serving now keeps an entity a served delete removed from a type whose
recorded item reads show that, until a reset or load and within the size
data/add loads, and answers a read of it with its last fields and the flag
true, instead of refusing the read.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(environment): keep unloaded create answers, check bulk answers with sent members, narrow inserts only on collection-order reads
- A create whose item read loaded values its answer had not records them
(`observation.read.unloaded`), and a served create's answer keeps them as
the recorded answer showed them, a zero-time owner included, while the state
holds the read's.
- Replay checks a bulk update's recorded answers with each target's sent
members, as serving answers them, so echoed `null` and `[]` no longer make
the plan body-unusable.
- A whole-collection read whose query sorts it shows the query's order, so it
no longer narrows a create's sorted insert candidates.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs(environment): record the census with main's unknown-clock rule merged
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(environment): learn a sort from a rename only when moving its entities reaches it
Serving moves only the entities a write changes in a collection kept sorted,
so replay now tries a sort after a rename only when the entities no write the
read exercised changed already follow it. Seeded folders listed out of title
order no longer teach a title sort that serving would answer with another
order.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(environment): keep unloaded create answers a sent value did not echo, and learn no sort from query-sorted reads
- A served create keeps a value its recorded answer had not loaded unless the
call sent that member and the plan stores it as sent, so a create sending
`active: false` is answered `false` as recorded while the state holds the
read's `true`.
- A rename no longer teaches a sort from a whole-collection read its query
sorts, whose ties need not follow the collection's own order.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(environment): learn the soft-delete flag in the state view and serve by it
Extraction now records the member later item reads set true on a record the
application soft-deleted, and the item view carries it as `deleted_flag`.
Serving reads that fact instead of scanning recorded bodies itself, so one
owner decides what a soft delete looks like.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(environment): learn the soft-delete flag from item reads with a query too
A client may read a deleted record back only with a query, such as a
cache-busting parameter. Every successful read of the item tool now shows the
flag; only plain later reads still add fields.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(environment): learn sorts only from orders a list read confirmed
- A sort retry after a failed whole-collection read requires the entities no
write changed since a list last showed them to follow it already, rather than
excusing every entity the update's tool ever wrote: an earlier rename a list
confirmed in place stays where it was.
- An update takes its creates' sort only from a create a list read showed
placed; replay still tries a sort a create guessed from the initial order.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(environment): drop entities read back as deleted and answer soft deletes as deleted
- A held entity a later read shows marked deleted is dropped, not adopted with
the marked object's fields.
- Bulk-update replay renders answers with the optional and later-only fields
serving uses, so a bulk update of created and seeded entities replays.
- A served delete of a soft-deleting type answers with the entity marked
deleted, single and bulk.
- A deleted flag only query reads showed is a field of the item view, so a
served tombstone keeps it where live records omit it.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(environment): move updated entities only by placed sorts and keep query-sorted reads from confirming order
Replay moved an updated entity by a sort a create only guessed, while the
update plan exported only placed sorts, so a read could confirm an update
by a move serving would not make. Both now use the placed sort. A passing
list read clears its entities from the moved set only when the state
decides the order of every entity it lists, so a read whose query sorts
it no longer hides an earlier move from a later sort check.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(environment): try a learned sort by moving each pending write in turn, as serving does
#retrySort sorted the whole collection, which a list could match while
serving, moving each renamed entity in turn against the others' values
before their own writes, gave another order. The state now records the
values each write replaced, and the retry replays the writes since a
list in collection order last showed their entities.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(environment): answer a served delete with the deleted flag its recorded answer showed
An application may answer a delete with the record as it was and mark it
deleted only on later reads. Delete answers, single and bulk, now take
the flag's value from the recorded answer; reads of a served-deleted
entity still show it marked deleted.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* chore(environment): set aside two hunks next to lines main's saved-values change rewrites
Main's #7267 changes the lines beside the blank-member docs and the
empty-spelling check in explainUpdate. Setting them aside lets that merge
apply cleanly; the commit after it puts them back on top of main's text.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* chore(environment): restore the set-aside hunks on main and bump the skill to 51
Puts the blank-member docs and the empty-spelling check back beside main's
slug and composed text and heldSlugRule. Main is at skill 50 with
different text, so this branch's skill changes take 51.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* chore(environment): bump the skill to 52 above #7260's 51
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(environment): keep sorts a list showed in place ruled out, and created answers' nested members as recorded
A whole list in collection order that showed a renamed entity where a
sort by the renamed field would have moved it now rules that sort out, so
a later rename of the same entity cannot teach it. A create's answer
leaves out members its item read added inside an object the answer
showed, as the recorded answer did; the state still stores them.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(environment): compare soft-deleted read fields
* fix(environment): preserve initially deleted item observations
* fix(environment): index stored deleted entities separately
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: morluto <[email protected]>
653 lines
23 KiB
TypeScript
653 lines
23 KiB
TypeScript
import type { JsonValue } from "../../src/domain/jsonValue.js";
|
|
import type { TimedCaptureRow } from "./analysisView.js";
|
|
import { bookClock } from "./environmentStateWrites.js";
|
|
|
|
type Entity = Record<string, JsonValue>;
|
|
|
|
const label = (
|
|
id: number,
|
|
name: string,
|
|
fields: { readonly description?: string; readonly color?: string } = {},
|
|
): Entity => ({
|
|
id,
|
|
name,
|
|
description: fields.description ?? "",
|
|
color: fields.color ?? "",
|
|
icon: "",
|
|
});
|
|
|
|
const KITCHEN = label(1, "Kitchen", { description: "Pots", color: "#aa3300" });
|
|
const TOOLS = label(2, "Tools");
|
|
|
|
/** What a label form sends: every member, those left as they were empty. */
|
|
const formBody = (entity: Entity): Entity => {
|
|
const { id: _id, ...sent } = entity;
|
|
return sent;
|
|
};
|
|
|
|
/** Two label creates through a form that sends every member, the members left empty differing between them, in a collection kept sorted by name. */
|
|
export const formLabelRows = (
|
|
icons: readonly [JsonValue, JsonValue] = ["", ""],
|
|
): TimedCaptureRow[] => {
|
|
const garden = label(3, "Garden", { description: "Seeds" });
|
|
const attic = label(4, "Attic", { color: "#00aa44" });
|
|
return [
|
|
["GET", "/api/labels", [KITCHEN, TOOLS]],
|
|
["GET", "/api/labels/1", KITCHEN],
|
|
["GET", "/api/labels/2", TOOLS],
|
|
[
|
|
"POST",
|
|
"/api/labels",
|
|
garden,
|
|
201,
|
|
{ ...formBody(garden), icon: icons[0] },
|
|
],
|
|
["GET", "/api/labels/3", garden],
|
|
["GET", "/api/labels", [garden, KITCHEN, TOOLS]],
|
|
["POST", "/api/labels", attic, 201, { ...formBody(attic), icon: icons[1] }],
|
|
["GET", "/api/labels/4", attic],
|
|
["GET", "/api/labels", [attic, garden, KITCHEN, TOOLS]],
|
|
];
|
|
};
|
|
|
|
/** The form label creates, then a rename that moves a label to the end of the name order, read back. */
|
|
export const RENAMED_LABEL_ROWS: readonly TimedCaptureRow[] = (() => {
|
|
const rows = formLabelRows();
|
|
const zinc = { ...KITCHEN, name: "Zinc" };
|
|
const [attic, garden] = [
|
|
label(4, "Attic", { color: "#00aa44" }),
|
|
label(3, "Garden", { description: "Seeds" }),
|
|
];
|
|
return [
|
|
...rows,
|
|
["PATCH", "/api/labels/1", zinc, 200, { name: "Zinc" }],
|
|
["GET", "/api/labels/1", zinc],
|
|
["GET", "/api/labels", [attic, garden, TOOLS, zinc]],
|
|
];
|
|
})();
|
|
|
|
const page = (results: readonly Entity[]): Entity => ({
|
|
count: results.length,
|
|
next: null,
|
|
previous: null,
|
|
all: results.map((entity) => entity["id"]!),
|
|
results: [...results],
|
|
});
|
|
|
|
/** Tag creates whose list answers carry `all`, the listed ids in list order, as Paperless-ngx's do. */
|
|
export const LISTED_ID_ROWS: readonly TimedCaptureRow[] = (() => {
|
|
const harbour = label(3, "Harbour");
|
|
const quarry = label(4, "Quarry");
|
|
return [
|
|
["GET", "/api/tags/", page([KITCHEN, TOOLS])],
|
|
["GET", "/api/tags/1/", KITCHEN],
|
|
["POST", "/api/tags/", harbour, 201, { name: "Harbour" }],
|
|
["GET", "/api/tags/3/", harbour],
|
|
["GET", "/api/tags/", page([harbour, KITCHEN, TOOLS])],
|
|
["POST", "/api/tags/", quarry, 201, { name: "Quarry" }],
|
|
["GET", "/api/tags/4/", quarry],
|
|
["GET", "/api/tags/", page([harbour, KITCHEN, quarry, TOOLS])],
|
|
];
|
|
})();
|
|
|
|
const folder = (id: number, title: string, version = 1): Entity => ({
|
|
id,
|
|
title,
|
|
version,
|
|
});
|
|
|
|
/**
|
|
* One seeded folder, two creates that sort after it, and a rename that sorts first, as Grafana lists folders by
|
|
* title: no read before the rename shows whether the folders are kept by title or in creation order.
|
|
*/
|
|
export const TITLE_ORDER_ROWS: readonly TimedCaptureRow[] = (() => {
|
|
const rota = folder(1, "Lighthouse Rota");
|
|
const alpha = folder(2, "Scratch Alpha");
|
|
const beta = folder(3, "Scratch Beta");
|
|
const renamed = folder(3, "Beta Renamed", 2);
|
|
return [
|
|
["GET", "/api/folders", [rota]],
|
|
["GET", "/api/folders/1", rota],
|
|
["POST", "/api/folders", alpha, 200, { title: "Scratch Alpha" }],
|
|
["GET", "/api/folders/2", alpha],
|
|
["GET", "/api/folders", [rota, alpha]],
|
|
["POST", "/api/folders", beta, 200, { title: "Scratch Beta" }],
|
|
["GET", "/api/folders/3", beta],
|
|
["GET", "/api/folders", [rota, alpha, beta]],
|
|
[
|
|
"PUT",
|
|
"/api/folders/3",
|
|
renamed,
|
|
200,
|
|
{ title: "Beta Renamed", version: 1 },
|
|
],
|
|
["GET", "/api/folders/3", renamed],
|
|
["GET", "/api/folders", [renamed, rota, alpha]],
|
|
];
|
|
})();
|
|
|
|
const OWNER = {
|
|
id: 1,
|
|
name: "Rea Admin",
|
|
created: "2026-10-01T09:00:00Z",
|
|
};
|
|
|
|
/** A create answer before the application loads what it stored: the owner's unset values and an empty list spelled `[]` where reads spell `null`. */
|
|
const unloaded = (entity: Entity, at: number): Entity => ({
|
|
...entity,
|
|
assignees: [],
|
|
owner: { id: 1, name: "", created: "0001-01-01T00:00:00Z" },
|
|
created: bookClock(at).replace("Z", ".123456789Z"),
|
|
});
|
|
|
|
const task = (id: number, title: string, at: number): Entity => ({
|
|
id,
|
|
title,
|
|
done: false,
|
|
assignees: null,
|
|
owner: OWNER,
|
|
created: bookClock(at),
|
|
});
|
|
|
|
/** Task creates whose answers show the owner and assignees unloaded, then a done flag whose answer spells empty assignees `[]`. */
|
|
export const UNLOADED_ANSWER_ROWS: readonly TimedCaptureRow[] = (() => {
|
|
const seed = task(1, "Trim the wicks", 0);
|
|
const first = task(2, "Order lamp oil", 2);
|
|
const second = task(3, "Paint the rail", 5);
|
|
const done = { ...first, done: true };
|
|
return [
|
|
["GET", "/api/tasks", [seed]],
|
|
["GET", "/api/tasks/1", seed],
|
|
[
|
|
"POST",
|
|
"/api/tasks",
|
|
unloaded(first, 2),
|
|
201,
|
|
{ title: "Order lamp oil" },
|
|
],
|
|
["GET", "/api/tasks/2", first],
|
|
["GET", "/api/tasks", [seed, first]],
|
|
[
|
|
"POST",
|
|
"/api/tasks",
|
|
unloaded(second, 5),
|
|
201,
|
|
{ title: "Paint the rail" },
|
|
],
|
|
["GET", "/api/tasks/3", second],
|
|
["GET", "/api/tasks", [seed, first, second]],
|
|
["PATCH", "/api/tasks/2", { ...done, assignees: [] }, 200, { done: true }],
|
|
["GET", "/api/tasks/2", done],
|
|
["GET", "/api/tasks", [seed, done, second]],
|
|
];
|
|
})();
|
|
|
|
/** Task creates whose answers show the owner by id alone, which their item reads show in full. */
|
|
export const PARTIAL_OWNER_ROWS: readonly TimedCaptureRow[] = (() => {
|
|
const seed = task(1, "Trim the wicks", 0);
|
|
const first = task(2, "Order lamp oil", 2);
|
|
const second = task(3, "Paint the rail", 5);
|
|
const answered = (entity: Entity): Entity => ({
|
|
...entity,
|
|
owner: { id: 1 },
|
|
});
|
|
return [
|
|
["GET", "/api/tasks", [seed]],
|
|
["GET", "/api/tasks/1", seed],
|
|
["POST", "/api/tasks", answered(first), 201, { title: "Order lamp oil" }],
|
|
["GET", "/api/tasks/2", first],
|
|
["GET", "/api/tasks", [seed, first]],
|
|
["POST", "/api/tasks", answered(second), 201, { title: "Paint the rail" }],
|
|
["GET", "/api/tasks/3", second],
|
|
["GET", "/api/tasks", [seed, first, second]],
|
|
];
|
|
})();
|
|
|
|
/** A tag as the application stores it: a text color it derives from the color, which no rule rebuilds. */
|
|
const painted = (id: number, name: string, color: string, text: string) => ({
|
|
id,
|
|
name,
|
|
color,
|
|
text_color: text,
|
|
});
|
|
|
|
/** Two tag creates whose derived text colors differ, then deletes of both, each read back as gone. */
|
|
export const DERIVED_TEXT_ROWS: readonly TimedCaptureRow[] = (() => {
|
|
const seed = painted(1, "Inbox", "#2a9d8f", "#ffffff");
|
|
const light = painted(2, "Light", "#a8dadc", "#000000");
|
|
const dark = painted(3, "Dark", "#264653", "#ffffff");
|
|
const { id: _light, text_color: _lightText, ...lightSent } = light;
|
|
const { id: _dark, text_color: _darkText, ...darkSent } = dark;
|
|
return [
|
|
["GET", "/api/tags", [seed]],
|
|
["GET", "/api/tags/1", seed],
|
|
["POST", "/api/tags", light, 201, lightSent],
|
|
["GET", "/api/tags/2", light],
|
|
["GET", "/api/tags", [seed, light]],
|
|
["POST", "/api/tags", dark, 201, darkSent],
|
|
["GET", "/api/tags/3", dark],
|
|
["GET", "/api/tags", [seed, light, dark]],
|
|
["DELETE", "/api/tags/2", "", 204],
|
|
["GET", "/api/tags/2", { detail: "Not found." }, 404],
|
|
["DELETE", "/api/tags/3", "", 204],
|
|
["GET", "/api/tags/3", { detail: "Not found." }, 404],
|
|
["GET", "/api/tags", [seed]],
|
|
];
|
|
})();
|
|
|
|
const contact = (
|
|
id: number,
|
|
fields: {
|
|
readonly firstName: string | null;
|
|
readonly lastName: string;
|
|
readonly email: string | null;
|
|
},
|
|
): Entity => ({
|
|
id,
|
|
...fields,
|
|
emailOptedOut: fields.email === null ? null : false,
|
|
});
|
|
|
|
/** A contact form's full create, then one with only the last name, whose empty members store as sent: `null` where the first stored a string, and an opt-out flag `null` where it stored `false`. */
|
|
export const MINIMAL_CONTACT_ROWS: readonly TimedCaptureRow[] = (() => {
|
|
const seed = contact(1, {
|
|
firstName: "Ada",
|
|
lastName: "Finch",
|
|
email: "[email protected]",
|
|
});
|
|
const full = contact(2, {
|
|
firstName: "Tomas",
|
|
lastName: "Vey",
|
|
email: "[email protected]",
|
|
});
|
|
const minimal = contact(3, {
|
|
firstName: null,
|
|
lastName: "Okafor",
|
|
email: null,
|
|
});
|
|
const sent = ({ id: _id, ...members }: Entity) => members;
|
|
return [
|
|
["GET", "/api/contacts", [seed]],
|
|
["GET", "/api/contacts/1", seed],
|
|
["POST", "/api/contacts", full, 201, sent(full)],
|
|
["GET", "/api/contacts/2", full],
|
|
["GET", "/api/contacts", [seed, full]],
|
|
["POST", "/api/contacts", minimal, 201, sent(minimal)],
|
|
["GET", "/api/contacts/3", minimal],
|
|
["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]],
|
|
];
|
|
})();
|
|
|
|
/**
|
|
* Two seeded folders and two creates kept in creation order, which a list read after both shows (`Charlie` after
|
|
* `Zulu`), then a rename whose read happens to follow title order.
|
|
*/
|
|
export const CREATION_ORDER_ROWS: readonly TimedCaptureRow[] = (() => {
|
|
const [alpha, bravo] = [folder(1, "Alpha"), folder(2, "Bravo")];
|
|
const [zulu, charlie] = [folder(3, "Zulu"), folder(4, "Charlie")];
|
|
const renamed = folder(3, "Aardvark", 2);
|
|
return [
|
|
["GET", "/api/folders", [alpha, bravo]],
|
|
["GET", "/api/folders/1", alpha],
|
|
["POST", "/api/folders", zulu, 200, { title: "Zulu" }],
|
|
["GET", "/api/folders/3", zulu],
|
|
["GET", "/api/folders", [alpha, bravo, zulu]],
|
|
["POST", "/api/folders", charlie, 200, { title: "Charlie" }],
|
|
["GET", "/api/folders/4", charlie],
|
|
["GET", "/api/folders", [alpha, bravo, zulu, charlie]],
|
|
["PUT", "/api/folders/3", renamed, 200, { title: "Aardvark", version: 1 }],
|
|
["GET", "/api/folders/3", renamed],
|
|
["GET", "/api/folders", [renamed, alpha, bravo, charlie]],
|
|
];
|
|
})();
|
|
|
|
/** Two label creates that send and echo empty tags, one `null` and one `[]`, as an application storing what it is sent does. */
|
|
export const ECHOED_EMPTY_ROWS: readonly TimedCaptureRow[] = (() => {
|
|
const tagged = (entity: Entity, tags: JsonValue): Entity => ({
|
|
...entity,
|
|
tags,
|
|
});
|
|
const [kitchen, tools] = [tagged(KITCHEN, null), tagged(TOOLS, null)];
|
|
const garden = tagged(label(3, "Garden"), null);
|
|
const attic = tagged(label(4, "Attic"), []);
|
|
return [
|
|
["GET", "/api/labels", [kitchen, tools]],
|
|
["GET", "/api/labels/1", kitchen],
|
|
["GET", "/api/labels/2", tools],
|
|
["POST", "/api/labels", garden, 201, formBody(garden)],
|
|
["GET", "/api/labels/3", garden],
|
|
["GET", "/api/labels", [garden, kitchen, tools]],
|
|
["POST", "/api/labels", attic, 201, formBody(attic)],
|
|
["GET", "/api/labels/4", attic],
|
|
["GET", "/api/labels", [attic, garden, kitchen, tools]],
|
|
];
|
|
})();
|
|
|
|
/** Two bulk label renames that send the empty tags each label holds as the form does, `null` on one and `[]` on the other, and echo them. */
|
|
export const BULK_EMPTY_TAG_ROWS: readonly TimedCaptureRow[] = (() => {
|
|
const tagged = (entity: Entity, tags: JsonValue): Entity => ({
|
|
...entity,
|
|
tags,
|
|
});
|
|
const [kitchen, tools] = [tagged(KITCHEN, null), tagged(TOOLS, [])];
|
|
const [larder, shed] = [
|
|
{ ...kitchen, name: "Larder" },
|
|
{ ...tools, name: "Shed" },
|
|
];
|
|
return [
|
|
["GET", "/api/labels", [kitchen, tools]],
|
|
["GET", "/api/labels/1", kitchen],
|
|
["GET", "/api/labels/2", tools],
|
|
[
|
|
"PATCH",
|
|
"/api/labels",
|
|
[larder],
|
|
200,
|
|
[{ id: 1, name: "Larder", tags: null }],
|
|
],
|
|
["GET", "/api/labels/1", larder],
|
|
["PATCH", "/api/labels", [shed], 200, [{ id: 2, name: "Shed", tags: [] }]],
|
|
["GET", "/api/labels/2", shed],
|
|
["GET", "/api/labels", [larder, shed]],
|
|
];
|
|
})();
|
|
|
|
/**
|
|
* Two seeded books kept in id order, which their names follow too, a create a read sorted by shelf places by its tie
|
|
* with a seeded book, then a rename an unsorted read lists in name order.
|
|
*/
|
|
export const SHELF_SORTED_ROWS: readonly TimedCaptureRow[] = (() => {
|
|
const shelved = (id: number, name: string, shelf: string): Entity => ({
|
|
id,
|
|
name,
|
|
shelf,
|
|
});
|
|
const [alpha, bravo] = [shelved(1, "Alpha", "b"), shelved(2, "Bravo", "a")];
|
|
const charlie = shelved(3, "Charlie", "a");
|
|
const renamed = shelved(3, "Aardvark", "a");
|
|
return [
|
|
["GET", "/api/books", [alpha, bravo]],
|
|
["GET", "/api/books?sort=-id", [bravo, alpha]],
|
|
["GET", "/api/books/1", alpha],
|
|
["GET", "/api/books/2", bravo],
|
|
["POST", "/api/books", charlie, 201, { name: "Charlie", shelf: "a" }],
|
|
["GET", "/api/books/3", charlie],
|
|
["GET", "/api/books?sort=shelf", [bravo, charlie, alpha]],
|
|
["PUT", "/api/books/3", renamed, 200, { name: "Aardvark", shelf: "a" }],
|
|
["GET", "/api/books/3", renamed],
|
|
["GET", "/api/books", [renamed, alpha, bravo]],
|
|
];
|
|
})();
|
|
|
|
/** Seeded folders listed out of title order, then a rename after which a list read happens to show title order. */
|
|
export const UNSORTED_SEED_ROWS: readonly TimedCaptureRow[] = (() => {
|
|
const [charlie, bravo, alpha] = [
|
|
folder(1, "Charlie"),
|
|
folder(2, "Bravo"),
|
|
folder(3, "Alpha"),
|
|
];
|
|
const renamed = folder(1, "Zulu", 2);
|
|
return [
|
|
["GET", "/api/folders", [charlie, bravo, alpha]],
|
|
["GET", "/api/folders/1", charlie],
|
|
["PUT", "/api/folders/1", renamed, 200, { title: "Zulu", version: 1 }],
|
|
["GET", "/api/folders/1", renamed],
|
|
["GET", "/api/folders", [alpha, bravo, renamed]],
|
|
];
|
|
})();
|
|
|
|
/** Labels listed by name but not by id, a create no list shows placed, then a rename read back by id only. */
|
|
export const UNPLACED_CREATE_ROWS: readonly TimedCaptureRow[] = (() => {
|
|
const [alpha, bravo, delta] = [
|
|
label(3, "Alpha"),
|
|
label(1, "Bravo"),
|
|
label(2, "Delta"),
|
|
];
|
|
const charlie = label(4, "Charlie");
|
|
const zulu = label(1, "Zulu");
|
|
return [
|
|
["GET", "/api/labels", [alpha, bravo, delta]],
|
|
["GET", "/api/labels/1", bravo],
|
|
["POST", "/api/labels", charlie, 201, formBody(charlie)],
|
|
["GET", "/api/labels/4", charlie],
|
|
["PATCH", "/api/labels/1", zulu, 200, { name: "Zulu" }],
|
|
["GET", "/api/labels/1", zulu],
|
|
];
|
|
})();
|
|
|
|
/** A rename confirmed in place by an unsorted list, then a second rename whose list is sorted by title: serving would move only the second. */
|
|
export const CONFIRMED_RENAME_ROWS: readonly TimedCaptureRow[] = (() => {
|
|
const [delta, bravo, alpha] = [
|
|
folder(1, "Delta"),
|
|
folder(2, "Bravo"),
|
|
folder(3, "Alpha"),
|
|
];
|
|
const aardvark = folder(3, "Aardvark", 2);
|
|
const echo = folder(2, "Echo", 2);
|
|
return [
|
|
["GET", "/api/folders", [delta, bravo, alpha]],
|
|
["GET", "/api/folders/1", delta],
|
|
["PUT", "/api/folders/3", aardvark, 200, { title: "Aardvark", version: 1 }],
|
|
["GET", "/api/folders/3", aardvark],
|
|
["GET", "/api/folders", [delta, bravo, aardvark]],
|
|
["PUT", "/api/folders/2", echo, 200, { title: "Echo", version: 1 }],
|
|
["GET", "/api/folders/2", echo],
|
|
["GET", "/api/folders", [aardvark, delta, echo]],
|
|
];
|
|
})();
|
|
|
|
/** 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]],
|
|
];
|
|
})();
|
|
|
|
/** Books listed by name but not by id, a create no read shows placed, then a rename only a read sorted by shelf shows moved. */
|
|
export const SHELF_GUESS_ROWS: readonly TimedCaptureRow[] = (() => {
|
|
const shelved = (id: number, name: string, shelf: string): Entity => ({
|
|
id,
|
|
name,
|
|
shelf,
|
|
});
|
|
const [alpha, bravo, delta] = [
|
|
shelved(3, "Alpha", "b"),
|
|
shelved(1, "Bravo", "a"),
|
|
shelved(2, "Delta", "a"),
|
|
];
|
|
const charlie = shelved(4, "Charlie", "c");
|
|
const zulu = shelved(1, "Zulu", "a");
|
|
return [
|
|
["GET", "/api/books", [alpha, bravo, delta]],
|
|
["GET", "/api/books?sort=-id", [alpha, delta, bravo]],
|
|
["GET", "/api/books/1", bravo],
|
|
["POST", "/api/books", charlie, 201, { name: "Charlie", shelf: "c" }],
|
|
["GET", "/api/books/4", charlie],
|
|
["PUT", "/api/books/1", zulu, 200, { name: "Zulu", shelf: "a" }],
|
|
["GET", "/api/books/1", zulu],
|
|
["GET", "/api/books?sort=shelf", [delta, zulu, alpha, charlie]],
|
|
];
|
|
})();
|
|
|
|
/** Two renames a list sorted by name shows moved, the first read back between them only by a list its query sorts by id. */
|
|
export const ID_SORTED_READ_ROWS: readonly TimedCaptureRow[] = (() => {
|
|
const book = (id: number, name: string): Entity => ({ id, name });
|
|
const [charlie, bravo, alpha] = [
|
|
book(1, "Charlie"),
|
|
book(2, "Bravo"),
|
|
book(3, "Alpha"),
|
|
];
|
|
const zulu = book(1, "Zulu");
|
|
const yankee = book(2, "Yankee");
|
|
return [
|
|
["GET", "/api/books", [charlie, bravo, alpha]],
|
|
["GET", "/api/books?sort=-id", [alpha, bravo, charlie]],
|
|
["GET", "/api/books/1", charlie],
|
|
["PUT", "/api/books/1", zulu, 200, { name: "Zulu" }],
|
|
["GET", "/api/books/1", zulu],
|
|
["GET", "/api/books?sort=id", [zulu, bravo, alpha]],
|
|
["PUT", "/api/books/2", yankee, 200, { name: "Yankee" }],
|
|
["GET", "/api/books/2", yankee],
|
|
["GET", "/api/books", [alpha, yankee, zulu]],
|
|
];
|
|
})();
|
|
|
|
/** Folders listed out of title order, then two renames before a list sorted by title that moving each in turn cannot reach. */
|
|
export const UNSORTED_PENDING_ROWS: readonly TimedCaptureRow[] = (() => {
|
|
const [echo, foxtrot, bravo, delta] = [
|
|
folder(1, "Echo"),
|
|
folder(2, "Foxtrot"),
|
|
folder(3, "Bravo"),
|
|
folder(4, "Delta"),
|
|
];
|
|
const charlie = folder(1, "Charlie", 2);
|
|
const alpha = folder(2, "Alpha", 2);
|
|
return [
|
|
["GET", "/api/folders", [echo, foxtrot, bravo, delta]],
|
|
["GET", "/api/folders/1", echo],
|
|
["PUT", "/api/folders/1", charlie, 200, { title: "Charlie", version: 1 }],
|
|
["GET", "/api/folders/1", charlie],
|
|
["PUT", "/api/folders/2", alpha, 200, { title: "Alpha", version: 1 }],
|
|
["GET", "/api/folders/2", alpha],
|
|
["GET", "/api/folders", [alpha, bravo, charlie, delta]],
|
|
];
|
|
})();
|
|
|
|
/** A label renamed twice: a list shows the first rename in place, a list in name order the second. */
|
|
const renamedTwice = (
|
|
names: readonly [string, string, string],
|
|
first: string,
|
|
second: string,
|
|
): readonly TimedCaptureRow[] => {
|
|
const seeds = names.map((name, index) => label(index + 1, name));
|
|
const [, two, three] = seeds;
|
|
const renamed = { ...seeds[0]!, name: first };
|
|
const again = { ...seeds[0]!, name: second };
|
|
return [
|
|
["GET", "/api/labels", seeds],
|
|
["GET", "/api/labels/1", seeds[0]!],
|
|
["PATCH", "/api/labels/1", renamed, 200, { name: first }],
|
|
["GET", "/api/labels/1", renamed],
|
|
["GET", "/api/labels", [renamed, two!, three!]],
|
|
["PATCH", "/api/labels/1", again, 200, { name: second }],
|
|
["GET", "/api/labels/1", again],
|
|
["GET", "/api/labels", [two!, three!, again]],
|
|
];
|
|
};
|
|
|
|
/** Bravo renamed Delta and listed in place, then renamed Zulu and listed last. */
|
|
export const IN_PLACE_THEN_LAST_ROWS = renamedTwice(
|
|
["Bravo", "Alpha", "Charlie"],
|
|
"Delta",
|
|
"Zulu",
|
|
);
|
|
|
|
/** Alpha renamed Zulu and listed in place, then renamed Delta and listed last. */
|
|
export const IN_PLACE_THEN_SORTED_ROWS = renamedTwice(
|
|
["Alpha", "Bravo", "Charlie"],
|
|
"Zulu",
|
|
"Delta",
|
|
);
|
|
|
|
/** 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)]],
|
|
];
|
|
})();
|