diff --git a/packages/cli/src/repl/application.ts b/packages/cli/src/repl/application.ts index 26ae5193..61abbbf2 100644 --- a/packages/cli/src/repl/application.ts +++ b/packages/cli/src/repl/application.ts @@ -30,7 +30,7 @@ import type { Json } from "@executablemd/durable-streams"; import { describe as describeNode } from "./description.ts"; import type { ReplDescription } from "./description.ts"; -import { drawerWidth, HISTORY_ROWS, NARROW, surfaceWidth } from "./layout.ts"; +import { drawerHeight, drawerWidth, HISTORY_ROWS, NARROW, surfaceWidth } from "./layout.ts"; import type { ReplSurface as ReplPlacedSurface, ReplSurfaceCell } from "./layout.ts"; import type { ReplTerminalSize } from "./terminal.ts"; import { decodeLocation, encodeLocation, NO_LIVE, resolveLocation } from "./route.ts"; @@ -42,7 +42,7 @@ import type { ReplSurface, } from "./route.ts"; import type { ReplModel, ReplRow, ReplScope } from "./model.ts"; -import type { ReplQuestion } from "./elicitation.ts"; +import type { ReplFormField, ReplQuestion, ReplQuestionForm } from "./elicitation.ts"; import type { ExpansionState } from "./expansion.ts"; import type { ReplTree } from "./reconcile.ts"; import { DRAWER, FIELD, LINE, REFUSAL, SELECT_ROW } from "./components/rows.ts"; @@ -61,15 +61,84 @@ export interface ReplLive { readonly pausable: boolean; } +/** One thing the last submission said was wrong, as a reader sees it. */ +export interface ReplFormMessage { + /** The field it belongs to, or none for the object as a whole. */ + readonly field: string | undefined; + readonly message: string; +} + +/** + * The form being filled in, while a question is being asked. + * + * Values are keyed by field name because the fields are independent: one global + * string would make editing either field overwrite the other. None of this is + * durable, none of it is in the route, and all of it is discarded when the + * question ends or its drawer is closed. + */ +export interface ReplFormState { + /** What has been typed into each field so far, by field name. */ + readonly values: Readonly>; + /** Which field text and Backspace act on, or none yet. */ + readonly field: string | undefined; + /** What the last submission was told was wrong. */ + readonly messages: readonly ReplFormMessage[]; + /** How far the read-only message region has been scrolled, in lines. */ + readonly offset: number; +} + +/** A form nobody has touched. */ +export const EMPTY_FORM: ReplFormState = Object.freeze({ + values: Object.freeze({}), + field: undefined, + messages: Object.freeze([]), + offset: 0, +}); + +/** + * Which control focus goes back to, because the drawer that held it went. + * + * Freedom restores the control that was focused before a modal was pushed, which + * is where focus *was* rather than where the question now is: answering leaves a + * retained invocation to look at, and dismissing leaves one still being asked. + * Both are named here rather than resolved, because which row that is depends on + * what is mounted and only the view knows that. + */ +export type ReplFocusRestore = + /** + * The invocation whose answer this was, once its record is projected. + * + * Named by what it caused rather than by where it sits: the record appends + * after the drawer is gone, so the row does not exist yet at the moment the + * claim is made. `known` is every answer already retained when this one was + * taken and `answer` is exactly what was sent, which together name one + * record — not the newest one, and not an answer somebody else caused. + */ + | { + readonly kind: "answered"; + readonly known: readonly string[]; + readonly answer: Json; + } + /** The invocation still asking, now that its drawer is not up. */ + | { readonly kind: "asked" }; + /** Everything typed and not yet committed anywhere. */ export interface ReplState { readonly route: ReplRoute; /** The entry draft, before an entry exists. */ readonly draft: string; - /** The answer being typed into the waiting question. */ - readonly answer: string; + /** The form being filled into the waiting question. */ + readonly form: ReplFormState; /** Why the last action changed nothing, or none. */ readonly refusal: string | undefined; + /** + * Where focus starts again, for the one commit after a drawer went. + * + * One-shot and process-local. A claim repeated every frame would drag focus + * back after every Tab, so the next action clears it: from then on focus is + * the tree's, which is the only thing that knows where it is. + */ + readonly restore: ReplFocusRestore | undefined; } /** What the root must perform, because a component cannot. */ @@ -78,7 +147,7 @@ export type ReplIntent = | { readonly kind: "submit"; readonly source: string } | { readonly kind: "pause" } | { readonly kind: "continue" } - | { readonly kind: "answer"; readonly choice: string }; + | { readonly kind: "answer"; readonly values: Readonly> }; /** One reduction: the state that stands now, and what the root owes. */ export interface ReplTransition { @@ -141,16 +210,25 @@ export function admitted(state: ReplState): ReplState { /** * The state after a question accepted its answer. * + * `model` is the history as it stood *before* the answer and `answer` is the + * object the question took, because together they name the record this answer is + * about to add. + * * The question is over, so the drawer that was asking it is over too. Leaving * `+elicit` in the route would print a location naming a drawer the topology no * longer mounts — a URL that describes a screen nobody can be shown — and * leaving the typed text in `answer` would offer it again as though it were * still waiting to be sent. */ -export function answered(state: ReplState): ReplState { +export function answered(state: ReplState, model: ReplModel, answer: Json): ReplState { return Object.freeze({ ...state, - answer: "", + form: EMPTY_FORM, + // The answer is what there is to look at now, so focus goes to the + // invocation that has it rather than wherever the drawer was opened from. + // Which invocation that is cannot be read yet: its record appends after this, + // so the claim carries what will identify it when it arrives. + restore: Object.freeze({ kind: "answered", known: retainedAnswers(model), answer }), route: Object.freeze({ ...state.route, drawers: Object.freeze(state.route.drawers.filter((drawer) => drawer.kind !== "live-elicit")), @@ -178,8 +256,9 @@ export function initialState(execution: string): ReplState { return Object.freeze({ route: initialRoute(execution), draft: "", - answer: "", + form: EMPTY_FORM, refusal: undefined, + restore: undefined, }); } @@ -193,8 +272,9 @@ export function stateFor(location: string): Result { Object.freeze({ route: decoded.value, draft: decoded.value.draft ?? "", - answer: "", + form: EMPTY_FORM, refusal: undefined, + restore: undefined, }), ); } @@ -313,17 +393,25 @@ const EMPTY_MODEL: ReplModel = Object.freeze({ * the decision and the effect are separable and the decision is testable alone. */ export function reduceRepl( - state: ReplState, + given: ReplState, action: ReplAction, model: ReplModel, live: ReplLive, + // What the frame can hold, for the one decision that depends on it: how far + // the drawer's content may be scrolled. Narrow is the smallest accepted + // frame, so a caller that states none clamps to the tightest capacity. + size: ReplTerminalSize = NARROW, ): ReplTransition { + // Whoever pressed this key has moved on from wherever the last drawer put + // focus, so the claim does not outlive the commit it was made for. + const state = + given.restore === undefined ? given : Object.freeze({ ...given, restore: undefined }); const answering = state.route.drawers.some((drawer) => drawer.kind === "live-elicit"); switch (action.kind) { case "type": { if (answering) { - return settled({ ...state, answer: state.answer + action.text, refusal: undefined }); + return editing(state, live, action.field, (value) => value + action.text); } if (model.entry !== undefined) { return refuse(state, "this execution has admitted its entry, and an entry is immutable."); @@ -332,7 +420,7 @@ export function reduceRepl( } case "erase": { if (answering) { - return settled({ ...state, answer: shortened(state.answer), refusal: undefined }); + return editing(state, live, action.field, shortened); } if (model.entry !== undefined) { return refuse(state, "this execution has admitted its entry, and an entry is immutable."); @@ -401,7 +489,16 @@ export function reduceRepl( // Dismissing the question's drawer discards what was typed into it. It is // not an answer, and keeping it would offer it again as though it were. return closing.kind === "live-elicit" - ? { ...closed, state: Object.freeze({ ...closed.state, answer: "" }) } + ? { + ...closed, + // Still being asked, so what focus returns to is the invocation that + // is asking — the one control that opens this drawer again. + state: Object.freeze({ + ...closed.state, + form: EMPTY_FORM, + restore: Object.freeze({ kind: "asked" }), + }), + } : closed; } case "select-marker": { @@ -458,15 +555,118 @@ export function reduceRepl( if (live.question === undefined) { return refuse(state, "nothing is being asked right now."); } - if (state.answer.length === 0) { - return refuse(state, "type one of the offered choices first."); - } + // The whole object, however it was assembled. Whether it is an answer is + // the schema's to say, and the root asks it — a reducer that guessed here + // would be a second validator disagreeing with the first. return { state: Object.freeze({ ...state, refusal: undefined }), - intent: { kind: "answer", choice: state.answer }, + intent: { kind: "answer", values: state.form.values }, + }; + } + case "select-field": { + if (!answering || live.question === undefined) { + return refuse(state, "nothing is being asked right now."); + } + if (!live.question.form.fields.some((one) => one.name === action.field)) { + return refuse(state, "this question has no such field."); + } + return settled({ + ...state, + form: Object.freeze({ ...state.form, field: action.field }), + refusal: undefined, + }); + } + case "choose": { + if (!answering || live.question === undefined) { + return refuse(state, "nothing is being asked right now."); + } + const field = live.question.form.fields.find((one) => one.name === action.field); + if (field === undefined) { + return refuse(state, "this question has no such field."); + } + // Never a value the question did not offer: a stray identifier puts + // nothing into the form rather than something the schema will reject. + if (field.choices === undefined || !field.choices.includes(action.option)) { + return refuse(state, "this field does not offer that value."); + } + // Activating an option is an answer attempt, like Enter anywhere else in + // the form: the value goes in and the whole object is offered. Whether it + // is an answer is the schema's to say — choosing "Request changes" with no + // feedback yet leaves the same question open, carrying what it said. + const chosen = written(state.form, action.field, action.option); + return { + state: Object.freeze({ ...state, form: chosen, refusal: undefined }), + intent: { kind: "answer", values: chosen.values }, }; } + case "scroll": { + if (!answering) { + return refuse(state, "nothing is being asked right now."); + } + // Clamped at both ends, and stored clamped. An offset kept past the last + // window would take several presses to have any visible effect, so the + // value held is the one the region is actually showing. + // Clamped against the whole ordered content, not only the message: the + // viewport is what moves, and the form rows are inside it. + const rows = drawerContentRows(live.question, state.form); + const capacity = drawerCapacity(size); + const furthest = Math.max(0, rows - capacity); + const offset = Math.min(Math.max(0, state.form.offset + action.delta), furthest); + return settled({ + ...state, + form: Object.freeze({ ...state.form, offset }), + refusal: undefined, + }); + } + } +} + +/** One field's value replaced, leaving every other field alone. */ +function written(form: ReplFormState, field: string, value: string): ReplFormState { + return Object.freeze({ + ...form, + values: Object.freeze({ ...form.values, [field]: value }), + // A value that has changed makes the last verdict about the old one stale, + // so the messages go rather than sitting under a form they no longer + // describe. + messages: Object.freeze([]), + }); +} + +/** + * The field text and Backspace act on. + * + * Whichever field has focus, or the first one the question declares — so a + * person who starts typing before selecting anything edits the field they are + * looking at rather than nothing. + */ +function fieldNamed(live: ReplLive, name: string): ReplFormField | undefined { + return live.question?.form.fields.find((one) => one.name === name); +} + +function focused(state: ReplState, live: ReplLive): ReplFormField | undefined { + const fields = live.question?.form.fields ?? []; + const named = fields.find((one) => one.name === state.form.field); + return named ?? fields[0]; +} + +/** Edit the focused field, and only it. */ +function editing( + state: ReplState, + live: ReplLive, + named: string | undefined, + change: (value: string) => string, +): ReplTransition { + const field = named === undefined ? focused(state, live) : fieldNamed(live, named); + if (field === undefined) { + return refuse(state, "nothing is being asked right now."); } + const form = written(state.form, field.name, change(state.form.values[field.name] ?? "")); + return settled({ + ...state, + form: Object.freeze({ ...form, field: field.name }), + refusal: undefined, + }); } function settled(state: ReplState): ReplTransition { @@ -556,19 +756,109 @@ function row( key: string, label: string, select: { readonly [name: string]: Json }, - options: { readonly focus?: true; readonly here?: string | undefined } = {}, + options: { + readonly focus?: true; + readonly here?: string | undefined; + /** The key this frame restores focus to, for the row that turns out to be it. */ + readonly claim?: string | undefined; + } = {}, ): Described { + const claims = options.focus === true || options.claim === key; return { key, description: describeNode({ key, component: SELECT_ROW, input: { label, ...select, ...(options.here === key ? { focused: true } : {}) }, - ...(options.focus === undefined ? {} : { focus: options.focus }), + ...(claims ? { focus: true } : {}), }), }; } +/** + * Which control this frame claims focus for, because a drawer went. + * + * Resolved against what is mounted rather than stored: a claim naming a row this + * view does not draw would be focus asked for on behalf of nothing, and the + * commit that honoured it would throw. So the claim is silent for as long as the + * row it names is not there — the answer's record has not been projected yet — + * and the hint waits rather than being spent on nothing. + */ +export function focusClaim(view: ReplView): string | undefined { + const { state, selection, live } = view; + if (state.restore === undefined) { + return undefined; + } + if (state.restore.kind === "asked") { + return live.question === undefined || state.route.at !== undefined ? undefined : "footer:asked"; + } + // The record this answer caused: one the history did not hold when the answer + // was taken, holding exactly what was sent. Not the newest record — by the time + // a frame can draw this row, another invocation may have settled after it. + const known = new Set(state.restore.known); + const answer = state.restore.answer; + const caused = (selection.scope?.elicitations ?? []).find( + (one) => !known.has(one.marker) && sameJson(one.answer, answer), + ); + return caused === undefined ? undefined : `elicit:${caused.marker}`; +} + +/** + * The state after a commit settled focus. + * + * A restoration claim is one claim, not a standing one. It survives the frames + * between the answer and its record — nothing can be focused that is not there + * yet — and is spent by the commit that puts focus where it asked. Focus + * traversal is not an action, so a claim nobody retired would pull somebody back + * after every Tab and traversal would never move. + */ +export function focusSettled(view: ReplView, focused: string | undefined): ReplState { + const claim = focusClaim(view); + if (view.state.restore === undefined || claim === undefined || focused !== claim) { + return view.state; + } + return Object.freeze({ ...view.state, restore: undefined }); +} + +/** Every answer this history already holds, wherever in the entry it holds it. */ +function retainedAnswers(model: ReplModel): readonly string[] { + const markers: string[] = []; + const walk = (scope: ReplScope): void => { + for (const elicitation of scope.elicitations) { + markers.push(elicitation.marker); + } + for (const child of scope.scopes) { + walk(child); + } + }; + if (model.entry !== undefined) { + walk(model.entry); + } + return Object.freeze(markers); +} + +/** Whether two Json values are the same value, whatever order they were written in. */ +function sameJson(left: Json, right: Json): boolean { + if (left === null || right === null || typeof left !== "object" || typeof right !== "object") { + return left === right; + } + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { + return false; + } + return left.every((member, index) => sameJson(member, right[index] ?? null)); + } + const names = Object.keys(left); + if (names.length !== Object.keys(right).length) { + return false; + } + return names.every((name) => { + const mine = left[name]; + const theirs = right[name]; + return mine !== undefined && theirs !== undefined && sameJson(mine, theirs); + }); +} + function line(key: string, label: string): Described { return { key, @@ -592,14 +882,25 @@ function field( prompt: string, text: string, purpose: "draft" | "answer", - options: { readonly focus?: true; readonly here?: string | undefined } = {}, + options: { + readonly focus?: true; + readonly here?: string | undefined; + /** The form field this line edits, for a line that edits one. */ + readonly field?: string; + } = {}, ): Described { return { key, description: describeNode({ key, component: FIELD, - input: { prompt, text, purpose, ...(options.here === key ? { focused: true } : {}) }, + input: { + prompt, + text, + purpose, + ...(options.field === undefined ? {} : { field: options.field }), + ...(options.here === key ? { focused: true } : {}), + }, ...(options.focus === undefined ? {} : { focus: options.focus }), }), }; @@ -638,6 +939,7 @@ function described(view: ReplView): readonly Described[] { const items: Described[] = []; const { model, selection, live, state } = view; + const claim = focusClaim(view); items.push( row( @@ -728,7 +1030,7 @@ function described(view: ReplView): readonly Described[] { select: "recorded-elicit", marker: elicitation.marker, }, - { here: view.focused }, + { here: view.focused, claim }, ), ); } @@ -737,7 +1039,23 @@ function described(view: ReplView): readonly Described[] { // The way into history. The band above shows where the positions are; choosing // an exact one is a drawer, because a position is something you select and the // band is something you read. - items.push(row("footer:history", "[history]", { select: "history" }, { here: view.focused })); + // The one way into history, and one node. While a drawer is mounted it is + // reparented into the modal branch below rather than drawn again beside it: + // two nodes with one key would be two controls claiming one name, and leaving + // it out here would put it outside the active focus root where nothing could + // reach it. + const history = row( + "footer:history", + "[history]", + { select: "history" }, + { + here: view.focused, + }, + ); + const modal = view.selection.drawers.length > 0; + if (!modal) { + items.push(history); + } if (state.route.at !== undefined) { items.push(row("footer:live", "[live]", { select: "live" }, { here: view.focused })); } @@ -767,9 +1085,11 @@ function described(view: ReplView): readonly Described[] { items.push( row( "footer:asked", - `? ${live.question.message}`, + // One line, bounded. The whole message is in the drawer this opens; a + // footer that drew every line of a Plan draft would be the transcript. + `? ${headline(live.question.message)}`, { select: "live-elicit" }, - { here: view.focused }, + { here: view.focused, claim }, ), ); } @@ -798,7 +1118,10 @@ function described(view: ReplView): readonly Described[] { // not an assertion repeated every frame: the tree re-reads the claim at each // commit, and this screen commits on every frame, so a standing claim would // drag focus back here after every Tab and traversal would never move at all. - const claiming = state.route.drawers.length === 0 && view.focused === undefined; + // A restoration claim is somebody else's: exactly one description may claim + // focus, and a drawer that just went says which one it is. + const claiming = + state.route.drawers.length === 0 && view.focused === undefined && claim === undefined; items.push( field( "footer:input", @@ -809,7 +1132,7 @@ function described(view: ReplView): readonly Described[] { ), ); - const drawer = drawerFor(view); + const drawer = drawerFor(view, history); if (drawer !== undefined) { items.push(drawer); } @@ -823,7 +1146,7 @@ function described(view: ReplView): readonly Described[] { * cell is a row: a label holding three lines would show one of them, and a reader * would have no way to know the other two existed. */ -function drawerFor(view: ReplView): Described | undefined { +function drawerFor(view: ReplView, history: Described): Described | undefined { const open = view.selection.drawers[view.selection.drawers.length - 1]; if (open === undefined) { return undefined; @@ -873,28 +1196,140 @@ function drawerFor(view: ReplView): Described | undefined { if (question === undefined) { return undefined; } - title = question.message; - // The retained normalized schema, as a form: the one field it asks for and - // the exact values it will accept. + const form = question.form; + title = form.title ?? "Answer"; + // The whole message, every line of it, through a window that scrolls. A + // drawer that showed only the first line — or only the first window — + // would be hiding the draft the question is about. + // One viewport over the whole ordered content. Everything a person has to + // read or reach — the complete message, the form's description, every + // field with its annotation, options, editable value, every validation + // message and [submit] — is built in order and then windowed. A drawer too + // short to hold all of it scrolls, rather than describing rows that layout + // has no frame to place. + const lines = question.message.split("\n"); + const capacity = drawerCapacity(view.size); + const last = Math.max(0, drawerContentRows(question, view.state.form) - capacity); + const from = Math.min(Math.max(0, view.state.form.offset), last); + const until = from + capacity; + const content: ReplDescription[] = []; + /** Whether the row about to be built lands inside the viewport. */ + const showing = (): boolean => content.length >= from && content.length < until; children.push( - drawerLine( - "drawer:form", - `${question.form.field}: ${question.form.choices.join(" | ")}`, - width, + row( + "drawer:scroll:up", + pad("[^ earlier]", width), + { select: "scroll", delta: -1 }, + { + here: view.focused, + }, ).description, ); - // The same rule inside the modal: the field claims focus when the drawer - // opens, and afterwards traversal inside the drawer owns it. + for (const [offset, text] of lines.entries()) { + content.push(drawerLine(`drawer:message:${offset}`, text, width).description); + } + if (form.description !== undefined) { + content.push(drawerLine("drawer:form:about", form.description, width).description); + } + // The same rule inside the modal: the first control claims focus when the + // drawer opens, and afterwards traversal inside the drawer owns it. const entering = view.focused === undefined || !view.focused.startsWith("drawer:"); - const claim: { readonly focus?: true } = entering ? { focus: true } : {}; + let claimed = false; + for (const one of form.fields) { + const value = view.state.form.values[one.name] ?? ""; + const marked = requiredNow(form, view.state.form.values, one) ? "*" : " "; + const label = one.title ?? one.name; + content.push( + row( + `drawer:field:${one.name}`, + pad(`${marked}${label}: ${value}`, width), + { select: "form-field", field: one.name }, + { here: view.focused }, + ).description, + ); + if (one.description !== undefined) { + content.push( + drawerLine(`drawer:field:${one.name}:about`, ` ${one.description}`, width).description, + ); + } + if (one.choices !== undefined) { + // What this field accepts, said once. The controls below are how a + // value is chosen; this is the line that names the whole set, and it + // is what a reader scanning the form reads first. + content.push( + drawerLine(`drawer:form:${one.name}`, `${one.name}: ${one.choices.join(" | ")}`, width) + .description, + ); + } + // Every offered value, each its own control. A form that drew only the + // first would be offering a choice nobody could make. + for (const option of one.choices ?? []) { + content.push( + row( + `drawer:choice:${one.name}:${option}`, + pad(` ${value === option ? "(x)" : "( )"} ${option}`, width), + { select: "form-choice", field: one.name, option }, + { here: view.focused }, + ).description, + ); + } + // The one editable line for this field, which is where text and Backspace + // land while it has focus. The first field's line claims focus when the + // drawer opens — including an enum's, because typing an offered value and + // pressing Enter is still a way to answer. + // Claimed only by a row the viewport actually shows: focusing one that + // scrolled out would put focus where nothing is placed. + const takes = entering && !claimed && showing(); + const focus: { readonly focus?: true } = takes ? { focus: true } : {}; + if (takes) { + claimed = true; + } + content.push( + field(`drawer:value:${one.name}`, " = ", value, "answer", { + ...focus, + here: view.focused, + }).description, + ); + } + // What the last submission was told, under the form it is about. + for (const [offset, message] of view.state.form.messages.entries()) { + content.push( + drawerLine( + `drawer:invalid:${offset}`, + message.field === undefined ? message.message : `${message.field}: ${message.message}`, + width, + ).description, + ); + } + content.push( + row( + "drawer:form:submit", + pad("[submit]", width), + { select: "form-submit" }, + { + here: view.focused, + }, + ).description, + ); + // Only what the viewport holds becomes a placed cell. A row outside it is + // not described at all, so it is neither drawn nor pointable. + for (const placed of content.slice(from, until)) { + children.push(placed); + } children.push( - field("drawer:answer", "= ", view.state.answer, "answer", { - ...claim, - here: view.focused, - }).description, + row( + "drawer:scroll:down", + pad("[v later]", width), + { select: "scroll", delta: 1 }, + { + here: view.focused, + }, + ).description, ); } + // The same node the footer would have drawn, inside the modal focus root. + children.push(history.description); children.push( row( "drawer:close", @@ -917,6 +1352,83 @@ function drawerFor(view: ReplView): Described | undefined { }; } +/** + * The rows the drawer keeps whatever the viewport shows. + * + * Its own title row, the two scroll controls and `[close]`: how a person moves + * the viewport and leaves, so none of them is ever inside the thing it moves. + * `[history]` is not among them — it is reparented into the drawer's subtree so + * it stays inside the active focus root, but layout places it in the footer + * region, where it costs the drawer no row. + */ +const FIXED_DRAWER_ROWS = 4; + +/** + * How many rows of ordered content this drawer can place at this size. + * + * At least one, because a viewport showing nothing would say the question was + * empty. + */ +function drawerCapacity(size: ReplTerminalSize): number { + return Math.max(1, drawerHeight(size) - FIXED_DRAWER_ROWS); +} + +/** + * How many rows the drawer's ordered content holds in total. + * + * The complete message, the form's description, every field with its + * annotation, enum summary, offered values and editable line, every validation + * message, and `[submit]`. Counted the same way the rows are built, because the + * reducer clamps a scroll against this before any of them exist. + */ +function drawerContentRows(question: ReplQuestion | undefined, form: ReplFormState): number { + if (question === undefined) { + return 0; + } + let rows = question.message.split("\n").length; + rows += question.form.description === undefined ? 0 : 1; + for (const one of question.form.fields) { + rows += 2; + rows += one.description === undefined ? 0 : 1; + rows += one.choices === undefined ? 0 : 1 + one.choices.length; + } + rows += form.messages.length; + // [submit] scrolls with the form it submits. + return rows + 1; +} + +/** The first line of a message, for a control that is one row tall. */ +function headline(message: string): string { + const [first = ""] = message.split("\n"); + return first.length > 60 ? `${first.slice(0, 59)}…` : first; +} + +function pad(label: string, width: number): string { + return width < 1 ? label : label.padEnd(width, " "); +} + +/** + * Whether this field is required as the form currently stands. + * + * The root's own `required`, plus whatever the one conditional adds while its + * field holds the value it tests for. Presentation only — what actually decides + * is the compiled schema, which judges the assembled object. + */ +function requiredNow( + form: ReplQuestionForm, + values: Readonly>, + field: ReplFormField, +): boolean { + if (field.required) { + return true; + } + const condition = form.condition; + if (condition === undefined || values[condition.field] !== condition.equals) { + return false; + } + return condition.requires.some((one) => one.name === field.name); +} + /** One value, as the lines a drawer shows it on. */ function detail(value: Json): readonly string[] { return (JSON.stringify(value, undefined, 2) ?? "null").split("\n"); diff --git a/packages/cli/src/repl/components/actions.ts b/packages/cli/src/repl/components/actions.ts index 75e5de45..7244de5f 100644 --- a/packages/cli/src/repl/components/actions.ts +++ b/packages/cli/src/repl/components/actions.ts @@ -15,10 +15,17 @@ import type { ReplDrawerRef, ReplSurface } from "../route.ts"; export type ReplAction = - /** Text for whichever field has focus. */ - | { readonly kind: "type"; readonly text: string } - /** Remove the last decoded text unit from whichever field has focus. */ - | { readonly kind: "erase" } + /** + * Text for one named field, or for the entry draft when it names none. + * + * The name travels with the text because focus belongs to the mounted tree + * and this vocabulary does not: a control that emitted bare text would have + * its characters delivered to whichever field the application last recorded, + * which is not necessarily the one under the cursor. + */ + | { readonly kind: "type"; readonly text: string; readonly field?: string } + /** Remove the last Unicode scalar value from one named field, or the draft. */ + | { readonly kind: "erase"; readonly field?: string } /** Admit the draft as this execution's one entry. */ | { readonly kind: "submit" } | { readonly kind: "select-surface"; readonly surface: ReplSurface } @@ -33,5 +40,17 @@ export type ReplAction = | { readonly kind: "go-live" } | { readonly kind: "pause" } | { readonly kind: "continue" } - /** Answer the question waiting right now. */ - | { readonly kind: "answer" }; + /** Answer the question waiting right now, from the form as it stands. */ + | { readonly kind: "answer" } + /** Give this form field the focus that text and Backspace act on. */ + | { readonly kind: "select-field"; readonly field: string } + /** Put one offered enum value into one field. */ + | { readonly kind: "choose"; readonly field: string; readonly option: string } + /** + * Move the read-only message region by whole lines. + * + * A delta rather than a position, because the control that emits it knows + * which way it points and nothing else: how far the region can go is the + * frame's to decide, and clamping belongs where the height is known. + */ + | { readonly kind: "scroll"; readonly delta: number }; diff --git a/packages/cli/src/repl/components/rows.ts b/packages/cli/src/repl/components/rows.ts index e75b6e77..91a0afff 100644 --- a/packages/cli/src/repl/components/rows.ts +++ b/packages/cli/src/repl/components/rows.ts @@ -140,6 +140,26 @@ function activation(input: ReplViewData): ReplAction | undefined { if (select === "close") { return { kind: "close-drawer" }; } + if (select === "form-field") { + const field = named["field"]; + return typeof field === "string" ? { kind: "select-field", field } : undefined; + } + if (select === "form-choice") { + const field = named["field"]; + const option = named["option"]; + return typeof field === "string" && typeof option === "string" + ? { kind: "choose", field, option } + : undefined; + } + if (select === "form-submit") { + return { kind: "answer" }; + } + if (select === "scroll") { + // A direction, not a position: the control knows which way it points, and + // how far the region may travel is the frame's to decide. + const delta = named["delta"]; + return typeof delta === "number" ? { kind: "scroll", delta } : undefined; + } return undefined; } @@ -159,14 +179,19 @@ export const FIELD: ReplComponent = component({ node.render(rendered(node.input)); node.onInput((input: ReplViewData) => node.render(rendered(input))); node.claim((event: ReplInputEvent): ReplAction | undefined => { + // The field this line edits, read off its own description. Text reaches + // the field the person is actually on rather than whichever one the + // application last recorded as selected. + const named = optional(node.input, "field"); + const owns: { readonly field?: string } = named === undefined ? {} : { field: named }; if (event.kind === "text") { - return { kind: "type", text: event.text }; + return { kind: "type", text: event.text, ...owns }; } if (event.kind === "pointer") { return undefined; } if (event.key === "Backspace") { - return { kind: "erase" }; + return { kind: "erase", ...owns }; } if (event.key === "Enter") { return submission(node.input); diff --git a/packages/cli/src/repl/elicitation.ts b/packages/cli/src/repl/elicitation.ts index e438c72c..e7f42cce 100644 --- a/packages/cli/src/repl/elicitation.ts +++ b/packages/cli/src/repl/elicitation.ts @@ -4,19 +4,33 @@ * A provider and nothing more. The document says what it is asking and what * shape the answer must have; this decides where the asking happens, publishes * the request so the application can draw it, and waits. Core normalized the - * schema, core validates what comes back, and core alone appends the answer — - * so a question this provider could not present never becomes a half-answered - * record. + * schema, Core compiles it, Core judges what comes back, and Core alone appends + * the answer — so a question this provider could not present never becomes a + * half-answered record. * - * ## One form, stated as a refusal + * ## The form language is closed, and says so * - * This slice presents exactly one shape: a closed object with one required - * string property whose values are an enum. Anything else is refused as an - * `ElicitationProviderError` *before* a value is yielded, because a provider - * that quietly rendered a schema it does not understand would collect an answer - * to a question nobody was shown. Refusing names the shape it can present, so - * the limit is legible rather than mysterious — and it is a limit rather than a - * general JSON Schema form toolkit, deliberately. + * This presents a bounded subset of JSON Schema: a closed object of string + * fields, each optionally an enum or a minimum length, with at most one + * conditional that makes further fields required when one field holds one exact + * value. That is what the packaged Plan and the generated README program ask + * for. + * + * Anything else is refused as an `ElicitationProviderError` *before* a value is + * yielded, before `asked` moves and before anything is published — and the + * refusal names the offending keyword and where it sits, so the limit is legible + * rather than mysterious. Refusing is deliberate: a provider that quietly + * rendered a keyword it does not understand would collect an answer to a + * question nobody was shown. + * + * ## Validation is Core's, twice + * + * The provider compiles the request's schema through Core's own preparation and + * judges each assembled object with Core's own validator before it resolves. An + * object that fails leaves the question exactly where it was — nothing is + * appended and nothing is resolved — and the normalized issues go back to the + * application to show. Core then judges the resolved answer again on its way + * out, which is the boundary that actually decides. * * ## Nothing here is durable * @@ -24,34 +38,73 @@ * pending request lives exactly as long as the suspended `action()` holding it: * if the run is halted the request disappears with it and no answer is * manufactured. Closing the drawer is a presentation decision and leaves the - * request waiting; only submitting a value answers it. + * request waiting; only a valid submission answers it. */ import { action, createSignal } from "effection"; import type { Operation, Stream } from "effection"; -import { Elicitation, ElicitationProviderError } from "@executablemd/core"; -import type { ElicitationRequest } from "@executablemd/core"; +import { Elicitation, ElicitationProviderError, prepareElicitation } from "@executablemd/core"; +import { validateParsed } from "@executablemd/core"; +import type { ElicitationRequest, NormalizedIssue } from "@executablemd/core"; import type { Json } from "@executablemd/durable-streams"; -/** The one question shape this REPL presents: one field, one list of choices. */ -export interface ReplQuestionForm { +/** What one field's value must also satisfy, when the schema says so. */ +export interface ReplFieldConstraint { + readonly name: string; + /** The shortest accepted value, when the schema states one. */ + readonly minLength: number | undefined; +} + +/** One field the form presents, in the order the schema declared it. */ +export interface ReplFormField extends ReplFieldConstraint { + /** The schema's own label for it, when it wrote one. */ + readonly title: string | undefined; + readonly description: string | undefined; + /** The exact values offered, when the field is an enum. */ + readonly choices: readonly string[] | undefined; + /** Whether the root requires it however the rest of the form is filled. */ + readonly required: boolean; +} + +/** What one conditional adds when its field holds one exact value. */ +export interface ReplFormCondition { readonly field: string; - readonly choices: readonly string[]; + readonly equals: string; + /** The fields that become required, with whatever `then` adds to them. */ + readonly requires: readonly ReplFieldConstraint[]; +} + +/** The whole question shape this REPL presents, parsed once. */ +export interface ReplQuestionForm { + readonly title: string | undefined; + readonly description: string | undefined; + readonly fields: readonly ReplFormField[]; + readonly condition: ReplFormCondition | undefined; } +/** What a submission did: it answered, or it is still the same question. */ +export type ReplFormOutcome = + /** + * Taken. `answer` is the exact object the question was resolved with, so + * whoever submitted it can recognise the record it causes among records it + * did not cause. + */ + | { readonly kind: "answered"; readonly answer: Json } + | { readonly kind: "invalid"; readonly issues: readonly NormalizedIssue[] }; + /** One question waiting for an answer in this process. */ export interface ReplQuestion { readonly message: string; - readonly schema: Json; readonly form: ReplQuestionForm; /** - * Submit one of the offered choices. + * Offer one assembled object as the answer. * - * A choice the form does not offer is not an answer: the request stays open - * and nothing is appended, which is what keeps a stray keystroke from binding - * a value the schema would then reject. + * Judged by the same compiled schema the request carries. A valid object + * resolves the wait exactly once; an invalid one changes nothing at all and + * comes back with the issues to show, so the question a person is looking at + * is still the question they are answering. */ - answer(choice: string): boolean; + submit(values: Readonly>): ReplFormOutcome; } /** What the application reads to draw the question, and to know there is one. */ @@ -64,47 +117,313 @@ export interface ReplElicitations { readonly asked: number; } +/** The keywords a root object may carry. */ +const ROOT_KEYWORDS = new Set([ + "type", + "additionalProperties", + "properties", + "required", + "if", + "then", + "title", + "description", +]); + +/** The keywords one string field may carry. */ +const FIELD_KEYWORDS = new Set(["type", "enum", "minLength", "title", "description"]); + +/** The keywords the `if` object may carry. */ +const CONDITION_KEYWORDS = new Set(["type", "properties", "required", "additionalProperties"]); + +/** The keywords the `then` object may carry. */ +const CONSEQUENCE_KEYWORDS = new Set(["type", "properties", "required"]); + /** - * The form one normalized schema describes, or none when this REPL cannot - * present it. + * The constraints one conditionally required field may restate. * - * Read from the normalized schema core compiled rather than from the source - * that produced it: what the person is shown has to be what the answer will be - * judged against. + * Narrower than a root field on purpose: what `then` adds is a requirement and + * a minimum length, and those are the two things the form both draws and has + * judged. Anything else would be a rule nothing here represents. */ -export function readQuestionForm(schema: Json): ReplQuestionForm | undefined { - if (!isObject(schema) || schema["type"] !== "object") { - return undefined; +const CONSEQUENCE_FIELD_KEYWORDS = new Set(["type", "minLength"]); + +function refuse(what: string, path: string): never { + throw new ElicitationProviderError( + `this REPL presents a bounded form language, and this schema ${what} at ${path}. It asks ` + + "for something this surface cannot draw, so nobody was asked. Supported: a closed " + + "object of string fields, each optionally an enum or a minLength, with at most one " + + "if/then that requires further string fields when one field holds one exact value.", + ); +} + +function isObject(value: Json | undefined): value is { [key: string]: Json } { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Every keyword on this object that the subset does not accept. */ +function refuseUnknown(subject: { [key: string]: Json }, allowed: Set, path: string): void { + for (const keyword of Object.keys(subject)) { + if (!allowed.has(keyword)) { + refuse(`uses the unsupported keyword "${keyword}"`, `${path}.${keyword}`); + } } - if (schema["additionalProperties"] !== false) { - return undefined; +} + +/** The annotations a reader is shown, which change no rule. */ +function annotations( + subject: { [key: string]: Json }, + path: string, +): { + title: string | undefined; + description: string | undefined; +} { + for (const key of ["title", "description"]) { + const value = subject[key]; + if (value !== undefined && typeof value !== "string") { + refuse(`declares a non-string "${key}"`, `${path}.${key}`); + } } - const properties = schema["properties"]; - const required = schema["required"]; - if (!isObject(properties) || !Array.isArray(required) || required.length !== 1) { + return { + title: typeof subject["title"] === "string" ? subject["title"] : undefined, + description: typeof subject["description"] === "string" ? subject["description"] : undefined, + }; +} + +/** The exact values one enum offers, or none when the field declares none. */ +function choicesOf(field: { [key: string]: Json }, path: string): readonly string[] | undefined { + const declared = field["enum"]; + if (declared === undefined) { return undefined; } - const names = Object.keys(properties); - const field = required[0]; - if (names.length !== 1 || typeof field !== "string" || names[0] !== field) { - return undefined; + if (!Array.isArray(declared) || declared.length === 0) { + refuse("declares an enum that is not a non-empty array", `${path}.enum`); + } + const offered: string[] = []; + for (const [index, choice] of declared.entries()) { + if (typeof choice !== "string") { + refuse("declares a non-string enum member", `${path}.enum[${index}]`); + } + offered.push(choice); } - const property = properties[field]; - if (!isObject(property) || property["type"] !== "string") { + return Object.freeze(offered); +} + +/** The shortest accepted value, when the field states one. */ +function minLengthOf(field: { [key: string]: Json }, path: string): number | undefined { + const declared = field["minLength"]; + if (declared === undefined) { return undefined; } - const choices = property["enum"]; - if (!Array.isArray(choices) || choices.length === 0) { + if (typeof declared !== "number" || !Number.isInteger(declared) || declared < 0) { + refuse("declares a minLength that is not a non-negative integer", `${path}.minLength`); + } + return declared; +} + +/** One list of names the schema declared, checked for shape rather than meaning. */ +function namesOf(declared: Json | undefined, path: string): readonly string[] { + if (!Array.isArray(declared)) { + refuse('declares a "required" that is not an array', path); + } + const names: string[] = []; + for (const [index, name] of declared.entries()) { + if (typeof name !== "string") { + refuse("declares a non-string required name", `${path}[${index}]`); + } + if (names.includes(name)) { + refuse(`declares "${name}" as required twice`, `${path}[${index}]`); + } + names.push(name); + } + return names; +} + +/** The one conditional this subset accepts, read from `if` and `then` together. */ +function conditionOf( + root: { [key: string]: Json }, + declared: ReadonlySet, +): ReplFormCondition | undefined { + const when = root["if"]; + const consequence = root["then"]; + if (when === undefined && consequence === undefined) { return undefined; } - const offered: string[] = []; - for (const choice of choices) { - if (typeof choice !== "string") { - return undefined; + if (when === undefined) { + refuse('declares a "then" with no "if"', "$.then"); + } + if (consequence === undefined) { + refuse('declares an "if" with no "then"', "$.if"); + } + if (!isObject(when)) { + refuse('declares an "if" that is not an object schema', "$.if"); + } + if (!isObject(consequence)) { + refuse('declares a "then" that is not an object schema', "$.then"); + } + refuseUnknown(when, CONDITION_KEYWORDS, "$.if"); + refuseUnknown(consequence, CONSEQUENCE_KEYWORDS, "$.then"); + const sides: readonly { readonly side: string; readonly schema: { [key: string]: Json } }[] = [ + { side: "if", schema: when }, + { side: "then", schema: consequence }, + ]; + for (const { side, schema } of sides) { + if (schema["type"] !== undefined && schema["type"] !== "object") { + refuse(`declares a "${side}" whose type is not object`, `$.${side}.type`); } - offered.push(choice); } - return { field, choices: Object.freeze(offered) }; + if (when["additionalProperties"] !== undefined && when["additionalProperties"] !== false) { + refuse('declares an "if" that is not closed', "$.if.additionalProperties"); + } + + const tested = when["properties"]; + if (!isObject(tested)) { + refuse('declares an "if" with no tested properties', "$.if.properties"); + } + const names = Object.keys(tested); + if (names.length !== 1) { + refuse("tests more than one field in its condition", "$.if.properties"); + } + const field = names[0]!; + if (!declared.has(field)) { + refuse(`tests "${field}", which the root does not declare`, `$.if.properties.${field}`); + } + const test = tested[field]; + if (!isObject(test)) { + refuse("declares a condition that is not an object schema", `$.if.properties.${field}`); + } + refuseUnknown(test, new Set(["const", "type"]), `$.if.properties.${field}`); + // Core evaluates this type as part of the condition. This form reduces the + // whole condition to one string equality, so a tested type of anything else + // would draw a form that requires different fields than validation does. + if (test["type"] !== undefined && test["type"] !== "string") { + refuse("tests its field as a type other than string", `$.if.properties.${field}.type`); + } + const equals = test["const"]; + if (typeof equals !== "string") { + refuse( + "tests its field with something other than a string const", + `$.if.properties.${field}.const`, + ); + } + // Not defaulted to the tested field. `properties` does not require a property + // to exist, so an `if` without `required` also matches an object where the + // field is absent — Core would apply `then` there, and this form would be + // waiting for a value the person never has to give. + if (when["required"] === undefined) { + refuse("tests a field without requiring it to be present", "$.if.required"); + } + const requiredByCondition = namesOf(when["required"], "$.if.required"); + if (requiredByCondition.length !== 1 || requiredByCondition[0] !== field) { + refuse("requires something other than the field it tests", "$.if.required"); + } + + // What the condition adds. Each name has to be a field the root declared, and + // may restate its own string constraints; nothing new is introduced here. + const added = namesOf(consequence["required"], "$.then.required"); + const constraints: ReplFieldConstraint[] = []; + const restated = consequence["properties"]; + if (restated !== undefined && !isObject(restated)) { + refuse('declares a "then" properties that is not an object', "$.then.properties"); + } + for (const name of added) { + if (!declared.has(name)) { + refuse(`requires "${name}", which the root does not declare`, "$.then.required"); + } + const own = restated === undefined ? undefined : restated[name]; + if (own !== undefined && !isObject(own)) { + refuse( + "restates a field as something other than an object schema", + `$.then.properties.${name}`, + ); + } + if (own !== undefined) { + // Only the constraints this condition actually models. A conditional + // `enum`, title or description would be accepted and then never drawn or + // enforced by anything here, so it is refused rather than ignored. + refuseUnknown(own, CONSEQUENCE_FIELD_KEYWORDS, `$.then.properties.${name}`); + if (own["type"] !== undefined && own["type"] !== "string") { + refuse("restates a field as a type other than string", `$.then.properties.${name}.type`); + } + } + constraints.push( + Object.freeze({ + name, + minLength: own === undefined ? undefined : minLengthOf(own, `$.then.properties.${name}`), + }), + ); + } + if (restated !== undefined) { + for (const name of Object.keys(restated)) { + if (!added.includes(name)) { + refuse(`restates "${name}" without requiring it`, `$.then.properties.${name}`); + } + } + } + return Object.freeze({ field, equals, requires: Object.freeze(constraints) }); +} + +/** + * The form one normalized schema describes, or a refusal naming why not. + * + * Read from the normalized schema Core compiled rather than from the source that + * produced it: what the person is shown has to be what the answer will be judged + * against. Nothing here retains or freezes the caller's object — every value is + * copied out. + */ +export function readQuestionForm(schema: Json): ReplQuestionForm { + if (!isObject(schema)) { + refuse("is not an object schema", "$"); + } + refuseUnknown(schema, ROOT_KEYWORDS, "$"); + if (schema["type"] !== "object") { + refuse('declares a root that is not type "object"', "$.type"); + } + if (schema["additionalProperties"] !== false) { + refuse("declares a root that is not closed", "$.additionalProperties"); + } + const properties = schema["properties"]; + if (!isObject(properties)) { + refuse("declares no properties", "$.properties"); + } + const order = Object.keys(properties); + if (order.length === 0) { + refuse("declares no properties", "$.properties"); + } + const declared = new Set(order); + const required = namesOf(schema["required"] ?? [], "$.required"); + for (const name of required) { + if (!declared.has(name)) { + refuse(`requires "${name}", which it does not declare`, "$.required"); + } + } + + const fields: ReplFormField[] = []; + for (const name of order) { + const path = `$.properties.${name}`; + const field = properties[name]; + if (!isObject(field)) { + refuse("declares a field that is not an object schema", path); + } + refuseUnknown(field, FIELD_KEYWORDS, path); + if (field["type"] !== "string") { + refuse("declares a field whose type is not string", `${path}.type`); + } + fields.push( + Object.freeze({ + name, + ...annotations(field, path), + choices: choicesOf(field, path), + minLength: minLengthOf(field, path), + required: required.includes(name), + }), + ); + } + + return Object.freeze({ + ...annotations(schema, "$"), + fields: Object.freeze(fields), + condition: conditionOf(schema, declared), + }); } /** @@ -127,26 +446,44 @@ export function* useReplElicitation(): Operation { yield* Elicitation.around( { *elicit([request]: [ElicitationRequest]) { + // The whole schema, before anything else happens. A refusal here has + // asked nobody, moved no counter and published no drawer. const form = readQuestionForm(request.schema); - if (form === undefined) { - throw new ElicitationProviderError( - "this REPL presents one question shape: an object with one required string " + - "property whose values are an enum, and no other properties. The document asked " + - "for something else, so nobody was asked.", - ); - } + // Core's own compiler, so what the person is judged against is the + // thing that will judge the answer on its way out. + const prepared = yield* prepareElicitation(request.schema, "Elicit"); asked++; return yield* action(function (resolve) { + let settled = false; const question: ReplQuestion = { message: request.message, - schema: request.schema, + // No schema member. The parsed form is everything a reader needs, + // and retaining the caller's object would hand a live reference to + // whatever built it out to the application. form, - answer(choice: string): boolean { - if (!form.choices.includes(choice)) { - return false; + submit(values: Readonly>): ReplFormOutcome { + if (settled) { + return { kind: "invalid", issues: [] }; } - resolve({ [form.field]: choice }); - return true; + // Every property the form actually holds, copied into a plain + // object — the empty string included. An optional field someone + // deliberately cleared is present and empty, which a schema may + // well accept; dropping it would answer a different question from + // the one on the screen. A field nobody has touched is absent. + const assembled: Record = {}; + for (const field of form.fields) { + const value = values[field.name]; + if (value !== undefined) { + assembled[field.name] = value; + } + } + const issues = validateParsed(prepared.validate, assembled); + if (issues.length > 0) { + return { kind: "invalid", issues: Object.freeze([...issues]) }; + } + settled = true; + resolve(assembled); + return { kind: "answered", answer: Object.freeze({ ...assembled }) }; }, }; publish(question); @@ -169,7 +506,3 @@ export function* useReplElicitation(): Operation { }, }; } - -function isObject(value: Json | undefined): value is { [key: string]: Json } { - return value !== null && typeof value === "object" && !Array.isArray(value); -} diff --git a/packages/cli/src/repl/layout.ts b/packages/cli/src/repl/layout.ts index 22c4b587..820ee886 100644 --- a/packages/cli/src/repl/layout.ts +++ b/packages/cli/src/repl/layout.ts @@ -170,6 +170,21 @@ export function drawerWidth(size: ReplTerminalSize): number { return size.columns - 2 * Math.floor(size.columns / 8); } +/** + * How tall the drawer layer is, at one size. + * + * The same arithmetic the placement below uses, exported because what a drawer + * can hold decides how much of a long message it may show: a region that drew + * more rows than this would clip its own controls off the bottom. + */ +export function drawerHeight(size: ReplTerminalSize): number { + if (profileFor(size) === "too-small") { + return 0; + } + const body = size.rows - FOOTER_ROWS; + return body - 2 * Math.floor(body / 8); +} + /** Which profile a size gets. */ export function profileFor(size: ReplTerminalSize): ReplProfile { if (size.columns < NARROW.columns || size.rows < NARROW.rows) { diff --git a/packages/cli/src/repl/program.ts b/packages/cli/src/repl/program.ts index 32ebd6a8..80f9fd49 100644 --- a/packages/cli/src/repl/program.ts +++ b/packages/cli/src/repl/program.ts @@ -44,6 +44,7 @@ import type { ExecutionInstallation } from "@executablemd/core/host"; import { admitted, answered, + focusSettled, describeApplication, initialState, reduceRepl, @@ -52,7 +53,15 @@ import { stateFor, viewFor, } from "./application.ts"; -import type { ReplAction, ReplIntent, ReplLive, ReplState, ReplView } from "./application.ts"; +import type { Json, NormalizedIssue } from "@executablemd/core"; +import type { + ReplAction, + ReplFormMessage, + ReplIntent, + ReplLive, + ReplState, + ReplView, +} from "./application.ts"; import { decodeLocation, encodeLocation, resolveLocation } from "./route.ts"; import { replRepository } from "./journal.ts"; import type { ReplExecution } from "./journal.ts"; @@ -396,7 +405,13 @@ function* drive( if (aimed !== undefined) { const dispatched = yield* tree.dispatch(aimed); if (dispatched.ok && dispatched.value.outcome === "action") { - const transition = reduceRepl(state, dispatched.value.action, model, liveOf(current)); + const transition = reduceRepl( + state, + dispatched.value.action, + model, + liveOf(current), + yield* screen.size(), + ); state = transition.state; const performed = yield* perform( transition.intent, @@ -411,9 +426,22 @@ function* drive( // The entry exists now, so the draft that became it is finished. state = admitted(state); } - if (performed.answered === true) { + if (performed.answered !== undefined) { // The question took it, so the drawer that was asking is over. - state = answered(state); + // The model here is the history without this answer in it yet, + // which is what makes the record it adds recognisable. + state = answered(state, model, performed.answered); + } + if (performed.messages !== undefined) { + // Still the same question. What the schema said goes under the + // form, and every value stays where it was typed. + state = Object.freeze({ + ...state, + form: Object.freeze({ + ...state.form, + messages: Object.freeze([...performed.messages]), + }), + }); } if (performed.refusal !== undefined) { state = Object.freeze({ ...state, refusal: performed.refusal }); @@ -425,7 +453,13 @@ function* drive( if (delivered !== undefined) { const dispatched = yield* tree.dispatch(delivered); if (dispatched.ok && dispatched.value.outcome === "action") { - const transition = reduceRepl(state, dispatched.value.action, model, liveOf(current)); + const transition = reduceRepl( + state, + dispatched.value.action, + model, + liveOf(current), + yield* screen.size(), + ); state = transition.state; const performed = yield* perform( transition.intent, @@ -440,9 +474,22 @@ function* drive( // The entry exists now, so the draft that became it is finished. state = admitted(state); } - if (performed.answered === true) { + if (performed.answered !== undefined) { // The question took it, so the drawer that was asking is over. - state = answered(state); + // The model here is the history without this answer in it yet, + // which is what makes the record it adds recognisable. + state = answered(state, model, performed.answered); + } + if (performed.messages !== undefined) { + // Still the same question. What the schema said goes under the + // form, and every value stays where it was typed. + state = Object.freeze({ + ...state, + form: Object.freeze({ + ...state.form, + messages: Object.freeze([...performed.messages]), + }), + }); } if (performed.refusal !== undefined) { state = Object.freeze({ ...state, refusal: performed.refusal }); @@ -499,16 +546,18 @@ function* drive( // moved, draw once more with where it actually is. Otherwise the marker is // always one keystroke behind, and a person reaching for a control would be // acting on the one after it. - const settledFocus = keyOfFocus(tree); + let settledFocus = keyOfFocus(tree); + // A focus claim this commit satisfied is spent here, at the commit that + // satisfied it: what the tree answers is the only thing that says whether + // the control a claim named actually took it. + state = focusSettled(view, settledFocus); if (settledFocus !== focused) { focused = settledFocus; - rendered = yield* paint( - frames, - tree, - renderer, - screen, - yield* build(state, model, current, yield* screen.size(), focused), - ); + const redrawn = yield* build(state, model, current, yield* screen.size(), focused); + rendered = yield* paint(frames, tree, renderer, screen, redrawn); + settledFocus = keyOfFocus(tree); + state = focusSettled(redrawn, settledFocus); + focused = settledFocus; } if (ended) { // End of input is a lifecycle outcome: the last frame is drawn, and then @@ -620,12 +669,47 @@ function* watch(session: ReplSession, wakes: Wakes): Operation { }); } +/** + * One normalized issue as a form message. + * + * The field is read from the issue's own instance path, so a message sits under + * the field it is about. An issue about the object as a whole — a missing + * required name — carries no field and is shown against the form. + */ +function reported(outcome: { readonly issues: readonly NormalizedIssue[] }): ReplFormMessage[] { + return outcome.issues.map((issue) => { + const named = /^\/([^/]+)/.exec(issue.instancePath)?.[1]; + const missing = + issue.keyword === "required" && isObject(issue.params) + ? issue.params["missingProperty"] + : undefined; + const field = named ?? (typeof missing === "string" ? missing : undefined); + return { ...(field === undefined ? { field: undefined } : { field }), message: issue.message }; + }); +} + +function isObject(value: Json): value is { [key: string]: Json } { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + /** What performing one intent produced. */ interface Performed { /** The session that stands now, when submitting produced a different one. */ readonly session?: ReplSession; - /** Whether a question took the answer it was given and is now over. */ - readonly answered?: boolean; + /** + * The exact object a question took, when one did and is now over. + * + * The object rather than a flag: the record it causes appends later, and this + * is what will tell that record apart from every other answer in the history. + */ + readonly answered?: Json; + /** + * Why the object it was given is not yet an answer. + * + * The question is untouched and still pending; these go back into application + * state so the form a person is looking at can say what is wrong with it. + */ + readonly messages?: readonly ReplFormMessage[]; /** Why it could not be done, for the screen to say. */ readonly refusal?: string; } @@ -657,13 +741,21 @@ function* perform( wakes.send({ kind: "session" }); return {}; case "answer": { - // A choice the form does not offer is not an answer: the question stays - // open, nothing is appended, and the drawer stays up holding what was - // typed so it can be corrected. Only an accepted answer ends the - // question, and the route has to end with it. - const accepted = session.overlay.question?.answer(intent.choice) ?? false; + // An object the schema rejects is not an answer: the question stays open, + // nothing is appended, and the drawer stays up holding what was filled in + // so it can be corrected. Only a valid object ends the question, and the + // route has to end with it. + // + // The schema decides, through the same compiled validator the request + // carries. Nothing here reads the form or judges a value. + const outcome = session.overlay.question?.submit(intent.values); wakes.send({ kind: "session" }); - return accepted ? { answered: true } : {}; + if (outcome === undefined) { + return {}; + } + return outcome.kind === "answered" + ? { answered: outcome.answer } + : { messages: reported(outcome) }; } case "submit": { const submitted = yield* submitReplEntry({ diff --git a/packages/cli/tests/repl-boundaries.test.ts b/packages/cli/tests/repl-boundaries.test.ts index 5face6ee..16856a7a 100644 --- a/packages/cli/tests/repl-boundaries.test.ts +++ b/packages/cli/tests/repl-boundaries.test.ts @@ -320,19 +320,53 @@ describe("REPL documentation: what it says is what the code does", () => { it("D1: the spec's Elicit metadata statement matches the real reader", function* () { const spec = yield* read(join(CLI, "..", "..", "specs", "repl-spec.md")); - // The spec says the drawer shows the one field the schema asks for and the - // values it will accept. The reader is what decides that. - expect(spec).toContain("the one field the\nschema asks for"); + // The spec says the drawer shows every field the schema declares, with its + // annotations, whether it is required, and the values it will accept. The + // reader is what decides all of that. + expect(spec).toContain("every field the schema declares"); const form = readQuestionForm({ type: "object", - properties: { decision: { type: "string", enum: ["approve", "decline"] } }, + properties: { + decision: { + type: "string", + enum: ["approve", "decline"], + title: "Decision", + description: "What to do with the draft", + }, + }, required: ["decision"], additionalProperties: false, }); - expect(form).toEqual({ field: "decision", choices: ["approve", "decline"] }); - // And a schema of another shape is not presentable, which is why the spec - // says one field rather than a form in general. - expect(readQuestionForm({ type: "string" })).toBeUndefined(); + // The complete reading, member for member: a form that dropped an + // annotation, a required marker or the condition would be a drawer showing + // less than the spec says it shows. + expect(form).toEqual({ + title: undefined, + description: undefined, + condition: undefined, + fields: [ + { + name: "decision", + title: "Decision", + description: "What to do with the draft", + choices: ["approve", "decline"], + minLength: undefined, + required: true, + }, + ], + }); + // And a schema this language does not model is refused by name and path + // before anything is published, which is why the spec describes the fields + // a schema declares rather than any schema at all. + let refused: unknown; + try { + readQuestionForm({ type: "string" }); + } catch (error) { + refused = error; + } + expect(refused).toBeInstanceOf(Error); + expect(refused instanceof Error ? refused.name : "").toBe("ElicitationProviderError"); + expect(refused instanceof Error ? refused.message : "").toContain("$.type"); }); it("D1: Freedom's recorded provenance still matches its manifest", function* () { diff --git a/packages/cli/tests/repl-execution.test.ts b/packages/cli/tests/repl-execution.test.ts index 41f863d9..c565ad5e 100644 --- a/packages/cli/tests/repl-execution.test.ts +++ b/packages/cli/tests/repl-execution.test.ts @@ -196,7 +196,7 @@ function* pausedOrDone(session: ReplSession): Operation { if (session.elicitation.pending !== undefined) { // A walk suspended on a question is not held: pause takes effect at the // walk's next boundary, which it only reaches once the question is over. - session.elicitation.pending.answer("approve"); + session.elicitation.pending.submit({ decision: "approve" }); } const next = yield* race([ states.next(), @@ -230,10 +230,27 @@ describe("REPL execution: submitting one entry", () => { const question = yield* nextQuestion(session); expect(question.message).toContain("Approve Ship the REPL?"); - expect(question.form).toEqual({ field: "decision", choices: ["approve", "decline"] }); + // The whole form the schema reads as, member for member: every field the + // drawer draws, with its annotations, its required marker and the values it + // accepts. + expect(question.form).toEqual({ + title: undefined, + description: undefined, + condition: undefined, + fields: [ + { + name: "decision", + title: undefined, + description: undefined, + choices: ["approve", "decline"], + minLength: undefined, + required: true, + }, + ], + }); expect(session.model.entry?.elicitations).toEqual([]); - expect(question.answer("approve")).toBe(true); + expect(question.submit({ decision: "approve" }).kind).toBe("answered"); yield* session.join(); const entry = session.model.entry; @@ -279,7 +296,7 @@ describe("REPL execution: submitting one entry", () => { expect(latest).toBe(session.overlay.output); expect(session.model.terminal).toBe(undefined); - question.answer("approve"); + question.submit({ decision: "approve" }); yield* session.join(); }); @@ -287,7 +304,7 @@ describe("REPL execution: submitting one entry", () => { const holder = execution(); const source = yield* referenceSource(); const session = opened(yield* submitReplEntry({ ...options(holder), source })); - (yield* nextQuestion(session)).answer("decline"); + (yield* nextQuestion(session)).submit({ decision: "decline" }); yield* session.join(); const generated = session.model.entry?.generated[0]; @@ -328,7 +345,7 @@ describe("REPL execution: submitting one entry", () => { expect(admitted).toBe(true); controlling(session).resume(); - (yield* nextQuestion(session)).answer("approve"); + (yield* nextQuestion(session)).submit({ decision: "approve" }); yield* session.join(); // The admitted fragment then ran where it was written: its rendering @@ -357,7 +374,9 @@ describe("REPL execution: submitting one entry", () => { const session = opened(yield* submitReplEntry({ ...options(holder), source })); const question = yield* nextQuestion(session); - expect(question.answer("maybe")).toBe(false); + // Not an offered value, so the schema rejects it: the question stays open and + // nothing is recorded. + expect(question.submit({ decision: "maybe" }).kind).toBe("invalid"); yield* sleep(10); // An incomplete interaction, not a rejected answer. The provider never @@ -370,7 +389,7 @@ describe("REPL execution: submitting one entry", () => { expect(counted(yield* readAll(holder), "elicit")).toBe(0); expect(session.model.settled).toBe(false); - question.answer("approve"); + question.submit({ decision: "approve" }); yield* session.join(); expect(counted(yield* readAll(holder), "elicit")).toBe(1); }); @@ -397,7 +416,7 @@ describe("REPL execution: reopening one history", () => { const performed = yield* countPerformed(); const session = opened(yield* openReplSession(options(holder))); - (yield* nextQuestion(session)).answer("approve"); + (yield* nextQuestion(session)).submit({ decision: "approve" }); yield* session.join(); const events = yield* readAll(holder); @@ -426,7 +445,7 @@ describe("REPL execution: reopening one history", () => { const asked = yield* countAsked(); const session = opened(yield* submitReplEntry({ ...options(holder), source })); - (yield* nextQuestion(session)).answer("approve"); + (yield* nextQuestion(session)).submit({ decision: "approve" }); yield* session.join(); // The positive control for every count the negatives rely on. Without it, a @@ -475,7 +494,7 @@ describe("REPL execution: reopening one history", () => { yield* scoped(function* () { const session = opened(yield* submitReplEntry({ ...options(holder), source })); expect(holder.stream.onAppend).not.toBe(null); - (yield* nextQuestion(session)).answer("approve"); + (yield* nextQuestion(session)).submit({ decision: "approve" }); yield* session.join(); ended = session; }); @@ -628,7 +647,7 @@ describe("REPL execution: pausing expansion", () => { // Answering lets the already-started operation settle. Its record reaches // the Journal while the pause request is outstanding, which is the whole // point: the expansion position and the Journal head are two positions. - question.answer("approve"); + question.submit({ decision: "approve" }); yield* pausedOrDone(session); const atPause = (yield* readAll(holder)).length; @@ -662,7 +681,7 @@ describe("REPL execution: pausing expansion", () => { expect(afterFirst).toBeGreaterThan(held); expect(controlling(session).released).toBe(afterFirst); - (yield* nextQuestion(session)).answer("approve"); + (yield* nextQuestion(session)).submit({ decision: "approve" }); yield* session.join(); }); @@ -681,7 +700,7 @@ describe("REPL execution: pausing expansion", () => { expect((yield* readAll(holder)).length).toBe(frozen); controlling(session).resume(); - (yield* nextQuestion(session)).answer("approve"); + (yield* nextQuestion(session)).submit({ decision: "approve" }); yield* session.join(); expect(session.model.settled).toBe(true); }); @@ -750,7 +769,7 @@ describe("REPL execution: the boundary inventory", () => { // The controller, not a snapshot of its crossings: `crossings` answers // with what has been crossed *so far*, and so far is nothing yet. const controller = controlling(session); - (yield* nextQuestion(session)).answer("approve"); + (yield* nextQuestion(session)).submit({ decision: "approve" }); yield* session.join(); const crossings = controller.crossings; diff --git a/packages/cli/tests/repl-forms.test.ts b/packages/cli/tests/repl-forms.test.ts new file mode 100644 index 00000000..46e1229b --- /dev/null +++ b/packages/cli/tests/repl-forms.test.ts @@ -0,0 +1,1203 @@ +/** + * Bounded Elicit forms (#854 F1, F2, F3). + * + * Three levels, each as small as the claim allows. F1 is the schema language on + * its own, because parsing is a pure question. F2 drives the real provider — the + * one `useReplElicitation()` installs — through the real reducer, so what judges + * a submission is the compiled schema and not a test's idea of one. F3 reads the + * described frame, because what a person can see and reach is a property of the + * rows, not of a screenshot. + */ + +import { beforeAll, describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { race, sleep, spawn, withResolvers } from "effection"; +import type { Operation, Result } from "effection"; +import { + Elicitation, + prepareElicitation, + useTempFileCompiler, + validateParsed, +} from "@executablemd/core"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import type { Json } from "@executablemd/core"; + +import { readQuestionForm, useReplElicitation } from "../src/repl/elicitation.ts"; +import type { ReplQuestion } from "../src/repl/elicitation.ts"; +import { + answered, + describeApplication, + EMPTY_FORM, + focusClaim, + focusSettled, + initialState, + reduceRepl, + viewFor, +} from "../src/repl/application.ts"; +import type { + ReplAction, + ReplLive, + ReplState, + ReplTransition, + ReplView, +} from "../src/repl/application.ts"; +import { layout, NARROW } from "../src/repl/layout.ts"; +import { fields, readDescription } from "../src/repl/description.ts"; +import type { ReplDescription } from "../src/repl/description.ts"; +import { ENTRY_SCOPE, projectRepl } from "../src/repl/model.ts"; +import type { ReplElicitation, ReplModel, ReplScope } from "../src/repl/model.ts"; +import { useReplTree } from "../src/repl/reconcile.ts"; +import { replSurface } from "../src/repl/application.ts"; +import type { ReplTree } from "../src/repl/reconcile.ts"; +import { submitReplEntry } from "../src/repl/session.ts"; +import type { ReplSession } from "../src/repl/session.ts"; +import type { ReplExecution } from "../src/repl/journal.ts"; +import { ordinaryEvaluationProfile } from "../src/evaluation-profile.ts"; +import { referenceEvents } from "./fixtures/repl/reference.ts"; + +/** The widest accepted frame, stated here because layout keeps it private. */ +const WIDE = { columns: 160, rows: 36 }; + +/** The packaged Plan's review schema, as `Plan.md` writes it. */ +const PLAN_SCHEMA: Json = { + type: "object", + properties: { + decision: { type: "string", enum: ["Approve", "Request changes", "Stop"] }, + feedback: { type: "string" }, + }, + required: ["decision"], + additionalProperties: false, + if: { + type: "object", + properties: { decision: { const: "Request changes" } }, + required: ["decision"], + }, + then: { + type: "object", + required: ["feedback"], + properties: { feedback: { type: "string", minLength: 1 } }, + }, +}; + +/** Project details: two required non-empty strings, both annotated. */ +const DETAILS_SCHEMA: Json = { + type: "object", + title: "Project details", + description: "Name the project and say what it is.", + properties: { + project: { type: "string", minLength: 1, title: "Project", description: "Its short name." }, + description: { type: "string", minLength: 1, title: "Description" }, + }, + required: ["project", "description"], + additionalProperties: false, +}; + +/** Confirmation: one required enum. */ +const CONFIRM_SCHEMA: Json = { + type: "object", + properties: { decision: { type: "string", enum: ["Approve", "Decline"] } }, + required: ["decision"], + additionalProperties: false, +}; + +const EMPTY_MODEL: ReplModel = Object.freeze({ + head: true, + entry: undefined, + settled: false, + terminal: undefined, + checkpoints: Object.freeze([]), + transcript: Object.freeze([]), + turns: Object.freeze([]), + sessions: Object.freeze([]), + selection: undefined, +}); + +/** A live reading with one question waiting. */ +function asking(question: ReplQuestion | undefined): ReplLive { + return { output: "", question, expansion: "playing", pausable: false }; +} + +describe("F1 — the bounded language is exact", () => { + it("F1: the Plan review schema parses into its complete form", function* () { + const form = readQuestionForm(PLAN_SCHEMA); + // Field order follows the schema's own property order. + expect(form.fields.map((one) => one.name)).toEqual(["decision", "feedback"]); + const [decision, feedback] = form.fields; + expect(decision?.choices).toEqual(["Approve", "Request changes", "Stop"]); + expect(decision?.required).toBe(true); + expect(feedback?.required).toBe(false); + expect(feedback?.choices).toBe(undefined); + // The conditional, exactly as written: which field, which value, and what + // becomes required with what constraint. + expect(form.condition).toEqual({ + field: "decision", + equals: "Request changes", + requires: [{ name: "feedback", minLength: 1 }], + }); + }); + + it("F1: project details and confirmation parse with their annotations", function* () { + const details = readQuestionForm(DETAILS_SCHEMA); + expect(details.title).toBe("Project details"); + expect(details.description).toBe("Name the project and say what it is."); + expect(details.fields.map((one) => one.name)).toEqual(["project", "description"]); + expect(details.fields.map((one) => one.title)).toEqual(["Project", "Description"]); + expect(details.fields[0]?.description).toBe("Its short name."); + expect(details.fields.map((one) => one.minLength)).toEqual([1, 1]); + expect(details.fields.every((one) => one.required)).toBe(true); + expect(details.condition).toBe(undefined); + + const confirm = readQuestionForm(CONFIRM_SCHEMA); + expect(confirm.fields).toHaveLength(1); + expect(confirm.fields[0]?.choices).toEqual(["Approve", "Decline"]); + }); + + it("F1: the parsed form is frozen, and the source schema is not", function* () { + const form = readQuestionForm(PLAN_SCHEMA); + expect(Object.isFrozen(form)).toBe(true); + expect(Object.isFrozen(form.fields)).toBe(true); + for (const field of form.fields) { + expect(Object.isFrozen(field)).toBe(true); + } + expect(Object.isFrozen(form.condition)).toBe(true); + // Nothing the caller owns was retained or frozen on its behalf. + expect(Object.isFrozen(PLAN_SCHEMA)).toBe(false); + }); + + const refusals: Array<[string, Json, string]> = [ + [ + "a field keyword it does not model", + { + type: "object", + properties: { feedback: { type: "string", pattern: "^x" } }, + additionalProperties: false, + }, + "$.properties.feedback.pattern", + ], + [ + "a root keyword it does not model", + { + type: "object", + properties: { a: { type: "string" } }, + additionalProperties: false, + oneOf: [], + }, + "$.oneOf", + ], + [ + "an open root", + { type: "object", properties: { a: { type: "string" } } }, + "$.additionalProperties", + ], + [ + "a nested object field", + { type: "object", properties: { a: { type: "object" } }, additionalProperties: false }, + "$.properties.a.type", + ], + [ + "a numeric field", + { type: "object", properties: { a: { type: "number" } }, additionalProperties: false }, + "$.properties.a.type", + ], + [ + "an empty enum", + { + type: "object", + properties: { a: { type: "string", enum: [] } }, + additionalProperties: false, + }, + "$.properties.a.enum", + ], + [ + "a mixed enum", + { + type: "object", + properties: { a: { type: "string", enum: ["x", 1] } }, + additionalProperties: false, + }, + "$.properties.a.enum[1]", + ], + [ + "a required name it does not declare", + { + type: "object", + properties: { a: { type: "string" } }, + required: ["b"], + additionalProperties: false, + }, + "$.required", + ], + [ + "a duplicate required name", + { + type: "object", + properties: { a: { type: "string" } }, + required: ["a", "a"], + additionalProperties: false, + }, + "$.required[1]", + ], + [ + "a then with no if", + { + type: "object", + properties: { a: { type: "string" } }, + additionalProperties: false, + then: { type: "object", required: ["a"] }, + }, + "$.then", + ], + [ + "an if with no then", + { + type: "object", + properties: { a: { type: "string" } }, + additionalProperties: false, + if: { type: "object", properties: { a: { const: "x" } }, required: ["a"] }, + }, + "$.if", + ], + [ + "a condition over a field the root does not declare", + { + type: "object", + properties: { a: { type: "string" } }, + additionalProperties: false, + if: { type: "object", properties: { b: { const: "x" } }, required: ["b"] }, + then: { type: "object", required: ["a"] }, + }, + "$.if.properties.b", + ], + [ + "a conditional property the root does not declare", + { + type: "object", + properties: { a: { type: "string" } }, + additionalProperties: false, + if: { type: "object", properties: { a: { const: "x" } }, required: ["a"] }, + then: { type: "object", required: ["b"] }, + }, + "$.then.required", + ], + [ + "a conditional constraint it does not model", + { + type: "object", + properties: { a: { type: "string" }, b: { type: "string" } }, + additionalProperties: false, + if: { type: "object", properties: { a: { const: "x" } }, required: ["a"] }, + then: { + type: "object", + required: ["b"], + properties: { b: { type: "string", enum: ["y"] } }, + }, + }, + "$.then.properties.b.enum", + ], + [ + "an if whose type is not object", + { + type: "object", + properties: { a: { type: "string" } }, + additionalProperties: false, + if: { type: "string", properties: { a: { const: "x" } }, required: ["a"] }, + then: { type: "object", required: ["a"] }, + }, + "$.if.type", + ], + [ + "a then whose type is not object", + { + type: "object", + properties: { a: { type: "string" } }, + additionalProperties: false, + if: { type: "object", properties: { a: { const: "x" } }, required: ["a"] }, + then: { type: "string", required: ["a"] }, + }, + "$.then.type", + ], + [ + // Core evaluates the tested type. This form reduces the condition to a + // string equality, so a numeric test would draw a form whose required + // fields disagree with validation. + "a condition that tests a type other than string", + { + type: "object", + properties: { a: { type: "string" }, b: { type: "string" } }, + additionalProperties: false, + if: { + type: "object", + properties: { a: { type: "number", const: "x" } }, + required: ["a"], + }, + then: { type: "object", required: ["b"] }, + }, + "$.if.properties.a.type", + ], + [ + // `properties` does not require a property to exist, so this condition + // also matches an object with no `a` at all. + "a condition that does not require the field it tests", + { + type: "object", + properties: { a: { type: "string" }, b: { type: "string" } }, + additionalProperties: false, + if: { type: "object", properties: { a: { const: "x" } } }, + then: { type: "object", required: ["b"] }, + }, + "$.if.required", + ], + ]; + + for (const [what, schema, path] of refusals) { + it(`F1: refuses ${what}, naming ${path}`, function* () { + let refused: unknown; + try { + readQuestionForm(schema); + } catch (error) { + refused = error; + } + expect(refused).toBeInstanceOf(Error); + expect(refused instanceof Error ? refused.name : "").toBe("ElicitationProviderError"); + // The first offending keyword, and where it sits. + expect(refused instanceof Error ? refused.message : "").toContain(path); + }); + } + + it("F1: a refused schema publishes nothing and asks nobody", function* () { + const elicitation = yield* useReplElicitation(); + const refused: string[] = []; + const outcome = yield* spawn(function* () { + try { + yield* Elicitation.operations.elicit({ + message: "unsupported", + schema: { + type: "object", + properties: { a: { type: "string", pattern: "^x" } }, + additionalProperties: false, + }, + }); + } catch (error) { + // The refusal belongs to whoever asked. Caught here so it does not + // also end this test, which is about what the provider did *not* do. + refused.push(error instanceof Error ? error.name : String(error)); + } + }); + // One turn is enough for the provider to have refused. + yield* sleep(0); + // It refused before publishing, so nothing was ever pending and the + // counter never moved. + expect(refused).toEqual(["ElicitationProviderError"]); + expect(elicitation.pending).toBe(undefined); + expect(elicitation.asked).toBe(0); + yield* outcome; + }); +}); + +/** The request shape the Api takes, from a schema written as a literal here. */ +function parsed(schema: Json): { [key: string]: Json } { + if (schema === null || typeof schema !== "object" || Array.isArray(schema)) { + throw new Error("these schemas are objects"); + } + return schema; +} + +/** Everything one live question needs in order to be driven. */ +interface Asked { + readonly question: ReplQuestion; + /** + * What the provider resolved with, once it has. + * + * `unknown`, because that is what the Api resolves with and an expectation + * reads a value rather than being told what it is. Read it after a turn of the + * loop: `submit` resolves the provider's action, and the asking coroutine + * resumes on the next turn. + */ + readonly answer: () => unknown; +} + +/** + * Install the real provider, ask one real question, and hand back the pending + * one. + * + * The question is the provider's own: what judges a submission is the schema + * Core compiled from the request, reached through the same `submit` the + * application reaches. + */ +function* askingFor(schema: Json, message = "Review the draft"): Operation { + const elicitation = yield* useReplElicitation(); + const settled: { value?: unknown } = {}; + yield* spawn(function* () { + settled.value = yield* Elicitation.operations.elicit({ message, schema: parsed(schema) }); + }); + // The provider publishes before it suspends, so one turn of the loop is + // enough for the question to exist. + yield* sleep(0); + const question = elicitation.pending; + if (question === undefined) { + throw new Error("the provider published no question"); + } + return { question, answer: () => settled.value }; +} + +/** + * One state with the live Elicit drawer open, which is how a form is filled. + * + * Opened through the ordinary action against the live question, because that is + * the only way the drawer opens: a route holding `+elicit` with nothing being + * asked is refused. + */ +function opened(live: ReplLive): ReplState { + const transition = reduceRepl( + initialState("forms"), + { kind: "open-drawer", drawer: { kind: "live-elicit" } }, + EMPTY_MODEL, + live, + ); + if (transition.state.refusal !== undefined) { + throw new Error(`the drawer did not open: ${transition.state.refusal}`); + } + return transition.state; +} + +function drive( + state: ReplState, + live: ReplLive, + actions: readonly ReplAction[], +): { state: ReplState; transitions: ReplTransition[] } { + let current = state; + const transitions: ReplTransition[] = []; + for (const action of actions) { + const transition = reduceRepl(current, action, EMPTY_MODEL, live, NARROW); + transitions.push(transition); + current = transition.state; + } + return { state: current, transitions }; +} + +describe("F2 — invalid stays open; valid is exact", () => { + it("F2: choosing Request changes with no feedback keeps the question and says why", function* () { + const asked = yield* askingFor(PLAN_SCHEMA); + const live = asking(asked.question); + const { state, transitions } = drive(opened(live), live, [ + { kind: "choose", field: "decision", option: "Request changes" }, + ]); + // Activating an option offers the whole object, as Enter does. + const intent = transitions[0]?.intent; + expect(intent?.kind).toBe("answer"); + expect(intent?.kind === "answer" ? intent.values : {}).toEqual({ + decision: "Request changes", + }); + // The schema rejects it, so nothing is answered and the question stands. + const outcome = asked.question.submit(state.form.values); + expect(outcome.kind).toBe("invalid"); + yield* sleep(0); + expect(asked.answer()).toBe(undefined); + const reported = outcome.kind === "invalid" ? outcome.issues : []; + expect(reported.length).toBeGreaterThan(0); + expect(JSON.stringify(reported)).toContain("feedback"); + }); + + it("F2: entering feedback then submitting resolves with exactly that object", function* () { + const asked = yield* askingFor(PLAN_SCHEMA); + const live = asking(asked.question); + const { state } = drive(opened(live), live, [ + { kind: "choose", field: "decision", option: "Request changes" }, + { kind: "type", text: "needs work", field: "feedback" }, + ]); + expect(asked.question.submit(state.form.values).kind).toBe("answered"); + yield* sleep(0); + expect(asked.answer()).toEqual({ decision: "Request changes", feedback: "needs work" }); + }); + + it("F2: each field edits independently, whichever one the control names", function* () { + const asked = yield* askingFor(DETAILS_SCHEMA); + const live = asking(asked.question); + // Typed into the second field first, then the first: an application that + // kept one global answer, or one that sent text to whichever field it had + // last recorded, would put both strings in one place. + const { state } = drive(opened(live), live, [ + { kind: "type", text: "a REPL", field: "description" }, + { kind: "type", text: "xmd", field: "project" }, + ]); + expect(state.form.values).toEqual({ description: "a REPL", project: "xmd" }); + expect(asked.question.submit(state.form.values).kind).toBe("answered"); + yield* sleep(0); + expect(asked.answer()).toEqual({ project: "xmd", description: "a REPL" }); + }); + + it("F2: both details are required, and one missing keeps the question open", function* () { + const asked = yield* askingFor(DETAILS_SCHEMA); + const live = asking(asked.question); + const { state } = drive(opened(live), live, [{ kind: "type", text: "xmd", field: "project" }]); + expect(asked.question.submit(state.form.values).kind).toBe("invalid"); + yield* sleep(0); + expect(asked.answer()).toBe(undefined); + }); + + it("F2: confirmation returns exactly its offered decision", function* () { + const asked = yield* askingFor(CONFIRM_SCHEMA); + const live = asking(asked.question); + const { state } = drive(opened(live), live, [ + { kind: "choose", field: "decision", option: "Decline" }, + ]); + expect(asked.question.submit(state.form.values).kind).toBe("answered"); + yield* sleep(0); + expect(asked.answer()).toEqual({ decision: "Decline" }); + }); + + it("F2: an option the field does not offer puts nothing into the form", function* () { + const asked = yield* askingFor(CONFIRM_SCHEMA); + const live = asking(asked.question); + const { state, transitions } = drive(opened(live), live, [ + { kind: "choose", field: "decision", option: "Maybe" }, + ]); + expect(transitions[0]?.intent.kind).toBe("none"); + expect(state.form.values).toEqual({}); + expect(state.refusal).toBeDefined(); + }); + + it("F2: an optional field deliberately emptied is present and empty", function* () { + const asked = yield* askingFor(PLAN_SCHEMA); + const live = asking(asked.question); + // Typed and then erased: the person cleared it, which is not the same as + // never having touched it, and an optional empty string is valid here. + const { state } = drive(opened(live), live, [ + { kind: "choose", field: "decision", option: "Approve" }, + { kind: "type", text: "x", field: "feedback" }, + { kind: "erase", field: "feedback" }, + ]); + expect(state.form.values["feedback"]).toBe(""); + expect(asked.question.submit(state.form.values).kind).toBe("answered"); + yield* sleep(0); + expect(asked.answer()).toEqual({ decision: "Approve", feedback: "" }); + }); + + it("F2: closing discards the draft, answers nothing, and reopens empty", function* () { + const asked = yield* askingFor(PLAN_SCHEMA); + const live = asking(asked.question); + const { state, transitions } = drive(opened(live), live, [ + { kind: "type", text: "half written", field: "feedback" }, + { kind: "close-drawer" }, + ]); + expect(state.form).toEqual(EMPTY_FORM); + // Closing owes nobody anything: a dismissal that came back as an answer + // would send the half-written draft to the schema on its way out. + expect(transitions[1]?.intent.kind).toBe("none"); + yield* sleep(0); + expect(asked.answer()).toBe(undefined); + // Still pending, and reopening starts with nothing in it. + const reopened = drive(state, live, [{ kind: "open-drawer", drawer: { kind: "live-elicit" } }]); + expect(reopened.state.form.values).toEqual({}); + }); + + it("F2: an astral scalar survives typing, and one erase removes one scalar", function* () { + const asked = yield* askingFor(DETAILS_SCHEMA); + const live = asking(asked.question); + const typed = drive(opened(live), live, [{ kind: "type", text: "é漢🙂", field: "project" }]); + expect(typed.state.form.values["project"]).toBe("é漢🙂"); + // One Unicode scalar, not one UTF-16 code unit: erasing the emoji leaves + // the two characters before it whole rather than half a surrogate pair. + const erased = drive(typed.state, live, [{ kind: "erase", field: "project" }]); + expect(erased.state.form.values["project"]).toBe("é漢"); + }); +}); + +/** Every described row, flattened, with its key and label. */ +function rowsOf(descriptions: readonly ReplDescription[]): Array<{ + key: string; + label: string; +}> { + const found: Array<{ key: string; label: string }> = []; + const walk = (description: ReplDescription): void => { + const read = readDescription(description); + const named = fields(read.input); + const label = named?.["label"] ?? named?.["text"] ?? ""; + found.push({ key: read.key, label: typeof label === "string" ? label : "" }); + for (const child of read.children ?? []) { + walk(child); + } + }; + for (const description of descriptions) { + walk(description); + } + return found; +} + +/** The view this state reads as, or the failure that stopped it. */ +function reading( + state: ReplState, + live: ReplLive, + model: ReplModel = EMPTY_MODEL, + size = NARROW, + focused?: string, +): ReplView { + const resolved = viewFor(state, model, live, size, focused); + if (!resolved.ok) { + throw resolved.error; + } + return resolved.value; +} + +/** The descriptions this state produces, or the failure that stopped it. */ +function seen(state: ReplState, live: ReplLive, size = NARROW, focused?: string) { + return describeApplication(reading(state, live, EMPTY_MODEL, size, focused)); +} + +function framed(state: ReplState, live: ReplLive, size = NARROW, focused?: string) { + return rowsOf(seen(state, live, size, focused)); +} + +const DRAFT = Array.from({ length: 40 }, (_, line) => `draft line ${line}`).join("\n"); + +describe("F3 — complete content and reachable navigation", () => { + /** Every key layout actually placed, with whether it can be pointed at. */ + function* placedKeys( + tree: ReplTree, + view: ReplView, + ): Operation> { + yield* applied(tree, view); + const frame = layout(NARROW, replSurface(tree, view)); + const keys = new Map(); + for (const cell of frame.cells) { + const key = tree.keyOf(cell.node); + if (key !== undefined) { + keys.set(key, cell.targetable); + } + } + return keys; + } + + /** The essential Plan controls a person has to be able to reach. */ + const ESSENTIAL = [ + "drawer:field:decision", + "drawer:choice:decision:Approve", + "drawer:choice:decision:Request changes", + "drawer:choice:decision:Stop", + "drawer:value:decision", + "drawer:field:feedback", + "drawer:value:feedback", + "drawer:form:submit", + "drawer:close", + "footer:history", + ]; + + it("F3: scrolling places every essential control in the narrow frame", function* () { + const asked = yield* askingFor(PLAN_SCHEMA, DRAFT); + const live = asking(asked.question); + const tree = yield* useReplTree(); + + // Walked from the first clamped position to the last, through the real + // tree and the real placement boundary. A described node layout never + // placed is not something a person can see or point at, so only placed + // cells count here. + let state = opened(live); + const reached = new Map(); + for (let press = 0; press < 120; press++) { + for (const [key, targetable] of yield* placedKeys( + tree, + reading(state, live, EMPTY_MODEL, NARROW, undefined), + )) { + if (targetable || !reached.has(key)) { + reached.set(key, targetable || (reached.get(key) ?? false)); + } + } + const next = reduceRepl(state, { kind: "scroll", delta: 1 }, EMPTY_MODEL, live, NARROW).state; + if (next.form.offset === state.form.offset) { + break; + } + state = next; + } + + for (const key of ESSENTIAL) { + expect([key, reached.has(key)]).toEqual([key, true]); + expect([key, reached.get(key)]).toEqual([key, true]); + } + }); + + it("F3: the viewport walks the whole message before the form controls", function* () { + const asked = yield* askingFor(PLAN_SCHEMA, DRAFT); + const live = asking(asked.question); + const shown = (rows: ReturnType): string[] => + rows.filter((one) => one.key.startsWith("drawer:message:")).map((one) => one.label.trim()); + + const first = framed(opened(live), live); + // Not every line at once, and the first window starts at the beginning. + expect(shown(first).length).toBeGreaterThan(0); + expect(shown(first).length).toBeLessThan(40); + expect(shown(first)[0]).toBe("draft line 0"); + + // Every message line is encountered, and all of them before the form's + // own controls come into view. + let state = opened(live); + const seenLines: string[] = []; + let sawSubmit = false; + let submitBeforeLastLine = false; + for (let press = 0; press < 120; press++) { + const rows = framed(state, live); + for (const label of shown(rows)) { + if (!seenLines.includes(label)) { + seenLines.push(label); + } + } + if (rows.some((one) => one.key === "drawer:form:submit")) { + sawSubmit = true; + if (seenLines.length < 40) { + submitBeforeLastLine = true; + } + } + const next = reduceRepl(state, { kind: "scroll", delta: 1 }, EMPTY_MODEL, live, NARROW).state; + if (next.form.offset === state.form.offset) { + break; + } + state = next; + } + + expect(seenLines).toHaveLength(40); + expect(seenLines[0]).toBe("draft line 0"); + expect(seenLines[39]).toBe("draft line 39"); + expect(sawSubmit).toBe(true); + expect(submitBeforeLastLine).toBe(false); + + // Clamped at the end rather than wrapping, and one press back moves + // immediately because the stored offset was clamped. + const held = state.form.offset; + state = reduceRepl(state, { kind: "scroll", delta: 1 }, EMPTY_MODEL, live, NARROW).state; + expect(state.form.offset).toBe(held); + const back = reduceRepl(state, { kind: "scroll", delta: -1 }, EMPTY_MODEL, live, NARROW).state; + expect(back.form.offset).toBe(held - 1); + + // And scrolling changed no value and no route. + expect(back.form.values).toEqual({}); + expect(back.route.drawers.map((one) => one.kind)).toEqual(["live-elicit"]); + }); + + it("F3: the one History control is inside the drawer while it is open", function* () { + const asked = yield* askingFor(PLAN_SCHEMA); + const live = asking(asked.question); + const descriptions = seen(opened(live), live); + // Exactly one node carries the key, and it is a child of the drawer + // rather than a sibling of it. + const all = rowsOf(descriptions).filter((one) => one.key === "footer:history"); + expect(all).toHaveLength(1); + const drawer = descriptions + .map((description) => readDescription(description)) + .find((read) => read.key === "drawer:open"); + expect(drawer).toBeDefined(); + const inside = rowsOf(drawer?.children ?? []).some((one) => one.key === "footer:history"); + expect(inside).toBe(true); + }); + + it("F3: the footer trigger is one bounded line, and the drawer holds it all", function* () { + const asked = yield* askingFor(PLAN_SCHEMA, DRAFT); + const live = asking(asked.question); + // Closed: the footer offers the question without becoming the transcript. + const closed = framed(initialState("forms"), live); + const trigger = closed.find((one) => one.key === "footer:asked"); + expect(trigger).toBeDefined(); + expect(trigger?.label.includes("\n")).toBe(false); + expect(trigger?.label).toContain("draft line 0"); + expect(trigger?.label).not.toContain("draft line 1"); + }); +}); + +describe("F3 — focus returns to the invocation, not to where the drawer came from", () => { + // The retained row focus lands on exists only once an answer is recorded, so + // the model here is a real execution's journal rather than a shape written by + // this test. + beforeAll(() => useTempFileCompiler()); + + it("F3: focus lands on the record this answer caused, once it appends", function* () { + // One real session over one real journal, asking three questions in a row. + // The record focus has to reach does not exist when the answer is taken — + // it appends when the invocation settles — so this drives the whole + // interval rather than a frame that already has everything in it. + const holder = execution(); + const session = granted( + yield* submitReplEntry({ + execution: holder, + installations: [{ evaluation: ordinaryEvaluationProfile() }], + source: THREE_QUESTIONS, + }), + ); + // Chained after the session's own observer, so a signal arrives with the + // session already reprojected — and so the session does not replace it. + const appended = elicitAppends(holder); + + // An answer this test did not cause, carrying the same value the one under + // test will carry. What excludes it is that it was already retained, not + // that it looks different. + const first = yield* waiting(session, "the first question"); + expect(first.message).toContain("First"); + expect(first.submit({ decision: "Approve" }).kind).toBe("answered"); + yield* appended(1); + expect(answers(session.model)).toHaveLength(1); + + // The question under test, in the mounted drawer. + const second = yield* waiting(session, "the question under test", first); + expect(second.message).toContain("Second"); + const held = liveReading(session); + const open = withScope(opened(held), ENTRY_SCOPE); + const tree = yield* useReplTree(); + yield* applied(tree, reading(open, held, session.model)); + const inside = keyed(tree); + expect(inside?.startsWith("drawer:")).toBe(true); + + // Answered by activating the option control the drawer mounted: Enter on + // it is one keystroke, and what it produces is the ordinary action. + yield* focusTo(tree, "drawer:choice:decision:Approve"); + const dispatched = yield* tree.dispatch({ kind: "key", key: "Enter" }); + if (!dispatched.ok || dispatched.value.outcome !== "action") { + throw new Error("the mounted option control produced no action"); + } + const transition = reduceRepl(open, dispatched.value.action, session.model, held, NARROW); + expect(transition.intent.kind).toBe("answer"); + if (transition.intent.kind !== "answer") { + throw new Error("activating an option offers the whole object"); + } + // The boundary the program performs it at, on the question this process is + // actually holding. + const outcome = session.overlay.question?.submit(transition.intent.values); + if (outcome?.kind !== "answered") { + throw new Error("the live question did not take its answer"); + } + // Nothing has appended yet: this is the interval the claim has to survive. + expect(answers(session.model)).toHaveLength(1); + let state = answered(transition.state, session.model, outcome.answer); + expect(state.restore?.kind).toBe("answered"); + + // A frame drawn in that interval claims nothing, because the row it would + // name is not there — and spends nothing either. + let view = reading(state, liveReading(session), session.model, NARROW, inside); + expect(focusClaim(view)).toBe(undefined); + yield* applied(tree, view); + const between = keyed(tree); + expect(between?.startsWith("elicit:")).toBe(false); + state = focusSettled(view, between); + expect(state.restore?.kind).toBe("answered"); + + // A third answer, with a different value, so the record under test is no + // longer the newest one by the time any frame can draw it. + const third = yield* waiting(session, "the third question", second); + expect(third.message).toContain("Third"); + expect(third.submit({ decision: "Stop" }).kind).toBe("answered"); + yield* appended(3); + + const retained = answers(session.model); + expect(retained).toHaveLength(3); + const caused = retained[1]; + const before = retained[0]; + const newest = retained[2]; + if (caused === undefined || before === undefined || newest === undefined) { + throw new Error("the entry retained three answers"); + } + // The one it did not cause has the same answer; the newest one is somebody + // else's. Identity here is both, or neither would be enough. + expect(before.answer).toEqual({ decision: "Approve" }); + expect(caused.answer).toEqual({ decision: "Approve" }); + expect(newest.answer).toEqual({ decision: "Stop" }); + + view = reading(state, liveReading(session), session.model, NARROW, between); + expect(focusClaim(view)).toBe(`elicit:${caused.marker}`); + yield* applied(tree, view); + const landed = keyed(tree); + expect(landed).toBe(`elicit:${caused.marker}`); + expect(landed).not.toBe(`elicit:${before.marker}`); + expect(landed).not.toBe(`elicit:${newest.marker}`); + + // Spent by the commit that satisfied it, so traversal from here is the + // person's: Tab moves, and the next frame leaves it where they moved it. + state = focusSettled(view, landed); + expect(state.restore).toBe(undefined); + const moved = yield* tabbed(tree); + expect(moved).not.toBe(landed); + yield* applied(tree, reading(state, liveReading(session), session.model, NARROW, moved)); + expect(keyed(tree)).toBe(moved); + }); + + it("F3: closing without answering moves focus to the invocation still asking", function* () { + const recorded = yield* retained(); + const asked = yield* askingFor(CONFIRM_SCHEMA); + const live = asking(asked.question); + const open = withScope(opened(live), recorded.scope); + const tree = yield* useReplTree(); + yield* applied(tree, reading(open, live, recorded.model)); + const inside = keyed(tree); + expect(inside?.startsWith("drawer:")).toBe(true); + + // Dismissed, not answered: the same question is still being asked, so the + // control that is asking it is where focus belongs — and it is the one + // control that opens this drawer again. + const closed = reduceRepl(open, { kind: "close-drawer" }, recorded.model, live, NARROW).state; + expect(closed.restore?.kind).toBe("asked"); + expect(closed.route.drawers).toEqual([]); + yield* applied(tree, reading(closed, live, recorded.model, NARROW, inside)); + expect(keyed(tree)).toBe("footer:asked"); + // Nothing was answered by closing it. + yield* sleep(0); + expect(asked.answer()).toBe(undefined); + }); + + it("F3: without the claim the drawer going leaves focus on an unrelated control", function* () { + const recorded = yield* retained(); + const asked = yield* askingFor(CONFIRM_SCHEMA); + const live = asking(asked.question); + const open = withScope(opened(live), recorded.scope); + const tree = yield* useReplTree(); + yield* applied(tree, reading(open, live, recorded.model)); + const inside = keyed(tree); + + // The same commit with the claim removed, which is what the tree does on + // its own: Freedom restores whatever held focus before the modal was + // pushed, and on this screen that is neither invocation. This is what the + // two rows above are distinguished from. + const taken = answered(open, recorded.model, { decision: "Approve" }); + const unclaimed = Object.freeze({ ...taken, restore: undefined }); + yield* applied(tree, reading(unclaimed, asking(undefined), recorded.model, NARROW, inside)); + const landed = keyed(tree); + expect(landed).not.toBe(`elicit:${recorded.marker}`); + expect(landed).toBe("sessions:heading"); + }); +}); + +/** One real journal with an answer in it, and the scope that shows it. */ +function* retained(): Operation<{ + readonly model: ReplModel; + readonly scope: string; + readonly marker: string; +}> { + const projected = projectRepl(yield* referenceEvents()); + if (!projected.ok) { + throw projected.error; + } + const entry = projected.value.entry; + const elicitation = entry?.elicitations[0]; + if (entry === undefined || elicitation === undefined) { + throw new Error("the reference execution recorded no answered elicitation"); + } + return { model: projected.value, scope: entry.key, marker: elicitation.marker }; +} + +/** The same state with one scope selected, which is what mounts its rows. */ +function withScope(state: ReplState, scope: string): ReplState { + return Object.freeze({ + ...state, + route: Object.freeze({ ...state.route, scopes: Object.freeze([scope]) }), + }); +} + +/** Commit one view into the real tree, refusing to assert past a rejected set. */ +function* applied(tree: ReplTree, view: ReplView): Operation { + const result = yield* tree.apply(describeApplication(view)); + if (!result.ok) { + throw result.error; + } +} + +/** The key of whatever holds focus now, as the root reads it. */ +function keyed(tree: ReplTree): string | undefined { + const node = tree.focused(); + return node === undefined ? undefined : tree.keyOf(node); +} + +/** One entry that asks three questions in a row, each from the same schema. */ +const THREE_QUESTIONS = [ + "```js eval", + `const decide = ${JSON.stringify({ + type: "object", + properties: { decision: { type: "string", enum: ["Approve", "Stop"] } }, + required: ["decision"], + additionalProperties: false, + })};`, + "```", + "", + 'First question', + "", + 'Second question', + "", + 'Third question', + "", + "{before.decision} {under.decision} {after.decision}", +].join("\n"); + +/** One execution with an empty journal of its own. */ +function execution(): ReplExecution { + return { id: "elicit-forms", stream: new InMemoryStream([]) }; +} + +/** The session, or the refusal that means there is nothing to drive. */ +function granted(result: Result): ReplSession { + if (!result.ok) { + throw result.error; + } + return result.value; +} + +/** This process's overlay, exactly as the program reads it into a view. */ +function liveReading(session: ReplSession): ReplLive { + return { + output: session.overlay.output, + question: session.overlay.question, + expansion: session.expansion.state, + pausable: session.controller !== undefined, + }; +} + +/** + * Wait until this process is asking a question that is not `previous`. + * + * The overlay is read rather than the change stream: a signal delivers to + * whoever is pulling at that moment, and the first question is published while + * the session is still being opened — before anything could have subscribed. + */ +function* waiting( + session: ReplSession, + what: string, + previous?: ReplQuestion, +): Operation { + for (let turn = 0; turn < 500; turn++) { + const question = session.overlay.question; + if (question !== undefined && question !== previous) { + return question; + } + yield* sleep(0); + } + throw new Error(`this process never asked ${what}`); +} + +/** + * Wait for the nth answer to be journaled. + * + * Chained onto the stream's own callback rather than watching the model: records + * only accumulate, so a count is something a test can wait for exactly, while a + * reprojection signal can be missed between reads. + */ +function elicitAppends(holder: ReplExecution): (count: number) => Operation { + const inner = holder.stream.onAppend; + const slots = new Map>>(); + const slot = (count: number): ReturnType> => { + const existing = slots.get(count); + if (existing !== undefined) { + return existing; + } + const created = withResolvers(); + slots.set(count, created); + return created; + }; + let seen = 0; + const written: string[] = []; + holder.stream.onAppend = (event) => { + inner?.(event); + written.push(event.type === "yield" ? event.description.type : "close"); + if (event.type === "yield" && event.description.type === "elicit") { + seen += 1; + slot(seen).resolve(); + } + }; + return function* (count: number): Operation { + if (seen >= count) { + return; + } + yield* race([ + slot(count).operation, + (function* (): Operation { + yield* sleep(5000); + throw new Error( + `only ${seen} of ${count} answers were journaled; the journal took ${written.join(", ")}`, + ); + })(), + ]); + }; +} + +/** Every answer the entry has retained, in the order they were recorded. */ +function answers(model: ReplModel): readonly ReplElicitation[] { + const found: ReplElicitation[] = []; + const walk = (scope: ReplScope): void => { + found.push(...scope.elicitations); + for (const child of scope.scopes) { + walk(child); + } + }; + if (model.entry !== undefined) { + walk(model.entry); + } + return found; +} + +/** Tab until the control this key names holds focus, the way a person reaches it. */ +function* focusTo(tree: ReplTree, key: string): Operation { + for (let press = 0; press < 200; press++) { + if (keyed(tree) === key) { + return; + } + yield* tree.dispatch({ kind: "key", key: "Tab" }); + } + throw new Error(`focus never reached ${key}`); +} + +/** One Tab, and where it left focus. */ +function* tabbed(tree: ReplTree): Operation { + yield* tree.dispatch({ kind: "key", key: "Tab" }); + return keyed(tree); +} + +describe("F1 — the form's conditional means exactly what Core validates", () => { + beforeAll(() => useTempFileCompiler()); + + /** What this reader did with a schema, as a refusal path or a drawn condition. */ + function* readingOf(schema: Json): Operation { + try { + const form = readQuestionForm(schema); + return `drew a condition on ${form.condition?.field ?? "nothing"}`; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + } + + it("F1: a numeric tested type is false where a string equality would be true", function* () { + const schema: Json = { + type: "object", + properties: { a: { type: "string" }, b: { type: "string" } }, + additionalProperties: false, + if: { + type: "object", + properties: { a: { type: "number", const: "x" } }, + required: ["a"], + }, + // `b` is declared here because Core compiles in strict mode, which + // requires a `required` name to be defined in the same subschema. + then: { type: "object", required: ["b"], properties: { b: { type: "string" } } }, + }; + + // What Core actually does: `a` is the string "x", the tested type is + // number, so the condition is false, `then` never applies and `b` is not + // required. The object validates. + const prepared = yield* prepareElicitation(schema, "Elicit"); + expect(validateParsed(prepared.validate, { a: "x" })).toEqual([]); + + // A form that reduced this to `a === "x"` would demand `b` for an answer + // Core accepts without it, so the reader refuses rather than disagree. + expect(yield* readingOf(schema)).toContain("$.if.properties.a.type"); + }); + + it("F1: an if with no required also matches the field being absent", function* () { + const schema: Json = { + type: "object", + properties: { a: { type: "string" }, b: { type: "string" } }, + additionalProperties: false, + if: { type: "object", properties: { a: { const: "x" } } }, + then: { type: "object", required: ["b"], properties: { b: { type: "string" } } }, + }; + + // What Core actually does: `properties` does not require `a` to exist, so + // the condition holds for an object with no `a` at all, `then` applies and + // the missing `b` is reported. + const prepared = yield* prepareElicitation(schema, "Elicit"); + expect(validateParsed(prepared.validate, {}).length).toBeGreaterThan(0); + + // A form that waited for `a === "x"` before asking for `b` would never ask + // for a field Core already requires, so the reader refuses. + expect(yield* readingOf(schema)).toContain("$.if.required"); + }); +}); diff --git a/specs/repl-spec.md b/specs/repl-spec.md index 80bf47e8..15e1cb1c 100644 --- a/specs/repl-spec.md +++ b/specs/repl-spec.md @@ -23,14 +23,22 @@ scopes the entry admitted, the bindings its `eval` blocks published, the source generated fragment produced before that fragment was admitted, and each line the document rendered. -When the entry asks a question, its drawer opens: the message, the one field the -schema asks for, and exactly the values it will accept. Type one and press Enter. -The answer is recorded, and what the document renders after it changes because of -the recorded answer rather than because of anything this process remembered. -Escape closes the drawer without answering; the question stays open. An answer -the schema accepts ends the question, and the drawer goes with it: it leaves the -screen and it leaves the location, because a URL naming a drawer nothing mounts -describes a view nobody can be shown. +When the entry asks a question, its drawer opens: the whole message, and then +every field the schema declares, with its title, its description, whether it is +required, and exactly the values it will accept. All of that is one ordered +whole, and it is that whole — not the message alone — that scrolls when it is +taller than the drawer. The earlier and later controls, the title and the close +control stay put while the content moves beneath them, so however short the +drawer is, scrolling reaches every field, every offered value and the control +that submits. Fill them in, or activate one of an enum's offered values, and +press Enter from any of them to offer the whole object. The answer is recorded, +and what the document renders after it changes because of the recorded answer +rather than because of anything this process remembered. An answer the schema +rejects keeps the question open and says what is wrong with it, under the field +it belongs to. Escape closes the drawer without answering; the question stays +open. An answer the schema accepts ends the question, and the drawer goes with +it: it leaves the screen and it leaves the location, because a URL naming a +drawer nothing mounts describes a view nobody can be shown. Press Enter on `[pause]` to stop expansion at its next boundary. `[continue]` exists only while a continuation is actually held — not while a pause is still