From 1ed421ebaf74a42a7967192c044b845008e4c4b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 05:57:32 +0000 Subject: [PATCH 01/32] eval: React reference for desk9 (grades 0: 77/77, 38/38, 0/20) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- eval/reference/desk9/react/App.tsx | 56 ++++ eval/reference/desk9/react/CommentList.tsx | 30 +++ eval/reference/desk9/react/HelpPage.tsx | 86 ++++++ eval/reference/desk9/react/IssueDetail.tsx | 80 ++++++ eval/reference/desk9/react/IssueList.tsx | 41 +++ eval/reference/desk9/react/IssuesPage.tsx | 279 ++++++++++++++++++++ eval/reference/desk9/react/Labels.tsx | 81 ++++++ eval/reference/desk9/react/Related.tsx | 29 ++ eval/reference/desk9/react/SettingsPage.tsx | 76 ++++++ eval/reference/desk9/react/errors.ts | 9 + 10 files changed, 767 insertions(+) create mode 100644 eval/reference/desk9/react/App.tsx create mode 100644 eval/reference/desk9/react/CommentList.tsx create mode 100644 eval/reference/desk9/react/HelpPage.tsx create mode 100644 eval/reference/desk9/react/IssueDetail.tsx create mode 100644 eval/reference/desk9/react/IssueList.tsx create mode 100644 eval/reference/desk9/react/IssuesPage.tsx create mode 100644 eval/reference/desk9/react/Labels.tsx create mode 100644 eval/reference/desk9/react/Related.tsx create mode 100644 eval/reference/desk9/react/SettingsPage.tsx create mode 100644 eval/reference/desk9/react/errors.ts diff --git a/eval/reference/desk9/react/App.tsx b/eval/reference/desk9/react/App.tsx new file mode 100644 index 00000000..f1f9dfb4 --- /dev/null +++ b/eval/reference/desk9/react/App.tsx @@ -0,0 +1,56 @@ +import { useState, type ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import IssuesPage from "./IssuesPage"; +import HelpPage from "./HelpPage"; +import SettingsPage from "./SettingsPage"; + +type Page = "issues" | "help" | "settings"; + +// Each page gets its own query cache, made when the page opens, so leaving a page discards what it loaded. +function PageClient({ children }: { children: ReactNode }) { + const [client] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, refetchOnWindowFocus: false, refetchOnReconnect: false }, + }, + }), + ); + return {children}; +} + +export default function App() { + const [page, setPage] = useState("issues"); + // the number of open issues the issues page last showed; null until they have loaded once + const [openCount, setOpenCount] = useState(null); + // where the help page opens: empty from its tab, on an issue's title from a related hit; every opening is a new visit + const [help, setHelp] = useState<{ query: string; selected: number | null; visit: number }>({ query: "", selected: null, visit: 0 }); + const openHelp = (query: string, selected: number | null) => { + setHelp(h => ({ query, selected, visit: h.visit + 1 })); + setPage("help"); + }; + return ( +
+ + + {page === "issues" ? ( + + ) : page === "help" ? ( + + ) : ( + + )} + +
+ ); +} diff --git a/eval/reference/desk9/react/CommentList.tsx b/eval/reference/desk9/react/CommentList.tsx new file mode 100644 index 00000000..3218198e --- /dev/null +++ b/eval/reference/desk9/react/CommentList.tsx @@ -0,0 +1,30 @@ +import type { Comment } from "./api"; +import { errorMessage } from "./errors"; + +export type Sending = { seq: number; issueId: number; author: string; text: string }; + +type Props = { + comments: Comment[] | undefined; + isPending: boolean; + error: unknown; + sending: Sending[]; +}; + +export default function CommentList({ comments, isPending, error, sending }: Props) { + if (isPending) return

loading comments

; + if (error || !comments) return

comments error: {errorMessage(error)}

; + return ( +
    + {comments.map((c, i) => ( +
  • + {c.author}: {c.text} +
  • + ))} + {sending.map(p => ( +
  • + {p.author}: {p.text} (sending…) +
  • + ))} +
+ ); +} diff --git a/eval/reference/desk9/react/HelpPage.tsx b/eval/reference/desk9/react/HelpPage.tsx new file mode 100644 index 00000000..beb7c919 --- /dev/null +++ b/eval/reference/desk9/react/HelpPage.tsx @@ -0,0 +1,86 @@ +import { useState } from "react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { fetchItem, search, type Hit } from "./api"; + +// Opens on `initialQuery` with `initialSelected` chosen (both empty from the tab; a related hit opens it on its issue's +// title). +export default function HelpPage({ initialQuery = "", initialSelected = null }: { initialQuery?: string; initialSelected?: number | null }) { + const [query, setQuery] = useState(initialQuery); + const [selected, setSelected] = useState(initialSelected); + return ( +
+ setQuery(e.target.value)} />{" "} + {query !== "" && }{" "} + +
+ ); +} + +function Results({ query, selected, onSelect }: { query: string; selected: number | null; onSelect: (id: number) => void }) { + const tooShort = query.length < 2; + const { data, error, isFetching } = useQuery({ + queryKey: ["search", query], + queryFn: () => search(query), + enabled: !tooShort, + placeholderData: keepPreviousData, + }); + if (tooShort) + return ( +
+

too short: {query}

+
+ ); + return ( +
+ {isFetching &&

searching…

} + {error && !isFetching ? ( +

search failed: {error.message}

+ ) : ( + data && ( +
+

+ {data.length} results for {query} +

+
    + {data.map((hit: Hit) => ( +
  • onSelect(hit.id)}> + {hit.id === selected ? "> " : ""} + {hit.name} +
  • + ))} +
+
+ ) + )} +
+ ); +} + +function HelpItem({ id }: { id: number | null }) { + const { data, error, isPending, refetch } = useQuery({ + queryKey: ["item", id], + queryFn: () => fetchItem(id!), + enabled: id !== null, + }); + if (id === null) return

pick a result

; + return ( +
+ {isPending ? ( +

loading…

+ ) : error ? ( + <> +

failed: {error.message}

{" "} + + + ) : data === null ? ( +

item {id} not found

+ ) : ( +
+

{data.name}

{data.blurb}

+
+ )} +
+ ); +} diff --git a/eval/reference/desk9/react/IssueDetail.tsx b/eval/reference/desk9/react/IssueDetail.tsx new file mode 100644 index 00000000..576d75c3 --- /dev/null +++ b/eval/reference/desk9/react/IssueDetail.tsx @@ -0,0 +1,80 @@ +import type { Comment, IssueDetail as Detail } from "./api"; +import { errorMessage } from "./errors"; +import CommentList, { type Sending } from "./CommentList"; +import Labels from "./Labels"; +import Related from "./Related"; + +type Props = { + id: number; + visit: number; + user: string; + detail: Detail | undefined; + loading: boolean; + error: unknown; + onRetry: () => void; + onAssign: () => void; + assignError: string | null; + titleInput: string; + onTitleInput: (title: string) => void; + onRename: () => void; + renameError: string | null; + onClose: () => void; + closeError: string | null; + comments: Comment[] | undefined; + commentsPending: boolean; + commentsError: unknown; + sending: Sending[]; + commentInput: string; + onCommentInput: (text: string) => void; + onPost: () => void; + postError: string | null; + onOpenHelp: (query: string, id: number) => void; +}; + +export default function IssueDetail(p: Props) { + let body; + if (p.loading) body =

loading #{p.id}

; + else if (p.error || !p.detail) body =

detail error: {errorMessage(p.error)}

; + else + body = ( + <> +

+ #{p.detail.id} {p.detail.title} +

+

{p.detail.body}

+ + + ); + return ( + <> + {body} + + + {p.assignError &&

{p.assignError}

} +
+ p.onTitleInput(e.target.value)} /> + + {p.renameError &&

rename error: {p.renameError}

} +
+ + {p.closeError &&

close error: {p.closeError}

} + +
+ +
+ p.onCommentInput(e.target.value)} /> + + {p.postError &&

post error: {p.postError}

} + + ); +} diff --git a/eval/reference/desk9/react/IssueList.tsx b/eval/reference/desk9/react/IssueList.tsx new file mode 100644 index 00000000..d4f9d8bb --- /dev/null +++ b/eval/reference/desk9/react/IssueList.tsx @@ -0,0 +1,41 @@ +import type { Issue } from "./api"; +import { errorMessage } from "./errors"; + +type Props = { + filter: "open" | "closed"; + issues: Issue[] | undefined; + isPending: boolean; + error: unknown; + selected: number | null; + user: string; + onSelect: (id: number) => void; + onRowAssign: (id: number) => void; + onRowClose: (id: number) => void; +}; + +export default function IssueList({ filter, issues, isPending, error, selected, user, onSelect, onRowAssign, onRowClose }: Props) { + if (isPending) return

loading {filter} issues

; + if (error || !issues) return

list error: {errorMessage(error)}

; + return ( +
    + {issues.map(issue => ( +
  • + {" "} + + {filter === "open" && ( + <> + {" "} + + + )} +
  • + ))} +
+ ); +} diff --git a/eval/reference/desk9/react/IssuesPage.tsx b/eval/reference/desk9/react/IssuesPage.tsx new file mode 100644 index 00000000..49ab25b4 --- /dev/null +++ b/eval/reference/desk9/react/IssuesPage.tsx @@ -0,0 +1,279 @@ +import { useEffect, useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { addComment, assign, closeIssue, fetchComments, fetchIssue, fetchIssues, renameIssue } from "./api"; +import type { Comment, Issue, IssueDetail as Detail } from "./api"; +import { errorIn, errorMessage, type VisitError } from "./errors"; +import IssueList from "./IssueList"; +import IssueDetail from "./IssueDetail"; +import type { Sending } from "./CommentList"; + +type Filter = "open" | "closed"; +// Where a write was started: the detail's buttons or a list row's. Each place reports its own failures. +type Origin = "detail" | "row"; +// a write still in the air: `seq` orders writes to the same issue, the latest one is what the issue shows +type Guess = { seq: number; value: T }; +type Guesses = Record[]>; + +const latest = (guesses: Guesses, id: number): T | undefined => guesses[id]?.at(-1)?.value; +const addGuess = (prev: Guesses, id: number, g: Guess): Guesses => ({ ...prev, [id]: [...(prev[id] ?? []), g] }); +const dropGuess = (prev: Guesses, id: number, seq: number): Guesses => { + const rest = (prev[id] ?? []).filter(g => g.seq !== seq); + const next = { ...prev }; + if (rest.length) next[id] = rest; + else delete next[id]; + return next; +}; + +type Props = { + /** told the number of open issues whenever the open list shows settled rows */ + onOpenCount: (n: number) => void; + onOpenHelp: (query: string, id: number) => void; +}; + +export default function IssuesPage({ onOpenCount, onOpenHelp }: Props) { + const queryClient = useQueryClient(); + const [user, setUser] = useState("guest"); + const [filter, setFilter] = useState("open"); + const [selected, setSelected] = useState(null); + // counts selections: the detail's errors and inputs belong to one selection + const [visit, setVisit] = useState(0); + const seqRef = useRef(0); + const nextSeq = () => ++seqRef.current; + + // assignments in the air, and the newest one confirmed per issue (an older answer must not overwrite it) + const [assigning, setAssigning] = useState>({}); + const [assignError, setAssignError] = useState(null); + const lastAssignRef = useRef(0); + const confirmedAssignRef = useRef>({}); + + const [renaming, setRenaming] = useState>({}); + const [renameError, setRenameError] = useState(null); + const [titleInput, setTitleInput] = useState(""); + const lastRenameRef = useRef(0); + const confirmedRenameRef = useRef>({}); + + // closes in the air, by issue id, with how many are out + const [closing, setClosing] = useState>({}); + const [closeError, setCloseError] = useState(null); + const lastCloseRef = useRef(0); + + const [sending, setSending] = useState([]); + const [postError, setPostError] = useState(null); + const [commentInput, setCommentInput] = useState(""); + const lastPostRef = useRef(0); + + // the newest row action (assign or close) is the one whose failure the row error reports + const [rowError, setRowError] = useState(null); + const lastRowActionRef = useRef(0); + + const list = useQuery({ queryKey: ["issues", filter], queryFn: () => fetchIssues(filter) }); + + const detail = useQuery({ + queryKey: ["issue", selected], + queryFn: async () => { + const id = selected as number; + const d = await fetchIssue(id); + if (d === null) throw new Error(`issue #${id} not found`); + return d; + }, + enabled: selected !== null, + }); + + const comments = useQuery({ + queryKey: ["comments", selected], + queryFn: () => fetchComments(selected as number), + enabled: selected !== null, + }); + + // the server's answers with the writes still in the air laid over them + const shownIssues = list.data + ?.filter(issue => !(filter === "open" && closing[issue.id])) + .map(issue => ({ + ...issue, + title: latest(renaming, issue.id) ?? issue.title, + assignee: latest(assigning, issue.id) ?? issue.assignee, + })); + const shownDetail = detail.data && { ...detail.data, title: latest(renaming, detail.data.id) ?? detail.data.title }; + + // the open list as shown (closes in the air already out of it) is the tab's count; the closed list, loads and + // failures leave the count as it was + const openCount = filter === "open" && !list.isFetching && shownIssues ? shownIssues.length : null; + useEffect(() => { + if (openCount !== null) onOpenCount(openCount); + }, [openCount, onOpenCount]); + + const select = (id: number) => { + setSelected(id); + setVisit(v => v + 1); + setTitleInput(""); + }; + + /** start a row action: it clears the row error, and only the newest one reports its failure */ + const startRowAction = () => { + const seq = nextSeq(); + lastRowActionRef.current = seq; + setRowError(null); + return (e: unknown) => { + if (lastRowActionRef.current === seq) setRowError(errorMessage(e)); + }; + }; + + const doAssign = async (id: number, who: string, origin: Origin) => { + const seq = nextSeq(); + let reportFailure: (e: unknown) => void; + if (origin === "row") reportFailure = startRowAction(); + else { + lastAssignRef.current = seq; + setAssignError(null); + reportFailure = e => { + if (lastAssignRef.current === seq) setAssignError({ visit, message: errorMessage(e) }); + }; + } + setAssigning(prev => addGuess(prev, id, { seq, value: who })); + try { + const res = await assign(id, who); + if ((confirmedAssignRef.current[id] ?? 0) < seq) { + confirmedAssignRef.current[id] = seq; + queryClient.setQueriesData({ queryKey: ["issues"] }, old => + old?.map(i => (i.id === id ? { ...i, assignee: res.assignee } : i)), + ); + } + } catch (e) { + reportFailure(e); + } finally { + setAssigning(prev => dropGuess(prev, id, seq)); + } + }; + + const doRename = async (id: number, title: string) => { + const seq = nextSeq(); + lastRenameRef.current = seq; + setRenameError(null); + setRenaming(prev => addGuess(prev, id, { seq, value: title })); + try { + const res = await renameIssue(id, title); + if ((confirmedRenameRef.current[id] ?? 0) < seq) { + confirmedRenameRef.current[id] = seq; + queryClient.setQueriesData({ queryKey: ["issues"] }, old => + old?.map(i => (i.id === id ? { ...i, title: res.title } : i)), + ); + queryClient.setQueryData(["issue", id], old => (old ? { ...old, title: res.title } : old)); + } + } catch (e) { + if (lastRenameRef.current === seq) setRenameError({ visit, message: errorMessage(e) }); + } finally { + setRenaming(prev => dropGuess(prev, id, seq)); + } + }; + + const doClose = async (id: number, origin: Origin) => { + const seq = nextSeq(); + let reportFailure: (e: unknown) => void; + if (origin === "row") reportFailure = startRowAction(); + else { + lastCloseRef.current = seq; + setCloseError(null); + reportFailure = e => { + if (lastCloseRef.current === seq) setCloseError({ visit, message: errorMessage(e) }); + }; + } + setClosing(prev => ({ ...prev, [id]: (prev[id] ?? 0) + 1 })); + try { + await closeIssue(id); + queryClient.setQueryData(["issues", "open"], old => old?.filter(i => i.id !== id)); + } catch (e) { + reportFailure(e); + } finally { + setClosing(prev => { + const next = { ...prev }; + if ((next[id] ?? 0) > 1) next[id] -= 1; + else delete next[id]; + return next; + }); + } + }; + + const doPost = async (id: number, author: string, text: string) => { + const seq = nextSeq(); + lastPostRef.current = seq; + setPostError(null); + setCommentInput(""); + setSending(prev => [...prev, { seq, issueId: id, author, text }]); + try { + const res = await addComment(id, author, text); + queryClient.setQueryData(["comments", id], old => (old ? [...old, res] : old)); + } catch (e) { + if (lastPostRef.current === seq) setPostError({ visit, message: errorMessage(e) }); + } finally { + setSending(prev => prev.filter(p => p.seq !== seq)); + } + }; + + return ( +
+
+ +
+ + +
+
+ void doAssign(id, user, "row")} + onRowClose={id => void doClose(id, "row")} + /> +
+ {rowError &&

row error: {rowError}

} +
+
+ {selected === null ? ( +

select an issue

+ ) : ( + void detail.refetch()} + onAssign={() => void doAssign(selected, user, "detail")} + assignError={errorIn(assignError, visit)} + titleInput={titleInput} + onTitleInput={setTitleInput} + onRename={() => { + if (titleInput !== "") void doRename(selected, titleInput); + }} + renameError={errorIn(renameError, visit)} + onClose={() => void doClose(selected, "detail")} + closeError={errorIn(closeError, visit)} + comments={comments.data} + commentsPending={comments.isPending} + commentsError={comments.error} + sending={sending.filter(p => p.issueId === selected)} + commentInput={commentInput} + onCommentInput={setCommentInput} + onPost={() => { + if (commentInput !== "") void doPost(selected, user, commentInput); + }} + postError={errorIn(postError, visit)} + onOpenHelp={onOpenHelp} + /> + )} +
+
+ ); +} diff --git a/eval/reference/desk9/react/Labels.tsx b/eval/reference/desk9/react/Labels.tsx new file mode 100644 index 00000000..8673e7da --- /dev/null +++ b/eval/reference/desk9/react/Labels.tsx @@ -0,0 +1,81 @@ +import { useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { addLabel, fetchLabels, removeLabel } from "./api"; +import { errorIn, errorMessage, type VisitError } from "./errors"; + +// a label change in the air, on one issue +type Change = { seq: number; issueId: number; kind: "add" | "remove"; label: string }; + +/** the labels as the server answered them, with the changes still in the air laid over them in the order they started */ +const withChanges = (labels: string[], changes: Change[]) => + changes.reduce( + (ls, c) => (c.kind === "remove" ? ls.filter(l => l !== c.label) : ls.includes(c.label) ? ls : [...ls, c.label]), + labels, + ); + +// Stays mounted while issues are selected one after another, so changes still in the air are laid over whatever the +// issue's labels answer, also after it was selected again. +export default function Labels({ id, visit }: { id: number; visit: number }) { + const queryClient = useQueryClient(); + const labels = useQuery({ queryKey: ["labels", id], queryFn: () => fetchLabels(id) }); + const [changes, setChanges] = useState([]); + const [labelError, setLabelError] = useState(null); + const [labelInput, setLabelInput] = useState(""); + const seqRef = useRef(0); + + const shown = labels.data && withChanges(labels.data, changes.filter(c => c.issueId === id)); + + const change = async (kind: Change["kind"], label: string) => { + const seq = ++seqRef.current; + const issueId = id; + setLabelError(null); + setChanges(prev => [...prev, { seq, issueId, kind, label }]); + try { + const after = await (kind === "add" ? addLabel(issueId, label) : removeLabel(issueId, label)); + queryClient.setQueryData(["labels", issueId], after); + } catch (e) { + // only the newest label change reports its failure + if (seqRef.current === seq) setLabelError({ visit, message: errorMessage(e) }); + } finally { + setChanges(prev => prev.filter(c => c.seq !== seq)); + } + }; + + const error = errorIn(labelError, visit); + return ( +
+
+ {labels.isPending ? ( +

loading labels

+ ) : labels.isError ? ( +

labels error: {errorMessage(labels.error)}

+ ) : ( +
    + {shown?.map(l => ( +
  • + {l}{" "} + +
  • + ))} +
+ )} +
+ setLabelInput(e.target.value)} /> + + {error &&

label error: {error}

} +
+ ); +} diff --git a/eval/reference/desk9/react/Related.tsx b/eval/reference/desk9/react/Related.tsx new file mode 100644 index 00000000..497375da --- /dev/null +++ b/eval/reference/desk9/react/Related.tsx @@ -0,0 +1,29 @@ +import { useQuery } from "@tanstack/react-query"; +import { search } from "./api"; +import { errorMessage } from "./errors"; + +/** help related to an issue: the first hits of a search for its title */ +export default function Related({ title, onOpen }: { title: string; onOpen: (query: string, id: number) => void }) { + const hits = useQuery({ queryKey: ["related", title], queryFn: () => search(title) }); + return ( +
+ {hits.isPending ? ( +

finding related help

+ ) : hits.isError ? ( +

related help failed: {errorMessage(hits.error)}

+ ) : hits.data.length === 0 ? ( +

no related help

+ ) : ( +
    + {hits.data.slice(0, 3).map(h => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/eval/reference/desk9/react/SettingsPage.tsx b/eval/reference/desk9/react/SettingsPage.tsx new file mode 100644 index 00000000..d4ad74e3 --- /dev/null +++ b/eval/reference/desk9/react/SettingsPage.tsx @@ -0,0 +1,76 @@ +import { useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { loadSettings, saveSettings, type Density, type Settings, type Theme } from "./api"; + +export default function SettingsPage() { + const queryClient = useQueryClient(); + const settings = useQuery({ queryKey: ["settings"], queryFn: loadSettings }); + const [saveError, setSaveError] = useState(""); + // saves still in the air, oldest first; the newest one shows until it answers or a newer one is confirmed + const [saving, setSaving] = useState<{ seq: number; value: Settings }[]>([]); + const seqRef = useRef(0); + const confirmedRef = useRef(0); + + const save = async (next: Settings) => { + const seq = ++seqRef.current; + setSaveError(""); + setSaving(prev => [...prev, { seq, value: next }]); + try { + const saved = await saveSettings(next); + if (seq > confirmedRef.current) { + confirmedRef.current = seq; + queryClient.setQueryData(["settings"], saved); + } + } catch (e) { + setSaveError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(prev => prev.filter(p => p.seq !== seq)); + } + }; + + if (settings.isPending) return

Loading settings…

; + if (settings.error) return ( +
+

Could not load settings: {settings.error.message}

{" "} + +
+ ); + const guess = saving.filter(p => p.seq > confirmedRef.current).at(-1); + const s = guess?.value ?? settings.data; + return ( +
+

Saved: {s.name} · {s.theme} · {s.density}{guess ? " (saving…)" : ""}

{" "} + {saveError &&

Save failed: {saveError}

} + void save(draft)} /> +
+ ); +} + +function nameError(name: string) { + if (name.length === 0) return "Display name is required"; + if (name.length > 20) return `Display name must be at most 20 characters, not ${name.length}`; + return ""; +} + +// Seeded once from the loaded settings; after that the draft is the user's. +function SettingsForm({ initial, onSave }: { initial: Settings; onSave: (draft: Settings) => void }) { + const [name, setName] = useState(initial.name); + const [theme, setTheme] = useState(initial.theme); + const [density, setDensity] = useState(initial.density); + const error = nameError(name); + return ( +
+ {" "} + {name.length}/20{" "} + {error &&

{error}

} +
+ Theme: {theme}{" "} + {" "} + {" "} + {" "} +

Preview: {theme} / {density}

+
+ +
+ ); +} diff --git a/eval/reference/desk9/react/errors.ts b/eval/reference/desk9/react/errors.ts new file mode 100644 index 00000000..8a2f6118 --- /dev/null +++ b/eval/reference/desk9/react/errors.ts @@ -0,0 +1,9 @@ +export const errorMessage = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +/** + * An error that belongs to one selection of an issue (`visit` counts selections): selecting an issue, even the same + * one again, leaves it behind, and a write that answers after its selection was left reports nothing. + */ +export type VisitError = { visit: number; message: string }; + +export const errorIn = (e: VisitError | null, visit: number) => (e && e.visit === visit ? e.message : null); From 91ec6ae865e75b89714f13d1134a4e76fc3dff3f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 06:17:17 +0000 Subject: [PATCH 02/32] eval: desk9 XState reference (grades 0) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- eval/reference/desk9/xstate/App.tsx | 36 ++ eval/reference/desk9/xstate/NOTES.md | 13 + eval/reference/desk9/xstate/appMachine.ts | 53 ++ eval/reference/desk9/xstate/help/HelpPage.tsx | 57 +++ .../desk9/xstate/help/helpMachine.ts | 99 ++++ .../desk9/xstate/issues/IssueDetail.tsx | 151 ++++++ .../desk9/xstate/issues/IssuesPage.tsx | 71 +++ .../desk9/xstate/issues/issuesMachine.ts | 464 ++++++++++++++++++ eval/reference/desk9/xstate/mutation.ts | 30 ++ .../desk9/xstate/settings/SettingsPage.tsx | 60 +++ .../desk9/xstate/settings/settingsMachine.ts | 119 +++++ 11 files changed, 1153 insertions(+) create mode 100644 eval/reference/desk9/xstate/App.tsx create mode 100644 eval/reference/desk9/xstate/NOTES.md create mode 100644 eval/reference/desk9/xstate/appMachine.ts create mode 100644 eval/reference/desk9/xstate/help/HelpPage.tsx create mode 100644 eval/reference/desk9/xstate/help/helpMachine.ts create mode 100644 eval/reference/desk9/xstate/issues/IssueDetail.tsx create mode 100644 eval/reference/desk9/xstate/issues/IssuesPage.tsx create mode 100644 eval/reference/desk9/xstate/issues/issuesMachine.ts create mode 100644 eval/reference/desk9/xstate/mutation.ts create mode 100644 eval/reference/desk9/xstate/settings/SettingsPage.tsx create mode 100644 eval/reference/desk9/xstate/settings/settingsMachine.ts diff --git a/eval/reference/desk9/xstate/App.tsx b/eval/reference/desk9/xstate/App.tsx new file mode 100644 index 00000000..3f1ba7a3 --- /dev/null +++ b/eval/reference/desk9/xstate/App.tsx @@ -0,0 +1,36 @@ +import { AppActor } from "./appMachine"; +import IssuesPage from "./issues/IssuesPage"; +import HelpPage from "./help/HelpPage"; +import SettingsPage from "./settings/SettingsPage"; + +export default function App() { + return ( + + + + ); +} + +function Desk() { + const desk = AppActor.useActorRef(); + const openCount = AppActor.useSelector(s => s.context.openCount); + const { issues, help, settings } = AppActor.useSelector(s => s.children); + return ( +
+ + {issues && } + {help && } + {settings && } +
+ ); +} diff --git a/eval/reference/desk9/xstate/NOTES.md b/eval/reference/desk9/xstate/NOTES.md new file mode 100644 index 00000000..8abbd61c --- /dev/null +++ b/eval/reference/desk9/xstate/NOTES.md @@ -0,0 +1,13 @@ +# desk9 reference: XState v5 + React 19 + +Grades 0: hidden 77/77, overlap 38/38, fuzz 0/20 (`--r9`). 1140 lines. + +- Pages are actors invoked by `appMachine` per tab; leaving a tab stops the page and its writes. +- Reads are `fromPromise` invokes in parallel regions; writes are spawned callback actors (`mutation.ts`). +- Pending writes kept in context for the page's lifetime and laid over every load; newest accepted write per field wins. + +Pitfalls an agent may hit: `reenter: true` on a region of a parallel state restarts its siblings (reloads the list on +select); spawned actors' done/error events are untyped. + +Brief ambiguities seen (none tested): `assign` error prefix; related-help query after an optimistic rename; a failed +save overtaken by a newer accepted save; inputs not cleared on selection; clicking the open tab does not reset it. diff --git a/eval/reference/desk9/xstate/appMachine.ts b/eval/reference/desk9/xstate/appMachine.ts new file mode 100644 index 00000000..9b0f66f9 --- /dev/null +++ b/eval/reference/desk9/xstate/appMachine.ts @@ -0,0 +1,53 @@ +import { assign, setup } from "xstate"; +import { createActorContext } from "@xstate/react"; +import { issuesMachine, openCount } from "./issues/issuesMachine"; +import { helpMachine, type HelpInput } from "./help/helpMachine"; +import { settingsMachine } from "./settings/settingsMachine"; + +type AppEvent = + | { type: "tab.issues" } + | { type: "tab.help" } + | { type: "tab.settings" } + /** open the help page on a query with a hit chosen (a related hit on the issues page) */ + | ({ type: "help.open" } & HelpInput); + +/** + * The desk: one page at a time, each an actor invoked while its tab is open. Leaving a page stops its actor, and with + * it every request the page still has in flight, so reopening a page starts it fresh. + */ +export const appMachine = setup({ + types: { + context: {} as { + /** the number of open issues the issues page last showed; null until they have loaded once */ + openCount: number | null; + help: HelpInput; + }, + events: {} as AppEvent, + children: {} as { issues: "issuesPage"; help: "helpPage"; settings: "settingsPage" }, + }, + actors: { issuesPage: issuesMachine, helpPage: helpMachine, settingsPage: settingsMachine }, +}).createMachine({ + id: "desk", + context: { openCount: null, help: { query: "", selected: null } }, + initial: "issues", + on: { + "tab.issues": ".issues", + "tab.help": { target: ".help", actions: assign({ help: { query: "", selected: null } }) }, + "tab.settings": ".settings", + "help.open": { target: ".help", actions: assign({ help: ({ event }) => ({ query: event.query, selected: event.selected }) }) }, + }, + states: { + issues: { + invoke: { + id: "issues", + src: "issuesPage", + // the tab's count follows the open list while the page shows it, and keeps its number otherwise + onSnapshot: { actions: assign({ openCount: ({ context, event }) => openCount(event.snapshot) ?? context.openCount }) }, + }, + }, + help: { invoke: { id: "help", src: "helpPage", input: ({ context }) => context.help } }, + settings: { invoke: { id: "settings", src: "settingsPage" } }, + }, +}); + +export const AppActor = createActorContext(appMachine); diff --git a/eval/reference/desk9/xstate/help/HelpPage.tsx b/eval/reference/desk9/xstate/help/HelpPage.tsx new file mode 100644 index 00000000..9d77dfb4 --- /dev/null +++ b/eval/reference/desk9/xstate/help/HelpPage.tsx @@ -0,0 +1,57 @@ +import { useSelector } from "@xstate/react"; +import type { ActorRefFrom } from "xstate"; +import type { helpMachine } from "./helpMachine"; + +export default function HelpPage({ actor }: { actor: ActorRefFrom }) { + const state = useSelector(actor, s => s); + const { context } = state; + const { send } = actor; + const { results } = context; + return ( +
+ send({ type: "query.change", value: e.target.value })} />{" "} +
+ {state.matches({ search: "tooShort" }) &&

too short: {context.query}

} + {state.matches({ search: "searching" }) &&

searching…

} + {state.matches({ search: "failed" }) &&

search failed: {context.searchError}

} + {results && ( +
+

+ {results.hits.length} results for {results.query} +

+
    + {results.hits.map(hit => ( +
  • send({ type: "hit.select", id: hit.id })}> + {hit.id === context.selected ? "> " : ""} + {hit.name} +
  • + ))} +
+
+ )} +
{" "} +
+ {state.matches({ item: "none" }) ? ( +

pick a result

+ ) : state.matches({ item: "loading" }) ? ( +

loading…

+ ) : state.matches({ item: "notFound" }) ? ( +

item {context.selected} not found

+ ) : state.matches({ item: "failed" }) ? ( + <> +

failed: {context.itemError}

{" "} + + + ) : ( + context.item && ( +
+

{context.item.name}

{context.item.blurb}

+
+ ) + )} +
+
+ ); +} diff --git a/eval/reference/desk9/xstate/help/helpMachine.ts b/eval/reference/desk9/xstate/help/helpMachine.ts new file mode 100644 index 00000000..bdddf923 --- /dev/null +++ b/eval/reference/desk9/xstate/help/helpMachine.ts @@ -0,0 +1,99 @@ +import { assign, fromPromise, setup } from "xstate"; +import { fetchItem, search, type Hit, type Item } from "../api"; +import { errorMessage } from "../mutation"; + +/** Where the help page opens: empty from its tab, on an issue's title with a hit chosen from a related hit. */ +export type HelpInput = { query: string; selected: number | null }; + +type HelpContext = { + query: string; + /** the results on screen and the query they answer */ + results: { query: string; hits: Hit[] } | null; + searchError: string; + selected: number | null; + item: Item | null; + itemError: string; +}; + +type HelpEvent = { type: "query.change"; value: string } | { type: "hit.select"; id: number } | { type: "item.retry" }; + +export const helpMachine = setup({ + types: { context: {} as HelpContext, events: {} as HelpEvent, input: {} as HelpInput }, + actors: { + search: fromPromise(({ input }: { input: string }) => search(input)), + fetchItem: fromPromise(({ input }: { input: number }) => fetchItem(input)), + }, + guards: { + emptyQuery: ({ context }) => context.query === "", + shortQuery: ({ context }) => context.query.length < 2, + hitSelected: ({ context }) => context.selected !== null, + }, + actions: { + clearResults: assign({ results: null }), + }, +}).createMachine({ + id: "help", + type: "parallel", + context: ({ input }) => ({ ...input, results: null, searchError: "", item: null, itemError: "" }), + states: { + search: { + initial: "checking", + on: { + // every keystroke starts over: a search still in flight for an older query is dropped + "query.change": { target: ".checking", actions: assign({ query: ({ event }) => event.value }) }, + }, + states: { + checking: { + always: [ + { guard: "emptyQuery", target: "empty" }, + { guard: "shortQuery", target: "tooShort" }, + { target: "searching" }, + ], + }, + empty: { entry: "clearResults" }, + tooShort: { entry: "clearResults" }, + // the results on screen stay up until these replace them + searching: { + invoke: { + src: "search", + input: ({ context }) => context.query, + onDone: { + target: "found", + actions: assign({ results: ({ context, event }) => ({ query: context.query, hits: event.output }) }), + }, + onError: { + target: "failed", + actions: [assign({ searchError: ({ event }) => errorMessage(event.error) }), "clearResults"], + }, + }, + }, + found: {}, + failed: {}, + }, + }, + item: { + initial: "checking", + on: { + "hit.select": { target: ".loading", actions: assign({ selected: ({ event }) => event.id }) }, + }, + states: { + checking: { always: [{ guard: "hitSelected", target: "loading" }, { target: "none" }] }, + none: {}, + loading: { + invoke: { + src: "fetchItem", + input: ({ context }) => context.selected!, + onDone: [ + { guard: ({ event }) => event.output === null, target: "notFound" }, + { target: "loaded", actions: assign({ item: ({ event }) => event.output }) }, + ], + onError: { target: "failed", actions: assign({ itemError: ({ event }) => errorMessage(event.error) }) }, + }, + }, + loaded: {}, + notFound: {}, + failed: { on: { "item.retry": "loading" } }, + }, + }, + }, +}); diff --git a/eval/reference/desk9/xstate/issues/IssueDetail.tsx b/eval/reference/desk9/xstate/issues/IssueDetail.tsx new file mode 100644 index 00000000..e3c84e55 --- /dev/null +++ b/eval/reference/desk9/xstate/issues/IssueDetail.tsx @@ -0,0 +1,151 @@ +import type { SnapshotFrom } from "xstate"; +import { AppActor } from "../appMachine"; +import { issuesMachine, sendingComments, shownLabels, shownTitle } from "./issuesMachine"; +import type { IssuesActor } from "./IssuesPage"; + +type Snapshot = SnapshotFrom; +type Props = { state: Snapshot; send: IssuesActor["send"] }; + +export default function IssueDetail({ state, send, id }: Props & { id: number }) { + const { context } = state; + const { errors } = context; + return ( + <> + {state.matches({ detail: { selected: { issue: "loading" } } }) ? ( +

loading #{id}

+ ) : state.matches({ detail: { selected: { issue: "notFound" } } }) ? ( +

detail error: issue #{id} not found

+ ) : state.matches({ detail: { selected: { issue: "failed" } } }) ? ( +

detail error: {context.detailError}

+ ) : ( + <> +

+ #{id} {shownTitle(context, id)} +

+

{context.detail?.body}

+ + + )} + +
+ + {errors.assign &&

assign error: {errors.assign}

} +
+
+ send({ type: "title.change", value: e.target.value })} /> + + {errors.rename &&

rename error: {errors.rename}

} +
+
+ + {errors.close &&

close error: {errors.close}

} +
+ + + + ); +} + +/** help related to the issue: the first hits of a search for its title */ +function Related({ state }: { state: Snapshot }) { + const desk = AppActor.useActorRef(); + const { related, relatedError } = state.context; + return ( +
+ {state.matches({ detail: { selected: { issue: { loaded: "relatedFailed" } } } }) ? ( +

related help failed: {relatedError}

+ ) : !state.matches({ detail: { selected: { issue: { loaded: "related" } } } }) || !related ? ( +

finding related help

+ ) : related.hits.length === 0 ? ( +

no related help

+ ) : ( +
    + {related.hits.slice(0, 3).map(hit => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} + +function Labels({ state, send }: Props) { + const { context } = state; + const labels = shownLabels(context); + return ( +
+
+ {state.matches({ detail: { selected: { labels: "failed" } } }) ? ( +

labels error: {context.labelsError}

+ ) : !labels ? ( +

loading labels

+ ) : ( +
    + {labels.map(label => ( +
  • + {label}{" "} + +
  • + ))} +
+ )} +
+ send({ type: "label.change", value: e.target.value })} /> + + {context.errors.label &&

label error: {context.errors.label}

} +
+ ); +} + +function Comments({ state, send }: Props) { + const { context } = state; + return ( +
+
+ {state.matches({ detail: { selected: { comments: "failed" } } }) ? ( +

comments error: {context.commentsError}

+ ) : !context.comments ? ( +

loading comments

+ ) : ( +
    + {context.comments.map((c, i) => ( +
  • + {c.author}: {c.text} +
  • + ))} + {sendingComments(context).map(p => ( +
  • + {p.author}: {p.text} (sending…) +
  • + ))} +
+ )} +
+ send({ type: "comment.change", value: e.target.value })} /> + + {context.errors.post &&

post error: {context.errors.post}

} +
+ ); +} diff --git a/eval/reference/desk9/xstate/issues/IssuesPage.tsx b/eval/reference/desk9/xstate/issues/IssuesPage.tsx new file mode 100644 index 00000000..0b57d5f1 --- /dev/null +++ b/eval/reference/desk9/xstate/issues/IssuesPage.tsx @@ -0,0 +1,71 @@ +import { useSelector } from "@xstate/react"; +import type { ActorRefFrom } from "xstate"; +import { issuesMachine, shownRows } from "./issuesMachine"; +import IssueDetail from "./IssueDetail"; + +export type IssuesActor = ActorRefFrom; + +export default function IssuesPage({ actor }: { actor: IssuesActor }) { + const state = useSelector(actor, s => s); + const { context } = state; + const { send } = actor; + return ( +
+
+ +
+ {(["open", "closed"] as const).map(filter => ( + + ))} +
+
+ {state.matches({ list: "loading" }) ? ( +

loading {context.filter} issues

+ ) : state.matches({ list: "failed" }) ? ( +

list error: {context.listError}

+ ) : ( +
    + {shownRows(context).map(issue => ( +
  • + {" "} + + {context.filter === "open" && ( + <> + {" "} + + + )} +
  • + ))} +
+ )} +
+ {context.errors.row &&

row error: {context.errors.row}

} +
+
+ {context.selected === null ?

select an issue

: } +
+
+ ); +} diff --git a/eval/reference/desk9/xstate/issues/issuesMachine.ts b/eval/reference/desk9/xstate/issues/issuesMachine.ts new file mode 100644 index 00000000..3f04578d --- /dev/null +++ b/eval/reference/desk9/xstate/issues/issuesMachine.ts @@ -0,0 +1,464 @@ +import { assign, enqueueActions, fromPromise, setup, stopChild, type SnapshotFrom } from "xstate"; +import * as api from "../api"; +import type { Comment, Hit, Issue, IssueDetail } from "../api"; +import { errorMessage, mutation, type MutationEvent } from "../mutation"; + +export type Filter = "open" | "closed"; +/** Where a write was started: the detail's buttons or a list row's. Each place reports its own failures. */ +export type Origin = "detail" | "row"; + +export type WriteRequest = + | { kind: "assign"; issueId: number; user: string } + | { kind: "rename"; issueId: number; title: string } + | { kind: "close"; issueId: number } + | { kind: "post"; issueId: number; author: string; text: string } + | { kind: "addLabel"; issueId: number; label: string } + | { kind: "removeLabel"; issueId: number; label: string }; + +/** A write in flight. `seq` orders all writes by when they started; `visit` is the selection it was started in. */ +export type Write = WriteRequest & { seq: number; visit: number; origin: Origin }; + +/** What the server answered a write, in the terms the page needs. */ +type Outcome = + | { kind: "assigned"; assignee: string } + | { kind: "renamed"; title: string } + | { kind: "closed" } + | { kind: "posted"; comment: Comment } + | { kind: "labels"; labels: string[] }; + +async function perform(w: Write): Promise { + switch (w.kind) { + case "assign": + return { kind: "assigned", assignee: (await api.assign(w.issueId, w.user)).assignee }; + case "rename": + return { kind: "renamed", title: (await api.renameIssue(w.issueId, w.title)).title }; + case "close": + await api.closeIssue(w.issueId); + return { kind: "closed" }; + case "post": + return { kind: "posted", comment: await api.addComment(w.issueId, w.author, w.text) }; + case "addLabel": + return { kind: "labels", labels: await api.addLabel(w.issueId, w.label) }; + case "removeLabel": + return { kind: "labels", labels: await api.removeLabel(w.issueId, w.label) }; + } +} + +/** Each kind of failure has one place on screen; the row buttons share one. */ +export type Channel = "assign" | "rename" | "close" | "post" | "label" | "row"; +const DETAIL_CHANNELS = ["assign", "rename", "close", "post", "label"] as const; +const channelOf = (w: Write): Channel => + w.origin === "row" ? "row" : w.kind === "addLabel" || w.kind === "removeLabel" ? "label" : w.kind; + +type Field = "title" | "assignee"; +const fieldKey = (issueId: number, field: Field) => `${issueId}:${field}`; +const writeId = (seq: number) => `write-${seq}`; + +export type IssuesContext = { + user: string; + filter: Filter; + /** the ids of the list's issues, in the server's order, for `filter` */ + rows: number[]; + listError: string; + /** each issue's title and assignee as the server last answered them */ + server: { title: Record; assignee: Record }; + selected: number | null; + /** counts selections: the detail's errors belong to the selection they happened in */ + visit: number; + detail: IssueDetail | null; + detailError: string; + /** the selected issue's comments and labels as the server last answered them; null until they load */ + comments: Comment[] | null; + commentsError: string; + labels: string[] | null; + labelsError: string; + related: { query: string; hits: Hit[] } | null; + relatedError: string; + titleInput: string; + commentInput: string; + labelInput: string; + seq: number; + /** the writes in flight, oldest first */ + writes: Write[]; + /** per issue field, the seq of the newest write the server accepted: an older write's answer must not replace it */ + confirmed: Record; + /** per channel, the newest write started: only it reports its failure */ + latest: Partial>; + errors: Partial>; +}; + +export type IssuesEvent = + | { type: "user.change"; value: string } + | { type: "filter.set"; filter: Filter } + | { type: "issue.select"; id: number } + | { type: "detail.retry" } + | { type: "assign"; issueId: number; origin: Origin } + | { type: "close"; issueId: number; origin: Origin } + | { type: "title.change"; value: string } + | { type: "rename" } + | { type: "comment.change"; value: string } + | { type: "post" } + | { type: "label.change"; value: string } + | { type: "label.add" } + | { type: "label.remove"; label: string } + | MutationEvent; + +// --- what the page shows: the server's answers with the writes in flight laid over them --- + +/** the value of the newest write in flight to `field`, unless the server already accepted a newer one */ +function guess(ctx: IssuesContext, issueId: number, field: Field): string | undefined { + const floor = ctx.confirmed[fieldKey(issueId, field)] ?? 0; + for (let i = ctx.writes.length - 1; i >= 0; i--) { + const w = ctx.writes[i]; + if (w.issueId !== issueId || w.seq <= floor) continue; + if (field === "title" && w.kind === "rename") return w.title; + if (field === "assignee" && w.kind === "assign") return w.user; + } + return undefined; +} + +export const shownTitle = (ctx: IssuesContext, id: number) => guess(ctx, id, "title") ?? ctx.server.title[id] ?? ""; +export const shownAssignee = (ctx: IssuesContext, id: number) => guess(ctx, id, "assignee") ?? ctx.server.assignee[id] ?? null; + +/** the list's rows: issues being closed are out of the open list until the server declines */ +export function shownRows(ctx: IssuesContext): Issue[] { + const closing = new Set(ctx.writes.filter(w => w.kind === "close").map(w => w.issueId)); + return ctx.rows + .filter(id => !(ctx.filter === "open" && closing.has(id))) + .map(id => ({ id, title: shownTitle(ctx, id), assignee: shownAssignee(ctx, id) })); +} + +/** the selected issue's labels, with the label changes in flight applied in the order they started */ +export function shownLabels(ctx: IssuesContext): string[] | null { + if (!ctx.labels) return null; + return ctx.writes.reduce((labels, w) => { + if (w.issueId !== ctx.selected) return labels; + if (w.kind === "addLabel") return labels.includes(w.label) ? labels : [...labels, w.label]; + if (w.kind === "removeLabel") return labels.filter(l => l !== w.label); + return labels; + }, ctx.labels); +} + +/** the comments in flight on the selected issue, oldest first */ +export const sendingComments = (ctx: IssuesContext) => + ctx.writes.filter((w): w is Write & { kind: "post" } => w.kind === "post" && w.issueId === ctx.selected); + +// --- applying the server's answers --- + +function accepted(ctx: IssuesContext, w: Write, outcome: Outcome): Partial { + const confirm = (field: Field, value: string): Partial => { + const key = fieldKey(w.issueId, field); + if ((ctx.confirmed[key] ?? 0) > w.seq) return {}; + return { + confirmed: { ...ctx.confirmed, [key]: w.seq }, + server: { ...ctx.server, [field]: { ...ctx.server[field], [w.issueId]: value } }, + }; + }; + switch (outcome.kind) { + case "assigned": + return confirm("assignee", outcome.assignee); + case "renamed": + return confirm("title", outcome.title); + case "closed": + return ctx.filter === "open" ? { rows: ctx.rows.filter(id => id !== w.issueId) } : {}; + case "posted": + // comments still loading will answer with the new one in them + return ctx.comments && ctx.selected === w.issueId ? { comments: [...ctx.comments, outcome.comment] } : {}; + case "labels": + return ctx.labels && ctx.selected === w.issueId ? { labels: outcome.labels } : {}; + } +} + +function declined(ctx: IssuesContext, w: Write, message: string): Partial { + const channel = channelOf(w); + // a newer write of the same kind took over the error line, and a selection that was left takes its errors along + const reports = ctx.latest[channel] === w.seq && (w.origin === "row" || w.visit === ctx.visit); + return reports ? { errors: { ...ctx.errors, [channel]: message } } : {}; +} + +const withoutWrite = (ctx: IssuesContext, w: Write) => ctx.writes.filter(x => x.seq !== w.seq); + +export const issuesMachine = setup({ + types: { context: {} as IssuesContext, events: {} as IssuesEvent }, + actors: { + fetchIssues: fromPromise(({ input }: { input: Filter }) => api.fetchIssues(input)), + fetchIssue: fromPromise(({ input }: { input: number }) => api.fetchIssue(input)), + fetchComments: fromPromise(({ input }: { input: number }) => api.fetchComments(input)), + fetchLabels: fromPromise(({ input }: { input: number }) => api.fetchLabels(input)), + searchHelp: fromPromise(({ input }: { input: string }) => api.search(input)), + write: mutation(perform), + }, + actions: { + /** start a write: it shows at once, clears its channel's error, and goes to the server */ + startWrite: enqueueActions(({ context, enqueue }, params: { request: WriteRequest; origin: Origin }) => { + const seq = context.seq + 1; + const write: Write = { ...params.request, seq, visit: context.visit, origin: params.origin }; + const channel = channelOf(write); + enqueue.assign({ + seq, + writes: [...context.writes, write], + latest: { ...context.latest, [channel]: seq }, + errors: { ...context.errors, [channel]: undefined }, + }); + enqueue.spawnChild("write", { id: writeId(seq), input: write }); + }), + }, + guards: { + otherFilter: ({ context, event }) => event.type === "filter.set" && event.filter !== context.filter, + titleTyped: ({ context }) => context.titleInput !== "", + commentTyped: ({ context }) => context.commentInput !== "", + newLabelTyped: ({ context }) => { + const labels = shownLabels(context); + return context.labelInput !== "" && labels !== null && !labels.includes(context.labelInput); + }, + }, +}).createMachine({ + id: "issues", + type: "parallel", + context: { + user: "guest", + filter: "open", + rows: [], + listError: "", + server: { title: {}, assignee: {} }, + selected: null, + visit: 0, + detail: null, + detailError: "", + comments: null, + commentsError: "", + labels: null, + labelsError: "", + related: null, + relatedError: "", + titleInput: "", + commentInput: "", + labelInput: "", + seq: 0, + writes: [], + confirmed: {}, + latest: {}, + errors: {}, + }, + on: { + "user.change": { actions: assign({ user: ({ event }) => event.value }) }, + "title.change": { actions: assign({ titleInput: ({ event }) => event.value }) }, + "comment.change": { actions: assign({ commentInput: ({ event }) => event.value }) }, + "label.change": { actions: assign({ labelInput: ({ event }) => event.value }) }, + assign: { + actions: { + type: "startWrite", + params: ({ context, event }) => ({ + request: { kind: "assign", issueId: event.issueId, user: context.user }, + origin: event.origin, + }), + }, + }, + close: { + actions: { + type: "startWrite", + params: ({ event }) => ({ request: { kind: "close", issueId: event.issueId }, origin: event.origin }), + }, + }, + "mutation.succeeded": { + actions: [ + assign(({ context, event }) => ({ + ...accepted(context, event.input, event.output), + writes: withoutWrite(context, event.input), + })), + stopChild(({ event }) => writeId(event.input.seq)), + ], + }, + "mutation.failed": { + actions: [ + assign(({ context, event }) => ({ + ...declined(context, event.input, event.message), + writes: withoutWrite(context, event.input), + })), + stopChild(({ event }) => writeId(event.input.seq)), + ], + }, + }, + states: { + list: { + initial: "loading", + on: { + "filter.set": { + guard: "otherFilter", + target: ".loading", + actions: assign({ filter: ({ event }) => event.filter }), + }, + }, + states: { + loading: { + invoke: { + src: "fetchIssues", + input: ({ context }) => context.filter, + onDone: { + target: "loaded", + actions: assign(({ context, event }) => ({ + rows: event.output.map(i => i.id), + server: { + title: { ...context.server.title, ...Object.fromEntries(event.output.map(i => [i.id, i.title])) }, + assignee: { ...context.server.assignee, ...Object.fromEntries(event.output.map(i => [i.id, i.assignee])) }, + }, + })), + }, + onError: { target: "failed", actions: assign({ listError: ({ event }) => errorMessage(event.error) }) }, + }, + }, + loaded: {}, + failed: {}, + }, + }, + detail: { + initial: "none", + on: { + "issue.select": { + target: ".selected", + actions: assign(({ context, event }) => ({ + selected: event.id, + visit: context.visit + 1, + titleInput: "", + errors: { ...context.errors, ...Object.fromEntries(DETAIL_CHANNELS.map(c => [c, undefined])) }, + })), + }, + }, + states: { + none: {}, + selected: { + type: "parallel", + on: { + rename: { + guard: "titleTyped", + actions: { + type: "startWrite", + params: ({ context }) => ({ + request: { kind: "rename", issueId: context.selected!, title: context.titleInput }, + origin: "detail", + }), + }, + }, + post: { + guard: "commentTyped", + actions: [ + { + type: "startWrite", + params: ({ context }) => ({ + request: { kind: "post", issueId: context.selected!, author: context.user, text: context.commentInput }, + origin: "detail", + }), + }, + assign({ commentInput: "" }), + ], + }, + "label.add": { + guard: "newLabelTyped", + actions: [ + { + type: "startWrite", + params: ({ context }) => ({ + request: { kind: "addLabel", issueId: context.selected!, label: context.labelInput }, + origin: "detail", + }), + }, + assign({ labelInput: "" }), + ], + }, + "label.remove": { + actions: { + type: "startWrite", + params: ({ context, event }) => ({ + request: { kind: "removeLabel", issueId: context.selected!, label: event.label }, + origin: "detail", + }), + }, + }, + }, + states: { + issue: { + initial: "loading", + on: { "detail.retry": { target: ".loading" } }, + states: { + loading: { + invoke: { + src: "fetchIssue", + input: ({ context }) => context.selected!, + onDone: [ + { guard: ({ event }) => event.output === null, target: "notFound" }, + { + target: "loaded", + actions: assign(({ context, event }) => { + const detail = event.output!; + return { detail, server: { ...context.server, title: { ...context.server.title, [detail.id]: detail.title } } }; + }), + }, + ], + onError: { target: "failed", actions: assign({ detailError: ({ event }) => errorMessage(event.error) }) }, + }, + }, + loaded: { + initial: "findingRelated", + states: { + findingRelated: { + invoke: { + src: "searchHelp", + input: ({ context }) => context.detail!.title, + onDone: { + target: "related", + actions: assign({ related: ({ context, event }) => ({ query: context.detail!.title, hits: event.output }) }), + }, + onError: { target: "relatedFailed", actions: assign({ relatedError: ({ event }) => errorMessage(event.error) }) }, + }, + }, + related: {}, + relatedFailed: {}, + }, + }, + notFound: {}, + failed: {}, + }, + }, + comments: { + initial: "loading", + states: { + loading: { + entry: assign({ comments: null }), + invoke: { + src: "fetchComments", + input: ({ context }) => context.selected!, + onDone: { target: "loaded", actions: assign({ comments: ({ event }) => event.output }) }, + onError: { target: "failed", actions: assign({ commentsError: ({ event }) => errorMessage(event.error) }) }, + }, + }, + loaded: {}, + failed: {}, + }, + }, + labels: { + initial: "loading", + states: { + loading: { + entry: assign({ labels: null }), + invoke: { + src: "fetchLabels", + input: ({ context }) => context.selected!, + onDone: { target: "loaded", actions: assign({ labels: ({ event }) => event.output }) }, + onError: { target: "failed", actions: assign({ labelsError: ({ event }) => errorMessage(event.error) }) }, + }, + }, + loaded: {}, + failed: {}, + }, + }, + }, + }, + }, + }, + }, +}); + +/** the number of rows in the open list as the page shows it, or null while it shows something else */ +export function openCount(snapshot: SnapshotFrom) { + return snapshot.context.filter === "open" && snapshot.matches({ list: "loaded" }) ? shownRows(snapshot.context).length : null; +} diff --git a/eval/reference/desk9/xstate/mutation.ts b/eval/reference/desk9/xstate/mutation.ts new file mode 100644 index 00000000..8e982ce3 --- /dev/null +++ b/eval/reference/desk9/xstate/mutation.ts @@ -0,0 +1,30 @@ +import { fromCallback, type EventObject } from "xstate"; + +export const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error)); + +/** What a mutation actor tells its parent when the server answers. */ +export type MutationEvent = + | { type: "mutation.succeeded"; input: TInput; output: TOutput } + | { type: "mutation.failed"; input: TInput; message: string }; + +/** + * Logic for one write to the server, spawned once per write so that any number can be in flight at once. It reports + * back to its parent with a `MutationEvent` carrying its input; once stopped (its parent stopped, or it was cleaned up + * after reporting) it reports nothing, so an answer for a page that was left never reaches anything. + */ +export function mutation(request: (input: TInput) => Promise) { + return fromCallback(({ input, sendBack }) => { + let live = true; + request(input).then( + output => { + if (live) sendBack({ type: "mutation.succeeded", input, output } satisfies MutationEvent); + }, + (error: unknown) => { + if (live) sendBack({ type: "mutation.failed", input, message: errorMessage(error) } satisfies MutationEvent); + }, + ); + return () => { + live = false; + }; + }); +} diff --git a/eval/reference/desk9/xstate/settings/SettingsPage.tsx b/eval/reference/desk9/xstate/settings/SettingsPage.tsx new file mode 100644 index 00000000..4789ef6e --- /dev/null +++ b/eval/reference/desk9/xstate/settings/SettingsPage.tsx @@ -0,0 +1,60 @@ +import { useSelector } from "@xstate/react"; +import type { ActorRefFrom } from "xstate"; +import { NAME_MAX, nameError, savedLine, type settingsMachine } from "./settingsMachine"; + +export default function SettingsPage({ actor }: { actor: ActorRefFrom }) { + const state = useSelector(actor, s => s); + const { context } = state; + const { send } = actor; + if (state.matches("loading")) return

Loading settings…

; + if (state.matches("failed")) + return ( +
+

Could not load settings: {context.loadError}

{" "} + +
+ ); + const { settings: saved, saving } = savedLine(context); + const { draft } = context; + const error = nameError(draft.name); + return ( +
+ {saved && ( +

+ Saved: {saved.name} · {saved.theme} · {saved.density} + {saving ? " (saving…)" : ""} +

+ )} + {context.saveError &&

Save failed: {context.saveError}

} +
+ {" "} + + {draft.name.length}/{NAME_MAX} + + {error &&

{error}

} +
+
+ Theme: {draft.theme}{" "} + {" "} + {" "} + +

+ Preview: {draft.theme} / {draft.density} +

+
+ +
+ ); +} diff --git a/eval/reference/desk9/xstate/settings/settingsMachine.ts b/eval/reference/desk9/xstate/settings/settingsMachine.ts new file mode 100644 index 00000000..c0005d3b --- /dev/null +++ b/eval/reference/desk9/xstate/settings/settingsMachine.ts @@ -0,0 +1,119 @@ +import { assign, enqueueActions, fromPromise, setup, stopChild } from "xstate"; +import { loadSettings, saveSettings, type Settings } from "../api"; +import { errorMessage, mutation, type MutationEvent } from "../mutation"; + +/** A save in flight: `seq` orders saves by when they started. */ +type Save = { seq: number; settings: Settings }; + +type SettingsContext = { + loadError: string; + /** the settings as the server last saved them; null until they load */ + saved: Settings | null; + /** the form: filled once from the loaded settings, then the user's */ + draft: Settings; + seq: number; + /** the saves in flight, oldest first */ + saves: Save[]; + /** the seq of the newest save the server accepted: an older save's answer must not replace it */ + confirmed: number; + saveError: string; +}; + +type SettingsEvent = + | { type: "load.retry" } + | { type: "name.change"; value: string } + | { type: "theme.set"; theme: Settings["theme"] } + | { type: "density.toggle" } + | { type: "save" } + | MutationEvent; + +export const NAME_MAX = 20; + +/** why the display name cannot be saved, or "" when it can */ +export function nameError(name: string) { + if (name.length === 0) return "Display name is required"; + if (name.length > NAME_MAX) return `Display name must be at most ${NAME_MAX} characters, not ${name.length}`; + return ""; +} + +/** the saved line: the newest save in flight not yet overtaken by an accepted one, else what was saved */ +export function savedLine(ctx: SettingsContext): { settings: Settings | null; saving: boolean } { + const pending = ctx.saves.filter(s => s.seq > ctx.confirmed).at(-1); + return pending ? { settings: pending.settings, saving: true } : { settings: ctx.saved, saving: false }; +} + +const saveId = (seq: number) => `save-${seq}`; + +export const settingsMachine = setup({ + types: { context: {} as SettingsContext, events: {} as SettingsEvent }, + actors: { + loadSettings: fromPromise(() => loadSettings()), + saveSettings: mutation((save: Save) => saveSettings(save.settings)), + }, + guards: { + draftValid: ({ context }) => nameError(context.draft.name) === "", + }, +}).createMachine({ + id: "settings", + context: { + loadError: "", + saved: null, + draft: { name: "", theme: "light", density: "comfortable" }, + seq: 0, + saves: [], + confirmed: 0, + saveError: "", + }, + initial: "loading", + states: { + loading: { + invoke: { + src: "loadSettings", + onDone: { target: "ready", actions: assign(({ event }) => ({ saved: event.output, draft: event.output })) }, + onError: { target: "failed", actions: assign({ loadError: ({ event }) => errorMessage(event.error) }) }, + }, + }, + failed: { on: { "load.retry": "loading" } }, + ready: { + on: { + "name.change": { actions: assign({ draft: ({ context, event }) => ({ ...context.draft, name: event.value }) }) }, + "theme.set": { actions: assign({ draft: ({ context, event }) => ({ ...context.draft, theme: event.theme }) }) }, + "density.toggle": { + actions: assign({ + draft: ({ context }) => ({ + ...context.draft, + density: context.draft.density === "compact" ? "comfortable" : "compact", + }), + }), + }, + save: { + guard: "draftValid", + actions: enqueueActions(({ context, enqueue }) => { + const save: Save = { seq: context.seq + 1, settings: context.draft }; + enqueue.assign({ seq: save.seq, saves: [...context.saves, save], saveError: "" }); + enqueue.spawnChild("saveSettings", { id: saveId(save.seq), input: save }); + }), + }, + "mutation.succeeded": { + actions: [ + assign(({ context, event }) => ({ + saves: context.saves.filter(s => s.seq !== event.input.seq), + ...(event.input.seq > context.confirmed ? { saved: event.output, confirmed: event.input.seq } : {}), + })), + stopChild(({ event }) => saveId(event.input.seq)), + ], + }, + "mutation.failed": { + actions: [ + assign(({ context, event }) => ({ + saves: context.saves.filter(s => s.seq !== event.input.seq), + // a save overtaken by a newer accepted one has nothing left to report + ...(event.input.seq > context.confirmed ? { saveError: event.message } : {}), + })), + stopChild(({ event }) => saveId(event.input.seq)), + ], + }, + }, + }, + }, +}); From f072d16b49c54eb0225af904f2c9885a2d5fdde8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 06:20:55 +0000 Subject: [PATCH 03/32] eval: desk9 vanilla reference (grades 0) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- eval/reference/desk9/vanilla/NOTES.md | 12 + eval/reference/desk9/vanilla/dom.ts | 54 +++++ eval/reference/desk9/vanilla/help.ts | 114 +++++++++ .../desk9/vanilla/issues/comments.ts | 52 ++++ eval/reference/desk9/vanilla/issues/detail.ts | 144 +++++++++++ eval/reference/desk9/vanilla/issues/facts.ts | 42 ++++ eval/reference/desk9/vanilla/issues/labels.ts | 90 +++++++ .../reference/desk9/vanilla/issues/outcome.ts | 34 +++ eval/reference/desk9/vanilla/issues/page.ts | 229 ++++++++++++++++++ .../reference/desk9/vanilla/issues/related.ts | 36 +++ eval/reference/desk9/vanilla/main.ts | 43 ++++ eval/reference/desk9/vanilla/register.ts | 63 +++++ eval/reference/desk9/vanilla/settings.ts | 118 +++++++++ 13 files changed, 1031 insertions(+) create mode 100644 eval/reference/desk9/vanilla/NOTES.md create mode 100644 eval/reference/desk9/vanilla/dom.ts create mode 100644 eval/reference/desk9/vanilla/help.ts create mode 100644 eval/reference/desk9/vanilla/issues/comments.ts create mode 100644 eval/reference/desk9/vanilla/issues/detail.ts create mode 100644 eval/reference/desk9/vanilla/issues/facts.ts create mode 100644 eval/reference/desk9/vanilla/issues/labels.ts create mode 100644 eval/reference/desk9/vanilla/issues/outcome.ts create mode 100644 eval/reference/desk9/vanilla/issues/page.ts create mode 100644 eval/reference/desk9/vanilla/issues/related.ts create mode 100644 eval/reference/desk9/vanilla/main.ts create mode 100644 eval/reference/desk9/vanilla/register.ts create mode 100644 eval/reference/desk9/vanilla/settings.ts diff --git a/eval/reference/desk9/vanilla/NOTES.md b/eval/reference/desk9/vanilla/NOTES.md new file mode 100644 index 00000000..dd2f7969 --- /dev/null +++ b/eval/reference/desk9/vanilla/NOTES.md @@ -0,0 +1,12 @@ +# desk9 reference: vanilla TypeScript + DOM + +Grades 0: hidden 77/77, overlap 38/38, fuzz 0/20 (`--r9`; also 0/60 on seeds 21-80). 1019 lines, no packages. + +- One `AbortController` per page, child signals per selection/load; every answer checks its signal. +- `Register`: server value as last stated plus in-flight writes over it; a read counts only if asked after the last + server statement. `IssueFacts` holds one per issue and field, shared by rows and detail. +- Pending posts and label changes live on the page, laid over every reload. + +Differs from the React reference: two accepted writes to one field show the one the server answered last (React keeps +the newest started). No hidden test separates them; the fuzz allowance for newest-started lasts only until the next +write starts. To settle in the round 15 prereg. diff --git a/eval/reference/desk9/vanilla/dom.ts b/eval/reference/desk9/vanilla/dom.ts new file mode 100644 index 00000000..6417c7b9 --- /dev/null +++ b/eval/reference/desk9/vanilla/dom.ts @@ -0,0 +1,54 @@ +// Small DOM helpers: building elements, and reading errors. + +export type Child = Node | string | number | null | undefined | false; +type Props = Partial> & { + testid?: string; +}; + +/** `h("button", { testid: "save", onclick }, "Save")`: an element with its properties and children */ +export function h(tag: K, props: Props | null = null, ...children: Child[]) { + const node = document.createElement(tag); + if (props) { + const { testid, ...rest } = props; + if (testid) node.dataset.testid = testid; + Object.assign(node, rest); + } + node.append(...nodes(children)); + return node; +} + +export const nodes = (children: Child[]): (Node | string)[] => + children.filter((c): c is Node | string | number => c !== null && c !== undefined && c !== false).map(c => (typeof c === "number" ? String(c) : c)); + +/** a button that does not submit, with a test id */ +export const button = (testid: string, label: string, onclick: () => void) => + h("button", { type: "button", testid, onclick: () => onclick() }, label); + +/** a text input with a test id, calling `oninput` with its value */ +export function textInput(testid: string, value: string, oninput: (value: string) => void) { + const input = h("input", { type: "text", testid, value }); + input.addEventListener("input", () => oninput(input.value)); + return input; +} + +export const errorMessage = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +/** a signal that aborts when `parent` does or when the returned controller is aborted */ +export function child(parent: AbortSignal) { + const controller = new AbortController(); + return { controller, signal: AbortSignal.any([parent, controller.signal]) }; +} + +/** + * Hands out signals for one kind of request where only the newest matters (a list per filter, an item per + * selection): each call aborts the previous one's signal. All of them abort with `parent`. + */ +export function latest(parent: AbortSignal) { + let current: AbortController | null = null; + return () => { + current?.abort(); + const next = child(parent); + current = next.controller; + return next.signal; + }; +} diff --git a/eval/reference/desk9/vanilla/help.ts b/eval/reference/desk9/vanilla/help.ts new file mode 100644 index 00000000..fb15baa7 --- /dev/null +++ b/eval/reference/desk9/vanilla/help.ts @@ -0,0 +1,114 @@ +// The help page: a search over the help catalogue, and the selected hit's item. +import * as api from "./api"; +import type { Hit, Item } from "./api"; +import { button, errorMessage, h, latest, textInput } from "./dom"; + +type Results = + | { status: "none" } + | { status: "too short" } + | { status: "searching"; previous: { query: string; hits: Hit[] } | null } + | { status: "ok"; query: string; hits: Hit[] } + | { status: "error"; message: string }; + +type ItemState = { status: "loading" } | { status: "error"; message: string } | { status: "ok"; item: Item | null }; + +/** Opens on `query` with hit `selected` chosen: both empty from the tab, an issue's title and hit from related help. */ +export function helpPage(signal: AbortSignal, initial: { query: string; selected: number | null }): HTMLElement { + let query = initial.query; + let results: Results = { status: "none" }; + let selected: number | null = null; + let item: ItemState = { status: "loading" }; + const nextSearch = latest(signal); + const nextItem = latest(signal); + + const resultsEl = h("section", { testid: "results" }); + const itemEl = h("section", { testid: "item" }); + + function setQuery(q: string) { + query = q; + const searchSignal = nextSearch(); + if (q === "") results = { status: "none" }; + else if (q.length < 2) results = { status: "too short" }; + else { + const previous = results.status === "ok" ? { query: results.query, hits: results.hits } : results.status === "searching" ? results.previous : null; + results = { status: "searching", previous }; + api.search(q).then( + hits => { + if (searchSignal.aborted) return; + results = { status: "ok", query: q, hits }; + renderResults(); + }, + (e: unknown) => { + if (searchSignal.aborted) return; + results = { status: "error", message: errorMessage(e) }; + renderResults(); + }, + ); + } + renderResults(); + } + + function select(id: number) { + selected = id; + renderResults(); + loadItem(); + } + + function loadItem() { + if (selected === null) return; + const id = selected; + const itemSignal = nextItem(); + item = { status: "loading" }; + api.fetchItem(id).then( + answer => { + if (itemSignal.aborted) return; + item = { status: "ok", item: answer }; + renderItem(); + }, + (e: unknown) => { + if (itemSignal.aborted) return; + item = { status: "error", message: errorMessage(e) }; + renderItem(); + }, + ); + renderItem(); + } + + function hitList(q: string, hits: Hit[]) { + return h( + "div", + null, + h("h2", null, `${hits.length} results for ${q}`), + h( + "ul", + null, + ...hits.map(hit => h("li", { testid: "hit", onclick: () => select(hit.id) }, hit.id === selected ? `> ${hit.name}` : hit.name)), + ), + ); + } + + function renderResults() { + const r = results; + if (r.status === "none") resultsEl.replaceChildren(); + else if (r.status === "too short") resultsEl.replaceChildren(h("p", null, `too short: ${query}`)); + else if (r.status === "searching") resultsEl.replaceChildren(h("p", null, "searching…"), ...(r.previous ? [hitList(r.previous.query, r.previous.hits)] : [])); + else if (r.status === "error") resultsEl.replaceChildren(h("p", null, `search failed: ${r.message}`)); + else resultsEl.replaceChildren(hitList(r.query, r.hits)); + } + + function renderItem() { + const s = item; + if (selected === null) itemEl.replaceChildren(h("p", null, "pick a result")); + else if (s.status === "loading") itemEl.replaceChildren(h("p", null, "loading…")); + else if (s.status === "error") + itemEl.replaceChildren(h("p", null, `failed: ${s.message}`), " ", button("retry-item", "retry", loadItem)); + else if (s.item === null) itemEl.replaceChildren(h("p", null, `item ${selected} not found`)); + else itemEl.replaceChildren(h("article", null, h("h3", null, s.item.name), h("p", null, s.item.blurb))); + } + + const root = h("main", { className: "help" }, textInput("query", query, setQuery), resultsEl, itemEl); + setQuery(query); + if (initial.selected !== null) select(initial.selected); + else renderItem(); + return root; +} diff --git a/eval/reference/desk9/vanilla/issues/comments.ts b/eval/reference/desk9/vanilla/issues/comments.ts new file mode 100644 index 00000000..c3a6125c --- /dev/null +++ b/eval/reference/desk9/vanilla/issues/comments.ts @@ -0,0 +1,52 @@ +// The selected issue's comments: as the server answered them, with the posts still on their way after them. +import * as api from "../api"; +import type { Comment } from "../api"; +import { errorMessage, h } from "../dom"; +import type { IssuesPage } from "./page"; + +type State = { status: "loading" } | { status: "error"; message: string } | { status: "ok"; comments: Comment[] }; + +export class Comments { + readonly root = h("div", { testid: "comments" }); + private state: State = { status: "loading" }; + + constructor( + private readonly page: IssuesPage, + private readonly id: number, + private readonly signal: AbortSignal, + ) { + void this.load(); + } + + private async load() { + try { + const comments = await api.fetchComments(this.id); + if (this.signal.aborted) return; + this.state = { status: "ok", comments }; + } catch (e) { + if (this.signal.aborted) return; + this.state = { status: "error", message: errorMessage(e) }; + } + this.render(); + } + + /** the server took a comment: it joins the list (a load still out answers with it) */ + added(comment: Comment) { + if (this.state.status === "ok") this.state = { status: "ok", comments: [...this.state.comments, comment] }; + } + + render() { + const s = this.state; + if (s.status === "loading") return this.root.replaceChildren(h("p", null, "loading comments")); + if (s.status === "error") return this.root.replaceChildren(h("p", null, `comments error: ${s.message}`)); + const row = (text: string) => h("li", { testid: "comment" }, text); + this.root.replaceChildren( + h( + "ul", + null, + ...s.comments.map(c => row(`${c.author}: ${c.text}`)), + ...this.page.postsFor(this.id).map(p => row(`${p.author}: ${p.text} (sending…)`)), + ), + ); + } +} diff --git a/eval/reference/desk9/vanilla/issues/detail.ts b/eval/reference/desk9/vanilla/issues/detail.ts new file mode 100644 index 00000000..7daacc89 --- /dev/null +++ b/eval/reference/desk9/vanilla/issues/detail.ts @@ -0,0 +1,144 @@ +// The detail of one selected issue. A new Detail is made for every selection (even of the same issue again), so its +// inputs, errors and loads belong to that selection; `signal` ends with it. +import * as api from "../api"; +import type { Comment, IssueDetail } from "../api"; +import { button, errorMessage, h, latest, textInput } from "../dom"; +import { Comments } from "./comments"; +import { Labels } from "./labels"; +import { ActionError } from "./outcome"; +import type { IssuesPage } from "./page"; +import { Related } from "./related"; + +type DetailState = + | { status: "loading" } + | { status: "error"; message: string } + | { status: "ok"; detail: IssueDetail; related: Related }; + +export class Detail { + readonly root = h("div"); + private state: DetailState = { status: "loading" }; + private readonly nextLoad: () => AbortSignal; + private readonly comments: Comments; + private readonly labels: Labels; + private readonly errors: Record<"assign" | "rename" | "close" | "post", ActionError>; + private title = ""; + private commentText = ""; + + private readonly bodyEl = h("div"); + private readonly assignButton: HTMLButtonElement; + private readonly errorEls = { assign: h("div"), rename: h("div"), close: h("div"), post: h("div") }; + private readonly titleInput: HTMLInputElement; + private readonly commentInput: HTMLInputElement; + + constructor( + private readonly page: IssuesPage, + readonly id: number, + private readonly signal: AbortSignal, + ) { + const render = () => this.render(); + this.nextLoad = latest(signal); + this.errors = { + assign: new ActionError(signal, render), + rename: new ActionError(signal, render), + close: new ActionError(signal, render), + post: new ActionError(signal, render), + }; + this.comments = new Comments(page, id, signal); + this.labels = new Labels(page, id, signal); + this.assignButton = button("assign", "", () => this.assign()); + this.titleInput = textInput("title-input", "", value => (this.title = value)); + this.commentInput = textInput("comment-input", "", value => (this.commentText = value)); + + this.root.append( + this.bodyEl, + h( + "div", + { className: "actions" }, + button("retry", "retry", () => void this.load()), + " ", + this.assignButton, + this.errorEls.assign, + ), + h("div", null, this.titleInput, " ", button("rename", "rename", () => this.rename()), this.errorEls.rename), + h("div", null, button("close", "close", () => this.close()), this.errorEls.close), + this.labels.root, + this.comments.root, + h("div", null, this.commentInput, " ", button("post", "post", () => this.post()), this.errorEls.post), + ); + void this.load(); + this.render(); + } + + private async load() { + const signal = this.nextLoad(); + const askedAt = this.page.facts.clock.tick(); + this.state = { status: "loading" }; + this.render(); + try { + const detail = await api.fetchIssue(this.id); + if (signal.aborted) return; + if (detail === null) { + this.state = { status: "error", message: `issue #${this.id} not found` }; + } else { + this.page.facts.of(detail.id).title.learn(detail.title, askedAt); + const title = this.page.facts.of(detail.id).title.shown(detail.title); + this.state = { status: "ok", detail, related: new Related(title, signal, (q, hit) => this.page.openHelp(q, hit)) }; + } + } catch (e) { + if (signal.aborted) return; + this.state = { status: "error", message: errorMessage(e) }; + } + this.render(); + } + + private assign() { + const user = this.page.currentUser(); + void this.errors.assign.run(() => this.page.assign(this.id, user)); + } + + private rename() { + const title = this.title.trim(); + if (title === "") return; + void this.errors.rename.run(() => this.page.rename(this.id, title)); + } + + private close() { + void this.errors.close.run(() => this.page.close(this.id)); + } + + private post() { + const text = this.commentText.trim(); + if (text === "") return; + this.commentText = this.commentInput.value = ""; + const author = this.page.currentUser(); + void this.errors.post.run(() => this.page.post(this.id, author, text)); + } + + /** a comment on issue `id` was accepted */ + commentAdded(id: number, comment: Comment) { + if (id === this.id) this.comments.added(comment); + } + + /** the server answered issue `id`'s labels after a change */ + labelsAnswered(id: number, labels: string[]) { + if (id === this.id) this.labels.answered(labels); + } + + render() { + if (this.signal.aborted) return; + const s = this.state; + if (s.status === "loading") this.bodyEl.replaceChildren(h("p", null, `loading #${this.id}`)); + else if (s.status === "error") this.bodyEl.replaceChildren(h("p", null, `detail error: ${s.message}`)); + else { + const title = this.page.facts.of(this.id).title.shown(s.detail.title); + this.bodyEl.replaceChildren(h("h2", null, `#${this.id} ${title}`), h("p", null, s.detail.body), s.related.root); + } + this.assignButton.textContent = `assign to ${this.page.currentUser()}`; + this.errorEls.assign.replaceChildren(this.errors.assign.line("")); + this.errorEls.rename.replaceChildren(this.errors.rename.line("rename error: ")); + this.errorEls.close.replaceChildren(this.errors.close.line("close error: ")); + this.errorEls.post.replaceChildren(this.errors.post.line("post error: ")); + this.labels.render(); + this.comments.render(); + } +} diff --git a/eval/reference/desk9/vanilla/issues/facts.ts b/eval/reference/desk9/vanilla/issues/facts.ts new file mode 100644 index 00000000..428c7767 --- /dev/null +++ b/eval/reference/desk9/vanilla/issues/facts.ts @@ -0,0 +1,42 @@ +// What the issues page knows about each issue, gathered from every answer it got (the lists, the details, the +// writes): the list rows and the detail heading read the same facts, so they always agree. +import type { Issue } from "../api"; +import { Clock, Register } from "../register"; + +type Facts = { title: Register; assignee: Register; open: Register }; + +export class IssueFacts { + private readonly byId = new Map(); + + constructor(readonly clock: Clock) {} + + of(id: number): Facts { + let facts = this.byId.get(id); + if (!facts) { + facts = { title: new Register(this.clock), assignee: new Register(this.clock), open: new Register(this.clock) }; + this.byId.set(id, facts); + } + return facts; + } + + /** a list of `filter` issues asked at `askedAt` answered `issues` */ + learnList(filter: "open" | "closed", issues: Issue[], askedAt: number) { + for (const issue of issues) { + const f = this.of(issue.id); + f.title.learn(issue.title, askedAt); + f.assignee.learn(issue.assignee, askedAt); + f.open.learn(filter === "open", askedAt); + } + } + + /** a list row as it shows now */ + row(issue: Issue): Issue { + const f = this.of(issue.id); + return { id: issue.id, title: f.title.shown(issue.title), assignee: f.assignee.shown(issue.assignee) }; + } + + /** whether a row of the open list shows: not while it is being closed or once it was closed */ + showsAsOpen(id: number) { + return this.of(id).open.shown(true); + } +} diff --git a/eval/reference/desk9/vanilla/issues/labels.ts b/eval/reference/desk9/vanilla/issues/labels.ts new file mode 100644 index 00000000..0287249d --- /dev/null +++ b/eval/reference/desk9/vanilla/issues/labels.ts @@ -0,0 +1,90 @@ +// The selected issue's labels: as the server last answered them, with the changes still on their way laid over them +// in the order they started; and the input that adds one. +import * as api from "../api"; +import { button, errorMessage, h, textInput } from "../dom"; +import { ActionError } from "./outcome"; +import type { IssuesPage, LabelChange } from "./page"; + +type State = { status: "loading" } | { status: "error"; message: string } | { status: "ok"; labels: string[] }; + +const withChanges = (labels: string[], changes: LabelChange[]) => + changes.reduce( + (ls, c) => (c.kind === "remove" ? ls.filter(l => l !== c.label) : ls.includes(c.label) ? ls : [...ls, c.label]), + labels, + ); + +export class Labels { + private state: State = { status: "loading" }; + private input = ""; + private readonly error: ActionError; + + private readonly listEl = h("div", { testid: "labels" }); + private readonly inputEl = textInput("label-input", "", value => (this.input = value)); + private readonly errorEl = h("div"); + readonly root = h( + "div", + { className: "labels" }, + this.listEl, + h("div", null, this.inputEl, " ", button("add-label", "add label", () => this.add()), this.errorEl), + ); + + constructor( + private readonly page: IssuesPage, + private readonly id: number, + private readonly signal: AbortSignal, + ) { + this.error = new ActionError(signal, () => this.render()); + void this.load(); + } + + private async load() { + try { + const labels = await api.fetchLabels(this.id); + if (this.signal.aborted) return; + this.state = { status: "ok", labels }; + } catch (e) { + if (this.signal.aborted) return; + this.state = { status: "error", message: errorMessage(e) }; + } + this.render(); + } + + /** the server answered the labels after a change (a load still out answers the same or newer) */ + answered(labels: string[]) { + if (this.state.status === "ok") this.state = { status: "ok", labels }; + } + + /** the labels as they show now, or null while they are not on screen */ + private shown() { + return this.state.status === "ok" ? withChanges(this.state.labels, this.page.labelChangesFor(this.id)) : null; + } + + private add() { + const label = this.input.trim(); + const shown = this.shown(); + if (label === "" || !shown || shown.includes(label)) return; + this.input = this.inputEl.value = ""; + void this.error.run(() => this.page.changeLabel(this.id, "add", label)); + } + + private remove(label: string) { + void this.error.run(() => this.page.changeLabel(this.id, "remove", label)); + } + + render() { + const s = this.state; + if (s.status === "loading") this.listEl.replaceChildren(h("p", null, "loading labels")); + else if (s.status === "error") this.listEl.replaceChildren(h("p", null, `labels error: ${s.message}`)); + else + this.listEl.replaceChildren( + h( + "ul", + null, + ...(this.shown() ?? []).map(label => + h("li", null, h("span", { testid: "label" }, label), " ", button("remove-label", `remove ${label}`, () => this.remove(label))), + ), + ), + ); + this.errorEl.replaceChildren(this.error.line("label error: ")); + } +} diff --git a/eval/reference/desk9/vanilla/issues/outcome.ts b/eval/reference/desk9/vanilla/issues/outcome.ts new file mode 100644 index 00000000..dad6bdbe --- /dev/null +++ b/eval/reference/desk9/vanilla/issues/outcome.ts @@ -0,0 +1,34 @@ +// What a write came to, and the error line its kind of action shows. +import { h } from "../dom"; + +/** `null` when the server took the write, else the server's message */ +export type Outcome = string | null; + +/** + * An error line that the newest action of its kind owns: starting one clears it, and only the newest one reports its + * failure, while `signal` (the selection or page it belongs to) lasts. + */ +export class ActionError { + private seq = 0; + private message: string | null = null; + + constructor( + private readonly signal: AbortSignal, + private readonly render: () => void, + ) {} + + async run(action: () => Promise) { + const seq = ++this.seq; + this.message = null; + const outcome = await action(); + if (outcome !== null && seq === this.seq && !this.signal.aborted) { + this.message = outcome; + this.render(); + } + } + + /** the line to show (`prefix` and the message), or nothing */ + line(prefix: string, testid?: string) { + return this.message === null ? "" : h("p", { className: "error", testid }, prefix + this.message); + } +} diff --git a/eval/reference/desk9/vanilla/issues/page.ts b/eval/reference/desk9/vanilla/issues/page.ts new file mode 100644 index 00000000..5f85cf14 --- /dev/null +++ b/eval/reference/desk9/vanilla/issues/page.ts @@ -0,0 +1,229 @@ +// The issues page: the current user, the filter, the list with its row actions, and the selected issue's detail. +// Every write starts here, so a write keeps going (and its answer still counts) when the issue is no longer selected; +// all of it ends when the page is left (`signal`). +import * as api from "../api"; +import type { Comment, Issue } from "../api"; +import { button, errorMessage, h, latest, textInput } from "../dom"; +import { Clock } from "../register"; +import { Detail } from "./detail"; +import { IssueFacts } from "./facts"; +import { ActionError, type Outcome } from "./outcome"; + +type Filter = "open" | "closed"; +type ListState = + | { status: "loading" } + | { status: "error"; message: string } + | { status: "ok"; issues: Issue[] }; + +/** a comment on its way to the server */ +export type Post = { seq: number; issueId: number; author: string; text: string }; +/** a label change on its way to the server */ +export type LabelChange = { seq: number; issueId: number; kind: "add" | "remove"; label: string }; + +export type IssuesHost = { + /** the open list shows `n` settled rows */ + onOpenCount(n: number): void; + /** open the help page on `query` with hit `id` selected */ + openHelp(query: string, id: number): void; +}; + +export class IssuesPage { + readonly root: HTMLElement; + readonly facts = new IssueFacts(new Clock()); + private user = "guest"; + private filter: Filter = "open"; + private list: ListState = { status: "loading" }; + private readonly nextListLoad: () => AbortSignal; + private readonly nextSelection: () => AbortSignal; + private detail: Detail | null = null; + private posts: Post[] = []; + private labelChanges: LabelChange[] = []; + /** the row actions' error line */ + private readonly rowError: ActionError; + + private readonly listEl = h("div", { testid: "list" }); + private readonly rowErrorEl = h("div"); + private readonly detailEl = h("section", { testid: "detail" }, h("p", null, "select an issue")); + private readonly filterButtons: Record; + + constructor( + private readonly signal: AbortSignal, + private readonly host: IssuesHost, + ) { + this.rowError = new ActionError(signal, () => this.render()); + this.nextListLoad = latest(signal); + this.nextSelection = latest(signal); + this.filterButtons = { + open: button("filter-open", "open", () => this.setFilter("open")), + closed: button("filter-closed", "closed", () => this.setFilter("closed")), + }; + const userInput = textInput("user", this.user, value => { + this.user = value; + this.render(); + }); + this.root = h( + "main", + { className: "issues" }, + h( + "section", + { className: "issue-list" }, + h("label", null, "user ", userInput), + h("div", { className: "filters" }, this.filterButtons.open, " ", this.filterButtons.closed), + this.listEl, + this.rowErrorEl, + ), + this.detailEl, + ); + void this.loadList(); + this.render(); + } + + currentUser() { + return this.user; + } + + private setFilter(filter: Filter) { + if (filter === this.filter) return; + this.filter = filter; + void this.loadList(); + this.render(); + } + + private async loadList() { + const signal = this.nextListLoad(); + const filter = this.filter; + const askedAt = this.facts.clock.tick(); + this.list = { status: "loading" }; + try { + const issues = await api.fetchIssues(filter); + if (signal.aborted) return; + this.facts.learnList(filter, issues, askedAt); + this.list = { status: "ok", issues }; + } catch (e) { + if (signal.aborted) return; + this.list = { status: "error", message: errorMessage(e) }; + } + this.render(); + } + + private select(id: number) { + this.detail = new Detail(this, id, this.nextSelection()); + this.detailEl.replaceChildren(this.detail.root); + } + + // --- writes ------------------------------------------------------------------------------------------------- + + /** + * Runs one write against the server: shows its guess at once, and re-renders when it answers. Once the page is + * left, its answer changes nothing (the callers' signals have ended too, so they report nothing). + */ + private async write(request: Promise, accepted: (answer: T) => void, declined: () => void): Promise { + this.render(); + try { + const answer = await request; + if (this.signal.aborted) return null; + accepted(answer); + return null; + } catch (e) { + if (this.signal.aborted) return null; + declined(); + return errorMessage(e); + } finally { + if (!this.signal.aborted) this.render(); + } + } + + assign(id: number, user: string): Promise { + const w = this.facts.of(id).assignee.write(user); + return this.write(api.assign(id, user), res => w.accepted(res.assignee), w.declined); + } + + rename(id: number, title: string): Promise { + const w = this.facts.of(id).title.write(title); + return this.write(api.renameIssue(id, title), res => w.accepted(res.title), w.declined); + } + + close(id: number): Promise { + const w = this.facts.of(id).open.write(false); + return this.write(api.closeIssue(id), () => w.accepted(false), w.declined); + } + + postsFor(id: number) { + return this.posts.filter(p => p.issueId === id); + } + + post(id: number, author: string, text: string): Promise { + const seq = this.facts.clock.tick(); + this.posts = [...this.posts, { seq, issueId: id, author, text }]; + const settle = () => { + this.posts = this.posts.filter(p => p.seq !== seq); + }; + return this.write( + api.addComment(id, author, text), + (comment: Comment) => { + settle(); + this.detail?.commentAdded(id, comment); + }, + settle, + ); + } + + labelChangesFor(id: number) { + return this.labelChanges.filter(c => c.issueId === id); + } + + changeLabel(id: number, kind: LabelChange["kind"], label: string): Promise { + const seq = this.facts.clock.tick(); + this.labelChanges = [...this.labelChanges, { seq, issueId: id, kind, label }]; + const settle = () => { + this.labelChanges = this.labelChanges.filter(c => c.seq !== seq); + }; + return this.write( + kind === "add" ? api.addLabel(id, label) : api.removeLabel(id, label), + labels => { + settle(); + this.detail?.labelsAnswered(id, labels); + }, + settle, + ); + } + + openHelp(query: string, id: number) { + this.host.openHelp(query, id); + } + + // --- rendering ---------------------------------------------------------------------------------------------- + + render() { + for (const f of ["open", "closed"] as const) this.filterButtons[f].setAttribute("aria-pressed", String(f === this.filter)); + this.renderList(); + this.rowErrorEl.replaceChildren(this.rowError.line("row error: ", "row-error")); + this.detail?.render(); + } + + private renderList() { + const list = this.list; + if (list.status === "loading") return this.listEl.replaceChildren(h("p", null, `loading ${this.filter} issues`)); + if (list.status === "error") return this.listEl.replaceChildren(h("p", null, `list error: ${list.message}`)); + const issues = this.filter === "open" ? list.issues.filter(i => this.facts.showsAsOpen(i.id)) : list.issues; + if (this.filter === "open") this.host.onOpenCount(issues.length); + this.listEl.replaceChildren(h("ul", null, ...issues.map(issue => this.renderRow(this.facts.row(issue))))); + } + + private renderRow(issue: Issue) { + const id = issue.id; + return h( + "li", + null, + h( + "button", + { type: "button", testid: "issue", ariaPressed: String(this.detail?.id === id), onclick: () => this.select(id) }, + `#${id} ${issue.title} (${issue.assignee ?? "unassigned"})`, + ), + " ", + button("row-assign", `assign to ${this.user}`, () => void this.rowError.run(() => this.assign(id, this.user))), + this.filter === "open" && " ", + this.filter === "open" && button("row-close", "close", () => void this.rowError.run(() => this.close(id))), + ); + } +} diff --git a/eval/reference/desk9/vanilla/issues/related.ts b/eval/reference/desk9/vanilla/issues/related.ts new file mode 100644 index 00000000..19fbd8ae --- /dev/null +++ b/eval/reference/desk9/vanilla/issues/related.ts @@ -0,0 +1,36 @@ +// Related help: the first hits of a help search for the issue's title. Opening a hit hands the title and the hit to +// the help page. +import * as api from "../api"; +import { errorMessage, h } from "../dom"; + +export class Related { + readonly root = h("div", { testid: "related" }, h("p", null, "finding related help")); + + constructor( + private readonly title: string, + private readonly signal: AbortSignal, + private readonly open: (query: string, id: number) => void, + ) { + void this.load(); + } + + private async load() { + try { + const hits = await api.search(this.title); + if (this.signal.aborted) return; + if (hits.length === 0) return this.root.replaceChildren(h("p", null, "no related help")); + this.root.replaceChildren( + h( + "ul", + null, + ...hits.slice(0, 3).map(hit => + h("li", null, h("button", { type: "button", testid: "related-hit", onclick: () => this.open(this.title, hit.id) }, hit.name)), + ), + ), + ); + } catch (e) { + if (this.signal.aborted) return; + this.root.replaceChildren(h("p", null, `related help failed: ${errorMessage(e)}`)); + } + } +} diff --git a/eval/reference/desk9/vanilla/main.ts b/eval/reference/desk9/vanilla/main.ts new file mode 100644 index 00000000..402256b8 --- /dev/null +++ b/eval/reference/desk9/vanilla/main.ts @@ -0,0 +1,43 @@ +// The issue desk: three pages behind a row of tabs. Only the open page exists: leaving a page aborts its signal, +// which discards everything it was waiting for, and opening it again builds it afresh. +import { button, h } from "./dom"; +import { helpPage } from "./help"; +import { IssuesPage } from "./issues/page"; +import { settingsPage } from "./settings"; + +type Page = { name: "issues" } | { name: "help"; query: string; selected: number | null } | { name: "settings" }; + +export default function mount(root: HTMLElement): void { + let current: AbortController | null = null; + const issuesTab = button("tab-issues", "Issues", () => open({ name: "issues" })); + const tabs = { + issues: issuesTab, + help: button("tab-help", "Help", () => open({ name: "help", query: "", selected: null })), + settings: button("tab-settings", "Settings", () => open({ name: "settings" })), + }; + const pageEl = h("div"); + root.replaceChildren(h("nav", null, tabs.issues, " ", tabs.help, " ", tabs.settings), pageEl); + + // the number of open issues the issues page last showed; it stays while the list is away, and on other pages + const setOpenCount = (n: number) => { + issuesTab.textContent = `Issues (${n})`; + }; + + function open(page: Page) { + current?.abort(); + current = new AbortController(); + const signal = current.signal; + for (const [name, tab] of Object.entries(tabs)) tab.setAttribute("aria-current", String(name === page.name)); + if (page.name === "issues") + pageEl.replaceChildren( + new IssuesPage(signal, { + onOpenCount: setOpenCount, + openHelp: (query, selected) => open({ name: "help", query, selected }), + }).root, + ); + else if (page.name === "help") pageEl.replaceChildren(helpPage(signal, page)); + else pageEl.replaceChildren(settingsPage(signal)); + } + + open({ name: "issues" }); +} diff --git a/eval/reference/desk9/vanilla/register.ts b/eval/reference/desk9/vanilla/register.ts new file mode 100644 index 00000000..ea71c67a --- /dev/null +++ b/eval/reference/desk9/vanilla/register.ts @@ -0,0 +1,63 @@ +// A value the server holds, as one page knows it: the server's last word on it, with this page's writes still in the +// air laid over it. + +/** orders a page's requests and answers; one per page visit */ +export class Clock { + private now = 0; + tick() { + return ++this.now; + } +} + +export class Register { + /** what the server last said; `at` is when it said it, on the page's clock */ + private known: { value: T; at: number } | undefined; + /** writes in the air, oldest first */ + private writes: { seq: number; value: T }[] = []; + /** the write whose answer `known` holds: writes started before it are no news once it has answered */ + private answeredSeq = 0; + + constructor(private readonly clock: Clock) {} + + /** a read of the server that was asked at `askedAt` answered `value`; an answer to a newer question wins */ + learn(value: T, askedAt: number) { + if (!this.known || askedAt >= this.known.at) this.known = { value, at: askedAt }; + } + + /** + * What to show: the newest write still in the air, unless the server has since answered a newer one; otherwise + * the server's last word (`fallback` until it has said anything). + */ + shown(fallback: T): T { + const guess = this.guess(); + if (guess) return guess.value; + return this.known ? this.known.value : fallback; + } + + /** whether what shows is a write still in the air */ + guessing() { + return this.guess() !== undefined; + } + + private guess() { + const newest = this.writes.at(-1); + return newest && newest.seq > this.answeredSeq ? newest : undefined; + } + + /** starts a write of `value`; call `accepted` or `declined` when the server answers */ + write(value: T) { + const seq = this.clock.tick(); + this.writes.push({ seq, value }); + const done = () => { + this.writes = this.writes.filter(w => w.seq !== seq); + }; + return { + accepted: (saved: T) => { + done(); + this.known = { value: saved, at: this.clock.tick() }; + this.answeredSeq = seq; + }, + declined: done, + }; + } +} diff --git a/eval/reference/desk9/vanilla/settings.ts b/eval/reference/desk9/vanilla/settings.ts new file mode 100644 index 00000000..6d21f3fc --- /dev/null +++ b/eval/reference/desk9/vanilla/settings.ts @@ -0,0 +1,118 @@ +// The settings page: loads the settings, then a draft that belongs to the user and saves it. +import * as api from "./api"; +import type { Settings, Theme } from "./api"; +import { button, errorMessage, h, textInput } from "./dom"; +import { Clock, Register } from "./register"; + +const MAX_NAME = 20; + +function nameError(name: string) { + if (name.length === 0) return "Display name is required"; + if (name.length > MAX_NAME) return `Display name must be at most ${MAX_NAME} characters, not ${name.length}`; + return null; +} + +export function settingsPage(signal: AbortSignal): HTMLElement { + const root = h("main", { className: "settings" }); + + async function load() { + root.replaceChildren(h("p", { testid: "load" }, "Loading settings…")); + try { + const settings = await api.loadSettings(); + if (signal.aborted) return; + root.replaceChildren(editor(signal, settings)); + } catch (e) { + if (signal.aborted) return; + root.replaceChildren( + h("p", { testid: "load" }, `Could not load settings: ${errorMessage(e)}`), + " ", + button("retry-load", "Retry", () => void load()), + ); + } + } + + void load(); + return root; +} + +/** the saved line and the draft, seeded once from `loaded` */ +function editor(signal: AbortSignal, loaded: Settings): HTMLElement { + const saved = new Register(new Clock()); + saved.learn(loaded, 0); + let saveError: string | null = null; + const draft: Settings = { ...loaded }; + + const savedEl = h("p", { testid: "saved" }); + const saveErrorEl = h("div"); + const countEl = h("small", { testid: "name-count" }); + const nameErrorEl = h("div"); + const previewEl = h("p", { testid: "preview" }); + + const setTheme = (theme: Theme) => { + draft.theme = theme; + render(); + }; + const toggleDensity = () => { + draft.density = draft.density === "compact" ? "comfortable" : "compact"; + render(); + }; + + async function save() { + if (nameError(draft.name) !== null) return; + const value = { ...draft }; + const write = saved.write(value); + saveError = null; + render(); + try { + const answer = await api.saveSettings(value); + if (signal.aborted) return; + write.accepted(answer); + } catch (e) { + if (signal.aborted) return; + write.declined(); + saveError = errorMessage(e); + } + render(); + } + + function render() { + const s = saved.shown(loaded); + savedEl.textContent = `Saved: ${s.name} · ${s.theme} · ${s.density}${saved.guessing() ? " (saving…)" : ""}`; + saveErrorEl.replaceChildren(saveError === null ? "" : h("p", { testid: "save-error" }, `Save failed: ${saveError}`)); + countEl.textContent = `${draft.name.length}/${MAX_NAME}`; + const error = nameError(draft.name); + nameErrorEl.replaceChildren(error === null ? "" : h("p", { testid: "name-error" }, error)); + previewEl.textContent = `Preview: ${draft.theme} / ${draft.density}`; + } + + const root = h( + "div", + null, + savedEl, + saveErrorEl, + h( + "div", + null, + h("label", null, "Display name ", textInput("name", draft.name, value => { + draft.name = value; + render(); + })), + " ", + countEl, + nameErrorEl, + ), + h( + "div", + null, + button("theme-light", "light", () => setTheme("light")), + " ", + button("theme-dark", "dark", () => setTheme("dark")), + " ", + button("density-toggle", "Toggle density", toggleDensity), + previewEl, + ), + button("save", "Save", () => void save()), + ); + render(); + return root; +} From 8e67b4154509ff75bb911240effa231998bab0ef Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 06:24:30 +0000 Subject: [PATCH 04/32] eval: desk9 Foldkit reference (grades 0) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- eval/reference/desk9/foldkit/NOTES.md | 10 + eval/reference/desk9/foldkit/comments.ts | 211 +++++++ eval/reference/desk9/foldkit/help.ts | 221 +++++++ eval/reference/desk9/foldkit/issues.ts | 728 +++++++++++++++++++++++ eval/reference/desk9/foldkit/labels.ts | 258 ++++++++ eval/reference/desk9/foldkit/main.ts | 221 +++++++ eval/reference/desk9/foldkit/request.ts | 7 + eval/reference/desk9/foldkit/settings.ts | 201 +++++++ 8 files changed, 1857 insertions(+) create mode 100644 eval/reference/desk9/foldkit/NOTES.md create mode 100644 eval/reference/desk9/foldkit/comments.ts create mode 100644 eval/reference/desk9/foldkit/help.ts create mode 100644 eval/reference/desk9/foldkit/issues.ts create mode 100644 eval/reference/desk9/foldkit/labels.ts create mode 100644 eval/reference/desk9/foldkit/main.ts create mode 100644 eval/reference/desk9/foldkit/request.ts create mode 100644 eval/reference/desk9/foldkit/settings.ts diff --git a/eval/reference/desk9/foldkit/NOTES.md b/eval/reference/desk9/foldkit/NOTES.md new file mode 100644 index 00000000..5910e089 --- /dev/null +++ b/eval/reference/desk9/foldkit/NOTES.md @@ -0,0 +1,10 @@ +# desk9 reference: Foldkit 0.170.0 + +Grades 0: hidden 77/77, overlap 38/38, fuzz 0/20 (`--r9`; also 0/60 on seeds 21-80). 1847 lines. + +- Labels and comments are Submodels (`Update.foldChild`); related hits open help through an OutMessage. +- Writes in flight are laid over the last server answer, newest started on top; label changes replay in start order. +- `learnFromEarlierVisit`: a write accepted after its page was left is applied to the reopened page (its pending + display and errors are not). Without it the fuzz flags a row/detail disagreement or a stale comment list at rest, + though the brief only says leaving a page discards it. +- Two accepted writes to one field: the last answered wins (as vanilla; React keeps newest started). diff --git a/eval/reference/desk9/foldkit/comments.ts b/eval/reference/desk9/foldkit/comments.ts new file mode 100644 index 00000000..2c1373ae --- /dev/null +++ b/eval/reference/desk9/foldkit/comments.ts @@ -0,0 +1,211 @@ +import { Array, Effect, Match, Option, Schema, pipe } from 'effect' +import { Command, Submodel, type Update } from 'foldkit' +import { Html, HtmlBuilder } from 'foldkit/html' +import { defineMessageUnion } from 'foldkit/message' +import { defineTaggedUnion } from 'foldkit/schema' +import { modifyFields } from 'foldkit/struct' + +import * as Api from './api' +import { request } from './request' + +// The selected issue's comments. The model lives as long as the issues page, so posts still in the air are shown +// after whatever the issue's comments answer, also after the issue was selected again. + +// MODEL + +const Comment = Schema.Struct({ author: Schema.String, text: Schema.String }) +type Comment = typeof Comment.Type + +export const CommentsState = defineTaggedUnion({ + Loading: {}, + Loaded: { comments: Schema.Array(Comment) }, + Failed: { error: Schema.String }, +}) + +const Sending = Schema.Struct({ seq: Schema.Number, issueId: Schema.Number, comment: Comment }) + +const PostError = Schema.Struct({ issueId: Schema.Number, message: Schema.String }) + +export const Model = Schema.Struct({ + maybeIssueId: Schema.Option(Schema.Number), + loadSeq: Schema.Number, + comments: CommentsState, + input: Schema.String, + seq: Schema.Number, + sending: Schema.Array(Sending), + lastPost: Schema.Number, + maybeError: Schema.Option(PostError), +}) +export type Model = typeof Model.Type + +// MESSAGE + +export const Message = defineMessageUnion({ + SucceededFetchComments: { seq: Schema.Number, comments: Schema.Array(Comment) }, + FailedFetchComments: { seq: Schema.Number, error: Schema.String }, + UpdatedInput: { value: Schema.String }, + ClickedPost: {}, + SucceededAddComment: { seq: Schema.Number, issueId: Schema.Number, comment: Comment }, + FailedAddComment: { seq: Schema.Number, issueId: Schema.Number, error: Schema.String }, +}) +export type Message = typeof Message.Type + +// COMMAND + +const FetchComments = Command.define('FetchComments', { + args: { seq: Schema.Number, issueId: Schema.Number }, + messages: [Message.SucceededFetchComments, Message.FailedFetchComments], + execute: ({ seq, issueId }) => + request(() => Api.fetchComments(issueId)).pipe( + Effect.map(comments => Message.SucceededFetchComments({ seq, comments })), + Effect.catch(error => Effect.succeed(Message.FailedFetchComments({ seq, error }))), + ), +}) + +const AddComment = Command.define('AddComment', { + args: { seq: Schema.Number, issueId: Schema.Number, author: Schema.String, text: Schema.String }, + messages: [Message.SucceededAddComment, Message.FailedAddComment], + execute: ({ seq, issueId, author, text }) => + request(() => Api.addComment(issueId, author, text)).pipe( + Effect.map(comment => Message.SucceededAddComment({ seq, issueId, comment })), + Effect.catch(error => Effect.succeed(Message.FailedAddComment({ seq, issueId, error }))), + ), +}) + +// INIT + +export const init = (): Model => ({ + maybeIssueId: Option.none(), + loadSeq: 0, + comments: CommentsState.Loading(), + input: '', + seq: 1, + sending: [], + lastPost: 0, + maybeError: Option.none(), +}) + +// UPDATE + +type UpdateReturn = Update.Return + +/** Shows the comments of `issueId`, newly selected. */ +export const select = (model: Model, issueId: number): UpdateReturn => { + const loadSeq = model.loadSeq + 1 + return { + model: modifyFields(model, { + maybeIssueId: () => Option.some(issueId), + loadSeq: () => loadSeq, + comments: () => CommentsState.Loading(), + maybeError: () => Option.none(), + }), + commands: [FetchComments({ seq: loadSeq, issueId })], + } +} + +/** A comment the server took on `issueId`: it joins the comments on screen. */ +const withServerComment = (model: Model, issueId: number, comment: Comment): Model => + modifyFields(model, { + comments: current => + Option.contains(model.maybeIssueId, issueId) && current._tag === 'Loaded' + ? CommentsState.Loaded({ comments: Array.append(current.comments, comment) }) + : current, + }) + +const settlePost = (model: Model, seq: number): Model => + modifyFields(model, { sending: Array.filter(sending => sending.seq !== seq) }) + +export const update = (model: Model, message: Message, context: Readonly<{ user: string }>) => + Message.match(message, { + SucceededFetchComments: ({ seq, comments }) => + seq === model.loadSeq + ? { model: modifyFields(model, { comments: () => CommentsState.Loaded({ comments }) }) } + : { model }, + + FailedFetchComments: ({ seq, error }) => + seq === model.loadSeq ? { model: modifyFields(model, { comments: () => CommentsState.Failed({ error }) }) } : { model }, + + UpdatedInput: ({ value }) => ({ model: modifyFields(model, { input: () => value }) }), + + ClickedPost: () => + Option.match(model.maybeIssueId, { + onNone: () => ({ model }), + onSome: issueId => { + const comment = { author: context.user, text: model.input } + if (comment.text === '') { + return { model } + } + return { + model: modifyFields(model, { + seq: seq => seq + 1, + lastPost: () => model.seq, + maybeError: () => Option.none(), + input: () => '', + sending: Array.append({ seq: model.seq, issueId, comment }), + }), + commands: [AddComment({ seq: model.seq, issueId, ...comment })], + } + }, + }), + + SucceededAddComment: ({ seq, issueId, comment }) => ({ + model: withServerComment(settlePost(model, seq), issueId, comment), + }), + + // Only the newest post reports its failure. + FailedAddComment: ({ seq, issueId, error }) => ({ + model: modifyFields(settlePost(model, seq), { + maybeError: current => (model.lastPost === seq ? Option.some({ issueId, message: error }) : current), + }), + }), + }) + +/** An answer to a post made on an earlier visit of the issues page: the comment the server took still holds. */ +export const learnFromEarlierVisit = (model: Model, message: Message): Model => + Match.value(message).pipe( + Match.tag('SucceededAddComment', ({ issueId, comment }) => withServerComment(model, issueId, comment)), + Match.orElse(() => model), + ) + +// VIEW + +const testId = (h: HtmlBuilder, id: string) => h.DataAttribute('testid', id) + +const commentList = (model: Model, issueId: number, comments: ReadonlyArray, h: HtmlBuilder): Html => + h.ul([], [ + ...Array.map(comments, comment => h.li([testId(h, 'comment')], [`${comment.author}: ${comment.text}`])), + ...pipe( + model.sending, + Array.filter(sending => sending.issueId === issueId), + Array.map(({ seq, comment }) => + h.keyed('li')(`sending-${seq}`, [testId(h, 'comment')], [`${comment.author}: ${comment.text} (sending…)`]), + ), + ), + ]) + +const commentsState = (model: Model, issueId: number, h: HtmlBuilder): Html => + CommentsState.match(model.comments, { + Loading: () => h.p([], ['loading comments']), + Failed: ({ error }) => h.p([], [`comments error: ${error}`]), + Loaded: ({ comments }) => commentList(model, issueId, comments, h), + }) + +const errorLine = (model: Model, issueId: number, h: HtmlBuilder): Html => + pipe( + model.maybeError, + Option.filter(error => error.issueId === issueId), + Option.match({ onNone: () => h.empty, onSome: error => h.p([], [`post error: ${error.message}`]) }), + ) + +export const view = Submodel.defineView((model, h) => + Option.match(model.maybeIssueId, { + onNone: () => h.empty, + onSome: issueId => + h.div([], [ + h.div([testId(h, 'comments')], [commentsState(model, issueId, h)]), + h.input([testId(h, 'comment-input'), h.Value(model.input), h.OnInput(value => Message.UpdatedInput({ value }))]), + h.button([testId(h, 'post'), h.OnClick(Message.ClickedPost())], ['post']), + errorLine(model, issueId, h), + ]), + }), +) diff --git a/eval/reference/desk9/foldkit/help.ts b/eval/reference/desk9/foldkit/help.ts new file mode 100644 index 00000000..391eb26d --- /dev/null +++ b/eval/reference/desk9/foldkit/help.ts @@ -0,0 +1,221 @@ +import { Array, Effect, Option, Schema } from 'effect' +import { Command, Submodel, type Update } from 'foldkit' +import { Html, HtmlBuilder } from 'foldkit/html' +import { defineMessageUnion } from 'foldkit/message' +import { defineTaggedUnion } from 'foldkit/schema' +import { modifyFields } from 'foldkit/struct' + +import * as Api from './api' +import { request } from './request' + +// MODEL + +const Hit = Schema.Struct({ id: Schema.Number, name: Schema.String }) +const Item = Schema.Struct({ id: Schema.Number, name: Schema.String, blurb: Schema.String }) + +// The results on screen stay until a newer query's answer replaces them. +const Shown = Schema.Struct({ query: Schema.String, hits: Schema.Array(Hit) }) + +export const ItemState = defineTaggedUnion({ + Loading: {}, + Loaded: { maybeItem: Schema.Option(Item) }, + Failed: { error: Schema.String }, +}) + +export const Model = Schema.Struct({ + query: Schema.String, + searchSeq: Schema.Number, + isSearching: Schema.Boolean, + maybeShown: Schema.Option(Shown), + maybeSearchError: Schema.Option(Schema.String), + maybeSelected: Schema.Option(Schema.Number), + itemSeq: Schema.Number, + item: ItemState, +}) +export type Model = typeof Model.Type + +// MESSAGE + +export const Message = defineMessageUnion({ + UpdatedQuery: { value: Schema.String }, + SucceededSearch: { seq: Schema.Number, query: Schema.String, hits: Schema.Array(Hit) }, + FailedSearch: { seq: Schema.Number, error: Schema.String }, + ClickedHit: { id: Schema.Number }, + SucceededFetchItem: { seq: Schema.Number, item: Schema.NullOr(Item) }, + FailedFetchItem: { seq: Schema.Number, error: Schema.String }, + ClickedRetryItem: {}, +}) +export type Message = typeof Message.Type + +// COMMAND + +const Search = Command.define('Search', { + args: { seq: Schema.Number, query: Schema.String }, + messages: [Message.SucceededSearch, Message.FailedSearch], + execute: ({ seq, query }) => + request(() => Api.search(query)).pipe( + Effect.map(hits => Message.SucceededSearch({ seq, query, hits })), + Effect.catch(error => Effect.succeed(Message.FailedSearch({ seq, error }))), + ), +}) + +const FetchItem = Command.define('FetchItem', { + args: { seq: Schema.Number, id: Schema.Number }, + messages: [Message.SucceededFetchItem, Message.FailedFetchItem], + execute: ({ seq, id }) => + request(() => Api.fetchItem(id)).pipe( + Effect.map(item => Message.SucceededFetchItem({ seq, item })), + Effect.catch(error => Effect.succeed(Message.FailedFetchItem({ seq, error }))), + ), +}) + +// INIT + +// Opens empty from its tab, or on an issue's title with a related hit selected. +export const init = (query: string, maybeSelected: Option.Option): Update.Return => { + const isAsked = query.length >= 2 + return { + model: { + query, + searchSeq: 1, + isSearching: isAsked, + maybeShown: Option.none(), + maybeSearchError: Option.none(), + maybeSelected, + itemSeq: 1, + item: ItemState.Loading(), + }, + commands: [ + ...(isAsked ? [Search({ seq: 1, query })] : []), + ...Option.match(maybeSelected, { onNone: () => [], onSome: id => [FetchItem({ seq: 1, id })] }), + ], + } +} + +// UPDATE + +export const update = (model: Model, message: Message) => + Message.match>(message, { + UpdatedQuery: ({ value }) => { + const searchSeq = model.searchSeq + 1 + const isAsked = value.length >= 2 + const next = modifyFields(model, { + query: () => value, + searchSeq: () => searchSeq, + isSearching: () => isAsked, + maybeSearchError: () => Option.none(), + maybeShown: shown => (value === '' ? Option.none() : shown), + }) + return isAsked ? { model: next, commands: [Search({ seq: searchSeq, query: value })] } : { model: next } + }, + + SucceededSearch: ({ seq, query, hits }) => + seq === model.searchSeq + ? { + model: modifyFields(model, { + isSearching: () => false, + maybeShown: () => Option.some({ query, hits }), + }), + } + : { model }, + + FailedSearch: ({ seq, error }) => + seq === model.searchSeq + ? { model: modifyFields(model, { isSearching: () => false, maybeSearchError: () => Option.some(error) }) } + : { model }, + + ClickedHit: ({ id }) => { + if (Option.contains(model.maybeSelected, id)) { + return { model } + } + const itemSeq = model.itemSeq + 1 + return { + model: modifyFields(model, { + maybeSelected: () => Option.some(id), + itemSeq: () => itemSeq, + item: () => ItemState.Loading(), + }), + commands: [FetchItem({ seq: itemSeq, id })], + } + }, + + ClickedRetryItem: () => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => { + const itemSeq = model.itemSeq + 1 + return { model: modifyFields(model, { itemSeq: () => itemSeq }), commands: [FetchItem({ seq: itemSeq, id })] } + }, + }), + + SucceededFetchItem: ({ seq, item }) => + seq === model.itemSeq + ? { model: modifyFields(model, { item: () => ItemState.Loaded({ maybeItem: Option.fromNullishOr(item) }) }) } + : { model }, + + FailedFetchItem: ({ seq, error }) => + seq === model.itemSeq ? { model: modifyFields(model, { item: () => ItemState.Failed({ error }) }) } : { model }, + }) + +// VIEW + +const testId = (h: HtmlBuilder, id: string) => h.DataAttribute('testid', id) + +const resultsView = (model: Model, h: HtmlBuilder): Html => { + if (model.query.length < 2) { + return h.section([testId(h, 'results')], [h.p([], [`too short: ${model.query}`])]) + } + const shown = Option.match(model.maybeSearchError, { + onSome: error => (model.isSearching ? h.empty : h.p([], [`search failed: ${error}`])), + onNone: () => + Option.match(model.maybeShown, { + onNone: () => h.empty, + onSome: ({ query, hits }) => + h.div([], [ + h.h2([], [`${hits.length} results for ${query}`]), + h.ul( + [], + Array.map(hits, hit => + h.keyed('li')( + String(hit.id), + [testId(h, 'hit'), h.OnClick(Message.ClickedHit({ id: hit.id }))], + [`${Option.contains(model.maybeSelected, hit.id) ? '> ' : ''}${hit.name}`], + ), + ), + ), + ]), + }), + }) + return h.section([testId(h, 'results')], [model.isSearching ? h.p([], ['searching…']) : h.empty, shown]) +} + +const itemView = (model: Model, id: number, h: HtmlBuilder): Html => + h.section( + [testId(h, 'item')], + [ + ItemState.match(model.item, { + Loading: () => h.p([], ['loading…']), + Failed: ({ error }) => + h.div([], [ + h.p([], [`failed: ${error}`]), + h.button([testId(h, 'retry-item'), h.OnClick(Message.ClickedRetryItem())], ['retry']), + ]), + Loaded: ({ maybeItem }) => + Option.match(maybeItem, { + onNone: () => h.p([], [`item ${id} not found`]), + onSome: item => h.article([], [h.h3([], [item.name]), h.p([], [item.blurb])]), + }), + }), + ], + ) + +export const view = Submodel.defineView((model, h) => + h.main([], [ + h.input([testId(h, 'query'), h.Value(model.query), h.OnInput(value => Message.UpdatedQuery({ value }))]), + model.query === '' ? h.empty : resultsView(model, h), + Option.match(model.maybeSelected, { + onNone: () => h.p([testId(h, 'item')], ['pick a result']), + onSome: id => itemView(model, id, h), + }), + ]), +) diff --git a/eval/reference/desk9/foldkit/issues.ts b/eval/reference/desk9/foldkit/issues.ts new file mode 100644 index 00000000..70609cd6 --- /dev/null +++ b/eval/reference/desk9/foldkit/issues.ts @@ -0,0 +1,728 @@ +import { Array, Effect, Match, Option, Schema, pipe } from 'effect' +import { Command, Submodel, Update } from 'foldkit' +import { Html, HtmlBuilder } from 'foldkit/html' +import { defineMessageUnion } from 'foldkit/message' +import { defineTaggedUnion } from 'foldkit/schema' +import { modifyFields } from 'foldkit/struct' + +import * as Api from './api' +import * as Comments from './comments' +import * as Labels from './labels' +import { request } from './request' + +// MODEL + +const Filter = Schema.Literals(['open', 'closed']) +type Filter = typeof Filter.Type + +const Issue = Schema.Struct({ id: Schema.Number, title: Schema.String, assignee: Schema.NullOr(Schema.String) }) +type Issue = typeof Issue.Type +const Detail = Schema.Struct({ id: Schema.Number, title: Schema.String, body: Schema.String }) +const Hit = Schema.Struct({ id: Schema.Number, name: Schema.String }) + +export const ListState = defineTaggedUnion({ + Loading: {}, + Loaded: { issues: Schema.Array(Issue) }, + Failed: { error: Schema.String }, +}) +type ListState = typeof ListState.Type + +export const DetailState = defineTaggedUnion({ + Loading: {}, + Loaded: { detail: Detail }, + NotFound: {}, + Failed: { error: Schema.String }, +}) + +export const RelatedState = defineTaggedUnion({ + Loading: {}, + Loaded: { hits: Schema.Array(Hit) }, + Failed: { error: Schema.String }, +}) + +// Where a write was started: the detail's buttons or a list row's. Each place reports its own failures. +const Origin = Schema.Literals(['Detail', 'Row']) +type Origin = typeof Origin.Type + +// A write still in the air. `seq` orders writes: the newest one started on an issue is what the issue shows. +const Guess = Schema.Struct({ seq: Schema.Number, id: Schema.Number, value: Schema.String }) +type Guess = typeof Guess.Type +const Closing = Schema.Struct({ seq: Schema.Number, id: Schema.Number }) + +// An error that belongs to one issue: shown only while that issue is selected. +const IssueError = Schema.Struct({ id: Schema.Number, message: Schema.String }) +type IssueError = typeof IssueError.Type + +export const Model = Schema.Struct({ + user: Schema.String, + filter: Filter, + listSeq: Schema.Number, + list: ListState, + maybeSelected: Schema.Option(Schema.Number), + detailSeq: Schema.Number, + detail: DetailState, + seq: Schema.Number, + assigning: Schema.Array(Guess), + lastAssign: Schema.Number, + maybeAssignError: Schema.Option(IssueError), + titleInput: Schema.String, + renaming: Schema.Array(Guess), + lastRename: Schema.Number, + maybeRenameError: Schema.Option(IssueError), + closing: Schema.Array(Closing), + lastClose: Schema.Number, + maybeCloseError: Schema.Option(IssueError), + lastRowAction: Schema.Number, + maybeRowError: Schema.Option(Schema.String), + maybeRelatedTitle: Schema.Option(Schema.String), + relatedSeq: Schema.Number, + related: RelatedState, + labels: Labels.Model, + comments: Comments.Model, +}) +export type Model = typeof Model.Type + +// MESSAGE + +export const Message = defineMessageUnion({ + UpdatedUser: { value: Schema.String }, + ClickedFilter: { filter: Filter }, + SucceededFetchIssues: { seq: Schema.Number, issues: Schema.Array(Issue) }, + FailedFetchIssues: { seq: Schema.Number, error: Schema.String }, + ClickedIssue: { id: Schema.Number }, + ClickedRetry: {}, + SucceededFetchIssue: { seq: Schema.Number, maybeDetail: Schema.Option(Detail) }, + FailedFetchIssue: { seq: Schema.Number, error: Schema.String }, + ClickedAssign: {}, + ClickedRowAssign: { id: Schema.Number }, + SucceededAssign: { seq: Schema.Number, id: Schema.Number, assignee: Schema.String }, + FailedAssign: { seq: Schema.Number, origin: Origin, id: Schema.Number, error: Schema.String }, + UpdatedTitleInput: { value: Schema.String }, + ClickedRename: {}, + SucceededRename: { seq: Schema.Number, id: Schema.Number, title: Schema.String }, + FailedRename: { seq: Schema.Number, id: Schema.Number, error: Schema.String }, + ClickedClose: {}, + ClickedRowClose: { id: Schema.Number }, + SucceededClose: { seq: Schema.Number, id: Schema.Number }, + FailedClose: { seq: Schema.Number, origin: Origin, id: Schema.Number, error: Schema.String }, + SucceededSearchRelated: { seq: Schema.Number, hits: Schema.Array(Hit) }, + FailedSearchRelated: { seq: Schema.Number, error: Schema.String }, + ClickedRelatedHit: { query: Schema.String, id: Schema.Number }, + GotLabelsMessage: { message: Labels.Message }, + GotCommentsMessage: { message: Comments.Message }, +}) +export type Message = typeof Message.Type + +export const OutMessage = defineMessageUnion({ + OpenedRelatedHelp: { query: Schema.String, id: Schema.Number }, +}) +export type OutMessage = typeof OutMessage.Type + +// COMMAND + +const FetchIssues = Command.define('FetchIssues', { + args: { seq: Schema.Number, filter: Filter }, + messages: [Message.SucceededFetchIssues, Message.FailedFetchIssues], + execute: ({ seq, filter }) => + request(() => Api.fetchIssues(filter)).pipe( + Effect.map(issues => Message.SucceededFetchIssues({ seq, issues })), + Effect.catch(error => Effect.succeed(Message.FailedFetchIssues({ seq, error }))), + ), +}) + +const FetchIssue = Command.define('FetchIssue', { + args: { seq: Schema.Number, id: Schema.Number }, + messages: [Message.SucceededFetchIssue, Message.FailedFetchIssue], + execute: ({ seq, id }) => + request(() => Api.fetchIssue(id)).pipe( + Effect.map(detail => Message.SucceededFetchIssue({ seq, maybeDetail: Option.fromNullishOr(detail) })), + Effect.catch(error => Effect.succeed(Message.FailedFetchIssue({ seq, error }))), + ), +}) + +const Assign = Command.define('Assign', { + args: { seq: Schema.Number, origin: Origin, id: Schema.Number, user: Schema.String }, + messages: [Message.SucceededAssign, Message.FailedAssign], + execute: ({ seq, origin, id, user }) => + request(() => Api.assign(id, user)).pipe( + Effect.map(answer => Message.SucceededAssign({ seq, id, assignee: answer.assignee })), + Effect.catch(error => Effect.succeed(Message.FailedAssign({ seq, origin, id, error }))), + ), +}) + +const Rename = Command.define('Rename', { + args: { seq: Schema.Number, id: Schema.Number, title: Schema.String }, + messages: [Message.SucceededRename, Message.FailedRename], + execute: ({ seq, id, title }) => + request(() => Api.renameIssue(id, title)).pipe( + Effect.map(answer => Message.SucceededRename({ seq, id, title: answer.title })), + Effect.catch(error => Effect.succeed(Message.FailedRename({ seq, id, error }))), + ), +}) + +const Close = Command.define('Close', { + args: { seq: Schema.Number, origin: Origin, id: Schema.Number }, + messages: [Message.SucceededClose, Message.FailedClose], + execute: ({ seq, origin, id }) => + request(() => Api.closeIssue(id)).pipe( + Effect.as(Message.SucceededClose({ seq, id })), + Effect.catch(error => Effect.succeed(Message.FailedClose({ seq, origin, id, error }))), + ), +}) + +const SearchRelated = Command.define('SearchRelated', { + args: { seq: Schema.Number, title: Schema.String }, + messages: [Message.SucceededSearchRelated, Message.FailedSearchRelated], + execute: ({ seq, title }) => + request(() => Api.search(title)).pipe( + Effect.map(hits => Message.SucceededSearchRelated({ seq, hits })), + Effect.catch(error => Effect.succeed(Message.FailedSearchRelated({ seq, error }))), + ), +}) + +// INIT + +export const init = (): Update.Return => ({ + model: { + user: 'guest', + filter: 'open', + listSeq: 1, + list: ListState.Loading(), + maybeSelected: Option.none(), + detailSeq: 0, + detail: DetailState.Loading(), + seq: 1, + assigning: [], + lastAssign: 0, + maybeAssignError: Option.none(), + titleInput: '', + renaming: [], + lastRename: 0, + maybeRenameError: Option.none(), + closing: [], + lastClose: 0, + maybeCloseError: Option.none(), + lastRowAction: 0, + maybeRowError: Option.none(), + maybeRelatedTitle: Option.none(), + relatedSeq: 0, + related: RelatedState.Loading(), + labels: Labels.init(), + comments: Comments.init(), + }, + commands: [FetchIssues({ seq: 1, filter: 'open' })], +}) + +// UPDATE + +type UpdateReturn = Update.ReturnWithOutMessage +type UpdateStep = Update.Step + +const readLabels = (model: Model): Option.Option => Option.some(model.labels) +const writeLabels = (model: Model, nextLabels: Labels.Model): Model => modifyFields(model, { labels: () => nextLabels }) +const toGotLabelsMessage = (message: Labels.Message): Message => Message.GotLabelsMessage({ message }) + +const foldLabels = Update.foldChild({ + update: Labels.update, + read: readLabels, + write: writeLabels, + toParentMessage: toGotLabelsMessage, +}) + +const selectLabels = Update.foldChild({ + update: Labels.select, + read: readLabels, + write: writeLabels, + toParentMessage: toGotLabelsMessage, +}) + +const readComments = (model: Model): Option.Option => Option.some(model.comments) +const writeComments = (model: Model, nextComments: Comments.Model): Model => + modifyFields(model, { comments: () => nextComments }) +const toGotCommentsMessage = (message: Comments.Message): Message => Message.GotCommentsMessage({ message }) + +const foldComments = (user: string) => + Update.foldChild({ + update: (comments: Comments.Model, message: Comments.Message) => Comments.update(comments, message, { user }), + read: readComments, + write: writeComments, + toParentMessage: toGotCommentsMessage, + }) + +const selectComments = Update.foldChild({ + update: Comments.select, + read: readComments, + write: writeComments, + toParentMessage: toGotCommentsMessage, +}) + +const dropSettled = + (seq: number) => + (writes: ReadonlyArray): ReadonlyArray => + Array.filter(writes, write => write.seq !== seq) + +const latestGuess = (guesses: ReadonlyArray, id: number): Option.Option => + pipe( + guesses, + Array.filter(guess => guess.id === id), + Array.last, + Option.map(guess => guess.value), + ) + +const isClosing = (model: Model, id: number): boolean => Array.some(model.closing, closing => closing.id === id) + +const isSelected = (model: Model, id: number): boolean => Option.contains(model.maybeSelected, id) + +/** The server's rows with the writes still in the air laid over them. */ +const shownIssues = (model: Model, issues: ReadonlyArray): ReadonlyArray => + pipe( + issues, + Array.filter(issue => !(model.filter === 'open' && isClosing(model, issue.id))), + Array.map(issue => ({ + ...issue, + title: Option.getOrElse(latestGuess(model.renaming, issue.id), () => issue.title), + assignee: Option.getOrElse(latestGuess(model.assigning, issue.id), () => issue.assignee), + })), + ) + +/** The title the detail's heading shows, once the detail has loaded. */ +const shownTitle = (model: Model): Option.Option => + DetailState.match(model.detail, { + Loading: () => Option.none(), + NotFound: () => Option.none(), + Failed: () => Option.none(), + Loaded: ({ detail }) => Option.some(Option.getOrElse(latestGuess(model.renaming, detail.id), () => detail.title)), + }) + +/** The open list as shown, closes in the air already out of it. Other lists, loads and failures have no count. */ +export const openCount = (model: Model): Option.Option => + model.filter === 'open' && model.list._tag === 'Loaded' + ? Option.some(shownIssues(model, model.list.issues).length) + : Option.none() + +const mapIssue = + (id: number, f: (issue: Issue) => Issue) => + (list: ListState): ListState => + ListState.match(list, { + Loading: () => list, + Failed: () => list, + Loaded: ({ issues }) => ListState.Loaded({ issues: Array.map(issues, issue => (issue.id === id ? f(issue) : issue)) }), + }) + +// What the server answered a write: it holds for the issue whatever page visit or selection started the write. + +const withServerAssignee = (model: Model, id: number, assignee: string): Model => + modifyFields(model, { list: mapIssue(id, issue => ({ ...issue, assignee })) }) + +const withServerTitle = (model: Model, id: number, title: string): Model => + modifyFields(model, { + list: mapIssue(id, issue => ({ ...issue, title })), + detail: detail => + detail._tag === 'Loaded' && detail.detail.id === id + ? DetailState.Loaded({ detail: { ...detail.detail, title } }) + : detail, + }) + +const withServerClosed = (model: Model, id: number): Model => + modifyFields(model, { + list: list => + model.filter === 'open' && list._tag === 'Loaded' + ? ListState.Loaded({ issues: Array.filter(list.issues, issue => issue.id !== id) }) + : list, + }) + +// Related help follows the title the detail shows: whenever that changes, it is searched again. +const followRelated: UpdateStep = model => { + const maybeTitle = shownTitle(model) + const isSameTitle = Option.match(maybeTitle, { + onNone: () => Option.isNone(model.maybeRelatedTitle), + onSome: title => Option.contains(model.maybeRelatedTitle, title), + }) + if (isSameTitle) { + return { model } + } + const relatedSeq = model.relatedSeq + 1 + return { + model: modifyFields(model, { + maybeRelatedTitle: () => maybeTitle, + relatedSeq: () => relatedSeq, + related: () => RelatedState.Loading(), + }), + commands: Option.match(maybeTitle, { + onNone: () => [], + onSome: title => [SearchRelated({ seq: relatedSeq, title })], + }), + } +} + +const selectIssue = + (id: number): UpdateStep => + model => { + const detailSeq = model.detailSeq + 1 + return { + model: modifyFields(model, { + maybeSelected: () => Option.some(id), + detailSeq: () => detailSeq, + detail: () => DetailState.Loading(), + titleInput: () => '', + maybeAssignError: () => Option.none(), + maybeRenameError: () => Option.none(), + maybeCloseError: () => Option.none(), + }), + commands: [FetchIssue({ seq: detailSeq, id })], + } + } + +// A row action clears the row error; only the newest one reports its failure. +const beginRowAction = (model: Model): Model => + modifyFields(model, { lastRowAction: () => model.seq, maybeRowError: () => Option.none() }) + +const startAssign = (model: Model, origin: Origin, id: number): UpdateReturn => { + const started = + origin === 'Row' + ? beginRowAction(model) + : modifyFields(model, { lastAssign: () => model.seq, maybeAssignError: () => Option.none() }) + return { + model: modifyFields(started, { + seq: seq => seq + 1, + assigning: Array.append({ seq: model.seq, id, value: model.user }), + }), + commands: [Assign({ seq: model.seq, origin, id, user: model.user })], + } +} + +const startClose = (model: Model, origin: Origin, id: number): UpdateReturn => { + const started = + origin === 'Row' + ? beginRowAction(model) + : modifyFields(model, { lastClose: () => model.seq, maybeCloseError: () => Option.none() }) + return { + model: modifyFields(started, { seq: seq => seq + 1, closing: Array.append({ seq: model.seq, id }) }), + commands: [Close({ seq: model.seq, origin, id })], + } +} + +// Only the newest write of a kind, from one place, reports its failure. +const failure = (last: number, seq: number, id: number, message: string) => (current: Option.Option) => + last === seq ? Option.some({ id, message }) : current + +const reportRowFailure = (model: Model, seq: number, message: string): Model => + modifyFields(model, { maybeRowError: current => (model.lastRowAction === seq ? Option.some(message) : current) }) + +const updatePage = (model: Model, message: Message) => + Message.match(message, { + UpdatedUser: ({ value }) => ({ model: modifyFields(model, { user: () => value }) }), + + ClickedFilter: ({ filter }) => { + if (filter === model.filter) { + return { model } + } + const listSeq = model.listSeq + 1 + return { + model: modifyFields(model, { filter: () => filter, listSeq: () => listSeq, list: () => ListState.Loading() }), + commands: [FetchIssues({ seq: listSeq, filter })], + } + }, + + SucceededFetchIssues: ({ seq, issues }) => + seq === model.listSeq ? { model: modifyFields(model, { list: () => ListState.Loaded({ issues }) }) } : { model }, + + FailedFetchIssues: ({ seq, error }) => + seq === model.listSeq ? { model: modifyFields(model, { list: () => ListState.Failed({ error }) }) } : { model }, + + ClickedIssue: ({ id }) => + isSelected(model, id) ? { model } : Update.combine(model, [selectIssue(id), selectLabels(id), selectComments(id)]), + + ClickedRetry: () => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => { + const detailSeq = model.detailSeq + 1 + return { + model: modifyFields(model, { detailSeq: () => detailSeq, detail: () => DetailState.Loading() }), + commands: [FetchIssue({ seq: detailSeq, id })], + } + }, + }), + + SucceededFetchIssue: ({ seq, maybeDetail }) => + seq === model.detailSeq + ? { + model: modifyFields(model, { + detail: () => + Option.match(maybeDetail, { + onNone: () => DetailState.NotFound(), + onSome: detail => DetailState.Loaded({ detail }), + }), + }), + } + : { model }, + + FailedFetchIssue: ({ seq, error }) => + seq === model.detailSeq ? { model: modifyFields(model, { detail: () => DetailState.Failed({ error }) }) } : { model }, + + ClickedAssign: () => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => startAssign(model, 'Detail', id), + }), + + ClickedRowAssign: ({ id }) => startAssign(model, 'Row', id), + + SucceededAssign: ({ seq, id, assignee }) => ({ + model: withServerAssignee(modifyFields(model, { assigning: dropSettled(seq) }), id, assignee), + }), + + FailedAssign: ({ seq, origin, id, error }) => { + const settled = modifyFields(model, { assigning: dropSettled(seq) }) + return { + model: + origin === 'Row' + ? reportRowFailure(settled, seq, error) + : modifyFields(settled, { maybeAssignError: failure(model.lastAssign, seq, id, error) }), + } + }, + + UpdatedTitleInput: ({ value }) => ({ model: modifyFields(model, { titleInput: () => value }) }), + + ClickedRename: () => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => + model.titleInput === '' + ? { model } + : { + model: modifyFields(model, { + seq: seq => seq + 1, + lastRename: () => model.seq, + maybeRenameError: () => Option.none(), + renaming: Array.append({ seq: model.seq, id, value: model.titleInput }), + }), + commands: [Rename({ seq: model.seq, id, title: model.titleInput })], + }, + }), + + SucceededRename: ({ seq, id, title }) => ({ + model: withServerTitle(modifyFields(model, { renaming: dropSettled(seq) }), id, title), + }), + + FailedRename: ({ seq, id, error }) => ({ + model: modifyFields(model, { + renaming: dropSettled(seq), + maybeRenameError: failure(model.lastRename, seq, id, error), + }), + }), + + ClickedClose: () => + Option.match(model.maybeSelected, { + onNone: () => ({ model }), + onSome: id => startClose(model, 'Detail', id), + }), + + ClickedRowClose: ({ id }) => startClose(model, 'Row', id), + + SucceededClose: ({ seq, id }) => ({ + model: withServerClosed(modifyFields(model, { closing: dropSettled(seq) }), id), + }), + + FailedClose: ({ seq, origin, id, error }) => { + const settled = modifyFields(model, { closing: dropSettled(seq) }) + return { + model: + origin === 'Row' + ? reportRowFailure(settled, seq, error) + : modifyFields(settled, { maybeCloseError: failure(model.lastClose, seq, id, error) }), + } + }, + + SucceededSearchRelated: ({ seq, hits }) => + seq === model.relatedSeq ? { model: modifyFields(model, { related: () => RelatedState.Loaded({ hits }) }) } : { model }, + + FailedSearchRelated: ({ seq, error }) => + seq === model.relatedSeq ? { model: modifyFields(model, { related: () => RelatedState.Failed({ error }) }) } : { model }, + + ClickedRelatedHit: ({ query, id }) => ({ model, outMessage: OutMessage.OpenedRelatedHelp({ query, id }) }), + + GotLabelsMessage: ({ message }) => foldLabels(model, message), + + GotCommentsMessage: ({ message }) => foldComments(model.user)(model, message), + }) + +export const update = (model: Model, message: Message): UpdateReturn => { + const pageUpdate = updatePage(model, message) + const relatedFollow = followRelated(pageUpdate.model) + return Update.withOutMessage( + { model: relatedFollow.model, commands: [...(pageUpdate.commands ?? []), ...(relatedFollow.commands ?? [])] }, + pageUpdate.outMessage, + ) +} + +/** An answer to a write made on an earlier visit of the issues page. Its guesses and errors went with that visit, + * but what the server took still holds and shows on this one. */ +export const learnFromEarlierVisit = (model: Model, message: Message): Update.Return => { + const learned = Match.value(message).pipe( + Match.tag('SucceededAssign', ({ id, assignee }) => withServerAssignee(model, id, assignee)), + Match.tag('SucceededRename', ({ id, title }) => withServerTitle(model, id, title)), + Match.tag('SucceededClose', ({ id }) => withServerClosed(model, id)), + Match.tag('GotLabelsMessage', ({ message }) => + modifyFields(model, { labels: labels => Labels.learnFromEarlierVisit(labels, message) }), + ), + Match.tag('GotCommentsMessage', ({ message }) => + modifyFields(model, { comments: comments => Comments.learnFromEarlierVisit(comments, message) }), + ), + Match.orElse(() => model), + ) + return followRelated(learned) +} + +// VIEW + +const testId = (h: HtmlBuilder, id: string) => h.DataAttribute('testid', id) + +const issueRow = (model: Model, issue: Issue, h: HtmlBuilder): Html => + h.keyed('li')(String(issue.id), [], [ + h.button( + [testId(h, 'issue'), h.AriaPressed(String(isSelected(model, issue.id))), h.OnClick(Message.ClickedIssue({ id: issue.id }))], + [`#${issue.id} ${issue.title} (${issue.assignee ?? 'unassigned'})`], + ), + ' ', + h.button([testId(h, 'row-assign'), h.OnClick(Message.ClickedRowAssign({ id: issue.id }))], [`assign to ${model.user}`]), + model.filter === 'open' + ? h.button([testId(h, 'row-close'), h.OnClick(Message.ClickedRowClose({ id: issue.id }))], ['close']) + : h.empty, + ]) + +const listView = (model: Model, h: HtmlBuilder): Html => + ListState.match(model.list, { + Loading: () => h.p([], [`loading ${model.filter} issues`]), + Failed: ({ error }) => h.p([], [`list error: ${error}`]), + Loaded: ({ issues }) => + h.ul( + [], + Array.map(shownIssues(model, issues), issue => issueRow(model, issue, h)), + ), + }) + +const errorLine = (model: Model, maybeError: Option.Option, prefix: string, h: HtmlBuilder): Html => + pipe( + maybeError, + Option.filter(error => isSelected(model, error.id)), + Option.match({ onNone: () => h.empty, onSome: error => h.p([], [`${prefix}${error.message}`]) }), + ) + +const assignErrorLine = (model: Model, h: HtmlBuilder): Html => + pipe( + model.maybeAssignError, + Option.filter(error => isSelected(model, error.id)), + Option.match({ onNone: () => h.empty, onSome: error => h.p([testId(h, 'assign-error')], [error.message]) }), + ) + +const relatedHits = (model: Model, hits: ReadonlyArray, h: HtmlBuilder): Html => + Array.match(hits, { + onEmpty: () => h.p([], ['no related help']), + onNonEmpty: nonEmpty => + h.ul( + [], + Array.map(Array.take(nonEmpty, 3), hit => + h.keyed('li')(String(hit.id), [], [ + h.button( + [ + testId(h, 'related-hit'), + h.OnClick( + Message.ClickedRelatedHit({ query: Option.getOrElse(model.maybeRelatedTitle, () => ''), id: hit.id }), + ), + ], + [hit.name], + ), + ]), + ), + ), + }) + +const relatedView = (model: Model, h: HtmlBuilder): Html => + h.div( + [testId(h, 'related')], + [ + RelatedState.match(model.related, { + Loading: () => h.p([], ['finding related help']), + Failed: ({ error }) => h.p([], [`related help failed: ${error}`]), + Loaded: ({ hits }) => relatedHits(model, hits, h), + }), + ], + ) + +const detailBody = (model: Model, id: number, h: HtmlBuilder): Html => + DetailState.match(model.detail, { + Loading: () => h.p([], [`loading #${id}`]), + NotFound: () => h.p([], [`detail error: issue #${id} not found`]), + Failed: ({ error }) => h.p([], [`detail error: ${error}`]), + Loaded: ({ detail }) => + h.div([], [ + h.h2([], [`#${detail.id} ${Option.getOrElse(shownTitle(model), () => detail.title)}`]), + h.p([], [detail.body]), + relatedView(model, h), + ]), + }) + +const detailView = (model: Model, id: number, h: HtmlBuilder): ReadonlyArray => [ + detailBody(model, id, h), + h.button([testId(h, 'retry'), h.OnClick(Message.ClickedRetry())], ['retry']), + h.button([testId(h, 'assign'), h.OnClick(Message.ClickedAssign())], [`assign to ${model.user}`]), + assignErrorLine(model, h), + h.div([], [ + h.input([testId(h, 'title-input'), h.Value(model.titleInput), h.OnInput(value => Message.UpdatedTitleInput({ value }))]), + h.button([testId(h, 'rename'), h.OnClick(Message.ClickedRename())], ['rename']), + errorLine(model, model.maybeRenameError, 'rename error: ', h), + ]), + h.button([testId(h, 'close'), h.OnClick(Message.ClickedClose())], ['close']), + errorLine(model, model.maybeCloseError, 'close error: ', h), + h.submodel({ + slotId: 'labels', + model: model.labels, + view: Labels.view, + toParentMessage: toGotLabelsMessage, + }), + h.submodel({ + slotId: 'comments', + model: model.comments, + view: Comments.view, + toParentMessage: toGotCommentsMessage, + }), +] + +export const view = Submodel.defineView((model, h) => + h.div([], [ + h.section([], [ + h.label([], [ + 'user ', + h.input([testId(h, 'user'), h.Value(model.user), h.OnInput(value => Message.UpdatedUser({ value }))]), + ]), + h.div([], [ + h.button( + [testId(h, 'filter-open'), h.AriaPressed(String(model.filter === 'open')), h.OnClick(Message.ClickedFilter({ filter: 'open' }))], + ['open'], + ), + h.button( + [ + testId(h, 'filter-closed'), + h.AriaPressed(String(model.filter === 'closed')), + h.OnClick(Message.ClickedFilter({ filter: 'closed' })), + ], + ['closed'], + ), + ]), + h.div([testId(h, 'list')], [listView(model, h)]), + Option.match(model.maybeRowError, { + onNone: () => h.empty, + onSome: error => h.p([testId(h, 'row-error')], [`row error: ${error}`]), + }), + ]), + h.section( + [testId(h, 'detail')], + Option.match(model.maybeSelected, { + onNone: () => [h.p([], ['select an issue'])], + onSome: id => detailView(model, id, h), + }), + ), + ]), +) diff --git a/eval/reference/desk9/foldkit/labels.ts b/eval/reference/desk9/foldkit/labels.ts new file mode 100644 index 00000000..4965ed9c --- /dev/null +++ b/eval/reference/desk9/foldkit/labels.ts @@ -0,0 +1,258 @@ +import { Array, Effect, Match, Option, Schema, pipe } from 'effect' +import { Command, Submodel, type Update } from 'foldkit' +import { Html, HtmlBuilder } from 'foldkit/html' +import { defineMessageUnion } from 'foldkit/message' +import { defineTaggedUnion } from 'foldkit/schema' +import { modifyFields } from 'foldkit/struct' + +import * as Api from './api' +import { request } from './request' + +// The selected issue's labels. The model lives as long as the issues page, so label changes still in the air are +// laid over whatever the issue's labels answer, also after the issue was selected again. + +// MODEL + +export const LabelsState = defineTaggedUnion({ + Loading: {}, + Loaded: { labels: Schema.Array(Schema.String) }, + Failed: { error: Schema.String }, +}) +type LabelsState = typeof LabelsState.Type + +const ChangeKind = Schema.Literals(['Add', 'Remove']) +type ChangeKind = typeof ChangeKind.Type + +const Change = Schema.Struct({ seq: Schema.Number, issueId: Schema.Number, kind: ChangeKind, label: Schema.String }) +type Change = typeof Change.Type + +const LabelError = Schema.Struct({ issueId: Schema.Number, message: Schema.String }) + +export const Model = Schema.Struct({ + maybeIssueId: Schema.Option(Schema.Number), + loadSeq: Schema.Number, + labels: LabelsState, + input: Schema.String, + seq: Schema.Number, + changes: Schema.Array(Change), + lastChange: Schema.Number, + maybeError: Schema.Option(LabelError), +}) +export type Model = typeof Model.Type + +// MESSAGE + +export const Message = defineMessageUnion({ + SucceededFetchLabels: { seq: Schema.Number, labels: Schema.Array(Schema.String) }, + FailedFetchLabels: { seq: Schema.Number, error: Schema.String }, + UpdatedInput: { value: Schema.String }, + ClickedAdd: {}, + ClickedRemove: { label: Schema.String }, + SucceededAddLabel: { seq: Schema.Number, issueId: Schema.Number, labels: Schema.Array(Schema.String) }, + FailedAddLabel: { seq: Schema.Number, issueId: Schema.Number, error: Schema.String }, + SucceededRemoveLabel: { seq: Schema.Number, issueId: Schema.Number, labels: Schema.Array(Schema.String) }, + FailedRemoveLabel: { seq: Schema.Number, issueId: Schema.Number, error: Schema.String }, +}) +export type Message = typeof Message.Type + +// COMMAND + +const FetchLabels = Command.define('FetchLabels', { + args: { seq: Schema.Number, issueId: Schema.Number }, + messages: [Message.SucceededFetchLabels, Message.FailedFetchLabels], + execute: ({ seq, issueId }) => + request(() => Api.fetchLabels(issueId)).pipe( + Effect.map(labels => Message.SucceededFetchLabels({ seq, labels })), + Effect.catch(error => Effect.succeed(Message.FailedFetchLabels({ seq, error }))), + ), +}) + +const AddLabel = Command.define('AddLabel', { + args: { seq: Schema.Number, issueId: Schema.Number, label: Schema.String }, + messages: [Message.SucceededAddLabel, Message.FailedAddLabel], + execute: ({ seq, issueId, label }) => + request(() => Api.addLabel(issueId, label)).pipe( + Effect.map(labels => Message.SucceededAddLabel({ seq, issueId, labels })), + Effect.catch(error => Effect.succeed(Message.FailedAddLabel({ seq, issueId, error }))), + ), +}) + +const RemoveLabel = Command.define('RemoveLabel', { + args: { seq: Schema.Number, issueId: Schema.Number, label: Schema.String }, + messages: [Message.SucceededRemoveLabel, Message.FailedRemoveLabel], + execute: ({ seq, issueId, label }) => + request(() => Api.removeLabel(issueId, label)).pipe( + Effect.map(labels => Message.SucceededRemoveLabel({ seq, issueId, labels })), + Effect.catch(error => Effect.succeed(Message.FailedRemoveLabel({ seq, issueId, error }))), + ), +}) + +// INIT + +export const init = (): Model => ({ + maybeIssueId: Option.none(), + loadSeq: 0, + labels: LabelsState.Loading(), + input: '', + seq: 1, + changes: [], + lastChange: 0, + maybeError: Option.none(), +}) + +// UPDATE + +type UpdateReturn = Update.Return + +/** Shows the labels of `issueId`, newly selected. */ +export const select = (model: Model, issueId: number): UpdateReturn => { + const loadSeq = model.loadSeq + 1 + return { + model: modifyFields(model, { + maybeIssueId: () => Option.some(issueId), + loadSeq: () => loadSeq, + labels: () => LabelsState.Loading(), + maybeError: () => Option.none(), + }), + commands: [FetchLabels({ seq: loadSeq, issueId })], + } +} + +const isShowing = (model: Model, issueId: number): boolean => Option.contains(model.maybeIssueId, issueId) + +const withChange = (labels: ReadonlyArray, change: Change): ReadonlyArray => + change.kind === 'Remove' + ? Array.filter(labels, label => label !== change.label) + : Array.contains(labels, change.label) + ? labels + : Array.append(labels, change.label) + +/** The server's labels with the changes still in the air laid over them, in the order they started. */ +const shownLabels = (model: Model, issueId: number, labels: ReadonlyArray): ReadonlyArray => + pipe( + model.changes, + Array.filter(change => change.issueId === issueId), + Array.reduce(labels, withChange), + ) + +/** The labels the server answered for `issueId`, after one of its changes. */ +const withServerLabels = (model: Model, issueId: number, labels: ReadonlyArray): Model => + modifyFields(model, { + labels: current => + isShowing(model, issueId) && current._tag === 'Loaded' ? LabelsState.Loaded({ labels }) : current, + }) + +const startChange = (model: Model, kind: ChangeKind, label: string): UpdateReturn => + Option.match(model.maybeIssueId, { + onNone: () => ({ model }), + onSome: issueId => ({ + model: modifyFields(model, { + seq: seq => seq + 1, + lastChange: () => model.seq, + maybeError: () => Option.none(), + changes: Array.append({ seq: model.seq, issueId, kind, label }), + }), + commands: [ + kind === 'Add' + ? AddLabel({ seq: model.seq, issueId, label }) + : RemoveLabel({ seq: model.seq, issueId, label }), + ], + }), + }) + +const settleChange = (model: Model, seq: number): Model => + modifyFields(model, { changes: Array.filter(change => change.seq !== seq) }) + +const acceptChange = (model: Model, seq: number, issueId: number, labels: ReadonlyArray): UpdateReturn => ({ + model: withServerLabels(settleChange(model, seq), issueId, labels), +}) + +// Only the newest label change reports its failure. +const declineChange = (model: Model, seq: number, issueId: number, message: string): UpdateReturn => ({ + model: modifyFields(settleChange(model, seq), { + maybeError: current => (model.lastChange === seq ? Option.some({ issueId, message }) : current), + }), +}) + +export const update = (model: Model, message: Message) => + Message.match(message, { + SucceededFetchLabels: ({ seq, labels }) => + seq === model.loadSeq ? { model: modifyFields(model, { labels: () => LabelsState.Loaded({ labels }) }) } : { model }, + + FailedFetchLabels: ({ seq, error }) => + seq === model.loadSeq ? { model: modifyFields(model, { labels: () => LabelsState.Failed({ error }) }) } : { model }, + + UpdatedInput: ({ value }) => ({ model: modifyFields(model, { input: () => value }) }), + + ClickedAdd: () => { + const label = model.input + const isNew = Option.match(model.maybeIssueId, { + onNone: () => false, + onSome: issueId => + model.labels._tag === 'Loaded' && !Array.contains(shownLabels(model, issueId, model.labels.labels), label), + }) + if (label === '' || !isNew) { + return { model } + } + return startChange(modifyFields(model, { input: () => '' }), 'Add', label) + }, + + ClickedRemove: ({ label }) => startChange(model, 'Remove', label), + + SucceededAddLabel: ({ seq, issueId, labels }) => acceptChange(model, seq, issueId, labels), + FailedAddLabel: ({ seq, issueId, error }) => declineChange(model, seq, issueId, error), + SucceededRemoveLabel: ({ seq, issueId, labels }) => acceptChange(model, seq, issueId, labels), + FailedRemoveLabel: ({ seq, issueId, error }) => declineChange(model, seq, issueId, error), + }) + +/** An answer to a change made on an earlier visit of the issues page: the labels the server answered still hold. */ +export const learnFromEarlierVisit = (model: Model, message: Message): Model => + Match.value(message).pipe( + Match.tag('SucceededAddLabel', 'SucceededRemoveLabel', ({ issueId, labels }) => + withServerLabels(model, issueId, labels), + ), + Match.orElse(() => model), + ) + +// VIEW + +const testId = (h: HtmlBuilder, id: string) => h.DataAttribute('testid', id) + +const labelList = (model: Model, issueId: number, labels: ReadonlyArray, h: HtmlBuilder): Html => + h.ul( + [], + Array.map(shownLabels(model, issueId, labels), label => + h.keyed('li')(label, [], [ + h.span([testId(h, 'label')], [label]), + ' ', + h.button([testId(h, 'remove-label'), h.OnClick(Message.ClickedRemove({ label }))], [`remove ${label}`]), + ]), + ), + ) + +const labelsState = (model: Model, issueId: number, h: HtmlBuilder): Html => + LabelsState.match(model.labels, { + Loading: () => h.p([], ['loading labels']), + Failed: ({ error }) => h.p([], [`labels error: ${error}`]), + Loaded: ({ labels }) => labelList(model, issueId, labels, h), + }) + +const errorLine = (model: Model, issueId: number, h: HtmlBuilder): Html => + pipe( + model.maybeError, + Option.filter(error => error.issueId === issueId), + Option.match({ onNone: () => h.empty, onSome: error => h.p([], [`label error: ${error.message}`]) }), + ) + +export const view = Submodel.defineView((model, h) => + Option.match(model.maybeIssueId, { + onNone: () => h.empty, + onSome: issueId => + h.div([], [ + h.div([testId(h, 'labels')], [labelsState(model, issueId, h)]), + h.input([testId(h, 'label-input'), h.Value(model.input), h.OnInput(value => Message.UpdatedInput({ value }))]), + h.button([testId(h, 'add-label'), h.OnClick(Message.ClickedAdd())], ['add label']), + errorLine(model, issueId, h), + ]), + }), +) diff --git a/eval/reference/desk9/foldkit/main.ts b/eval/reference/desk9/foldkit/main.ts new file mode 100644 index 00000000..6fc6e6ea --- /dev/null +++ b/eval/reference/desk9/foldkit/main.ts @@ -0,0 +1,221 @@ +import { Option, Schema } from 'effect' +import { Command, type Runtime, Update } from 'foldkit' +import { Document, Html, HtmlBuilder } from 'foldkit/html' +import { defineMessageUnion } from 'foldkit/message' +import { defineTaggedUnion } from 'foldkit/schema' +import { modifyFields } from 'foldkit/struct' + +import * as Help from './help' +import * as Issues from './issues' +import * as Settings from './settings' + +// MODEL + +const Tab = Schema.Literals(['issues', 'help', 'settings']) +type Tab = typeof Tab.Type + +// Only the open page is kept: leaving a page discards it. Each opening of a page gets a new `pageId`, and answers to +// a page that was left carry an old one. +export const Page = defineTaggedUnion({ + Issues: { model: Issues.Model }, + Help: { model: Help.Model }, + Settings: { model: Settings.Model }, +}) +type Page = typeof Page.Type + +// `maybeOpenCount` is the tab's open-issue count: the last one the issues page showed, kept while other pages are open. +export const Model = Schema.Struct({ pageId: Schema.Number, page: Page, maybeOpenCount: Schema.Option(Schema.Number) }) +export type Model = typeof Model.Type + +// MESSAGE + +export const Message = defineMessageUnion({ + ClickedTab: { tab: Tab }, + GotIssuesMessage: { pageId: Schema.Number, message: Issues.Message }, + GotHelpMessage: { pageId: Schema.Number, message: Help.Message }, + GotSettingsMessage: { pageId: Schema.Number, message: Settings.Message }, +}) +export type Message = typeof Message.Type + +// INIT + +type UpdateReturn = Update.Return +type UpdateStep = Update.Step + +const toGotIssuesMessage = (pageId: number) => (message: Issues.Message) => Message.GotIssuesMessage({ pageId, message }) +const toGotHelpMessage = (pageId: number) => (message: Help.Message) => Message.GotHelpMessage({ pageId, message }) +const toGotSettingsMessage = (pageId: number) => (message: Settings.Message) => + Message.GotSettingsMessage({ pageId, message }) + +const openIssues = (model: Model): UpdateReturn => { + const pageId = model.pageId + 1 + const issuesInit = Issues.init() + return { + model: modifyFields(model, { pageId: () => pageId, page: () => Page.Issues({ model: issuesInit.model }) }), + commands: Command.mapMessages(issuesInit.commands, toGotIssuesMessage(pageId)), + } +} + +const openHelp = (model: Model, query: string, maybeSelected: Option.Option): UpdateReturn => { + const pageId = model.pageId + 1 + const helpInit = Help.init(query, maybeSelected) + return { + model: modifyFields(model, { pageId: () => pageId, page: () => Page.Help({ model: helpInit.model }) }), + commands: Command.mapMessages(helpInit.commands, toGotHelpMessage(pageId)), + } +} + +const openSettings = (model: Model): UpdateReturn => { + const pageId = model.pageId + 1 + const settingsInit = Settings.init() + return { + model: modifyFields(model, { pageId: () => pageId, page: () => Page.Settings({ model: settingsInit.model }) }), + commands: Command.mapMessages(settingsInit.commands, toGotSettingsMessage(pageId)), + } +} + +export const init: Runtime.ApplicationInit = () => + openIssues({ pageId: 0, page: Page.Settings({ model: Settings.init().model }), maybeOpenCount: Option.none() }) + +// UPDATE + +const tabOf = (page: Page): Tab => + Page.match(page, { Issues: () => 'issues', Help: () => 'help', Settings: () => 'settings' }) + +const readIssues = (model: Model): Option.Option => + model.page._tag === 'Issues' ? Option.some(model.page.model) : Option.none() + +const readIssuesVisit = + (pageId: number) => + (model: Model): Option.Option => + pageId === model.pageId ? readIssues(model) : Option.none() + +const writeIssues = (model: Model, nextIssues: Issues.Model): Model => + modifyFields(model, { page: () => Page.Issues({ model: nextIssues }) }) + +const followOpenCount: UpdateStep = model => ({ + model: modifyFields(model, { + maybeOpenCount: maybeOpenCount => + Option.match(readIssues(model), { + onNone: () => maybeOpenCount, + onSome: issues => Option.orElse(Issues.openCount(issues), () => maybeOpenCount), + }), + }), +}) + +const foldIssuesOutMessage = (outMessage: Issues.OutMessage): UpdateStep => + Issues.OutMessage.match(outMessage, { + OpenedRelatedHelp: + ({ query, id }) => + model => + openHelp(model, query, Option.some(id)), + }) + +const foldIssuesMessage = (pageId: number) => + Update.foldChild({ + update: Issues.update, + read: readIssuesVisit(pageId), + write: writeIssues, + toParentMessage: toGotIssuesMessage(pageId), + foldOutMessage: foldIssuesOutMessage, + }) + +// A write started on an earlier visit of the issues page may answer on this one: what the server took still shows. +const foldEarlierIssuesMessage = (model: Model) => + Update.foldChild({ + update: Issues.learnFromEarlierVisit, + read: readIssues, + write: writeIssues, + toParentMessage: toGotIssuesMessage(model.pageId), + }) + +const foldHelpMessage = (pageId: number) => + Update.foldChild({ + update: Help.update, + read: (model: Model) => + model.page._tag === 'Help' && pageId === model.pageId ? Option.some(model.page.model) : Option.none(), + write: (model, nextHelp) => modifyFields(model, { page: () => Page.Help({ model: nextHelp }) }), + toParentMessage: toGotHelpMessage(pageId), + }) + +const foldSettingsMessage = (pageId: number) => + Update.foldChild({ + update: Settings.update, + read: (model: Model) => + model.page._tag === 'Settings' && pageId === model.pageId ? Option.some(model.page.model) : Option.none(), + write: (model, nextSettings) => modifyFields(model, { page: () => Page.Settings({ model: nextSettings }) }), + toParentMessage: toGotSettingsMessage(pageId), + }) + +export const update = (model: Model, message: Message) => + Message.match(message, { + ClickedTab: ({ tab }) => { + if (tab === tabOf(model.page)) { + return { model } + } + return tab === 'issues' ? openIssues(model) : tab === 'help' ? openHelp(model, '', Option.none()) : openSettings(model) + }, + + GotIssuesMessage: ({ pageId, message }) => + Update.combine(model, [ + stepModel => + pageId === stepModel.pageId + ? foldIssuesMessage(pageId)(stepModel, message) + : foldEarlierIssuesMessage(stepModel)(stepModel, message), + followOpenCount, + ]), + + GotHelpMessage: ({ pageId, message }) => foldHelpMessage(pageId)(model, message), + + GotSettingsMessage: ({ pageId, message }) => foldSettingsMessage(pageId)(model, message), + }) + +// VIEW + +const pageView = (model: Model, h: HtmlBuilder): Html => + Page.match(model.page, { + Issues: page => + h.submodel({ + slotId: `issues-${model.pageId}`, + model: page.model, + view: Issues.view, + toParentMessage: toGotIssuesMessage(model.pageId), + }), + Help: page => + h.submodel({ + slotId: `help-${model.pageId}`, + model: page.model, + view: Help.view, + toParentMessage: toGotHelpMessage(model.pageId), + }), + Settings: page => + h.submodel({ + slotId: `settings-${model.pageId}`, + model: page.model, + view: Settings.view, + toParentMessage: toGotSettingsMessage(model.pageId), + }), + }) + +const tabButton = (model: Model, tab: Tab, label: string, h: HtmlBuilder): Html => + h.button( + [h.DataAttribute('testid', `tab-${tab}`), h.AriaPressed(String(tab === tabOf(model.page))), h.OnClick(Message.ClickedTab({ tab }))], + [label], + ) + +export const view = (model: Model, h: HtmlBuilder): Document => ({ + title: 'Issue desk', + body: h.div([], [ + h.nav([], [ + tabButton( + model, + 'issues', + Option.match(model.maybeOpenCount, { onNone: () => 'Issues', onSome: count => `Issues (${count})` }), + h, + ), + tabButton(model, 'help', 'Help', h), + tabButton(model, 'settings', 'Settings', h), + ]), + pageView(model, h), + ]), +}) diff --git a/eval/reference/desk9/foldkit/request.ts b/eval/reference/desk9/foldkit/request.ts new file mode 100644 index 00000000..d744b31c --- /dev/null +++ b/eval/reference/desk9/foldkit/request.ts @@ -0,0 +1,7 @@ +import { Effect } from 'effect' + +export const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error) + +export const request = (call: () => Promise): Effect.Effect => + Effect.tryPromise({ try: call, catch: errorMessage }) diff --git a/eval/reference/desk9/foldkit/settings.ts b/eval/reference/desk9/foldkit/settings.ts new file mode 100644 index 00000000..01f58073 --- /dev/null +++ b/eval/reference/desk9/foldkit/settings.ts @@ -0,0 +1,201 @@ +import { Array, Effect, Option, Schema, pipe } from 'effect' +import { Command, Submodel, type Update } from 'foldkit' +import { Html, HtmlBuilder } from 'foldkit/html' +import { defineMessageUnion } from 'foldkit/message' +import { defineTaggedUnion } from 'foldkit/schema' +import { modifyFields } from 'foldkit/struct' + +import * as Api from './api' +import { request } from './request' + +// MODEL + +const Theme = Schema.Literals(['light', 'dark']) +const Density = Schema.Literals(['comfortable', 'compact']) +const Settings = Schema.Struct({ name: Schema.String, theme: Theme, density: Density }) +type Settings = typeof Settings.Type + +// The draft is seeded once from the loaded settings; after that it is the user's. +export const LoadState = defineTaggedUnion({ + Loading: {}, + Failed: { error: Schema.String }, + Loaded: { saved: Settings, draft: Settings }, +}) + +export const Model = Schema.Struct({ + loadSeq: Schema.Number, + load: LoadState, + seq: Schema.Number, + saving: Schema.Array(Schema.Struct({ seq: Schema.Number, value: Settings })), + confirmed: Schema.Number, + maybeSaveError: Schema.Option(Schema.String), +}) +export type Model = typeof Model.Type + +// MESSAGE + +export const Message = defineMessageUnion({ + SucceededLoad: { seq: Schema.Number, settings: Settings }, + FailedLoad: { seq: Schema.Number, error: Schema.String }, + ClickedRetryLoad: {}, + UpdatedName: { value: Schema.String }, + ClickedTheme: { theme: Theme }, + ClickedDensityToggle: {}, + ClickedSave: {}, + SucceededSave: { seq: Schema.Number, settings: Settings }, + FailedSave: { seq: Schema.Number, error: Schema.String }, +}) +export type Message = typeof Message.Type + +// COMMAND + +const Load = Command.define('Load', { + args: { seq: Schema.Number }, + messages: [Message.SucceededLoad, Message.FailedLoad], + execute: ({ seq }) => + request(() => Api.loadSettings()).pipe( + Effect.map(settings => Message.SucceededLoad({ seq, settings })), + Effect.catch(error => Effect.succeed(Message.FailedLoad({ seq, error }))), + ), +}) + +const Save = Command.define('Save', { + args: { seq: Schema.Number, settings: Settings }, + messages: [Message.SucceededSave, Message.FailedSave], + execute: ({ seq, settings }) => + request(() => Api.saveSettings(settings)).pipe( + Effect.map(saved => Message.SucceededSave({ seq, settings: saved })), + Effect.catch(error => Effect.succeed(Message.FailedSave({ seq, error }))), + ), +}) + +// INIT + +export const init = (): Update.Return => ({ + model: { loadSeq: 1, load: LoadState.Loading(), seq: 1, saving: [], confirmed: 0, maybeSaveError: Option.none() }, + commands: [Load({ seq: 1 })], +}) + +// UPDATE + +const nameError = (name: string): string => + name.length === 0 + ? 'Display name is required' + : name.length > 20 + ? `Display name must be at most 20 characters, not ${name.length}` + : '' + +const editDraft = (model: Model, f: (draft: Settings) => Settings): Model => + modifyFields(model, { + load: load => (load._tag === 'Loaded' ? LoadState.Loaded({ saved: load.saved, draft: f(load.draft) }) : load), + }) + +export const update = (model: Model, message: Message) => + Message.match>(message, { + SucceededLoad: ({ seq, settings }) => + seq === model.loadSeq + ? { model: modifyFields(model, { load: () => LoadState.Loaded({ saved: settings, draft: settings }) }) } + : { model }, + + FailedLoad: ({ seq, error }) => + seq === model.loadSeq ? { model: modifyFields(model, { load: () => LoadState.Failed({ error }) }) } : { model }, + + ClickedRetryLoad: () => { + const loadSeq = model.loadSeq + 1 + return { model: modifyFields(model, { loadSeq: () => loadSeq }), commands: [Load({ seq: loadSeq })] } + }, + + UpdatedName: ({ value }) => ({ model: editDraft(model, draft => ({ ...draft, name: value })) }), + + ClickedTheme: ({ theme }) => ({ model: editDraft(model, draft => ({ ...draft, theme })) }), + + ClickedDensityToggle: () => ({ + model: editDraft(model, draft => ({ ...draft, density: draft.density === 'compact' ? 'comfortable' : 'compact' })), + }), + + ClickedSave: () => { + if (model.load._tag !== 'Loaded' || nameError(model.load.draft.name) !== '') { + return { model } + } + const draft = model.load.draft + return { + model: modifyFields(model, { + seq: seq => seq + 1, + maybeSaveError: () => Option.none(), + saving: Array.append({ seq: model.seq, value: draft }), + }), + commands: [Save({ seq: model.seq, settings: draft })], + } + }, + + SucceededSave: ({ seq, settings }) => { + const isNewest = seq > model.confirmed + return { + model: modifyFields(model, { + saving: Array.filter(saving => saving.seq !== seq), + confirmed: confirmed => (isNewest ? seq : confirmed), + load: load => + isNewest && load._tag === 'Loaded' ? LoadState.Loaded({ saved: settings, draft: load.draft }) : load, + }), + } + }, + + FailedSave: ({ seq, error }) => ({ + model: modifyFields(model, { + saving: Array.filter(saving => saving.seq !== seq), + maybeSaveError: () => Option.some(error), + }), + }), + }) + +// VIEW + +const testId = (h: HtmlBuilder, id: string) => h.DataAttribute('testid', id) + +const loadedView = (model: Model, saved: Settings, draft: Settings, h: HtmlBuilder): Html => { + const maybeGuess = pipe( + model.saving, + Array.filter(saving => saving.seq > model.confirmed), + Array.last, + ) + const shown = Option.match(maybeGuess, { onNone: () => saved, onSome: guess => guess.value }) + const error = nameError(draft.name) + return h.main([], [ + h.p( + [testId(h, 'saved')], + [`Saved: ${shown.name} · ${shown.theme} · ${shown.density}${Option.isSome(maybeGuess) ? ' (saving…)' : ''}`], + ), + Option.match(model.maybeSaveError, { + onNone: () => h.empty, + onSome: saveError => h.p([testId(h, 'save-error')], [`Save failed: ${saveError}`]), + }), + h.div([], [ + h.label([], [ + 'Display name ', + h.input([testId(h, 'name'), h.Value(draft.name), h.OnInput(value => Message.UpdatedName({ value }))]), + ]), + h.small([testId(h, 'name-count')], [`${draft.name.length}/20`]), + error === '' ? h.empty : h.p([testId(h, 'name-error')], [error]), + h.div([], [ + h.span([], [`Theme: ${draft.theme}`]), + h.button([testId(h, 'theme-light'), h.OnClick(Message.ClickedTheme({ theme: 'light' }))], ['light']), + h.button([testId(h, 'theme-dark'), h.OnClick(Message.ClickedTheme({ theme: 'dark' }))], ['dark']), + h.button([testId(h, 'density-toggle'), h.OnClick(Message.ClickedDensityToggle())], ['Toggle density']), + h.p([testId(h, 'preview')], [`Preview: ${draft.theme} / ${draft.density}`]), + ]), + h.button([testId(h, 'save'), h.OnClick(Message.ClickedSave())], ['Save']), + ]), + ]) +} + +export const view = Submodel.defineView((model, h) => + LoadState.match(model.load, { + Loading: () => h.p([testId(h, 'load')], ['Loading settings…']), + Failed: ({ error }) => + h.section([], [ + h.p([testId(h, 'load')], [`Could not load settings: ${error}`]), + h.button([testId(h, 'retry-load'), h.OnClick(Message.ClickedRetryLoad())], ['Retry']), + ]), + Loaded: ({ saved, draft }) => loadedView(model, saved, draft, h), + }), +) From 883b0ccf554e117c0579fd8fa5290f7c644ddbab Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 09:06:40 +0000 Subject: [PATCH 05/32] eval fuzz: A4 also covers detail, labels, comments and two views A write that lands after its page was left is not judged on the next visit (Ryan 2026-11-14: late writes are out of scope). A4 was only applied to rows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- eval/harness/fuzz.mjs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/eval/harness/fuzz.mjs b/eval/harness/fuzz.mjs index c09618a2..420ee319 100644 --- a/eval/harness/fuzz.mjs +++ b/eval/harness/fuzz.mjs @@ -166,7 +166,7 @@ async function session(browser, url, seed, steps, log, r9 = false) { if (listShown && detail && ui.selected != null) { const p = parsed.find(x => x.id === ui.selected); const m = detail.match(/^#(\d+) /); - if (p && m && Number(m[1]) === p.id && !detail.startsWith(`#${p.id} ${p.title}`)) return note("two views", `row "#${p.id} ${p.title}", detail "${detail.slice(0, 40)}"`); + if (p && m && Number(m[1]) === p.id && !stale.some(t => t.issue === p.id && t.field === "title") && !detail.startsWith(`#${p.id} ${p.title}`)) return note("two views", `row "#${p.id} ${p.title}", detail "${detail.slice(0, 40)}"`); } const cm = tab && tab.match(/\((\d+)\)/); if (listShown && ui.filter === "open" && cm && Number(cm[1]) !== parsed.length) note("count", `tab ${tab}, ${parsed.length} open rows shown`); @@ -205,6 +205,7 @@ async function session(browser, url, seed, steps, log, r9 = false) { const parsed = (await texts("issue")).map(t => t.match(/^#(\d+) (.*) \((.*)\)$/)).filter(Boolean).map(m => ({ id: Number(m[1]), title: m[2], assignee: m[3] === "unassigned" ? null : m[3] })); const want = srv.issues.filter(i => i.open === (ui.filter === "open") || (ui.filter === "open" && stale.some(t => t.issue === i.id && t.field === "open") && parsed.some(p => p.id === i.id))); const newest = (id, field) => { const ws = started.filter(w => w.issue === id && w.field === field && w.accepted); return ws.length ? ws.at(-1).value : undefined; }; + const late = (id, ...fields) => stale.some(t => t.issue === id && fields.includes(t.field)); // A4: not judged on the new page const ok = (id, field, v) => { const x = srv.issues.find(i => i.id === id); return v === x[field] || v === newest(id, field) || stale.some(t => t.issue === id && t.field === field && t.before === v); }; // A2, A4 const ids = parsed.map(p => p.id).join(","); // the closed list need not gain an issue closed after it loaded (no brief asks for it): it must hold no open issue @@ -221,13 +222,13 @@ async function session(browser, url, seed, steps, log, r9 = false) { if (ui.selected != null) { const detail = await text("detail"); const x = srv.issues.find(i => i.id === ui.selected); - if (detail && !/detail error/.test(detail) && !detail.includes(`#${x.id} ${x.title}`) && !(newest(x.id, "title") && detail.includes(`#${x.id} ${newest(x.id, "title")}`))) note("at rest: detail", `"${detail.slice(0, 60)}", server "#${x.id} ${x.title}"`); - if (await exists("label")) { + if (detail && !late(x.id, "title") && !/detail error/.test(detail) && !detail.includes(`#${x.id} ${x.title}`) && !(newest(x.id, "title") && detail.includes(`#${x.id} ${newest(x.id, "title")}`))) note("at rest: detail", `"${detail.slice(0, 60)}", server "#${x.id} ${x.title}"`); + if (!late(x.id, "addLabel", "removeLabel") && await exists("label")) { const labelsText = await text("labels"); if (labelsText && !/error/.test(labelsText)) { const ls = (await texts("label")).join(","); if (ls !== (srv.labels[x.id] ?? []).join(",")) note("at rest: labels", `#${x.id} shows [${ls}], server [${srv.labels[x.id]}]`); } } const commentsText = await text("comments"); - if (commentsText && !/error|loading/.test(commentsText)) { + if (commentsText && !late(x.id, "addComment") && !/error|loading/.test(commentsText)) { const cs = (await texts("comment")).join(" | "); const want2 = (srv.comments[x.id] ?? []).map(c => `${c.author}: ${c.text}`).join(" | "); if (cs !== want2) note("at rest: comments", `#${x.id} shows [${cs}], server [${want2}]`); } @@ -244,7 +245,7 @@ async function session(browser, url, seed, steps, log, r9 = false) { const times = Math.max(1, (await page.evaluate(k => globalThis.__flights.asked().filter(x => x === k).length, key)) - (settledCount.get(key) ?? 0)); settledCount.set(key, (settledCount.get(key) ?? 0) + times); let a; for (let t = 0; t < times; t++) a = answer(srv, key, fail); - if (w && !fail && w.gen !== gen && target) stale.push({ issue: w.issue, field: w.field, before }); // A4 + if (w && !fail && w.gen !== gen && target) stale.push({ issue: w.issue, field: w.field, before }); // A4: a write from a page since left trail.push(`${fail ? "fail" : "land"} ${key}`); if (w) { writes.delete(key); if (fail) declined.push(w); else w.accepted = true; } await page.evaluate(([k, a]) => a.fail ? globalThis.__flights.fail(k, "declined") : globalThis.__flights.land(k, a.value), [key, a]); From 53e2f681ae952bb7422cfe5011971f17fd7156a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 09:40:07 +0000 Subject: [PATCH 06/32] eval: --r15 fuzz rule (server's last answer wins at rest); React and XState references follow it Ryan 2026-11-14: with two accepted writes to one field, the server's last answer is the only correct value at rest. --r15 drops allowance A2; FUZZ_ROUND=--r15 sh harness/grade.sh. All six desk9 references grade 0 under it (20 seeds; React, Exact, XState, Foldkit and vanilla also 0/60 on seeds 21-80). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- eval/harness/fuzz.mjs | 14 ++++++----- eval/harness/grade.sh | 3 ++- eval/reference/desk9/react/IssuesPage.tsx | 24 +++++++------------ eval/reference/desk9/xstate/NOTES.md | 3 +++ .../desk9/xstate/issues/issuesMachine.ts | 6 ++--- 5 files changed, 24 insertions(+), 26 deletions(-) diff --git a/eval/harness/fuzz.mjs b/eval/harness/fuzz.mjs index 420ee319..576e981b 100644 --- a/eval/harness/fuzz.mjs +++ b/eval/harness/fuzz.mjs @@ -19,7 +19,8 @@ // - two views: an issue's row and the detail heading show the same title; // - count: with the open list on screen, the tab count equals its rows. // Writes weigh twice as much among the moves and answers come a little less often (0.4), so writes overlap. -// Usage: node harness/fuzz.mjs [--seeds N] [--steps N] [--from S] [--r9] [--json out.json] [--log] +// Round 15 (`--r15`, implies --r9): A2 is dropped; at rest a field shows the server's last answer (Ryan, 2026-11-14). +// Usage: node harness/fuzz.mjs [--seeds N] [--steps N] [--from S] [--r9] [--r15] [--json out.json] [--log] import { writeFileSync } from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -73,7 +74,7 @@ export function answer(srv, key, fail) { } /** one session; returns the violations it found (each with the step and the log so far) */ -async function session(browser, url, seed, steps, log, r9 = false) { +async function session(browser, url, seed, steps, log, r9 = false, r15 = false) { const r = rng(seed); const pick = xs => xs[Math.floor(r() * xs.length)]; const page = await browser.newPage(); const errors = []; page.on("pageerror", e => { errors.push(e.message); if (process.env.FUZZ_STACK) console.log("PAGEERROR after", trail.at(-1), trail.length, e.message); }); @@ -204,7 +205,8 @@ async function session(browser, url, seed, steps, log, r9 = false) { if (!listText || /error/.test(listText)) return; const parsed = (await texts("issue")).map(t => t.match(/^#(\d+) (.*) \((.*)\)$/)).filter(Boolean).map(m => ({ id: Number(m[1]), title: m[2], assignee: m[3] === "unassigned" ? null : m[3] })); const want = srv.issues.filter(i => i.open === (ui.filter === "open") || (ui.filter === "open" && stale.some(t => t.issue === i.id && t.field === "open") && parsed.some(p => p.id === i.id))); - const newest = (id, field) => { const ws = started.filter(w => w.issue === id && w.field === field && w.accepted); return ws.length ? ws.at(-1).value : undefined; }; + // A2 is dropped with --r15 (Ryan, 2026-11-14: the server's last answer is the only correct one) + const newest = (id, field) => { if (r15) return undefined; const ws = started.filter(w => w.issue === id && w.field === field && w.accepted); return ws.length ? ws.at(-1).value : undefined; }; const late = (id, ...fields) => stale.some(t => t.issue === id && fields.includes(t.field)); // A4: not judged on the new page const ok = (id, field, v) => { const x = srv.issues.find(i => i.id === id); return v === x[field] || v === newest(id, field) || stale.some(t => t.issue === id && t.field === field && t.before === v); }; // A2, A4 const ids = parsed.map(p => p.id).join(","); @@ -316,7 +318,7 @@ async function session(browser, url, seed, steps, log, r9 = false) { return found.slice(0, 1); } -export async function fuzz(dir, { seeds = 40, steps = 30, from = 1, log = false, r9 = false } = {}) { +export async function fuzz(dir, { seeds = 40, steps = 30, from = 1, log = false, r9 = false, r15 = false } = {}) { const b = await build(dir); if (!b.ok) return { dir, build: false, seeds, violations: [] }; const server = await serve(b.dist); let browser = await launch(); @@ -324,7 +326,7 @@ export async function fuzz(dir, { seeds = 40, steps = 30, from = 1, log = false, try { for (let s = from; s < from + seeds; s++) { // a page that crashes or hangs the browser is a hole of its own; the next seed gets a fresh browser - try { let t; violations.push(...await Promise.race([session(browser, server.url, s, steps, log, r9), new Promise((_, rej) => { t = setTimeout(() => rej(new Error("session timed out (120 s): the page hangs")), 120000); })]).finally(() => clearTimeout(t))); } + try { let t; violations.push(...await Promise.race([session(browser, server.url, s, steps, log, r9 || r15, r15), new Promise((_, rej) => { t = setTimeout(() => rej(new Error("session timed out (120 s): the page hangs")), 120000); })]).finally(() => clearTimeout(t))); } catch (e) { violations.push({ seed: s, step: -1, kind: "page crash", detail: e.message.split("\n")[0].slice(0, 120), trail: [] }); await browser.close().catch(() => {}); browser = await launch(); } } } @@ -334,7 +336,7 @@ export async function fuzz(dir, { seeds = 40, steps = 30, from = 1, log = false, if (import.meta.url === pathToFileURL(process.argv[1]).href) { const a = process.argv.slice(2); const opt = (k, d) => { const i = a.indexOf(k); return i >= 0 ? a[i + 1] : d; }; - const res = await fuzz(path.resolve(a[0]), { seeds: Number(opt("--seeds", 40)), steps: Number(opt("--steps", 30)), from: Number(opt("--from", 1)), log: a.includes("--log"), r9: a.includes("--r9") }); + const res = await fuzz(path.resolve(a[0]), { seeds: Number(opt("--seeds", 40)), steps: Number(opt("--steps", 30)), from: Number(opt("--from", 1)), log: a.includes("--log"), r9: a.includes("--r9"), r15: a.includes("--r15") }); console.log(`${path.basename(res.dir)}: ${res.build ? `${res.violations.length}/${res.seeds} seeds found a hole` : "build failed"}`); for (const v of res.violations) console.log(` seed ${v.seed} step ${v.step}: ${v.kind}: ${v.detail}\n ${v.trail.join(" ; ")}`); if (opt("--json")) writeFileSync(opt("--json"), JSON.stringify(res, null, 2) + "\n"); diff --git a/eval/harness/grade.sh b/eval/harness/grade.sh index ed9762a4..2c60d8fa 100644 --- a/eval/harness/grade.sh +++ b/eval/harness/grade.sh @@ -1,10 +1,11 @@ #!/bin/sh # Grade one desk (scaffolded with `node harness/scaffold.mjs desk9 --brief plain [--inline-docs]`), however # the agent that wrote it was run: hidden tests, hidden overlap tests, and 20 timing-fuzzer seeds. +# FUZZ_ROUND=--r15 grades round 15 (server's last answer wins at rest). # Usage: sh harness/grade.sh [out.txt] (run from eval/; defects = ✗ lines + fuzz seeds with a hole) set -e d=$(cd "$1" && pwd); out=${2:-/dev/stdout} cd "$(dirname "$0")/.." { echo "== hidden"; node harness/test.mjs "$d" tasks/desk9/hidden.mjs 2>&1 | grep "^✗\|passed\|build failed" || true echo "== overlap"; node harness/test.mjs "$d" tasks/desk9/hidden-overlap.mjs 2>&1 | grep "^✗\|passed\|build failed" || true - echo "== fuzz"; node harness/fuzz.mjs "$d" --seeds 20 --r9 2>&1 | cut -c1-400; } > "$out" + echo "== fuzz"; node harness/fuzz.mjs "$d" --seeds 20 ${FUZZ_ROUND:---r9} 2>&1 | cut -c1-400; } > "$out" diff --git a/eval/reference/desk9/react/IssuesPage.tsx b/eval/reference/desk9/react/IssuesPage.tsx index 49ab25b4..02f8a4a5 100644 --- a/eval/reference/desk9/react/IssuesPage.tsx +++ b/eval/reference/desk9/react/IssuesPage.tsx @@ -40,17 +40,15 @@ export default function IssuesPage({ onOpenCount, onOpenHelp }: Props) { const seqRef = useRef(0); const nextSeq = () => ++seqRef.current; - // assignments in the air, and the newest one confirmed per issue (an older answer must not overwrite it) + // assignments in the air; every answer the server gives is its value, whichever write it answers const [assigning, setAssigning] = useState>({}); const [assignError, setAssignError] = useState(null); const lastAssignRef = useRef(0); - const confirmedAssignRef = useRef>({}); const [renaming, setRenaming] = useState>({}); const [renameError, setRenameError] = useState(null); const [titleInput, setTitleInput] = useState(""); const lastRenameRef = useRef(0); - const confirmedRenameRef = useRef>({}); // closes in the air, by issue id, with how many are out const [closing, setClosing] = useState>({}); @@ -132,12 +130,9 @@ export default function IssuesPage({ onOpenCount, onOpenHelp }: Props) { setAssigning(prev => addGuess(prev, id, { seq, value: who })); try { const res = await assign(id, who); - if ((confirmedAssignRef.current[id] ?? 0) < seq) { - confirmedAssignRef.current[id] = seq; - queryClient.setQueriesData({ queryKey: ["issues"] }, old => - old?.map(i => (i.id === id ? { ...i, assignee: res.assignee } : i)), - ); - } + queryClient.setQueriesData({ queryKey: ["issues"] }, old => + old?.map(i => (i.id === id ? { ...i, assignee: res.assignee } : i)), + ); } catch (e) { reportFailure(e); } finally { @@ -152,13 +147,10 @@ export default function IssuesPage({ onOpenCount, onOpenHelp }: Props) { setRenaming(prev => addGuess(prev, id, { seq, value: title })); try { const res = await renameIssue(id, title); - if ((confirmedRenameRef.current[id] ?? 0) < seq) { - confirmedRenameRef.current[id] = seq; - queryClient.setQueriesData({ queryKey: ["issues"] }, old => - old?.map(i => (i.id === id ? { ...i, title: res.title } : i)), - ); - queryClient.setQueryData(["issue", id], old => (old ? { ...old, title: res.title } : old)); - } + queryClient.setQueriesData({ queryKey: ["issues"] }, old => + old?.map(i => (i.id === id ? { ...i, title: res.title } : i)), + ); + queryClient.setQueryData(["issue", id], old => (old ? { ...old, title: res.title } : old)); } catch (e) { if (lastRenameRef.current === seq) setRenameError({ visit, message: errorMessage(e) }); } finally { diff --git a/eval/reference/desk9/xstate/NOTES.md b/eval/reference/desk9/xstate/NOTES.md index 8abbd61c..a6f9935d 100644 --- a/eval/reference/desk9/xstate/NOTES.md +++ b/eval/reference/desk9/xstate/NOTES.md @@ -11,3 +11,6 @@ select); spawned actors' done/error events are untyped. Brief ambiguities seen (none tested): `assign` error prefix; related-help query after an optimistic rename; a failed save overtaken by a newer accepted save; inputs not cleared on selection; clicking the open tab does not reset it. + +Round 15 rule (`--r15`, Ryan 2026-11-14): two accepted writes to one field show the server's last answer. The +reference takes every write answer as the server's value; earlier it kept the newest started (failed r15 seed 18). diff --git a/eval/reference/desk9/xstate/issues/issuesMachine.ts b/eval/reference/desk9/xstate/issues/issuesMachine.ts index 3f04578d..a362bb59 100644 --- a/eval/reference/desk9/xstate/issues/issuesMachine.ts +++ b/eval/reference/desk9/xstate/issues/issuesMachine.ts @@ -80,7 +80,7 @@ export type IssuesContext = { seq: number; /** the writes in flight, oldest first */ writes: Write[]; - /** per issue field, the seq of the newest write the server accepted: an older write's answer must not replace it */ + /** per issue field, the seq of the newest write the server accepted: guesses older than it no longer show */ confirmed: Record; /** per channel, the newest write started: only it reports its failure */ latest: Partial>; @@ -147,10 +147,10 @@ export const sendingComments = (ctx: IssuesContext) => function accepted(ctx: IssuesContext, w: Write, outcome: Outcome): Partial { const confirm = (field: Field, value: string): Partial => { + // the server's last answer is its value, whichever write it answers; the floor only hides older guesses const key = fieldKey(w.issueId, field); - if ((ctx.confirmed[key] ?? 0) > w.seq) return {}; return { - confirmed: { ...ctx.confirmed, [key]: w.seq }, + confirmed: { ...ctx.confirmed, [key]: Math.max(ctx.confirmed[key] ?? 0, w.seq) }, server: { ...ctx.server, [field]: { ...ctx.server[field], [w.issueId]: value } }, }; }; From 274f306160857e65f329ae22ebf1b0b1df10172d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 10:13:08 +0000 Subject: [PATCH 07/32] eval: plain Solid repro of a stale heading after select (rx desk9 reference, fuzz seed 45) Also replay.mjs: DW= widens the detail text it prints. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- eval/harness/replay.mjs | 2 +- .../repro/stale-heading-after-select.spec.tsx | 44 ++++++++++ .../r15prep/repro/stale-heading-app.tsx | 85 +++++++++++++++++++ 3 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 eval/results/r15prep/repro/stale-heading-after-select.spec.tsx create mode 100644 eval/results/r15prep/repro/stale-heading-app.tsx diff --git a/eval/harness/replay.mjs b/eval/harness/replay.mjs index cd1d3722..81cc9dc1 100644 --- a/eval/harness/replay.mjs +++ b/eval/harness/replay.mjs @@ -23,7 +23,7 @@ const r = await runTest(br, srv.url, { run: async app => { else await app.click(op); if (process.env.DUMP) console.log(" store:", await app.page.evaluate(() => { const s = globalThis.__stores?.issues; if (!s) return ""; return JSON.stringify([...s].map(x => ({ keys: Object.keys(x), id: x.id, t: x.title, a: x.assignee }))); })); if (process.env.EFFLOG) console.log(" fx:", await app.page.evaluate(() => { const l = globalThis.__efflog ?? []; globalThis.__efflog = []; return l.join(" | "); })); - console.log(s.slice(0, 32).padEnd(32), ...(process.env.TAB ? [`[${await app.text("tab-issues")}]`] : []), "|", JSON.stringify(await app.items("issue")), "|", (await app.text("detail") ?? "").slice(0, 30), "| air", JSON.stringify(await app.pending())); + console.log(s.slice(0, 32).padEnd(32), ...(process.env.TAB ? [`[${await app.text("tab-issues")}]`] : []), "|", JSON.stringify(await app.items("issue")), "|", (await app.text("detail") ?? "").slice(0, Number(process.env.DW ?? 30)), "| air", JSON.stringify(await app.pending())); } } }); console.log(r.ok ? "ok" : r.error); await br.close(); srv.close(); diff --git a/eval/results/r15prep/repro/stale-heading-after-select.spec.tsx b/eval/results/r15prep/repro/stale-heading-after-select.spec.tsx new file mode 100644 index 00000000..e3e90b19 --- /dev/null +++ b/eval/results/r15prep/repro/stale-heading-after-select.spec.tsx @@ -0,0 +1,44 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +import { test, expect } from "vitest"; +import { Show, Loading, Errored, action, createMemo, createSignal, createOptimistic, flush, untrack } from "solid-js"; +import { render } from "../src/index.js"; +const tick = async () => { for (let i = 0; i < 4; i++) { await new Promise(r => setTimeout(r, 0)); flush(); } }; +test("a heading reading a rename guess shows the newly selected issue's title, not the previous issue's", async () => { + const asks: { key: string; res: (v: any) => void }[] = []; + const ask = (key: string) => new Promise(res => asks.push({ key, res })); + const land = (key: string, v: any) => { const i = asks.findIndex(a => a.key === key); if (i < 0) throw new Error("no ask " + key); asks.splice(i, 1)[0].res(v); }; + const c = document.body.appendChild(document.createElement("div")); + + const [selected, setSelected] = createSignal(null); + const [renames, guessRename] = createOptimistic<{ id: number; title: string }[]>(() => []); + const detail = createMemo(() => (selected() != null ? ask(`detail:${selected()}`) : undefined)); + const heading = createMemo(() => { const d: any = detail(); return d == null ? "" : renames().reduce((t, r) => (r.id === d.id ? r.title : t), d.title); }); + const select = action(function* (id: number) { setSelected(id); }); + const rename = action(function* (id: number, title: string) { guessRename(rs => [...rs, { id, title }]); yield ask(`rename:${id}`); }); + + // related help searched by the heading, under its own boundary + const Related = (props: { title: string }) => { + const hits = createMemo(() => ask(`search:${props.title}`)); + return finding related help

}> + failed

}> + some help

}>

no help

+
+
; + }; + render(() => loading #{selected()}

}> +

#{(detail() as any)?.id} {heading()}

+ +
, c); + + select(4); await tick(); + land("detail:4", { id: 4, title: "Typo in footer" }); await tick(); + select(3); await tick(); + land("search:Typo in footer", [{ id: 9 }]); await tick(); + rename(3, "Delta"); await tick(); // its answer stays out + land("detail:3", { id: 3, title: "Crash on save" }); await tick(); + console.log("heading memo:", untrack(heading), "| screen:", c.querySelector("h2")?.textContent, "| in the air:", asks.map(a => a.key).join(", ")); + expect(c.querySelector("h2")?.textContent).toBe("#3 Delta"); // shows "#3 Typo in footer": #4's title +}); diff --git a/eval/results/r15prep/repro/stale-heading-app.tsx b/eval/results/r15prep/repro/stale-heading-app.tsx new file mode 100644 index 00000000..72a909f0 --- /dev/null +++ b/eval/results/r15prep/repro/stale-heading-app.tsx @@ -0,0 +1,85 @@ +import { For, Show, Loading, Errored, action, createMemo, createSignal, createOptimistic, refresh, latest, getObserver } from "solid-js"; +import * as api from "./api.js"; +import { assign, renameIssue, removeLabel } from "./api.js"; + +// one request per key while it is cached; an action's refresh forgets the key first +const cache = new Map>(); +const cached =
(name: string, f: (...a: A) => Promise) => (...a: A) => { + const key = `${name}:${a.join(",")}`; + if (!getObserver()) return f(...a); + if (!cache.has(key)) cache.set(key, f(...a)); + return cache.get(key)!; +}; +const fetchIssues = cached("fetchIssues", api.fetchIssues), fetchIssue = cached("fetchIssue", api.fetchIssue); +const search = cached("search", api.search), fetchLabels = cached("fetchLabels", api.fetchLabels); +const guessable = (init: T) => { const [t] = createSignal((() => init) as any); return createOptimistic(() => t()); }; + +type Rename = { id: number; title: string }; +const titleOf = (rs: Rename[], id: number, t: string) => rs.reduce((x, r) => (r.id === id ? r.title : x), t); + +function Related(props: { title: string }) { + const hits = createMemo(() => search(props.title)); + return finding related help

}> + related help failed

}> + some related help

}>

no related help

+
+
; +} + +function Labels(props: { id: number }) { + const [labels] = createSignal(() => fetchLabels(props.id)); + const [removing, guessRemove] = guessable([]); + const shown = createMemo(() => (labels() as string[]).filter(l => !removing().includes(l))); + const remove = action(function* (id: number, label: string) { + try { guessRemove([...removing(), label]); yield removeLabel(id, label); cache.delete(`fetchLabels:${id}`); refresh(labels); } catch {} + }); + return loading labels

}> + labels error

}> +
    {(l: any) =>
  • }
+
+
; +} + +export default function App() { + const [selected, setSelected] = createSignal(null); + const issues = createMemo(() => fetchIssues("open")); + const answer = createMemo(() => (selected() != null ? fetchIssue(selected()!) : undefined)); + const detail = createMemo(() => (selected() == null ? null : (answer() as any))); + const [renames, guessRename] = guessable([]); + const [assigns, guessAssign] = guessable<{ id: number; who: string }[]>([]); + const rows = createMemo(() => (issues() as any[]).map(r => ({ id: r.id, title: titleOf(renames(), r.id, r.title), assignee: assigns().reduce((w, x) => (x.id === r.id ? x.who : w), r.assignee) }))); + const heading = createMemo(() => (detail() == null ? "" : titleOf(renames(), detail().id, detail().title))); + const [titleInput, setTitleInput] = createSignal(""); + + const select = action(function* (id: number) { setSelected(id); }); + const assignTo = action(function* (id: number, who: string) { + try { guessAssign(xs => [...xs, { id, who }]); yield assign(id, who); cache.delete("fetchIssues:open"); refresh(issues); } catch {} + }); + const renameTo = action(function* (id: number, title: string) { + try { guessRename(rs => [...rs, { id, title }]); yield renameIssue(id, title); cache.delete("fetchIssues:open"); cache.delete(`fetchIssue:${id}`); refresh(issues); refresh(answer); refresh(detail); } catch {} + }); + + return
+
+ loading open issues

}> +
    {(it: any) =>
  • + select(it.id)}>#{it.id} {it.title} ({it.assignee ?? "unassigned"}){" "} + +
  • }
+
+
+
+ select an issue

}> + {(_x: any) => [ + loading #{latest(selected)}

}> +

#{detail()?.id} {heading()}

(title {detail()?.title})

+ +
, + setTitleInput(e.currentTarget.value)} />, + , + , + ]} +
+
+
; +} From 472e73ee823edd0d567ddea71afd42c11fb8bd5e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 10:58:16 +0000 Subject: [PATCH 08/32] eval: board task (round 15 held-out), its fuzzer, and the React reference (grades 0) Brief frozen 2026-11-14 (Ryan). 26 visible, 45 hidden, 26 overlap tests; fuzz-board.mjs with oracles for duplicates, headings, counts, the work-in-progress limit, the detail, and settled state. TASK=board sh harness/grade.sh grades it. The fuzzer catches two planted bugs in the reference (limit off by one, an accepted toggle not kept). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- eval/harness/driver.mjs | 14 + eval/harness/fuzz-board.mjs | 291 ++++++++++++++++++ eval/harness/grade.sh | 8 +- eval/reference/board/react/App.tsx | 37 +++ eval/reference/board/react/ArchivePage.tsx | 54 ++++ eval/reference/board/react/BoardColumn.tsx | 84 ++++++ eval/reference/board/react/BoardPage.tsx | 292 ++++++++++++++++++ eval/reference/board/react/Detail.tsx | 88 ++++++ eval/reference/board/react/board.ts | 117 ++++++++ eval/reference/board/react/errors.ts | 9 + eval/tasks/board/NOTES.md | 27 ++ eval/tasks/board/api.d.rx | 15 + eval/tasks/board/api.d.ts | 14 + eval/tasks/board/api.js | 12 + eval/tasks/board/brief-plain.md | 115 +++++++ eval/tasks/board/fixtures.mjs | 39 +++ eval/tasks/board/hidden-overlap.mjs | 297 +++++++++++++++++++ eval/tasks/board/hidden.mjs | 329 +++++++++++++++++++++ eval/tasks/board/visible.mjs | 216 ++++++++++++++ 19 files changed, 2055 insertions(+), 3 deletions(-) create mode 100644 eval/harness/fuzz-board.mjs create mode 100644 eval/reference/board/react/App.tsx create mode 100644 eval/reference/board/react/ArchivePage.tsx create mode 100644 eval/reference/board/react/BoardColumn.tsx create mode 100644 eval/reference/board/react/BoardPage.tsx create mode 100644 eval/reference/board/react/Detail.tsx create mode 100644 eval/reference/board/react/board.ts create mode 100644 eval/reference/board/react/errors.ts create mode 100644 eval/tasks/board/NOTES.md create mode 100644 eval/tasks/board/api.d.rx create mode 100644 eval/tasks/board/api.d.ts create mode 100644 eval/tasks/board/api.js create mode 100644 eval/tasks/board/brief-plain.md create mode 100644 eval/tasks/board/fixtures.mjs create mode 100644 eval/tasks/board/hidden-overlap.mjs create mode 100644 eval/tasks/board/hidden.mjs create mode 100644 eval/tasks/board/visible.mjs diff --git a/eval/harness/driver.mjs b/eval/harness/driver.mjs index 7d0f0621..ecd9faf1 100644 --- a/eval/harness/driver.mjs +++ b/eval/harness/driver.mjs @@ -61,8 +61,22 @@ export async function runTest(browser, url, test) { async failOnly(fn, message) { await app.fail(await app.only(fn), message); }, async noneInAir(fn) { await settle(); const ks = (await app.pending()).filter(k => k.startsWith(`${fn}:`)); if (ks.length) throw new Failure(`expected no ${fn} request in flight, found ${JSON.stringify(ks)}`); }, async fail(key, message) { await settle(); await page.evaluate(([k, m]) => globalThis.__flights.fail(k, m), [key, message]); await settle(); }, + // a button next to an element (in the nearest ancestor that holds no other element of its kind): the board's move buttons beside a `card`, + // `toggle` beside an `item`, `restore` beside an `archived-card`. `text` is the start of the element's text. + near(anchor, text, id) { return page.locator(sel(anchor), { hasText: new RegExp(`^\\s*${text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`) }).first().locator(`xpath=ancestor::*[count(.//*[@data-testid="${anchor}"]) = 1][1]`).locator(sel(id)); }, + async clickNear(anchor, text, id) { await app.near(anchor, text, id).first().click(); await settle(); }, + async nearCount(anchor, text, id) { await settle(); return app.near(anchor, text, id).count(); }, + /** the button beside the element is shown and disabled (`true`), or shown and enabled (`false`) */ + async nearDisabled(anchor, text, id, expected = true) { await settle(); const l = app.near(anchor, text, id); if (!(await l.count())) throw new Failure(`expected a [${id}] beside [${anchor}] "${text}…", there is none`); const d = await l.first().isDisabled(); if (d !== expected) throw new Failure(`expected [${id}] beside [${anchor}] "${text}…" to be ${expected ? "disabled" : "enabled"}, it is ${d ? "disabled" : "enabled"}`); }, + async noNear(anchor, text, id) { if (await app.nearCount(anchor, text, id)) throw new Failure(`expected no [${id}] beside [${anchor}] "${text}…", there is one`); }, + async disabled(id, expected = true, nth = 0) { await settle(); const l = page.locator(sel(id)).nth(nth); if (!(await l.count())) throw new Failure(`expected [${id}], there is none`); const d = await l.isDisabled(); if (d !== expected) throw new Failure(`expected [${id}] to be ${expected ? "disabled" : "enabled"}, it is ${d ? "disabled" : "enabled"}`); }, + /** the `card` rows inside column `col` (todo, doing, done), top first */ + async colRows(col, expected) { await settle(); const r = (await page.locator(`${sel(`col-${col}`)} ${sel("card")}`).allTextContents()).map(norm); if (JSON.stringify(r) !== JSON.stringify(expected)) throw new Failure(`expected [col-${col}] cards ${JSON.stringify(expected)}, got ${JSON.stringify(r)}`); }, // assertions async see(id, ...parts) { const t = await app.text(id); for (const p of parts) if (t == null || !t.includes(p)) throw new Failure(`expected [${id}] to show "${p}", it shows ${t == null ? "nothing (no such element)" : `"${t}"`}`); }, + /** the page as a whole shows each part (for text the brief places only "on the page") */ + async seePage(...parts) { await settle(); const t = norm(await page.locator("body").textContent()); for (const p of parts) if (!t.includes(p)) throw new Failure(`expected the page to show "${p}", it shows "${t.slice(0, 300)}"`); }, + async notSeePage(...parts) { await settle(); const t = norm(await page.locator("body").textContent()); for (const p of parts) if (t.includes(p)) throw new Failure(`expected the page not to show "${p}", it shows "${t.slice(0, 300)}"`); }, async notSee(id, ...parts) { const t = await app.text(id); for (const p of parts) if (t != null && t.includes(p)) throw new Failure(`expected [${id}] not to show "${p}", it shows "${t}"`); }, async eq(id, expected) { const t = await app.text(id); if (t !== expected) throw new Failure(`expected [${id}] to read "${expected}", it reads ${t == null ? "nothing (no such element)" : `"${t}"`}`); }, async rows(id, expected) { const r = await app.items(id); if (JSON.stringify(r) !== JSON.stringify(expected)) throw new Failure(`expected [${id}] rows ${JSON.stringify(expected)}, got ${JSON.stringify(r)}`); }, diff --git a/eval/harness/fuzz-board.mjs b/eval/harness/fuzz-board.mjs new file mode 100644 index 00000000..325f2a46 --- /dev/null +++ b/eval/harness/fuzz-board.mjs @@ -0,0 +1,291 @@ +// The board's timing fuzzer (round 15). No per-app tests: a seeded random session of user moves and backend answers +// (land or fail, in any order, also after the page was left) against a fake server that keeps the truth, checked by +// oracles every board app must pass whatever the agent built. The brief is tasks/board/brief-plain.md. +// +// Every frame, on the board page (a column counts once its heading reads "(n)"): +// - duplicate card: no card id shows twice, and an added card never shows beside its own "(adding…)" row; +// - heading: a loaded column's heading counts the cards it shows; +// - count: the selected card's "/" equals the checklist's "/ done" while both show; +// - limit: while Doing shows 3 or more cards, every To do card's right and every Done card's left is disabled; +// - detail: the selected card's detail names the column it shows in, and the title its card shows (two views). +// Settled (nothing in the air), on the board page with all three columns loaded, and at the end of a session: +// - the columns hold the server's cards, with its titles and counts, in its order; the selected card's checklist +// is the server's. On the archive page: its rows are the server's archive. +// Allowances, the same for every arm: +// - B1 (brief: leaving a page discards it): a write that answers after its page was left need not show on the next +// visit, so a visit where one landed is not checked settled (Ryan, 2026-11-14: late writes are out of scope). +// - B2 (brief: "if the server accepts, the card stays where it was moved"): after two moves overlapped in the air, a +// column's order is not checked settled for the rest of the visit, only which cards it holds. An app asked to keep a +// card where it showed it cannot know an order the server made from answers it applied in another order. +// - Two accepted writes to one field: settled, the field must show the server's last answer (Ryan, 2026-11-14). +// Usage: node harness/fuzz-board.mjs [--seeds N] [--steps N] [--from S] [--json out.json] [--log] +// (--r9 and --r15 are accepted and ignored, so harness/grade.sh can pass its round flag.) +import { writeFileSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { build } from "./build.mjs"; +import { launch, serve } from "./driver.mjs"; + +const norm = s => (s ?? "").replace(/\s+/g, " ").trim(); +function rng(seed) { let a = seed >>> 0; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } + +const COLS = ["todo", "doing", "done"]; +const NAME = { todo: "To do", doing: "Doing", done: "Done" }; +const TITLES = ["Alpha", "Bravo", "Delta"]; +const ITEMS = ["ship", "docs", "qa"]; +const WRITES = new Set(["moveCard", "renameCard", "addCard", "archiveCard", "toggleItem", "addItem", "restoreCard"]); + +export function freshServer() { + return { + cols: { + todo: [{ id: 1, title: "Write spec" }, { id: 2, title: "Fix login" }], + doing: [{ id: 3, title: "Review PR" }, { id: 4, title: "Deploy" }], + done: [{ id: 5, title: "Kickoff" }], + }, + items: { + 1: [{ id: 11, text: "outline", done: false }, { id: 12, text: "draft", done: false }], + 2: [{ id: 21, text: "repro", done: true }, { id: 22, text: "patch", done: false }, { id: 23, text: "test", done: false }], + 3: [{ id: 31, text: "read", done: true }, { id: 32, text: "approve", done: true }], + 4: [{ id: 41, text: "release", done: false }], + 5: [{ id: 51, text: "invite", done: true }, { id: 52, text: "agenda", done: true }, { id: 53, text: "notes", done: true }], + 8: [], 9: [{ id: 91, text: "try it", done: true }], + }, + archived: [{ id: 8, title: "Old idea" }, { id: 9, title: "Spike" }], + nextCard: 10, nextItem: 100, + }; +} +const withCounts = (srv, c) => { const xs = srv.items[c.id] ?? []; return { id: c.id, title: c.title, done: xs.filter(x => x.done).length, total: xs.length }; }; +const where = (srv, id) => { for (const col of COLS) { const i = srv.cols[col].findIndex(c => c.id === id); if (i >= 0) return [col, i]; } return null; }; + +/** the answer the server gives `key` now, applying a write; `fail` = the server declines / the read fails */ +export function answer(srv, key, fail) { + if (fail) return { fail: true }; + const i = key.indexOf(":"); const name = key.slice(0, i); const a = key.slice(i + 1).split(","); + switch (name) { + case "fetchCards": return { value: srv.cols[a[0]].map(c => withCounts(srv, c)) }; + case "moveCard": { + const id = Number(a[0]); const w = where(srv, id); if (!w) return { fail: true }; + const [c] = srv.cols[w[0]].splice(w[1], 1); const to = srv.cols[a[1]]; const at = Math.max(0, Math.min(Number(a[2]), to.length)); + to.splice(at, 0, c); return { value: { id, column: a[1], index: at } }; + } + case "renameCard": { + const id = Number(a[0]); const title = a.slice(1).join(","); + const w = where(srv, id); const x = w ? srv.cols[w[0]][w[1]] : srv.archived.find(c => c.id === id); if (!x) return { fail: true }; + x.title = title; return { value: { id, title } }; + } + case "addCard": { const c = { id: srv.nextCard++, title: a.slice(1).join(",") }; srv.cols[a[0]].push(c); srv.items[c.id] = []; return { value: { id: c.id, title: c.title } }; } + case "archiveCard": { const id = Number(a[0]); const w = where(srv, id); if (!w) return { fail: true }; const [c] = srv.cols[w[0]].splice(w[1], 1); srv.archived.unshift(c); return { value: { id } }; } + case "fetchChecklist": return { value: srv.items[a[0]] ?? [] }; + case "toggleItem": { const x = (srv.items[a[0]] ?? []).find(t => t.id === Number(a[1])); if (!x) return { fail: true }; x.done = a[2] === "true"; return { value: { id: x.id, done: x.done } }; } + case "addItem": { const x = { id: srv.nextItem++, text: a.slice(1).join(","), done: false }; srv.items[a[0]] = [...(srv.items[a[0]] ?? []), x]; return { value: x }; } + case "fetchArchived": return { value: srv.archived.map(c => ({ ...c })) }; + case "restoreCard": { const id = Number(a[0]); const k = srv.archived.findIndex(c => c.id === id); if (k < 0) return { fail: true }; const [c] = srv.archived.splice(k, 1); srv.cols.todo.push(c); return { value: { id } }; } + default: return { value: null }; + } +} + +const CARD = /^#(\d+) (.*) · (\d+)\/(\d+)$/; +const ADDING = /^(.*) \(adding…\)$/; + +/** one session; returns the violations it found (each with the step and the log so far) */ +async function session(browser, url, seed, steps, log) { + const r = rng(seed); const pick = xs => xs[Math.floor(r() * xs.length)]; + const page = await browser.newPage(); + const errors = []; page.on("pageerror", e => errors.push(e.message)); + const settle = () => page.waitForTimeout(60); + const sel = id => `[data-testid="${id}"]`; + const srv = freshServer(); + const ui = { page: "board", selected: null, visit: 0 }; + const writeVisit = new Map(); // write key -> the visit (board or archive) it was made on + const settledCount = new Map(); + let lateVisit = false; // B1 + let looseOrder = false; // B2 + const trail = []; const found = []; + let made = 0; // added cards and items get distinct texts: two adds of one text would be one flight to the harness + const pending = () => page.evaluate(() => globalThis.__flights.pending()); + const exists = async id => (await page.locator(sel(id)).count()) > 0; + const text = async id => (await exists(id)) ? norm(await page.locator(sel(id)).first().textContent()) : null; + const note = (kind, detail) => { if (!found.length) found.push({ seed, step: trail.length, kind, detail, trail: [...trail] }); }; + const track = async () => { + const ks = await pending(); + for (const k of ks) if (WRITES.has(k.slice(0, k.indexOf(":"))) && !writeVisit.has(k)) writeVisit.set(k, ui.visit); + if (ks.filter(k => k.startsWith("moveCard:")).length >= 2) looseOrder = true; + }; + + // what the board shows: per column, null when it isn't loaded, else its rows + async function board() { + const out = {}; + for (const col of COLS) { + const head = await text(`head-${col}`); + const m = head && head.match(/\((\d+)\)\s*$/); + if (!m) { out[col] = null; continue; } + const rows = (await page.locator(`${sel(`col-${col}`)} ${sel("card")}`).allTextContents()).map(norm); + out[col] = { n: Number(m[1]), rows: rows.map(t => { const c = t.match(CARD); if (c) return { id: Number(c[1]), title: c[2], done: Number(c[3]), total: Number(c[4]), text: t }; const g = t.match(ADDING); return { adding: true, title: g ? g[1] : t, text: t }; }) }; + } + return out; + } + + async function everyFrame(settled) { + if (ui.page === "archive") { + if (!settled) return; + const list = await text("archived"); + if (!list || /loading|error/.test(list)) return; + const rows = (await page.locator(sel("archived-card")).allTextContents()).map(norm); + const want = srv.archived.map(c => `#${c.id} ${c.title}`); + if (!lateVisit && JSON.stringify(rows) !== JSON.stringify(want)) note("settled: archive", `shows ${JSON.stringify(rows)}, server ${JSON.stringify(want)}`); + return; + } + const b = await board(); + const seen = new Map(); + for (const col of COLS) { + const c = b[col]; if (!c) continue; + if (c.n !== c.rows.length) note("heading", `head-${col} counts ${c.n}, the column shows ${c.rows.length} cards`); + for (const x of c.rows) if (!x.adding) { if (seen.has(x.id)) note("duplicate card", `#${x.id} shows in ${seen.get(x.id)} and ${col}`); seen.set(x.id, col); } + } + const placeholders = COLS.flatMap(col => (b[col]?.rows ?? []).filter(x => x.adding).map(x => x.title)); + const addsOut = (await pending()).filter(k => k.startsWith("addCard:")).map(k => k.slice(k.indexOf(",") + 1)); + for (const t of new Set(placeholders)) { + const shownReal = COLS.flatMap(col => (b[col]?.rows ?? []).filter(x => !x.adding && x.title === t && x.id >= 10)).length; + const n = placeholders.filter(p => p === t).length, out = addsOut.filter(a => a === t).length; + if (n > out) note("duplicate card", `"${t} (adding…)" shows ${n} time(s) with ${out} add(s) of it in the air${shownReal ? ", beside its added card" : ""}`); + } + // the work-in-progress limit + if (b.doing && b.doing.rows.length >= 3) { + for (const [col, dir] of [["todo", "right"], ["done", "left"]]) { + const btns = page.locator(`${sel(`col-${col}`)} ${sel(dir)}`); + for (let k = 0; k < await btns.count(); k++) if (!(await btns.nth(k).isDisabled())) { note("limit", `Doing shows ${b.doing.rows.length} cards and a ${NAME[col]} card's ${dir} is enabled`); break; } + } + } + // the detail against the card it shows + const detail = await text("detail"); + if (ui.selected != null && detail && !/select a card/.test(detail)) { + const col = COLS.find(cl => b[cl]?.rows.some(x => x.id === ui.selected)); + const x = col && b[col].rows.find(y => y.id === ui.selected); + if (x) { + if (!detail.includes(`in ${NAME[col]}`)) note("detail column", `#${x.id} shows in ${NAME[col]}, the detail says "${detail.slice(0, 80)}"`); + if (!detail.includes(`#${x.id} ${x.title}`)) note("two views", `card "${x.text}", detail "${detail.slice(0, 80)}"`); + const prog = await text("progress"); const m = prog && prog.match(/^(\d+)\/(\d+) done$/); + const list = await text("checklist"); + if (m && list && !/loading|error/.test(list) && (Number(m[1]) !== x.done || Number(m[2]) !== x.total)) note("count", `card "${x.text}", progress "${prog}"`); + } + } + if (!settled || lateVisit || !COLS.every(col => b[col])) return; + for (const col of COLS) { + const want = srv.cols[col].map(c => withCounts(srv, c)); + const got = b[col].rows; + if (got.some(x => x.adding)) { note(`settled: ${col}`, `an "(adding…)" row with nothing in the air`); continue; } + const ids = got.map(x => x.id), wids = want.map(x => x.id); + const same = looseOrder ? JSON.stringify([...ids].sort()) === JSON.stringify([...wids].sort()) : JSON.stringify(ids) === JSON.stringify(wids); + if (!same) { note(`settled: ${col}`, `shows [${ids}], server [${wids}]${looseOrder ? " (order not checked)" : ""}`); continue; } + for (const x of got) { const w = want.find(y => y.id === x.id); if (x.title !== w.title || x.done !== w.done || x.total !== w.total) note(`settled: card`, `"${x.text}", server "#${w.id} ${w.title} · ${w.done}/${w.total}"`); } + } + if (ui.selected != null && b && COLS.some(col => b[col].rows.some(x => x.id === ui.selected))) { + const list = await text("checklist"); + if (list && !/loading|error/.test(list)) { + const rows = (await page.locator(sel("item")).allTextContents()).map(norm); + const want = (srv.items[ui.selected] ?? []).map(t => `${t.done ? "[x]" : "[ ]"} ${t.text}`); + if (JSON.stringify(rows) !== JSON.stringify(want)) note("settled: checklist", `#${ui.selected} shows ${JSON.stringify(rows)}, server ${JSON.stringify(want)}`); + } + } + } + + async function answerOne(keys) { + const key = pick(keys); const name = key.slice(0, key.indexOf(":")); + const fail = r() < (WRITES.has(name) ? 0.3 : 0.1); + const times = Math.max(1, (await page.evaluate(k => globalThis.__flights.asked().filter(x => x === k).length, key)) - (settledCount.get(key) ?? 0)); + settledCount.set(key, (settledCount.get(key) ?? 0) + times); + let a; for (let t = 0; t < times; t++) a = answer(srv, key, fail); + if (WRITES.has(name) && !a.fail && writeVisit.get(key) !== ui.visit) lateVisit = true; // B1 + writeVisit.delete(key); + trail.push(`${a.fail ? "fail" : "land"} ${key}`); + await page.evaluate(([k, a]) => a.fail ? globalThis.__flights.fail(k, "declined") : globalThis.__flights.land(k, a.value), [key, a]); + await settle(); + } + + async function move() { + const opts = []; + const click = async (id, nth = 0) => { await page.locator(sel(id)).nth(nth).click({ timeout: 2000 }); }; + if (ui.page === "board") { + const cards = page.locator(sel("card")); + const n = await cards.count(); + if (n) opts.push(["select", 3], ["move", 6]); + for (const col of COLS) if (await exists(`retry-${col}`)) opts.push([`retry-${col}`, 2]); + opts.push(["add", 2], ["archive-tab", 1]); + if (ui.selected != null && await exists("rename")) opts.push(["rename", 3], ["archive", 1]); + if (await exists("toggle")) opts.push(["toggle", 3]); + if (await exists("add-item")) opts.push(["add-item", 2]); + } else { + if (await exists("restore")) opts.push(["restore", 3]); + opts.push(["board-tab", 2]); + } + let total = opts.reduce((s, [, w]) => s + w, 0), x = r() * total, choice = opts[0][0]; + for (const [k, w] of opts) { if ((x -= w) < 0) { choice = k; break; } } + switch (choice) { + case "select": { + const ids = (await page.locator(sel("card")).allTextContents()).map(norm).map(t => t.match(CARD)).filter(Boolean).map(m => Number(m[1])); + if (!ids.length) return; const id = pick(ids); trail.push(`select #${id}`); + await page.locator(sel("card"), { hasText: new RegExp(`^\\s*#${id} `) }).first().click({ timeout: 2000 }); ui.selected = id; break; + } + case "move": { + const btns = []; + for (const dir of ["left", "right", "up", "down"]) { const l = page.locator(sel(dir)); for (let k = 0; k < await l.count(); k++) if (!(await l.nth(k).isDisabled())) btns.push([dir, k]); } + if (!btns.length) return; const [dir, k] = pick(btns); + const owner = norm(await page.locator(sel(dir)).nth(k).locator(`xpath=ancestor::*[count(.//*[@data-testid="card"]) = 1][1]`).locator(sel("card")).first().textContent().catch(() => "?")); + trail.push(`${dir} ${owner.match(/^#\d+/)?.[0] ?? owner}`); await click(dir, k); break; + } + case "add": { const t = `${pick(TITLES)} ${++made}`; trail.push(`add ${t}`); await page.locator(sel("new-card")).first().fill(t, { timeout: 2000 }); await click("add-card"); break; } + case "rename": { const t = pick(TITLES); trail.push(`rename ${t}`); await page.locator(sel("title-input")).first().fill(t, { timeout: 2000 }); await click("rename"); break; } + case "archive": trail.push(`archive #${ui.selected}`); await click("archive"); ui.selected = null; break; + case "toggle": { const n = await page.locator(sel("toggle")).count(); const k = Math.floor(r() * n); trail.push(`toggle ${k}`); await click("toggle", k); break; } + case "add-item": { const t = `${pick(ITEMS)} ${++made}`; trail.push(`add-item ${t}`); await page.locator(sel("item-input")).first().fill(t, { timeout: 2000 }); await click("add-item"); break; } + case "archive-tab": trail.push("tab archive"); await click("tab-archive"); ui.page = "archive"; ui.selected = null; ui.visit++; lateVisit = false; looseOrder = false; break; + case "board-tab": trail.push("tab board"); await click("tab-board"); ui.page = "board"; ui.selected = null; ui.visit++; lateVisit = false; looseOrder = false; break; + case "restore": { const n = await page.locator(sel("restore")).count(); const k = Math.floor(r() * n); trail.push(`restore ${k}`); await click("restore", k); break; } + default: if (choice.startsWith("retry-")) { trail.push(choice); await click(choice); } + } + await settle(); + } + + try { + await page.goto(url); await page.waitForFunction(() => globalThis.__flights !== undefined, null, { timeout: 5000 }).catch(() => {}); + await settle(); + for (let s = 0; s < steps && !found.length; s++) { + await track(); + const keys = await pending(); + if (keys.length && r() < 0.4) await answerOne(keys); else await move().catch(e => { trail.push(`(move failed: ${e.message.split("\n")[0].slice(0, 80)})`); }); + await track(); + await everyFrame((await pending()).length === 0); + } + for (let guard = 0; !found.length && guard < 200; guard++) { + await track(); + const keys = await pending(); + if (!keys.length) break; + await answerOne(keys); await track(); await everyFrame((await pending()).length === 0); + } + if (errors.length && !found.length) note("page error", errors[0].slice(0, 120)); + } finally { await page.close(); } + if (log) console.log(`seed ${seed}: ${trail.join(" ; ")}`); + return found.slice(0, 1); +} + +export async function fuzz(dir, { seeds = 20, steps = 30, from = 1, log = false } = {}) { + const b = await build(dir); + if (!b.ok) return { dir, build: false, seeds, violations: [] }; + const server = await serve(b.dist); let browser = await launch(); + const violations = []; + try { + for (let s = from; s < from + seeds; s++) { + try { let t; violations.push(...await Promise.race([session(browser, server.url, s, steps, log), new Promise((_, rej) => { t = setTimeout(() => rej(new Error("session timed out (120 s): the page hangs")), 120000); })]).finally(() => clearTimeout(t))); } + catch (e) { violations.push({ seed: s, step: -1, kind: "page crash", detail: e.message.split("\n")[0].slice(0, 120), trail: [] }); await browser.close().catch(() => {}); browser = await launch(); } + } + } finally { await browser.close(); server.close(); } + return { dir, build: true, seeds, violations }; +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + const a = process.argv.slice(2); const opt = (k, d) => { const i = a.indexOf(k); return i >= 0 ? a[i + 1] : d; }; + const res = await fuzz(path.resolve(a[0]), { seeds: Number(opt("--seeds", 20)), steps: Number(opt("--steps", 30)), from: Number(opt("--from", 1)), log: a.includes("--log") }); + console.log(`${path.basename(res.dir)}: ${res.build ? `${res.violations.length}/${res.seeds} seeds found a hole` : "build failed"}`); + for (const v of res.violations) console.log(` seed ${v.seed} step ${v.step}: ${v.kind}: ${v.detail}\n ${v.trail.join(" ; ")}`); + if (opt("--json")) writeFileSync(opt("--json"), JSON.stringify(res, null, 2) + "\n"); +} diff --git a/eval/harness/grade.sh b/eval/harness/grade.sh index 2c60d8fa..3b41383a 100644 --- a/eval/harness/grade.sh +++ b/eval/harness/grade.sh @@ -1,11 +1,13 @@ #!/bin/sh # Grade one desk (scaffolded with `node harness/scaffold.mjs desk9 --brief plain [--inline-docs]`), however # the agent that wrote it was run: hidden tests, hidden overlap tests, and 20 timing-fuzzer seeds. +# TASK=board grades another task (its fuzzer is harness/fuzz-.mjs when there is one). # FUZZ_ROUND=--r15 grades round 15 (server's last answer wins at rest). # Usage: sh harness/grade.sh [out.txt] (run from eval/; defects = ✗ lines + fuzz seeds with a hole) set -e d=$(cd "$1" && pwd); out=${2:-/dev/stdout} cd "$(dirname "$0")/.." -{ echo "== hidden"; node harness/test.mjs "$d" tasks/desk9/hidden.mjs 2>&1 | grep "^✗\|passed\|build failed" || true - echo "== overlap"; node harness/test.mjs "$d" tasks/desk9/hidden-overlap.mjs 2>&1 | grep "^✗\|passed\|build failed" || true - echo "== fuzz"; node harness/fuzz.mjs "$d" --seeds 20 ${FUZZ_ROUND:---r9} 2>&1 | cut -c1-400; } > "$out" +t=${TASK:-desk9}; fz=harness/fuzz.mjs; [ -f harness/fuzz-$t.mjs ] && fz=harness/fuzz-$t.mjs +{ echo "== hidden"; node harness/test.mjs "$d" tasks/$t/hidden.mjs 2>&1 | grep "^✗\|passed\|build failed" || true + echo "== overlap"; node harness/test.mjs "$d" tasks/$t/hidden-overlap.mjs 2>&1 | grep "^✗\|passed\|build failed" || true + echo "== fuzz"; node $fz "$d" --seeds 20 ${FUZZ_ROUND:---r9} 2>&1 | cut -c1-400; } > "$out" diff --git a/eval/reference/board/react/App.tsx b/eval/reference/board/react/App.tsx new file mode 100644 index 00000000..3b24b592 --- /dev/null +++ b/eval/reference/board/react/App.tsx @@ -0,0 +1,37 @@ +import { useState, type ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import BoardPage from "./BoardPage"; +import ArchivePage from "./ArchivePage"; + +type Page = "board" | "archive"; + +// Each page gets its own query cache, made when the page opens, so leaving a page discards what it loaded; a write +// that answers after its page was left patches only the discarded cache. +function PageClient({ children }: { children: ReactNode }) { + const [client] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { retry: false, staleTime: Infinity, refetchOnWindowFocus: false, refetchOnReconnect: false }, + }, + }), + ); + return {children}; +} + +export default function App() { + const [page, setPage] = useState("board"); + return ( +
+ + {page === "board" ? : } +
+ ); +} diff --git a/eval/reference/board/react/ArchivePage.tsx b/eval/reference/board/react/ArchivePage.tsx new file mode 100644 index 00000000..7a03d0c3 --- /dev/null +++ b/eval/reference/board/react/ArchivePage.tsx @@ -0,0 +1,54 @@ +import { useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { fetchArchived, restoreCard, type Archived } from "./api"; +import { errorMessage } from "./errors"; + +export default function ArchivePage() { + const queryClient = useQueryClient(); + const archived = useQuery({ queryKey: ["archived"], queryFn: fetchArchived }); + // restores in the air, each with its own seq so that two restores of one row settle separately + const [restoring, setRestoring] = useState<{ seq: number; id: number }[]>([]); + const [restoreError, setRestoreError] = useState(null); + const seqRef = useRef(0); + + const doRestore = async (id: number) => { + const seq = ++seqRef.current; + setRestoreError(null); + setRestoring(prev => [...prev, { seq, id }]); + try { + await restoreCard(id); + queryClient.setQueryData(["archived"], old => old?.filter(a => a.id !== id)); + } catch (e) { + setRestoreError(errorMessage(e)); + } finally { + setRestoring(prev => prev.filter(r => r.seq !== seq)); + } + }; + + const rows = archived.data?.filter(a => !restoring.some(r => r.id === a.id)); + return ( +
+
+ {rows ? ( +
    + {rows.map(a => ( +
  • + + #{a.id} {a.title} + {" "} + +
  • + ))} +
+ ) : archived.isError ? ( +

archive error: {errorMessage(archived.error)}

+ ) : ( +

loading archive

+ )} +
+ {restoreError !== null &&

restore error: {restoreError}

} +
+ ); +} diff --git a/eval/reference/board/react/BoardColumn.tsx b/eval/reference/board/react/BoardColumn.tsx new file mode 100644 index 00000000..dee1a851 --- /dev/null +++ b/eval/reference/board/react/BoardColumn.tsx @@ -0,0 +1,84 @@ +import type { ReactNode } from "react"; +import type { Column } from "./api"; +import { COLUMNS, COLUMN_NAME } from "./board"; +import { errorMessage } from "./errors"; + +/** a card as the board shows it, or one being added (no id yet) */ +export type ShownRow = { kind: "card"; id: number; title: string; done: number; total: number } | { kind: "adding"; seq: number; title: string }; + +type Props = { + column: Column; + /** undefined until the column has loaded */ + rows: ShownRow[] | undefined; + /** the load's failure, while it is not being asked again */ + error: unknown; + onRetry: () => void; + /** whether a card from another column may move into `target`, and how many cards `target` shows */ + canEnter: (target: Column) => boolean; + sizeOf: (target: Column) => number; + selected: number | null; + onSelect: (id: number) => void; + onMove: (id: number, column: Column, index: number) => void; + footer?: ReactNode; +}; + +export default function BoardColumn({ column, rows, error, onRetry, canEnter, sizeOf, selected, onSelect, onMove, footer }: Props) { + const at = COLUMNS.indexOf(column); + const left = COLUMNS[at - 1]; + const right = COLUMNS[at + 1]; + return ( +
+

+ {COLUMN_NAME[column]} + {rows ? ` (${rows.length})` : ""} +

+ {rows ? ( +
    + {rows.map((r, i) => + r.kind === "adding" ? ( +
  • + {r.title} (adding…) +
  • + ) : ( +
  • + {" "} + {left && ( + + )} + {right && ( + + )} + {i > 0 && ( + + )} + {i < rows.length - 1 && ( + + )} +
  • + ), + )} +
+ ) : error ? ( +

+ error: {errorMessage(error)}{" "} + +

+ ) : ( +

loading

+ )} + {footer} +
+ ); +} diff --git a/eval/reference/board/react/BoardPage.tsx b/eval/reference/board/react/BoardPage.tsx new file mode 100644 index 00000000..18fe1754 --- /dev/null +++ b/eval/reference/board/react/BoardPage.tsx @@ -0,0 +1,292 @@ +import { useRef, useState } from "react"; +import { useQueries, useQueryClient } from "@tanstack/react-query"; +import { addCard, addItem, archiveCard, fetchCards, fetchChecklist, moveCard, renameCard, toggleItem, type Column } from "./api"; +import { + COLUMNS, + WIP_LIMIT, + addSlot, + countItems, + isColumn, + layColumns, + layItems, + newest, + placeCard, + type Archiving, + type CachedCard, + type CachedItem, + type CardAdd, + type ItemAdd, + type Move, + type Rename, + type Toggle, +} from "./board"; +import { errorIn, errorMessage, type VisitError } from "./errors"; +import BoardColumn, { type ShownRow } from "./BoardColumn"; +import Detail from "./Detail"; + +const cardsKey = (c: Column) => ["cards", c]; +const checklistKey = (id: number) => ["checklist", id]; + +export default function BoardPage() { + const queryClient = useQueryClient(); + const seqRef = useRef(0); + const nextSeq = () => ++seqRef.current; + + const [selected, setSelected] = useState(null); + // counts selections: the detail's inputs and errors belong to one selection + const [visit, setVisit] = useState(0); + const [titleInput, setTitleInput] = useState(""); + const [itemInput, setItemInput] = useState(""); + const [newCard, setNewCard] = useState(""); + + // writes in the air + const [moves, setMoves] = useState([]); + const [adds, setAdds] = useState([]); + const [archiving, setArchiving] = useState([]); + const [renames, setRenames] = useState([]); + const [toggles, setToggles] = useState([]); + const [itemAdds, setItemAdds] = useState([]); + + const [moveError, setMoveError] = useState(null); + const [addError, setAddError] = useState(null); + const [archiveError, setArchiveError] = useState(null); + const [renameError, setRenameError] = useState(null); + const [itemError, setItemError] = useState(null); + + const columnQueries = useQueries({ + queries: COLUMNS.map(c => ({ queryKey: cardsKey(c), queryFn: (): Promise => fetchCards(c) })), + }); + const base = {} as Record; + COLUMNS.forEach((c, i) => (base[c] = columnQueries[i].data)); + const laid = layColumns(base, moves, adds, archiving); + + // Checklists: the selected card's loads; every other card's stays as last answered for the page's life, so a card + // whose checklist is known counts its items as the checklist shows them, writes in the air included. + const cardIds = COLUMNS.flatMap(c => laid[c] ?? []).flatMap(r => (r.kind === "card" ? [r.card.id] : [])); + const checklistIds = selected !== null && !cardIds.includes(selected) ? [...cardIds, selected] : cardIds; + const checklistQueries = useQueries({ + queries: checklistIds.map(id => ({ + queryKey: checklistKey(id), + queryFn: (): Promise => fetchChecklist(id), + enabled: id === selected, + gcTime: Infinity, + })), + }); + const itemsOf = (id: number) => { + const list = checklistQueries[checklistIds.indexOf(id)]?.data; + return list && layItems(list, toggles.filter(t => t.cardId === id), itemAdds.filter(a => a.cardId === id)); + }; + + const shown = {} as Record; + for (const c of COLUMNS) + shown[c] = laid[c]?.map(r => { + if (r.kind === "adding") return r; + const items = itemsOf(r.card.id); + const { done, total } = items ? countItems(items) : r.card; + const title = newest(renames, n => n.id === r.card.id)?.title ?? r.card.title; + return { kind: "card", id: r.card.id, title, done, total }; + }); + + /** whether a card may move into `target` from another column: it has loaded, and Doing is under its limit */ + const canEnter = (target: Column) => { + const rows = shown[target]; + return rows !== undefined && (target !== "doing" || rows.length < WIP_LIMIT); + }; + + // the server's answers go into the caches + const setColumns = (f: (cols: Record) => Record) => { + const cols = {} as Record; + for (const c of COLUMNS) cols[c] = queryClient.getQueryData(cardsKey(c)); + const next = f(cols); + for (const c of COLUMNS) if (next[c] !== cols[c]) queryClient.setQueryData(cardsKey(c), next[c]); + }; + const patchCard = (id: number, f: (card: CachedCard) => CachedCard) => + setColumns(cols => { + const next = { ...cols }; + for (const c of COLUMNS) next[c] = cols[c]?.map(x => (x.id === id ? f(x) : x)); + return next; + }); + + const doMove = async (id: number, column: Column, index: number) => { + const seq = nextSeq(); + setMoveError(null); + setMoves(prev => [...prev, { seq, id, column, index }]); + try { + const res = await moveCard(id, column, index); + const to = isColumn(res.column) ? res.column : column; + setColumns(cols => placeCard(cols, res.id, to, res.index)); + } catch (e) { + setMoveError(errorMessage(e)); + } finally { + setMoves(prev => prev.filter(m => m.seq !== seq)); + } + }; + + const doAdd = async (title: string) => { + const seq = nextSeq(); + setAddError(null); + setNewCard(""); + setAdds(prev => [...prev, { seq, title }]); + try { + const res = await addCard("todo", title); + queryClient.setQueryData(cardsKey("todo"), old => { + if (!old || old.some(x => x.id === res.id)) return old; + const next = [...old]; + next.splice(addSlot(old, seq, x => x.addSeq), 0, { id: res.id, title: res.title, done: 0, total: 0, addSeq: seq }); + return next; + }); + } catch (e) { + setAddError(errorMessage(e)); + } finally { + setAdds(prev => prev.filter(a => a.seq !== seq)); + } + }; + + const doRename = async (id: number, title: string) => { + const seq = nextSeq(); + setRenameError(null); + setRenames(prev => [...prev, { seq, id, title }]); + try { + const res = await renameCard(id, title); + patchCard(res.id, x => ({ ...x, title: res.title })); + } catch (e) { + setRenameError({ visit, message: errorMessage(e) }); + } finally { + setRenames(prev => prev.filter(n => n.seq !== seq)); + } + }; + + const doArchive = async (id: number) => { + const seq = nextSeq(); + setArchiveError(null); + setSelected(null); + setArchiving(prev => [...prev, { seq, id }]); + try { + const res = await archiveCard(id); + setColumns(cols => { + const next = { ...cols }; + for (const c of COLUMNS) next[c] = cols[c]?.filter(x => x.id !== res.id); + return next; + }); + } catch (e) { + setArchiveError(errorMessage(e)); + } finally { + setArchiving(prev => prev.filter(a => a.seq !== seq)); + } + }; + + const doToggle = async (cardId: number, itemId: number, done: boolean) => { + const seq = nextSeq(); + setItemError(null); + setToggles(prev => [...prev, { seq, cardId, itemId, done }]); + try { + const res = await toggleItem(cardId, itemId, done); + queryClient.setQueryData(checklistKey(cardId), old => old?.map(i => (i.id === res.id ? { ...i, done: res.done } : i))); + } catch (e) { + setItemError({ visit, message: errorMessage(e) }); + } finally { + setToggles(prev => prev.filter(t => t.seq !== seq)); + } + }; + + const doAddItem = async (cardId: number, text: string) => { + const seq = nextSeq(); + setItemError(null); + setItemInput(""); + setItemAdds(prev => [...prev, { seq, cardId, text }]); + try { + const res = await addItem(cardId, text); + queryClient.setQueryData(checklistKey(cardId), old => { + if (!old || old.some(i => i.id === res.id)) return old; + const next = [...old]; + next.splice(addSlot(old, seq, i => i.addSeq), 0, { ...res, addSeq: seq }); + return next; + }); + } catch (e) { + setItemError({ visit, message: errorMessage(e) }); + } finally { + setItemAdds(prev => prev.filter(a => a.seq !== seq)); + } + }; + + const select = (id: number) => { + setSelected(id); + setVisit(v => v + 1); + setTitleInput(""); + setItemInput(""); + }; + + // the selected card as the board shows it + let detailCard: { id: number; title: string; column: Column } | null = null; + for (const c of COLUMNS) + for (const r of shown[c] ?? []) if (r.kind === "card" && r.id === selected) detailCard = { id: r.id, title: r.title, column: c }; + const checklist = selected === null ? undefined : checklistQueries[checklistIds.indexOf(selected)]; + const items = detailCard && itemsOf(detailCard.id); + + return ( +
+
+ {COLUMNS.map((c, i) => { + const q = columnQueries[i]; + return ( + void q.refetch()} + canEnter={canEnter} + sizeOf={target => shown[target]?.length ?? 0} + selected={selected} + onSelect={select} + onMove={(id, column, index) => void doMove(id, column, index)} + footer={ + c === "todo" && ( +
+ setNewCard(e.target.value)} placeholder="new card" />{" "} + + {addError !== null &&

add error: {addError}

} +
+ ) + } + /> + ); + })} + { + if (detailCard && titleInput !== "") void doRename(detailCard.id, titleInput); + }} + renameError={errorIn(renameError, visit)} + onArchive={() => { + if (detailCard) void doArchive(detailCard.id); + }} + items={items ?? undefined} + checklistError={checklist?.data === undefined && checklist?.isError && !checklist.isFetching ? checklist.error : null} + onToggle={(itemId, done) => { + if (detailCard) void doToggle(detailCard.id, itemId, done); + }} + itemInput={itemInput} + onItemInput={setItemInput} + onAddItem={() => { + if (detailCard && items && itemInput !== "") void doAddItem(detailCard.id, itemInput); + }} + itemError={errorIn(itemError, visit)} + /> +
+ {moveError !== null &&

move error: {moveError}

} + {archiveError !== null &&

archive error: {archiveError}

} +
+ ); +} diff --git a/eval/reference/board/react/Detail.tsx b/eval/reference/board/react/Detail.tsx new file mode 100644 index 00000000..d9ba5754 --- /dev/null +++ b/eval/reference/board/react/Detail.tsx @@ -0,0 +1,88 @@ +import type { Column } from "./api"; +import { COLUMN_NAME, countItems, type ItemRow } from "./board"; +import { errorMessage } from "./errors"; + +type Props = { + /** the selected card as the board shows it; null while nothing is selected */ + card: { id: number; title: string; column: Column } | null; + titleInput: string; + onTitleInput: (v: string) => void; + onRename: () => void; + renameError: string | null; + onArchive: () => void; + /** the checklist as shown; undefined until it has loaded */ + items: ItemRow[] | undefined; + checklistError: unknown; + onToggle: (itemId: number, done: boolean) => void; + itemInput: string; + onItemInput: (v: string) => void; + onAddItem: () => void; + itemError: string | null; +}; + +export default function Detail(p: Props) { + const { card, items } = p; + if (!card) + return ( +
+

select a card

+
+ ); + const { done, total } = items ? countItems(items) : { done: 0, total: 0 }; + return ( +
+

+ #{card.id} {card.title} +

+

in {COLUMN_NAME[card.column]}

+
+ p.onTitleInput(e.target.value)} placeholder="new title" />{" "} + {" "} + +
+ {p.renameError !== null &&

rename error: {p.renameError}

} +
+ {items ? ( + <> +
    + {items.map(r => + r.kind === "adding" ? ( +
  • + [ ] {r.text} (adding…) +
  • + ) : ( +
  • + + [{r.done ? "x" : " "}] {r.text} + {" "} + +
  • + ), + )} +
+

+ {done}/{total} done +

+ + ) : p.checklistError ? ( +

checklist error: {errorMessage(p.checklistError)}

+ ) : ( +

loading checklist

+ )} +
+
+ p.onItemInput(e.target.value)} placeholder="new item" />{" "} + +
+ {p.itemError !== null &&

item error: {p.itemError}

} +
+ ); +} diff --git a/eval/reference/board/react/board.ts b/eval/reference/board/react/board.ts new file mode 100644 index 00000000..daae7d32 --- /dev/null +++ b/eval/reference/board/react/board.ts @@ -0,0 +1,117 @@ +// The board as shown: the server's last answers (the query caches) with the writes still in the air laid over them. +import type { Card, Column, Item } from "./api"; + +export const COLUMNS: Column[] = ["todo", "doing", "done"]; +export const COLUMN_NAME: Record = { todo: "To do", doing: "Doing", done: "Done" }; +export const isColumn = (c: string): c is Column => (COLUMNS as string[]).includes(c); +/** Doing holds at most this many cards */ +export const WIP_LIMIT = 3; + +/** a card or item as the cache holds it; `addSeq` marks one this page added, by the seq of its add */ +export type CachedCard = Card & { addSeq?: number }; +export type CachedItem = Item & { addSeq?: number }; + +// writes in the air; `seq` orders all of the page's writes +export type Move = { seq: number; id: number; column: Column; index: number }; +export type CardAdd = { seq: number; title: string }; +export type Archiving = { seq: number; id: number }; +export type Rename = { seq: number; id: number; title: string }; +export type Toggle = { seq: number; cardId: number; itemId: number; done: boolean }; +export type ItemAdd = { seq: number; cardId: number; text: string }; + +/** + * Where an entry from the add with `seq` goes in a list: before the first entry that a later add put there (that add + * answered first), else at the end. So entries keep the order their adds started in, guessed or answered. + */ +export function addSlot(list: T[], seq: number, seqOf: (e: T) => number | undefined) { + const i = list.findIndex(e => (seqOf(e) ?? -Infinity) > seq); + return i < 0 ? list.length : i; +} + +export type CardRow = { kind: "card"; card: CachedCard } | { kind: "adding"; seq: number; title: string }; +const rowSeq = (r: CardRow) => (r.kind === "adding" ? r.seq : r.card.addSeq); + +/** + * The loaded columns with the moves, adds and archives in the air replayed over them in the order they started, so + * each lands where it was made: a move at the position it was shown at, an add at the bottom of To do. + */ +export function layColumns( + base: Record, + moves: Move[], + adds: CardAdd[], + archiving: Archiving[], +): Record { + const cols = {} as Record; + for (const c of COLUMNS) cols[c] = base[c]?.map(card => ({ kind: "card", card })); + const find = (id: number) => { + for (const c of COLUMNS) { + const i = cols[c]?.findIndex(r => r.kind === "card" && r.card.id === id) ?? -1; + if (i >= 0) return { list: cols[c] as CardRow[], i }; + } + return null; + }; + type Op = ({ kind: "move" } & Move) | ({ kind: "add" } & CardAdd) | ({ kind: "archive" } & Archiving); + const ops: Op[] = [ + ...moves.map(m => ({ kind: "move" as const, ...m })), + ...adds.map(a => ({ kind: "add" as const, ...a })), + ...archiving.map(a => ({ kind: "archive" as const, ...a })), + ].sort((a, b) => a.seq - b.seq); + for (const op of ops) { + if (op.kind === "add") { + const todo = cols.todo; + if (todo) todo.splice(addSlot(todo, op.seq, rowSeq), 0, { kind: "adding", seq: op.seq, title: op.title }); + continue; + } + const at = find(op.id); + if (!at) continue; + if (op.kind === "archive") { + at.list.splice(at.i, 1); + continue; + } + const target = cols[op.column]; + if (!target) continue; + const [row] = at.list.splice(at.i, 1); + target.splice(Math.min(op.index, target.length), 0, row); + } + return cols; +} + +/** the newest of the writes that `match` (writes are kept in the order they started) */ +export function newest(writes: T[], match: (w: T) => boolean): T | undefined { + for (let i = writes.length - 1; i >= 0; i--) if (match(writes[i])) return writes[i]; + return undefined; +} + +export type ItemRow = { kind: "item"; id: number; text: string; done: boolean } | { kind: "adding"; seq: number; text: string }; + +/** a card's checklist with its toggles (the newest one per item) and item adds in the air laid over it */ +export function layItems(base: CachedItem[], toggles: Toggle[], adds: ItemAdd[]): ItemRow[] { + type Entry = { row: ItemRow; seq: number | undefined }; + const list: Entry[] = base.map(item => ({ + row: { kind: "item", id: item.id, text: item.text, done: newest(toggles, t => t.itemId === item.id)?.done ?? item.done }, + seq: item.addSeq, + })); + for (const a of [...adds].sort((x, y) => x.seq - y.seq)) + list.splice(addSlot(list, a.seq, e => e.seq), 0, { row: { kind: "adding", seq: a.seq, text: a.text }, seq: a.seq }); + return list.map(e => e.row); +} + +/** an item list's done and total, the adds in the air counted as not done */ +export const countItems = (rows: ItemRow[]) => ({ done: rows.filter(r => r.kind === "item" && r.done).length, total: rows.length }); + +// the server's answers, written into the caches + +/** the card answered at `column`/`index`, taken out of wherever the caches hold it */ +export function placeCard( + cols: Record, + id: number, + column: Column, + index: number, +): Record { + const card = COLUMNS.flatMap(c => cols[c] ?? []).find(x => x.id === id); + const next = {} as Record; + for (const c of COLUMNS) next[c] = cols[c]?.filter(x => x.id !== id); + const target = next[column]; + if (card && target) target.splice(Math.min(index, target.length), 0, card); + return next; +} diff --git a/eval/reference/board/react/errors.ts b/eval/reference/board/react/errors.ts new file mode 100644 index 00000000..cfe2337f --- /dev/null +++ b/eval/reference/board/react/errors.ts @@ -0,0 +1,9 @@ +export const errorMessage = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +/** + * An error that belongs to one selection of a card (`visit` counts selections): selecting a card, even the same one + * again, leaves it behind, and a write that answers after its selection was left reports nothing. + */ +export type VisitError = { visit: number; message: string }; + +export const errorIn = (e: VisitError | null, visit: number) => (e && e.visit === visit ? e.message : null); diff --git a/eval/tasks/board/NOTES.md b/eval/tasks/board/NOTES.md new file mode 100644 index 00000000..abdb6ab8 --- /dev/null +++ b/eval/tasks/board/NOTES.md @@ -0,0 +1,27 @@ +# Board task: reviewer notes (never shown to agents) + +Frozen 2026-11-14 (Ryan approved the draft as written). + +Draft for Ryan's review. Not shown to any agent yet. Once it's frozen it becomes `eval/tasks/board/brief-plain.md`, and +the visible, hidden and overlap tests and the fuzz oracles are written against it. Notes for the reviewer are in +quote blocks like this one; they are not part of the brief. +**Why this task.** It shares no screens with the issue desk, so the guide can't have been tuned to it. It tests what +separated the arms on desk9, in new shapes: +- one item leaving one list and joining another while both lists can load or reload (the column move: two keyed lists + and a guess in each); +- a count derived in two places from guesses (headings, the card's `done/total`, `progress`), which must agree; +- a limit derived from guesses (the WIP limit), so a stale count is visible as a wrong enabled button; +- a guess with no server id yet (the added card and item), which has to turn into the real one without a flicker or a + duplicate; +- page lifetime (an answer to a page that was left must not show on the next visit; a write that lands after the + page was left is not judged on that visit: Ryan 2026-11-14, late writes are a caching concern, out of scope). + +**Hidden tests (sketch, about 60):** declines of every action; overlapping moves of one card (newest wins at rest); +moves while the target column is loading or retrying (the guess shows when it loads); a move whose column reloads +before its answer; an add accepted while To do reloads (no duplicate); toggles while the checklist reloads (kept); +archive of a selected card with a pending rename; the WIP limit with a move out of Doing still pending; answers that +land after the board page was left. **Fuzz:** a board-specific session generator over the same moves, plus oracles: +every card shows in exactly one column (no duplicates, none missing unless a write explains it); headings equal their +card counts; the card's `done/total` equals the checklist's `progress` when both show; at rest everything matches the +server, except fields a write from a left page landed on. Two accepted writes to one field must show the +server's last answer (Ryan 2026-11-14: the only correct answer). diff --git a/eval/tasks/board/api.d.rx b/eval/tasks/board/api.d.rx new file mode 100644 index 00000000..c0005e00 --- /dev/null +++ b/eval/tasks/board/api.d.rx @@ -0,0 +1,15 @@ +// src/api.js, as .rx sees it. fetchCards, fetchChecklist and fetchArchived are loaders (safe to ask from an equation); +// moveCard, renameCard, addCard, archiveCard, toggleItem, addItem and restoreCard write. +export type Card = { id: number; title: string; done: number; total: number } +export pure fn fetchCards(column: string): Promise +export fn moveCard(id: number, column: string, index: number): Promise<{ id: number; column: string; index: number }> +export fn renameCard(id: number, title: string): Promise<{ id: number; title: string }> +export fn addCard(column: string, title: string): Promise<{ id: number; title: string }> +export fn archiveCard(id: number): Promise<{ id: number }> +export type Item = { id: number; text: string; done: boolean } +export pure fn fetchChecklist(id: number): Promise +export fn toggleItem(cardId: number, itemId: number, done: boolean): Promise<{ id: number; done: boolean }> +export fn addItem(cardId: number, text: string): Promise +export type Archived = { id: number; title: string } +export pure fn fetchArchived(): Promise +export fn restoreCard(id: number): Promise<{ id: number }> diff --git a/eval/tasks/board/api.d.ts b/eval/tasks/board/api.d.ts new file mode 100644 index 00000000..4b3ec7cf --- /dev/null +++ b/eval/tasks/board/api.d.ts @@ -0,0 +1,14 @@ +export type Column = "todo" | "doing" | "done"; +export type Card = { id: number; title: string; done: number; total: number }; +export function fetchCards(column: Column): Promise; +export function moveCard(id: number, column: Column, index: number): Promise<{ id: number; column: string; index: number }>; +export function renameCard(id: number, title: string): Promise<{ id: number; title: string }>; +export function addCard(column: Column, title: string): Promise<{ id: number; title: string }>; +export function archiveCard(id: number): Promise<{ id: number }>; +export type Item = { id: number; text: string; done: boolean }; +export function fetchChecklist(id: number): Promise; +export function toggleItem(cardId: number, itemId: number, done: boolean): Promise<{ id: number; done: boolean }>; +export function addItem(cardId: number, text: string): Promise; +export type Archived = { id: number; title: string }; +export function fetchArchived(): Promise; +export function restoreCard(id: number): Promise<{ id: number }>; diff --git a/eval/tasks/board/api.js b/eval/tasks/board/api.js new file mode 100644 index 00000000..5cd747bd --- /dev/null +++ b/eval/tasks/board/api.js @@ -0,0 +1,12 @@ +import { flight } from "./flights.js"; + +export const fetchCards = column => flight("fetchCards", [column]); +export const moveCard = (id, column, index) => flight("moveCard", [id, column, index]); +export const renameCard = (id, title) => flight("renameCard", [id, title]); +export const addCard = (column, title) => flight("addCard", [column, title]); +export const archiveCard = id => flight("archiveCard", [id]); +export const fetchChecklist = id => flight("fetchChecklist", [id]); +export const toggleItem = (cardId, itemId, done) => flight("toggleItem", [cardId, itemId, done]); +export const addItem = (cardId, text) => flight("addItem", [cardId, text]); +export const fetchArchived = () => flight("fetchArchived", []); +export const restoreCard = id => flight("restoreCard", [id]); diff --git a/eval/tasks/board/brief-plain.md b/eval/tasks/board/brief-plain.md new file mode 100644 index 00000000..e288c150 --- /dev/null +++ b/eval/tasks/board/brief-plain.md @@ -0,0 +1,115 @@ +# Sprint board + +Build a sprint board: two pages behind a row of tabs. The board page shows three columns of cards that can be moved, +reordered, renamed, added and archived, with a detail panel holding the selected card's checklist. The archive page lists +archived cards and can restore them. + +This is production code: it ships to real users on real networks. Build it the way you would for that. + +## The API (`src/api.js`, given; do not change it) + +Cards: + +- `fetchCards(column: "todo" | "doing" | "done"): Promise<{ id: number; title: string; done: number; total: number }[]>`: + the column's cards, top first. `done` and `total` count the card's checklist items. May reject with an `Error`. +- `moveCard(id: number, column: "todo" | "doing" | "done", index: number): Promise<{ id: number; column: string; index: number }>`: + moves the card on the server to position `index` (0 = top) of `column`, which may be its own column. May reject with an + `Error` (the server declined). +- `renameCard(id: number, title: string): Promise<{ id: number; title: string }>`. May reject with an `Error`. +- `addCard(column: "todo" | "doing" | "done", title: string): Promise<{ id: number; title: string }>`: adds a card at the + bottom of the column on the server and answers it with its new id. May reject with an `Error`. +- `archiveCard(id: number): Promise<{ id: number }>`: takes the card off the board on the server and into the archive. + May reject with an `Error`. + +Checklist: + +- `fetchChecklist(id: number): Promise<{ id: number; text: string; done: boolean }[]>`: the card's items, in order. May + reject with an `Error`. +- `toggleItem(cardId: number, itemId: number, done: boolean): Promise<{ id: number; done: boolean }>`. May reject with an + `Error`. +- `addItem(cardId: number, text: string): Promise<{ id: number; text: string; done: boolean }>`: adds an item at the end + on the server. May reject with an `Error`. + +Archive: + +- `fetchArchived(): Promise<{ id: number; title: string }[]>`: archived cards, most recently archived first. May reject + with an `Error`. +- `restoreCard(id: number): Promise<{ id: number }>`: puts the card back at the bottom of `todo` on the server. May + reject with an `Error`. + +## What the app must do + +Elements the tests look for carry these `data-testid`s. Text in quotes must appear exactly as written. + +### Tabs + +1. Two buttons, `tab-board` and `tab-archive`, always on screen. The app opens on the board page. +2. Only the open page is on screen. Leaving a page discards it: opening it again starts it as it starts when the app + opens (it loads again, nothing selected, inputs at their starting values). + +### The board page + +1. **Columns.** Three elements, `col-todo`, `col-doing` and `col-done`, left to right, each from + `fetchCards(column)`, asked when the page opens. + - Each column's heading, `head-`, reads `To do ()`, `Doing ()`, `Done ()`, where `` is the number + of cards the column shows. While the column loads, or if it failed, it reads `To do`, `Doing`, `Done`. + - While a column loads: `loading`. If it fails: `error: ` and a button `retry-` that asks + `fetchCards(column)` again. + - Then one element per card, top first, `data-testid="card"`, reading `# · <done>/<total>` + (`#7 Fix login · 1/3`). +2. **Moving.** Each card has four buttons next to its `card` element: `left`, `right`, `up`, `down`. `left`/`right` move + the card to the bottom of the next column over; `up`/`down` swap it with the card above or below. A button that has + nowhere to go (`left` in To do, `up` on the top card) is not shown. Clicking one: + - moves the card there straight away (both columns' cards and headings follow); + - calls `moveCard(id, column, index)` with the card's new column and position as shown; + - if the server declines, the card goes back where it was, and the page shows `move error: <message>` in an element + `move-error`; + - if the server accepts, the card stays where it was moved. + - A move error is cleared when the next move starts. +3. **Work-in-progress limit.** Doing holds at most 3 cards. While Doing shows 3 cards (counting cards moved there that + the server hasn't answered yet), the `right` button of every To do card is disabled, and the `left` button of every + Done card too. +4. **Adding.** Under To do, a text input `new-card` and a button `add-card`. Clicking `add-card` while `new-card` holds a + title: + - adds the card at the bottom of To do straight away, reading `<title> (adding…)` with no id and no move buttons, and + empties the input; + - calls `addCard("todo", title)`; + - if the server declines, the card goes away and `add-error` shows `add error: <message>`; + - if the server accepts, the card reads `#<id> <title> · 0/0` and gets its move buttons. + - An add error is cleared when the next add starts. + +### The detail + +1. **Selecting.** Clicking a `card` selects it. The detail, `detail`, shows `select a card` while nothing is selected. +2. While a card is selected, the detail shows `#<id> <title>` and `in <column name>` (`in Doing`), where it is on the + board as shown, moves included. +3. **Rename.** A text input `title-input`, empty when the card is selected, and a button `rename`. Clicking `rename` + while `title-input` holds a title: + - shows the new title on the card and in the detail straight away; + - calls `renameCard(id, title)`; + - if the server declines, both go back, and the detail shows `rename error: <message>`; + - if the server accepts, both keep the new title. +4. **Archive.** A button `archive`. Clicking it takes the card off the board straight away and clears the selection + (`select a card`), then calls `archiveCard(id)`. If the server declines, the card comes back where it was (still not + selected), and the board shows `archive error: <message>` in `archive-error`. If it accepts, the card stays off. +5. **Checklist**, `checklist`, from `fetchChecklist(id)`. + - While it loads: `loading checklist`. If it fails: `checklist error: <message>`. + - Then one row per item, `data-testid="item"`, reading `[x] <text>` when done and `[ ] <text>` when not, each with a + button `toggle` next to it. + - A line `progress` reading `<done>/<total> done`. +6. **Toggling.** Clicking an item's `toggle` flips it straight away. The item, `progress`, and the card's + `<done>/<total>` on the board all follow at once. Then it calls `toggleItem(cardId, itemId, done)`. If the server + declines, all three go back and the detail shows `item error: <message>`. If it accepts, they stay. +7. **Adding an item.** A text input `item-input` and a button `add-item`. Clicking `add-item` while `item-input` holds + text: the item shows as the last row straight away, `[ ] <text> (adding…)`, `progress` and the card's total count it, + and the input empties; then `addItem(cardId, text)`. Declined: it goes away, everything goes back, and the detail shows + `item error: <message>`. Accepted: it reads `[ ] <text>` and gets its `toggle`. +8. A rename error or an item error is cleared when a new action of its kind starts or another card is selected. + +### The archive page + +1. A list, `archived`, from `fetchArchived()`: `loading archive` while it loads, `archive error: <message>` if it fails, + then one row per card, `data-testid="archived-card"`, reading `#<id> <title>`, each with a button `restore`. +2. **Restoring.** Clicking `restore` takes the row off the list straight away and calls `restoreCard(id)`. If the server + declines, the row comes back where it was and the page shows `restore error: <message>`. If it accepts, the row stays + off. (The board shows the card when the board page opens again, since it loads again.) diff --git a/eval/tasks/board/fixtures.mjs b/eval/tasks/board/fixtures.mjs new file mode 100644 index 00000000..15bf1b88 --- /dev/null +++ b/eval/tasks/board/fixtures.mjs @@ -0,0 +1,39 @@ +// The board's starting data and the moves the tests share. +export const TODO = [{ id: 1, title: "Write spec", done: 0, total: 2 }, { id: 2, title: "Fix login", done: 1, total: 3 }]; +export const DOING = [{ id: 3, title: "Review PR", done: 2, total: 2 }, { id: 4, title: "Deploy", done: 0, total: 1 }]; +export const DONE = [{ id: 5, title: "Kickoff", done: 3, total: 3 }]; +export const card = c => `#${c.id} ${c.title} · ${c.done}/${c.total}`; +export const TODO_ROWS = TODO.map(card), DOING_ROWS = DOING.map(card), DONE_ROWS = DONE.map(card); +/** a third card in Doing, for the work-in-progress limit */ +export const DOING3 = [...DOING, { id: 6, title: "Write docs", done: 0, total: 0 }]; + +/** the three columns loaded */ +export async function loaded(app, todo = TODO, doing = DOING, done = DONE) { + await app.land("fetchCards:todo", todo); + await app.land("fetchCards:doing", doing); + await app.land("fetchCards:done", done); +} +/** click a move button (left, right, up, down) beside card `id` */ +export async function move(app, id, dir) { await app.clickNear("card", `#${id} `, dir); } + +// The checklist of card 2 ("Fix login", 1/3) +export const L2 = [{ id: 21, text: "repro", done: true }, { id: 22, text: "patch", done: false }, { id: 23, text: "test", done: false }]; +export const L2_ROWS = ["[x] repro", "[ ] patch", "[ ] test"]; +/** the board loaded and card `id` selected */ +export async function selected(app, id = 2) { await loaded(app); await app.clickText("card", `#${id} `); } +/** the board loaded, card 2 selected and its checklist loaded */ +export async function withChecklist(app) { await selected(app, 2); await app.land("fetchChecklist:2", L2); } +/** click the toggle beside the item whose text is `text` */ +export async function toggle(app, text) { await app.clickNear("item", (await app.nearCount("item", `[x] ${text}`, "toggle")) ? `[x] ${text}` : `[ ] ${text}`, "toggle"); } +/** type a title into title-input and click rename */ +export async function rename(app, title) { await app.type("title-input", title); await app.click("rename"); } +/** type a title into new-card and click add-card */ +export async function addCard(app, title) { await app.type("new-card", title); await app.click("add-card"); } +/** type text into item-input and click add-item */ +export async function addItem(app, text) { await app.type("item-input", text); await app.click("add-item"); } + +// The archive +export const ARCHIVED = [{ id: 8, title: "Old idea" }, { id: 9, title: "Spike" }]; +export const ARCHIVED_ROWS = ["#8 Old idea", "#9 Spike"]; +/** open the archive tab and land its list */ +export async function archive(app, list = ARCHIVED) { await app.click("tab-archive"); await app.land("fetchArchived:", list); } diff --git a/eval/tasks/board/hidden-overlap.mjs b/eval/tasks/board/hidden-overlap.mjs new file mode 100644 index 00000000..09c430a0 --- /dev/null +++ b/eval/tasks/board/hidden-overlap.mjs @@ -0,0 +1,297 @@ +// Hidden overlap tests: never shown to the agent. Writes that overlap each other, loads and page lifetimes. Where the +// brief leaves an app free to ask again (a reload after a write), the test answers it if asked (landIfAsked) and +// checks the screen either way. +import { ARCHIVED, ARCHIVED_ROWS, DOING, DOING3, DOING_ROWS, DONE, DONE_ROWS, L2, L2_ROWS, TODO, TODO_ROWS, addCard, addItem, archive, card, loaded, move, rename, selected, toggle, withChecklist } from "./fixtures.mjs"; + +const [W, F] = [card(TODO[0]), card(TODO[1])]; +const [R, D] = [card(DOING[0]), card(DOING[1])]; +const K = card(DONE[0]); +const c = (id, title, done, total) => ({ id, title, done, total }); +/** answer any column reloads the app asked, with the given server state */ +async function reloads(app, todo, doing, done) { + if (todo) await app.landIfAsked("fetchCards:todo", todo); + if (doing) await app.landIfAsked("fetchCards:doing", doing); + if (done) await app.landIfAsked("fetchCards:done", done); +} + +export default [ + // one card moved twice + { name: "two moves of one card answered in order leave it where the second put it", async run(app) { + await loaded(app); + await move(app, 1, "down"); + await move(app, 1, "up"); + await app.colRows("todo", TODO_ROWS); + await app.inAir("moveCard:1,todo,1"); await app.inAir("moveCard:1,todo,0"); + await app.land("moveCard:1,todo,1", { id: 1, column: "todo", index: 1 }); + await app.colRows("todo", TODO_ROWS); + await app.land("moveCard:1,todo,0", { id: 1, column: "todo", index: 0 }); + await reloads(app, TODO); + await app.colRows("todo", TODO_ROWS); + } }, + { name: "a card moved right twice ends in Done", async run(app) { + await loaded(app); + await move(app, 2, "right"); + await move(app, 2, "right"); + await app.colRows("done", [K, F]); await app.colRows("doing", DOING_ROWS); + await app.inAir("moveCard:2,doing,2"); await app.inAir("moveCard:2,done,1"); + await app.land("moveCard:2,doing,2", { id: 2, column: "doing", index: 2 }); + await app.colRows("done", [K, F]); await app.colRows("doing", DOING_ROWS); + await app.land("moveCard:2,done,1", { id: 2, column: "done", index: 1 }); + await reloads(app, [TODO[0]], DOING, [...DONE, TODO[1]]); + await app.colRows("todo", [W]); await app.colRows("doing", DOING_ROWS); await app.colRows("done", [K, F]); + } }, + { name: "an earlier move declined while a later move of the card is out leaves it where the later one put it", async run(app) { + await loaded(app); + await move(app, 2, "right"); + await move(app, 2, "right"); + await app.fail("moveCard:2,doing,2", "nope"); + await app.colRows("done", [K, F]); await app.colRows("doing", DOING_ROWS); await app.colRows("todo", [W]); + await app.land("moveCard:2,done,1", { id: 2, column: "done", index: 1 }); + await reloads(app, [TODO[0]], DOING, [...DONE, TODO[1]]); + await app.colRows("done", [K, F]); await app.colRows("todo", [W]); + } }, + { name: "a later move declined after an earlier one was accepted puts the card where the earlier one put it", async run(app) { + await loaded(app); + await move(app, 2, "right"); + await move(app, 2, "right"); + await app.land("moveCard:2,doing,2", { id: 2, column: "doing", index: 2 }); + await app.fail("moveCard:2,done,1", "frozen"); + await reloads(app, [TODO[0]], [...DOING, TODO[1]], DONE); + await app.colRows("doing", [R, D, F]); await app.colRows("done", DONE_ROWS); await app.colRows("todo", [W]); + await app.see("move-error", "move error: frozen"); + } }, + + // two cards + { name: "one of two moves declined puts back only its card", async run(app) { + await loaded(app); + await move(app, 3, "right"); + await move(app, 4, "right"); + await app.colRows("done", [K, R, D]); await app.colRows("doing", []); + await app.fail("moveCard:3,done,1", "nope"); + await app.colRows("doing", [R]); await app.colRows("done", [K, D]); + await app.eq("head-doing", "Doing (1)"); await app.eq("head-done", "Done (2)"); + } }, + { name: "a move into To do while a card is being added there keeps both", async run(app) { + await loaded(app); + await addCard(app, "Plan sprint"); + await move(app, 3, "left"); + await app.eq("head-todo", "To do (4)"); + await app.land("addCard:todo,Plan sprint", { id: 7, title: "Plan sprint" }); + await app.landOnly("moveCard", { id: 3, column: "todo", index: 3 }); + await reloads(app, [...TODO, c(7, "Plan sprint", 0, 0), DOING[0]], [DOING[1]]); + const shown = (await app.items("card")).filter(t => t.startsWith("#7 ") || t.startsWith("#3 ")); + if (shown.length !== 2) throw new Error(`expected #7 and #3 once each, shown: ${JSON.stringify(shown)}`); + await app.eq("head-todo", "To do (4)"); + } }, + + // loads and reloads + { name: "a column that reloads while a move into it is out keeps the moved card", async run(app) { + await loaded(app); + await move(app, 2, "right"); + await move(app, 3, "down"); + await app.land("moveCard:3,doing,1", { id: 3, column: "doing", index: 1 }); + await reloads(app, TODO, [DOING[1], DOING[0]]); // the server hasn't moved #2 yet + await app.colRows("doing", [D, R, F]); await app.colRows("todo", [W]); + await app.land("moveCard:2,doing,2", { id: 2, column: "doing", index: 2 }); + await reloads(app, [TODO[0]], [DOING[1], DOING[0], TODO[1]]); + await app.colRows("doing", [D, R, F]); await app.colRows("todo", [W]); + } }, + { name: "a move into a column that is still loading shows once it loads, or waits for it", async run(app) { + await app.land("fetchCards:todo", TODO); + await app.land("fetchCards:done", DONE); + const btn = app.near("card", "#2 ", "right"); + if ((await btn.count()) && !(await btn.first().isDisabled())) { + await move(app, 2, "right"); + await app.land("fetchCards:doing", DOING); + await app.colRows("doing", [R, D, F]); await app.colRows("todo", [W]); + await app.eq("head-doing", "Doing (3)"); + } else { + await app.land("fetchCards:doing", DOING); + await app.nearDisabled("card", "#2 ", "right", false); + } + } }, + { name: "an add accepted while To do reloads shows the card once", async run(app) { + await loaded(app); + await addCard(app, "Plan sprint"); + await move(app, 1, "down"); + await app.land("moveCard:1,todo,1", { id: 1, column: "todo", index: 1 }); + await reloads(app, [TODO[1], TODO[0]]); // the server hasn't added it yet + await app.land("addCard:todo,Plan sprint", { id: 7, title: "Plan sprint" }); + await reloads(app, [TODO[1], TODO[0], c(7, "Plan sprint", 0, 0)]); + await app.colRows("todo", [F, W, "#7 Plan sprint · 0/0"]); + } }, + { name: "toggles keep showing while the checklist reloads", async run(app) { + await withChecklist(app); + await toggle(app, "patch"); + await toggle(app, "test"); + await app.land("toggleItem:2,23,true", { id: 23, done: true }); + await app.landIfAsked("fetchChecklist:2", [L2[0], L2[1], { ...L2[2], done: true }]); // the server hasn't taken the first yet + await reloads(app, [TODO[0], { ...TODO[1], done: 2 }]); + await app.rows("item", ["[x] repro", "[x] patch", "[x] test"]); + await app.eq("progress", "3/3 done"); + await app.colRows("todo", [W, "#2 Fix login · 3/3"]); + } }, + { name: "an item added while the checklist reloads shows once", async run(app) { + await withChecklist(app); + await addItem(app, "ship"); + await toggle(app, "patch"); + await app.land("toggleItem:2,22,true", { id: 22, done: true }); + await app.landIfAsked("fetchChecklist:2", [L2[0], { ...L2[1], done: true }, L2[2]]); + await app.land("addItem:2,ship", { id: 24, text: "ship", done: false }); + await app.landIfAsked("fetchChecklist:2", [L2[0], { ...L2[1], done: true }, L2[2], { id: 24, text: "ship", done: false }]); + await reloads(app, [TODO[0], { ...TODO[1], done: 2, total: 4 }]); + await app.rows("item", ["[x] repro", "[x] patch", "[ ] test", "[ ] ship"]); + await app.eq("progress", "2/4 done"); + await app.colRows("todo", [W, "#2 Fix login · 2/4"]); + } }, + + // counts that must agree + { name: "a toggle and an item add out together: the progress and the card's count agree", async run(app) { + await withChecklist(app); + await toggle(app, "patch"); + await addItem(app, "ship"); + await app.eq("progress", "2/4 done"); + await app.colRows("todo", [W, "#2 Fix login · 2/4"]); + await app.fail("toggleItem:2,22,true", "read only"); + await app.eq("progress", "1/4 done"); + await app.colRows("todo", [W, "#2 Fix login · 1/4"]); + } }, + { name: "a toggle accepted after another card was selected keeps the first card's count", async run(app) { + await withChecklist(app); + await toggle(app, "patch"); + await app.clickText("card", "#1 "); + await app.land("toggleItem:2,22,true", { id: 22, done: true }); + await reloads(app, [TODO[0], { ...TODO[1], done: 2 }]); + await app.colRows("todo", [W, "#2 Fix login · 2/3"]); + } }, + { name: "a toggle declined after another card was selected puts the first card's count back", async run(app) { + await withChecklist(app); + await toggle(app, "patch"); + await app.clickText("card", "#1 "); + await app.fail("toggleItem:2,22,true", "read only"); + await app.colRows("todo", TODO_ROWS); + } }, + + // renames and archives + { name: "a rename and a move out together, the move declined: the card goes back with its new title", async run(app) { + await selected(app, 2); + await rename(app, "Fix signup"); + await move(app, 2, "right"); + await app.fail("moveCard:2,doing,2", "nope"); + await app.colRows("todo", [W, "#2 Fix signup · 1/3"]); + await app.see("detail", "#2 Fix signup", "in To do"); + } }, + { name: "two renames, the later declined after the earlier was accepted: the earlier title stays", async run(app) { + await selected(app, 2); + await rename(app, "Fix signup"); + await rename(app, "Fix auth"); + await app.see("detail", "#2 Fix auth"); + await app.land("renameCard:2,Fix signup", { id: 2, title: "Fix signup" }); + await app.fail("renameCard:2,Fix auth", "taken"); + await reloads(app, [TODO[0], { ...TODO[1], title: "Fix signup" }]); + await app.colRows("todo", [W, "#2 Fix signup · 1/3"]); + await app.see("detail", "#2 Fix signup", "rename error: taken"); + } }, + { name: "archiving a card whose rename is out keeps it off the board when the rename lands", async run(app) { + await selected(app, 2); + await rename(app, "Fix signup"); + await app.click("archive"); + await app.see("detail", "select a card"); + await app.land("renameCard:2,Fix signup", { id: 2, title: "Fix signup" }); + await reloads(app, [TODO[0]]); + await app.colRows("todo", [W]); + await app.see("detail", "select a card"); + await app.land("archiveCard:2", { id: 2 }); + await reloads(app, [TODO[0]]); + await app.colRows("todo", [W]); + } }, + { name: "a declined archive of a card whose rename is out brings it back with the new title", async run(app) { + await selected(app, 2); + await rename(app, "Fix signup"); + await app.click("archive"); + await app.fail("archiveCard:2", "in use"); + await app.colRows("todo", [W, "#2 Fix signup · 1/3"]); + await app.land("renameCard:2,Fix signup", { id: 2, title: "Fix signup" }); + await reloads(app, [TODO[0], { ...TODO[1], title: "Fix signup" }]); + await app.colRows("todo", [W, "#2 Fix signup · 1/3"]); + } }, + + // the work-in-progress limit + { name: "a declined move out of a full Doing disables To do's right again", async run(app) { + await loaded(app, TODO, DOING3); + await move(app, 6, "right"); + await app.nearDisabled("card", "#1 ", "right", false); + await app.fail("moveCard:6,done,1", "nope"); + await app.nearDisabled("card", "#1 ", "right"); + } }, + { name: "a move into Doing that fills it while a move out is pending disables the rest", async run(app) { + await loaded(app, TODO, DOING3); + await move(app, 6, "right"); + await move(app, 2, "right"); + await app.nearDisabled("card", "#1 ", "right"); + await app.fail("moveCard:6,done,1", "nope"); + const doing = (await app.items("card")).filter(t => ["#3 ", "#4 ", "#6 ", "#2 "].some(p => t.startsWith(p))); // #6 back and #2 in, in either order + if (doing.length !== 4) throw new Error(`expected #3, #4, #6 and #2 in Doing, shown: ${JSON.stringify(doing)}`); + await app.eq("head-doing", "Doing (4)"); + await app.nearDisabled("card", "#1 ", "right"); + } }, + + // page lifetime + { name: "a move accepted after the board was left shows nothing extra on the next visit", async run(app) { + await loaded(app); + await move(app, 2, "right"); + await app.click("tab-archive"); + await app.land("moveCard:2,doing,2", { id: 2, column: "doing", index: 2 }); + await app.click("tab-board"); + await loaded(app, [TODO[0]], [...DOING, TODO[1]], DONE); + await app.colRows("todo", [W]); await app.colRows("doing", [R, D, F]); + } }, + { name: "a move declined after the board was left shows no move error on the next visit", async run(app) { + await loaded(app); + await move(app, 2, "right"); + await app.click("tab-archive"); + await app.click("tab-board"); + await app.fail("moveCard:2,doing,2", "nope"); + await loaded(app); + await app.colRows("todo", TODO_ROWS); await app.colRows("doing", DOING_ROWS); + await app.notSeePage("move error"); + } }, + { name: "an add accepted after the board was left does not show twice on the next visit", async run(app) { + await loaded(app); + await addCard(app, "Plan sprint"); + await app.click("tab-archive"); + await app.click("tab-board"); + await app.land("addCard:todo,Plan sprint", { id: 7, title: "Plan sprint" }); + await loaded(app, [...TODO, c(7, "Plan sprint", 0, 0)]); + await app.colRows("todo", [...TODO_ROWS, "#7 Plan sprint · 0/0"]); + } }, + { name: "an archive declined after the board was left shows no archive error on the next visit", async run(app) { + await selected(app, 2); + await app.click("archive"); + await app.click("tab-archive"); + await app.click("tab-board"); + await app.fail("archiveCard:2", "in use"); + await loaded(app); + await app.colRows("todo", TODO_ROWS); + await app.notSeePage("archive error"); + } }, + { name: "a restore declined after the archive was left shows no restore error on the next visit", async run(app) { + await archive(app); + await app.clickNear("archived-card", "#8 ", "restore"); + await app.click("tab-board"); + await app.click("tab-archive"); + await app.fail("restoreCard:8", "no room"); + await app.land("fetchArchived:", ARCHIVED); + await app.rows("archived-card", ARCHIVED_ROWS); + await app.notSeePage("restore error"); + } }, + { name: "a restore declined after another was accepted brings back only its row", async run(app) { + await archive(app); + await app.clickNear("archived-card", "#8 ", "restore"); + await app.clickNear("archived-card", "#9 ", "restore"); + await app.land("restoreCard:8", { id: 8 }); + await app.fail("restoreCard:9", "no room"); + await app.landIfAsked("fetchArchived:", [ARCHIVED[1]]); + await app.rows("archived-card", ["#9 Spike"]); + } }, +]; diff --git a/eval/tasks/board/hidden.mjs b/eval/tasks/board/hidden.mjs new file mode 100644 index 00000000..1c98ca6e --- /dev/null +++ b/eval/tasks/board/hidden.mjs @@ -0,0 +1,329 @@ +// Hidden tests: never shown to the agent. Each follows a line of the brief; none names a race the brief doesn't imply. +import { ARCHIVED, ARCHIVED_ROWS, DOING, DOING3, DOING_ROWS, DONE, DONE_ROWS, L2, L2_ROWS, TODO, TODO_ROWS, addCard, addItem, archive, card, loaded, move, rename, selected, toggle, withChecklist } from "./fixtures.mjs"; + +const [W, F] = [card(TODO[0]), card(TODO[1])]; // "#1 Write spec · 0/2", "#2 Fix login · 1/3" +const [R, D] = [card(DOING[0]), card(DOING[1])]; // "#3 Review PR · 2/2", "#4 Deploy · 0/1" +const K = card(DONE[0]); // "#5 Kickoff · 3/3" + +export default [ + // tabs + { name: "the app opens on the board page with both tabs on screen", async run(app) { + if (!(await app.exists("tab-board")) || !(await app.exists("tab-archive"))) throw new Error("both tabs should be on screen"); + if (await app.exists("archived")) throw new Error("the archive page is on screen"); + await app.inAir("fetchCards:todo"); await app.inAir("fetchCards:doing"); await app.inAir("fetchCards:done"); + } }, + { name: "the archive page loads again each time it opens", async run(app) { + await archive(app); + await app.click("tab-board"); + await app.click("tab-archive"); + await app.see("archived", "loading archive"); + await app.land("fetchArchived:", ARCHIVED); + await app.rows("archived-card", ARCHIVED_ROWS); + } }, + { name: "the new-card input starts empty when the board opens again", async run(app) { + await loaded(app); + await app.type("new-card", "half typed"); + await app.click("tab-archive"); + await app.click("tab-board"); + await app.hasValue("new-card", ""); + } }, + { name: "an archive that fails shows its error", async run(app) { + await app.click("tab-archive"); + await app.fail("fetchArchived:", "offline"); + await app.see("archived", "archive error: offline"); + } }, + + // columns + { name: "a heading counts only once its column has loaded", async run(app) { + await app.land("fetchCards:doing", DOING); + await app.eq("head-doing", "Doing (2)"); + await app.eq("head-todo", "To do"); + await app.see("col-todo", "loading"); + } }, + { name: "a column's retry shows loading, then the cards", async run(app) { + await app.fail("fetchCards:done", "offline"); + await app.click("retry-done"); + await app.see("col-done", "loading"); + await app.eq("head-done", "Done"); + await app.land("fetchCards:done", DONE); + await app.colRows("done", DONE_ROWS); + await app.eq("head-done", "Done (1)"); + } }, + { name: "an empty column reads (0)", async run(app) { + await loaded(app, TODO, DOING, []); + await app.eq("head-done", "Done (0)"); + await app.colRows("done", []); + } }, + + // moving + { name: "left moves a Doing card to the bottom of To do", async run(app) { + await loaded(app); + await move(app, 3, "left"); + await app.colRows("todo", [W, F, R]); await app.colRows("doing", [D]); + await app.inAir("moveCard:3,todo,2"); + } }, + { name: "right moves a Doing card to the bottom of Done", async run(app) { + await loaded(app); + await move(app, 4, "right"); + await app.colRows("done", [K, D]); + await app.inAir("moveCard:4,done,1"); + } }, + { name: "down swaps a card with the one below it", async run(app) { + await loaded(app); + await move(app, 3, "down"); + await app.colRows("doing", [D, R]); + await app.inAir("moveCard:3,doing,1"); + } }, + { name: "a declined reorder puts the cards back in their order", async run(app) { + await loaded(app); + await move(app, 1, "down"); + await app.fail("moveCard:1,todo,1", "nope"); + await app.colRows("todo", TODO_ROWS); + await app.see("move-error", "move error: nope"); + } }, + { name: "a declined move from Done puts the card back in Done", async run(app) { + await loaded(app); + await move(app, 5, "left"); + await app.colRows("doing", [R, D, K]); + await app.fail("moveCard:5,doing,2", "frozen"); + await app.colRows("done", DONE_ROWS); await app.colRows("doing", DOING_ROWS); + await app.eq("head-done", "Done (1)"); await app.eq("head-doing", "Doing (2)"); + } }, + { name: "a moved card's buttons follow its new place", async run(app) { + await loaded(app); + await move(app, 2, "right"); + if (!(await app.nearCount("card", "#2 ", "left")) || !(await app.nearCount("card", "#2 ", "up"))) throw new Error("#2 in Doing should have left and up"); + await app.noNear("card", "#2 ", "down"); + await app.noNear("card", "#1 ", "down"); + } }, + { name: "the move error is cleared when the next move starts", async run(app) { + await loaded(app); + await move(app, 1, "down"); + await app.fail("moveCard:1,todo,1", "nope"); + await app.see("move-error", "move error: nope"); + await move(app, 3, "down"); + await app.notSee("move-error", "nope"); + } }, + { name: "a move error stays when another move is accepted", async run(app) { + await loaded(app); + await move(app, 1, "down"); + await move(app, 3, "down"); + await app.fail("moveCard:1,todo,1", "nope"); + await app.land("moveCard:3,doing,1", { id: 3, column: "doing", index: 1 }); + await app.see("move-error", "move error: nope"); + await app.colRows("todo", TODO_ROWS); await app.colRows("doing", [D, R]); + } }, + + // the work-in-progress limit + { name: "a move into Doing that brings it to three disables To do's right at once", async run(app) { + await loaded(app); + await move(app, 2, "right"); + await app.nearDisabled("card", "#1 ", "right"); + await app.nearDisabled("card", "#5 ", "left"); + } }, + { name: "a declined move into Doing enables the right buttons again", async run(app) { + await loaded(app); + await move(app, 2, "right"); + await app.fail("moveCard:2,doing,2", "nope"); + await app.nearDisabled("card", "#1 ", "right", false); + await app.nearDisabled("card", "#2 ", "right", false); + } }, + { name: "a move out of a full Doing enables the right buttons at once", async run(app) { + await loaded(app, TODO, DOING3); + await move(app, 6, "right"); + await app.nearDisabled("card", "#1 ", "right", false); + await app.nearDisabled("card", "#5 ", "left", false); + } }, + { name: "Doing's own buttons are not limited", async run(app) { + await loaded(app, TODO, DOING3); + await app.nearDisabled("card", "#3 ", "down", false); + await app.nearDisabled("card", "#4 ", "left", false); + } }, + + // adding + { name: "add-card with an empty input adds nothing", async run(app) { + await loaded(app); + await app.click("add-card"); + await app.colRows("todo", TODO_ROWS); + await app.noneInAir("addCard"); + } }, + { name: "a card being added counts in the heading", async run(app) { + await loaded(app); + await addCard(app, "Plan sprint"); + await app.eq("head-todo", "To do (3)"); + await app.fail("addCard:todo,Plan sprint", "no"); + await app.eq("head-todo", "To do (2)"); + } }, + { name: "the add error is cleared when the next add starts", async run(app) { + await loaded(app); + await addCard(app, "Plan sprint"); + await app.fail("addCard:todo,Plan sprint", "too many cards"); + await addCard(app, "Retro"); + await app.notSeePage("add error: too many cards"); + } }, + { name: "two adds show both, each taking its own id", async run(app) { + await loaded(app); + await addCard(app, "Plan sprint"); + await addCard(app, "Retro"); + await app.colRows("todo", [...TODO_ROWS, "Plan sprint (adding…)", "Retro (adding…)"]); + await app.land("addCard:todo,Retro", { id: 8, title: "Retro" }); + await app.colRows("todo", [...TODO_ROWS, "Plan sprint (adding…)", "#8 Retro · 0/0"]); + await app.land("addCard:todo,Plan sprint", { id: 7, title: "Plan sprint" }); + await app.colRows("todo", [...TODO_ROWS, "#7 Plan sprint · 0/0", "#8 Retro · 0/0"]); + } }, + { name: "a card being added has no move buttons", async run(app) { + await loaded(app); + await addCard(app, "Plan sprint"); + for (const b of ["left", "right", "up", "down"]) await app.noNear("card", "Plan sprint", b); + } }, + { name: "an added card can be moved once it has its id", async run(app) { + await loaded(app); + await addCard(app, "Plan sprint"); + await app.land("addCard:todo,Plan sprint", { id: 7, title: "Plan sprint" }); + await move(app, 7, "up"); + await app.colRows("todo", [W, "#7 Plan sprint · 0/0", F]); + await app.inAir("moveCard:7,todo,1"); + } }, + + // the detail + { name: "the detail names the column of a card in Done", async run(app) { + await selected(app, 5); + await app.see("detail", "#5 Kickoff", "in Done"); + } }, + { name: "a declined move puts the detail's column back", async run(app) { + await selected(app, 2); + await move(app, 2, "right"); + await app.fail("moveCard:2,doing,2", "nope"); + await app.see("detail", "in To do"); + } }, + { name: "selecting another card shows its detail and empties title-input", async run(app) { + await selected(app, 2); + await app.type("title-input", "half typed"); + await app.clickText("card", "#3 "); + await app.see("detail", "#3 Review PR", "in Doing"); + await app.hasValue("title-input", ""); + } }, + { name: "rename with an empty title-input asks nothing", async run(app) { + await selected(app, 2); + await app.click("rename"); + await app.noneInAir("renameCard"); + } }, + { name: "the rename error is cleared when another card is selected", async run(app) { + await selected(app, 2); + await rename(app, "Fix signup"); + await app.fail("renameCard:2,Fix signup", "taken"); + await app.clickText("card", "#3 "); + await app.notSee("detail", "rename error"); + } }, + { name: "the rename error is cleared when the next rename starts", async run(app) { + await selected(app, 2); + await rename(app, "Fix signup"); + await app.fail("renameCard:2,Fix signup", "taken"); + await rename(app, "Fix auth"); + await app.notSee("detail", "rename error: taken"); + } }, + { name: "a renamed card keeps its new title when it moves", async run(app) { + await selected(app, 2); + await rename(app, "Fix signup"); + await move(app, 2, "right"); + await app.colRows("doing", [R, D, "#2 Fix signup · 1/3"]); + await app.land("renameCard:2,Fix signup", { id: 2, title: "Fix signup" }); + await app.land("moveCard:2,doing,2", { id: 2, column: "doing", index: 2 }); + await app.colRows("doing", [R, D, "#2 Fix signup · 1/3"]); + } }, + { name: "archiving a card counts it out of its heading at once", async run(app) { + await selected(app, 3); + await app.click("archive"); + await app.eq("head-doing", "Doing (1)"); + } }, + { name: "a declined archive brings the card back in its place", async run(app) { + await selected(app, 3); + await app.click("archive"); + await app.fail("archiveCard:3", "in use"); + await app.colRows("doing", DOING_ROWS); + await app.eq("head-doing", "Doing (2)"); + } }, + { name: "archiving a card from a full Doing enables To do's right at once", async run(app) { + await loaded(app, TODO, DOING3); + await app.clickText("card", "#6 "); + await app.click("archive"); + await app.nearDisabled("card", "#1 ", "right", false); + } }, + + // the checklist + { name: "an empty checklist reads 0/0 done", async run(app) { + await selected(app, 5); + await app.land("fetchChecklist:5", []); + await app.rows("item", []); + await app.eq("progress", "0/0 done"); + } }, + { name: "toggling a done item un-does it", async run(app) { + await withChecklist(app); + await toggle(app, "repro"); + await app.rows("item", ["[ ] repro", "[ ] patch", "[ ] test"]); + await app.eq("progress", "0/3 done"); + await app.colRows("todo", [W, "#2 Fix login · 0/3"]); + await app.inAir("toggleItem:2,21,false"); + } }, + { name: "an accepted toggle keeps the item, the progress and the card's count", async run(app) { + await withChecklist(app); + await toggle(app, "patch"); + await app.land("toggleItem:2,22,true", { id: 22, done: true }); + await app.rows("item", ["[x] repro", "[x] patch", "[ ] test"]); + await app.eq("progress", "2/3 done"); + await app.colRows("todo", [W, "#2 Fix login · 2/3"]); + } }, + { name: "selecting another card loads its checklist", async run(app) { + await withChecklist(app); + await app.clickText("card", "#1 "); + await app.see("checklist", "loading checklist"); + await app.land("fetchChecklist:1", [{ id: 11, text: "outline", done: false }, { id: 12, text: "draft", done: false }]); + await app.rows("item", ["[ ] outline", "[ ] draft"]); + await app.eq("progress", "0/2 done"); + } }, + { name: "the item error is cleared when another card is selected", async run(app) { + await withChecklist(app); + await toggle(app, "patch"); + await app.fail("toggleItem:2,22,true", "read only"); + await app.clickText("card", "#1 "); + await app.notSee("detail", "item error"); + } }, + { name: "the item error is cleared when the next item action starts", async run(app) { + await withChecklist(app); + await toggle(app, "patch"); + await app.fail("toggleItem:2,22,true", "read only"); + await addItem(app, "ship"); + await app.notSee("detail", "item error: read only"); + } }, + { name: "add-item with an empty input adds nothing", async run(app) { + await withChecklist(app); + await app.click("add-item"); + await app.rows("item", L2_ROWS); + await app.noneInAir("addItem"); + } }, + { name: "a declined item add puts the card's count back", async run(app) { + await withChecklist(app); + await addItem(app, "ship"); + await app.fail("addItem:2,ship", "full"); + await app.colRows("todo", TODO_ROWS); + } }, + { name: "an added item can be toggled once it has its id", async run(app) { + await withChecklist(app); + await addItem(app, "ship"); + await app.land("addItem:2,ship", { id: 24, text: "ship", done: false }); + await toggle(app, "ship"); + await app.inAir("toggleItem:2,24,true"); + await app.eq("progress", "2/4 done"); + await app.colRows("todo", [W, "#2 Fix login · 2/4"]); + } }, + + // the archive page + { name: "an accepted restore keeps the row off, and the board shows the card when it opens again", async run(app) { + await archive(app); + await app.clickNear("archived-card", "#8 ", "restore"); + await app.land("restoreCard:8", { id: 8 }); + await app.click("tab-board"); + await loaded(app, [...TODO, { id: 8, title: "Old idea", done: 0, total: 0 }]); + await app.colRows("todo", [...TODO_ROWS, "#8 Old idea · 0/0"]); + } }, +]; diff --git a/eval/tasks/board/visible.mjs b/eval/tasks/board/visible.mjs new file mode 100644 index 00000000..206fda71 --- /dev/null +++ b/eval/tasks/board/visible.mjs @@ -0,0 +1,216 @@ +// Visible tests: the agent sees and runs these. +import { ARCHIVED, ARCHIVED_ROWS, DOING, DOING3, DOING_ROWS, DONE_ROWS, L2, L2_ROWS, TODO, TODO_ROWS, addCard, addItem, archive, card, loaded, move, rename, selected, toggle, withChecklist } from "./fixtures.mjs"; + +export default [ + // tabs and columns + { name: "on load: the board page shows three loading columns, then their cards", async run(app) { + await app.eq("head-todo", "To do"); await app.eq("head-doing", "Doing"); await app.eq("head-done", "Done"); + await app.see("col-todo", "loading"); await app.see("col-doing", "loading"); await app.see("col-done", "loading"); + await app.see("detail", "select a card"); + await loaded(app); + await app.colRows("todo", TODO_ROWS); await app.colRows("doing", DOING_ROWS); await app.colRows("done", DONE_ROWS); + await app.eq("head-todo", "To do (2)"); await app.eq("head-doing", "Doing (2)"); await app.eq("head-done", "Done (1)"); + } }, + { name: "a column that fails shows its error, and retry asks again", async run(app) { + await app.land("fetchCards:todo", TODO); + await app.fail("fetchCards:doing", "offline"); + await app.see("col-doing", "error: offline"); + await app.eq("head-doing", "Doing"); + await app.click("retry-doing"); + await app.see("col-doing", "loading"); + await app.land("fetchCards:doing", DOING); + await app.colRows("doing", DOING_ROWS); + } }, + { name: "the archive tab shows the archive page, and the board tab the board again", async run(app) { + await loaded(app); + await archive(app); + await app.rows("archived-card", ARCHIVED_ROWS); + if (await app.exists("col-todo")) throw new Error("the board is still on screen"); + await app.click("tab-board"); + await app.see("col-todo", "loading"); + } }, + + // moving + { name: "right moves a card to the bottom of the next column at once", async run(app) { + await loaded(app); + await move(app, 2, "right"); + await app.colRows("todo", [card(TODO[0])]); + await app.colRows("doing", [...DOING_ROWS, card(TODO[1])]); + await app.eq("head-todo", "To do (1)"); await app.eq("head-doing", "Doing (3)"); + await app.inAir("moveCard:2,doing,2"); + } }, + { name: "an accepted move keeps the card where it was moved", async run(app) { + await loaded(app); + await move(app, 2, "right"); + await app.land("moveCard:2,doing,2", { id: 2, column: "doing", index: 2 }); + await app.colRows("doing", [...DOING_ROWS, card(TODO[1])]); + } }, + { name: "a declined move puts the card back and shows the move error", async run(app) { + await loaded(app); + await move(app, 2, "right"); + await app.fail("moveCard:2,doing,2", "column locked"); + await app.colRows("todo", TODO_ROWS); await app.colRows("doing", DOING_ROWS); + await app.eq("head-doing", "Doing (2)"); + await app.see("move-error", "move error: column locked"); + } }, + { name: "up swaps a card with the one above it", async run(app) { + await loaded(app); + await move(app, 2, "up"); + await app.colRows("todo", [card(TODO[1]), card(TODO[0])]); + await app.inAir("moveCard:2,todo,0"); + } }, + { name: "a move button with nowhere to go is not shown", async run(app) { + await loaded(app); + await app.noNear("card", "#1 ", "left"); await app.noNear("card", "#1 ", "up"); + await app.noNear("card", "#2 ", "down"); + await app.noNear("card", "#5 ", "right"); + if (!(await app.nearCount("card", "#1 ", "right")) || !(await app.nearCount("card", "#1 ", "down"))) throw new Error("#1 should have right and down"); + } }, + { name: "with three cards in Doing, To do's right and Done's left are disabled", async run(app) { + await loaded(app, TODO, DOING3); + await app.nearDisabled("card", "#1 ", "right"); await app.nearDisabled("card", "#2 ", "right"); + await app.nearDisabled("card", "#5 ", "left"); + await app.nearDisabled("card", "#3 ", "right", false); + } }, + + // adding + { name: "adding a card shows it at the bottom of To do at once, then its id", async run(app) { + await loaded(app); + await addCard(app, "Plan sprint"); + await app.colRows("todo", [...TODO_ROWS, "Plan sprint (adding…)"]); + await app.hasValue("new-card", ""); + await app.inAir("addCard:todo,Plan sprint"); + await app.land("addCard:todo,Plan sprint", { id: 7, title: "Plan sprint" }); + await app.colRows("todo", [...TODO_ROWS, "#7 Plan sprint · 0/0"]); + if (!(await app.nearCount("card", "#7 ", "right"))) throw new Error("the added card has no move buttons"); + } }, + { name: "a declined add takes the card away and shows the add error", async run(app) { + await loaded(app); + await addCard(app, "Plan sprint"); + await app.fail("addCard:todo,Plan sprint", "too many cards"); + await app.colRows("todo", TODO_ROWS); + await app.see("add-error", "add error: too many cards"); + } }, + + // the detail + { name: "selecting a card shows it in the detail with its column", async run(app) { + await selected(app, 2); + await app.see("detail", "#2 Fix login", "in To do"); + await app.hasValue("title-input", ""); + await app.see("checklist", "loading checklist"); + } }, + { name: "the detail's column follows a move", async run(app) { + await selected(app, 2); + await move(app, 2, "right"); + await app.see("detail", "in Doing"); + } }, + { name: "renaming shows the new title on the card and in the detail at once", async run(app) { + await selected(app, 2); + await rename(app, "Fix signup"); + await app.colRows("todo", [card(TODO[0]), "#2 Fix signup · 1/3"]); + await app.see("detail", "#2 Fix signup"); + await app.inAir("renameCard:2,Fix signup"); + await app.land("renameCard:2,Fix signup", { id: 2, title: "Fix signup" }); + await app.see("detail", "#2 Fix signup"); + } }, + { name: "a declined rename puts the title back and shows the rename error", async run(app) { + await selected(app, 2); + await rename(app, "Fix signup"); + await app.fail("renameCard:2,Fix signup", "taken"); + await app.colRows("todo", TODO_ROWS); + await app.see("detail", "#2 Fix login", "rename error: taken"); + } }, + { name: "archiving takes the card off the board and clears the selection", async run(app) { + await selected(app, 2); + await app.click("archive"); + await app.colRows("todo", [card(TODO[0])]); + await app.see("detail", "select a card"); + await app.inAir("archiveCard:2"); + await app.land("archiveCard:2", { id: 2 }); + await app.colRows("todo", [card(TODO[0])]); + } }, + { name: "a declined archive brings the card back, not selected, with the archive error", async run(app) { + await selected(app, 2); + await app.click("archive"); + await app.fail("archiveCard:2", "in use"); + await app.colRows("todo", TODO_ROWS); + await app.see("detail", "select a card"); + await app.see("archive-error", "archive error: in use"); + } }, + + // the checklist + { name: "the checklist loads with its progress", async run(app) { + await withChecklist(app); + await app.rows("item", L2_ROWS); + await app.eq("progress", "1/3 done"); + } }, + { name: "a checklist that fails shows its error", async run(app) { + await selected(app, 2); + await app.fail("fetchChecklist:2", "gone"); + await app.see("checklist", "checklist error: gone"); + } }, + { name: "toggling an item flips it, the progress and the card's count at once", async run(app) { + await withChecklist(app); + await toggle(app, "patch"); + await app.rows("item", ["[x] repro", "[x] patch", "[ ] test"]); + await app.eq("progress", "2/3 done"); + await app.colRows("todo", [card(TODO[0]), "#2 Fix login · 2/3"]); + await app.inAir("toggleItem:2,22,true"); + } }, + { name: "a declined toggle puts all three back and shows the item error", async run(app) { + await withChecklist(app); + await toggle(app, "patch"); + await app.fail("toggleItem:2,22,true", "read only"); + await app.rows("item", L2_ROWS); + await app.eq("progress", "1/3 done"); + await app.colRows("todo", TODO_ROWS); + await app.see("detail", "item error: read only"); + } }, + { name: "adding an item shows it at once, counted, then with its toggle", async run(app) { + await withChecklist(app); + await addItem(app, "ship"); + await app.rows("item", [...L2_ROWS, "[ ] ship (adding…)"]); + await app.eq("progress", "1/4 done"); + await app.colRows("todo", [card(TODO[0]), "#2 Fix login · 1/4"]); + await app.hasValue("item-input", ""); + await app.land("addItem:2,ship", { id: 24, text: "ship", done: false }); + await app.rows("item", [...L2_ROWS, "[ ] ship"]); + if (!(await app.nearCount("item", "[ ] ship", "toggle"))) throw new Error("the added item has no toggle"); + } }, + { name: "a declined item add takes it away and shows the item error", async run(app) { + await withChecklist(app); + await addItem(app, "ship"); + await app.fail("addItem:2,ship", "full"); + await app.rows("item", L2_ROWS); + await app.eq("progress", "1/3 done"); + await app.see("detail", "item error: full"); + } }, + + // the archive page + { name: "the archive loads, and restore takes a row off at once", async run(app) { + await app.click("tab-archive"); + await app.see("archived", "loading archive"); + await app.land("fetchArchived:", ARCHIVED); + await app.rows("archived-card", ARCHIVED_ROWS); + await app.clickNear("archived-card", "#8 ", "restore"); + await app.rows("archived-card", ["#9 Spike"]); + await app.inAir("restoreCard:8"); + await app.land("restoreCard:8", { id: 8 }); + await app.rows("archived-card", ["#9 Spike"]); + } }, + { name: "a declined restore brings the row back with the restore error", async run(app) { + await archive(app); + await app.clickNear("archived-card", "#8 ", "restore"); + await app.fail("restoreCard:8", "no room"); + await app.rows("archived-card", ARCHIVED_ROWS); + await app.seePage("restore error: no room"); + } }, + { name: "opening the board again loads it again with nothing selected", async run(app) { + await selected(app, 2); + await archive(app); + await app.click("tab-board"); + await app.see("detail", "select a card"); + await loaded(app); + await app.colRows("todo", TODO_ROWS); + } }, +]; From 6ba2af8479ca09f54122459a9add52e900a16ea5 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 14 Nov 2026 12:03:55 +0000 Subject: [PATCH 09/32] eval: board .rx reference (grades 0) + plain-Solid repro of stale label under a pending guess Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- eval/reference/board/rx/GRADE.txt | 6 + eval/reference/board/rx/archive.rx | 44 +++ eval/reference/board/rx/board.rx | 321 ++++++++++++++++++ eval/reference/board/rx/column.rx | 50 +++ eval/reference/board/rx/lay.rx | 96 ++++++ eval/reference/board/rx/main.rx | 21 ++ .../repro/stale-label-under-guess.spec.tsx | 46 +++ 7 files changed, 584 insertions(+) create mode 100644 eval/reference/board/rx/GRADE.txt create mode 100644 eval/reference/board/rx/archive.rx create mode 100644 eval/reference/board/rx/board.rx create mode 100644 eval/reference/board/rx/column.rx create mode 100644 eval/reference/board/rx/lay.rx create mode 100644 eval/reference/board/rx/main.rx create mode 100644 eval/results/r15prep/repro/stale-label-under-guess.spec.tsx diff --git a/eval/reference/board/rx/GRADE.txt b/eval/reference/board/rx/GRADE.txt new file mode 100644 index 00000000..ed3f10aa --- /dev/null +++ b/eval/reference/board/rx/GRADE.txt @@ -0,0 +1,6 @@ +== hidden +45/45 passed +== overlap +26/26 passed +== fuzz +board-rx: 0/20 seeds found a hole diff --git a/eval/reference/board/rx/archive.rx b/eval/reference/board/rx/archive.rx new file mode 100644 index 00000000..2e0a8d23 --- /dev/null +++ b/eval/reference/board/rx/archive.rx @@ -0,0 +1,44 @@ +// The archive page: the archived cards, and restoring them. Restores in flight are a guess list laid over the +// server's answer; an accepted one ends with the list asked again. + +import pure { fetchArchived } from "./api" +import { restoreCard } from "./api" + +export component ArchivePage() { + archived = await fetchArchived() + source restoring: number[] = [] + shown = archived.filter(a => !restoring.includes(a.id)) + source restoreError: string := "" + + action restore(id: number) { + onFlush(() => { restoreError := "" }) + try { + guess restoring := xs => [...xs, id] + await restoreCard(id) + refresh archived + } catch (e) { + restoreError := e.message + onFlush(() => { restoreError := e.message }) + } + } + + <section> + <div data-testid="archived"> + {loading fallback { <p>loading archive</p> } { + {errored fallback(e) { <p>archive error: {e.message}</p> } { + <ul> + {for a in shown { + <li> + <span data-testid="archived-card">#{a.id} {a.title}</span>{" "} + <button data-testid="restore" onClick={() => { restore(a.id) }}>restore</button> + </li> + }} + </ul> + }} + }} + </div> + {if restoreError != "" { + <p data-testid="restore-error">restore error: {restoreError}</p> + }} + </section> +} diff --git a/eval/reference/board/rx/board.rx b/eval/reference/board/rx/board.rx new file mode 100644 index 00000000..f6189583 --- /dev/null +++ b/eval/reference/board/rx/board.rx @@ -0,0 +1,321 @@ +// The board page. Everything here lives as long as the page: its loads, the writes made on it, its inputs and errors, +// so leaving the page discards them and a write that answers after that changes nothing on the next visit. +// +// Two layers: each column's answer (asked once, when the page opens; again only by its retry), and this visit's +// writes laid over them (lay.rx). The writes are the client's own records, not guesses: a guess on the board lives +// until its action ends, and an action that asks a column again holds every reader of the board with it (the detail, +// a new selection) until that answer lands. So each write lands its record at once (`onFlush`) and, when the server +// answers, lands its outcome the same way; nothing waits for anything but its own answer. + +import pure { fetchCards, fetchChecklist } from "./api" +import { moveCard, renameCard, addCard, archiveCard, toggleItem, addItem } from "./api" +import { CardAdd, Move, Rename, Archive, Toggle, ItemAdd, Known, Placed, answered, once, addedCards, shownIn, placed, bottomRank, cardCount, layItems, knownItems, knowAnswer, knowToggle, knowItem } from "./lay.rx" +import { Column } from "./column.rx" + +fn colName(c: string): string => c == "todo" ? "To do" : c == "doing" ? "Doing" : "Done" +fn findIn(rows: Placed[], id: number | null): Placed | null => id == null ? null : rows.find(r => !r.adding && r.id == id) ?? null +fn errFor(e: { card: number; message: string } | null, id: number): string => e == null || e.card != id ? "" : e.message + +export component BoardPage() { + // the server's answers; each column shows `loading` until its first one (null) + todoAns = await fetchCards("todo") -> null + doingAns = await fetchCards("doing") -> null + doneAns = await fetchCards("done") -> null + todoLoaded = (todoAns catch (e) => null) != null + doingLoaded = (doingAns catch (e) => null) != null + doneLoaded = (doneAns catch (e) => null) != null + + // this visit's writes: out (made, not answered) and accepted (in the order the server answered) + source nextKey: number := 0 + source cardAdds: CardAdd[] := [] + source moves: Move[] := [] + source moved: Move[] := [] + source renames: Rename[] := [] + source renamed: Rename[] := [] + source archiving: Archive[] := [] + source archived: number[] := [] + source toggles: Toggle[] := [] + source itemAdds: ItemAdd[] := [] + source known: Known[] := [] + w = { moves: moves, moved: moved, renames: renames, renamed: renamed, archiving: archiving, archived: archived, toggles: toggles, itemAdds: itemAdds, known: known } + + // a card shows once even when two columns' answers both hold it (a write from a left visit can land between them) + answers = once([...answered(todoAns catch (e) => null, "todo"), ...answered(doingAns catch (e) => null, "doing"), ...answered(doneAns catch (e) => null, "done")]) + cards = [...answers, ...addedCards(cardAdds, answers)] + todoRows key key = placed(shownIn("todo", cards, cardAdds, w)) + doingRows key key = placed(shownIn("doing", cards, cardAdds, w)) + doneRows key key = placed(shownIn("done", cards, cardAdds, w)) + + // a card may move into To do or Done once it shows; into Doing while it shows fewer than three cards + intoTodo = todoLoaded + intoDoing = doingLoaded && doingRows.length < 3 + intoDone = doneLoaded + + source selected: number | null := null + selTodo = findIn(todoRows, selected) + selDoing = findIn(doingRows, selected) + selDone = findIn(doneRows, selected) + sel = selTodo ?? selDoing ?? selDone + selCol = selTodo != null ? "todo" : selDoing != null ? "doing" : "done" + + // the answer carries the card it is for: the checklist's rows read the card from it, not from `selected` + checklist = selected == null ? null : { card: selected, items: await fetchChecklist(selected) } + canAddItem = selected != null && knownItems(w, selected) != null + + source newCard: string := "" + source titleInput: string := "" + source itemInput: string := "" + + source moveError: string := "" + source addError: string := "" + source archiveError: string := "" + // a detail error belongs to the card it was made on + source renameError: { card: number; message: string } | null := null + source itemError: { card: number; message: string } | null := null + + action pick(id: number) { + titleInput := "" + itemInput := "" + renameError := null + itemError := null + selected := id + } + + action retryTodo() { affects todoAns; refresh todoAns } + action retryDoing() { affects doingAns; refresh doingAns } + action retryDone() { affects doneAns; refresh doneAns } + + action move(id: number, to: string, index: number, rank: number) { + let k = nextKey + onFlush(() => { + nextKey := k + 1 + moveError := "" + moves := ms => [...ms, { key: k, id: id, col: to, rank: rank }] + }) + try { + await moveCard(id, to, index) + onFlush(() => { + moves := ms => ms.filter(m => m.key != k) + moved := ms => [...ms, { key: k, id: id, col: to, rank: rank }] + }) + } catch (e) { + onFlush(() => { + moves := ms => ms.filter(m => m.key != k) + moveError := e.message + }) + } + } + + action add(title: string) { + let k = nextKey + onFlush(() => { + nextKey := k + 1 + newCard := "" + addError := "" + cardAdds := as => [...as, { key: k, id: -1, title: title }] + }) + try { + let r = await addCard("todo", title) + onFlush(() => { cardAdds := as => as.map(a => a.key == k ? { key: k, id: r.id, title: r.title } : a) }) + } catch (e) { + onFlush(() => { + cardAdds := as => as.filter(a => a.key != k) + addError := e.message + }) + } + } + + action rename(id: number, title: string) { + let k = nextKey + onFlush(() => { + nextKey := k + 1 + renameError := null + renames := rs => [...rs, { key: k, id: id, title: title }] + }) + try { + let r = await renameCard(id, title) + onFlush(() => { + renames := rs => rs.filter(x => x.key != k) + renamed := rs => [...rs, { key: k, id: id, title: r.title }] + }) + } catch (e) { + onFlush(() => { + renames := rs => rs.filter(x => x.key != k) + renameError := { card: id, message: e.message } + }) + } + } + + action archive(id: number) { + let k = nextKey + onFlush(() => { + nextKey := k + 1 + archiveError := "" + selected := null + archiving := xs => [...xs, { key: k, id: id }] + }) + try { + await archiveCard(id) + onFlush(() => { + archiving := xs => xs.filter(x => x.key != k) + archived := xs => [...xs, id] + }) + } catch (e) { + onFlush(() => { + archiving := xs => xs.filter(x => x.key != k) + archiveError := e.message + }) + } + } + + action toggle(card: number, item: number, isDone: boolean) { + let k = nextKey + onFlush(() => { + nextKey := k + 1 + itemError := null + toggles := ts => [...ts, { key: k, card: card, item: item, done: isDone }] + }) + try { + let r = await toggleItem(card, item, isDone) + onFlush(() => { + toggles := ts => ts.filter(t => t.key != k) + known := ks => knowToggle(ks, card, r.id, r.done) + }) + } catch (e) { + onFlush(() => { + toggles := ts => ts.filter(t => t.key != k) + itemError := { card: card, message: e.message } + }) + } + } + + action addOneItem(card: number, text: string) { + let k = nextKey + onFlush(() => { + nextKey := k + 1 + itemInput := "" + itemError := null + itemAdds := xs => [...xs, { key: k, card: card, text: text }] + }) + try { + let r = await addItem(card, text) + onFlush(() => { + itemAdds := xs => xs.filter(x => x.key != k) + known := ks => knowItem(ks, card, r) + }) + } catch (e) { + onFlush(() => { + itemAdds := xs => xs.filter(x => x.key != k) + itemError := { card: card, message: e.message } + }) + } + } + + <div> + <div style="display: flex; gap: 1.5rem; align-items: flex-start"> + <div> + <Column col="todo" name="To do" ans={todoAns} loaded={todoLoaded} retrying={pending? todoAns} rows={todoRows} + leftCol="" rightCol="doing" leftOk={false} rightOk={intoDoing} + leftSize={0} rightSize={cardCount(doingRows)} leftRank={0} rightRank={bottomRank(doingRows)} + onRetry={() => { retryTodo() }} + onSelect={id => { pick(id) }} + onMove={(id, to, index, rank) => { move(id, to, index, rank) }} /> + <div> + <input data-testid="new-card" value={newCard} onInput={e => { newCard := e.target.value }} placeholder="new card" />{" "} + <button data-testid="add-card" disabled={!todoLoaded} onClick={() => { + if (newCard != "") { + add(newCard) + } + }}>add</button> + {if addError != "" { + <p data-testid="add-error">add error: {addError}</p> + }} + </div> + </div> + <Column col="doing" name="Doing" ans={doingAns} loaded={doingLoaded} retrying={pending? doingAns} rows={doingRows} + leftCol="todo" rightCol="done" leftOk={intoTodo} rightOk={intoDone} + leftSize={cardCount(todoRows)} rightSize={cardCount(doneRows)} leftRank={bottomRank(todoRows)} rightRank={bottomRank(doneRows)} + onRetry={() => { retryDoing() }} + onSelect={id => { pick(id) }} + onMove={(id, to, index, rank) => { move(id, to, index, rank) }} /> + <Column col="done" name="Done" ans={doneAns} loaded={doneLoaded} retrying={pending? doneAns} rows={doneRows} + leftCol="doing" rightCol="" leftOk={intoDoing} rightOk={false} + leftSize={cardCount(doingRows)} rightSize={0} leftRank={bottomRank(doingRows)} rightRank={0} + onRetry={() => { retryDone() }} + onSelect={id => { pick(id) }} + onMove={(id, to, index, rank) => { move(id, to, index, rank) }} /> + <section data-testid="detail" style="min-width: 16rem"> + {if sel == null { + <p>select a card</p> + } else { + <h2>#{sel.id} {sel.title}</h2> + <p>in {colName(selCol)}</p> + <div> + <input data-testid="title-input" value={titleInput} onInput={e => { titleInput := e.target.value }} placeholder="new title" />{" "} + <button data-testid="rename" onClick={() => { + if (titleInput != "" && sel != null) { + rename(sel.id, titleInput) + } + }}>rename</button>{" "} + <button data-testid="archive" onClick={() => { + if (sel != null) { + archive(sel.id) + } + }}>archive</button> + </div> + {if errFor(renameError, sel.id) != "" { + <p>rename error: {errFor(renameError, sel.id)}</p> + }} + <div data-testid="checklist"> + {loading on(selected) fallback { <p>loading checklist</p> } { + {errored fallback(e) { <p>checklist error: {e.message}</p> } { + // what this visit knows of each checklist it loaded: the card counts read it, so they agree with it + effect (checklist) { + if ($0 != null) { + let c = $0 + known := ks => knowAnswer(ks, c.card, c.items) + } + } + items key key = checklist == null ? [] : layItems(knownItems(w, checklist.card) ?? checklist.items, checklist.card, w) + <ul> + {for it in items { + {if it.adding { + <li><span data-testid="item">[ ] {it.text} (adding…)</span></li> + } else { + <li> + <span data-testid="item">[{it.done ? "x" : " "}] {it.text}</span>{" "} + <button data-testid="toggle" onClick={() => { + if (checklist != null) { + toggle(checklist.card, it.id, !it.done) + } + }}>toggle</button> + </li> + }} + }} + </ul> + <p data-testid="progress">{items.filter(it => it.done).length}/{items.length} done</p> + }} + }} + </div> + <div> + <input data-testid="item-input" value={itemInput} onInput={e => { itemInput := e.target.value }} placeholder="new item" />{" "} + <button data-testid="add-item" disabled={!canAddItem} onClick={() => { + if (itemInput != "" && selected != null) { + addOneItem(selected, itemInput) + } + }}>add item</button> + </div> + {if errFor(itemError, sel.id) != "" { + <p>item error: {errFor(itemError, sel.id)}</p> + }} + }} + </section> + </div> + {if moveError != "" { + <p data-testid="move-error">move error: {moveError}</p> + }} + {if archiveError != "" { + <p data-testid="archive-error">archive error: {archiveError}</p> + }} + </div> +} diff --git a/eval/reference/board/rx/column.rx b/eval/reference/board/rx/column.rx new file mode 100644 index 00000000..f32e00de --- /dev/null +++ b/eval/reference/board/rx/column.rx @@ -0,0 +1,50 @@ +// One column of the board: its heading, its load (loading, error and retry), and its cards with their move buttons. + +import { Card } from "./api" +import { Placed } from "./lay.rx" + +export component Column(props: { + col: string; name: string; ans: Card[] | null; loaded: boolean; retrying: boolean; rows: Placed[]; + leftCol: string; rightCol: string; leftOk: boolean; rightOk: boolean; + leftSize: number; rightSize: number; leftRank: number; rightRank: number; + onRetry: () => void; onSelect: (id: number) => void; onMove: (id: number, to: string, index: number, rank: number) => void; +}) { + <section data-testid={`col-${props.col}`} style="min-width: 14rem"> + <h2 data-testid={`head-${props.col}`}>{props.name}{props.loaded && !props.retrying ? ` (${props.rows.length})` : ""}</h2> + {if props.retrying { + <p>loading</p> + } else { + {errored fallback(e) { + <p>error: {e.message}{" "}<button data-testid={`retry-${props.col}`} onClick={() => { props.onRetry() }}>retry</button></p> + } { + {if props.ans == null { + <p>loading</p> + } else { + <ul> + {for r in props.rows { + <li> + {if r.adding { + <span data-testid="card">{r.title} (adding…)</span> + } else { + <button data-testid="card" onClick={() => { props.onSelect(r.id) }}>#{r.id} {r.title} · {r.done}/{r.total}</button> + {if props.leftCol != "" { + {" "}<button data-testid="left" disabled={!props.leftOk} onClick={() => { props.onMove(r.id, props.leftCol, props.leftSize, props.leftRank) }}>left</button> + }} + {if props.rightCol != "" { + {" "}<button data-testid="right" disabled={!props.rightOk} onClick={() => { props.onMove(r.id, props.rightCol, props.rightSize, props.rightRank) }}>right</button> + }} + {if r.hasUp { + {" "}<button data-testid="up" onClick={() => { props.onMove(r.id, props.col, r.pos - 1, r.up) }}>up</button> + }} + {if r.hasDown { + {" "}<button data-testid="down" onClick={() => { props.onMove(r.id, props.col, r.pos + 1, r.down) }}>down</button> + }} + }} + </li> + }} + </ul> + }} + }} + }} + </section> +} diff --git a/eval/reference/board/rx/lay.rx b/eval/reference/board/rx/lay.rx new file mode 100644 index 00000000..5f973784 --- /dev/null +++ b/eval/reference/board/rx/lay.rx @@ -0,0 +1,96 @@ +// The board as shown: the server's answers with this visit's writes laid over them. Pure functions only. +// +// A write is a record in a list of its kind. While it is out, its record shows it (`moves`, `renames`, …, in the order +// they were made, the newest winning); when the server takes it, its outcome joins the accepted list of its kind (in +// the order the server answered, so two accepted writes to one field show the server's last answer); when the server +// declines, its record goes and what was under it shows again. + +import { Card, Item } from "./api" + +// a card as an answer gives it (or as an accepted add made it), with its place: `rank` orders a column +export type Base = { id: number; title: string; done: number; total: number; col: string; rank: number } +// a card added on this visit: `id` is -1 until the server answers it +export type CardAdd = { key: number; id: number; title: string } +export type Move = { key: number; id: number; col: string; rank: number } +export type Rename = { key: number; id: number; title: string } +export type Archive = { key: number; id: number } +export type Toggle = { key: number; card: number; item: number; done: boolean } +export type ItemAdd = { key: number; card: number; text: string } +// a checklist as this visit knows it: its last answer, with the writes the server took since +export type Known = { card: number; items: Item[] } +export type Writes = { + moves: Move[]; moved: Move[]; renames: Rename[]; renamed: Rename[]; archiving: Archive[]; archived: number[]; + toggles: Toggle[]; itemAdds: ItemAdd[]; known: Known[]; +} + +// a card as a column shows it +export type Shown = { key: string; id: number; title: string; done: number; total: number; adding: boolean; rank: number } +// with what its move buttons would do: `pos` its place among the column's cards, `up`/`down` its rank after a swap +export type Placed = { key: string; id: number; title: string; done: number; total: number; adding: boolean; rank: number; pos: number; hasUp: boolean; hasDown: boolean; up: number; down: number } +export type ItemRow = { key: string; id: number; text: string; done: boolean; adding: boolean } + +export fn answered(ans: Card[] | null, col: string): Base[] => + ans == null ? [] : ans.map((c, i) => ({ id: c.id, title: c.title, done: c.done, total: c.total, col: col, rank: i })) +export fn once(bs: Base[]): Base[] => bs.filter((b, i) => bs.findIndex(x => x.id == b.id) == i) +// accepted adds go at the bottom of To do, in the order they were made +export fn addedCards(adds: CardAdd[], all: Base[]): Base[] => + adds.filter(a => a.id >= 0 && !all.some(b => b.id == a.id)).map(a => ({ id: a.id, title: a.title, done: 0, total: 0, col: "todo", rank: 1000000 + a.key })) + +fn lastMove(ms: Move[], id: number): Move | null => ms.filter(x => x.id == id).at(-1) ?? null +fn placeOf(b: Base, w: Writes): Move => lastMove(w.moves, b.id) ?? lastMove(w.moved, b.id) ?? { key: -1, id: b.id, col: b.col, rank: b.rank } +fn lastTitle(rs: Rename[], id: number): string | null => rs.filter(r => r.id == id).at(-1)?.title ?? null +export fn titleOf(w: Writes, id: number, title: string): string => lastTitle(w.renames, id) ?? lastTitle(w.renamed, id) ?? title +fn gone(w: Writes, id: number): boolean => w.archived.includes(id) || w.archiving.some(a => a.id == id) + +fn doneOf(ts: Toggle[], card: number, item: number, done: boolean): boolean => ts.reduce((d, t) => t.card == card && t.item == item ? t.done : d, done) +// a checklist as shown: its items with the toggles out, then the items being added +export fn layItems(items: Item[], card: number, w: Writes): ItemRow[] => + [ + ...items.map(it => ({ key: `i${it.id}`, id: it.id, text: it.text, done: doneOf(w.toggles, card, it.id, it.done), adding: false })), + ...w.itemAdds.filter(a => a.card == card).map(a => ({ key: `a${a.key}`, id: -1, text: a.text, done: false, adding: true })), + ] +export fn knownItems(w: Writes, card: number): Item[] | null => w.known.find(k => k.card == card)?.items ?? null +fn countDone(rows: ItemRow[]): number => rows.filter(r => r.done).length + +// a card's count: its checklist's as shown, once this visit has loaded it; else its column's answer +fn shownCard(b: Base, w: Writes): Shown => shownWith(b, placeOf(b, w), titleOf(w, b.id, b.title), knownItems(w, b.id), w) +fn shownWith(b: Base, at: Move, title: string, items: Item[] | null, w: Writes): Shown => + items == null ? ({ key: `c${b.id}`, id: b.id, title: title, done: b.done, total: b.total, adding: false, rank: at.rank }) : countedCard(b, at, title, layItems(items, b.id, w)) +fn countedCard(b: Base, at: Move, title: string, rows: ItemRow[]): Shown => + ({ key: `c${b.id}`, id: b.id, title: title, done: countDone(rows), total: rows.length, adding: false, rank: at.rank }) + +fn addingIn(c: string, adds: CardAdd[]): Shown[] => + c != "todo" ? [] : adds.filter(a => a.id < 0).map(a => ({ key: `a${a.key}`, id: -1, title: a.title, done: 0, total: 0, adding: true, rank: 1000000 + a.key })) + +// the cards column `c` shows, top first +export fn shownIn(c: string, cards: Base[], adds: CardAdd[], w: Writes): Shown[] => + [...cards.filter(b => !gone(w, b.id) && placeOf(b, w).col == c).map(b => shownCard(b, w)), ...addingIn(c, adds)].sort((x, y) => x.rank - y.rank) + +fn prevReal(rows: Shown[], p: number): number => rows.reduce((a, r, i) => i < p && !r.adding ? i : a, -1) +fn nextReal(rows: Shown[], p: number): number => rows.findIndex((r, i) => i > p && !r.adding) +fn upRank(rows: Shown[], q: number): number => q < 0 ? 0 : q >= 1 ? (rows[q - 1].rank + rows[q].rank) / 2 : rows[q].rank - 1 +fn downRank(rows: Shown[], n: number): number => n < 0 ? 0 : n + 1 < rows.length ? (rows[n].rank + rows[n + 1].rank) / 2 : rows[n].rank + 1 + +export fn placed(rows: Shown[]): Placed[] => + rows.map((r, p) => ({ + key: r.key, id: r.id, title: r.title, done: r.done, total: r.total, adding: r.adding, rank: r.rank, + pos: rows.filter((x, i) => i < p && !x.adding).length, + hasUp: prevReal(rows, p) >= 0, + hasDown: nextReal(rows, p) >= 0, + up: upRank(rows, prevReal(rows, p)), + down: downRank(rows, nextReal(rows, p)), + })) + +// where a card moved in from another column goes: below the column's last card, above the cards being added below it +fn lastReal(rows: Placed[]): number => rows.reduce((a, r, i) => r.adding ? a : i, -1) +fn rankBelow(rows: Placed[], q: number): number => + q < 0 ? (rows.length > 0 ? rows[0].rank - 1 : 0) : q + 1 < rows.length ? (rows[q].rank + rows[q + 1].rank) / 2 : rows[q].rank + 1 +export fn bottomRank(rows: Placed[]): number => rankBelow(rows, lastReal(rows)) +export fn cardCount(rows: Placed[]): number => rows.filter(r => !r.adding).length + +// the accepted writes, written into what this visit knows of a checklist +export fn knowAnswer(ks: Known[], card: number, items: Item[]): Known[] => [...ks.filter(k => k.card != card), { card: card, items: items }] +export fn knowToggle(ks: Known[], card: number, item: number, done: boolean): Known[] => + ks.map(k => k.card != card ? k : { card: card, items: k.items.map(it => it.id == item ? { id: it.id, text: it.text, done: done } : it) }) +export fn knowItem(ks: Known[], card: number, it: Item): Known[] => + ks.map(k => k.card != card || k.items.some(x => x.id == it.id) ? k : { card: card, items: [...k.items, it] }) diff --git a/eval/reference/board/rx/main.rx b/eval/reference/board/rx/main.rx new file mode 100644 index 00000000..6717acbb --- /dev/null +++ b/eval/reference/board/rx/main.rx @@ -0,0 +1,21 @@ +// The sprint board: two pages behind tabs. Only the open page is mounted, so leaving one discards everything it +// holds (its loads, its guesses, its inputs and errors), and opening it again starts it as the app starts. + +import { BoardPage } from "./board.rx" +import { ArchivePage } from "./archive.rx" + +export default component App() { + source page: string := "board" + + <div> + <nav> + <button data-testid="tab-board" onClick={() => { page := "board" }}>Board</button>{" "} + <button data-testid="tab-archive" onClick={() => { page := "archive" }}>Archive</button> + </nav> + {if page == "board" { + <BoardPage /> + } else { + <ArchivePage /> + }} + </div> +} diff --git a/eval/results/r15prep/repro/stale-label-under-guess.spec.tsx b/eval/results/r15prep/repro/stale-label-under-guess.spec.tsx new file mode 100644 index 00000000..0cd3da50 --- /dev/null +++ b/eval/results/r15prep/repro/stale-label-under-guess.spec.tsx @@ -0,0 +1,46 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +import { test, expect } from "vitest"; +import { Loading, Errored, For, action, createMemo, createSignal, createOptimistic, flush } from "solid-js"; +import { render } from "../src/index.js"; +const tick = async () => { for (let i = 0; i < 4; i++) { await new Promise(r => setTimeout(r, 0)); flush(); } }; +test("a checklist reading the selection and a pending guess shows the fallback on select, and drops a declined guess at once", async () => { + const asks: { key: string; res: (v: any) => void; rej: (e: any) => void }[] = []; + const ask = (key: string) => new Promise<any>((res, rej) => asks.push({ key, res, rej })); + const take = (key: string) => { const i = asks.findIndex(a => a.key === key); if (i < 0) throw new Error("no ask " + key); return asks.splice(i, 1)[0]; }; + const c = document.body.appendChild(document.createElement("div")); + + const [selected, setSelected] = createSignal(2); + const [toggling, guessToggling] = createOptimistic<number[]>(() => []); + const checklist = createMemo(() => ask(`checklist:${selected()}`)); + const toggle = action(function* (item: number) { + try { guessToggling(ts => [...ts, item]); yield ask(`toggle:${item}`); } catch { /* declined */ } + }); + + render(() => <div> + <p id="count">{toggling().length}</p> + <Loading on={selected()} fallback={<p id="fb">loading checklist</p>}> + <Errored fallback={<p>error</p>}> + {(() => { + const items = createMemo(() => (checklist() as any[]).map(it => `${selected()}/${it.text}:${toggling().includes(it.id)}`)); + return <For each={items()}>{it => <span class="item">{it()}</span>}</For>; + })()} + </Errored> + </Loading> + </div>, c); + const screen = () => `count=${c.querySelector("#count")!.textContent} | ${c.querySelector("#fb") ? "loading checklist" : [...c.querySelectorAll(".item")].map(e => e.textContent).join(" ")}`; + + take("checklist:2").res([{ id: 21, text: "repro" }, { id: 22, text: "patch" }]); await tick(); + toggle(22); await tick(); + console.log("after toggle:", screen()); + setSelected(1); await tick(); + const afterSelect = screen(); + console.log("after select 1:", afterSelect); + take("toggle:22").rej(new Error("read only")); await tick(); + const afterDecline = screen(); + console.log("after decline:", afterDecline, "| in the air:", asks.map(a => a.key).join(", ")); + expect(afterSelect).toBe("count=1 | loading checklist"); + expect(afterDecline).toBe("count=0 | loading checklist"); +}); From ea24896e02782783e9e3241543e1a162f983a085 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 14 Nov 2026 12:04:16 +0000 Subject: [PATCH 10/32] eval: fuzz-board B1 also exempts the duplicate-card check in a late-write visit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- eval/harness/fuzz-board.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/eval/harness/fuzz-board.mjs b/eval/harness/fuzz-board.mjs index 325f2a46..0448ff9a 100644 --- a/eval/harness/fuzz-board.mjs +++ b/eval/harness/fuzz-board.mjs @@ -13,7 +13,8 @@ // is the server's. On the archive page: its rows are the server's archive. // Allowances, the same for every arm: // - B1 (brief: leaving a page discards it): a write that answers after its page was left need not show on the next -// visit, so a visit where one landed is not checked settled (Ryan, 2026-11-14: late writes are out of scope). +// visit, so a visit where one landed is not checked settled, nor for a card in two columns (a late move can land +// between the visit's column fetches) (Ryan, 2026-11-14: late writes are out of scope). // - B2 (brief: "if the server accepts, the card stays where it was moved"): after two moves overlapped in the air, a // column's order is not checked settled for the rest of the visit, only which cards it holds. An app asked to keep a // card where it showed it cannot know an order the server made from answers it applied in another order. @@ -140,7 +141,7 @@ async function session(browser, url, seed, steps, log) { for (const col of COLS) { const c = b[col]; if (!c) continue; if (c.n !== c.rows.length) note("heading", `head-${col} counts ${c.n}, the column shows ${c.rows.length} cards`); - for (const x of c.rows) if (!x.adding) { if (seen.has(x.id)) note("duplicate card", `#${x.id} shows in ${seen.get(x.id)} and ${col}`); seen.set(x.id, col); } + for (const x of c.rows) if (!x.adding) { if (seen.has(x.id) && !lateVisit) note("duplicate card", `#${x.id} shows in ${seen.get(x.id)} and ${col}`); seen.set(x.id, col); } } const placeholders = COLS.flatMap(col => (b[col]?.rows ?? []).filter(x => x.adding).map(x => x.title)); const addsOut = (await pending()).filter(k => k.startsWith("addCard:")).map(k => k.slice(k.indexOf(",") + 1)); From 882172fbd66656b631311b7e12d1afa18551bf10 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 14 Nov 2026 12:17:55 +0000 Subject: [PATCH 11/32] eval: board vanilla reference (grades 0) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- eval/reference/board/vanilla/GRADE.txt | 6 + eval/reference/board/vanilla/archive.ts | 67 ++++ eval/reference/board/vanilla/board/detail.ts | 114 ++++++ eval/reference/board/vanilla/board/model.ts | 120 ++++++ eval/reference/board/vanilla/board/page.ts | 370 +++++++++++++++++++ eval/reference/board/vanilla/dom.ts | 54 +++ eval/reference/board/vanilla/main.ts | 27 ++ 7 files changed, 758 insertions(+) create mode 100644 eval/reference/board/vanilla/GRADE.txt create mode 100644 eval/reference/board/vanilla/archive.ts create mode 100644 eval/reference/board/vanilla/board/detail.ts create mode 100644 eval/reference/board/vanilla/board/model.ts create mode 100644 eval/reference/board/vanilla/board/page.ts create mode 100644 eval/reference/board/vanilla/dom.ts create mode 100644 eval/reference/board/vanilla/main.ts diff --git a/eval/reference/board/vanilla/GRADE.txt b/eval/reference/board/vanilla/GRADE.txt new file mode 100644 index 00000000..70d2ba71 --- /dev/null +++ b/eval/reference/board/vanilla/GRADE.txt @@ -0,0 +1,6 @@ +== hidden +45/45 passed +== overlap +26/26 passed +== fuzz +board-vanilla: 0/20 seeds found a hole diff --git a/eval/reference/board/vanilla/archive.ts b/eval/reference/board/vanilla/archive.ts new file mode 100644 index 00000000..9784c768 --- /dev/null +++ b/eval/reference/board/vanilla/archive.ts @@ -0,0 +1,67 @@ +// The archive page: the archived cards, and restoring them. Leaving the page ends `signal`. +import * as api from "./api"; +import type { Archived } from "./api"; +import { button, errorMessage, h } from "./dom"; + +type ListState = { status: "loading" } | { status: "error"; message: string } | { status: "ok"; cards: Archived[] }; + +export function archivePage(signal: AbortSignal): HTMLElement { + let list: ListState = { status: "loading" }; + // restores in the air, each with its own seq so that two restores of one row settle separately + let restoring: { seq: number; id: number }[] = []; + let restoreError: string | null = null; + let seq = 0; + + const listEl = h("div", { testid: "archived" }); + const errorEl = h("div"); + + async function load() { + try { + const cards = await api.fetchArchived(); + if (signal.aborted) return; + list = { status: "ok", cards }; + } catch (e) { + if (signal.aborted) return; + list = { status: "error", message: errorMessage(e) }; + } + render(); + } + + async function restore(id: number) { + const mine = ++seq; + restoreError = null; + restoring = [...restoring, { seq: mine, id }]; + render(); + try { + await api.restoreCard(id); + if (signal.aborted) return; + if (list.status === "ok") list = { status: "ok", cards: list.cards.filter(c => c.id !== id) }; + } catch (e) { + if (signal.aborted) return; + restoreError = errorMessage(e); + } + restoring = restoring.filter(r => r.seq !== mine); + render(); + } + + function render() { + const s = list; + if (s.status === "loading") listEl.replaceChildren(h("p", null, "loading archive")); + else if (s.status === "error") listEl.replaceChildren(h("p", null, `archive error: ${s.message}`)); + else + listEl.replaceChildren( + h( + "ul", + null, + ...s.cards + .filter(c => !restoring.some(r => r.id === c.id)) + .map(c => h("li", null, h("span", { testid: "archived-card" }, `#${c.id} ${c.title}`), " ", button("restore", "restore", () => void restore(c.id)))), + ), + ); + errorEl.replaceChildren(restoreError === null ? "" : h("p", { testid: "restore-error" }, `restore error: ${restoreError}`)); + } + + void load(); + render(); + return h("main", { className: "archive" }, listEl, errorEl); +} diff --git a/eval/reference/board/vanilla/board/detail.ts b/eval/reference/board/vanilla/board/detail.ts new file mode 100644 index 00000000..acab3c5a --- /dev/null +++ b/eval/reference/board/vanilla/board/detail.ts @@ -0,0 +1,114 @@ +// The detail of one selected card. A new Detail is made for every selection (even of the same card again), so its +// inputs and errors belong to that selection; `signal` ends with it. The page hands it the card as the board shows +// it on every render. +import type { Column } from "../api"; +import { button, h, textInput } from "../dom"; +import { COLUMN_NAME, countItems, type CachedItem, type ItemRow } from "./model"; +import type { BoardPage, Outcome } from "./page"; + +export type ChecklistState = { status: "loading" } | { status: "error"; message: string } | { status: "ok"; items: CachedItem[] }; + +/** the selected card as the board shows it, and its checklist (`items` as shown, once it has loaded) */ +export type DetailView = { id: number; title: string; column: Column; checklist: ChecklistState; items: ItemRow[] | undefined }; + +export class Detail { + readonly root = h("div"); + private title = ""; + private itemText = ""; + private renameError: string | null = null; + private itemError: string | null = null; + /** what the detail last showed */ + private view: DetailView | null = null; + + private readonly headEl = h("h2"); + private readonly columnEl = h("p"); + private readonly renameErrorEl = h("div"); + private readonly checklistEl = h("div", { testid: "checklist" }); + private readonly itemErrorEl = h("div"); + private readonly titleInput: HTMLInputElement; + private readonly itemInput: HTMLInputElement; + private readonly addItemButton: HTMLButtonElement; + + constructor( + private readonly page: BoardPage, + readonly id: number, + private readonly signal: AbortSignal, + ) { + this.titleInput = textInput("title-input", "", value => (this.title = value)); + this.titleInput.placeholder = "new title"; + this.itemInput = textInput("item-input", "", value => (this.itemText = value)); + this.itemInput.placeholder = "new item"; + this.addItemButton = button("add-item", "add item", () => this.addItem()); + this.root.append( + this.headEl, + this.columnEl, + h( + "div", + null, + this.titleInput, + " ", + button("rename", "rename", () => this.rename()), + " ", + button("archive", "archive", () => page.archive(this.id)), + ), + this.renameErrorEl, + this.checklistEl, + h("div", null, this.itemInput, " ", this.addItemButton), + this.itemErrorEl, + ); + } + + /** runs one write of the kind whose error line `set` writes: it clears the line, and a failure fills it */ + private async run(set: (message: string | null) => void, action: () => Promise<Outcome>) { + set(null); + const outcome = await action(); + if (outcome !== null && !this.signal.aborted) { + set(outcome); + this.page.render(); + } + } + + private rename() { + const title = this.title; + if (title === "") return; + void this.run(m => (this.renameError = m), () => this.page.rename(this.id, title)); + } + + private toggle(itemId: number, done: boolean) { + void this.run(m => (this.itemError = m), () => this.page.toggle(this.id, itemId, done)); + } + + private addItem() { + const text = this.itemText; + if (text === "" || !this.view?.items) return; + this.itemText = this.itemInput.value = ""; + void this.run(m => (this.itemError = m), () => this.page.addItem(this.id, text)); + } + + render(view: DetailView) { + this.view = view; + this.headEl.textContent = `#${view.id} ${view.title}`; + this.columnEl.textContent = `in ${COLUMN_NAME[view.column]}`; + this.renameErrorEl.replaceChildren(this.renameError === null ? "" : h("p", null, `rename error: ${this.renameError}`)); + this.itemErrorEl.replaceChildren(this.itemError === null ? "" : h("p", null, `item error: ${this.itemError}`)); + this.addItemButton.disabled = !view.items; + + const { checklist, items } = view; + if (items) { + const { done, total } = countItems(items); + this.checklistEl.replaceChildren( + h( + "ul", + null, + ...items.map(r => + r.kind === "adding" + ? h("li", null, h("span", { testid: "item" }, `[ ] ${r.text} (adding…)`)) + : h("li", null, h("span", { testid: "item" }, `[${r.done ? "x" : " "}] ${r.text}`), " ", button("toggle", "toggle", () => this.toggle(r.id, !r.done))), + ), + ), + h("p", { testid: "progress" }, `${done}/${total} done`), + ); + } else if (checklist.status === "error") this.checklistEl.replaceChildren(h("p", null, `checklist error: ${checklist.message}`)); + else this.checklistEl.replaceChildren(h("p", null, "loading checklist")); + } +} diff --git a/eval/reference/board/vanilla/board/model.ts b/eval/reference/board/vanilla/board/model.ts new file mode 100644 index 00000000..2f1d99d8 --- /dev/null +++ b/eval/reference/board/vanilla/board/model.ts @@ -0,0 +1,120 @@ +// The board as shown: what the server last answered (the page's caches), with the writes still in the air laid over +// it. Pure functions; the page holds the state. +import type { Card, Column, Item } from "../api"; + +export const COLUMNS: Column[] = ["todo", "doing", "done"]; +export const COLUMN_NAME: Record<Column, string> = { todo: "To do", doing: "Doing", done: "Done" }; +export const isColumn = (c: string): c is Column => (COLUMNS as string[]).includes(c); +/** Doing holds at most this many cards */ +export const WIP_LIMIT = 3; + +/** a card or item as the cache holds it; `addSeq` marks one this page added, by the seq of its add */ +export type CachedCard = Card & { addSeq?: number }; +export type CachedItem = Item & { addSeq?: number }; +export type Columns = Record<Column, CachedCard[] | undefined>; + +// writes in the air; `seq` orders all of the page's writes +export type Move = { seq: number; id: number; column: Column; index: number }; +export type CardAdd = { seq: number; title: string }; +export type Archiving = { seq: number; id: number }; +export type Rename = { seq: number; id: number; title: string }; +export type Toggle = { seq: number; cardId: number; itemId: number; done: boolean }; +export type ItemAdd = { seq: number; cardId: number; text: string }; + +/** + * Where an entry from the add with `seq` goes in a list: before the first entry that a later add put there (that add + * answered first), else at the end. So entries keep the order their adds started in, guessed or answered. + */ +export function addSlot<T>(list: T[], seq: number, seqOf: (e: T) => number | undefined) { + const i = list.findIndex(e => (seqOf(e) ?? -Infinity) > seq); + return i < 0 ? list.length : i; +} + +export type CardRow = { kind: "card"; card: CachedCard } | { kind: "adding"; seq: number; title: string }; +const rowSeq = (r: CardRow) => (r.kind === "adding" ? r.seq : r.card.addSeq); + +/** + * The loaded columns with the moves, adds and archives in the air replayed over them in the order they started, so + * each lands where it was made: a move at the position it was shown at, an add at the bottom of To do. + */ +export function layColumns(base: Columns, moves: Move[], adds: CardAdd[], archiving: Archiving[]): Record<Column, CardRow[] | undefined> { + const cols = {} as Record<Column, CardRow[] | undefined>; + for (const c of COLUMNS) cols[c] = base[c]?.map(card => ({ kind: "card", card })); + const find = (id: number) => { + for (const c of COLUMNS) { + const list = cols[c]; + const i = list?.findIndex(r => r.kind === "card" && r.card.id === id) ?? -1; + if (list && i >= 0) return { list, i }; + } + return null; + }; + type Op = ({ kind: "move" } & Move) | ({ kind: "add" } & CardAdd) | ({ kind: "archive" } & Archiving); + const ops: Op[] = [ + ...moves.map(m => ({ kind: "move" as const, ...m })), + ...adds.map(a => ({ kind: "add" as const, ...a })), + ...archiving.map(a => ({ kind: "archive" as const, ...a })), + ].sort((a, b) => a.seq - b.seq); + for (const op of ops) { + if (op.kind === "add") { + const todo = cols.todo; + if (todo) todo.splice(addSlot(todo, op.seq, rowSeq), 0, { kind: "adding", seq: op.seq, title: op.title }); + continue; + } + const at = find(op.id); + if (!at) continue; + if (op.kind === "archive") { + at.list.splice(at.i, 1); + continue; + } + const target = cols[op.column]; + if (!target) continue; + const [row] = at.list.splice(at.i, 1); + target.splice(Math.min(op.index, target.length), 0, row); + } + return cols; +} + +/** the newest of the writes that `match` (writes are kept in the order they started) */ +export function newest<T>(writes: T[], match: (w: T) => boolean): T | undefined { + for (let i = writes.length - 1; i >= 0; i--) if (match(writes[i])) return writes[i]; + return undefined; +} + +export type ItemRow = { kind: "item"; id: number; text: string; done: boolean } | { kind: "adding"; seq: number; text: string }; + +/** a card's checklist with its toggles (the newest one per item) and item adds in the air laid over it */ +export function layItems(base: CachedItem[], toggles: Toggle[], adds: ItemAdd[]): ItemRow[] { + type Entry = { row: ItemRow; seq: number | undefined }; + const list: Entry[] = base.map(item => ({ + row: { kind: "item", id: item.id, text: item.text, done: newest(toggles, t => t.itemId === item.id)?.done ?? item.done }, + seq: item.addSeq, + })); + for (const a of [...adds].sort((x, y) => x.seq - y.seq)) + list.splice(addSlot(list, a.seq, e => e.seq), 0, { row: { kind: "adding", seq: a.seq, text: a.text }, seq: a.seq }); + return list.map(e => e.row); +} + +/** an item list's done and total, the adds in the air counted as not done */ +export const countItems = (rows: ItemRow[]) => ({ done: rows.filter(r => r.kind === "item" && r.done).length, total: rows.length }); + +// the server's answers, written into the caches + +/** the card answered at `column`/`index`, taken out of wherever the caches hold it */ +export function placeCard(cols: Columns, id: number, column: Column, index: number): Columns { + const card = COLUMNS.flatMap(c => cols[c] ?? []).find(x => x.id === id); + const next = {} as Columns; + for (const c of COLUMNS) next[c] = cols[c]?.filter(x => x.id !== id); + const target = next[column]; + if (card && target) target.splice(Math.min(index, target.length), 0, card); + return next; +} + +/** every column with `f` applied to its cards (unloaded columns stay unloaded) */ +export function mapColumns(cols: Columns, f: (cards: CachedCard[]) => CachedCard[]): Columns { + const next = {} as Columns; + for (const c of COLUMNS) { + const cards = cols[c]; + next[c] = cards && f(cards); + } + return next; +} diff --git a/eval/reference/board/vanilla/board/page.ts b/eval/reference/board/vanilla/board/page.ts new file mode 100644 index 00000000..95bef48d --- /dev/null +++ b/eval/reference/board/vanilla/board/page.ts @@ -0,0 +1,370 @@ +// The board page: three columns, the add-card input under To do, and the selected card's detail. The page keeps the +// server's last answers (the columns, and every checklist it loaded) and the writes still in the air; what shows is +// always the one laid over the other, so the cards, headings, counts and the detail read the same thing. Every write +// starts here, so it keeps going (and its answer still counts) when its card is no longer selected; all of it ends +// when the page is left (`signal`). +import * as api from "../api"; +import type { Column } from "../api"; +import { button, errorMessage, h, latest, textInput } from "../dom"; +import { Detail, type ChecklistState, type DetailView } from "./detail"; +import { + COLUMNS, + COLUMN_NAME, + WIP_LIMIT, + addSlot, + countItems, + isColumn, + layColumns, + layItems, + mapColumns, + newest, + placeCard, + type Archiving, + type CachedItem, + type CardAdd, + type Columns, + type ItemAdd, + type ItemRow, + type Move, + type Rename, + type Toggle, +} from "./model"; + +/** `null` when the server took the write, else the server's message */ +export type Outcome = string | null; + +/** a card as the board shows it, or one being added (no id yet) */ +type ShownRow = { kind: "card"; id: number; title: string; done: number; total: number } | { kind: "adding"; seq: number; title: string }; + +export class BoardPage { + readonly root: HTMLElement; + private seq = 0; + + // the server's last answers + private cols: Columns = { todo: undefined, doing: undefined, done: undefined }; + /** a column's load failure, while it is not being asked again */ + private colErrors: Record<Column, string | null> = { todo: null, doing: null, done: null }; + private readonly nextColumnLoad: Record<Column, () => AbortSignal>; + /** every checklist the page loaded stays for its life, so a card whose checklist is known counts it as shown */ + private readonly checklists = new Map<number, ChecklistState>(); + + // writes in the air + private moves: Move[] = []; + private adds: CardAdd[] = []; + private archiving: Archiving[] = []; + private renames: Rename[] = []; + private toggles: Toggle[] = []; + private itemAdds: ItemAdd[] = []; + + private moveError: string | null = null; + private addError: string | null = null; + private archiveError: string | null = null; + + private detail: { view: Detail; end: AbortController } | null = null; + private newCard = ""; + + private readonly colEls: Record<Column, { head: HTMLElement; body: HTMLElement }>; + private readonly newCardInput: HTMLInputElement; + private readonly addCardButton: HTMLButtonElement; + private readonly addErrorEl = h("div"); + private readonly detailEl = h("section", { testid: "detail", className: "detail" }); + private readonly errorsEl = h("div"); + + constructor(private readonly signal: AbortSignal) { + this.nextColumnLoad = { todo: latest(signal), doing: latest(signal), done: latest(signal) }; + this.newCardInput = textInput("new-card", "", value => (this.newCard = value)); + this.newCardInput.placeholder = "new card"; + this.addCardButton = button("add-card", "add", () => this.add()); + const section = (c: Column) => { + const head = h("h2", { testid: `head-${c}` }); + const body = h("div"); + return { head, body }; + }; + this.colEls = { todo: section("todo"), doing: section("doing"), done: section("done") }; + const column = (c: Column, ...footer: HTMLElement[]) => + h("section", { testid: `col-${c}`, className: "column" }, this.colEls[c].head, this.colEls[c].body, ...footer); + this.root = h( + "main", + { className: "board" }, + h( + "div", + { className: "lanes" }, + column("todo", h("div", null, this.newCardInput, " ", this.addCardButton, this.addErrorEl)), + column("doing"), + column("done"), + this.detailEl, + ), + this.errorsEl, + ); + for (const c of COLUMNS) void this.loadColumn(c); + this.render(); + } + + private nextSeq() { + return ++this.seq; + } + + // --- loads -------------------------------------------------------------------------------------------------- + + private async loadColumn(c: Column) { + const signal = this.nextColumnLoad[c](); + this.colErrors[c] = null; + this.render(); + try { + const cards = await api.fetchCards(c); + if (signal.aborted) return; + this.cols = { ...this.cols, [c]: cards }; + } catch (e) { + if (signal.aborted) return; + this.colErrors[c] = errorMessage(e); + } + this.render(); + } + + /** loads card `id`'s checklist unless the page already has it or is getting it */ + private async loadChecklist(id: number) { + const known = this.checklists.get(id); + if (known && known.status !== "error") return; + this.checklists.set(id, { status: "loading" }); + try { + const items = await api.fetchChecklist(id); + if (this.signal.aborted) return; + this.checklists.set(id, { status: "ok", items }); + } catch (e) { + if (this.signal.aborted) return; + this.checklists.set(id, { status: "error", message: errorMessage(e) }); + } + this.render(); + } + + // --- writes ------------------------------------------------------------------------------------------------- + + /** + * Runs one write: its guess is already in the air (and shows at once); `settle` takes it out when the server + * answers, and `accepted` writes the answer into the caches. Once the page is left, its answer changes nothing. + */ + private async write<T>(request: () => Promise<T>, settle: () => void, accepted: (answer: T) => void): Promise<Outcome> { + this.render(); + try { + const answer = await request(); + if (this.signal.aborted) return null; + settle(); + accepted(answer); + return null; + } catch (e) { + if (this.signal.aborted) return null; + settle(); + return errorMessage(e); + } finally { + if (!this.signal.aborted) this.render(); + } + } + + private async move(id: number, column: Column, index: number) { + const seq = this.nextSeq(); + this.moveError = null; + this.moves = [...this.moves, { seq, id, column, index }]; + const outcome = await this.write( + () => api.moveCard(id, column, index), + () => (this.moves = this.moves.filter(m => m.seq !== seq)), + res => (this.cols = placeCard(this.cols, res.id, isColumn(res.column) ? res.column : column, res.index)), + ); + if (outcome !== null) this.showError(() => (this.moveError = outcome)); + } + + private add() { + const title = this.newCard; + if (title === "" || this.cols.todo === undefined) return; + this.newCard = this.newCardInput.value = ""; + const seq = this.nextSeq(); + this.addError = null; + this.adds = [...this.adds, { seq, title }]; + void this.write( + () => api.addCard("todo", title), + () => (this.adds = this.adds.filter(a => a.seq !== seq)), + res => { + const todo = this.cols.todo; + if (!todo || todo.some(x => x.id === res.id)) return; + const next = [...todo]; + next.splice(addSlot(todo, seq, x => x.addSeq), 0, { id: res.id, title: res.title, done: 0, total: 0, addSeq: seq }); + this.cols = { ...this.cols, todo: next }; + }, + ).then(outcome => { + if (outcome !== null) this.showError(() => (this.addError = outcome)); + }); + } + + private showError(set: () => void) { + if (this.signal.aborted) return; + set(); + this.render(); + } + + rename(id: number, title: string): Promise<Outcome> { + const seq = this.nextSeq(); + this.renames = [...this.renames, { seq, id, title }]; + return this.write( + () => api.renameCard(id, title), + () => (this.renames = this.renames.filter(n => n.seq !== seq)), + res => (this.cols = mapColumns(this.cols, cards => cards.map(x => (x.id === res.id ? { ...x, title: res.title } : x)))), + ); + } + + archive(id: number) { + const seq = this.nextSeq(); + this.archiveError = null; + this.deselect(); + this.archiving = [...this.archiving, { seq, id }]; + void this.write( + () => api.archiveCard(id), + () => (this.archiving = this.archiving.filter(a => a.seq !== seq)), + res => (this.cols = mapColumns(this.cols, cards => cards.filter(x => x.id !== res.id))), + ).then(outcome => { + if (outcome !== null) this.showError(() => (this.archiveError = outcome)); + }); + } + + /** patches card `cardId`'s loaded checklist with `f` */ + private patchChecklist(cardId: number, f: (items: CachedItem[]) => CachedItem[]) { + const list = this.checklists.get(cardId); + if (list?.status === "ok") this.checklists.set(cardId, { status: "ok", items: f(list.items) }); + } + + toggle(cardId: number, itemId: number, done: boolean): Promise<Outcome> { + const seq = this.nextSeq(); + this.toggles = [...this.toggles, { seq, cardId, itemId, done }]; + return this.write( + () => api.toggleItem(cardId, itemId, done), + () => (this.toggles = this.toggles.filter(t => t.seq !== seq)), + res => this.patchChecklist(cardId, items => items.map(i => (i.id === res.id ? { ...i, done: res.done } : i))), + ); + } + + addItem(cardId: number, text: string): Promise<Outcome> { + const seq = this.nextSeq(); + this.itemAdds = [...this.itemAdds, { seq, cardId, text }]; + return this.write( + () => api.addItem(cardId, text), + () => (this.itemAdds = this.itemAdds.filter(a => a.seq !== seq)), + res => + this.patchChecklist(cardId, items => { + if (items.some(i => i.id === res.id)) return items; + const next = [...items]; + next.splice(addSlot(items, seq, i => i.addSeq), 0, { ...res, addSeq: seq }); + return next; + }), + ); + } + + // --- selection ---------------------------------------------------------------------------------------------- + + private select(id: number) { + this.detail?.end.abort(); + const end = new AbortController(); + const signal = AbortSignal.any([this.signal, end.signal]); + this.detail = { view: new Detail(this, id, signal), end }; + void this.loadChecklist(id); + this.render(); + } + + private deselect() { + this.detail?.end.abort(); + this.detail = null; + } + + // --- what shows --------------------------------------------------------------------------------------------- + + /** card `id`'s checklist as shown, writes in the air included; undefined until it has loaded */ + private itemsOf(id: number): ItemRow[] | undefined { + const list = this.checklists.get(id); + if (list?.status !== "ok") return undefined; + return layItems( + list.items, + this.toggles.filter(t => t.cardId === id), + this.itemAdds.filter(a => a.cardId === id), + ); + } + + private shown(): Record<Column, ShownRow[] | undefined> { + const laid = layColumns(this.cols, this.moves, this.adds, this.archiving); + const shown = {} as Record<Column, ShownRow[] | undefined>; + for (const c of COLUMNS) + shown[c] = laid[c]?.map(r => { + if (r.kind === "adding") return r; + const items = this.itemsOf(r.card.id); + const { done, total } = items ? countItems(items) : r.card; + const title = newest(this.renames, n => n.id === r.card.id)?.title ?? r.card.title; + return { kind: "card", id: r.card.id, title, done, total }; + }); + return shown; + } + + render() { + if (this.signal.aborted) return; + const shown = this.shown(); + /** whether a card may move into `target` from another column: it has loaded, and Doing is under its limit */ + const canEnter = (target: Column) => { + const rows = shown[target]; + return rows !== undefined && (target !== "doing" || rows.length < WIP_LIMIT); + }; + const selected = this.detail?.view.id ?? null; + + for (const [at, c] of COLUMNS.entries()) { + const rows = shown[c]; + const { head, body } = this.colEls[c]; + head.textContent = `${COLUMN_NAME[c]}${rows ? ` (${rows.length})` : ""}`; + const error = this.colErrors[c]; + if (rows) { + const left = COLUMNS[at - 1] as Column | undefined; + const right = COLUMNS[at + 1] as Column | undefined; + const across = (dir: "left" | "right", target: Column | undefined, id: number) => { + if (!target) return null; + const b = button(dir, dir, () => void this.move(id, target, shown[target]?.length ?? 0)); + b.disabled = !canEnter(target); + return b; + }; + body.replaceChildren( + h( + "ul", + null, + ...rows.map((r, i) => { + if (r.kind === "adding") return h("li", null, h("span", { testid: "card" }, `${r.title} (adding…)`)); + const card = h("button", { type: "button", testid: "card", onclick: () => this.select(r.id) }, `#${r.id} ${r.title} · ${r.done}/${r.total}`); + card.setAttribute("aria-pressed", String(r.id === selected)); + return h( + "li", + null, + card, + " ", + across("left", left, r.id), + across("right", right, r.id), + i > 0 && button("up", "up", () => void this.move(r.id, c, i - 1)), + i < rows.length - 1 && button("down", "down", () => void this.move(r.id, c, i + 1)), + ); + }), + ), + ); + } else if (error !== null) + body.replaceChildren(h("p", null, `error: ${error} `, button(`retry-${c}`, "retry", () => void this.loadColumn(c)))); + else body.replaceChildren(h("p", null, "loading")); + } + this.addCardButton.disabled = shown.todo === undefined; + this.addErrorEl.replaceChildren(this.addError === null ? "" : h("p", { testid: "add-error" }, `add error: ${this.addError}`)); + + // the selected card as the board shows it + let view: DetailView | null = null; + for (const c of COLUMNS) + for (const r of shown[c] ?? []) + if (r.kind === "card" && r.id === selected) + view = { id: r.id, title: r.title, column: c, checklist: this.checklists.get(r.id) ?? { status: "loading" }, items: this.itemsOf(r.id) }; + if (this.detail && view) { + this.detail.view.render(view); + this.detailEl.replaceChildren(this.detail.view.root); + } else this.detailEl.replaceChildren(h("p", null, "select a card")); + + this.errorsEl.replaceChildren( + this.moveError === null ? "" : h("p", { testid: "move-error" }, `move error: ${this.moveError}`), + this.archiveError === null ? "" : h("p", { testid: "archive-error" }, `archive error: ${this.archiveError}`), + ); + } +} diff --git a/eval/reference/board/vanilla/dom.ts b/eval/reference/board/vanilla/dom.ts new file mode 100644 index 00000000..6417c7b9 --- /dev/null +++ b/eval/reference/board/vanilla/dom.ts @@ -0,0 +1,54 @@ +// Small DOM helpers: building elements, and reading errors. + +export type Child = Node | string | number | null | undefined | false; +type Props<K extends keyof HTMLElementTagNameMap> = Partial<Omit<HTMLElementTagNameMap[K], "dataset" | "style">> & { + testid?: string; +}; + +/** `h("button", { testid: "save", onclick }, "Save")`: an element with its properties and children */ +export function h<K extends keyof HTMLElementTagNameMap>(tag: K, props: Props<K> | null = null, ...children: Child[]) { + const node = document.createElement(tag); + if (props) { + const { testid, ...rest } = props; + if (testid) node.dataset.testid = testid; + Object.assign(node, rest); + } + node.append(...nodes(children)); + return node; +} + +export const nodes = (children: Child[]): (Node | string)[] => + children.filter((c): c is Node | string | number => c !== null && c !== undefined && c !== false).map(c => (typeof c === "number" ? String(c) : c)); + +/** a button that does not submit, with a test id */ +export const button = (testid: string, label: string, onclick: () => void) => + h("button", { type: "button", testid, onclick: () => onclick() }, label); + +/** a text input with a test id, calling `oninput` with its value */ +export function textInput(testid: string, value: string, oninput: (value: string) => void) { + const input = h("input", { type: "text", testid, value }); + input.addEventListener("input", () => oninput(input.value)); + return input; +} + +export const errorMessage = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +/** a signal that aborts when `parent` does or when the returned controller is aborted */ +export function child(parent: AbortSignal) { + const controller = new AbortController(); + return { controller, signal: AbortSignal.any([parent, controller.signal]) }; +} + +/** + * Hands out signals for one kind of request where only the newest matters (a list per filter, an item per + * selection): each call aborts the previous one's signal. All of them abort with `parent`. + */ +export function latest(parent: AbortSignal) { + let current: AbortController | null = null; + return () => { + current?.abort(); + const next = child(parent); + current = next.controller; + return next.signal; + }; +} diff --git a/eval/reference/board/vanilla/main.ts b/eval/reference/board/vanilla/main.ts new file mode 100644 index 00000000..44c9b69d --- /dev/null +++ b/eval/reference/board/vanilla/main.ts @@ -0,0 +1,27 @@ +// The sprint board: two pages behind a row of tabs. Only the open page exists: leaving a page aborts its signal, +// which discards everything it was waiting for, and opening it again builds it afresh. +import { archivePage } from "./archive"; +import { BoardPage } from "./board/page"; +import { button, h } from "./dom"; + +type Page = "board" | "archive"; + +export default function mount(root: HTMLElement): void { + let current: AbortController | null = null; + const tabs: Record<Page, HTMLButtonElement> = { + board: button("tab-board", "Board", () => open("board")), + archive: button("tab-archive", "Archive", () => open("archive")), + }; + const pageEl = h("div"); + root.replaceChildren(h("nav", null, tabs.board, " ", tabs.archive), pageEl); + + function open(page: Page) { + current?.abort(); + current = new AbortController(); + const signal = current.signal; + for (const [name, tab] of Object.entries(tabs)) tab.setAttribute("aria-pressed", String(name === page)); + pageEl.replaceChildren(page === "board" ? new BoardPage(signal).root : archivePage(signal)); + } + + open("board"); +} From 2df041f87bd3c94733f8b0ca85336159dc9e6a09 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 14 Nov 2026 12:21:24 +0000 Subject: [PATCH 12/32] eval: board XState reference (grades 0) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- eval/reference/board/xstate/App.tsx | 31 ++ eval/reference/board/xstate/GRADE.txt | 6 + eval/reference/board/xstate/appMachine.ts | 29 ++ .../board/xstate/archive/ArchivePage.tsx | 34 ++ .../board/xstate/archive/archiveMachine.ts | 74 ++++ .../board/xstate/board/BoardColumn.tsx | 79 ++++ .../board/xstate/board/BoardPage.tsx | 63 ++++ eval/reference/board/xstate/board/Detail.tsx | 87 +++++ .../board/xstate/board/boardMachine.ts | 345 ++++++++++++++++++ eval/reference/board/xstate/board/lay.ts | 118 ++++++ eval/reference/board/xstate/mutation.ts | 30 ++ 11 files changed, 896 insertions(+) create mode 100644 eval/reference/board/xstate/App.tsx create mode 100644 eval/reference/board/xstate/GRADE.txt create mode 100644 eval/reference/board/xstate/appMachine.ts create mode 100644 eval/reference/board/xstate/archive/ArchivePage.tsx create mode 100644 eval/reference/board/xstate/archive/archiveMachine.ts create mode 100644 eval/reference/board/xstate/board/BoardColumn.tsx create mode 100644 eval/reference/board/xstate/board/BoardPage.tsx create mode 100644 eval/reference/board/xstate/board/Detail.tsx create mode 100644 eval/reference/board/xstate/board/boardMachine.ts create mode 100644 eval/reference/board/xstate/board/lay.ts create mode 100644 eval/reference/board/xstate/mutation.ts diff --git a/eval/reference/board/xstate/App.tsx b/eval/reference/board/xstate/App.tsx new file mode 100644 index 00000000..7277d8f2 --- /dev/null +++ b/eval/reference/board/xstate/App.tsx @@ -0,0 +1,31 @@ +import { AppActor } from "./appMachine"; +import BoardPage from "./board/BoardPage"; +import ArchivePage from "./archive/ArchivePage"; + +export default function App() { + return ( + <AppActor.Provider> + <Tabs /> + </AppActor.Provider> + ); +} + +function Tabs() { + const app = AppActor.useActorRef(); + const page = AppActor.useSelector(s => (s.matches("board") ? "board" : "archive")); + const { board, archive } = AppActor.useSelector(s => s.children); + return ( + <div> + <nav> + <button type="button" data-testid="tab-board" aria-pressed={page === "board"} onClick={() => app.send({ type: "tab.board" })}> + Board + </button>{" "} + <button type="button" data-testid="tab-archive" aria-pressed={page === "archive"} onClick={() => app.send({ type: "tab.archive" })}> + Archive + </button> + </nav> + {board && <BoardPage key={board.sessionId} actor={board} />} + {archive && <ArchivePage key={archive.sessionId} actor={archive} />} + </div> + ); +} diff --git a/eval/reference/board/xstate/GRADE.txt b/eval/reference/board/xstate/GRADE.txt new file mode 100644 index 00000000..c1b3311e --- /dev/null +++ b/eval/reference/board/xstate/GRADE.txt @@ -0,0 +1,6 @@ +== hidden +45/45 passed +== overlap +26/26 passed +== fuzz +board-xstate: 0/20 seeds found a hole diff --git a/eval/reference/board/xstate/appMachine.ts b/eval/reference/board/xstate/appMachine.ts new file mode 100644 index 00000000..aff0e1aa --- /dev/null +++ b/eval/reference/board/xstate/appMachine.ts @@ -0,0 +1,29 @@ +import { setup } from "xstate"; +import { createActorContext } from "@xstate/react"; +import { boardMachine } from "./board/boardMachine"; +import { archiveMachine } from "./archive/archiveMachine"; + +/** + * The app: one page at a time, each an actor invoked while its tab is open. Leaving a page stops its actor, and with it + * every request the page still has in flight, so reopening a page starts it fresh. + */ +export const appMachine = setup({ + types: { + events: {} as { type: "tab.board" } | { type: "tab.archive" }, + children: {} as { board: "boardPage"; archive: "archivePage" }, + }, + actors: { boardPage: boardMachine, archivePage: archiveMachine }, +}).createMachine({ + id: "app", + initial: "board", + on: { + "tab.board": ".board", + "tab.archive": ".archive", + }, + states: { + board: { invoke: { id: "board", src: "boardPage" } }, + archive: { invoke: { id: "archive", src: "archivePage" } }, + }, +}); + +export const AppActor = createActorContext(appMachine); diff --git a/eval/reference/board/xstate/archive/ArchivePage.tsx b/eval/reference/board/xstate/archive/ArchivePage.tsx new file mode 100644 index 00000000..f2fb73a7 --- /dev/null +++ b/eval/reference/board/xstate/archive/ArchivePage.tsx @@ -0,0 +1,34 @@ +import { useSelector } from "@xstate/react"; +import type { ActorRefFrom } from "xstate"; +import { shownRows, type archiveMachine } from "./archiveMachine"; + +export default function ArchivePage({ actor }: { actor: ActorRefFrom<typeof archiveMachine> }) { + const state = useSelector(actor, s => s); + const { context } = state; + const { send } = actor; + return ( + <section> + <div data-testid="archived"> + {state.matches("loading") ? ( + <p>loading archive</p> + ) : state.matches("failed") ? ( + <p>archive error: {context.loadError}</p> + ) : ( + <ul> + {shownRows(context).map(a => ( + <li key={a.id}> + <span data-testid="archived-card"> + #{a.id} {a.title} + </span>{" "} + <button type="button" data-testid="restore" onClick={() => send({ type: "restore", id: a.id })}> + restore + </button> + </li> + ))} + </ul> + )} + </div> + {context.restoreError !== null && <p data-testid="restore-error">restore error: {context.restoreError}</p>} + </section> + ); +} diff --git a/eval/reference/board/xstate/archive/archiveMachine.ts b/eval/reference/board/xstate/archive/archiveMachine.ts new file mode 100644 index 00000000..e5e66578 --- /dev/null +++ b/eval/reference/board/xstate/archive/archiveMachine.ts @@ -0,0 +1,74 @@ +import { assign, enqueueActions, fromPromise, setup, stopChild } from "xstate"; +import { fetchArchived, restoreCard, type Archived } from "../api"; +import { errorMessage, mutation, type MutationEvent } from "../mutation"; + +/** A restore in flight: `seq` keeps two restores of one row apart. */ +type Restore = { seq: number; id: number }; + +type ArchiveContext = { + /** the archive as the server last answered it */ + rows: Archived[]; + loadError: string; + seq: number; + /** the restores in flight, oldest first */ + restoring: Restore[]; + restoreError: string | null; +}; + +type ArchiveEvent = { type: "restore"; id: number } | MutationEvent<Restore, { id: number }>; + +/** the rows as shown: the ones being restored are off the list until the server declines */ +export const shownRows = (ctx: ArchiveContext) => ctx.rows.filter(a => !ctx.restoring.some(r => r.id === a.id)); + +const restoreId = (seq: number) => `restore-${seq}`; + +export const archiveMachine = setup({ + types: { context: {} as ArchiveContext, events: {} as ArchiveEvent }, + actors: { + fetchArchived: fromPromise(() => fetchArchived()), + restore: mutation((r: Restore) => restoreCard(r.id)), + }, +}).createMachine({ + id: "archive", + context: { rows: [], loadError: "", seq: 0, restoring: [], restoreError: null }, + initial: "loading", + states: { + loading: { + invoke: { + src: "fetchArchived", + onDone: { target: "loaded", actions: assign({ rows: ({ event }) => event.output }) }, + onError: { target: "failed", actions: assign({ loadError: ({ event }) => errorMessage(event.error) }) }, + }, + }, + failed: {}, + loaded: { + on: { + restore: { + actions: enqueueActions(({ context, enqueue, event }) => { + const restore: Restore = { seq: context.seq + 1, id: event.id }; + enqueue.assign({ seq: restore.seq, restoring: [...context.restoring, restore], restoreError: null }); + enqueue.spawnChild("restore", { id: restoreId(restore.seq), input: restore }); + }), + }, + "mutation.succeeded": { + actions: [ + assign(({ context, event }) => ({ + rows: context.rows.filter(a => a.id !== event.output.id), + restoring: context.restoring.filter(r => r.seq !== event.input.seq), + })), + stopChild(({ event }) => restoreId(event.input.seq)), + ], + }, + "mutation.failed": { + actions: [ + assign(({ context, event }) => ({ + restoreError: event.message, + restoring: context.restoring.filter(r => r.seq !== event.input.seq), + })), + stopChild(({ event }) => restoreId(event.input.seq)), + ], + }, + }, + }, + }, +}); diff --git a/eval/reference/board/xstate/board/BoardColumn.tsx b/eval/reference/board/xstate/board/BoardColumn.tsx new file mode 100644 index 00000000..3adb2e29 --- /dev/null +++ b/eval/reference/board/xstate/board/BoardColumn.tsx @@ -0,0 +1,79 @@ +import type { ReactNode } from "react"; +import type { Column } from "../api"; +import { COLUMNS, COLUMN_NAME } from "./lay"; +import { canEnter, type ShownBoard } from "./boardMachine"; +import type { BoardActor } from "./BoardPage"; + +type Props = { + column: Column; + board: ShownBoard; + /** the load's failure, while the column shows it */ + failed: string | null; + selected: number | null; + send: BoardActor["send"]; + footer?: ReactNode; +}; + +export default function BoardColumn({ column, board, failed, selected, send, footer }: Props) { + const rows = board[column]; + const at = COLUMNS.indexOf(column); + const left = COLUMNS[at - 1]; + const right = COLUMNS[at + 1]; + const moveTo = (id: number, target: Column, index: number) => send({ type: "card.move", id, column: target, index }); + const toBottom = (id: number, target: Column) => moveTo(id, target, board[target]?.length ?? 0); + return ( + <section data-testid={`col-${column}`} style={{ minWidth: "14rem" }}> + <h2 data-testid={`head-${column}`}> + {COLUMN_NAME[column]} + {rows ? ` (${rows.length})` : ""} + </h2> + {rows ? ( + <ul> + {rows.map((r, i) => + r.kind === "adding" ? ( + <li key={`adding-${r.seq}`}> + <span data-testid="card">{r.title} (adding…)</span> + </li> + ) : ( + <li key={r.id}> + <button type="button" data-testid="card" aria-pressed={r.id === selected} onClick={() => send({ type: "card.select", id: r.id })}> + #{r.id} {r.title} · {r.done}/{r.total} + </button>{" "} + {left && ( + <button type="button" data-testid="left" disabled={!canEnter(board, left)} onClick={() => toBottom(r.id, left)}> + left + </button> + )} + {right && ( + <button type="button" data-testid="right" disabled={!canEnter(board, right)} onClick={() => toBottom(r.id, right)}> + right + </button> + )} + {i > 0 && ( + <button type="button" data-testid="up" onClick={() => moveTo(r.id, column, i - 1)}> + up + </button> + )} + {i < rows.length - 1 && ( + <button type="button" data-testid="down" onClick={() => moveTo(r.id, column, i + 1)}> + down + </button> + )} + </li> + ), + )} + </ul> + ) : failed !== null ? ( + <p> + error: {failed}{" "} + <button type="button" data-testid={`retry-${column}`} onClick={() => send({ type: "column.retry", column })}> + retry + </button> + </p> + ) : ( + <p>loading</p> + )} + {footer} + </section> + ); +} diff --git a/eval/reference/board/xstate/board/BoardPage.tsx b/eval/reference/board/xstate/board/BoardPage.tsx new file mode 100644 index 00000000..a78f75bc --- /dev/null +++ b/eval/reference/board/xstate/board/BoardPage.tsx @@ -0,0 +1,63 @@ +import { useSelector } from "@xstate/react"; +import type { ActorRefFrom } from "xstate"; +import { COLUMNS } from "./lay"; +import { boardMachine, errorOf, selectedCard, shownBoard, shownItems } from "./boardMachine"; +import BoardColumn from "./BoardColumn"; +import Detail from "./Detail"; + +export type BoardActor = ActorRefFrom<typeof boardMachine>; + +export default function BoardPage({ actor }: { actor: BoardActor }) { + const state = useSelector(actor, s => s); + const { context } = state; + const { send } = actor; + const board = shownBoard(context); + const card = selectedCard(context, board); + const addError = errorOf(context, "add"); + const moveError = errorOf(context, "move"); + const archiveError = errorOf(context, "archive"); + return ( + <div> + <div style={{ display: "flex", gap: "1.5rem", alignItems: "flex-start" }}> + {COLUMNS.map(c => ( + <BoardColumn + key={c} + column={c} + board={board} + failed={state.matches({ [c]: "failed" }) ? context.cardsError[c] : null} + selected={context.selected} + send={send} + footer={ + c === "todo" && ( + <div> + <input + data-testid="new-card" + value={context.newCard} + onChange={e => send({ type: "newCard.change", value: e.target.value })} + placeholder="new card" + />{" "} + <button type="button" data-testid="add-card" disabled={board.todo === null} onClick={() => send({ type: "card.add" })}> + add + </button> + {addError !== null && <p data-testid="add-error">add error: {addError}</p>} + </div> + ) + } + /> + ))} + <Detail + card={card} + items={card && shownItems(context, card.id)} + checklistError={state.matches({ detail: "failed" }) ? context.checklistError : null} + titleInput={context.titleInput} + itemInput={context.itemInput} + renameError={errorOf(context, "rename")} + itemError={errorOf(context, "item")} + send={send} + /> + </div> + {moveError !== null && <p data-testid="move-error">move error: {moveError}</p>} + {archiveError !== null && <p data-testid="archive-error">archive error: {archiveError}</p>} + </div> + ); +} diff --git a/eval/reference/board/xstate/board/Detail.tsx b/eval/reference/board/xstate/board/Detail.tsx new file mode 100644 index 00000000..f7300d44 --- /dev/null +++ b/eval/reference/board/xstate/board/Detail.tsx @@ -0,0 +1,87 @@ +import type { Column } from "../api"; +import { COLUMN_NAME, countItems, type ItemRow } from "./lay"; +import type { BoardActor } from "./BoardPage"; + +type Props = { + /** the selected card as the board shows it; null while nothing is selected */ + card: { id: number; title: string; column: Column } | null; + /** its checklist as shown; null until it has loaded */ + items: ItemRow[] | null; + checklistError: string | null; + titleInput: string; + itemInput: string; + renameError: string | null; + itemError: string | null; + send: BoardActor["send"]; +}; + +export default function Detail({ card, items, checklistError, titleInput, itemInput, renameError, itemError, send }: Props) { + if (!card) + return ( + <section data-testid="detail"> + <p>select a card</p> + </section> + ); + const { done, total } = items ? countItems(items) : { done: 0, total: 0 }; + return ( + <section data-testid="detail" style={{ minWidth: "16rem" }}> + <h2> + #{card.id} {card.title} + </h2> + <p>in {COLUMN_NAME[card.column]}</p> + <div> + <input + data-testid="title-input" + value={titleInput} + onChange={e => send({ type: "title.change", value: e.target.value })} + placeholder="new title" + />{" "} + <button type="button" data-testid="rename" onClick={() => send({ type: "card.rename" })}> + rename + </button>{" "} + <button type="button" data-testid="archive" onClick={() => send({ type: "card.archive" })}> + archive + </button> + </div> + {renameError !== null && <p>rename error: {renameError}</p>} + <div data-testid="checklist"> + {items ? ( + <> + <ul> + {items.map(r => + r.kind === "adding" ? ( + <li key={`adding-${r.seq}`}> + <span data-testid="item">[ ] {r.text} (adding…)</span> + </li> + ) : ( + <li key={r.id}> + <span data-testid="item"> + [{r.done ? "x" : " "}] {r.text} + </span>{" "} + <button type="button" data-testid="toggle" onClick={() => send({ type: "item.toggle", itemId: r.id, done: !r.done })}> + toggle + </button> + </li> + ), + )} + </ul> + <p data-testid="progress"> + {done}/{total} done + </p> + </> + ) : checklistError !== null ? ( + <p>checklist error: {checklistError}</p> + ) : ( + <p>loading checklist</p> + )} + </div> + <div> + <input data-testid="item-input" value={itemInput} onChange={e => send({ type: "item.change", value: e.target.value })} placeholder="new item" />{" "} + <button type="button" data-testid="add-item" disabled={!items} onClick={() => send({ type: "item.add" })}> + add item + </button> + </div> + {itemError !== null && <p>item error: {itemError}</p>} + </section> + ); +} diff --git a/eval/reference/board/xstate/board/boardMachine.ts b/eval/reference/board/xstate/board/boardMachine.ts new file mode 100644 index 00000000..6d8cf87c --- /dev/null +++ b/eval/reference/board/xstate/board/boardMachine.ts @@ -0,0 +1,345 @@ +import { assign, enqueueActions, fromPromise, setup, stopChild } from "xstate"; +import * as api from "../api"; +import type { Column, Item } from "../api"; +import { errorMessage, mutation, type MutationEvent } from "../mutation"; +import { + COLUMNS, + WIP_LIMIT, + addSlot, + countItems, + isColumn, + layColumns, + layItems, + mapCards, + newest, + placeCard, + writesOf, + type Columns, + type ItemRow, + type KnownItem, + type Write, + type WriteRequest, +} from "./lay"; + +/** What the server answered a write, in the terms the page needs. */ +type Outcome = + | { kind: "moved"; id: number; column: string; index: number } + | { kind: "cardAdded"; id: number; title: string } + | { kind: "archived"; id: number } + | { kind: "renamed"; id: number; title: string } + | { kind: "toggled"; id: number; done: boolean } + | { kind: "itemAdded"; item: Item }; + +async function perform(w: Write): Promise<Outcome> { + switch (w.kind) { + case "move": + return { kind: "moved", ...(await api.moveCard(w.id, w.column, w.index)) }; + case "addCard": + return { kind: "cardAdded", ...(await api.addCard("todo", w.title)) }; + case "archive": + return { kind: "archived", ...(await api.archiveCard(w.id)) }; + case "rename": + return { kind: "renamed", ...(await api.renameCard(w.id, w.title)) }; + case "toggle": + return { kind: "toggled", ...(await api.toggleItem(w.cardId, w.itemId, w.done)) }; + case "addItem": + return { kind: "itemAdded", item: await api.addItem(w.cardId, w.text) }; + } +} + +/** Each kind of failure has one line on screen. */ +export type Channel = "move" | "add" | "archive" | "rename" | "item"; +const CHANNEL: Record<Write["kind"], Channel> = { + move: "move", + addCard: "add", + archive: "archive", + rename: "rename", + toggle: "item", + addItem: "item", +}; +/** the detail's lines belong to the selection they happened in; the board's stay until their next write starts */ +const DETAIL_CHANNELS: Channel[] = ["rename", "item"]; + +export type BoardContext = { + /** each column's cards as the server last answered them; null until the column loads */ + cards: Columns; + cardsError: Record<Column, string>; + /** the checklists this page has loaded, by card, as the server last answered them; kept for the page's life */ + checklists: Record<number, KnownItem[]>; + checklistError: string; + selected: number | null; + /** counts selections, even of the same card again */ + visit: number; + newCard: string; + titleInput: string; + itemInput: string; + seq: number; + /** the writes in flight, oldest first */ + writes: Write[]; + errors: Partial<Record<Channel, { visit: number; message: string }>>; +}; + +export type BoardEvent = + | { type: "column.retry"; column: Column } + | { type: "newCard.change"; value: string } + | { type: "title.change"; value: string } + | { type: "item.change"; value: string } + | { type: "card.add" } + | { type: "card.move"; id: number; column: Column; index: number } + | { type: "card.select"; id: number } + | { type: "card.rename" } + | { type: "card.archive" } + | { type: "item.toggle"; itemId: number; done: boolean } + | { type: "item.add" } + | MutationEvent<Write, Outcome>; + +// --- what the page shows --- + +/** a card as the board shows it, or one being added (no id yet) */ +export type ShownRow = { kind: "card"; id: number; title: string; done: number; total: number } | { kind: "adding"; seq: number; title: string }; +export type ShownBoard = Record<Column, ShownRow[] | null>; + +/** a card's checklist as shown, or null while it has not loaded */ +export function shownItems(ctx: BoardContext, cardId: number): ItemRow[] | null { + const items = ctx.checklists[cardId]; + return items ? layItems(items, ctx.writes, cardId) : null; +} + +/** the columns as shown; a card whose checklist is known counts its items as the checklist shows them */ +export function shownBoard(ctx: BoardContext): ShownBoard { + const laid = layColumns(ctx.cards, ctx.writes); + const renames = writesOf(ctx.writes, "rename"); + const board = {} as ShownBoard; + for (const c of COLUMNS) + board[c] = + laid[c]?.map(r => { + if (r.kind === "adding") return r; + const items = shownItems(ctx, r.card.id); + const { done, total } = items ? countItems(items) : r.card; + const title = newest(renames, n => n.id === r.card.id)?.title ?? r.card.title; + return { kind: "card", id: r.card.id, title, done, total }; + }) ?? null; + return board; +} + +/** the selected card as the board shows it, and where; null while nothing selected shows */ +export function selectedCard(ctx: BoardContext, board: ShownBoard) { + for (const column of COLUMNS) + for (const r of board[column] ?? []) if (r.kind === "card" && r.id === ctx.selected) return { id: r.id, title: r.title, column }; + return null; +} + +/** whether a card from another column may move into `target`: it has loaded, and Doing is under its limit */ +export function canEnter(board: ShownBoard, target: Column) { + const rows = board[target]; + return rows !== null && (target !== "doing" || rows.length < WIP_LIMIT); +} + +/** the error line of `channel`, if it belongs on screen now */ +export function errorOf(ctx: BoardContext, channel: Channel) { + const e = ctx.errors[channel]; + return e && (!DETAIL_CHANNELS.includes(channel) || e.visit === ctx.visit) ? e.message : null; +} + +// --- applying the server's answers --- + +function accepted(ctx: BoardContext, w: Write, out: Outcome): Partial<BoardContext> { + switch (out.kind) { + case "moved": { + const column = isColumn(out.column) ? out.column : w.kind === "move" ? w.column : "todo"; + return { cards: placeCard(ctx.cards, out.id, column, out.index) }; + } + case "cardAdded": { + const todo = ctx.cards.todo; + // a reload of To do may already hold it + if (!todo || todo.some(x => x.id === out.id)) return {}; + const next = [...todo]; + next.splice(addSlot(todo, w.seq, x => x.addSeq), 0, { id: out.id, title: out.title, done: 0, total: 0, addSeq: w.seq }); + return { cards: { ...ctx.cards, todo: next } }; + } + case "archived": + return { cards: mapCards(ctx.cards, cards => cards.filter(x => x.id !== out.id)) }; + case "renamed": + return { cards: mapCards(ctx.cards, cards => cards.map(x => (x.id === out.id ? { ...x, title: out.title } : x))) }; + case "toggled": { + if (w.kind !== "toggle") return {}; + const items = ctx.checklists[w.cardId]; + if (!items) return {}; + return { checklists: { ...ctx.checklists, [w.cardId]: items.map(i => (i.id === out.id ? { ...i, done: out.done } : i)) } }; + } + case "itemAdded": { + if (w.kind !== "addItem") return {}; + const items = ctx.checklists[w.cardId]; + if (!items || items.some(i => i.id === out.item.id)) return {}; + const next = [...items]; + next.splice(addSlot(items, w.seq, i => i.addSeq), 0, { ...out.item, addSeq: w.seq }); + return { checklists: { ...ctx.checklists, [w.cardId]: next } }; + } + } +} + +const writeId = (seq: number) => `write-${seq}`; +const withoutWrite = (ctx: BoardContext, w: Write) => ctx.writes.filter(x => x.seq !== w.seq); + +const board = setup({ + types: { context: {} as BoardContext, events: {} as BoardEvent }, + actors: { + fetchCards: fromPromise(async ({ input }: { input: Column }) => ({ column: input, cards: await api.fetchCards(input) })), + fetchChecklist: fromPromise(async ({ input }: { input: number }) => ({ cardId: input, items: await api.fetchChecklist(input) })), + write: mutation(perform), + }, + actions: { + /** start a write: it shows at once, clears its channel's error, and goes to the server */ + startWrite: enqueueActions(({ context, enqueue }, request: WriteRequest) => { + const write: Write = { ...request, seq: context.seq + 1, visit: context.visit }; + enqueue.assign({ + seq: write.seq, + writes: [...context.writes, write], + errors: { ...context.errors, [CHANNEL[write.kind]]: undefined }, + }); + enqueue.spawnChild("write", { id: writeId(write.seq), input: write }); + }), + }, + guards: { + newCardTyped: ({ context }) => context.newCard !== "" && context.cards.todo !== null, + moveAllowed: ({ context, event }) => { + if (event.type !== "card.move") return false; + const shown = shownBoard(context); + const from = COLUMNS.find(c => shown[c]?.some(r => r.kind === "card" && r.id === event.id)); + return from !== undefined && (from === event.column || canEnter(shown, event.column)); + }, + selectedShown: ({ context }) => selectedCard(context, shownBoard(context)) !== null, + titleTyped: ({ context }) => context.titleInput !== "" && selectedCard(context, shownBoard(context)) !== null, + itemTyped: ({ context }) => + context.itemInput !== "" && context.selected !== null && context.checklists[context.selected] !== undefined && selectedCard(context, shownBoard(context)) !== null, + checklistKnown: ({ context }) => context.selected !== null && context.checklists[context.selected] !== undefined, + }, +}); + +/** one column: it loads when the page opens, and a failed load can be asked again */ +const column = (c: Column) => + board.createStateConfig({ + initial: "loading", + states: { + loading: { + invoke: { + src: "fetchCards", + input: c, + onDone: { + target: "loaded", + actions: assign({ cards: ({ context, event }) => ({ ...context.cards, [event.output.column]: event.output.cards }) }), + }, + onError: { + target: "failed", + actions: assign({ cardsError: ({ context, event }) => ({ ...context.cardsError, [c]: errorMessage(event.error) }) }), + }, + }, + }, + loaded: {}, + failed: { + on: { "column.retry": { guard: ({ event }) => event.column === c, target: "loading" } }, + }, + }, + }); + +export const boardMachine = board.createMachine({ + id: "board", + type: "parallel", + context: { + cards: { todo: null, doing: null, done: null }, + cardsError: { todo: "", doing: "", done: "" }, + checklists: {}, + checklistError: "", + selected: null, + visit: 0, + newCard: "", + titleInput: "", + itemInput: "", + seq: 0, + writes: [], + errors: {}, + }, + on: { + "newCard.change": { actions: assign({ newCard: ({ event }) => event.value }) }, + "title.change": { actions: assign({ titleInput: ({ event }) => event.value }) }, + "item.change": { actions: assign({ itemInput: ({ event }) => event.value }) }, + "card.add": { + guard: "newCardTyped", + actions: [{ type: "startWrite", params: ({ context }) => ({ kind: "addCard", title: context.newCard }) }, assign({ newCard: "" })], + }, + "card.move": { + guard: "moveAllowed", + actions: { type: "startWrite", params: ({ event }) => ({ kind: "move", id: event.id, column: event.column, index: event.index }) }, + }, + "mutation.succeeded": { + actions: [ + assign(({ context, event }) => ({ ...accepted(context, event.input, event.output), writes: withoutWrite(context, event.input) })), + stopChild(({ event }) => writeId(event.input.seq)), + ], + }, + "mutation.failed": { + actions: [ + assign(({ context, event }) => ({ + errors: { ...context.errors, [CHANNEL[event.input.kind]]: { visit: event.input.visit, message: event.message } }, + writes: withoutWrite(context, event.input), + })), + stopChild(({ event }) => writeId(event.input.seq)), + ], + }, + }, + states: { + todo: column("todo"), + doing: column("doing"), + done: column("done"), + detail: { + initial: "none", + on: { + "card.select": { + target: ".checking", + actions: assign(({ context, event }) => ({ selected: event.id, visit: context.visit + 1, titleInput: "", itemInput: "" })), + }, + "card.rename": { + guard: "titleTyped", + actions: { type: "startWrite", params: ({ context }) => ({ kind: "rename", id: context.selected!, title: context.titleInput }) }, + }, + "card.archive": { + guard: "selectedShown", + target: ".none", + actions: [{ type: "startWrite", params: ({ context }) => ({ kind: "archive", id: context.selected! }) }, assign({ selected: null })], + }, + "item.toggle": { + guard: "selectedShown", + actions: { + type: "startWrite", + params: ({ context, event }) => ({ kind: "toggle", cardId: context.selected!, itemId: event.itemId, done: event.done }), + }, + }, + "item.add": { + guard: "itemTyped", + actions: [ + { type: "startWrite", params: ({ context }) => ({ kind: "addItem", cardId: context.selected!, text: context.itemInput }) }, + assign({ itemInput: "" }), + ], + }, + }, + states: { + none: {}, + // a checklist this page already has shows as it is; any other loads + checking: { always: [{ guard: "checklistKnown", target: "loaded" }, { target: "loading" }] }, + loading: { + invoke: { + src: "fetchChecklist", + input: ({ context }) => context.selected!, + onDone: { + target: "loaded", + actions: assign({ checklists: ({ context, event }) => ({ ...context.checklists, [event.output.cardId]: event.output.items }) }), + }, + onError: { target: "failed", actions: assign({ checklistError: ({ event }) => errorMessage(event.error) }) }, + }, + }, + loaded: {}, + failed: {}, + }, + }, + }, +}); diff --git a/eval/reference/board/xstate/board/lay.ts b/eval/reference/board/xstate/board/lay.ts new file mode 100644 index 00000000..be753ee8 --- /dev/null +++ b/eval/reference/board/xstate/board/lay.ts @@ -0,0 +1,118 @@ +// The board as shown: the server's last answers with the writes still in the air laid over them. +import type { Card, Column, Item } from "../api"; + +export const COLUMNS: Column[] = ["todo", "doing", "done"]; +export const COLUMN_NAME: Record<Column, string> = { todo: "To do", doing: "Doing", done: "Done" }; +export const isColumn = (c: string): c is Column => (COLUMNS as string[]).includes(c); +/** Doing holds at most this many cards */ +export const WIP_LIMIT = 3; + +/** a card or item as the page last had it from the server; `addSeq` marks one this page added, by its add's seq */ +export type KnownCard = Card & { addSeq?: number }; +export type KnownItem = Item & { addSeq?: number }; +export type Columns = Record<Column, KnownCard[] | null>; + +export type WriteRequest = + | { kind: "move"; id: number; column: Column; index: number } + | { kind: "addCard"; title: string } + | { kind: "archive"; id: number } + | { kind: "rename"; id: number; title: string } + | { kind: "toggle"; cardId: number; itemId: number; done: boolean } + | { kind: "addItem"; cardId: number; text: string }; + +/** A write in flight. `seq` orders all of the page's writes by when they started; `visit` is the selection it was made in. */ +export type Write = WriteRequest & { seq: number; visit: number }; +type WriteOf<K extends Write["kind"]> = Extract<Write, { kind: K }>; +export const writesOf = <K extends Write["kind"]>(writes: Write[], kind: K) => writes.filter((w): w is WriteOf<K> => w.kind === kind); + +/** the newest of the writes that `match` (writes are kept in the order they started) */ +export function newest<T>(writes: T[], match: (w: T) => boolean): T | undefined { + for (let i = writes.length - 1; i >= 0; i--) if (match(writes[i])) return writes[i]; + return undefined; +} + +/** + * Where an entry from the add with `seq` goes in a list: before the first entry that a later add put there (that add + * answered first), else at the end. So entries keep the order their adds started in, guessed or answered. + */ +export function addSlot<T>(list: T[], seq: number, seqOf: (e: T) => number | undefined) { + const i = list.findIndex(e => (seqOf(e) ?? -Infinity) > seq); + return i < 0 ? list.length : i; +} + +// --- cards --- + +export type CardRow = { kind: "card"; card: KnownCard } | { kind: "adding"; seq: number; title: string }; +const rowSeq = (r: CardRow) => (r.kind === "adding" ? r.seq : r.card.addSeq); + +/** + * The loaded columns with the moves, adds and archives in the air replayed over them in the order they started, so + * each lands where it was made: a move at the position it was shown at, an add at the bottom of To do. + */ +export function layColumns(base: Columns, writes: Write[]): Record<Column, CardRow[] | null> { + const cols = {} as Record<Column, CardRow[] | null>; + for (const c of COLUMNS) cols[c] = base[c]?.map(card => ({ kind: "card", card })) ?? null; + const find = (id: number) => { + for (const c of COLUMNS) { + const list = cols[c]; + const i = list?.findIndex(r => r.kind === "card" && r.card.id === id) ?? -1; + if (list && i >= 0) return { list, i }; + } + return null; + }; + for (const w of writes) { + if (w.kind === "addCard") { + const todo = cols.todo; + if (todo) todo.splice(addSlot(todo, w.seq, rowSeq), 0, { kind: "adding", seq: w.seq, title: w.title }); + continue; + } + if (w.kind !== "move" && w.kind !== "archive") continue; + const at = find(w.id); + if (!at) continue; + if (w.kind === "archive") { + at.list.splice(at.i, 1); + continue; + } + const target = cols[w.column]; + if (!target) continue; + const [row] = at.list.splice(at.i, 1); + target.splice(Math.min(w.index, target.length), 0, row); + } + return cols; +} + +/** the card the server answered at `column`/`index`, taken out of wherever the page holds it */ +export function placeCard(cols: Columns, id: number, column: Column, index: number): Columns { + const card = COLUMNS.flatMap(c => cols[c] ?? []).find(x => x.id === id); + const next = {} as Columns; + for (const c of COLUMNS) next[c] = cols[c]?.filter(x => x.id !== id) ?? null; + const target = next[column]; + if (card && target) target.splice(Math.min(index, target.length), 0, card); + return next; +} + +export const mapCards = (cols: Columns, f: (cards: KnownCard[]) => KnownCard[]): Columns => ({ + todo: cols.todo && f(cols.todo), + doing: cols.doing && f(cols.doing), + done: cols.done && f(cols.done), +}); + +// --- checklist items --- + +export type ItemRow = { kind: "item"; id: number; text: string; done: boolean } | { kind: "adding"; seq: number; text: string }; + +/** a card's checklist with its toggles (the newest one per item) and item adds in the air laid over it */ +export function layItems(base: KnownItem[], writes: Write[], cardId: number): ItemRow[] { + const toggles = writesOf(writes, "toggle").filter(t => t.cardId === cardId); + type Entry = { row: ItemRow; seq: number | undefined }; + const list: Entry[] = base.map(item => ({ + row: { kind: "item", id: item.id, text: item.text, done: newest(toggles, t => t.itemId === item.id)?.done ?? item.done }, + seq: item.addSeq, + })); + for (const a of writesOf(writes, "addItem")) + if (a.cardId === cardId) list.splice(addSlot(list, a.seq, e => e.seq), 0, { row: { kind: "adding", seq: a.seq, text: a.text }, seq: a.seq }); + return list.map(e => e.row); +} + +/** an item list's done and total, the adds in the air counted as not done */ +export const countItems = (rows: ItemRow[]) => ({ done: rows.filter(r => r.kind === "item" && r.done).length, total: rows.length }); diff --git a/eval/reference/board/xstate/mutation.ts b/eval/reference/board/xstate/mutation.ts new file mode 100644 index 00000000..8e982ce3 --- /dev/null +++ b/eval/reference/board/xstate/mutation.ts @@ -0,0 +1,30 @@ +import { fromCallback, type EventObject } from "xstate"; + +export const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error)); + +/** What a mutation actor tells its parent when the server answers. */ +export type MutationEvent<TInput, TOutput> = + | { type: "mutation.succeeded"; input: TInput; output: TOutput } + | { type: "mutation.failed"; input: TInput; message: string }; + +/** + * Logic for one write to the server, spawned once per write so that any number can be in flight at once. It reports + * back to its parent with a `MutationEvent` carrying its input; once stopped (its parent stopped, or it was cleaned up + * after reporting) it reports nothing, so an answer for a page that was left never reaches anything. + */ +export function mutation<TInput, TOutput>(request: (input: TInput) => Promise<TOutput>) { + return fromCallback<EventObject, TInput>(({ input, sendBack }) => { + let live = true; + request(input).then( + output => { + if (live) sendBack({ type: "mutation.succeeded", input, output } satisfies MutationEvent<TInput, TOutput>); + }, + (error: unknown) => { + if (live) sendBack({ type: "mutation.failed", input, message: errorMessage(error) } satisfies MutationEvent<TInput, TOutput>); + }, + ); + return () => { + live = false; + }; + }); +} From 0cba38d51b6906c85d1e90a79f766b496c11c415 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 14 Nov 2026 12:24:36 +0000 Subject: [PATCH 13/32] eval: board Foldkit reference (grades 0) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- eval/reference/board/foldkit/GRADE.txt | 6 + eval/reference/board/foldkit/archive.ts | 145 +++++ eval/reference/board/foldkit/board.ts | 790 ++++++++++++++++++++++++ eval/reference/board/foldkit/layout.ts | 214 +++++++ eval/reference/board/foldkit/main.ts | 137 ++++ eval/reference/board/foldkit/request.ts | 7 + 6 files changed, 1299 insertions(+) create mode 100644 eval/reference/board/foldkit/GRADE.txt create mode 100644 eval/reference/board/foldkit/archive.ts create mode 100644 eval/reference/board/foldkit/board.ts create mode 100644 eval/reference/board/foldkit/layout.ts create mode 100644 eval/reference/board/foldkit/main.ts create mode 100644 eval/reference/board/foldkit/request.ts diff --git a/eval/reference/board/foldkit/GRADE.txt b/eval/reference/board/foldkit/GRADE.txt new file mode 100644 index 00000000..1cedb44e --- /dev/null +++ b/eval/reference/board/foldkit/GRADE.txt @@ -0,0 +1,6 @@ +== hidden +45/45 passed +== overlap +26/26 passed +== fuzz +board-foldkit: 0/20 seeds found a hole diff --git a/eval/reference/board/foldkit/archive.ts b/eval/reference/board/foldkit/archive.ts new file mode 100644 index 00000000..bdeb3d1b --- /dev/null +++ b/eval/reference/board/foldkit/archive.ts @@ -0,0 +1,145 @@ +import { Array, Effect, Option, Schema } from 'effect' +import { Command, Submodel, type Update } from 'foldkit' +import { Html, HtmlBuilder } from 'foldkit/html' +import { defineMessageUnion } from 'foldkit/message' +import { defineTaggedUnion } from 'foldkit/schema' +import { modifyFields } from 'foldkit/struct' + +import * as Api from './api' +import { request } from './request' + +// The archive page: archived cards, most recently archived first, each of which can be restored to the board. + +// MODEL + +const Archived = Schema.Struct({ id: Schema.Number, title: Schema.String }) +type Archived = typeof Archived.Type + +export const ListState = defineTaggedUnion({ + Loading: {}, + Loaded: { cards: Schema.Array(Archived) }, + Failed: { error: Schema.String }, +}) + +// A restore still in the air, each with its own seq so that two restores of one row settle separately. +const Restoring = Schema.Struct({ seq: Schema.Number, id: Schema.Number }) + +export const Model = Schema.Struct({ + list: ListState, + seq: Schema.Number, + restoring: Schema.Array(Restoring), + maybeRestoreError: Schema.Option(Schema.String), +}) +export type Model = typeof Model.Type + +// MESSAGE + +export const Message = defineMessageUnion({ + SucceededFetchArchived: { cards: Schema.Array(Archived) }, + FailedFetchArchived: { error: Schema.String }, + ClickedRestore: { id: Schema.Number }, + SucceededRestore: { seq: Schema.Number, id: Schema.Number }, + FailedRestore: { seq: Schema.Number, error: Schema.String }, +}) +export type Message = typeof Message.Type + +// COMMAND + +const FetchArchived = Command.define('FetchArchived', { + args: {}, + messages: [Message.SucceededFetchArchived, Message.FailedFetchArchived], + execute: () => + request(() => Api.fetchArchived()).pipe( + Effect.map(cards => Message.SucceededFetchArchived({ cards })), + Effect.catch(error => Effect.succeed(Message.FailedFetchArchived({ error }))), + ), +}) + +const RestoreCard = Command.define('RestoreCard', { + args: { seq: Schema.Number, id: Schema.Number }, + messages: [Message.SucceededRestore, Message.FailedRestore], + execute: ({ seq, id }) => + request(() => Api.restoreCard(id)).pipe( + Effect.as(Message.SucceededRestore({ seq, id })), + Effect.catch(error => Effect.succeed(Message.FailedRestore({ seq, error }))), + ), +}) + +// INIT + +export const init = (): Update.Return<Model, Message> => ({ + model: { list: ListState.Loading(), seq: 1, restoring: [], maybeRestoreError: Option.none() }, + commands: [FetchArchived({})], +}) + +// UPDATE + +type UpdateReturn = Update.Return<Model, Message> + +const settleRestore = (model: Model, seq: number): Model => + modifyFields(model, { restoring: Array.filter(restoring => restoring.seq !== seq) }) + +export const update = (model: Model, message: Message) => + Message.match<UpdateReturn>(message, { + SucceededFetchArchived: ({ cards }) => ({ model: modifyFields(model, { list: () => ListState.Loaded({ cards }) }) }), + + FailedFetchArchived: ({ error }) => ({ model: modifyFields(model, { list: () => ListState.Failed({ error }) }) }), + + ClickedRestore: ({ id }) => ({ + model: modifyFields(model, { + seq: seq => seq + 1, + maybeRestoreError: () => Option.none(), + restoring: Array.append({ seq: model.seq, id }), + }), + commands: [RestoreCard({ seq: model.seq, id })], + }), + + SucceededRestore: ({ seq, id }) => ({ + model: modifyFields(settleRestore(model, seq), { + list: list => + ListState.match(list, { + Loading: () => list, + Failed: () => list, + Loaded: ({ cards }) => ListState.Loaded({ cards: Array.filter(cards, card => card.id !== id) }), + }), + }), + }), + + FailedRestore: ({ seq, error }) => ({ + model: modifyFields(settleRestore(model, seq), { maybeRestoreError: () => Option.some(error) }), + }), + }) + +// VIEW + +const testId = (h: HtmlBuilder<Message>, id: string) => h.DataAttribute('testid', id) + +const archivedRow = (card: Archived, h: HtmlBuilder<Message>): Html => + h.keyed('li')(String(card.id), [], [ + h.span([testId(h, 'archived-card')], [`#${card.id} ${card.title}`]), + ' ', + h.button([testId(h, 'restore'), h.OnClick(Message.ClickedRestore({ id: card.id }))], ['restore']), + ]) + +const listView = (model: Model, h: HtmlBuilder<Message>): Html => + ListState.match(model.list, { + Loading: () => h.p([], ['loading archive']), + Failed: ({ error }) => h.p([], [`archive error: ${error}`]), + Loaded: ({ cards }) => + h.ul( + [], + cards + .filter(card => !Array.some(model.restoring, restoring => restoring.id === card.id)) + .map(card => archivedRow(card, h)), + ), + }) + +export const view = Submodel.defineView<Model, Message>((model, h) => + h.section([], [ + h.div([testId(h, 'archived')], [listView(model, h)]), + Option.match(model.maybeRestoreError, { + onNone: () => h.empty, + onSome: error => h.p([testId(h, 'restore-error')], [`restore error: ${error}`]), + }), + ]), +) diff --git a/eval/reference/board/foldkit/board.ts b/eval/reference/board/foldkit/board.ts new file mode 100644 index 00000000..78e74488 --- /dev/null +++ b/eval/reference/board/foldkit/board.ts @@ -0,0 +1,790 @@ +import { Array, Effect, Option, Schema, pipe } from 'effect' +import { Command, Submodel, type Update } from 'foldkit' +import { Html, HtmlBuilder } from 'foldkit/html' +import { defineMessageUnion } from 'foldkit/message' +import { defineTaggedUnion } from 'foldkit/schema' +import { modifyFields } from 'foldkit/struct' + +import * as Api from './api' +import { + Archiving, + COLUMNS, + COLUMN_NAME, + Card, + CardAdd, + type CardRow, + Column, + type Columns, + Item, + ItemAdd, + type ItemRow, + Move, + Rename, + Toggle, + WIP_LIMIT, + addSlot, + countItems, + layColumns, + layItems, + mapCards, + newest, + placeCard, +} from './layout' +import { request } from './request' + +// The board page. Its model lives as long as one visit of the page: what it loaded, the writes it started that are +// still in the air, and its inputs and errors. + +// MODEL + +const ServerCard = Schema.Struct({ id: Schema.Number, title: Schema.String, done: Schema.Number, total: Schema.Number }) +const ServerItem = Schema.Struct({ id: Schema.Number, text: Schema.String, done: Schema.Boolean }) + +export const ColumnState = defineTaggedUnion({ + Loading: {}, + Loaded: { cards: Schema.Array(Card) }, + Failed: { error: Schema.String }, +}) +type ColumnState = typeof ColumnState.Type + +const ColumnStates = Schema.Struct({ todo: ColumnState, doing: ColumnState, done: ColumnState }) +type ColumnStates = typeof ColumnStates.Type + +export const ChecklistState = defineTaggedUnion({ + Loading: {}, + Loaded: { items: Schema.Array(Item) }, + Failed: { error: Schema.String }, +}) +type ChecklistState = typeof ChecklistState.Type + +// Every checklist the page asked for stays as last answered for the page's life, so a card whose checklist is known +// counts its items as the checklist shows them, writes in the air included. +const Checklist = Schema.Struct({ cardId: Schema.Number, state: ChecklistState }) + +// An error that belongs to one selection of a card (`visit` counts selections): selecting a card, even the same one +// again, leaves it behind, and a write that answers after its selection was left reports nothing. +const VisitError = Schema.Struct({ visit: Schema.Number, message: Schema.String }) +type VisitError = typeof VisitError.Type + +export const Model = Schema.Struct({ + columns: ColumnStates, + checklists: Schema.Array(Checklist), + maybeSelected: Schema.Option(Schema.Number), + visit: Schema.Number, + newCard: Schema.String, + titleInput: Schema.String, + itemInput: Schema.String, + seq: Schema.Number, + moves: Schema.Array(Move), + cardAdds: Schema.Array(CardAdd), + archiving: Schema.Array(Archiving), + renames: Schema.Array(Rename), + toggles: Schema.Array(Toggle), + itemAdds: Schema.Array(ItemAdd), + maybeMoveError: Schema.Option(Schema.String), + maybeAddError: Schema.Option(Schema.String), + maybeArchiveError: Schema.Option(Schema.String), + maybeRenameError: Schema.Option(VisitError), + maybeItemError: Schema.Option(VisitError), +}) +export type Model = typeof Model.Type + +// MESSAGE + +export const Message = defineMessageUnion({ + SucceededFetchCards: { column: Column, cards: Schema.Array(ServerCard) }, + FailedFetchCards: { column: Column, error: Schema.String }, + ClickedRetry: { column: Column }, + ClickedMove: { id: Schema.Number, column: Column, index: Schema.Number }, + SucceededMove: { seq: Schema.Number, id: Schema.Number, column: Column, index: Schema.Number }, + FailedMove: { seq: Schema.Number, error: Schema.String }, + UpdatedNewCard: { value: Schema.String }, + ClickedAddCard: {}, + SucceededAddCard: { seq: Schema.Number, id: Schema.Number, title: Schema.String }, + FailedAddCard: { seq: Schema.Number, error: Schema.String }, + ClickedCard: { id: Schema.Number }, + UpdatedTitleInput: { value: Schema.String }, + ClickedRename: {}, + SucceededRename: { seq: Schema.Number, id: Schema.Number, title: Schema.String }, + FailedRename: { seq: Schema.Number, visit: Schema.Number, error: Schema.String }, + ClickedArchive: {}, + SucceededArchive: { seq: Schema.Number, id: Schema.Number }, + FailedArchive: { seq: Schema.Number, error: Schema.String }, + SucceededFetchChecklist: { cardId: Schema.Number, items: Schema.Array(ServerItem) }, + FailedFetchChecklist: { cardId: Schema.Number, error: Schema.String }, + ClickedToggle: { itemId: Schema.Number, done: Schema.Boolean }, + SucceededToggle: { seq: Schema.Number, cardId: Schema.Number, itemId: Schema.Number, done: Schema.Boolean }, + FailedToggle: { seq: Schema.Number, visit: Schema.Number, error: Schema.String }, + UpdatedItemInput: { value: Schema.String }, + ClickedAddItem: {}, + SucceededAddItem: { seq: Schema.Number, cardId: Schema.Number, item: ServerItem }, + FailedAddItem: { seq: Schema.Number, visit: Schema.Number, error: Schema.String }, +}) +export type Message = typeof Message.Type + +// COMMAND + +const FetchCards = Command.define('FetchCards', { + args: { column: Column }, + messages: [Message.SucceededFetchCards, Message.FailedFetchCards], + execute: ({ column }) => + request(() => Api.fetchCards(column)).pipe( + Effect.map(cards => Message.SucceededFetchCards({ column, cards })), + Effect.catch(error => Effect.succeed(Message.FailedFetchCards({ column, error }))), + ), +}) + +const isColumn = Schema.is(Column) + +const MoveCard = Command.define('MoveCard', { + args: { seq: Schema.Number, id: Schema.Number, column: Column, index: Schema.Number }, + messages: [Message.SucceededMove, Message.FailedMove], + execute: ({ seq, id, column, index }) => + request(() => Api.moveCard(id, column, index)).pipe( + Effect.map(answer => + Message.SucceededMove({ + seq, + id: answer.id, + column: isColumn(answer.column) ? answer.column : column, + index: answer.index, + }), + ), + Effect.catch(error => Effect.succeed(Message.FailedMove({ seq, error }))), + ), +}) + +const AddCard = Command.define('AddCard', { + args: { seq: Schema.Number, title: Schema.String }, + messages: [Message.SucceededAddCard, Message.FailedAddCard], + execute: ({ seq, title }) => + request(() => Api.addCard('todo', title)).pipe( + Effect.map(answer => Message.SucceededAddCard({ seq, id: answer.id, title: answer.title })), + Effect.catch(error => Effect.succeed(Message.FailedAddCard({ seq, error }))), + ), +}) + +const RenameCard = Command.define('RenameCard', { + args: { seq: Schema.Number, visit: Schema.Number, id: Schema.Number, title: Schema.String }, + messages: [Message.SucceededRename, Message.FailedRename], + execute: ({ seq, visit, id, title }) => + request(() => Api.renameCard(id, title)).pipe( + Effect.map(answer => Message.SucceededRename({ seq, id: answer.id, title: answer.title })), + Effect.catch(error => Effect.succeed(Message.FailedRename({ seq, visit, error }))), + ), +}) + +const ArchiveCard = Command.define('ArchiveCard', { + args: { seq: Schema.Number, id: Schema.Number }, + messages: [Message.SucceededArchive, Message.FailedArchive], + execute: ({ seq, id }) => + request(() => Api.archiveCard(id)).pipe( + Effect.map(answer => Message.SucceededArchive({ seq, id: answer.id })), + Effect.catch(error => Effect.succeed(Message.FailedArchive({ seq, error }))), + ), +}) + +const FetchChecklist = Command.define('FetchChecklist', { + args: { cardId: Schema.Number }, + messages: [Message.SucceededFetchChecklist, Message.FailedFetchChecklist], + execute: ({ cardId }) => + request(() => Api.fetchChecklist(cardId)).pipe( + Effect.map(items => Message.SucceededFetchChecklist({ cardId, items })), + Effect.catch(error => Effect.succeed(Message.FailedFetchChecklist({ cardId, error }))), + ), +}) + +const ToggleItem = Command.define('ToggleItem', { + args: { seq: Schema.Number, visit: Schema.Number, cardId: Schema.Number, itemId: Schema.Number, done: Schema.Boolean }, + messages: [Message.SucceededToggle, Message.FailedToggle], + execute: ({ seq, visit, cardId, itemId, done }) => + request(() => Api.toggleItem(cardId, itemId, done)).pipe( + Effect.map(answer => Message.SucceededToggle({ seq, cardId, itemId: answer.id, done: answer.done })), + Effect.catch(error => Effect.succeed(Message.FailedToggle({ seq, visit, error }))), + ), +}) + +const AddItem = Command.define('AddItem', { + args: { seq: Schema.Number, visit: Schema.Number, cardId: Schema.Number, text: Schema.String }, + messages: [Message.SucceededAddItem, Message.FailedAddItem], + execute: ({ seq, visit, cardId, text }) => + request(() => Api.addItem(cardId, text)).pipe( + Effect.map(item => Message.SucceededAddItem({ seq, cardId, item })), + Effect.catch(error => Effect.succeed(Message.FailedAddItem({ seq, visit, error }))), + ), +}) + +// INIT + +export const init = (): Update.Return<Model, Message> => ({ + model: { + columns: { todo: ColumnState.Loading(), doing: ColumnState.Loading(), done: ColumnState.Loading() }, + checklists: [], + maybeSelected: Option.none(), + visit: 0, + newCard: '', + titleInput: '', + itemInput: '', + seq: 1, + moves: [], + cardAdds: [], + archiving: [], + renames: [], + toggles: [], + itemAdds: [], + maybeMoveError: Option.none(), + maybeAddError: Option.none(), + maybeArchiveError: Option.none(), + maybeRenameError: Option.none(), + maybeItemError: Option.none(), + }, + commands: COLUMNS.map(column => FetchCards({ column })), +}) + +// WHAT THE PAGE SHOWS + +/** a card as the board shows it, or one being added (no id yet) */ +type ShownRow = + | { readonly _tag: 'Card'; readonly id: number; readonly title: string; readonly done: number; readonly total: number } + | { readonly _tag: 'Adding'; readonly seq: number; readonly title: string } + +const loadedCards = (columns: ColumnStates): Columns<Card> => ({ + todo: columns.todo._tag === 'Loaded' ? Option.some(columns.todo.cards) : Option.none(), + doing: columns.doing._tag === 'Loaded' ? Option.some(columns.doing.cards) : Option.none(), + done: columns.done._tag === 'Loaded' ? Option.some(columns.done.cards) : Option.none(), +}) + +const findChecklist = (model: Model, cardId: number): Option.Option<ChecklistState> => + pipe( + model.checklists, + Array.findFirst(checklist => checklist.cardId === cardId), + Option.map(checklist => checklist.state), + ) + +/** the checklist of `cardId` as shown, once it has loaded */ +const shownItems = (model: Model, cardId: number): Option.Option<ReadonlyArray<ItemRow>> => + pipe( + findChecklist(model, cardId), + Option.flatMap(state => (state._tag === 'Loaded' ? Option.some(state.items) : Option.none())), + Option.map(items => + layItems( + items, + Array.filter(model.toggles, toggle => toggle.cardId === cardId), + Array.filter(model.itemAdds, add => add.cardId === cardId), + ), + ), + ) + +const shownRow = + (model: Model) => + (row: CardRow): ShownRow => { + if (row._tag === 'Adding') { + return row + } + const { card } = row + const { done, total } = Option.match(shownItems(model, card.id), { + onNone: () => card, + onSome: countItems, + }) + const title = Option.match(newest(model.renames, rename => rename.id === card.id), { + onNone: () => card.title, + onSome: rename => rename.title, + }) + return { _tag: 'Card', id: card.id, title, done, total } + } + +const shownColumns = (model: Model): Columns<ShownRow> => { + const laid = layColumns(loadedCards(model.columns), model.moves, model.cardAdds, model.archiving) + const toShown = Option.map((rows: ReadonlyArray<CardRow>) => rows.map(shownRow(model))) + return { todo: toShown(laid.todo), doing: toShown(laid.doing), done: toShown(laid.done) } +} + +type ShownCard = { readonly id: number; readonly title: string; readonly column: Column } + +/** the selected card as the board shows it, while it is on the board */ +const selectedCard = (model: Model, shown: Columns<ShownRow>): Option.Option<ShownCard> => + Option.flatMap(model.maybeSelected, id => + pipe( + COLUMNS, + Array.findFirst(column => + pipe( + shown[column], + Option.flatMap(Array.findFirst(row => row._tag === 'Card' && row.id === id)), + Option.flatMap(row => (row._tag === 'Card' ? Option.some({ id, title: row.title, column }) : Option.none())), + ), + ), + ), + ) + +const sizeOf = (shown: Columns<ShownRow>, column: Column): number => + Option.match(shown[column], { onNone: () => 0, onSome: rows => rows.length }) + +/** whether a card may move into `target` from another column: it has loaded, and Doing is under its limit */ +const canEnter = (shown: Columns<ShownRow>, target: Column): boolean => + Option.match(shown[target], { + onNone: () => false, + onSome: rows => target !== 'doing' || rows.length < WIP_LIMIT, + }) + +// UPDATE + +type UpdateReturn = Update.Return<Model, Message> + +const dropSettled = + (seq: number) => + <A extends { readonly seq: number }>(writes: ReadonlyArray<A>): ReadonlyArray<A> => + Array.filter(writes, write => write.seq !== seq) + +// The server's answers, written into what the page loaded. + +const withCards = (model: Model, f: (cards: Columns<Card>) => Columns<Card>): Model => { + const next = f(loadedCards(model.columns)) + const write = (column: Column): ColumnState => + Option.match(next[column], { + onNone: () => model.columns[column], + onSome: cards => ColumnState.Loaded({ cards }), + }) + return modifyFields(model, { columns: () => ({ todo: write('todo'), doing: write('doing'), done: write('done') }) }) +} + +const withCardPatch = (model: Model, id: number, f: (card: Card) => Card): Model => + withCards(model, columns => mapCards(columns, Array.map(card => (card.id === id ? f(card) : card)))) + +const withChecklist = (model: Model, cardId: number, f: (state: Option.Option<ChecklistState>) => ChecklistState): Model => + modifyFields(model, { + checklists: checklists => [ + ...Array.filter(checklists, checklist => checklist.cardId !== cardId), + { cardId, state: f(findChecklist(model, cardId)) }, + ], + }) + +const withItems = (model: Model, cardId: number, f: (items: ReadonlyArray<Item>) => ReadonlyArray<Item>): Model => + Option.match(findChecklist(model, cardId), { + onNone: () => model, + onSome: state => + state._tag === 'Loaded' ? withChecklist(model, cardId, () => ChecklistState.Loaded({ items: f(state.items) })) : model, + }) + +const select = (model: Model, id: number): UpdateReturn => { + const selected = modifyFields(model, { + maybeSelected: () => Option.some(id), + visit: visit => visit + 1, + titleInput: () => '', + itemInput: () => '', + }) + const isKnown = Option.exists(findChecklist(model, id), state => state._tag !== 'Failed') + return isKnown + ? { model: selected } + : { + model: withChecklist(selected, id, () => ChecklistState.Loading()), + commands: [FetchChecklist({ cardId: id })], + } +} + +const visitError = (model: Model, visit: number, message: string) => (current: Option.Option<VisitError>) => + visit === model.visit ? Option.some({ visit, message }) : current + +export const update = (model: Model, message: Message): UpdateReturn => + Message.match<UpdateReturn>(message, { + SucceededFetchCards: ({ column, cards }) => + model.columns[column]._tag === 'Loading' + ? { + model: modifyFields(model, { + columns: columns => ({ + ...columns, + [column]: ColumnState.Loaded({ cards: cards.map(card => ({ ...card, maybeAddSeq: Option.none() })) }), + }), + }), + } + : { model }, + + FailedFetchCards: ({ column, error }) => + model.columns[column]._tag === 'Loading' + ? { model: modifyFields(model, { columns: columns => ({ ...columns, [column]: ColumnState.Failed({ error }) }) }) } + : { model }, + + ClickedRetry: ({ column }) => + model.columns[column]._tag === 'Failed' + ? { + model: modifyFields(model, { columns: columns => ({ ...columns, [column]: ColumnState.Loading() }) }), + commands: [FetchCards({ column })], + } + : { model }, + + ClickedMove: ({ id, column, index }) => ({ + model: modifyFields(model, { + seq: seq => seq + 1, + maybeMoveError: () => Option.none(), + moves: Array.append({ seq: model.seq, id, column, index }), + }), + commands: [MoveCard({ seq: model.seq, id, column, index })], + }), + + SucceededMove: ({ seq, id, column, index }) => ({ + model: withCards(modifyFields(model, { moves: dropSettled(seq) }), columns => placeCard(columns, id, column, index)), + }), + + FailedMove: ({ seq, error }) => ({ + model: modifyFields(model, { moves: dropSettled(seq), maybeMoveError: () => Option.some(error) }), + }), + + UpdatedNewCard: ({ value }) => ({ model: modifyFields(model, { newCard: () => value }) }), + + ClickedAddCard: () => { + const title = model.newCard + if (title === '' || model.columns.todo._tag !== 'Loaded') { + return { model } + } + return { + model: modifyFields(model, { + seq: seq => seq + 1, + newCard: () => '', + maybeAddError: () => Option.none(), + cardAdds: Array.append({ seq: model.seq, title }), + }), + commands: [AddCard({ seq: model.seq, title })], + } + }, + + SucceededAddCard: ({ seq, id, title }) => ({ + model: withCards(modifyFields(model, { cardAdds: dropSettled(seq) }), columns => ({ + ...columns, + todo: Option.map(columns.todo, cards => { + if (Array.some(cards, card => card.id === id)) { + return cards + } + const next = [...cards] + next.splice(addSlot(cards, seq, card => card.maybeAddSeq), 0, { + id, + title, + done: 0, + total: 0, + maybeAddSeq: Option.some(seq), + }) + return next + }), + })), + }), + + FailedAddCard: ({ seq, error }) => ({ + model: modifyFields(model, { cardAdds: dropSettled(seq), maybeAddError: () => Option.some(error) }), + }), + + ClickedCard: ({ id }) => select(model, id), + + UpdatedTitleInput: ({ value }) => ({ model: modifyFields(model, { titleInput: () => value }) }), + + ClickedRename: () => + Option.match(selectedCard(model, shownColumns(model)), { + onNone: () => ({ model }), + onSome: ({ id }) => + model.titleInput === '' + ? { model } + : { + model: modifyFields(model, { + seq: seq => seq + 1, + maybeRenameError: () => Option.none(), + renames: Array.append({ seq: model.seq, id, title: model.titleInput }), + }), + commands: [RenameCard({ seq: model.seq, visit: model.visit, id, title: model.titleInput })], + }, + }), + + SucceededRename: ({ seq, id, title }) => ({ + model: withCardPatch(modifyFields(model, { renames: dropSettled(seq) }), id, card => ({ ...card, title })), + }), + + FailedRename: ({ seq, visit, error }) => ({ + model: modifyFields(model, { renames: dropSettled(seq), maybeRenameError: visitError(model, visit, error) }), + }), + + ClickedArchive: () => + Option.match(selectedCard(model, shownColumns(model)), { + onNone: () => ({ model }), + onSome: ({ id }) => ({ + model: modifyFields(model, { + seq: seq => seq + 1, + maybeSelected: () => Option.none(), + maybeArchiveError: () => Option.none(), + archiving: Array.append({ seq: model.seq, id }), + }), + commands: [ArchiveCard({ seq: model.seq, id })], + }), + }), + + SucceededArchive: ({ seq, id }) => ({ + model: withCards(modifyFields(model, { archiving: dropSettled(seq) }), columns => + mapCards(columns, Array.filter(card => card.id !== id)), + ), + }), + + FailedArchive: ({ seq, error }) => ({ + model: modifyFields(model, { archiving: dropSettled(seq), maybeArchiveError: () => Option.some(error) }), + }), + + SucceededFetchChecklist: ({ cardId, items }) => + Option.exists(findChecklist(model, cardId), state => state._tag === 'Loading') + ? { + model: withChecklist(model, cardId, () => + ChecklistState.Loaded({ items: items.map(item => ({ ...item, maybeAddSeq: Option.none() })) }), + ), + } + : { model }, + + FailedFetchChecklist: ({ cardId, error }) => + Option.exists(findChecklist(model, cardId), state => state._tag === 'Loading') + ? { model: withChecklist(model, cardId, () => ChecklistState.Failed({ error })) } + : { model }, + + ClickedToggle: ({ itemId, done }) => + Option.match(selectedCard(model, shownColumns(model)), { + onNone: () => ({ model }), + onSome: ({ id: cardId }) => ({ + model: modifyFields(model, { + seq: seq => seq + 1, + maybeItemError: () => Option.none(), + toggles: Array.append({ seq: model.seq, cardId, itemId, done }), + }), + commands: [ToggleItem({ seq: model.seq, visit: model.visit, cardId, itemId, done })], + }), + }), + + SucceededToggle: ({ seq, cardId, itemId, done }) => ({ + model: withItems(modifyFields(model, { toggles: dropSettled(seq) }), cardId, items => + items.map(item => (item.id === itemId ? { ...item, done } : item)), + ), + }), + + FailedToggle: ({ seq, visit, error }) => ({ + model: modifyFields(model, { toggles: dropSettled(seq), maybeItemError: visitError(model, visit, error) }), + }), + + UpdatedItemInput: ({ value }) => ({ model: modifyFields(model, { itemInput: () => value }) }), + + ClickedAddItem: () => + Option.match(selectedCard(model, shownColumns(model)), { + onNone: () => ({ model }), + onSome: ({ id: cardId }) => { + const text = model.itemInput + if (text === '' || Option.isNone(shownItems(model, cardId))) { + return { model } + } + return { + model: modifyFields(model, { + seq: seq => seq + 1, + itemInput: () => '', + maybeItemError: () => Option.none(), + itemAdds: Array.append({ seq: model.seq, cardId, text }), + }), + commands: [AddItem({ seq: model.seq, visit: model.visit, cardId, text })], + } + }, + }), + + SucceededAddItem: ({ seq, cardId, item }) => ({ + model: withItems(modifyFields(model, { itemAdds: dropSettled(seq) }), cardId, items => { + if (Array.some(items, existing => existing.id === item.id)) { + return items + } + const next = [...items] + next.splice(addSlot(items, seq, existing => existing.maybeAddSeq), 0, { ...item, maybeAddSeq: Option.some(seq) }) + return next + }), + }), + + FailedAddItem: ({ seq, visit, error }) => ({ + model: modifyFields(model, { itemAdds: dropSettled(seq), maybeItemError: visitError(model, visit, error) }), + }), + }) + +// VIEW + +const testId = (h: HtmlBuilder<Message>, id: string) => h.DataAttribute('testid', id) + +const errorLine = (maybeError: Option.Option<string>, id: string, prefix: string, h: HtmlBuilder<Message>): Html => + Option.match(maybeError, { onNone: () => h.empty, onSome: error => h.p([testId(h, id)], [`${prefix}${error}`]) }) + +const visitErrorLine = (model: Model, maybeError: Option.Option<VisitError>, prefix: string, h: HtmlBuilder<Message>): Html => + pipe( + maybeError, + Option.filter(error => error.visit === model.visit), + Option.match({ onNone: () => h.empty, onSome: error => h.p([], [`${prefix}${error.message}`]) }), + ) + +const moveButton = ( + h: HtmlBuilder<Message>, + direction: string, + isDisabled: boolean, + move: { id: number; column: Column; index: number }, +): Html => + h.button([testId(h, direction), h.Disabled(isDisabled), h.OnClick(Message.ClickedMove(move))], [direction]) + +const cardRow = ( + model: Model, + shown: Columns<ShownRow>, + column: Column, + rows: ReadonlyArray<ShownRow>, + row: ShownRow, + index: number, + h: HtmlBuilder<Message>, +): Html => { + if (row._tag === 'Adding') { + return h.keyed('li')(`adding-${row.seq}`, [], [h.span([testId(h, 'card')], [`${row.title} (adding…)`])]) + } + const at = COLUMNS.indexOf(column) + const maybeLeft = Array.get(COLUMNS, at - 1) + const maybeRight = Array.get(COLUMNS, at + 1) + const across = (direction: string, maybeTarget: Option.Option<Column>): Html => + Option.match(maybeTarget, { + onNone: () => h.empty, + onSome: target => + moveButton(h, direction, !canEnter(shown, target), { id: row.id, column: target, index: sizeOf(shown, target) }), + }) + return h.keyed('li')(`card-${row.id}`, [], [ + h.button( + [ + testId(h, 'card'), + h.AriaPressed(String(Option.contains(model.maybeSelected, row.id))), + h.OnClick(Message.ClickedCard({ id: row.id })), + ], + [`#${row.id} ${row.title} · ${row.done}/${row.total}`], + ), + ' ', + across('left', maybeLeft), + across('right', maybeRight), + index > 0 ? moveButton(h, 'up', false, { id: row.id, column, index: index - 1 }) : h.empty, + index < rows.length - 1 ? moveButton(h, 'down', false, { id: row.id, column, index: index + 1 }) : h.empty, + ]) +} + +const addCardForm = (model: Model, h: HtmlBuilder<Message>): Html => + h.div([], [ + h.input([ + testId(h, 'new-card'), + h.Value(model.newCard), + h.Placeholder('new card'), + h.OnInput(value => Message.UpdatedNewCard({ value })), + ]), + ' ', + h.button( + [testId(h, 'add-card'), h.Disabled(model.columns.todo._tag !== 'Loaded'), h.OnClick(Message.ClickedAddCard())], + ['add'], + ), + errorLine(model.maybeAddError, 'add-error', 'add error: ', h), + ]) + +const columnView = (model: Model, shown: Columns<ShownRow>, column: Column, h: HtmlBuilder<Message>): Html => { + const maybeRows = shown[column] + const body = Option.match(maybeRows, { + onSome: rows => h.ul([], rows.map((row, index) => cardRow(model, shown, column, rows, row, index, h))), + onNone: () => + ColumnState.match(model.columns[column], { + Loading: () => h.p([], ['loading']), + Loaded: () => h.p([], ['loading']), + Failed: ({ error }) => + h.p([], [ + `error: ${error} `, + h.button([testId(h, `retry-${column}`), h.OnClick(Message.ClickedRetry({ column }))], ['retry']), + ]), + }), + }) + return h.section([testId(h, `col-${column}`), h.Style({ minWidth: '14rem' })], [ + h.h2([testId(h, `head-${column}`)], [ + Option.match(maybeRows, { + onNone: () => COLUMN_NAME[column], + onSome: rows => `${COLUMN_NAME[column]} (${rows.length})`, + }), + ]), + body, + column === 'todo' ? addCardForm(model, h) : h.empty, + ]) +} + +const itemRow = (row: ItemRow, h: HtmlBuilder<Message>): Html => + row._tag === 'Adding' + ? h.keyed('li')(`adding-${row.seq}`, [], [h.span([testId(h, 'item')], [`[ ] ${row.text} (adding…)`])]) + : h.keyed('li')(`item-${row.id}`, [], [ + h.span([testId(h, 'item')], [`[${row.done ? 'x' : ' '}] ${row.text}`]), + ' ', + h.button([testId(h, 'toggle'), h.OnClick(Message.ClickedToggle({ itemId: row.id, done: !row.done }))], ['toggle']), + ]) + +const checklistView = (model: Model, cardId: number, h: HtmlBuilder<Message>): Html => + h.div( + [testId(h, 'checklist')], + [ + Option.match(shownItems(model, cardId), { + onSome: rows => { + const { done, total } = countItems(rows) + return h.div([], [ + h.ul([], rows.map(row => itemRow(row, h))), + h.p([testId(h, 'progress')], [`${done}/${total} done`]), + ]) + }, + onNone: () => + Option.match(findChecklist(model, cardId), { + onNone: () => h.p([], ['loading checklist']), + onSome: state => + ChecklistState.match(state, { + Loading: () => h.p([], ['loading checklist']), + Loaded: () => h.p([], ['loading checklist']), + Failed: ({ error }) => h.p([], [`checklist error: ${error}`]), + }), + }), + }), + ], + ) + +const detailView = (model: Model, card: ShownCard, h: HtmlBuilder<Message>): ReadonlyArray<Html> => [ + h.h2([], [`#${card.id} ${card.title}`]), + h.p([], [`in ${COLUMN_NAME[card.column]}`]), + h.div([], [ + h.input([ + testId(h, 'title-input'), + h.Value(model.titleInput), + h.Placeholder('new title'), + h.OnInput(value => Message.UpdatedTitleInput({ value })), + ]), + ' ', + h.button([testId(h, 'rename'), h.OnClick(Message.ClickedRename())], ['rename']), + ' ', + h.button([testId(h, 'archive'), h.OnClick(Message.ClickedArchive())], ['archive']), + ]), + visitErrorLine(model, model.maybeRenameError, 'rename error: ', h), + checklistView(model, card.id, h), + h.div([], [ + h.input([ + testId(h, 'item-input'), + h.Value(model.itemInput), + h.Placeholder('new item'), + h.OnInput(value => Message.UpdatedItemInput({ value })), + ]), + ' ', + h.button( + [ + testId(h, 'add-item'), + h.Disabled(Option.isNone(shownItems(model, card.id))), + h.OnClick(Message.ClickedAddItem()), + ], + ['add item'], + ), + ]), + visitErrorLine(model, model.maybeItemError, 'item error: ', h), +] + +export const view = Submodel.defineView<Model, Message>((model, h) => { + const shown = shownColumns(model) + return h.div([], [ + h.div([h.Style({ display: 'flex', gap: '1.5rem', alignItems: 'flex-start' })], [ + ...COLUMNS.map(column => columnView(model, shown, column, h)), + h.section( + [testId(h, 'detail'), h.Style({ minWidth: '16rem' })], + Option.match(selectedCard(model, shown), { + onNone: () => [h.p([], ['select a card'])], + onSome: card => detailView(model, card, h), + }), + ), + ]), + errorLine(model.maybeMoveError, 'move-error', 'move error: ', h), + errorLine(model.maybeArchiveError, 'archive-error', 'archive error: ', h), + ]) +}) diff --git a/eval/reference/board/foldkit/layout.ts b/eval/reference/board/foldkit/layout.ts new file mode 100644 index 00000000..a3993b1c --- /dev/null +++ b/eval/reference/board/foldkit/layout.ts @@ -0,0 +1,214 @@ +import { Array, Option, Order, Schema, pipe } from 'effect' + +// The board as shown: the server's last answers with the writes still in the air laid over them. + +export const Column = Schema.Literals(['todo', 'doing', 'done']) +export type Column = typeof Column.Type + +export const COLUMNS: ReadonlyArray<Column> = ['todo', 'doing', 'done'] +export const COLUMN_NAME: Readonly<Record<Column, string>> = { todo: 'To do', doing: 'Doing', done: 'Done' } + +/** Doing holds at most this many cards. */ +export const WIP_LIMIT = 3 + +// A card or item as the server last answered it. `maybeAddSeq` marks one this page added, by the seq of its add. +export const Card = Schema.Struct({ + id: Schema.Number, + title: Schema.String, + done: Schema.Number, + total: Schema.Number, + maybeAddSeq: Schema.Option(Schema.Number), +}) +export type Card = typeof Card.Type + +export const Item = Schema.Struct({ + id: Schema.Number, + text: Schema.String, + done: Schema.Boolean, + maybeAddSeq: Schema.Option(Schema.Number), +}) +export type Item = typeof Item.Type + +// Writes in the air. `seq` orders all of the page's writes. +export const Move = Schema.Struct({ seq: Schema.Number, id: Schema.Number, column: Column, index: Schema.Number }) +export type Move = typeof Move.Type +export const CardAdd = Schema.Struct({ seq: Schema.Number, title: Schema.String }) +export type CardAdd = typeof CardAdd.Type +export const Archiving = Schema.Struct({ seq: Schema.Number, id: Schema.Number }) +export type Archiving = typeof Archiving.Type +export const Rename = Schema.Struct({ seq: Schema.Number, id: Schema.Number, title: Schema.String }) +export type Rename = typeof Rename.Type +export const Toggle = Schema.Struct({ seq: Schema.Number, cardId: Schema.Number, itemId: Schema.Number, done: Schema.Boolean }) +export type Toggle = typeof Toggle.Type +export const ItemAdd = Schema.Struct({ seq: Schema.Number, cardId: Schema.Number, text: Schema.String }) +export type ItemAdd = typeof ItemAdd.Type + +export type Columns<A> = Readonly<Record<Column, Option.Option<ReadonlyArray<A>>>> + +/** + * Where an entry from the add with `seq` goes in a list: before the first entry that a later add put there (that add + * answered first), else at the end. So entries keep the order their adds started in, guessed or answered. + */ +export const addSlot = <A>(list: ReadonlyArray<A>, seq: number, seqOf: (entry: A) => Option.Option<number>): number => + Option.getOrElse( + Array.findFirstIndex(list, entry => Option.exists(seqOf(entry), entrySeq => entrySeq > seq)), + () => list.length, + ) + +const bySeq = <A extends { readonly seq: number }>(): Order.Order<A> => Order.mapInput(Order.Number, (write: A) => write.seq) + +/** the newest of the writes that `isMatch` (writes are kept in the order they started) */ +export const newest = <A>(writes: ReadonlyArray<A>, isMatch: (write: A) => boolean): Option.Option<A> => + Array.findLast(writes, isMatch) + +// THE COLUMNS + +export type CardRow = + | { readonly _tag: 'Card'; readonly card: Card } + | { readonly _tag: 'Adding'; readonly seq: number; readonly title: string } + +const rowSeq = (row: CardRow): Option.Option<number> => + row._tag === 'Adding' ? Option.some(row.seq) : row.card.maybeAddSeq + +type Op = + | ({ readonly _tag: 'Move' } & Move) + | ({ readonly _tag: 'Add' } & CardAdd) + | ({ readonly _tag: 'Archive' } & Archiving) + +/** + * The loaded columns with the moves, adds and archives in the air replayed over them in the order they started, so + * each lands where it was made: a move at the position it was shown at, an add at the bottom of To do. + */ +export const layColumns = ( + base: Columns<Card>, + moves: ReadonlyArray<Move>, + adds: ReadonlyArray<CardAdd>, + archiving: ReadonlyArray<Archiving>, +): Columns<CardRow> => { + const lists: Record<Column, Array<CardRow> | undefined> = { todo: undefined, doing: undefined, done: undefined } + for (const column of COLUMNS) { + lists[column] = Option.getOrUndefined( + Option.map(base[column], cards => cards.map((card): CardRow => ({ _tag: 'Card', card }))), + ) + } + const find = (id: number): { list: Array<CardRow>; index: number } | undefined => { + for (const column of COLUMNS) { + const list = lists[column] + const index = list?.findIndex(row => row._tag === 'Card' && row.card.id === id) ?? -1 + if (list && index >= 0) { + return { list, index } + } + } + return undefined + } + const ops: ReadonlyArray<Op> = pipe( + [ + ...moves.map((move): Op => ({ _tag: 'Move', ...move })), + ...adds.map((add): Op => ({ _tag: 'Add', ...add })), + ...archiving.map((archive): Op => ({ _tag: 'Archive', ...archive })), + ], + Array.sort(bySeq<Op>()), + ) + for (const op of ops) { + if (op._tag === 'Add') { + const todo = lists.todo + if (todo) { + todo.splice(addSlot(todo, op.seq, rowSeq), 0, { _tag: 'Adding', seq: op.seq, title: op.title }) + } + continue + } + const at = find(op.id) + if (!at) { + continue + } + if (op._tag === 'Archive') { + at.list.splice(at.index, 1) + continue + } + const target = lists[op.column] + if (!target) { + continue + } + const [row] = at.list.splice(at.index, 1) + if (row) { + target.splice(Math.min(op.index, target.length), 0, row) + } + } + return { + todo: Option.fromUndefinedOr(lists.todo), + doing: Option.fromUndefinedOr(lists.doing), + done: Option.fromUndefinedOr(lists.done), + } +} + +// THE CHECKLIST + +export type ItemRow = + | { readonly _tag: 'Item'; readonly id: number; readonly text: string; readonly done: boolean } + | { readonly _tag: 'Adding'; readonly seq: number; readonly text: string } + +/** a card's checklist with its toggles (the newest one per item) and item adds in the air laid over it */ +export const layItems = ( + base: ReadonlyArray<Item>, + toggles: ReadonlyArray<Toggle>, + adds: ReadonlyArray<ItemAdd>, +): ReadonlyArray<ItemRow> => { + type Entry = { readonly row: ItemRow; readonly maybeSeq: Option.Option<number> } + const entries: Array<Entry> = base.map(item => ({ + row: { + _tag: 'Item', + id: item.id, + text: item.text, + done: Option.match(newest(toggles, toggle => toggle.itemId === item.id), { + onNone: () => item.done, + onSome: toggle => toggle.done, + }), + }, + maybeSeq: item.maybeAddSeq, + })) + for (const add of Array.sort(adds, bySeq<ItemAdd>())) { + entries.splice(addSlot(entries, add.seq, entry => entry.maybeSeq), 0, { + row: { _tag: 'Adding', seq: add.seq, text: add.text }, + maybeSeq: Option.some(add.seq), + }) + } + return entries.map(entry => entry.row) +} + +/** an item list's done and total, the adds in the air counted as not done */ +export const countItems = (rows: ReadonlyArray<ItemRow>): { done: number; total: number } => ({ + done: rows.filter(row => row._tag === 'Item' && row.done).length, + total: rows.length, +}) + +// THE SERVER'S ANSWERS + +/** the card answered at `column`/`index`, taken out of wherever the loaded columns hold it */ +export const placeCard = (columns: Columns<Card>, id: number, column: Column, index: number): Columns<Card> => { + const maybeCard = pipe( + COLUMNS, + Array.flatMap(c => Option.getOrElse(columns[c], () => [])), + Array.findFirst(card => card.id === id), + ) + const without = mapCards(columns, cards => cards.filter(card => card.id !== id)) + return Option.match(maybeCard, { + onNone: () => without, + onSome: card => ({ + ...without, + [column]: Option.map(without[column], cards => { + const next = [...cards] + next.splice(Math.min(index, next.length), 0, card) + return next + }), + }), + }) +} + +export const mapCards = ( + columns: Columns<Card>, + f: (cards: ReadonlyArray<Card>) => ReadonlyArray<Card>, +): Columns<Card> => ({ + todo: Option.map(columns.todo, f), + doing: Option.map(columns.doing, f), + done: Option.map(columns.done, f), +}) diff --git a/eval/reference/board/foldkit/main.ts b/eval/reference/board/foldkit/main.ts new file mode 100644 index 00000000..1ec480aa --- /dev/null +++ b/eval/reference/board/foldkit/main.ts @@ -0,0 +1,137 @@ +import { Option, Schema } from 'effect' +import { Command, type Runtime, Update } from 'foldkit' +import { Document, Html, HtmlBuilder } from 'foldkit/html' +import { defineMessageUnion } from 'foldkit/message' +import { defineTaggedUnion } from 'foldkit/schema' +import { modifyFields } from 'foldkit/struct' + +import * as Archive from './archive' +import * as Board from './board' + +// MODEL + +const Tab = Schema.Literals(['board', 'archive']) +type Tab = typeof Tab.Type + +// Only the open page is kept: leaving a page discards it. Each opening of a page gets a new `pageId`, and answers to +// a page that was left carry an old one, so they reach nothing. +export const Page = defineTaggedUnion({ + Board: { model: Board.Model }, + Archive: { model: Archive.Model }, +}) +type Page = typeof Page.Type + +export const Model = Schema.Struct({ pageId: Schema.Number, page: Page }) +export type Model = typeof Model.Type + +// MESSAGE + +export const Message = defineMessageUnion({ + ClickedTab: { tab: Tab }, + GotBoardMessage: { pageId: Schema.Number, message: Board.Message }, + GotArchiveMessage: { pageId: Schema.Number, message: Archive.Message }, +}) +export type Message = typeof Message.Type + +// INIT + +type UpdateReturn = Update.Return<Model, Message> + +const toGotBoardMessage = (pageId: number) => (message: Board.Message) => Message.GotBoardMessage({ pageId, message }) +const toGotArchiveMessage = (pageId: number) => (message: Archive.Message) => + Message.GotArchiveMessage({ pageId, message }) + +const openBoard = (model: Model): UpdateReturn => { + const pageId = model.pageId + 1 + const boardInit = Board.init() + return { + model: modifyFields(model, { pageId: () => pageId, page: () => Page.Board({ model: boardInit.model }) }), + commands: Command.mapMessages(boardInit.commands, toGotBoardMessage(pageId)), + } +} + +const openArchive = (model: Model): UpdateReturn => { + const pageId = model.pageId + 1 + const archiveInit = Archive.init() + return { + model: modifyFields(model, { pageId: () => pageId, page: () => Page.Archive({ model: archiveInit.model }) }), + commands: Command.mapMessages(archiveInit.commands, toGotArchiveMessage(pageId)), + } +} + +export const init: Runtime.ApplicationInit<Model, Message> = () => + openBoard({ pageId: 0, page: Page.Archive({ model: Archive.init().model }) }) + +// UPDATE + +const tabOf = (page: Page): Tab => Page.match<Tab>(page, { Board: () => 'board', Archive: () => 'archive' }) + +const foldBoardMessage = (pageId: number) => + Update.foldChild({ + update: Board.update, + read: (model: Model) => + model.page._tag === 'Board' && pageId === model.pageId ? Option.some(model.page.model) : Option.none(), + write: (model, nextBoard) => modifyFields(model, { page: () => Page.Board({ model: nextBoard }) }), + toParentMessage: toGotBoardMessage(pageId), + }) + +const foldArchiveMessage = (pageId: number) => + Update.foldChild({ + update: Archive.update, + read: (model: Model) => + model.page._tag === 'Archive' && pageId === model.pageId ? Option.some(model.page.model) : Option.none(), + write: (model, nextArchive) => modifyFields(model, { page: () => Page.Archive({ model: nextArchive }) }), + toParentMessage: toGotArchiveMessage(pageId), + }) + +export const update = (model: Model, message: Message) => + Message.match<UpdateReturn>(message, { + ClickedTab: ({ tab }) => { + if (tab === tabOf(model.page)) { + return { model } + } + return tab === 'board' ? openBoard(model) : openArchive(model) + }, + + GotBoardMessage: ({ pageId, message }) => foldBoardMessage(pageId)(model, message), + + GotArchiveMessage: ({ pageId, message }) => foldArchiveMessage(pageId)(model, message), + }) + +// VIEW + +const pageView = (model: Model, h: HtmlBuilder<Message>): Html => + Page.match(model.page, { + Board: page => + h.submodel({ + slotId: `board-${model.pageId}`, + model: page.model, + view: Board.view, + toParentMessage: toGotBoardMessage(model.pageId), + }), + Archive: page => + h.submodel({ + slotId: `archive-${model.pageId}`, + model: page.model, + view: Archive.view, + toParentMessage: toGotArchiveMessage(model.pageId), + }), + }) + +const tabButton = (model: Model, tab: Tab, label: string, h: HtmlBuilder<Message>): Html => + h.button( + [ + h.DataAttribute('testid', `tab-${tab}`), + h.AriaPressed(String(tab === tabOf(model.page))), + h.OnClick(Message.ClickedTab({ tab })), + ], + [label], + ) + +export const view = (model: Model, h: HtmlBuilder<Message>): Document => ({ + title: 'Sprint board', + body: h.div([], [ + h.nav([], [tabButton(model, 'board', 'Board', h), ' ', tabButton(model, 'archive', 'Archive', h)]), + pageView(model, h), + ]), +}) diff --git a/eval/reference/board/foldkit/request.ts b/eval/reference/board/foldkit/request.ts new file mode 100644 index 00000000..d744b31c --- /dev/null +++ b/eval/reference/board/foldkit/request.ts @@ -0,0 +1,7 @@ +import { Effect } from 'effect' + +export const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error) + +export const request = <A>(call: () => Promise<A>): Effect.Effect<A, string> => + Effect.tryPromise({ try: call, catch: errorMessage }) From 1074753e65dfbaea45d632d1a1507adb6928d628 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 14 Nov 2026 12:26:52 +0000 Subject: [PATCH 14/32] eval: board Exact (contract) reference (grades 0) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- eval/reference/board/contract/GRADE.txt | 6 + eval/reference/board/contract/app.contract | 323 +++++++++++++++++++++ eval/reference/board/contract/app.ts | 187 ++++++++++++ 3 files changed, 516 insertions(+) create mode 100644 eval/reference/board/contract/GRADE.txt create mode 100644 eval/reference/board/contract/app.contract create mode 100644 eval/reference/board/contract/app.ts diff --git a/eval/reference/board/contract/GRADE.txt b/eval/reference/board/contract/GRADE.txt new file mode 100644 index 00000000..770d0d13 --- /dev/null +++ b/eval/reference/board/contract/GRADE.txt @@ -0,0 +1,6 @@ +== hidden +45/45 passed +== overlap +26/26 passed +== fuzz +board-contract: 0/20 seeds found a hole diff --git a/eval/reference/board/contract/app.contract b/eval/reference/board/contract/app.contract new file mode 100644 index 00000000..4ce8d88a --- /dev/null +++ b/eval/reference/board/contract/app.contract @@ -0,0 +1,323 @@ +// The sprint board: a board page and an archive page behind two tabs. +// +// Every resource is the root's, so a page's state lives here too, and opening a page resets it. A resource asks only +// while its page is open (`when`) and asks again each time it opens; "loading" is `not current(…)`, so a reload after +// a write keeps what shows. Each kind of write is a `parallel` mutation that refreshes every resource it changes; +// app.ts's overlay lays the writes still out over the answers, so each shows at once and until an answer has it. +// +// `visit` counts page openings: every read and write carries it, so a write from a page that was left is not laid +// over the next visit, and its error is not reported there. `pick` counts selections, for the detail's errors. Each +// kind of write counts its sends (`moves`, `adds`…): an error belongs to the latest send of its kind, so the next +// action of its kind clears it, and one that was accepted meanwhile does not (`writeErrors` in app.ts). + +shape Card + id: number + title: string + done: number + total: number + adding: bool + key: string + +shape Item + id: number + text: string + done: bool + adding: bool + key: string + +shape Archived + id: number + title: string + +shape Moved + id: number + column: string + index: number + +shape Renamed + id: number + title: string + +shape Added + id: number + title: string + +shape Ack + id: number + +shape Toggled + id: number + done: bool + +shape Errors + move: string + add: string + archive: string + rename: string + item: string + restore: string + +fn columnName(c: string): string = c == "todo" ? "To do" : c == "doing" ? "Doing" : "Done" +fn message(f: option<Failure>): string = match f { case some(x) => x.message, case none => "" } +fn has(cards: Card[], id: number): bool = cards.filter(c => c.id == id and not c.adding).length > 0 + +component Board + state page = "board" + state visit = 1 + + // the board page + state selected = 0 + state pick = 0 + state newCard = "" + state titleDraft = "" + state itemDraft = "" + state moves = 0 + state adds = 0 + state archives = 0 + state renames = 0 + state itemActions = 0 + state restores = 0 + + derive onBoard = page == "board" + derive chosen = onBoard and selected != 0 + + resource todo = fetchCards("todo", visit) when onBoard as shape Card[] else empty() + resource doing = fetchCards("doing", visit) when onBoard as shape Card[] else empty() + resource done = fetchCards("done", visit) when onBoard as shape Card[] else empty() + resource checklist = fetchChecklist(selected, visit) when chosen as shape Item[] else empty() + resource archived = fetchArchived(visit) when page == "archive" as shape Archived[] else empty() + resource errors = writeErrors(visit, pick, moves, adds, archives, renames, itemActions, restores) as shape Errors else empty() + + mutation moved as shape Moved parallel refreshes todo, doing, done, errors + mutation added as shape Added parallel refreshes todo, errors + mutation archivedCard as shape Ack parallel refreshes todo, doing, done, errors + mutation renamed as shape Renamed parallel refreshes todo, doing, done, errors + mutation toggled as shape Toggled parallel refreshes checklist, todo, doing, done, errors + mutation itemAdded as shape Item parallel refreshes checklist, todo, doing, done, errors + mutation restored as shape Ack parallel refreshes archived, errors + + // the columns as shown: a column that has not loaded shows no cards + derive todoCards = current(todo) ? todo : [] + derive doingCards = current(doing) ? doing : [] + derive doneCards = current(done) ? done : [] + + // the selected card's checklist as shown, which its card on the board counts too + derive listShown = chosen and current(checklist) + derive itemsDone = checklist.filter(i => i.done and not i.adding).length + derive progress = `${itemsDone}/${checklist.length}` + + // where the selected card shows on the board + derive selectedColumn = has(todoCards, selected) ? "todo" : has(doingCards, selected) ? "doing" : has(doneCards, selected) ? "done" : "" + derive selectedCard = first(todoCards.concat(doingCards).concat(doneCards).filter(c => c.id == selected and not c.adding)) + derive selectedTitle = match selectedCard { case some(c) => c.title, case none => "" } + + // Doing holds at most three cards, counting the ones moved there that the server hasn't answered yet + derive todoOpen = current(todo) + derive doingOpen = current(doing) and doingCards.length < 3 + derive doneOpen = current(done) + + action openBoard + if page != "board" + page = "board" + visit = visit + 1 + selected = 0 + pick = pick + 1 + newCard = "" + titleDraft = "" + itemDraft = "" + action openArchive + if page != "archive" + page = "archive" + visit = visit + 1 + selected = 0 + pick = pick + 1 + + action retryTodo + refresh todo + action retryDoing + refresh doing + action retryDone + refresh done + + action move(id: number, column: string, index: number, title: string, d: number, t: number) + let n = moves + 1 + moves = n + send moved = moveCard(id, column, index, title, d, t, visit, n) + + action setNewCard(v: string) + newCard = v + action addCard + if newCard != "" + let n = adds + 1 + adds = n + send added = addCard("todo", newCard, visit, n) + newCard = "" + + action select(id: number) + selected = id + pick = pick + 1 + titleDraft = "" + itemDraft = "" + + action setTitle(v: string) + titleDraft = v + action rename + if titleDraft != "" and selectedColumn != "" + let n = renames + 1 + renames = n + send renamed = renameCard(selected, titleDraft, visit, pick, n) + + action archive + if selectedColumn != "" + let n = archives + 1 + archives = n + send archivedCard = archiveCard(selected, visit, n) + selected = 0 + pick = pick + 1 + + action toggle(itemId: number, now: bool) + let n = itemActions + 1 + itemActions = n + send toggled = toggleItem(selected, itemId, not now, visit, pick, n) + action setItem(v: string) + itemDraft = v + action addItem + if itemDraft != "" and listShown + let n = itemActions + 1 + itemActions = n + send itemAdded = addItem(selected, itemDraft, visit, pick, n) + itemDraft = "" + + action restore(id: number) + let n = restores + 1 + restores = n + send restored = restoreCard(id, visit, n) + + view + column gap=16 padding=16 + nav + row gap=8 role="tablist" + button press=openBoard role="tab" aria-selected=onBoard testId="tab-board" + text "Board" + button press=openArchive role="tab" aria-selected=(page == "archive") testId="tab-archive" + text "Archive" + if onBoard + column gap=12 + row gap=24 align-items="flex-start" flex-wrap="wrap" + column gap=8 min-width="14rem" testId="col-todo" + BoardColumn(column="todo", cards=todo, loaded=current(todo), error=message(failure(todo)), selected=selected, listShown=listShown, progress=progress, leftOpen=false, leftSize=0, rightOpen=doingOpen, rightSize=doingCards.length, select=select, move=move, retry=retryTodo) + row gap=8 + input value=newCard input=setNewCard submit=addCard placeholder="New card" aria-label="New card" testId="new-card" + button press=addCard disabled=(not current(todo)) testId="add-card" + text "add" + if errors.add != "" + text `add error: ${errors.add}` testId="add-error" + column gap=8 min-width="14rem" testId="col-doing" + BoardColumn(column="doing", cards=doing, loaded=current(doing), error=message(failure(doing)), selected=selected, listShown=listShown, progress=progress, leftOpen=todoOpen, leftSize=todoCards.length, rightOpen=doneOpen, rightSize=doneCards.length, select=select, move=move, retry=retryDoing) + column gap=8 min-width="14rem" testId="col-done" + BoardColumn(column="done", cards=done, loaded=current(done), error=message(failure(done)), selected=selected, listShown=listShown, progress=progress, leftOpen=doingOpen, leftSize=doingCards.length, rightOpen=false, rightSize=0, select=select, move=move, retry=retryDone) + column gap=8 min-width="16rem" testId="detail" + if selectedColumn == "" + text "select a card" + else + text `#${selected} ${selectedTitle}` role="heading" aria-level=2 + text `in ${columnName(selectedColumn)}` + row gap=8 + input value=titleDraft input=setTitle submit=rename placeholder="New title" aria-label="New title" testId="title-input" + button press=rename testId="rename" + text "rename" + button press=archive testId="archive" + text "archive" + if errors.rename != "" + text `rename error: ${errors.rename}` + column gap=4 testId="checklist" + if listShown + for i in checklist key=i.key + row gap=8 + if i.adding + text `[ ] ${i.text} (adding…)` testId="item" + else + text `[${i.done ? "x" : " "}] ${i.text}` testId="item" + button press=toggle(i.id, i.done) testId="toggle" + text "toggle" + text `${progress} done` testId="progress" + else if failed(checklist) + text `checklist error: ${message(failure(checklist))}` + else + text "loading checklist" + row gap=8 + input value=itemDraft input=setItem submit=addItem placeholder="New item" aria-label="New item" testId="item-input" + button press=addItem disabled=(not listShown) testId="add-item" + text "add item" + if errors.item != "" + text `item error: ${errors.item}` + if errors.move != "" + text `move error: ${errors.move}` testId="move-error" + if errors.archive != "" + text `archive error: ${errors.archive}` testId="archive-error" + if page == "archive" + column gap=8 + column gap=4 testId="archived" + if current(archived) + for a in archived key=a.id + row gap=8 + text `#${a.id} ${a.title}` testId="archived-card" + button press=restore(a.id) testId="restore" + text "restore" + else if failed(archived) + text `archive error: ${message(failure(archived))}` + else + text "loading archive" + if errors.restore != "" + text `restore error: ${errors.restore}` testId="restore-error" + +// One column: its heading, its cards with their move buttons, or its loading or error line. A card moves left or right +// to the bottom of the next column, which takes it only once it has loaded (and Doing only under its limit); up and +// down swap it with its neighbour. +component BoardColumn + props + column: string + cards: Card[] + loaded: bool + error: string + selected: number + listShown: bool + progress: string + leftOpen: bool + leftSize: number + rightOpen: bool + rightSize: number + select: action + move: action + retry: action + derive left = column == "todo" ? "" : column == "doing" ? "todo" : "doing" + derive right = column == "done" ? "" : column == "todo" ? "doing" : "done" + view + column gap=8 + text (loaded ? `${columnName(column)} (${cards.length})` : columnName(column)) role="heading" aria-level=2 testId=`head-${column}` + if loaded + for c, i in cards key=c.key + row gap=6 align-items="center" + if c.adding + text `${c.title} (adding…)` testId="card" + else + button press=select(c.id) aria-pressed=(c.id == selected) testId="card" + text `#${c.id} ${c.title} · ${c.id == selected and listShown ? progress : `${c.done}/${c.total}`}` + if left != "" + button press=move(c.id, left, leftSize, c.title, c.done, c.total) disabled=(not leftOpen) testId="left" + text "left" + if right != "" + button press=move(c.id, right, rightSize, c.title, c.done, c.total) disabled=(not rightOpen) testId="right" + text "right" + if i > 0 + button press=move(c.id, column, i - 1, c.title, c.done, c.total) testId="up" + text "up" + if i < cards.length - 1 + button press=move(c.id, column, i + 1, c.title, c.done, c.total) testId="down" + text "down" + else if error != "" + text `error: ${error}` + button press=retry testId=`retry-${column}` + text "retry" + else + text "loading" diff --git a/eval/reference/board/contract/app.ts b/eval/reference/board/contract/app.ts new file mode 100644 index 00000000..3e389239 --- /dev/null +++ b/eval/reference/board/contract/app.ts @@ -0,0 +1,187 @@ +// The board's data module: each source calls the backend in src/api.js and projects its answer onto the declared +// shape. The overlay shows the writes still out over the answers they change; the write errors are kept here, by +// kind, for `writeErrors` to report. +import type { Answer, Args, Overlay, Result, Sources, Write } from './app.contract.d.ts'; +import * as api from './src/api.js'; +import type { Column } from './src/api.js'; + +export const grants = ''; + +type Card = Result<'fetchCards'>[number]; +type Item = Result<'fetchChecklist'>[number]; +type Archived = Result<'fetchArchived'>; +type Kind = keyof Result<'writeErrors'>; + +const card = (c: { id: number; title: string; done: number; total: number }): Card => ({ + id: c.id, title: c.title, done: c.done, total: c.total, adding: false, key: `card-${c.id}`, +}); +const item = (i: { id: number; text: string; done: boolean }): Item => ({ + id: i.id, text: i.text, done: i.done, adding: false, key: `item-${i.id}`, +}); + +// The last failure of each kind of write, with the page visit and selection it was sent in and the latest send of its +// kind out at the time. It shows while its visit is open, no later send of its kind has started, and, for the +// detail's kinds, its card is still the one selected. +type Failed = { message: string; visit: number; pick: number; send: number }; +const failures: Partial<Record<Kind, Failed>> = {}; +const latest: Record<Kind, number> = { move: 0, add: 0, archive: 0, rename: 0, item: 0, restore: 0 }; + +async function write<T>(kind: Kind, visit: number, pick: number, send: number, call: Promise<T>): Promise<T> { + latest[kind] = Math.max(latest[kind], send); + try { + return await call; + } catch (e) { + failures[kind] = { message: e instanceof Error ? e.message : String(e), visit, pick, send: latest[kind] }; + throw e; + } +} + +const sources: Sources = { + fetchCards: async ([column]) => (await api.fetchCards(column as Column)).map(card), + fetchChecklist: async ([id]) => (await api.fetchChecklist(id)).map(item), + fetchArchived: async () => (await api.fetchArchived()).map((a) => ({ id: a.id, title: a.title })), + writeErrors: ([visit, pick, moves, adds, archives, renames, items, restores]) => { + const shown = (kind: Kind, send: number, scoped: boolean) => { + const f = failures[kind]; + return f && f.visit === visit && f.send === send && (!scoped || f.pick === pick) ? f.message : ''; + }; + return { + move: shown('move', moves, false), + add: shown('add', adds, false), + archive: shown('archive', archives, false), + rename: shown('rename', renames, true), + item: shown('item', items, true), + restore: shown('restore', restores, false), + }; + }, + + moveCard: async ([id, column, index, , , , visit, send]) => { + const r = await write('move', visit, 0, send, api.moveCard(id, column as Column, index)); + return { id: r.id, column: r.column, index: r.index }; + }, + addCard: async ([column, title, visit, send]) => { + const r = await write('add', visit, 0, send, api.addCard(column as Column, title)); + return { id: r.id, title: r.title }; + }, + archiveCard: async ([id, visit, send]) => ({ id: (await write('archive', visit, 0, send, api.archiveCard(id))).id }), + renameCard: async ([id, title, visit, pick, send]) => { + const r = await write('rename', visit, pick, send, api.renameCard(id, title)); + return { id: r.id, title: r.title }; + }, + toggleItem: async ([cardId, itemId, done, visit, pick, send]) => { + const r = await write('item', visit, pick, send, api.toggleItem(cardId, itemId, done)); + return { id: r.id, done: r.done }; + }, + addItem: async ([cardId, text, visit, pick, send]) => item(await write('item', visit, pick, send, api.addItem(cardId, text))), + restoreCard: async ([id, visit, send]) => ({ id: (await write('restore', visit, 0, send, api.restoreCard(id))).id }), +}; +export const answer: Answer = (source, args, store, storage, native) => sources[source](args, store, storage, native); + +/** the page visit a write was sent in */ +function visitOf(w: Write): number { + switch (w.source) { + case 'moveCard': return w.args[6]; + case 'addCard': return w.args[2]; + case 'archiveCard': return w.args[1]; + case 'renameCard': return w.args[2]; + case 'toggleItem': return w.args[3]; + case 'addItem': return w.args[2]; + case 'restoreCard': return w.args[1]; + default: return -1; + } +} + +/** + * Puts an entry from the add with send `id` into a list: before the first entry a later add put there (that add was + * answered first), else at the end, so added entries keep the order their adds started in. + */ +function insertAdded<T>(list: T[], entry: T, id: number, addedBy: Map<T, number>) { + const at = list.findIndex((e) => (addedBy.get(e) ?? -1) > id); + list.splice(at < 0 ? list.length : at, 0, entry); + addedBy.set(entry, id); +} + +function cardsShown(column: string, answer: Card[], writes: readonly Write[]): Card[] { + let rows = [...answer]; + const addedBy = new Map<Card, number>(); + const patch = (id: number, f: (c: Card) => Card) => { rows = rows.map((c) => (c.id === id && !c.adding ? f(c) : c)); }; + for (const w of writes) { + switch (w.source) { + case 'moveCard': { + const [id, to, index, title, done, total] = w.args; + const [toColumn, toIndex] = w.reply ? [w.reply.column, w.reply.index] : [to, index]; + const at = rows.findIndex((c) => c.id === id && !c.adding); + const moving = at >= 0 ? rows.splice(at, 1)[0] : card({ id, title, done, total }); + if (toColumn === column) rows.splice(Math.min(toIndex, rows.length), 0, moving); + break; + } + case 'addCard': { + if (column !== w.args[0]) break; + if (w.reply) { + const { id, title } = w.reply; + if (!rows.some((c) => c.id === id && !c.adding)) insertAdded(rows, card({ id, title, done: 0, total: 0 }), w.id, addedBy); + } else { + insertAdded(rows, { id: 0, title: w.args[1], done: 0, total: 0, adding: true, key: `adding-${w.id}` }, w.id, addedBy); + } + break; + } + case 'archiveCard': + rows = rows.filter((c) => c.adding || c.id !== w.args[0]); + break; + case 'renameCard': { + const title = w.reply ? w.reply.title : w.args[1]; + patch(w.args[0], (c) => ({ ...c, title })); + break; + } + case 'toggleItem': { + const step = w.args[2] ? 1 : -1; + patch(w.args[0], (c) => ({ ...c, done: Math.max(0, Math.min(c.total, c.done + step)) })); + break; + } + case 'addItem': + patch(w.args[0], (c) => ({ ...c, total: c.total + 1 })); + break; + } + } + return rows; +} + +function itemsShown(cardId: number, answer: Item[], writes: readonly Write[]): Item[] { + let rows = [...answer]; + const addedBy = new Map<Item, number>(); + for (const w of writes) { + if (w.source === 'toggleItem' && w.args[0] === cardId) { + const [, itemId, wanted] = w.args; + const done = w.reply ? w.reply.done : wanted; + rows = rows.map((i) => (i.id === itemId && !i.adding ? { ...i, done } : i)); + } + if (w.source === 'addItem' && w.args[0] === cardId) { + if (w.reply) { + const added = w.reply; + if (!rows.some((i) => i.id === added.id && !i.adding)) insertAdded(rows, item(added), w.id, addedBy); + } else { + insertAdded(rows, { id: 0, text: w.args[1], done: false, adding: true, key: `adding-${w.id}` }, w.id, addedBy); + } + } + } + return rows; +} + +// The writes still out over the answers they change. A write whose reply landed before the answer was asked is in the +// answer already (`answered`), and one sent in another visit of the page belongs to a page that was left, so only the +// others are laid over it, in the order the runner gives them: the landed ones as they landed, then the rest as sent. +export const overlay: Overlay = (source, args, answer, writes) => { + const visit = args[args.length - 1] as number; + const out = writes.filter((w) => !w.answered && visitOf(w) === visit); + switch (source) { + case 'fetchCards': + return cardsShown((args as Args<'fetchCards'>)[0], answer as Card[], out) as never; + case 'fetchChecklist': + return itemsShown((args as Args<'fetchChecklist'>)[0], answer as Item[], out) as never; + case 'fetchArchived': { + const gone = out.flatMap((w) => (w.source === 'restoreCard' ? [w.args[0]] : [])); + return (answer as Archived).filter((a) => !gone.includes(a.id)) as never; + } + } + return undefined; +}; From 7278c8799729205b41337be1a41079184d5dcac5 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 14 Nov 2026 12:41:54 +0000 Subject: [PATCH 15/32] eval: board fuzz checks every card's count against the server; round 15 prereg draft Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- eval/PREREG-15.md | 106 ++++++++++++++++++++++++++++++++++++ eval/harness/fuzz-board.mjs | 12 ++++ 2 files changed, 118 insertions(+) create mode 100644 eval/PREREG-15.md diff --git a/eval/PREREG-15.md b/eval/PREREG-15.md new file mode 100644 index 00000000..e1f8bda4 --- /dev/null +++ b/eval/PREREG-15.md @@ -0,0 +1,106 @@ +# Round 15: six arms, two tasks, two tiers, for publication + +Pre-registration, committed before any round 15 agent runs. Status: DRAFT (needs Ryan's review; nothing here is frozen yet). + +## Why + +Rounds 10 to 14 were exploratory. Each one changed the guide, Solid, or the grader between runs, used one task the guide had been tuned on (desk9), and ran on unreleased builds. Round 15 is the round meant to be published. + +- **Fixed in advance:** the arms, tasks, builds, grader and tests below. +- **Builds:** released versions only. +- **Tasks:** one task the guide has never seen (board) next to the familiar one (desk9). +- **Reporting:** every result is published, including nulls and reversals. + +Ryan's direction (2026-11-12): .rx has to at least equal React at frontier models to matter. The defect gap at small models is the claim it may be able to win. Cost is reported, but it isn't a hypothesis. + +## Arms + +All six arms have a hand-written reference that grades 0 on both tasks (see "References"). + +| arm | stack | what it stands for | +|---|---|---| +| `rx` | ReactiveScript over Solid 2.0, with `--inline-docs` (language.md, async.md, reference.md) | PRIMITIVES | +| `react` | React 19, TanStack Query offered | the default | +| `contract` | Exact (Contract + TypeScript data module), its own `start-here.md` | Exact, a likely competitor | +| `xstate` | React 19 + xstate 5.32 + @xstate/react 6.1, Stately v5 docs | PROCESS (state machines) | +| `foldkit` | Foldkit 0.170 on Effect 4, with `--inline-docs` | PROCESS (Effect/TEA) | +| `vanilla` | plain TypeScript and the DOM | PLATFORM (stand-in for Remix 3's no-abstractions bet) | + +## Tasks + +- **`desk9`** (`brief-plain.md`), unchanged since round 12. Graded by `hidden.mjs` (77), `hidden-overlap.mjs` (38) and `fuzz.mjs --r15`, 20 seeds. The guide was tuned against this task over rounds 9 to 14, so it is not held out. It is reported as the familiar task. +- **`board`** (`brief-plain.md`), a sprint board: cards across three columns, moves and reordering, a work-in-progress limit, each card's checklist, renames, adds, an archive page, and overlapping writes. It is new and held out: no .rx, guide or Solid change was made while looking at agent output for it. Graded by `hidden.mjs` (45), `hidden-overlap.mjs` (26) and `fuzz-board.mjs`, 20 seeds. Agents see `visible.mjs` (26). + +## Builds (released only; exact versions filled in at freeze) + +- **Solid:** the first `@solidjs/signals` / `solid-js` 2.0 release candidate that contains #4856. It must also either contain the fix for #4859 or be recorded here as not containing it. +- **rxc:** a published `@reactivescript/compiler` built from the commit that freezes this file (bump release first). +- **Exact:** a tagged Exact release (not a checkout). If Exact has none, then a pinned commit, named as a deviation. +- **Foldkit:** the published 0.170.x, with Effect 4 as Foldkit pins it. +- **React / XState:** as installed in round 12 (react 19, xstate 5.32.6, @xstate/react 6.1.0, @tanstack/react-query 5). + +**Gate:** before the round starts, all 12 references (6 arms × 2 tasks) are regraded on the frozen builds and must grade 0. If one gains a hole, it is traced. A framework bug is filed upstream and the round waits; a reference bug is fixed in the reference. + +## Grading + +- **What counts:** only defects (Ryan, 2026-11-10). Holds that are by design (H-11) and failures the brief caused are excluded from every total. Each exclusion is listed with its trace. +- **Defects per app:** failed hidden tests, plus failed overlap tests, plus fuzz seeds with a hole, with a task's tests and fuzz counted together (as `harness/grade.sh`). +- **Fuzz rules,** the same for every arm: + - **Same field written twice:** at rest, the field must show the server's last answer (Ryan, 2026-11-14, final). This is why `--r15` drops desk9's A2 allowance. + - **Late writes:** a write that answers after its page was left need not show on the next visit (Ryan, 2026-11-14: out of scope). On desk9 this is A4, extended to the detail, labels, comments and two-views checks. On board it is B1: settled checks are skipped, as is the duplicate-card check, in a visit where a late write landed. + - **B2 (board):** after two moves overlap in the air, a column's settled order isn't checked, only which cards it holds. + - **Distinct texts:** fuzz-added cards and items get unique texts, because two adds of one text are one flight to the harness. +- **Board fuzz oracles:** these are listed at the top of `harness/fuzz-board.mjs`. + - Every frame: duplicate card, heading count, card count against progress, every card's count against the server (give or take that card's own writes still in the air), the work-in-progress limit, the detail's column and title. + - Settled: columns, checklist and archive equal the server's. + +## Runs + +- **Count:** for each arm and task, 6 frontier (Opus) and 6 small (Haiku) apps. That is 6 × 2 × 12 = 144 runs, in one interleaved batch. +- **Limits:** at most 15 `./check`/`./test` calls per run. +- **Harness:** subagents with the round 12 prompt per arm (`harness/prompt-arms.json`). Time is recorded per run (`.agent.json` `ms`). + +## Hypotheses + +All tests use seed 15. Each app's defects are summed across its task's three graders. Comparisons pool the two tasks, and permutations shuffle labels within a task, so 12 apps per arm per tier. + +- **H15a (primary family, small tier):** .rx apps have fewer defects than each other arm. + - Five one-sided permutation tests on mean defects, 100,000 relabellings, Holm-corrected together: rx < react, rx < contract, rx < xstate, rx < foldkit, rx < vanilla. +- **H15b (frontier tier):** .rx apps have no more defects than React apps. + - Difference in means (rx − react), with a 90% bootstrap interval stratified by task, 10,000 resamples. Holds if the upper end is under 1 defect. + - The same interval is reported for rx against each other arm, without a claim. +- **H15c (held-out check):** H15a's rx < react, on board alone, small tier. Reported beside the pooled test. This shows whether the effect survives on a task the guide was never tuned for. +- **Descriptive, every arm × tier × task:** mean defects with a 90% interval; defects by kind; cost per run and per correct app; time per run; turns; lines. + - Cost and time are not hypotheses (Ryan: .rx won't win on cost). +- **Not tested:** PROCESS against PLATFORM comparisons are reported descriptively only. + +The direction is the claim. A null or reversed result is reported as plainly, and all 144 apps, their grades and their traces are published. + +## Known caveats (from writing the references) + +- **Board, .rx:** the guide's desk9 shape gives fuzz holes on board: each write refreshes its keyed list, and guesses on one list from separate actions end together (H-11, ruled). The .rx reference instead keeps one record of every write per page (passes, 547 lines). Agents who follow the guide's desk9 shape will lose points here. That counts as an agent/guide defect, not a test bug. The guide is not changed for round 15. +- **Board, Exact:** `parallel` hides an earlier send's failure once a later send concludes, and the brief clears a move error only when the next move starts. The Exact reference keeps errors in its data module. +- **Board brief, unspecified but untested:** + - a move into a column still loading; + - whether "archive error" covers both the archive page's load failure and a declined archive; + - adding before To do has loaded; + - when the archive error clears; + - re-selecting a card whose checklist failed to load. +- **Desk9 (from rounds 9 to 14):** + - the fuzz server never returns a stale answer to a load that started before a write; + - the 60 ms settle can hide late TanStack updates. +- **Solid:** #4859 (a new key under `Loading on(key)` shows the old answer while a guess is out) was found by the .rx board reference. If the frozen Solid lacks the fix, .rx agents can hit it, and each such hole counts as a defect, attributed to Solid in the report. + +## References (all grade 0) + +| task | rx | react | contract | xstate | foldkit | vanilla | +|---|---|---|---|---|---|---| +| desk9 (`--r15`) | 0 | 0 | 0 | 0 | 0 | 0 | +| board (+40 extra seeds) | 0 (547 lines) | 0 (681) | 0 (510) | 0 (890) | 0 (1293) | 0 (752) | + +`reference/desk9/*` and `reference/board/*`, graded on Solid `next` 57a7f422f, regraded at the gate. + +## Review and cost + +- **Review:** before freezing, an outside reviewer (not Claude) reads this file and the board brief and tests. Ryan picks who. +- **Cost:** about $200 to $250. Frontier runs cost about $1.30 to $3.30 each, depending on arm; small runs about $0.15 to $0.30 each. diff --git a/eval/harness/fuzz-board.mjs b/eval/harness/fuzz-board.mjs index 0448ff9a..b15ec849 100644 --- a/eval/harness/fuzz-board.mjs +++ b/eval/harness/fuzz-board.mjs @@ -6,6 +6,8 @@ // - duplicate card: no card id shows twice, and an added card never shows beside its own "(adding…)" row; // - heading: a loaded column's heading counts the cards it shows; // - count: the selected card's "<done>/<total>" equals the checklist's "<done>/<total> done" while both show; +// - card count: every card's "<done>/<total>" is the server's, give or take that card's own toggles and item adds +// still in the air (not checked in a B1 visit); // - limit: while Doing shows 3 or more cards, every To do card's right and every Done card's left is disabled; // - detail: the selected card's detail names the column it shows in, and the title its card shows (two views). // Settled (nothing in the air), on the board page with all three columns loaded, and at the end of a session: @@ -150,6 +152,16 @@ async function session(browser, url, seed, steps, log) { const n = placeholders.filter(p => p === t).length, out = addsOut.filter(a => a === t).length; if (n > out) note("duplicate card", `"${t} (adding…)" shows ${n} time(s) with ${out} add(s) of it in the air${shownReal ? ", beside its added card" : ""}`); } + // every card's count: the server's, give or take that card's own toggles and item adds still in the air + if (!lateVisit) { + const out = await pending(); + for (const col of COLS) for (const x of b[col]?.rows ?? []) { + if (x.adding) continue; + const xs = srv.items[x.id] ?? [], sd = xs.filter(y => y.done).length; + const t = out.filter(k => k.startsWith(`toggleItem:${x.id},`)).length, ad = out.filter(k => k.startsWith(`addItem:${x.id},`)).length; + if (x.total < xs.length || x.total > xs.length + ad || x.done < sd - t || x.done > sd + t) note("card count", `card "${x.text}", server ${sd}/${xs.length} with ${t} toggle(s) and ${ad} item add(s) of it in the air`); + } + } // the work-in-progress limit if (b.doing && b.doing.rows.length >= 3) { for (const [col, dir] of [["todo", "right"], ["done", "left"]]) { From a169017c17a5654c9d7697f141701bf9f7b7030f Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 14 Nov 2026 17:00:51 +0000 Subject: [PATCH 16/32] eval: fuzz-board B3, legal holds (as desk9 A1/A5): held detail and an answered add's placeholder while work is in the air Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- eval/harness/fuzz-board.mjs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/eval/harness/fuzz-board.mjs b/eval/harness/fuzz-board.mjs index b15ec849..4e2a6d4c 100644 --- a/eval/harness/fuzz-board.mjs +++ b/eval/harness/fuzz-board.mjs @@ -20,6 +20,9 @@ // - B2 (brief: "if the server accepts, the card stays where it was moved"): after two moves overlapped in the air, a // column's order is not checked settled for the rest of the visit, only which cards it holds. An app asked to keep a // card where it showed it cannot know an order the server made from answers it applied in another order. +// - B3 (H-11, as desk9's A5: a switch made while work is in flight may hold): while anything is in the air, the +// detail may still show a card selected earlier in that busy period, checked against that card; and an add's +// "(adding…)" row may stay after its answer (accepted or declined) while other work is in the air. At rest neither. // - Two accepted writes to one field: settled, the field must show the server's last answer (Ryan, 2026-11-14). // Usage: node harness/fuzz-board.mjs <dir> [--seeds N] [--steps N] [--from S] [--json out.json] [--log] // (--r9 and --r15 are accepted and ignored, so harness/grade.sh can pass its round flag.) @@ -103,6 +106,7 @@ async function session(browser, url, seed, steps, log) { const settledCount = new Map(); let lateVisit = false; // B1 let looseOrder = false; // B2 + const heldSel = new Set(); // B3: selections made while something was in the air const trail = []; const found = []; let made = 0; // added cards and items get distinct texts: two adds of one text would be one flight to the harness const pending = () => page.evaluate(() => globalThis.__flights.pending()); @@ -145,12 +149,14 @@ async function session(browser, url, seed, steps, log) { if (c.n !== c.rows.length) note("heading", `head-${col} counts ${c.n}, the column shows ${c.rows.length} cards`); for (const x of c.rows) if (!x.adding) { if (seen.has(x.id) && !lateVisit) note("duplicate card", `#${x.id} shows in ${seen.get(x.id)} and ${col}`); seen.set(x.id, col); } } + const busy = (await pending()).length > 0; + if (!busy) heldSel.clear(); const placeholders = COLS.flatMap(col => (b[col]?.rows ?? []).filter(x => x.adding).map(x => x.title)); const addsOut = (await pending()).filter(k => k.startsWith("addCard:")).map(k => k.slice(k.indexOf(",") + 1)); for (const t of new Set(placeholders)) { const shownReal = COLS.flatMap(col => (b[col]?.rows ?? []).filter(x => !x.adding && x.title === t && x.id >= 10)).length; const n = placeholders.filter(p => p === t).length, out = addsOut.filter(a => a === t).length; - if (n > out) note("duplicate card", `"${t} (adding…)" shows ${n} time(s) with ${out} add(s) of it in the air${shownReal ? ", beside its added card" : ""}`); + if (n > out && !(shownReal === 0 && busy)) note("duplicate card", `"${t} (adding…)" shows ${n} time(s) with ${out} add(s) of it in the air${shownReal ? ", beside its added card" : ""}`); } // every card's count: the server's, give or take that card's own toggles and item adds still in the air if (!lateVisit) { @@ -171,9 +177,12 @@ async function session(browser, url, seed, steps, log) { } // the detail against the card it shows const detail = await text("detail"); - if (ui.selected != null && detail && !/select a card/.test(detail)) { - const col = COLS.find(cl => b[cl]?.rows.some(x => x.id === ui.selected)); - const x = col && b[col].rows.find(y => y.id === ui.selected); + const shownId = Number((detail ?? "").match(/#(\d+) /)?.[1]); + // B3 (H-11, as desk9's A5): while anything is in the air, the detail may still show a card selected before + const target = busy && shownId !== ui.selected && heldSel.has(shownId) ? shownId : ui.selected; + if (target != null && detail && !/select a card/.test(detail)) { + const col = COLS.find(cl => b[cl]?.rows.some(x => x.id === target)); + const x = col && b[col].rows.find(y => y.id === target); if (x) { if (!detail.includes(`in ${NAME[col]}`)) note("detail column", `#${x.id} shows in ${NAME[col]}, the detail says "${detail.slice(0, 80)}"`); if (!detail.includes(`#${x.id} ${x.title}`)) note("two views", `card "${x.text}", detail "${detail.slice(0, 80)}"`); @@ -237,7 +246,7 @@ async function session(browser, url, seed, steps, log) { case "select": { const ids = (await page.locator(sel("card")).allTextContents()).map(norm).map(t => t.match(CARD)).filter(Boolean).map(m => Number(m[1])); if (!ids.length) return; const id = pick(ids); trail.push(`select #${id}`); - await page.locator(sel("card"), { hasText: new RegExp(`^\\s*#${id} `) }).first().click({ timeout: 2000 }); ui.selected = id; break; + await page.locator(sel("card"), { hasText: new RegExp(`^\\s*#${id} `) }).first().click({ timeout: 2000 }); if ((await pending()).length) { if (ui.selected != null) heldSel.add(ui.selected); } ui.selected = id; break; } case "move": { const btns = []; From f9edf8f697b152af0eddcb04e382c956bbc50866 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 14 Nov 2026 17:15:58 +0000 Subject: [PATCH 17/32] eval: prereg records the Exact move-error limit (Ryan's call) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- eval/PREREG-15.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eval/PREREG-15.md b/eval/PREREG-15.md index e1f8bda4..4e556bc1 100644 --- a/eval/PREREG-15.md +++ b/eval/PREREG-15.md @@ -79,7 +79,7 @@ The direction is the claim. A null or reversed result is reported as plainly, an ## Known caveats (from writing the references) - **Board, .rx:** the guide's desk9 shape gives fuzz holes on board: each write refreshes its keyed list, and guesses on one list from separate actions end together (H-11, ruled). The .rx reference instead keeps one record of every write per page (passes, 547 lines). Agents who follow the guide's desk9 shape will lose points here. That counts as an agent/guide defect, not a test bug. The guide is not changed for round 15. -- **Board, Exact:** `parallel` hides an earlier send's failure once a later send concludes, and the brief clears a move error only when the next move starts. The Exact reference keeps errors in its data module. +- **Board, Exact:** `parallel` hides an earlier send's failure once a later send concludes, and the brief clears a move error only when the next move starts. Idiomatic Contract cannot meet this (`parallel` takes no `then`), so the Exact reference keeps errors in its data module, the only way found. Recorded as an Exact limit that agents must work around; the test stays (Ryan, 2026-11-14). - **Board brief, unspecified but untested:** - a move into a column still loading; - whether "archive error" covers both the archive page's load failure and a declined archive; From edde969955ecdad0a84c6fed12925c84e6f767b1 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 14 Nov 2026 18:06:33 +0000 Subject: [PATCH 18/32] eval: guide-shaped .rx board (rx-guide) and repros for solidjs/solid#4861-#4864 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- eval/reference/board/rx-guide/GRADE.txt | 19 ++ eval/reference/board/rx-guide/archive.rx | 55 +++++ eval/reference/board/rx-guide/board.rx | 206 ++++++++++++++++++ eval/reference/board/rx-guide/main.rx | 32 +++ eval/reference/board/rx-guide/store.rx | 173 +++++++++++++++ .../repro/blank-region-after-revisit.app.tsx | 28 +++ ...lank-region-after-revisit.attempt.spec.tsx | 49 +++++ .../repro/blank-region-after-revisit.rx | 45 ++++ .../blank-region-after-revisit.steps.mjs | 11 + .../repro/board-limit-after-reask.steps.mjs | 15 ++ .../keyed-checklist-reverts-selection.rx | 49 +++++ ...keyed-checklist-reverts-selection.spec.tsx | 55 +++++ ...eyed-checklist-reverts-selection.steps.mjs | 10 + .../r15prep/repro/keyed-null-never-updates.rx | 10 + .../repro/keyed-row-under-if-after-guess.rx | 23 ++ .../keyed-row-under-if-after-guess.steps.mjs | 2 + .../keyed-row-under-show-after-guess.spec.tsx | 36 +++ .../r15prep/repro/limit-after-reask.app.tsx | 26 +++ .../r15prep/repro/limit-after-reask.board.rx | 20 ++ .../r15prep/repro/limit-after-reask.spec.tsx | 48 ++++ .../r15prep/repro/limit-after-reask.steps.mjs | 11 + .../r15prep/repro/limit-after-reask.store.rx | 27 +++ eval/results/r15prep/repro/probe-rx.mjs | 16 ++ .../repro/tear-attempt-not-reproduced.rx | 53 +++++ 24 files changed, 1019 insertions(+) create mode 100644 eval/reference/board/rx-guide/GRADE.txt create mode 100644 eval/reference/board/rx-guide/archive.rx create mode 100644 eval/reference/board/rx-guide/board.rx create mode 100644 eval/reference/board/rx-guide/main.rx create mode 100644 eval/reference/board/rx-guide/store.rx create mode 100644 eval/results/r15prep/repro/blank-region-after-revisit.app.tsx create mode 100644 eval/results/r15prep/repro/blank-region-after-revisit.attempt.spec.tsx create mode 100644 eval/results/r15prep/repro/blank-region-after-revisit.rx create mode 100644 eval/results/r15prep/repro/blank-region-after-revisit.steps.mjs create mode 100644 eval/results/r15prep/repro/board-limit-after-reask.steps.mjs create mode 100644 eval/results/r15prep/repro/keyed-checklist-reverts-selection.rx create mode 100644 eval/results/r15prep/repro/keyed-checklist-reverts-selection.spec.tsx create mode 100644 eval/results/r15prep/repro/keyed-checklist-reverts-selection.steps.mjs create mode 100644 eval/results/r15prep/repro/keyed-null-never-updates.rx create mode 100644 eval/results/r15prep/repro/keyed-row-under-if-after-guess.rx create mode 100644 eval/results/r15prep/repro/keyed-row-under-if-after-guess.steps.mjs create mode 100644 eval/results/r15prep/repro/keyed-row-under-show-after-guess.spec.tsx create mode 100644 eval/results/r15prep/repro/limit-after-reask.app.tsx create mode 100644 eval/results/r15prep/repro/limit-after-reask.board.rx create mode 100644 eval/results/r15prep/repro/limit-after-reask.spec.tsx create mode 100644 eval/results/r15prep/repro/limit-after-reask.steps.mjs create mode 100644 eval/results/r15prep/repro/limit-after-reask.store.rx create mode 100644 eval/results/r15prep/repro/probe-rx.mjs create mode 100644 eval/results/r15prep/repro/tear-attempt-not-reproduced.rx diff --git a/eval/reference/board/rx-guide/GRADE.txt b/eval/reference/board/rx-guide/GRADE.txt new file mode 100644 index 00000000..02331cd1 --- /dev/null +++ b/eval/reference/board/rx-guide/GRADE.txt @@ -0,0 +1,19 @@ +== hidden +✗ a move error stays when another move is accepted +✗ two adds show both, each taking its own id +✗ an added item can be toggled once it has its id +42/45 passed +== overlap +✗ one of two moves declined puts back only its card +✗ an add accepted while To do reloads shows the card once +✗ an item added while the checklist reloads shows once +✗ two renames, the later declined after the earlier was accepted: the earlier title stays +✗ a move into Doing that fills it while a move out is pending disables the rest +✗ an add accepted after the board was left does not show twice on the next visit +20/26 passed +== fuzz +board-rx2: 2/20 seeds found a hole + seed 7 step 43: duplicate card: "Delta 1 (adding…)" shows 1 time(s) with 0 add(s) of it in the air + land fetchCards:todo ; up #2 ; select #1 ; up #1 ; up #2 ; land fetchCards:done ; fail moveCard:1,todo,0 ; up #1 ; land moveCard:2,todo,0 ; select #5 ; add Delta 1 ; add Delta 2 ; select #1 ; fail fetchCards:todo ; land addCard:todo,Delta 1 ; down #1 ; up #1 ; land fetchChecklist:1 ; up #2 ; up #1 ; tab archive ; tab board ; tab archive ; land addCard:todo,Delta 2 ; tab board ; add Delta 3 ; ( + seed 15 step 49: heading: head-todo counts 3, the column shows 6 cards + land fetchCards:doing ; select #4 ; up #4 ; land fetchCards:done ; fail moveCard:4,doing,0 ; land fetchCards:todo ; rename Bravo ; fail fetchChecklist:4 ; select #4 ; land renameCard:4,Bravo ; add Alpha 1 ; add Alpha 2 ; land fetchCards:todo ; add Bravo 3 ; select #5 ; land fetchCards:doing ; up #4 ; left #5 ; rename Alpha ; land renameCard:4,Alpha ; land fetchChecklist:4 ; left #5 ; down #2 ; diff --git a/eval/reference/board/rx-guide/archive.rx b/eval/reference/board/rx-guide/archive.rx new file mode 100644 index 00000000..ed68e4d2 --- /dev/null +++ b/eval/reference/board/rx-guide/archive.rx @@ -0,0 +1,55 @@ +// The archive page's data and its one write. Restores in flight are a guess list laid over the server's answer; an +// accepted one ends with the list asked again. + +import pure { fetchArchived } from "./api" +import { restoreCard } from "./api" + +// opening the archive page asks for it again; the app opens on the board, so the first open asks +source visit: number := -1 +archived = visit >= 0 ? await fetchArchived() : [] +source restoring: number[] = [] +shown = archived.filter(a => !restoring.includes(a.id)) +type Fail = (m: string) => void + +export action openArchive() { + visit := visit + 1 +} + +// a decline goes to `fail`, which the page that made the restore owns (its failure lives with that visit) +action restore(id: number, fail: Fail) { + try { + guess restoring := xs => [...xs, id] + await restoreCard(id) + refresh archived + } catch (e) { + fail(e.message) + onFlush(() => { fail(e.message) }) + } +} + +export component ArchivePage() { + source restoreError: string := "" + + <section> + <div data-testid="archived"> + {loading fallback { <p>loading archive</p> } { + {errored fallback(e) { <p>archive error: {e.message}</p> } { + <ul> + {for a in shown { + <li> + <span data-testid="archived-card">#{a.id} {a.title}</span>{" "} + <button data-testid="restore" onClick={() => { + onFlush(() => { restoreError := "" }) + restore(a.id, m => { restoreError := m }) + }}>restore</button> + </li> + }} + </ul> + }} + }} + </div> + {if restoreError != "" { + <p data-testid="restore-error">restore error: {restoreError}</p> + }} + </section> +} diff --git a/eval/reference/board/rx-guide/board.rx b/eval/reference/board/rx-guide/board.rx new file mode 100644 index 00000000..553d7121 --- /dev/null +++ b/eval/reference/board/rx-guide/board.rx @@ -0,0 +1,206 @@ +// The board page: three columns, the add form, and the selected card's detail. The data and the writes are in +// store.rx; this page holds only its inputs, which go when it closes. + +import { Card } from "./api" +import { Row, selected, todoAns, doingAns, doneAns, todoRows, doingRows, doneRows, checklist, items } from "./store.rx" +import { select, retry, move, add, rename, archive, toggle, addOneItem } from "./store.rx" + +fn colName(c: string): string => c == "todo" ? "To do" : c == "doing" ? "Doing" : "Done" +fn real(rows: Row[] | null): number => rows == null ? 0 : rows.filter(r => !r.adding).length +fn count(rows: Row[] | null): string => rows == null ? "" : ` (${rows.length})` +fn findIn(rows: Row[] | null, id: number | null): Row | null => rows == null || id == null ? null : rows.find(r => !r.adding && r.id == id) ?? null + +component Column(props: { + col: string; rows: Row[] | null; retrying: boolean; left: string; right: string; leftOk: boolean; rightOk: boolean; + leftSize: number; rightSize: number; onSelect: (id: number) => void; onMove: (card: Card, col: string, index: number) => void; +}) { + // the heading and the cards show in one region, so the count always counts the cards on screen + <section data-testid={`col-${props.col}`} style="min-width: 14rem"> + {loading fallback { <h2 data-testid={`head-${props.col}`}>{colName(props.col)}</h2><p>loading</p> } { + <h2 data-testid={`head-${props.col}`}>{colName(props.col)}{count(props.rows catch (e) => null)}</h2> + {errored fallback(e) { + {if props.retrying { + <p>loading</p> + } else { + <p>error: {e.message}{" "}<button data-testid={`retry-${props.col}`} onClick={() => { retry(props.col) }}>retry</button></p> + }} + } { + {if props.rows == null { + <p>loading</p> + } else { + <ul> + {for r in props.rows { + <li> + {if r.adding { + <span data-testid="card">{r.title} (adding…)</span> + } else { + <button data-testid="card" onClick={() => { props.onSelect(r.id) }}>#{r.id} {r.title} · {r.done}/{r.total}</button> + {if props.left != "" { + {" "}<button data-testid="left" disabled={!props.leftOk} onClick={() => { props.onMove(r.card, props.left, props.leftSize) }}>left</button> + }} + {if props.right != "" { + {" "}<button data-testid="right" disabled={!props.rightOk} onClick={() => { props.onMove(r.card, props.right, props.rightSize) }}>right</button> + }} + {if r.pos > 0 { + {" "}<button data-testid="up" onClick={() => { props.onMove(r.card, props.col, r.pos - 1) }}>up</button> + }} + {if !r.last { + {" "}<button data-testid="down" onClick={() => { props.onMove(r.card, props.col, r.pos + 1) }}>down</button> + }} + }} + </li> + }} + </ul> + }} + }} + }} + </section> +} + +export component BoardPage() { + source newCard: string := "" + source titleInput: string := "" + source itemInput: string := "" + // failures belong to this visit: a write that is declined after the page closed shows nothing on the next one + source moveError: string := "" + source addError: string := "" + source archiveError: string := "" + source renameError: string := "" + source itemError: string := "" + + // each column as the other panes see it: null while it loads (the probe reads ahead, so a column that loads never + // holds another column or the detail) or after it failed; nothing moves into a column that isn't shown + todoShown = pending? todoAns ? null : todoRows catch (e) => null + doingShown = pending? doingAns ? null : doingRows catch (e) => null + doneShown = pending? doneAns ? null : doneRows catch (e) => null + todoN = real(todoShown) + doingN = real(doingShown) + doneN = real(doneShown) + todoUp = todoShown != null + doingUp = doingShown != null + doneUp = doneShown != null + intoDoing = doingUp && doingN < 3 + + // the detail reads the card where the board shows it + selTodo = findIn(todoShown, selected) + selDoing = findIn(doingShown, selected) + selDone = findIn(doneShown, selected) + sel = selTodo ?? selDoing ?? selDone + selCol = selTodo != null ? "todo" : selDoing != null ? "doing" : "done" + + action pick(id: number) { + titleInput := "" + itemInput := "" + onFlush(() => { + renameError := "" + itemError := "" + }) + select(id) + } + + action moveTo(c: Card, to: string, i: number) { + onFlush(() => { moveError := "" }) + move(c, to, i, m => { moveError := m }) + } + + <div> + <div style="display: flex; gap: 1.5rem; align-items: flex-start"> + <div> + <Column col="todo" rows={todoRows} retrying={pending? todoAns} left="" right="doing" leftOk={false} rightOk={intoDoing} + leftSize={0} rightSize={doingN} onSelect={id => { pick(id) }} onMove={(c, to, i) => { moveTo(c, to, i) }} /> + <div> + <input data-testid="new-card" value={newCard} onInput={e => { newCard := e.target.value }} placeholder="new card" />{" "} + {loading fallback { <button data-testid="add-card" disabled>add</button> } { + <button data-testid="add-card" disabled={!todoUp} onClick={() => { + if (newCard != "") { + let t = newCard + onFlush(() => { + newCard := "" + addError := "" + }) + add(t, m => { addError := m }) + } + }}>add</button> + }} + {if addError != "" { + <p data-testid="add-error">add error: {addError}</p> + }} + </div> + </div> + <Column col="doing" rows={doingRows} retrying={pending? doingAns} left="todo" right="done" leftOk={todoUp} rightOk={doneUp} + leftSize={todoN} rightSize={doneN} onSelect={id => { pick(id) }} onMove={(c, to, i) => { moveTo(c, to, i) }} /> + <Column col="done" rows={doneRows} retrying={pending? doneAns} left="doing" right="" leftOk={intoDoing} rightOk={false} + leftSize={doingN} rightSize={0} onSelect={id => { pick(id) }} onMove={(c, to, i) => { moveTo(c, to, i) }} /> + <section data-testid="detail" style="min-width: 16rem"> + {if selected == null || sel == null { + <p>select a card</p> + } else { + <h2>#{sel.id} {sel.title}</h2> + <p>in {colName(selCol)}</p> + <div> + <input data-testid="title-input" value={titleInput} onInput={e => { titleInput := e.target.value }} placeholder="new title" />{" "} + <button data-testid="rename" onClick={() => { + if (titleInput != "") { + let t = titleInput + onFlush(() => { + titleInput := "" + renameError := "" + }) + rename(sel.id, t, m => { renameError := m }) + } + }}>rename</button>{" "} + <button data-testid="archive" onClick={() => { + onFlush(() => { archiveError := "" }) + archive(sel.id, m => { archiveError := m }) + }}>archive</button> + </div> + {if renameError != "" { + <p>rename error: {renameError}</p> + }} + <div data-testid="checklist"> + {loading on(selected) fallback { <p>loading checklist</p> } { + {errored fallback(e) { <p>checklist error: {e.message}</p> } { + <ul> + {for it in items { + {if it.adding { + <li><span data-testid="item">[ ] {it.text} (adding…)</span></li> + } else { + <li> + <span data-testid="item">[{it.done ? "x" : " "}] {it.text}</span>{" "} + <button data-testid="toggle" onClick={() => { + onFlush(() => { itemError := "" }) + toggle(sel.id, it.id, !it.done, m => { itemError := m }) + }}>toggle</button> + </li> + }} + }} + </ul> + <p data-testid="progress">{items.filter(it => it.done).length}/{items.length} done</p> + <input data-testid="item-input" value={itemInput} onInput={e => { itemInput := e.target.value }} placeholder="new item" />{" "} + <button data-testid="add-item" onClick={() => { + if (itemInput != "") { + let t = itemInput + onFlush(() => { + itemInput := "" + itemError := "" + }) + addOneItem(sel.id, t, m => { itemError := m }) + } + }}>add item</button> + }} + }} + </div> + {if itemError != "" { + <p>item error: {itemError}</p> + }} + }} + </section> + </div> + {if moveError != "" { + <p data-testid="move-error">move error: {moveError}</p> + }} + {if archiveError != "" { + <p data-testid="archive-error">archive error: {archiveError}</p> + }} + </div> +} diff --git a/eval/reference/board/rx-guide/main.rx b/eval/reference/board/rx-guide/main.rx new file mode 100644 index 00000000..cd60940c --- /dev/null +++ b/eval/reference/board/rx-guide/main.rx @@ -0,0 +1,32 @@ +// The sprint board: two pages behind tabs. Only the open page is mounted, so leaving one discards its inputs, and +// opening it asks for its data again. + +import { BoardPage } from "./board.rx" +import { ArchivePage, openArchive } from "./archive.rx" +import { openBoard } from "./store.rx" + +export default component App() { + source page: string := "board" + + <div> + <nav> + <button data-testid="tab-board" onClick={() => { + if (page != "board") { + openBoard() + page := "board" + } + }}>Board</button>{" "} + <button data-testid="tab-archive" onClick={() => { + if (page != "archive") { + openArchive() + page := "archive" + } + }}>Archive</button> + </nav> + {if page == "board" { + <BoardPage /> + } else { + <ArchivePage /> + }} + </div> +} diff --git a/eval/reference/board/rx-guide/store.rx b/eval/reference/board/rx-guide/store.rx new file mode 100644 index 00000000..11d8379b --- /dev/null +++ b/eval/reference/board/rx-guide/store.rx @@ -0,0 +1,173 @@ +// The board's data and every write on it (guide/async.md, "three layers"). The server's answers are equations; the +// writes still out are guess lists with a constant question, laid over every answer by the equations below, so a +// card shows each write the same in its column and in the detail. An accepted write ends with the answers asked +// again; its guess stays until they land (D-34). A declined one reverts with its message, in one frame (AC-7). + +import pure { fetchCards, fetchChecklist } from "./api" +import { moveCard, renameCard, addCard, archiveCard, toggleItem, addItem, Card, Item } from "./api" + +// a card as shown, with the answer it came from (a move carries that, so the column it lands in can show it) +export type Row = { key: string; id: number; title: string; done: number; total: number; adding: boolean; card: Card; pos: number; last: boolean } +type Move = { id: number; col: string; index: number; card: Card } +type Rename = { id: number; title: string } +type Toggle = { card: number; item: number; done: boolean } +type ItemAdd = { card: number; text: string } +type Fail = (m: string) => void +type List = { card: number; items: Item[] } + +// opening the board page asks for its columns again (the app opens on it) +export source visit: number := 0 +export source selected: number | null := null + +// the server's answers, with what an accepted add answered written over them until they are asked again (an add's +// answer is the new card or item itself, so it re-asks nothing); `null` while a column's first answer is out +export source todoAns = visit >= 0 ? (await fetchCards("todo") -> null) : null +export source doingAns = visit >= 0 ? (await fetchCards("doing") -> null) : null +export source doneAns = visit >= 0 ? (await fetchCards("done") -> null) : null +// the answer names the card it is for, so an item add answered after another card was selected leaves it alone +export source checklist = selected == null ? null : { card: selected, items: await fetchChecklist(selected) } + +source moving: Move[] = [] +source renaming: Rename[] = [] +source archiving: number[] = [] +source adding: string[] = [] +source toggling: Toggle[] = [] +source itemAdding: ItemAdd[] = [] + +fn without(cs: Card[], id: number): Card[] => cs.filter(c => c.id != id) +fn step(cs: Card[], col: string, m: Move): Card[] => + m.col != col ? without(cs, m.id) : [...without(cs, m.id).slice(0, m.index), cs.find(c => c.id == m.id) ?? m.card, ...without(cs, m.id).slice(m.index)] +fn withCard(cs: Card[] | null, c: Card): Card[] | null => cs == null || cs.some(x => x.id == c.id) ? cs : [...cs, c] +fn counted(cs: Card[] | null, id: number): Card[] | null => cs == null ? cs : cs.map(c => c.id == id ? { ...c, total: c.total + 1 } : c) +fn withItem(l: List | null, card: number, it: Item): List | null => + l == null || l.card != card || l.items.some(x => x.id == it.id) ? l : { card: card, items: [...l.items, it] } +fn titleOf(rs: Rename[], id: number, title: string): string => rs.reduce((t, r) => r.id == id ? r.title : t, title) +fn doneOf(ts: Toggle[], card: number, item: number, done: boolean): boolean => ts.reduce((d, t) => t.card == card && t.item == item ? t.done : d, done) +type Writes = { moving: Move[]; renaming: Rename[]; archiving: number[]; adding: string[]; toggling: Toggle[]; itemAdding: ItemAdd[] } +fn shownCard(c: Card, w: Writes): Row => + ({ + key: `c${c.id}`, id: c.id, title: titleOf(w.renaming, c.id, c.title), + done: w.toggling.filter(t => t.card == c.id).reduce((n, t) => n + (t.done ? 1 : -1), c.done), + total: c.total + w.itemAdding.filter(a => a.card == c.id).length, adding: false, card: c, pos: 0, last: false, + }) +fn placed(cs: Card[], w: Writes): Row[] => cs.map((c, i) => ({ ...shownCard(c, w), pos: i, last: i + 1 == cs.length })) +fn lay(ans: Card[] | null, col: string, w: Writes): Row[] | null => + ans == null ? null : [ + ...placed(w.moving.reduce((cs, m) => step(cs, col, m), ans).filter(c => !w.archiving.includes(c.id)), w), + ...(col == "todo" ? w.adding.map((t, i) => ({ key: `a${i}`, id: -1, title: t, done: 0, total: 0, adding: true, card: { id: -1, title: t, done: 0, total: 0 }, pos: -1, last: false })) : []), + ] + +w = { moving: moving, renaming: renaming, archiving: archiving, adding: adding, toggling: toggling, itemAdding: itemAdding } +// null while the column's first answer is out +export todoRows = lay(todoAns, "todo", w) +export doingRows = lay(doingAns, "doing", w) +export doneRows = lay(doneAns, "done", w) + +export type ItemRow = { key: string; id: number; text: string; done: boolean; adding: boolean } +export items key key = checklist == null ? [] : [ + ...checklist.items.map(it => ({ key: `i${it.id}`, id: it.id, text: it.text, done: doneOf(toggling, checklist.card, it.id, it.done), adding: false })), + ...itemAdding.filter(a => a.card == checklist.card).map((a, i) => ({ key: `a${i}`, id: -1, text: a.text, done: false, adding: true })), +] + +// a write's re-ask (quiet, AC-4) of every column: each lays every write over its answer, and behind moves still +// out, or answered in another order, the server may hold the card in any of them +action reask() { + refresh todoAns + refresh doingAns + refresh doneAns +} + +export action openBoard() { + visit := visit + 1 + selected := null +} + +export action select(id: number) { + selected := id +} + +export action retry(col: string) { + if (col == "todo") { + affects todoAns + refresh todoAns + } else if (col == "doing") { + affects doingAns + refresh doingAns + } else { + affects doneAns + refresh doneAns + } +} + +// Each write reports a decline to `fail`, which the page that made it owns: a failure message lives with the visit, +// the write may outlive it. An accepted write re-asks what it guessed on. +export action move(card: Card, col: string, index: number, fail: Fail) { + try { + guess moving := ms => [...ms, { id: card.id, col: col, index: index, card: card }] + await moveCard(card.id, col, index) + await reask() + } catch (e) { + fail(e.message) + onFlush(() => { fail(e.message) }) + } +} + +export action add(title: string, fail: Fail) { + try { + guess adding := as => [...as, title] + let r = await addCard("todo", title) + todoAns := cs => withCard(cs, { id: r.id, title: r.title, done: 0, total: 0 }) + } catch (e) { + fail(e.message) + onFlush(() => { fail(e.message) }) + } +} + +export action rename(id: number, title: string, fail: Fail) { + try { + guess renaming := rs => [...rs, { id: id, title: title }] + await renameCard(id, title) + await reask() + } catch (e) { + fail(e.message) + onFlush(() => { fail(e.message) }) + } +} + +export action archive(id: number, fail: Fail) { + onFlush(() => { selected := null }) + try { + guess archiving := xs => [...xs, id] + await archiveCard(id) + await reask() + } catch (e) { + fail(e.message) + onFlush(() => { fail(e.message) }) + } +} + +export action toggle(card: number, item: number, done: boolean, fail: Fail) { + try { + guess toggling := ts => [...ts, { card: card, item: item, done: done }] + await toggleItem(card, item, done) + refresh checklist + await reask() + } catch (e) { + fail(e.message) + onFlush(() => { fail(e.message) }) + } +} + +export action addOneItem(card: number, text: string, fail: Fail) { + try { + guess itemAdding := xs => [...xs, { card: card, text: text }] + let it = await addItem(card, text) + checklist := l => withItem(l, card, it) + todoAns := cs => counted(cs, card) + doingAns := cs => counted(cs, card) + doneAns := cs => counted(cs, card) + } catch (e) { + fail(e.message) + onFlush(() => { fail(e.message) }) + } +} diff --git a/eval/results/r15prep/repro/blank-region-after-revisit.app.tsx b/eval/results/r15prep/repro/blank-region-after-revisit.app.tsx new file mode 100644 index 00000000..605f55c9 --- /dev/null +++ b/eval/results/r15prep/repro/blank-region-after-revisit.app.tsx @@ -0,0 +1,28 @@ +import { Show, Loading, Errored, action, createMemo, createSignal, createOptimistic } from "solid-js"; +import { fetchCards, addCard } from "./api.js"; + +export default function App() { + // as rxc lowers blank-region-after-revisit.rx: state owned by the root, `adding` a guess layer over its own truth + const [visit, setVisit] = createSignal(0); + const [cards, setCards] = createSignal<any>(() => (visit() >= 0 ? fetchCards("todo") : null), { loadingValue: null } as any); + const [addingTruth] = createSignal<string[]>(() => []); + const [adding, guessAdding] = createOptimistic<string[]>(() => addingTruth()); + const rows = createMemo(() => { const cs = cards(); return cs == null ? null : [...cs.map((c: any) => c.title), ...adding()]; }); + const add = action(function* (t: string) { + try { + guessAdding(as => [...as, t]); + const r: any = yield addCard("todo", t); + setCards((cs: any) => (cs == null ? cs : [...cs, { id: r.id, title: r.title, done: 0, total: 0 }])); + } catch { /* declined */ } + }); + const Page = () => <div data-testid="out"><Loading fallback={<p>loading</p>}><Errored fallback={<p>err</p>}> + <Show when={rows() == null} fallback={<p>{rows()?.join(",")}</p>}>{(_x: any) => <p>first load</p>}</Show> + </Errored></Loading></div>; + const [page, setPage] = createSignal("a"); + return <div> + <button data-testid="add" onClick={() => add("x")}>add</button> + <button data-testid="away" onClick={() => setPage("b")}>away</button> + <button data-testid="back" onClick={() => { setVisit(visit() + 1); setPage("a"); }}>back</button> + <Show when={page() === "a"} fallback={<p>other page</p>}>{(_x: any) => <Page />}</Show> + </div>; +} diff --git a/eval/results/r15prep/repro/blank-region-after-revisit.attempt.spec.tsx b/eval/results/r15prep/repro/blank-region-after-revisit.attempt.spec.tsx new file mode 100644 index 00000000..b651f069 --- /dev/null +++ b/eval/results/r15prep/repro/blank-region-after-revisit.attempt.spec.tsx @@ -0,0 +1,49 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +import { test, expect } from "vitest"; +import { Show, Loading, Errored, action, createMemo, createSignal, createOptimistic, flush } from "solid-js"; +import { render } from "../src/index.js"; +const tick = async () => { await new Promise(r => setTimeout(r, 50)); }; +// .rx (results/r15prep/repro/blank-region-after-revisit.rx), as rxc lowers it: an add is out (a guess on `adding`); +// the page is left and opened again (`visit` asks `cards` anew); the add's answer is written to `cards` while that +// question is out; then its answer lands. The page's <Loading> then shows neither its fallback nor its content. +test("a region over a source written while its new question is out shows its content when the answer lands", async () => { + (globalThis as any).__log = []; + const asks: { key: string; res: (v: any) => void }[] = []; + const ask = (key: string) => new Promise<any>(res => asks.push({ key, res })); + const land = (key: string, v: any) => { const i = asks.findIndex(a => a.key === key); if (i < 0) throw new Error("no ask " + key + " in " + asks.map(a => a.key)); asks.splice(i, 1)[0].res(v); }; + const c = document.body.appendChild(document.createElement("div")); + + const [visit, setVisit] = createSignal(0); + const [cards, setCards] = createSignal<any>(() => (visit() >= 0 ? ask(`cards:${visit()}`) : null), { loadingValue: null } as any); + const [addingTruth] = createSignal<string[]>(() => []); + const [adding, guessAdding] = createOptimistic<string[]>(() => addingTruth()); + const rows = createMemo(() => { const cs = cards(); return cs == null ? null : [...cs, ...adding()]; }); + const add = action(function* (t: string) { + guessAdding(as => [...as, t]); + const r: string = yield ask(`add:${t}`); + setCards((cs: any) => (cs == null ? cs : [...cs, r])); + }); + const [page, setPage] = createSignal("a"); + render(() => <Show when={page() === "a"} fallback={<p>other page</p>}>{() => + <div id="out"><Loading fallback={<p>loading</p>}><Errored fallback={<p>err</p>}> + <Show when={rows() == null} fallback={<p>{(rows() as string[]).join(",")}</p>}>{() => <p>first load</p>}</Show> + </Errored></Loading></div>} + </Show>, c); + await tick(); + land("cards:0", ["a"]); await tick(); + expect(c.textContent).toBe("a"); + add("x"); await tick(); + expect(c.textContent).toBe("a,x"); + setPage("b"); await tick(); + setVisit(1); setPage("a"); await tick(); + (globalThis as any).__log.push("back:", JSON.stringify(c.textContent)); + land("add:x", "x"); await tick(); + (globalThis as any).__log.push("add accepted:", JSON.stringify(c.textContent)); + land("cards:1", ["a", "x"]); await tick(); + (globalThis as any).__log.push("reload landed:", JSON.stringify(c.textContent), c.innerHTML); + expect(c.textContent).toBe("a,x"); + expect((globalThis as any).__log.join(" | ")).toBe(""); +}); diff --git a/eval/results/r15prep/repro/blank-region-after-revisit.rx b/eval/results/r15prep/repro/blank-region-after-revisit.rx new file mode 100644 index 00000000..5679df59 --- /dev/null +++ b/eval/results/r15prep/repro/blank-region-after-revisit.rx @@ -0,0 +1,45 @@ +// An add is out (a guess on `adding`); the page is left and opened again (a new question for `cards`); the add's +// answer lands (a write to `cards`), then the new question's answer. The page's region then shows nothing at all. +import pure { fetchCards } from "./api" +import { addCard } from "./api" + +source visit: number := 0 +source cards = visit >= 0 ? (await fetchCards("todo") -> null) : null +source adding: string[] = [] +rows = cards == null ? null : [...cards.map(c => c.title), ...adding] + +action add(t: string) { + try { + guess adding := as => [...as, t] + let r = await addCard("todo", t) + cards := cs => cs == null ? cs : [...cs, { id: r.id, title: r.title, done: 0, total: 0 }] + } catch (e) { + } +} + +component Page() { + <div data-testid="out"> + {loading fallback { <p>loading</p> } { + {errored fallback(e) { <p>err</p> } { + {if rows == null { + <p>first load</p> + } else { + <p>{rows.join(",")}</p> + }} + }} + }} + </div> +} + +export default component App() { + source page: string := "a" + <div> + <button data-testid="add" onClick={() => { add("x") }}>add</button> + <button data-testid="away" onClick={() => { page := "b" }}>away</button> + <button data-testid="back" onClick={() => { + visit := visit + 1 + page := "a" + }}>back</button> + {if page == "a" { <Page /> } else { <p>other page</p> }} + </div> +} diff --git a/eval/results/r15prep/repro/blank-region-after-revisit.steps.mjs b/eval/results/r15prep/repro/blank-region-after-revisit.steps.mjs new file mode 100644 index 00000000..8f893588 --- /dev/null +++ b/eval/results/r15prep/repro/blank-region-after-revisit.steps.mjs @@ -0,0 +1,11 @@ +const land = (k, v) => p => p.evaluate(([k, v]) => globalThis.__flights.land(k, v), [k, v]); +const click = (sel) => p => p.locator(sel).click(); +export default [ + ["load", land("fetchCards:todo", [{ id: 1, title: "a", done: 0, total: 0 }])], + ["add", click('[data-testid="add"]')], + ["away", click('[data-testid="away"]')], + ["back", click('[data-testid="back"]')], + ["accept add", land("addCard:todo,x", { id: 7, title: "x" })], + ["land reload", land("fetchCards:todo", [{ id: 1, title: "a", done: 0, total: 0 }, { id: 7, title: "x", done: 0, total: 0 }])], + ["html", async p => console.log(await p.locator('body').innerHTML())], +]; diff --git a/eval/results/r15prep/repro/board-limit-after-reask.steps.mjs b/eval/results/r15prep/repro/board-limit-after-reask.steps.mjs new file mode 100644 index 00000000..be66f446 --- /dev/null +++ b/eval/results/r15prep/repro/board-limit-after-reask.steps.mjs @@ -0,0 +1,15 @@ +const land = (k, v) => p => p.evaluate(([k, v]) => globalThis.__flights.land(k, v), [k, v]); +const click = (sel) => p => p.locator(sel).first().click(); +const TODO = [{ id: 1, title: "Write spec", done: 0, total: 2 }, { id: 2, title: "Fix login", done: 1, total: 3 }]; +const DOING = [{ id: 3, title: "Review PR", done: 2, total: 2 }, { id: 4, title: "Deploy", done: 0, total: 1 }]; +const DONE = [{ id: 5, title: "Kickoff", done: 3, total: 3 }]; +const near = (t, b) => p => p.locator('[data-testid="card"]', { hasText: t }).first().locator('xpath=..').locator(`[data-testid="${b}"]`).click(); +const dis = async p => console.log(" todo right disabled:", await p.locator('[data-testid="col-todo"] [data-testid="right"]').evaluateAll(bs => bs.map(b => b.disabled))); +const VISITS = process.env.VISITS === "1"; +export default [ + ...(VISITS ? [["tab archive", click('[data-testid="tab-archive"]')], ["tab board", click('[data-testid="tab-board"]')]] : []), + ["load", async p => { await land("fetchCards:doing", DOING)(p); await land("fetchCards:done", DONE)(p); await land("fetchCards:todo", TODO)(p); await dis(p); }], + ["left #5", async p => { await near("#5 ", "left")(p); await dis(p); }], + ["accept", async p => { await land("moveCard:5,doing,2", { id: 5, column: "doing", index: 2 })(p); await dis(p); }], + ["land todo", async p => { await land("fetchCards:todo", TODO)(p); await p.waitForTimeout(100); await dis(p); }], +]; diff --git a/eval/results/r15prep/repro/keyed-checklist-reverts-selection.rx b/eval/results/r15prep/repro/keyed-checklist-reverts-selection.rx new file mode 100644 index 00000000..bc6ae7d7 --- /dev/null +++ b/eval/results/r15prep/repro/keyed-checklist-reverts-selection.rx @@ -0,0 +1,49 @@ +// Keyed (`items key k`): after 'accept toggle' the loading on(selected) region, which showed its fallback for card 4, +// shows card 2's rows again (with the toggle guess); unkeyed (`items = …` of strings) it stays on the fallback. In the +// board (seed 14) the region then stays on card 2's rows after card 4's checklist landed. +// An item add on card 2 is out (it stays out), and a toggle on card 2's checklist; card 4 is selected (its checklist is asked); the toggle is accepted and +// re-asks the checklist; card 4's checklist lands. The checklist should be card 4's. +import pure { fetchChecklist } from "./api" +import { toggleItem, addItem } from "./api" + +source selected: number := 2 +source checklist = { card: selected, items: await fetchChecklist(selected) } +source toggling: number[] = [] +source adding: { card: number; text: string }[] = [] +items key k = [ + ...checklist.items.map(it => ({ k: `i${it.id}`, t: `${checklist.card}/${it.text}${toggling.includes(it.id) ? " (toggling)" : ""}` })), + ...adding.filter(a => a.card == checklist.card).map((a, i) => ({ k: `a${i}`, t: `${a.card}/${a.text} (adding)` })), +] + +action addOne(card: number, text: string) { + try { + guess adding := as => [...as, { card: card, text: text }] + let it = await addItem(card, text) + checklist := l => l.card != card ? l : { card: card, items: [...l.items, it] } + } catch (e) { + } +} + +action toggle(card: number, item: number) { + try { + guess toggling := ts => [...ts, item] + await toggleItem(card, item, true) + refresh checklist + } catch (e) { + } +} + +export default component App() { + <div> + <button data-testid="sel4" onClick={() => { selected := 4 }}>select 4</button> + <button data-testid="add" onClick={() => { addOne(2, "ship") }}>add ship</button> + <button data-testid="toggle" onClick={() => { toggle(2, 22) }}>toggle 22</button> + <div data-testid="out"> + {loading on(selected) fallback { <p>loading checklist</p> } { + {errored fallback(e) { <p>err</p> } { + <p>{items.length}: {for it in items { <span>{it.t} </span> }}</p> + }} + }} + </div> + </div> +} diff --git a/eval/results/r15prep/repro/keyed-checklist-reverts-selection.spec.tsx b/eval/results/r15prep/repro/keyed-checklist-reverts-selection.spec.tsx new file mode 100644 index 00000000..82f4329f --- /dev/null +++ b/eval/results/r15prep/repro/keyed-checklist-reverts-selection.spec.tsx @@ -0,0 +1,55 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +import { test, expect } from "vitest"; +import { Loading, Errored, For, action, createSignal, createOptimistic, createProjection, refresh, flush } from "solid-js"; +import { render } from "../src/index.js"; +const tick = async () => { for (let i = 0; i < 4; i++) { await new Promise(r => setTimeout(r, 0)); flush(); } }; +test("a keyed checklist under Loading on(selected) does not go back to the previous selection when a toggle is accepted", async () => { + const asks: { key: string; res: (v: any) => void }[] = []; + const ask = (key: string) => new Promise<any>(res => asks.push({ key, res })); + const take = (key: string) => { const i = asks.findIndex(a => a.key === key); if (i < 0) throw new Error("no ask " + key + " in " + asks.map(a => a.key)); return asks.splice(i, 1)[0]; }; + const c = document.body.appendChild(document.createElement("div")); + + const [selected, setSelected] = createSignal(2); + const [checklist, setChecklist] = createSignal(async () => { const s = selected(); return { card: s, items: await ask(`checklist:${s}`) }; }); + const [toggling, guessToggling] = createOptimistic<number[]>(() => []); + const [adding, guessAdding] = createOptimistic<{ card: number; text: string }[]>(() => []); + const items = createProjection(() => { + const l: any = checklist(); + return [ + ...l.items.map((it: any) => ({ k: `i${it.id}`, t: `${l.card}/${it.text}${toggling().includes(it.id) ? " (toggling)" : ""}` })), + ...adding().filter(a => a.card === l.card).map((a, i) => ({ k: `a${i}`, t: `${a.card}/${a.text} (adding)` })), + ]; + }, [] as any, { key: "k" } as any) as any; + const addOne = action(function* (card: number, text: string) { + guessAdding(as => [...as, { card, text }]); + const it = yield ask(`addItem:${card},${text}`); + setChecklist((l: any) => (l.card !== card ? l : { card, items: [...l.items, it] })); + }); + const toggle = action(function* (card: number, item: number) { + guessToggling(ts => [...ts, item]); + yield ask(`toggle:${card},${item}`); + refresh(checklist); + }); + + render(() => <div> + <Loading on={selected()} fallback={<p>loading checklist</p>}> + <Errored fallback={<p>err</p>}> + <p>{items.length}: <For each={items}>{(it: any) => <span>{it.t} </span>}</For></p> + </Errored> + </Loading> + </div>, c); + const screen = () => c.textContent!.replace(/\s+/g, " ").trim(); + + take("checklist:2").res([{ id: 21, text: "repro" }, { id: 22, text: "patch" }]); await tick(); + addOne(2, "ship"); await tick(); + toggle(2, 22); await tick(); + setSelected(4); await tick(); + console.log("after select 4:", screen(), "| air:", asks.map(a => a.key).join(", ")); + take("toggle:2,22").res({ id: 22, done: true }); await tick(); + const afterAccept = screen(); + console.log("after accept toggle:", afterAccept, "| air:", asks.map(a => a.key).join(", ")); + expect(afterAccept).not.toMatch(/2\/repro/); // shows card 2's rows again while card 4 is selected +}); diff --git a/eval/results/r15prep/repro/keyed-checklist-reverts-selection.steps.mjs b/eval/results/r15prep/repro/keyed-checklist-reverts-selection.steps.mjs new file mode 100644 index 00000000..a6ad999a --- /dev/null +++ b/eval/results/r15prep/repro/keyed-checklist-reverts-selection.steps.mjs @@ -0,0 +1,10 @@ +const land = (k, v) => p => p.evaluate(([k, v]) => globalThis.__flights.land(k, v), [k, v]); +const click = (sel) => p => p.locator(sel).first().click(); +export default [ + ["land checklist 2", land("fetchChecklist:2", [{ id: 21, text: "repro", done: true }, { id: 22, text: "patch", done: false }])], + ["add ship", click('[data-testid="add"]')], + ["toggle 22", click('[data-testid="toggle"]')], + ["select 4", click('[data-testid="sel4"]')], + ["accept toggle", land("toggleItem:2,22,true", { id: 22, done: true })], + ["land checklist 4", land("fetchChecklist:4", [{ id: 41, text: "release", done: false }])], +]; diff --git a/eval/results/r15prep/repro/keyed-null-never-updates.rx b/eval/results/r15prep/repro/keyed-null-never-updates.rx new file mode 100644 index 00000000..77239c86 --- /dev/null +++ b/eval/results/r15prep/repro/keyed-null-never-updates.rx @@ -0,0 +1,10 @@ +// `rows key id` over a value that starts null (a first-paint default) never shows the answer: "null" stays after +// fetchCards:todo lands. Without `key id` it shows "1 rows". rxc accepts it (ST-4a types a keyed equation T[]). +import pure { fetchCards } from "./api" + +ans = await fetchCards("todo") -> null +rows key id = ans == null ? null : ans + +export default component App() { + <div data-testid="out">{errored fallback(e) { <p>err</p> } { <p>{rows == null ? "null" : `${rows.length} rows`}</p> }}</div> +} diff --git a/eval/results/r15prep/repro/keyed-row-under-if-after-guess.rx b/eval/results/r15prep/repro/keyed-row-under-if-after-guess.rx new file mode 100644 index 00000000..a35b41ea --- /dev/null +++ b/eval/results/r15prep/repro/keyed-row-under-if-after-guess.rx @@ -0,0 +1,23 @@ +// an add: the guess row (key "b…") goes and the answer's row (key "a…") comes, in the action's last instant +import pure { fetchChecklist } from "./api" +import { addItem } from "./api" + +source a = await fetchChecklist(2) +source adding: string[] = [] +rows key k = [...a.map(it => ({ k: `a${it.id}`, t: it.text, adding: false })), ...adding.map(t => ({ k: `b${t}`, t: t, adding: true }))] + +action add(t: string) { + try { + guess adding := xs => [...xs, t] + let r = await addItem(2, t) + a := xs => [...xs, r] + } catch (e) { + } +} + +export default component App() { + <div> + <button data-testid="go" onClick={() => { add("two") }}>go</button> + <p data-testid="out">{loading fallback { <span>loading</span> } { errored fallback(e) { <span>err</span> } { <span>{rows.length}:</span>{for r in rows { {if r.adding { <span>[{r.t} (adding)]</span> } else { <span>[{r.t}]</span> }} }} } }}</p> + </div> +} diff --git a/eval/results/r15prep/repro/keyed-row-under-if-after-guess.steps.mjs b/eval/results/r15prep/repro/keyed-row-under-if-after-guess.steps.mjs new file mode 100644 index 00000000..9e116cfc --- /dev/null +++ b/eval/results/r15prep/repro/keyed-row-under-if-after-guess.steps.mjs @@ -0,0 +1,2 @@ +export default [["start", async () => {}], ["load", p => p.evaluate(() => globalThis.__flights.land("fetchChecklist:2", [{ id: 21, text: "one", done: false }]))], ["go", p => p.locator('[data-testid="go"]').click()], + ["accept", p => p.evaluate(() => globalThis.__flights.land("addItem:2,two", { id: 24, text: "two", done: false }))]]; diff --git a/eval/results/r15prep/repro/keyed-row-under-show-after-guess.spec.tsx b/eval/results/r15prep/repro/keyed-row-under-show-after-guess.spec.tsx new file mode 100644 index 00000000..5f4630b1 --- /dev/null +++ b/eval/results/r15prep/repro/keyed-row-under-show-after-guess.spec.tsx @@ -0,0 +1,36 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +import { test, expect } from "vitest"; +import { For, Show, action, createMemo, createSignal, createOptimistic, createProjection, flush } from "solid-js"; +import { render } from "../src/index.js"; +const tick = async () => { for (let i = 0; i < 4; i++) { await new Promise(r => setTimeout(r, 0)); flush(); } }; +// .rx: `rows key k = [...items, ...adding]` (createProjection keyed), `for r in rows { if r.adding {…} else {…} }`. +// Passes without the <Show> (row text only), and with <For each={src()}> (no projection); passes in .rx with two +// plain signals written in one click (no guess). So: projection + a guess dropped and a row added in one commit + Show. +// An add's action ends: its guess row (key b-two) goes and its answer's row (key a-two) comes, in one commit. +test("a keyed row that replaces a guessed row in one commit renders under <For>/<Show>", async () => { + let res!: (v: string) => void; + const c = document.body.appendChild(document.createElement("div")); + const [items, setItems] = createSignal<string[]>(["one"]); + const [adding, guessAdding] = createOptimistic<string[]>(() => []); + const src = createMemo(() => [ + ...items().map(t => ({ k: `a-${t}`, t, adding: false })), + ...adding().map(t => ({ k: `b-${t}`, t, adding: true })), + ]); + const rows = createProjection(() => src() as any, [] as any, { key: "k" } as any) as any; + const add = action(function* (t: string) { + guessAdding(xs => [...xs, t]); + const r: string = yield new Promise<string>(r => (res = r)); + setItems(xs => [...xs, r]); + }); + render(() => <p>{rows.length}:<For each={rows}>{(r: any) => + <Show when={r.adding} fallback={<span>[{r.t}]</span>}><span>[{r.t} (adding)]</span></Show>}</For></p>, c); + await tick(); + add("two"); await tick(); + expect(c.textContent).toBe("2:[one][two (adding)]"); + res("two"); await tick(); + console.log("after accept:", c.textContent); + expect(c.textContent).toBe("2:[one][two]"); +}); diff --git a/eval/results/r15prep/repro/limit-after-reask.app.tsx b/eval/results/r15prep/repro/limit-after-reask.app.tsx new file mode 100644 index 00000000..338c20d0 --- /dev/null +++ b/eval/results/r15prep/repro/limit-after-reask.app.tsx @@ -0,0 +1,26 @@ +import { For, action, createMemo, createSignal, createOptimistic, refresh } from "solid-js"; +import { fetchCards, moveCard } from "./api.js"; + +export default function App() { + const [todoAns] = createSignal<any>(() => fetchCards("todo"), { loadingValue: null } as any); + const [doingAns] = createSignal<any>(() => fetchCards("doing"), { loadingValue: null } as any); + const [movingTruth] = createSignal<number[]>(() => []); + const [moving, guessMoving] = createOptimistic<number[]>(() => movingTruth()); + const todoRows = createMemo(() => { const a = todoAns(); return a == null ? [] : a.filter((c: any) => !moving().includes(c.id)).map((c: any) => ({ card: c })); }); + const doingRows = createMemo(() => { const a = doingAns(); return a == null ? null : [...a, ...moving()]; }); + const doingUp = createMemo(() => doingRows() != null); // a second reader of doingRows whose value never changes + const intoDoing = createMemo(() => doingRows() != null && doingRows()!.length < 3); + const reask = action(function* () { refresh(todoAns); refresh(doingAns); }); + const move = action(function* (card: any) { + guessMoving(ms => [...ms, card.id]); + yield moveCard(card.id, "doing", 2); + yield reask(); + }); + return <div> + <section data-testid="col-todo"> + <For each={todoRows()}>{(r: any) => <button data-testid="right" disabled={!intoDoing()} onClick={() => move(r.card)}>right</button>}</For> + </section> + <p>{doingUp() ? "up" : "down"}</p> + <p data-testid="dbg">doing={String(doingRows()?.length)} intoDoing={String(intoDoing())}</p> + </div>; +} diff --git a/eval/results/r15prep/repro/limit-after-reask.board.rx b/eval/results/r15prep/repro/limit-after-reask.board.rx new file mode 100644 index 00000000..9d53022d --- /dev/null +++ b/eval/results/r15prep/repro/limit-after-reask.board.rx @@ -0,0 +1,20 @@ +import { Row, doingAns, todoRows, doingRows, move } from "./store.rx" + +export component BoardPage() { + intoDoing = (doingRows catch (e) => null) != null && (doingRows catch (e) => null).length < 3 + + <div> + <section data-testid="col-todo"> + {errored fallback(e) { <p>error</p> } { + {if todoRows == null { + <p>loading</p> + } else { + {for r in todoRows { + <button data-testid="right" disabled={!intoDoing} onClick={() => { move(r.card) }}>right</button> + }} + }} + }} + </section> + {errored fallback(e) { <p>error</p> } { {if doingRows == null { <p>loading</p> } else { <p>x</p> }} }} + </div> +} diff --git a/eval/results/r15prep/repro/limit-after-reask.spec.tsx b/eval/results/r15prep/repro/limit-after-reask.spec.tsx new file mode 100644 index 00000000..37b7ed9f --- /dev/null +++ b/eval/results/r15prep/repro/limit-after-reask.spec.tsx @@ -0,0 +1,48 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +import { test, expect } from "vitest"; +import { For, action, createMemo, createSignal, createOptimistic, flush, refresh } from "solid-js"; +import { render } from "../src/index.js"; +const tick = async () => { for (let i = 0; i < 4; i++) { await new Promise(r => setTimeout(r, 0)); flush(); } }; + +// A kanban board: card 5 moves into "doing" (a guess), the move is accepted, and the action re-asks both columns in a +// nested action. While the doing re-ask is still out, the todo re-ask lands: the todo rows are rebuilt and the new +// buttons read `intoDoing` as if the guess were gone, while the text beside them still reads it with the guess. +test("rows rebuilt while a sibling refresh is out read a memo without the action's guess", async () => { + const fl: Record<string, ((v: any) => void)[]> = {}; + const ask = (k: string) => new Promise<any>(r => (fl[k] ??= []).push(r)); + const land = (k: string, v: any) => { const rs = fl[k]; fl[k] = []; rs.forEach(r => r(structuredClone(v))); }; + const c = document.body.appendChild(document.createElement("div")); + + const [todoAns] = createSignal<any>(() => ask("todo"), { loadingValue: null } as any); + const [doingAns] = createSignal<any>(() => ask("doing"), { loadingValue: null } as any); + const [moving, guessMoving] = createOptimistic<number[]>(() => []); + const todoRows = createMemo(() => { const a = todoAns(); return a == null ? [] : a.filter((id: number) => !moving().includes(id)).map((id: number) => ({ id })); }); + const doingRows = createMemo(() => { const a = doingAns(); return a == null ? null : [...a, ...moving()]; }); + const doingUp = createMemo(() => doingRows() != null); // needed: a second reader of doingRows + const intoDoing = createMemo(() => doingRows() != null && doingRows()!.length < 3); + const reask = action(function* () { refresh(todoAns); refresh(doingAns); }); + const move = action(function* (id: number) { + guessMoving(ms => [...ms, id]); + yield ask("move"); + yield reask(); // needed: nested action (inline refreshes pass) + }); + render(() => <div> + <For each={todoRows()}>{(r: any) => <button disabled={!intoDoing()}>{r.id}</button>}</For> + <p>{doingUp() ? "up" : "down"}</p> + <p>doing={String(doingRows()?.length)} into={String(intoDoing())}</p> + </div>, c); + const state = () => `${[...c.querySelectorAll("button")].map(b => b.textContent + (b.disabled ? "x" : "")).join(",")} ${c.querySelectorAll("p")[1].textContent}`; + + land("doing", [3, 4]); land("todo", [1, 2, 5]); await tick(); + expect(state()).toBe("1,2,5 doing=2 into=true"); + move(5); await tick(); + expect(state()).toBe("1x,2x doing=3 into=false"); + land("move", {}); await tick(); + expect(state()).toBe("1x,2x doing=3 into=false"); + land("todo", [1, 2, 5]); await tick(); // doing's re-ask still out + console.log("after todo lands:", state()); + expect(state()).toBe("1x,2x doing=3 into=false"); // actual: "1,2 doing=3 into=false" +}); diff --git a/eval/results/r15prep/repro/limit-after-reask.steps.mjs b/eval/results/r15prep/repro/limit-after-reask.steps.mjs new file mode 100644 index 00000000..2a8fd9b9 --- /dev/null +++ b/eval/results/r15prep/repro/limit-after-reask.steps.mjs @@ -0,0 +1,11 @@ +const land = (k, v) => p => p.evaluate(([k, v]) => globalThis.__flights.land(k, v), [k, v]); +const C = id => ({ id, title: "t" + id, done: 0, total: 0 }); +const TODO = [C(1), C(2), C(5)]; +const DOING = [C(3), C(4)]; +const dis = async p => { await p.waitForTimeout(100); console.log(" todo right disabled:", await p.locator('[data-testid="col-todo"] [data-testid="right"]').evaluateAll(bs => bs.map(b => b.disabled))); }; +export default [ + ["load", async p => { await land("fetchCards:doing", DOING)(p); await land("fetchCards:todo", TODO)(p); await dis(p); }], + ["right #5", async p => { await p.locator('[data-testid="col-todo"] [data-testid="right"]').nth(2).click(); await dis(p); }], + ["accept", async p => { await land("moveCard:5,doing,2", {})(p); await dis(p); }], + ["land todo", async p => { await land("fetchCards:todo", TODO)(p); await dis(p); }], +]; diff --git a/eval/results/r15prep/repro/limit-after-reask.store.rx b/eval/results/r15prep/repro/limit-after-reask.store.rx new file mode 100644 index 00000000..2d260069 --- /dev/null +++ b/eval/results/r15prep/repro/limit-after-reask.store.rx @@ -0,0 +1,27 @@ +import pure { fetchCards } from "./api" +import { moveCard, Card } from "./api" + +export type Row = { id: number; card: Card } +source visit: number := 0 +export source todoAns = visit >= 0 ? (await fetchCards("todo") -> null) : null +export source doingAns = visit >= 0 ? (await fetchCards("doing") -> null) : null +source moving: number[] = [] + +fn lay(ans: Card[] | null, col: string, ms: number[]): Row[] | null => + ans == null ? null : col == "doing" ? [...ans.map(c => ({ id: c.id, card: c })), ...ms.map(m => ({ id: m, card: { id: m, title: "", done: 0, total: 0 } }))] : ans.filter(c => !ms.includes(c.id)).map(c => ({ id: c.id, card: c })) +export todoRows = lay(todoAns, "todo", moving) +export doingRows = lay(doingAns, "doing", moving) + +action reask() { + refresh todoAns + refresh doingAns +} + +export action move(card: Card) { + try { + guess moving := ms => [...ms, card.id] + await moveCard(card.id, "doing", 2) + await reask() + } catch (e) { + } +} diff --git a/eval/results/r15prep/repro/probe-rx.mjs b/eval/results/r15prep/repro/probe-rx.mjs new file mode 100644 index 00000000..f80e3f09 --- /dev/null +++ b/eval/results/r15prep/repro/probe-rx.mjs @@ -0,0 +1,16 @@ +// probe: build, open, run steps from a JS file, dumping body text after each +const E = "/tmp/claude-0/-home-user/b0c1a8f0-5828-5b86-a816-75e9c36897f9/scratchpad/rx/eval/"; +const { build } = await import(E + "harness/build.mjs"); +const { launch, serve } = await import(E + "harness/driver.mjs"); +const [dir, stepsFile] = process.argv.slice(2); +const b = await build(dir); if (!b.ok) { console.log(b.output); process.exit(1); } +const server = await serve(b.dist); const browser = await launch(); +const page = await browser.newPage(); +page.on("pageerror", e => console.log("PAGEERR", e.message)); +page.on("console", m => console.log("CONSOLE", m.type(), m.text().slice(0, 300))); +await page.goto(server.url); await page.waitForTimeout(200); +const norm = s => (s ?? "").replace(/\s+/g, " ").trim(); +const dump = async (l) => console.log(`--- ${l}\n`, norm(await Promise.race([page.locator("body").textContent(), new Promise(r => setTimeout(() => r("HUNG"), 3000))])), "\n in air:", await Promise.race([page.evaluate(() => globalThis.__flights.pending()), new Promise(r => setTimeout(() => r("HUNG"), 3000))])); +const steps = (await import(stepsFile)).default; +for (const [label, f] of steps) { try { await Promise.race([f(page), new Promise((_, j) => setTimeout(() => j(new Error("step timeout")), 4000))]); } catch (e) { console.log("STEP ERR", e.message.split("\n")[0]); } await page.waitForTimeout(100); await dump(label); } +await browser.close(); server.close(); process.exit(0); diff --git a/eval/results/r15prep/repro/tear-attempt-not-reproduced.rx b/eval/results/r15prep/repro/tear-attempt-not-reproduced.rx new file mode 100644 index 00000000..54afef33 --- /dev/null +++ b/eval/results/r15prep/repro/tear-attempt-not-reproduced.rx @@ -0,0 +1,53 @@ +// Doing's region reads Done (for a button's disabled), and Done is loading a new question, so Doing waits. A guess on +// `moving`, which every column reads, then shows Doing's heading with its count over an empty list. +import pure { fetchCards } from "./api" +import { moveCard } from "./api" + +source visit: number := 0 +doing = visit >= 0 ? (await fetchCards("doing") -> null) : null +done = visit >= 0 ? (await fetchCards("done") -> null) : null +source moving: number[] = [] +doingRows = doing == null ? null : doing.filter(c => !moving.includes(c.id)) +doneRows = done == null ? null : done.filter(c => !moving.includes(c.id)) + +action move(id: number) { + try { + guess moving := ms => [...ms, id] + await moveCard(id, "done", 0) + } catch (e) { + } +} + +component Doing() { + <section data-testid="doing"> + {loading fallback { <p>loading doing</p> } { errored fallback(e) { <p>err</p> } { + <h2>Doing {doingRows == null ? "" : `(${doingRows.length})`}</h2> + {if doingRows == null { + <p>first load</p> + } else { + <ul> + {for c in doingRows { + <li>{if c.done > 0 { + <span>done</span> + } else { + <span>#{c.id} <button disabled={doneRows == null}>right</button></span> + }}</li> + }} + </ul> + }} + } }} + </section> +} + +export default component App() { + source page: string := "board" + <div> + <button data-testid="away" onClick={() => { page := "other" }}>away</button> + <button data-testid="back" onClick={() => { + visit := visit + 1 + page := "board" + }}>back</button> + <button data-testid="move" onClick={() => { move(1) }}>move</button> + {if page == "board" { <Doing /> } else { <p>other</p> }} + </div> +} From 2d3b0ebd1550cd6f239315f7ed94b1c347267512 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 14 Nov 2026 22:24:41 +0000 Subject: [PATCH 19/32] eval: repro for the page hang from solid 9e02d02a8 (board guide shape, minimal trail) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- .../repro/board-store-phantom-row/README.txt | 10 + .../repro/board-store-phantom-row/min11.trail | 1 + .../board-store-phantom-row/replay-board.mjs | 32 ++ .../src-with-store-dump/api.d.rx | 15 + .../src-with-store-dump/api.js | 12 + .../src-with-store-dump/archive.rx | 46 +++ .../src-with-store-dump/board.rx | 352 ++++++++++++++++++ .../src-with-store-dump/flights.js | 30 ++ .../src-with-store-dump/main.rx | 21 ++ .../catch-read-in-handler-during-refresh.rx | 30 ++ ...h-read-in-handler-during-refresh.steps.mjs | 8 + .../check-accepts-arity-and-missing-props.rx | 7 + ...ntangled-adds-plain-write-reveals.spec.tsx | 38 ++ .../handler-read-during-refresh.spec.tsx | 32 ++ .../r15prep/repro/hang-after-reask.archive.rx | 55 +++ .../repro/hang-after-reask.attempt.app.tsx | 105 ++++++ .../r15prep/repro/hang-after-reask.board.rx | 176 +++++++++ .../r15prep/repro/hang-after-reask.main.rx | 32 ++ .../r15prep/repro/hang-after-reask.replay.mjs | 49 +++ .../r15prep/repro/hang-after-reask.store.rx | 173 +++++++++ .../r15prep/repro/hang-after-reask.trail | 1 + .../path-guess-row-leaves-truth.spec.tsx | 31 ++ .../repro/store-read-then-guess-entangles.rx | 35 ++ .../store-read-then-guess-entangles.steps.mjs | 8 + 24 files changed, 1299 insertions(+) create mode 100644 eval/results/r15prep/repro/board-store-phantom-row/README.txt create mode 100644 eval/results/r15prep/repro/board-store-phantom-row/min11.trail create mode 100644 eval/results/r15prep/repro/board-store-phantom-row/replay-board.mjs create mode 100644 eval/results/r15prep/repro/board-store-phantom-row/src-with-store-dump/api.d.rx create mode 100644 eval/results/r15prep/repro/board-store-phantom-row/src-with-store-dump/api.js create mode 100644 eval/results/r15prep/repro/board-store-phantom-row/src-with-store-dump/archive.rx create mode 100644 eval/results/r15prep/repro/board-store-phantom-row/src-with-store-dump/board.rx create mode 100644 eval/results/r15prep/repro/board-store-phantom-row/src-with-store-dump/flights.js create mode 100644 eval/results/r15prep/repro/board-store-phantom-row/src-with-store-dump/main.rx create mode 100644 eval/results/r15prep/repro/catch-read-in-handler-during-refresh.rx create mode 100644 eval/results/r15prep/repro/catch-read-in-handler-during-refresh.steps.mjs create mode 100644 eval/results/r15prep/repro/check-accepts-arity-and-missing-props.rx create mode 100644 eval/results/r15prep/repro/entangled-adds-plain-write-reveals.spec.tsx create mode 100644 eval/results/r15prep/repro/handler-read-during-refresh.spec.tsx create mode 100644 eval/results/r15prep/repro/hang-after-reask.archive.rx create mode 100644 eval/results/r15prep/repro/hang-after-reask.attempt.app.tsx create mode 100644 eval/results/r15prep/repro/hang-after-reask.board.rx create mode 100644 eval/results/r15prep/repro/hang-after-reask.main.rx create mode 100644 eval/results/r15prep/repro/hang-after-reask.replay.mjs create mode 100644 eval/results/r15prep/repro/hang-after-reask.store.rx create mode 100644 eval/results/r15prep/repro/hang-after-reask.trail create mode 100644 eval/results/r15prep/repro/path-guess-row-leaves-truth.spec.tsx create mode 100644 eval/results/r15prep/repro/store-read-then-guess-entangles.rx create mode 100644 eval/results/r15prep/repro/store-read-then-guess-entangles.steps.mjs diff --git a/eval/results/r15prep/repro/board-store-phantom-row/README.txt b/eval/results/r15prep/repro/board-store-phantom-row/README.txt new file mode 100644 index 00000000..08f8b42a --- /dev/null +++ b/eval/results/r15prep/repro/board-store-phantom-row/README.txt @@ -0,0 +1,10 @@ +Not reduced past 21 steps (30-min box). Fuzz seed 11 (r15, board-rx3 = eval/reference/board/rx-store). +At rest (nothing in the air) the Doing column renders a row "# · /" (every field empty): the store dump +(src-with-store-dump, a <pre> reading the same `board`) holds the right rows (STORE[... 5:doing:0 3:doing:1] over +DOING[5 3] DONE[]), so the keyed row #3 under `for r in props.rows { if r.adding … else … }` renders undefined fields +after it moved Done -> Doing while the Doing column's re-ask failed and answered again. Same family as +keyed-row-under-if-after-guess.rx / keyed-row-under-show-after-guess.spec.tsx. A plain-Solid attempt (two <For>/<Show> +over a keyed createOptimisticStore over signals, same guesses and truth changes) did not reproduce: it needs the +actions' refreshes (async memos in transitions). +Run: node replay-board.mjs <desk> "$(cat min11.trail)" 1 (replay-board's `answer` applies a write even when the +flight is not in the air; step 17's error is that, harmless.) diff --git a/eval/results/r15prep/repro/board-store-phantom-row/min11.trail b/eval/results/r15prep/repro/board-store-phantom-row/min11.trail new file mode 100644 index 00000000..bda534e2 --- /dev/null +++ b/eval/results/r15prep/repro/board-store-phantom-row/min11.trail @@ -0,0 +1 @@ +land fetchCards:doing ; select #4 ; down #3 ; up #3 ; archive #4 ; land fetchCards:done ; land moveCard:3,doing,1 ; left #5 ; right #3 ; land fetchCards:todo ; right #5 ; land moveCard:5,done,1 ; left #3 ; fail moveCard:5,doing,1 ; land moveCard:3,done,0 ; land moveCard:3,doing,0 ; land moveCard:5,doing,0 ; fail fetchCards:doing ; land fetchCards:done ; land archiveCard:4 ; land fetchCards:doing diff --git a/eval/results/r15prep/repro/board-store-phantom-row/replay-board.mjs b/eval/results/r15prep/repro/board-store-phantom-row/replay-board.mjs new file mode 100644 index 00000000..338a15ff --- /dev/null +++ b/eval/results/r15prep/repro/board-store-phantom-row/replay-board.mjs @@ -0,0 +1,32 @@ +// replay a board fuzz trail with the probe's dump after each step: node replay-board.mjs <desk> "<trail>" [fromStep] +const E = "/tmp/claude-0/-home-user/b0c1a8f0-5828-5b86-a816-75e9c36897f9/scratchpad/rx/eval/"; +const { build } = await import(E + "harness/build.mjs"); +const { launch, serve } = await import(E + "harness/driver.mjs"); +const { freshServer, answer } = await import(E + "harness/fuzz-board.mjs"); +const [dir, trail, from = "0"] = process.argv.slice(2); +const b = await build(dir); if (!b.ok) { console.log(b.output); process.exit(1); } +const server = await serve(b.dist); const browser = await launch(); const page = await browser.newPage(); +page.on("pageerror", e => console.log("PAGEERR", e.message)); +await page.goto(server.url); await page.waitForFunction(() => globalThis.__flights !== undefined); +const norm = s => (s ?? "").replace(/\s+/g, " ").trim(); +const db = freshServer(); let n = 0; +const cardBtn = id => page.locator(`[data-testid=card]`, { hasText: new RegExp(`^#${id} `) }).first(); +for (const s of trail.split(" ; ").filter(s => !s.startsWith("("))) { + const [op, ...rest] = s.split(" "); const arg = rest.join(" "); n++; + try { + if (op === "land" || op === "fail") { const a = answer(db, arg, op === "fail"); await page.evaluate(([k, v, f]) => f ? globalThis.__flights.fail(k, "declined") : globalThis.__flights.land(k, v), [arg, a.value, !!a.fail]); } + else if (op === "select") await cardBtn(arg.slice(1)).click({ timeout: 1000 }); + else if (["up", "down", "left", "right"].includes(op)) await page.locator("li", { has: cardBtn(arg.slice(1)) }).locator(`[data-testid=${op}]`).click({ timeout: 1000 }); + else if (op === "rename") { await page.fill("[data-testid=title-input]", arg); await page.click("[data-testid=rename]", { timeout: 1000 }); } + else if (op === "archive") await page.click("[data-testid=archive]", { timeout: 1000 }); + else if (op === "add") { await page.fill("[data-testid=new-card]", arg); await page.click("[data-testid=add-card]", { timeout: 1000 }); } + else if (op === "add-item") { await page.fill("[data-testid=item-input]", arg); await page.click("[data-testid=add-item]", { timeout: 1000 }); } + else if (op === "toggle") await page.locator("[data-testid=toggle]").nth(Number(arg)).click({ timeout: 1000 }); + else if (op === "restore") await page.locator("[data-testid=restore]").nth(Number(arg)).click({ timeout: 1000 }); + else if (op === "tab") await page.click(`[data-testid=tab-${arg}]`); + else if (op === "retry-todo" || op.startsWith("retry")) await page.click(`[data-testid=${op}]`); + } catch (e) { console.log("STEP ERR", s, e.message.split("\n")[0]); } + await page.waitForTimeout(60); + if (n >= Number(from) || n === trail.split(" ; ").filter(s => !s.startsWith("(")).length) console.log(`--- ${n} ${s}\n`, norm(await page.locator("body").textContent()).replace("Board Archive", ""), "\n air:", (await page.evaluate(() => globalThis.__flights.pending())).join(", ")); +} +await browser.close(); server.close(); process.exit(0); diff --git a/eval/results/r15prep/repro/board-store-phantom-row/src-with-store-dump/api.d.rx b/eval/results/r15prep/repro/board-store-phantom-row/src-with-store-dump/api.d.rx new file mode 100644 index 00000000..c0005e00 --- /dev/null +++ b/eval/results/r15prep/repro/board-store-phantom-row/src-with-store-dump/api.d.rx @@ -0,0 +1,15 @@ +// src/api.js, as .rx sees it. fetchCards, fetchChecklist and fetchArchived are loaders (safe to ask from an equation); +// moveCard, renameCard, addCard, archiveCard, toggleItem, addItem and restoreCard write. +export type Card = { id: number; title: string; done: number; total: number } +export pure fn fetchCards(column: string): Promise<Card[]> +export fn moveCard(id: number, column: string, index: number): Promise<{ id: number; column: string; index: number }> +export fn renameCard(id: number, title: string): Promise<{ id: number; title: string }> +export fn addCard(column: string, title: string): Promise<{ id: number; title: string }> +export fn archiveCard(id: number): Promise<{ id: number }> +export type Item = { id: number; text: string; done: boolean } +export pure fn fetchChecklist(id: number): Promise<Item[]> +export fn toggleItem(cardId: number, itemId: number, done: boolean): Promise<{ id: number; done: boolean }> +export fn addItem(cardId: number, text: string): Promise<Item> +export type Archived = { id: number; title: string } +export pure fn fetchArchived(): Promise<Archived[]> +export fn restoreCard(id: number): Promise<{ id: number }> diff --git a/eval/results/r15prep/repro/board-store-phantom-row/src-with-store-dump/api.js b/eval/results/r15prep/repro/board-store-phantom-row/src-with-store-dump/api.js new file mode 100644 index 00000000..5cd747bd --- /dev/null +++ b/eval/results/r15prep/repro/board-store-phantom-row/src-with-store-dump/api.js @@ -0,0 +1,12 @@ +import { flight } from "./flights.js"; + +export const fetchCards = column => flight("fetchCards", [column]); +export const moveCard = (id, column, index) => flight("moveCard", [id, column, index]); +export const renameCard = (id, title) => flight("renameCard", [id, title]); +export const addCard = (column, title) => flight("addCard", [column, title]); +export const archiveCard = id => flight("archiveCard", [id]); +export const fetchChecklist = id => flight("fetchChecklist", [id]); +export const toggleItem = (cardId, itemId, done) => flight("toggleItem", [cardId, itemId, done]); +export const addItem = (cardId, text) => flight("addItem", [cardId, text]); +export const fetchArchived = () => flight("fetchArchived", []); +export const restoreCard = id => flight("restoreCard", [id]); diff --git a/eval/results/r15prep/repro/board-store-phantom-row/src-with-store-dump/archive.rx b/eval/results/r15prep/repro/board-store-phantom-row/src-with-store-dump/archive.rx new file mode 100644 index 00000000..f2102204 --- /dev/null +++ b/eval/results/r15prep/repro/board-store-phantom-row/src-with-store-dump/archive.rx @@ -0,0 +1,46 @@ +// The archive page: one optimistic store over the server's archive, as the board page is. A restore's guess is a +// path write into it; an accepted restore re-asks the archive and holds until it lands. Everything here is the +// visit's: the page is mounted only while it is open. + +import pure { fetchArchived } from "./api" +import { restoreCard } from "./api" + +type Shown = { id: number; title: string; gone: boolean } + +export component ArchivePage() { + archived key id = (await fetchArchived()).map(a => ({ id: a.id, title: a.title, gone: false })) + source restoreError: string := "" + + action restore(id: number) { + try { + let i = archived.findIndex(a => a.id == id) + guess archived[i].gone := true + onFlush(() => { restoreError := "" }) + await restoreCard(id) + refresh archived + } catch (e) { + restoreError := e.message + onFlush(() => { restoreError := e.message }) + } + } + + <section> + <div data-testid="archived"> + {loading fallback { <p>loading archive</p> } { + {errored fallback(e) { <p>archive error: {e.message}</p> } { + <ul> + {for a in archived.filter(a => !a.gone) { + <li> + <span data-testid="archived-card">#{a.id} {a.title}</span>{" "} + <button data-testid="restore" onClick={() => { restore(a.id) }}>restore</button> + </li> + }} + </ul> + }} + }} + </div> + {if restoreError != "" { + <p data-testid="restore-error">restore error: {restoreError}</p> + }} + </section> +} diff --git a/eval/results/r15prep/repro/board-store-phantom-row/src-with-store-dump/board.rx b/eval/results/r15prep/repro/board-store-phantom-row/src-with-store-dump/board.rx new file mode 100644 index 00000000..3547752e --- /dev/null +++ b/eval/results/r15prep/repro/board-store-phantom-row/src-with-store-dump/board.rx @@ -0,0 +1,352 @@ +// The board page, in the shape of Solid's own board example: one optimistic store over the server's board. +// +// The server answers per column, so its board is three questions. The store, `board`, is those answers laid flat: +// one row per card, naming its column and its place, keyed by id. The columns on screen are derived from it by +// filtering and sorting, so moving a card is one field write on one row, and a card shows in exactly one column. +// +// Every write has the same life. Its guess is a path write into `board` (or into the checklist, the other store), +// shown at once. The write goes to the server; when it is accepted, the action re-asks the answers it changed, and +// holds until they land (D-34), so the guess is dropped onto data that already agrees with it. When it is declined +// there was only ever the guess, and removing it is the undo. Errors are plain client state under the guesses: a +// refusal is a fact about one request, and no answer has a place for it. +// +// An add has no id until the server answers. Its placeholder is guessed into the store under a key minted here, for +// this visit only, and is inert (no move buttons, not selectable). The server's answer is the new card itself, so the +// action records it in To do's answer (a write whose answer is the new value re-asks nothing) and ends: in one frame +// the placeholder goes with its guess and the real card shows from the truth. An added item is the same over the +// checklist's answer. +// +// Everything here is the visit's: the page is mounted only while it is open, so leaving it discards the store, its +// guesses and its errors, and opening it again asks the server again. + +import pure { fetchCards, fetchChecklist } from "./api" +import { moveCard, renameCard, addCard, archiveCard, toggleItem, addItem, Card, Item } from "./api" + +export type Row = { id: number; title: string; done: number; total: number; col: string; pos: number; adding: boolean; gone: boolean } +type Line = { id: number; text: string; done: boolean; adding: boolean } + +fn colName(c: string): string => c == "todo" ? "To do" : c == "doing" ? "Doing" : "Done" +fn rowsOf(cs: Card[] | null, col: string): Row[] => + cs == null ? [] : cs.map((c, i) => ({ id: c.id, title: c.title, done: c.done, total: c.total, col: col, pos: i, adding: false, gone: false })) +type List = { card: number; items: Item[] } +fn withCard(cs: Card[] | null, c: Card): Card[] | null => cs == null || cs.some(x => x.id == c.id) ? cs : [...cs, c] +fn counted(cs: Card[] | null, id: number): Card[] | null => cs == null ? cs : cs.map(c => c.id == id ? { ...c, total: c.total + 1 } : c) +fn withItem(l: List | null, card: number, it: Item): List | null => + l == null || l.card != card || l.items.some(x => x.id == it.id) ? l : { card: card, items: [...l.items, it] } +fn has(cs: Card[] | null, id: number): boolean => cs != null && cs.some(c => c.id == id) +fn inCol(b: Row[], col: string): Row[] => b.filter(r => r.col == col && !r.gone).sort((x, y) => x.pos - y.pos) +// the place between the rows at i-1 and i (the column without the card being moved), as the example's keyAt +fn placeAt(rows: Row[], i: number): number => + rows.length == 0 ? 0 : i <= 0 ? rows[0].pos - 1 : i >= rows.length ? rows[rows.length - 1].pos + 1 : (rows[i - 1].pos + rows[i].pos) / 2 + +// Where a move puts a card, worked out from the board as shown: `col` set, to the bottom of that column; `col` empty, +// `by` places up or down in its own. +type Place = { col: string; index: number; pos: number } +fn placeOf(b: Row[], id: number, col: string, by: number): Place => + col != "" ? ({ col: col, index: inCol(b, col).length, pos: placeAt(inCol(b, col), inCol(b, col).length) }) : stepped(b, id, b.find(r => r.id == id)?.col ?? "todo", by) +fn stepped(b: Row[], id: number, col: string, by: number): Place => + ({ col: col, index: inCol(b, col).findIndex(r => r.id == id) + by, + pos: placeAt(inCol(b, col).filter(r => r.id != id), inCol(b, col).findIndex(r => r.id == id) + by) }) + +component Column(props: { + col: string; ans: Card[] | null; rows: Row[]; retrying: boolean; left: string; right: string; + leftOpen: boolean; rightOpen: boolean; onRetry: () => void; onSelect: (id: number) => void; onMove: (id: number, col: string, by: number) => void; +}) { + <section data-testid={`col-${props.col}`} style="min-width: 14rem"> + {errored fallback(e) { + <h2 data-testid={`head-${props.col}`}>{colName(props.col)}</h2> + {if props.retrying { + <p>loading</p> + } else { + <p>error: {e.message}{" "}<button data-testid={`retry-${props.col}`} onClick={() => { props.onRetry() }}>retry</button></p> + }} + } { + {if props.ans == null { + <h2 data-testid={`head-${props.col}`}>{colName(props.col)}</h2> + <p>loading</p> + } else { + <h2 data-testid={`head-${props.col}`}>{colName(props.col)} ({props.rows.length})</h2> + <ul> + {for r in props.rows { + <li> + {if r.adding { + <span data-testid="card">{r.title} (adding…)</span> + } else { + <button data-testid="card" onClick={() => { props.onSelect(r.id) }}>#{r.id} {r.title} · {r.done}/{r.total}</button> + {if props.left != "" { + {" "}<button data-testid="left" disabled={!props.leftOpen} onClick={() => { props.onMove(r.id, props.left, 0) }}>left</button> + }} + {if props.right != "" { + {" "}<button data-testid="right" disabled={!props.rightOpen} onClick={() => { props.onMove(r.id, props.right, 0) }}>right</button> + }} + {if props.rows[0].id != r.id { + {" "}<button data-testid="up" onClick={() => { props.onMove(r.id, "", -1) }}>up</button> + }} + {if props.rows[props.rows.length - 1].id != r.id { + {" "}<button data-testid="down" onClick={() => { props.onMove(r.id, "", 1) }}>down</button> + }} + }} + </li> + }} + </ul> + }} + }} + </section> +} + +export component BoardPage() { + // 1. The server's truth: one question per column, `null` until its first answer, so one column loading or failing + // never holds another. + // Sources with a question: an accepted add writes its answer here, until the next re-ask brings the server's list. + source todoAns = await fetchCards("todo") -> null + source doingAns = await fetchCards("doing") -> null + source doneAns = await fetchCards("done") -> null + + // 2. The one store over it, flat; every guess is a path write into it. + board key id = [...rowsOf(todoSeen, "todo"), ...rowsOf(doingSeen, "doing"), ...rowsOf(doneSeen, "done")] + todo = inCol(board, "todo") + doing = inCol(board, "doing") + done = inCol(board, "done") + // each answer as the other panes read it: `null` while it loads or after it failed + todoSeen = todoAns catch (e) => null + doingSeen = doingAns catch (e) => null + doneSeen = doneAns catch (e) => null + todoUp = todoSeen != null + doingUp = doingSeen != null + doneUp = doneSeen != null + // the work-in-progress limit counts the cards Doing shows, moves still out included + intoDoing = doingUp && doing.length < 3 + // the column whose answer holds a card: what a write to it changes on the server, whatever the guesses show + fn home(id: number): string => has(todoSeen, id) ? "todo" : has(doingSeen, id) ? "doing" : "done" + + // The selection and its checklist, the other store. + source selected: number | null := null + // the answer names its card, so an item add answered after another card was selected leaves it alone + source listAns = selected == null ? null : { card: selected, items: await fetchChecklist(selected) } + checklist key id = listAns == null ? [] : listAns.items.map(it => ({ id: it.id, text: it.text, done: it.done, adding: false })) + sel = selected == null ? null : board.find(r => r.id == selected && !r.gone && !r.adding) ?? null + + // Inputs, and what went wrong: plain client state of this visit. + source newCard: string := "" + source titleInput: string := "" + source itemInput: string := "" + source minted: number := 0 + source moveError: string := "" + source addError: string := "" + source archiveError: string := "" + source renameError: string := "" + source itemError: string := "" + + // selecting starts the detail as it starts: its inputs empty, its errors gone + action pick(id: number) { + selected := id + titleInput := "" + itemInput := "" + renameError := "" + itemError := "" + } + + // An accepted card write ends by re-asking the answers it changed: for a move the one that held the card and the + // one it joined, for an edit the one that holds it. + action reask(a: string, b: string) { + if (a == "todo" || b == "todo") { refresh todoAns } + if (a == "doing" || b == "doing") { refresh doingAns } + if (a == "done" || b == "done") { refresh doneAns } + } + + action retry(col: string) { + if (col == "todo") { + affects todoAns + refresh todoAns + } else if (col == "doing") { + affects doingAns + refresh doingAns + } else { + affects doneAns + refresh doneAns + } + } + + // The place is worked out by the click, from the board as shown, as the example's `nudge` does: an action that read + // another action's guess and guessed beside it would be one change with it (D-33). + action move(id: number, from: string, col: string, index: number, pos: number) { + try { + let i = board.findIndex(r => r.id == id) + guess board[i].col := col + guess board[i].pos := pos + onFlush(() => { moveError := "" }) + await moveCard(id, col, index) + await reask(from, col) + } catch (e) { + moveError := e.message + onFlush(() => { moveError := e.message }) + } + } + + action add(key: number, title: string, at: number, pos: number) { + try { + guess board[at] := { id: key, title: title, done: 0, total: 0, col: "todo", pos: pos, adding: true, gone: false } + onFlush(() => { addError := "" }) + let r = await addCard("todo", title) + todoAns := cs => withCard(cs, { id: r.id, title: r.title, done: 0, total: 0 }) + } catch (e) { + addError := e.message + onFlush(() => { addError := e.message }) + } + } + + action rename(id: number, title: string, col: string) { + let i = board.findIndex(r => r.id == id) + try { + guess board[i].title := title + onFlush(() => { renameError := "" }) + await renameCard(id, title) + await reask(col, col) + } catch (e) { + renameError := e.message + onFlush(() => { renameError := e.message }) + } + } + + action archive(id: number, col: string) { + let i = board.findIndex(r => r.id == id) + try { + guess board[i].gone := true + onFlush(() => { + selected := null + archiveError := "" + }) + await archiveCard(id) + await reask(col, col) + } catch (e) { + archiveError := e.message + onFlush(() => { archiveError := e.message }) + } + } + + action toggle(card: number, item: number, to: boolean, col: string) { + try { + let i = board.findIndex(r => r.id == card) + let j = checklist.findIndex(it => it.id == item) + guess checklist[j].done := to + guess board[i].done := n => n + (to ? 1 : -1) + onFlush(() => { itemError := "" }) + await toggleItem(card, item, to) + refresh listAns + await reask(col, col) + } catch (e) { + itemError := e.message + onFlush(() => { itemError := e.message }) + } + } + + action addLine(card: number, key: number, text: string) { + let i = board.findIndex(r => r.id == card) + try { + guess checklist := ls => [...ls, { id: key, text: text, done: false, adding: true }] + guess board[i].total := n => n + 1 + onFlush(() => { itemError := "" }) + let it = await addItem(card, text) + listAns := l => withItem(l, card, it) + todoAns := cs => counted(cs, card) + doingAns := cs => counted(cs, card) + doneAns := cs => counted(cs, card) + } catch (e) { + itemError := e.message + onFlush(() => { itemError := e.message }) + } + } + + <div> + <div style="display: flex; gap: 1.5rem; align-items: flex-start"> + <div> + <Column col="todo" ans={todoAns} rows={todo} retrying={pending? todoAns} left="" right="doing" leftOpen={false} rightOpen={intoDoing} + onRetry={() => { retry("todo") }} onSelect={id => { pick(id) }} onMove={(id, c, by) => { let p = placeOf(board, id, c, by); move(id, home(id), p.col, p.index, p.pos) }} /> + <div> + <input data-testid="new-card" value={newCard} onInput={e => { newCard := e.target.value }} placeholder="new card" />{" "} + <button data-testid="add-card" disabled={!todoUp} onClick={() => { + if (newCard != "") { + let t = newCard + let key = -(minted + 1) + onFlush(() => { + newCard := "" + minted := n => n + 1 + }) + add(key, t, board.length, placeAt(todo, todo.length)) + } + }}>add</button> + {if addError != "" { + <p data-testid="add-error">add error: {addError}</p> + }} + </div> + </div> + <Column col="doing" ans={doingAns} rows={doing} retrying={pending? doingAns} left="todo" right="done" leftOpen={todoUp} rightOpen={doneUp} + onRetry={() => { retry("doing") }} onSelect={id => { pick(id) }} onMove={(id, c, by) => { let p = placeOf(board, id, c, by); move(id, home(id), p.col, p.index, p.pos) }} /> + <Column col="done" ans={doneAns} rows={done} retrying={pending? doneAns} left="doing" right="" leftOpen={intoDoing} rightOpen={false} + onRetry={() => { retry("done") }} onSelect={id => { pick(id) }} onMove={(id, c, by) => { let p = placeOf(board, id, c, by); move(id, home(id), p.col, p.index, p.pos) }} /> + <section data-testid="detail" style="min-width: 16rem"> + {if sel == null { + <p>select a card</p> + } else { + <h2>#{sel.id} {sel.title}</h2> + <p>in {colName(sel.col)}</p> + <div> + <input data-testid="title-input" value={titleInput} onInput={e => { titleInput := e.target.value }} placeholder="new title" />{" "} + <button data-testid="rename" onClick={() => { + if (titleInput != "") { + let t = titleInput + onFlush(() => { titleInput := "" }) + rename(sel.id, t, home(sel.id)) + } + }}>rename</button>{" "} + <button data-testid="archive" onClick={() => { archive(sel.id, home(sel.id)) }}>archive</button> + </div> + {if renameError != "" { + <p>rename error: {renameError}</p> + }} + <div data-testid="checklist"> + {loading on(selected) fallback { <p>loading checklist</p> } { + {errored fallback(e) { <p>checklist error: {e.message}</p> } { + <ul> + {for it in checklist { + <li> + {if it.adding { + <span data-testid="item">[ ] {it.text} (adding…)</span> + } else { + <span data-testid="item">[{it.done ? "x" : " "}] {it.text}</span>{" "} + <button data-testid="toggle" disabled={pending? checklist} onClick={() => { toggle(sel.id, it.id, !it.done, home(sel.id)) }}>toggle</button> + }} + </li> + }} + </ul> + <p data-testid="progress">{checklist.filter(it => it.done).length}/{checklist.length} done</p> + <input data-testid="item-input" value={itemInput} onInput={e => { itemInput := e.target.value }} placeholder="new item" />{" "} + <button data-testid="add-item" disabled={pending? checklist} onClick={() => { + if (itemInput != "") { + let t = itemInput + let key = -(minted + 1) + onFlush(() => { + itemInput := "" + minted := n => n + 1 + }) + addLine(sel.id, key, t) + } + }}>add item</button> + }} + }} + </div> + {if itemError != "" { + <p>item error: {itemError}</p> + }} + }} + </section> + </div> + <pre>STORE[{board.map(r => `${r.id}:${r.col}:${r.pos}:${r.gone}`).join(" ")}] DOING[{doingSeen == null ? "null" : doingSeen.map(c => `${c.id}`).join(" ")}] DONE[{doneSeen == null ? "null" : doneSeen.map(c => `${c.id}`).join(" ")}]</pre> + {if moveError != "" { + <p data-testid="move-error">move error: {moveError}</p> + }} + {if archiveError != "" { + <p data-testid="archive-error">archive error: {archiveError}</p> + }} + </div> +} diff --git a/eval/results/r15prep/repro/board-store-phantom-row/src-with-store-dump/flights.js b/eval/results/r15prep/repro/board-store-phantom-row/src-with-store-dump/flights.js new file mode 100644 index 00000000..21a541a1 --- /dev/null +++ b/eval/results/r15prep/repro/board-store-phantom-row/src-with-store-dump/flights.js @@ -0,0 +1,30 @@ +const fmt = v => (typeof v === "string" ? v : JSON.stringify(v)); +const flights = []; +let seq = 0; + +export function flight(name, args) { + const key = `${name}:${args.map(fmt).join(",")}`; + let resolve, reject; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + flights.push({ key, seq: seq++, settled: false, resolve, reject }); + return promise; +} + +const open = key => flights.filter(f => !f.settled && f.key === key); +const control = { + /** keys of the flights still in the air, oldest first */ + pending: () => [...new Set(flights.filter(f => !f.settled).map(f => f.key))], + /** every key ever asked, oldest first, repeats included */ + asked: () => flights.map(f => f.key), + land(key, value) { + const fs = open(key); + if (!fs.length) throw new Error(`no flight in the air for ${key}; in the air: ${control.pending().join(" | ") || "none"}`); + for (const f of fs) { f.settled = true; f.resolve(structuredClone(value)); } + }, + fail(key, message) { + const fs = open(key); + if (!fs.length) throw new Error(`no flight in the air for ${key}; in the air: ${control.pending().join(" | ") || "none"}`); + for (const f of fs) { f.settled = true; f.reject(new Error(message)); } + }, +}; +globalThis.__flights = control; diff --git a/eval/results/r15prep/repro/board-store-phantom-row/src-with-store-dump/main.rx b/eval/results/r15prep/repro/board-store-phantom-row/src-with-store-dump/main.rx new file mode 100644 index 00000000..a6b7cdf1 --- /dev/null +++ b/eval/results/r15prep/repro/board-store-phantom-row/src-with-store-dump/main.rx @@ -0,0 +1,21 @@ +// The sprint board: two pages behind tabs. Only the open page is mounted, so leaving one discards it, and opening it +// asks for its data again. + +import { BoardPage } from "./board.rx" +import { ArchivePage } from "./archive.rx" + +export default component App() { + source page: string := "board" + + <div> + <nav> + <button data-testid="tab-board" onClick={() => { page := "board" }}>Board</button>{" "} + <button data-testid="tab-archive" onClick={() => { page := "archive" }}>Archive</button> + </nav> + {if page == "board" { + <BoardPage /> + } else { + <ArchivePage /> + }} + </div> +} diff --git a/eval/results/r15prep/repro/catch-read-in-handler-during-refresh.rx b/eval/results/r15prep/repro/catch-read-in-handler-during-refresh.rx new file mode 100644 index 00000000..ac203965 --- /dev/null +++ b/eval/results/r15prep/repro/catch-read-in-handler-during-refresh.rx @@ -0,0 +1,30 @@ +// .rx runtime bug: `x catch (e) => alt` read in a handler (here through a component fn) lowers to rt.catchRead, which builds a +// fresh memo; while an action's `refresh x` is in flight that memo has no committed value, so the click throws NotReadyError +// (uncaught page error, the click is lost). The same read without `catch` gives the answer on screen; so does plain Solid +// (untrack(ans) during refresh -> committed value). Also: the checker reports neither R-8 nor GUESS_WHILE_LOADING through the fn. +// Run: probe-rx.mjs <desk with this as src/main.rx> catch-read-in-handler-during-refresh.steps.mjs +import pure { fetchCards } from "./api" +import { moveCard } from "./api" +type Row = { id: number; col: string } +export default component App() { + source ans = await fetchCards("doing") -> null + board key id = (ans == null ? [] : ans.map(c => ({ id: c.id, col: "doing" }))) + fn cnt(): number => (ans catch (e) => null) == null ? -1 : 2 + action move(id: number, n: number) { + try { + let i = board.findIndex(r => r.id == id) + guess board[i].col := "done" + await moveCard(id, "done", n) + refresh ans + } catch (e) { + log(e.message) + } + } + errored fallback(e) { <p>err</p> } { + <ul> + {for r in board { + <li><span data-testid="card">#{r.id} {r.col}</span> <button data-testid="right" onClick={() => { move(r.id, cnt()) }}>right</button></li> + }} + </ul> + } +} diff --git a/eval/results/r15prep/repro/catch-read-in-handler-during-refresh.steps.mjs b/eval/results/r15prep/repro/catch-read-in-handler-during-refresh.steps.mjs new file mode 100644 index 00000000..03c1374c --- /dev/null +++ b/eval/results/r15prep/repro/catch-read-in-handler-during-refresh.steps.mjs @@ -0,0 +1,8 @@ +import { land, DOING, near } from "/tmp/claude-0/runs/r15prep/board-rx3/scratch/common.mjs"; +export default [ + ["load", land("fetchCards:doing", DOING)], + ["3 right", p => near(p, 3, "right")], + ["land 3", land("moveCard:3,done,2", {})], + ["4 right (reads board while refresh is out)", p => near(p, 4, "right")], + ["land doing", land("fetchCards:doing", DOING)], +]; diff --git a/eval/results/r15prep/repro/check-accepts-arity-and-missing-props.rx b/eval/results/r15prep/repro/check-accepts-arity-and-missing-props.rx new file mode 100644 index 00000000..5de495de --- /dev/null +++ b/eval/results/r15prep/repro/check-accepts-arity-and-missing-props.rx @@ -0,0 +1,7 @@ +// .rx checker gap: `go(1, 2)` on a zero-parameter action and `props.col` in a component declared without props both pass `rxc check`; props compiles to `v$props?.col` (undefined, rendered silently). Found when board.rx passed `props.col` from BoardPage. +import { moveCard } from "./api" +export default component App() { + source n: number := 0 + action go() { n := 1 } + <button onClick={() => { go(1, 2); log(props.col) }}>x{props.col}</button> +} diff --git a/eval/results/r15prep/repro/entangled-adds-plain-write-reveals.spec.tsx b/eval/results/r15prep/repro/entangled-adds-plain-write-reveals.spec.tsx new file mode 100644 index 00000000..0cc5f3fd --- /dev/null +++ b/eval/results/r15prep/repro/entangled-adds-plain-write-reveals.spec.tsx @@ -0,0 +1,38 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +// Two adds guess a row each into one optimistic store (b[n] = …, b[n+1] = …: one length slot, so their holds are +// one, A34/H-11). The first add's answer lands: its action writes the answer into the store's truth (a writable +// signal the store derives from) and returns. Expected (H-11, S-action-4): neither action's writes reveal until both +// have returned — the screen keeps "A1 (adding) A2 (adding)". Landing the SECOND add first does hold. +import { test, expect } from "vitest"; +import { action, createSignal, createOptimisticStore, flush, For } from "solid-js"; +import { render } from "../src/index.js"; +const tick = async () => { for (let i = 0; i < 4; i++) { await new Promise(r => setTimeout(r, 0)); flush(); } }; +type Row = { id: number; title: string; adding?: boolean }; +function setup() { + const asks: { key: string; res: (v: any) => void }[] = []; + const ask = (key: string) => new Promise<any>(res => asks.push({ key, res })); + const take = (key: string) => asks.splice(asks.findIndex(a => a.key === key), 1)[0]; + const c = document.body.appendChild(document.createElement("div")); + const [ans, setAns] = createSignal<Row[]>([{ id: 1, title: "W" }]); + const [board, guess] = createOptimisticStore<Row[]>(() => ans().map(r => ({ ...r })), [], { key: "id" }); + const add = action(function* (key: number, title: string, at: number) { + guess(b => { b[at] = { id: key, title, adding: true }; }); + const r = yield ask(`add:${title}`); + setAns(cs => [...cs, { id: r.id, title }]); + }); + render(() => <p><For each={board}>{r => <span>{r.adding ? `${r.title}(adding) ` : `#${r.id} ${r.title} `}</span>}</For></p>, c); + return { add, take, screen: () => c.textContent!.trim(), board }; +} +for (const first of ["A1", "A2"]) test(`two adds, ${first} answered first`, async () => { + const { add, take, screen, board } = setup(); await tick(); + add(-1, "A1", board.length); await tick(); + add(-2, "A2", board.length); await tick(); + const before = screen(); + take(`add:${first}`).res({ id: first === "A1" ? 10 : 11 }); await tick(); + const after = screen(); + console.log(first, "first | before:", before, "| after:", after); + expect(after).toBe(before); +}); diff --git a/eval/results/r15prep/repro/handler-read-during-refresh.spec.tsx b/eval/results/r15prep/repro/handler-read-during-refresh.spec.tsx new file mode 100644 index 00000000..0f262fd2 --- /dev/null +++ b/eval/results/r15prep/repro/handler-read-during-refresh.spec.tsx @@ -0,0 +1,32 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +// A click handler reads (untracked) an async memo that has an answer on screen, while an action's refresh of it is +// in flight. Expected: the committed answer (the screen's). Observed: does it throw NotReadyError? +import { test, expect } from "vitest"; +import { action, createMemo, createOptimisticStore, refresh, untrack, flush, NotReadyError } from "solid-js"; +import { render } from "../src/index.js"; +const tick = async () => { for (let i = 0; i < 4; i++) { await new Promise(r => setTimeout(r, 0)); flush(); } }; +test("an untracked read of an answered memo while an action's refresh of it is out", async () => { + const asks: { key: string; res: (v: any) => void }[] = []; + const ask = (key: string) => new Promise<any>(res => asks.push({ key, res })); + const take = (key: string) => { const i = asks.findIndex(a => a.key === key); return asks.splice(i, 1)[0]; }; + const c = document.body.appendChild(document.createElement("div")); + const ans = createMemo(() => ask("cards")); + const [board, guess] = createOptimisticStore(() => (ans() as any[]).map(x => ({ ...x })), [], { key: "id" }); + const move = action(function* (id: number) { + guess(b => { const r = b.find(r => r.id === id); if (r) r.col = "done"; }); + yield ask(`move:${id}`); + refresh(ans); + }); + render(() => <ul>{(board as any[]).map(r => <li>{r.id} {r.col}</li>)}</ul>, c); + take("cards").res([{ id: 3, col: "doing" }, { id: 4, col: "doing" }]); await tick(); + const before = untrack(() => (ans() as any[]).length); + move(3); await tick(); + take("move:3").res({}); await tick(); // the refresh of `ans` is now in flight, inside the action + let read: unknown; + try { read = untrack(() => (ans() as any[]).length); } catch (e) { read = e instanceof NotReadyError ? "NotReadyError" : String(e); } + console.log("before:", before, "during refresh:", read, "| in the air:", asks.map(a => a.key).join(", ")); + expect(read).toBe(2); +}); diff --git a/eval/results/r15prep/repro/hang-after-reask.archive.rx b/eval/results/r15prep/repro/hang-after-reask.archive.rx new file mode 100644 index 00000000..ed68e4d2 --- /dev/null +++ b/eval/results/r15prep/repro/hang-after-reask.archive.rx @@ -0,0 +1,55 @@ +// The archive page's data and its one write. Restores in flight are a guess list laid over the server's answer; an +// accepted one ends with the list asked again. + +import pure { fetchArchived } from "./api" +import { restoreCard } from "./api" + +// opening the archive page asks for it again; the app opens on the board, so the first open asks +source visit: number := -1 +archived = visit >= 0 ? await fetchArchived() : [] +source restoring: number[] = [] +shown = archived.filter(a => !restoring.includes(a.id)) +type Fail = (m: string) => void + +export action openArchive() { + visit := visit + 1 +} + +// a decline goes to `fail`, which the page that made the restore owns (its failure lives with that visit) +action restore(id: number, fail: Fail) { + try { + guess restoring := xs => [...xs, id] + await restoreCard(id) + refresh archived + } catch (e) { + fail(e.message) + onFlush(() => { fail(e.message) }) + } +} + +export component ArchivePage() { + source restoreError: string := "" + + <section> + <div data-testid="archived"> + {loading fallback { <p>loading archive</p> } { + {errored fallback(e) { <p>archive error: {e.message}</p> } { + <ul> + {for a in shown { + <li> + <span data-testid="archived-card">#{a.id} {a.title}</span>{" "} + <button data-testid="restore" onClick={() => { + onFlush(() => { restoreError := "" }) + restore(a.id, m => { restoreError := m }) + }}>restore</button> + </li> + }} + </ul> + }} + }} + </div> + {if restoreError != "" { + <p data-testid="restore-error">restore error: {restoreError}</p> + }} + </section> +} diff --git a/eval/results/r15prep/repro/hang-after-reask.attempt.app.tsx b/eval/results/r15prep/repro/hang-after-reask.attempt.app.tsx new file mode 100644 index 00000000..6227e28e --- /dev/null +++ b/eval/results/r15prep/repro/hang-after-reask.attempt.app.tsx @@ -0,0 +1,105 @@ +import { onFlush, createErrorBoundary, untrack, Show, For, Loading, Errored, action, createMemo, createSignal, createOptimistic, createProjection, refresh, isPending } from "solid-js"; +import { fetchArchived, fetchCards, fetchChecklist, moveCard, renameCard, toggleItem } from "./api.js"; + +// root-owned state, as rxc lowers store.rx +const [visit, setVisit] = createSignal(0); +const [selected, setSelected] = createSignal<number | null>(null); +const col = (c: string) => createSignal<any>(() => (visit() >= 0 ? fetchCards(c) : null), { loadingValue: null } as any)[0]; +const todoAns = col("todo"), doingAns = col("doing"), doneAns = col("done"); +const checklistQ = createMemo<any>(() => (selected() != null ? fetchChecklist(selected()) : undefined)); +const [checklist] = createSignal<any>(() => (selected() == null ? null : { card: selected(), items: checklistQ() })); +const guessList = () => { const [t] = createSignal<any[]>(() => []); return createOptimistic<any[]>(() => t()); }; +const [moving, guessMoving] = guessList(); +const [renaming, guessRenaming] = guessList(); +const [toggling, guessToggling] = guessList(); +const w = createMemo(() => ({ moving: moving(), renaming: renaming(), toggling: toggling() })); +const without = (cs: any[], id: number) => cs.filter(c => c.id !== id); +const step = (cs: any[], c: string, m: any) => m.col !== c ? without(cs, m.id) : [...without(cs, m.id).slice(0, m.index), cs.find(x => x.id === m.id) ?? m.card, ...without(cs, m.id).slice(m.index)]; +const lay = (ans: any[] | null, c: string, w: any) => ans == null ? null : w.moving.reduce((cs: any[], m: any) => step(cs, c, m), ans).map((x: any, i: number, cs: any[]) => ({ + id: x.id, title: w.renaming.reduce((t: string, r: any) => r.id === x.id ? r.title : t, x.title), + done: w.toggling.filter((t: any) => t.card === x.id).reduce((n: number, t: any) => n + (t.done ? 1 : -1), x.done), total: x.total, card: x, pos: i, last: i + 1 === cs.length })); +const todoRows = createMemo(() => lay(todoAns(), "todo", w())); +const doingRows = createMemo(() => lay(doingAns(), "doing", w())); +const doneRows = createMemo(() => lay(doneAns(), "done", w())); +const itemsSrc = createMemo(() => { const l = checklist(); return l == null ? [] : l.items.map((it: any) => ({ key: `i${it.id}`, id: it.id, text: it.text, done: toggling().reduce((d: boolean, t: any) => t.card === l.card && t.item === it.id ? t.done : d, it.done) })); }); +const items = createProjection(() => itemsSrc() as any, [] as any, { key: "key" } as any) as any; + +const reask = action(function* () { refresh(todoAns); refresh(doingAns); refresh(doneAns); }); +const move = action(function* (card: any, c: string, index: number) { + try { guessMoving(ms => [...ms, { id: card.id, col: c, index, card }]); yield moveCard(card.id, c, index); yield reask(); } catch {} +}); +const rename = action(function* (id: number, title: string) { + try { guessRenaming(rs => [...rs, { id, title }]); yield renameCard(id, title); yield reask(); } catch {} +}); +const toggle = action(function* (card: number, item: number, done: boolean) { + try { guessToggling(ts => [...ts, { card, item, done }]); yield toggleItem(card, item, done); refresh(checklistQ); refresh(checklist); yield reask(); } catch {} +}); +const openBoard = action(function* () { setVisit(v => v + 1); setSelected(null); }); +const select = action(function* (id: number) { setSelected(id); }); + +const colName = (c: string) => c === "todo" ? "To do" : c === "doing" ? "Doing" : "Done"; +function Column(props: any) { + return <section data-testid={`col-${props.col}`}> + <Loading fallback={<><h2 data-testid={`head-${props.col}`}>{colName(props.col)}</h2><p>loading</p></>}> + <h2 data-testid={`head-${props.col}`}>{colName(props.col)}{props.rows == null ? "" : ` (${props.rows.length})`}</h2> + <Errored fallback={(e: any) => <p>error {String(e()?.message ?? e())}</p>}> + <Show when={props.rows == null} fallback={<ul><For each={props.rows}>{(r: any) => <li> + <button data-testid="card" onClick={() => props.onSelect(r.id)}>#{r.id} {r.title} · {r.done}/{r.total}</button> + <Show when={props.left !== ""}>{" "}<button data-testid="left" disabled={!props.leftOk} onClick={() => props.onMove(r.card, props.left, props.leftSize)}>left</button></Show> + <Show when={props.right !== ""}>{" "}<button data-testid="right" disabled={!props.rightOk} onClick={() => props.onMove(r.card, props.right, props.rightSize)}>right</button></Show> + </li>}</For></ul>}><p>loading</p></Show> + </Errored> + </Loading> + </section>; +} + +function BoardPage() { + const [titleInput, setTitleInput] = createSignal(""); + const [moveError, setMoveError] = createSignal(""); + const pick = action(function* (id: number) { setTitleInput(""); onFlush(() => setMoveError("")); select(id); }); + const moveTo = action(function* (c: any, to: string, i: number) { onFlush(() => setMoveError("")); move(c, to, i); }); + const shown = (ans: any, rows: any) => { const m = createMemo(() => (isPending(() => ans()) ? null : rows())); return untrack(() => createErrorBoundary(() => m(), () => null)) as any; }; + const todoShown = shown(todoAns, todoRows), doingShown = shown(doingAns, doingRows), doneShown = shown(doneAns, doneRows); + const n = (s: any) => createMemo(() => (s() == null ? 0 : s().length)); + const todoN = n(todoShown), doingN = n(doingShown), doneN = n(doneShown); + const todoUp = createMemo(() => todoShown() == null); + const intoDoing = createMemo(() => doingShown() != null && doingN() < 3); + const findIn = (rs: any, id: any) => (rs == null || id == null ? null : rs.find((r: any) => r.id === id) ?? null); + const sel = createMemo(() => findIn(todoShown(), selected()) ?? findIn(doingShown(), selected()) ?? findIn(doneShown(), selected())); + return <div> + <Column col="todo" rows={todoRows()} left="" right="doing" leftOk={false} rightOk={intoDoing()} leftSize={0} rightSize={doingN()} onSelect={pick} onMove={moveTo} /> + <button data-testid="add-card" disabled={todoUp()}>add</button> + <Column col="doing" rows={doingRows()} left="todo" right="done" leftOk={todoShown() != null} rightOk={doneShown() != null} leftSize={todoN()} rightSize={doneN()} onSelect={pick} onMove={moveTo} /> + <Column col="done" rows={doneRows()} left="doing" right="" leftOk={intoDoing()} rightOk={false} leftSize={doingN()} rightSize={0} onSelect={pick} onMove={moveTo} /> + <p>{moveError()}</p><section data-testid="detail"> + <Show when={selected() == null || sel() == null} fallback={<> + <h2>#{sel()?.id} {sel()?.title}</h2> + <input data-testid="title-input" value={titleInput()} onInput={e => setTitleInput(e.currentTarget.value)} /> + <button data-testid="rename" onClick={() => { const t = titleInput(); if (t) { onFlush(() => setTitleInput("")); rename(sel()!.id, t); } }}>rename</button> + <div data-testid="checklist"><Loading on={selected()} fallback={<p>loading checklist</p>}><Errored fallback={<p>checklist error</p>}> + <ul><For each={items}>{(it: any) => <li><span data-testid="item">[{it.done ? "x" : " "}] {it.text}</span> + <button data-testid="toggle" onClick={() => toggle(sel()!.id, it.id, !it.done)}>toggle</button></li>}</For></ul> + <p data-testid="progress">{items.filter((it: any) => it.done).length}/{items.length} done</p> + </Errored></Loading></div> + </>}><p>select a card</p></Show> + </section> + </div>; +} + +const [avisit, setAvisit] = createSignal(-1); +const archived = createMemo<any>(() => (avisit() >= 0 ? fetchArchived() : [])); +const openArchive = action(function* () { setAvisit(v => v + 1); }); +function ArchivePage() { + return <section data-testid="archived"><Loading fallback={<p>loading archive</p>}><Errored fallback={<p>archive error</p>}> + <ul><For each={archived()}>{(a: any) => <li><span data-testid="archived-card">#{a.id} {a.title}</span></li>}</For></ul> + </Errored></Loading></section>; +} + +export default function App() { + const [page, setPage] = createSignal("board"); + return <div> + <button data-testid="tab-board" onClick={() => { if (page() !== "board") { openBoard(); setPage("board"); } }}>Board</button> + <button data-testid="tab-archive" onClick={() => { if (page() !== "archive") { openArchive(); setPage("archive"); } }}>Archive</button> + <Show when={page() === "board"} fallback={<ArchivePage />}><BoardPage /></Show> + </div>; +} diff --git a/eval/results/r15prep/repro/hang-after-reask.board.rx b/eval/results/r15prep/repro/hang-after-reask.board.rx new file mode 100644 index 00000000..90ba0b5a --- /dev/null +++ b/eval/results/r15prep/repro/hang-after-reask.board.rx @@ -0,0 +1,176 @@ +// The board page: three columns, the add form, and the selected card's detail. The data and the writes are in +// store.rx; this page holds only its inputs, which go when it closes. + +import { Card } from "./api" +import { Row, selected, todoAns, doingAns, doneAns, todoRows, doingRows, doneRows, checklist, items } from "./store.rx" +import { select, retry, move, add, rename, archive, toggle, addOneItem } from "./store.rx" + +fn colName(c: string): string => c == "todo" ? "To do" : c == "doing" ? "Doing" : "Done" +fn real(rows: Row[] | null): number => rows == null ? 0 : rows.filter(r => !r.adding).length +fn count(rows: Row[] | null): string => rows == null ? "" : ` (${rows.length})` +fn findIn(rows: Row[] | null, id: number | null): Row | null => rows == null || id == null ? null : rows.find(r => !r.adding && r.id == id) ?? null + +component Column(props: { + col: string; rows: Row[] | null; retrying: boolean; left: string; right: string; leftOk: boolean; rightOk: boolean; + leftSize: number; rightSize: number; onSelect: (id: number) => void; onMove: (card: Card, col: string, index: number) => void; +}) { + // the heading and the cards show in one region, so the count always counts the cards on screen + <section data-testid={`col-${props.col}`} style="min-width: 14rem"> + {loading fallback { <h2 data-testid={`head-${props.col}`}>{colName(props.col)}</h2><p>loading</p> } { + <h2 data-testid={`head-${props.col}`}>{colName(props.col)}</h2> + {errored fallback(e) { + <p>error</p> + } { + {if props.rows == null { + <p>loading</p> + } else { + <ul> + {for r in props.rows { + <li> + {if r.adding { + <span data-testid="card">{r.title} (adding…)</span> + } else { + <button data-testid="card" onClick={() => { props.onSelect(r.id) }}>#{r.id} {r.title} · {r.done}/{r.total}</button> + {if props.left != "" { + {" "}<button data-testid="left" disabled={!props.leftOk} onClick={() => { props.onMove(r.card, props.left, props.leftSize) }}>left</button> + }} + {if props.right != "" { + {" "}<button data-testid="right" disabled={!props.rightOk} onClick={() => { props.onMove(r.card, props.right, props.rightSize) }}>right</button> + }} + }} + </li> + }} + </ul> + }} + }} + }} + </section> +} + +export component BoardPage() { + source newCard: string := "" + source titleInput: string := "" + source itemInput: string := "" + // failures belong to this visit: a write that is declined after the page closed shows nothing on the next one + source moveError: string := "" + source addError: string := "" + source archiveError: string := "" + source renameError: string := "" + source itemError: string := "" + + // each column as the other panes see it: null while it loads (the probe reads ahead, so a column that loads never + // holds another column or the detail) or after it failed; nothing moves into a column that isn't shown + todoShown = pending? todoAns ? null : todoRows catch (e) => null + doingShown = pending? doingAns ? null : doingRows catch (e) => null + doneShown = pending? doneAns ? null : doneRows catch (e) => null + todoN = real(todoShown) + doingN = real(doingShown) + doneN = real(doneShown) + todoUp = todoShown != null + doingUp = doingShown != null + doneUp = doneShown != null + intoDoing = doingUp && doingN < 3 + + // the detail reads the card where the board shows it + selTodo = findIn(todoShown, selected) + selDoing = findIn(doingShown, selected) + selDone = findIn(doneShown, selected) + sel = selTodo ?? selDoing ?? selDone + selCol = selTodo != null ? "todo" : selDoing != null ? "doing" : "done" + + action pick(id: number) { + titleInput := "" + itemInput := "" + onFlush(() => { + renameError := "" + itemError := "" + }) + select(id) + } + + action moveTo(c: Card, to: string, i: number) { + onFlush(() => { moveError := "" }) + move(c, to, i, m => { moveError := m }) + } + + <div> + <div style="display: flex; gap: 1.5rem; align-items: flex-start"> + <div> + <Column col="todo" rows={todoRows} retrying={pending? todoAns} left="" right="doing" leftOk={false} rightOk={intoDoing} + leftSize={0} rightSize={doingN} onSelect={id => { pick(id) }} onMove={(c, to, i) => { moveTo(c, to, i) }} /> + <div> + <button data-testid="add-card" disabled={!todoUp}>add</button> + </div> + </div> + <Column col="doing" rows={doingRows} retrying={pending? doingAns} left="todo" right="done" leftOk={todoUp} rightOk={doneUp} + leftSize={todoN} rightSize={doneN} onSelect={id => { pick(id) }} onMove={(c, to, i) => { moveTo(c, to, i) }} /> + <Column col="done" rows={doneRows} retrying={pending? doneAns} left="doing" right="" leftOk={intoDoing} rightOk={false} + leftSize={doingN} rightSize={0} onSelect={id => { pick(id) }} onMove={(c, to, i) => { moveTo(c, to, i) }} /> + <section data-testid="detail" style="min-width: 16rem"> + {if selected == null || sel == null { + <p>select a card</p> + } else { + <h2>#{sel.id} {sel.title}</h2> + <div> + <input data-testid="title-input" value={titleInput} onInput={e => { titleInput := e.target.value }} placeholder="new title" />{" "} + <button data-testid="rename" onClick={() => { + if (titleInput != "") { + let t = titleInput + onFlush(() => { + titleInput := "" + renameError := "" + }) + rename(sel.id, t, m => { renameError := m }) + } + }}>rename</button> + </div> + {if renameError != "" { + <p>rename error: {renameError}</p> + }} + <div data-testid="checklist"> + {loading on(selected) fallback { <p>loading checklist</p> } { + {errored fallback(e) { <p>checklist error: {e.message}</p> } { + <ul> + {for it in items { + {if it.adding { + <li><span data-testid="item">[ ] {it.text} (adding…)</span></li> + } else { + <li> + <span data-testid="item">[{it.done ? "x" : " "}] {it.text}</span>{" "} + <button data-testid="toggle" onClick={() => { + onFlush(() => { itemError := "" }) + toggle(sel.id, it.id, !it.done, m => { itemError := m }) + }}>toggle</button> + </li> + }} + }} + </ul> + + <input data-testid="item-input" value={itemInput} onInput={e => { itemInput := e.target.value }} placeholder="new item" />{" "} + <button data-testid="add-item" onClick={() => { + if (itemInput != "") { + let t = itemInput + onFlush(() => { + itemInput := "" + itemError := "" + }) + addOneItem(sel.id, t, m => { itemError := m }) + } + }}>add item</button> + }} + }} + </div> + {if itemError != "" { + <p>item error: {itemError}</p> + }} + }} + </section> + </div> + {if moveError != "" { + <p data-testid="move-error">move error: {moveError}</p> + }} + {if archiveError != "" { + <p data-testid="archive-error">archive error: {archiveError}</p> + }} + </div> +} diff --git a/eval/results/r15prep/repro/hang-after-reask.main.rx b/eval/results/r15prep/repro/hang-after-reask.main.rx new file mode 100644 index 00000000..cd60940c --- /dev/null +++ b/eval/results/r15prep/repro/hang-after-reask.main.rx @@ -0,0 +1,32 @@ +// The sprint board: two pages behind tabs. Only the open page is mounted, so leaving one discards its inputs, and +// opening it asks for its data again. + +import { BoardPage } from "./board.rx" +import { ArchivePage, openArchive } from "./archive.rx" +import { openBoard } from "./store.rx" + +export default component App() { + source page: string := "board" + + <div> + <nav> + <button data-testid="tab-board" onClick={() => { + if (page != "board") { + openBoard() + page := "board" + } + }}>Board</button>{" "} + <button data-testid="tab-archive" onClick={() => { + if (page != "archive") { + openArchive() + page := "archive" + } + }}>Archive</button> + </nav> + {if page == "board" { + <BoardPage /> + } else { + <ArchivePage /> + }} + </div> +} diff --git a/eval/results/r15prep/repro/hang-after-reask.replay.mjs b/eval/results/r15prep/repro/hang-after-reask.replay.mjs new file mode 100644 index 00000000..b87e75d2 --- /dev/null +++ b/eval/results/r15prep/repro/hang-after-reask.replay.mjs @@ -0,0 +1,49 @@ +// Replays a fuzz-board trail (';'-separated, as the fuzzer prints it) against an app and reports whether the page +// hangs (page.evaluate unanswered for 3 s) after the last step. Answers come from fuzz-board's server model. +// Usage: node hang-after-reask.replay.mjs <desk> <trail-file-or-string> [--quiet] +const E = "/tmp/claude-0/-home-user/b0c1a8f0-5828-5b86-a816-75e9c36897f9/scratchpad/rx/eval/"; +const { build } = await import(E + "harness/build.mjs"); +const { launch, serve } = await import(E + "harness/driver.mjs"); +const { freshServer, answer } = await import(E + "harness/fuzz-board.mjs"); +import { existsSync, readFileSync } from "node:fs"; +const [dir, t] = process.argv.slice(2); const quiet = process.argv.includes("--quiet"); +const trail = (existsSync(t) ? readFileSync(t, "utf8") : t).split(";").map(s => s.trim()).filter(s => s && !s.startsWith("(")); +const b = await build(dir); if (!b.ok) { console.log(b.output); process.exit(2); } +const server = await serve(b.dist); const browser = await launch(); +export async function replay(page, trail, log = () => {}) { + const srv = freshServer(); const settled = new Map(); + const sel = id => `[data-testid="${id}"]`; const T = { timeout: 1000 }; + const ev = (f, a) => Promise.race([page.evaluate(f, a), new Promise((_, j) => setTimeout(() => j(new Error("HUNG")), 3000))]); + for (const [n, s] of trail.entries()) { + log(`STEP ${n} ${s}`); + try { + let m; + if ((m = s.match(/^(land|fail) (.*)$/))) { + const key = m[2]; const fail = m[1] === "fail"; + const times = Math.max(1, (await ev(k => globalThis.__flights.asked().filter(x => x === k).length, key)) - (settled.get(key) ?? 0)); + settled.set(key, (settled.get(key) ?? 0) + times); + let a; for (let i = 0; i < times; i++) a = answer(srv, key, fail); + await ev(([k, a]) => a.fail ? globalThis.__flights.fail(k, "declined") : globalThis.__flights.land(k, a.value), [key, a]); + } else if ((m = s.match(/^select #(\d+)$/))) await page.locator(sel("card"), { hasText: new RegExp(`^\\s*#${m[1]} `) }).first().click(T); + else if ((m = s.match(/^(left|right|up|down) #(\d+)$/))) await page.locator(sel("card"), { hasText: new RegExp(`^\\s*#${m[2]} `) }).first().locator("xpath=..").locator(sel(m[1])).click(T); + else if ((m = s.match(/^add (.*)$/))) { await page.locator(sel("new-card")).first().fill(m[1], T); await page.locator(sel("add-card")).first().click(T); } + else if ((m = s.match(/^rename (.*)$/))) { await page.locator(sel("title-input")).first().fill(m[1], T); await page.locator(sel("rename")).first().click(T); } + else if ((m = s.match(/^add-item (.*)$/))) { await page.locator(sel("item-input")).first().fill(m[1], T); await page.locator(sel("add-item")).first().click(T); } + else if ((m = s.match(/^toggle (\d+)$/))) await page.locator(sel("toggle")).nth(Number(m[1])).click(T); + else if ((m = s.match(/^restore (\d+)$/))) await page.locator(sel("restore")).nth(Number(m[1])).click(T); + else if ((m = s.match(/^tab (board|archive)$/))) await page.locator(sel(`tab-${m[1]}`)).click(T); + else if ((m = s.match(/^retry-(\w+)$/))) await page.locator(sel(s)).click(T); + else throw new Error("unknown step " + s); + } catch (e) { if (e.message === "HUNG") return { hung: n }; log(` (step failed: ${e.message.split("\n")[0].slice(0, 80)})`); } + await page.waitForTimeout(60); + if (process.env.DUMP) log(" | " + (await ev(() => document.body.textContent.replace(/\s+/g, " ") + " ~ " + globalThis.__flights.pending().join("|")).catch(e => e.message))); + } + try { await ev(() => 1); return { hung: false }; } catch { return { hung: trail.length - 1 }; } +} +if (!process.env.NO_MAIN) { + const page = await browser.newPage(); page.on("pageerror", e => console.log("PAGEERR", e.message)); + await page.goto(server.url); await page.waitForTimeout(200); + const r = await replay(page, trail, quiet ? () => {} : console.log); + console.log(r.hung === false ? "RESULT: no hang" : `RESULT: HANG after step ${r.hung} (${trail[r.hung]})`); + await browser.close().catch(() => {}); server.close(); process.exit(r.hung === false ? 0 : 1); +} diff --git a/eval/results/r15prep/repro/hang-after-reask.store.rx b/eval/results/r15prep/repro/hang-after-reask.store.rx new file mode 100644 index 00000000..11d8379b --- /dev/null +++ b/eval/results/r15prep/repro/hang-after-reask.store.rx @@ -0,0 +1,173 @@ +// The board's data and every write on it (guide/async.md, "three layers"). The server's answers are equations; the +// writes still out are guess lists with a constant question, laid over every answer by the equations below, so a +// card shows each write the same in its column and in the detail. An accepted write ends with the answers asked +// again; its guess stays until they land (D-34). A declined one reverts with its message, in one frame (AC-7). + +import pure { fetchCards, fetchChecklist } from "./api" +import { moveCard, renameCard, addCard, archiveCard, toggleItem, addItem, Card, Item } from "./api" + +// a card as shown, with the answer it came from (a move carries that, so the column it lands in can show it) +export type Row = { key: string; id: number; title: string; done: number; total: number; adding: boolean; card: Card; pos: number; last: boolean } +type Move = { id: number; col: string; index: number; card: Card } +type Rename = { id: number; title: string } +type Toggle = { card: number; item: number; done: boolean } +type ItemAdd = { card: number; text: string } +type Fail = (m: string) => void +type List = { card: number; items: Item[] } + +// opening the board page asks for its columns again (the app opens on it) +export source visit: number := 0 +export source selected: number | null := null + +// the server's answers, with what an accepted add answered written over them until they are asked again (an add's +// answer is the new card or item itself, so it re-asks nothing); `null` while a column's first answer is out +export source todoAns = visit >= 0 ? (await fetchCards("todo") -> null) : null +export source doingAns = visit >= 0 ? (await fetchCards("doing") -> null) : null +export source doneAns = visit >= 0 ? (await fetchCards("done") -> null) : null +// the answer names the card it is for, so an item add answered after another card was selected leaves it alone +export source checklist = selected == null ? null : { card: selected, items: await fetchChecklist(selected) } + +source moving: Move[] = [] +source renaming: Rename[] = [] +source archiving: number[] = [] +source adding: string[] = [] +source toggling: Toggle[] = [] +source itemAdding: ItemAdd[] = [] + +fn without(cs: Card[], id: number): Card[] => cs.filter(c => c.id != id) +fn step(cs: Card[], col: string, m: Move): Card[] => + m.col != col ? without(cs, m.id) : [...without(cs, m.id).slice(0, m.index), cs.find(c => c.id == m.id) ?? m.card, ...without(cs, m.id).slice(m.index)] +fn withCard(cs: Card[] | null, c: Card): Card[] | null => cs == null || cs.some(x => x.id == c.id) ? cs : [...cs, c] +fn counted(cs: Card[] | null, id: number): Card[] | null => cs == null ? cs : cs.map(c => c.id == id ? { ...c, total: c.total + 1 } : c) +fn withItem(l: List | null, card: number, it: Item): List | null => + l == null || l.card != card || l.items.some(x => x.id == it.id) ? l : { card: card, items: [...l.items, it] } +fn titleOf(rs: Rename[], id: number, title: string): string => rs.reduce((t, r) => r.id == id ? r.title : t, title) +fn doneOf(ts: Toggle[], card: number, item: number, done: boolean): boolean => ts.reduce((d, t) => t.card == card && t.item == item ? t.done : d, done) +type Writes = { moving: Move[]; renaming: Rename[]; archiving: number[]; adding: string[]; toggling: Toggle[]; itemAdding: ItemAdd[] } +fn shownCard(c: Card, w: Writes): Row => + ({ + key: `c${c.id}`, id: c.id, title: titleOf(w.renaming, c.id, c.title), + done: w.toggling.filter(t => t.card == c.id).reduce((n, t) => n + (t.done ? 1 : -1), c.done), + total: c.total + w.itemAdding.filter(a => a.card == c.id).length, adding: false, card: c, pos: 0, last: false, + }) +fn placed(cs: Card[], w: Writes): Row[] => cs.map((c, i) => ({ ...shownCard(c, w), pos: i, last: i + 1 == cs.length })) +fn lay(ans: Card[] | null, col: string, w: Writes): Row[] | null => + ans == null ? null : [ + ...placed(w.moving.reduce((cs, m) => step(cs, col, m), ans).filter(c => !w.archiving.includes(c.id)), w), + ...(col == "todo" ? w.adding.map((t, i) => ({ key: `a${i}`, id: -1, title: t, done: 0, total: 0, adding: true, card: { id: -1, title: t, done: 0, total: 0 }, pos: -1, last: false })) : []), + ] + +w = { moving: moving, renaming: renaming, archiving: archiving, adding: adding, toggling: toggling, itemAdding: itemAdding } +// null while the column's first answer is out +export todoRows = lay(todoAns, "todo", w) +export doingRows = lay(doingAns, "doing", w) +export doneRows = lay(doneAns, "done", w) + +export type ItemRow = { key: string; id: number; text: string; done: boolean; adding: boolean } +export items key key = checklist == null ? [] : [ + ...checklist.items.map(it => ({ key: `i${it.id}`, id: it.id, text: it.text, done: doneOf(toggling, checklist.card, it.id, it.done), adding: false })), + ...itemAdding.filter(a => a.card == checklist.card).map((a, i) => ({ key: `a${i}`, id: -1, text: a.text, done: false, adding: true })), +] + +// a write's re-ask (quiet, AC-4) of every column: each lays every write over its answer, and behind moves still +// out, or answered in another order, the server may hold the card in any of them +action reask() { + refresh todoAns + refresh doingAns + refresh doneAns +} + +export action openBoard() { + visit := visit + 1 + selected := null +} + +export action select(id: number) { + selected := id +} + +export action retry(col: string) { + if (col == "todo") { + affects todoAns + refresh todoAns + } else if (col == "doing") { + affects doingAns + refresh doingAns + } else { + affects doneAns + refresh doneAns + } +} + +// Each write reports a decline to `fail`, which the page that made it owns: a failure message lives with the visit, +// the write may outlive it. An accepted write re-asks what it guessed on. +export action move(card: Card, col: string, index: number, fail: Fail) { + try { + guess moving := ms => [...ms, { id: card.id, col: col, index: index, card: card }] + await moveCard(card.id, col, index) + await reask() + } catch (e) { + fail(e.message) + onFlush(() => { fail(e.message) }) + } +} + +export action add(title: string, fail: Fail) { + try { + guess adding := as => [...as, title] + let r = await addCard("todo", title) + todoAns := cs => withCard(cs, { id: r.id, title: r.title, done: 0, total: 0 }) + } catch (e) { + fail(e.message) + onFlush(() => { fail(e.message) }) + } +} + +export action rename(id: number, title: string, fail: Fail) { + try { + guess renaming := rs => [...rs, { id: id, title: title }] + await renameCard(id, title) + await reask() + } catch (e) { + fail(e.message) + onFlush(() => { fail(e.message) }) + } +} + +export action archive(id: number, fail: Fail) { + onFlush(() => { selected := null }) + try { + guess archiving := xs => [...xs, id] + await archiveCard(id) + await reask() + } catch (e) { + fail(e.message) + onFlush(() => { fail(e.message) }) + } +} + +export action toggle(card: number, item: number, done: boolean, fail: Fail) { + try { + guess toggling := ts => [...ts, { card: card, item: item, done: done }] + await toggleItem(card, item, done) + refresh checklist + await reask() + } catch (e) { + fail(e.message) + onFlush(() => { fail(e.message) }) + } +} + +export action addOneItem(card: number, text: string, fail: Fail) { + try { + guess itemAdding := xs => [...xs, { card: card, text: text }] + let it = await addItem(card, text) + checklist := l => withItem(l, card, it) + todoAns := cs => counted(cs, card) + doingAns := cs => counted(cs, card) + doneAns := cs => counted(cs, card) + } catch (e) { + fail(e.message) + onFlush(() => { fail(e.message) }) + } +} diff --git a/eval/results/r15prep/repro/hang-after-reask.trail b/eval/results/r15prep/repro/hang-after-reask.trail new file mode 100644 index 00000000..bbc342eb --- /dev/null +++ b/eval/results/r15prep/repro/hang-after-reask.trail @@ -0,0 +1 @@ +land fetchCards:doing ; land fetchCards:todo ; right #2 ; left #4 ; select #1 ; land fetchChecklist:1 ; toggle 1 ; rename Delta ; land moveCard:2,doing,2 ; tab archive ; tab board ; land moveCard:4,todo,1 ; land renameCard:1,Delta ; land toggleItem:1,12,true ; land fetchCards:doing diff --git a/eval/results/r15prep/repro/path-guess-row-leaves-truth.spec.tsx b/eval/results/r15prep/repro/path-guess-row-leaves-truth.spec.tsx new file mode 100644 index 00000000..62d4c1c0 --- /dev/null +++ b/eval/results/r15prep/repro/path-guess-row-leaves-truth.spec.tsx @@ -0,0 +1,31 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +// A keyed optimistic store over a truth signal. An action guesses one field of row #4 (b[i].col = "done") and waits. +// Meanwhile the truth drops row #4 (its column's re-ask failed, or another write took it off). Expected: the store +// shows the truth's rows, #4's guess having nothing to stand on (or #4 whole, if the guess keeps it). Observed? +import { test, expect } from "vitest"; +import { action, createSignal, createOptimisticStore, flush, For } from "solid-js"; +import { render } from "../src/index.js"; +const tick = async () => { for (let i = 0; i < 4; i++) { await new Promise(r => setTimeout(r, 0)); flush(); } }; +type Row = { id: number; title: string; col: string }; +test("a row guessed by path leaves the truth while its action is out", async () => { + const asks: { key: string; res: (v: any) => void }[] = []; + const ask = (key: string) => new Promise<any>(res => asks.push({ key, res })); + const c = document.body.appendChild(document.createElement("div")); + const [ans, setAns] = createSignal<Row[]>([{ id: 3, title: "R", col: "doing" }, { id: 4, title: "D", col: "doing" }, { id: 5, title: "K", col: "done" }]); + const [board, guess] = createOptimisticStore<Row[]>(() => ans().map(r => ({ ...r })), [], { key: "id" }); + const move = action(function* (id: number) { + guess(b => { const i = b.findIndex(r => r.id === id); b[i].col = "done"; }); + yield ask(`move:${id}`); + }); + render(() => <p><For each={board}>{r => <span>{`#${r.id} ${r.title} ${r.col}; `}</span>}</For></p>, c); + await tick(); + move(4); await tick(); + const guessed = c.textContent; + setAns([{ id: 5, title: "K", col: "done" }]); await tick(); // the truth no longer has #3 or #4 + const after = c.textContent; + console.log("guessed:", guessed, "| truth without #3,#4:", after); + expect(after).not.toMatch(/#undefined|# /); +}); diff --git a/eval/results/r15prep/repro/store-read-then-guess-entangles.rx b/eval/results/r15prep/repro/store-read-then-guess-entangles.rx new file mode 100644 index 00000000..1a7400f6 --- /dev/null +++ b/eval/results/r15prep/repro/store-read-then-guess-entangles.rx @@ -0,0 +1,35 @@ +// D-33 (#4788, by design in Solid): action 4 reads board rows that action 3 guessed (the filter) and then guesses beside it, +// so the two frames entangle: 3 declined stays in Done until 4 ends. Moving the read to the handler (pass n in) frees it. +// Not a bug; a guide gap. Run: probe-rx.mjs <desk with this as src/main.rx> store-read-then-guess-entangles.steps.mjs +import pure { fetchCards } from "./api" +import { moveCard, Card } from "./api" +fn rowsOf(cs: Card[] | null, col: string): Row[] => + cs == null ? [] : cs.map((c, i) => ({ id: c.id, title: c.title, col: col, pos: i })) +type Row = { id: number; title: string; col: string; pos: number } +export default component App() { + source ans = await fetchCards("doing") -> null + source ans2 = await fetchCards("done") -> null + board key id = [...rowsOf(ans catch (e) => null, "doing"), ...rowsOf(ans2 catch (e) => null, "done")] + action move(id: number) { + try { + let i = board.findIndex(r => r.id == id) + let n = board.filter(r => r.col == "done").length + guess board[i].col := "done" + guess board[i].pos := 9 + await moveCard(id, "done", n) + } catch (e) { + log(e.message) + } + } + errored fallback(e) { <p>err</p> } { + <ul> + {for r in board.filter(r => r.col == "doing") { + <li><span data-testid="card">#{r.id} {r.col}</span> <button data-testid="right" onClick={() => { move(r.id) }}>right</button></li> + }} + | + {for r in board.filter(r => r.col == "done") { + <li><span data-testid="card">#{r.id} {r.col}</span> <button data-testid="right" onClick={() => { move(r.id) }}>right</button></li> + }} + </ul> + } +} diff --git a/eval/results/r15prep/repro/store-read-then-guess-entangles.steps.mjs b/eval/results/r15prep/repro/store-read-then-guess-entangles.steps.mjs new file mode 100644 index 00000000..f5236f6c --- /dev/null +++ b/eval/results/r15prep/repro/store-read-then-guess-entangles.steps.mjs @@ -0,0 +1,8 @@ +import { land, fail, DOING, DONE, near } from "/tmp/claude-0/runs/r15prep/board-rx3/scratch/common.mjs"; +export default [ + ["load", async p => { await land("fetchCards:doing", DOING)(p); await land("fetchCards:done", DONE)(p); }], + ["3 right", p => near(p, 3, "right")], + ["4 right", p => near(p, 4, "right")], + ["fail 3", fail("moveCard:3,done,1", "nope")], + ["land 4", land("moveCard:4,done,2", {})], +]; From fbf898d17886adcde8048a39a0185edaf887ea41 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 14 Nov 2026 22:33:13 +0000 Subject: [PATCH 20/32] eval: idiomatic one-store .rx board (rx-store) and its repros (solidjs/solid#4874, rx runtime catchRead, pending probe) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- eval/reference/board/rx-store/FUZZ-21-60.txt | 17 + eval/reference/board/rx-store/GRADE.txt | 14 + eval/reference/board/rx-store/archive.rx | 46 +++ eval/reference/board/rx-store/board.rx | 350 ++++++++++++++++++ eval/reference/board/rx-store/main.rx | 21 ++ .../repro/pending-probe-inside-held-region.rx | 31 ++ ...pending-probe-inside-held-region.steps.mjs | 7 + 7 files changed, 486 insertions(+) create mode 100644 eval/reference/board/rx-store/FUZZ-21-60.txt create mode 100644 eval/reference/board/rx-store/GRADE.txt create mode 100644 eval/reference/board/rx-store/archive.rx create mode 100644 eval/reference/board/rx-store/board.rx create mode 100644 eval/reference/board/rx-store/main.rx create mode 100644 eval/results/r15prep/repro/pending-probe-inside-held-region.rx create mode 100644 eval/results/r15prep/repro/pending-probe-inside-held-region.steps.mjs diff --git a/eval/reference/board/rx-store/FUZZ-21-60.txt b/eval/reference/board/rx-store/FUZZ-21-60.txt new file mode 100644 index 00000000..5e5cf664 --- /dev/null +++ b/eval/reference/board/rx-store/FUZZ-21-60.txt @@ -0,0 +1,17 @@ +board-rx3: 8/40 seeds found a hole + seed 24 step 26: duplicate card: #1 shows in todo and doing + land fetchCards:done ; land fetchCards:doing ; land fetchCards:todo ; add Bravo 1 ; land addCard:todo,Bravo 1 ; right #10 ; add Bravo 2 ; land addCard:todo,Bravo 2 ; up #10 ; land moveCard:10,doing,2 ; add Alpha 3 ; land fetchCards:doing ; left #3 ; add Bravo 4 ; down #11 ; right #4 ; fail addCard:todo,Bravo 4 ; right #3 ; add Delta 5 ; land addCard:todo,Delta 5 ; right #10 ; land moveCard:10,done,2 ; down #5 ; down #2 ; fail moveCard:2,todo,2 ; right #1 + seed 25 step 11: duplicate card: "Delta 1 (adding…)" shows 1 time(s) with 0 add(s) of it in the air, beside its added card + tab archive ; tab board ; land fetchCards:todo ; up #2 ; land fetchCards:doing ; fail moveCard:2,todo,0 ; land fetchCards:done ; add Delta 1 ; add Alpha 2 ; land fetchArchived: ; land addCard:todo,Delta 1 + seed 28 step 32: duplicate card: #4 shows in doing and doing + tab archive ; tab board ; land fetchCards:doing ; land fetchArchived: ; add Delta 1 ; land fetchCards:todo ; select #1 ; select #2 ; land fetchChecklist:1 ; land fetchChecklist:2 ; rename Alpha ; rename Delta ; land renameCard:2,Delta ; rename Alpha ; land fetchCards:todo ; rename Bravo ; fail renameCard:2,Alpha ; fail renameCard:2,Bravo ; up #4 ; up #2 ; add Delta 2 ; fail moveCard:2,todo,0 ; left #4 ; archive #2 ; land addCard:todo,Delta 2 ; land archiveCard:2 ; land moveCard:4,todo,3 ; la + seed 36 step 28: two views: card "#5 Delta · 3/3", detail "#5 Kickoffin Doing rename archivechecklist error: declined" + tab archive ; land fetchCards:todo ; tab board ; tab archive ; land fetchArchived: ; fail fetchCards:todo ; tab board ; add Delta 1 ; add Alpha 2 ; land fetchCards:doing ; land fetchCards:done ; land fetchCards:todo ; left #5 ; land moveCard:5,doing,2 ; land fetchCards:doing ; land fetchCards:done ; select #5 ; add Delta 3 ; land addCard:todo,Delta 3 ; down #1 ; down #3 ; add Bravo 4 ; down #10 ; land moveCard:10,todo,3 ; fail fetchChecklist:5 ; rename Delta + seed 39 step 6: duplicate card: "Delta 1 (adding…)" shows 1 time(s) with 0 add(s) of it in the air, beside its added card + land fetchCards:done ; land fetchCards:doing ; land fetchCards:todo ; add Delta 1 ; add Bravo 2 ; land addCard:todo,Delta 1 + seed 46 step 14: two views: card "#5 Bravo · 3/3", detail "#5 Kickoffin Done rename archive[x] invite toggle[x] agenda toggle[x] notes togg" + land fetchCards:done ; select #5 ; select #5 ; land fetchChecklist:5 ; land fetchCards:doing ; land fetchCards:todo ; down #3 ; right #1 ; left #3 ; add Delta 1 ; land moveCard:3,todo,1 ; down #4 ; up #3 ; rename Bravo + seed 48 step 32: card count: card "#5 Bravo · 3/4", server 3/3 with 0 toggle(s) and 0 item add(s) of it in the air + add Alpha 1 ; land fetchCards:doing ; land fetchCards:todo ; left #3 ; right #3 ; land moveCard:3,doing,1 ; land fetchCards:done ; land moveCard:3,todo,2 ; select #3 ; land fetchCards:doing ; tab archive ; land fetchChecklist:3 ; tab board ; add Bravo 2 ; land fetchArchived: ; add Bravo 3 ; land fetchCards:done ; select #5 ; select #5 ; land fetchCards:todo ; land fetchCards:doing ; land fetchChecklist:5 ; rename Bravo ; up #3 ; fail renameCard:5,Bravo ; land moveCard:3,todo,1 ; rename Bravo + seed 57 step 28: count: card "#1 Alpha · 1/3", progress "0/3 done" + add Alpha 1 ; add Bravo 2 ; land fetchCards:doing ; down #3 ; land fetchCards:todo ; left #3 ; add Delta 3 ; left #4 ; right #3 ; select #1 ; rename Alpha ; land moveCard:4,todo,4 ; land fetchChecklist:1 ; up #4 ; left #3 ; down #4 ; add-item ship 4 ; select #4 ; right #3 ; land fetchCards:doing ; down #2 ; land addCard:todo,Delta 3 ; land moveCard:4,todo,3 ; fail moveCard:3,todo,4 ; land renameCard:1,Alpha ; toggle 0 diff --git a/eval/reference/board/rx-store/GRADE.txt b/eval/reference/board/rx-store/GRADE.txt new file mode 100644 index 00000000..e8f2fbab --- /dev/null +++ b/eval/reference/board/rx-store/GRADE.txt @@ -0,0 +1,14 @@ +== hidden +✗ two adds show both, each taking its own id +44/45 passed +== overlap +✗ a toggle declined after another card was selected puts the first card's count back +25/26 passed +== fuzz +board-rx3: 3/20 seeds found a hole + seed 11 step 46: duplicate card: "# · / (adding…)" shows 1 time(s) with 0 add(s) of it in the air + add Bravo 1 ; (move failed: locator.click: Timeout 2000ms exceeded.) ; tab archive ; tab board ; add Bravo 2 ; (move failed: locator.click: Timeout 2000ms exceeded.) ; tab archive ; tab board ; add Alpha 3 ; (move failed: locator.click: Timeout 2000ms exceeded.) ; land fetchCards:doing ; select #4 ; down #3 ; rename Alpha ; up #3 ; archive #4 ; land fetchCards:done ; land moveCard:3,doing,1 ; + seed 14 step 17: count: card "#2 Fix login · 1/4", progress "2/4 done" + add Bravo 1 ; (move failed: locator.click: Timeout 2000ms exceeded.) ; land fetchCards:todo ; select #2 ; up #2 ; select #2 ; land fetchCards:done ; land fetchChecklist:2 ; down #2 ; add-item ship 2 ; land fetchCards:doing ; land moveCard:2,todo,0 ; land moveCard:2,todo,1 ; toggle 2 ; right #3 ; select #4 ; toggle 0 + seed 15 step 16: duplicate card: "Alpha 1 (adding…)" shows 1 time(s) with 0 add(s) of it in the air, beside its added card + land fetchCards:doing ; select #4 ; up #4 ; land fetchCards:done ; fail moveCard:4,doing,0 ; land fetchCards:todo ; rename Bravo ; fail fetchChecklist:4 ; select #4 ; land renameCard:4,Bravo ; add Alpha 1 ; add Alpha 2 ; land fetchCards:doing ; add Bravo 3 ; select #5 ; land addCard:todo,Alpha 1 diff --git a/eval/reference/board/rx-store/archive.rx b/eval/reference/board/rx-store/archive.rx new file mode 100644 index 00000000..f2102204 --- /dev/null +++ b/eval/reference/board/rx-store/archive.rx @@ -0,0 +1,46 @@ +// The archive page: one optimistic store over the server's archive, as the board page is. A restore's guess is a +// path write into it; an accepted restore re-asks the archive and holds until it lands. Everything here is the +// visit's: the page is mounted only while it is open. + +import pure { fetchArchived } from "./api" +import { restoreCard } from "./api" + +type Shown = { id: number; title: string; gone: boolean } + +export component ArchivePage() { + archived key id = (await fetchArchived()).map(a => ({ id: a.id, title: a.title, gone: false })) + source restoreError: string := "" + + action restore(id: number) { + try { + let i = archived.findIndex(a => a.id == id) + guess archived[i].gone := true + onFlush(() => { restoreError := "" }) + await restoreCard(id) + refresh archived + } catch (e) { + restoreError := e.message + onFlush(() => { restoreError := e.message }) + } + } + + <section> + <div data-testid="archived"> + {loading fallback { <p>loading archive</p> } { + {errored fallback(e) { <p>archive error: {e.message}</p> } { + <ul> + {for a in archived.filter(a => !a.gone) { + <li> + <span data-testid="archived-card">#{a.id} {a.title}</span>{" "} + <button data-testid="restore" onClick={() => { restore(a.id) }}>restore</button> + </li> + }} + </ul> + }} + }} + </div> + {if restoreError != "" { + <p data-testid="restore-error">restore error: {restoreError}</p> + }} + </section> +} diff --git a/eval/reference/board/rx-store/board.rx b/eval/reference/board/rx-store/board.rx new file mode 100644 index 00000000..478931a0 --- /dev/null +++ b/eval/reference/board/rx-store/board.rx @@ -0,0 +1,350 @@ +// The board page, in the shape of Solid's own board example: one optimistic store over the server's board. +// +// The server answers per column, so its board is three questions. The store, `board`, is those answers laid flat: +// one row per card, naming its column and its place, keyed by id. The columns on screen are derived from it by +// filtering and sorting, so moving a card is one field write on one row, and a card shows in exactly one column. +// +// Every write has the same life. Its guess is a path write into `board` (or into the checklist, the other store), +// shown at once. The write goes to the server; when it is accepted, the action re-asks the answers it changed, and +// holds until they land (D-34), so the guess is dropped onto data that already agrees with it. When it is declined +// there was only ever the guess, and removing it is the undo. Errors are plain client state under the guesses: a +// refusal is a fact about one request, and no answer has a place for it. +// +// An add has no id until the server answers. Its placeholder is guessed into the store under a key minted here, for +// this visit only, and is inert (no move buttons, not selectable). The server's answer is the new card itself, so the +// action records it in To do's answer (a write whose answer is the new value re-asks nothing) and ends: in one frame +// the placeholder goes with its guess and the real card shows from the truth. An added item is the same over the +// checklist's answer. +// +// Everything here is the visit's: the page is mounted only while it is open, so leaving it discards the store, its +// guesses and its errors, and opening it again asks the server again. + +import pure { fetchCards, fetchChecklist } from "./api" +import { moveCard, renameCard, addCard, archiveCard, toggleItem, addItem, Card, Item } from "./api" + +export type Row = { id: number; title: string; done: number; total: number; col: string; pos: number; adding: boolean; gone: boolean } + +fn colName(c: string): string => c == "todo" ? "To do" : c == "doing" ? "Doing" : "Done" +fn rowsOf(cs: Card[] | null, col: string): Row[] => + cs == null ? [] : cs.map((c, i) => ({ id: c.id, title: c.title, done: c.done, total: c.total, col: col, pos: i, adding: false, gone: false })) +type List = { card: number; items: Item[] } +fn withCard(cs: Card[] | null, c: Card): Card[] | null => cs == null || cs.some(x => x.id == c.id) ? cs : [...cs, c] +fn counted(cs: Card[] | null, id: number): Card[] | null => cs == null ? cs : cs.map(c => c.id == id ? { ...c, total: c.total + 1 } : c) +fn withItem(l: List | null, card: number, it: Item): List | null => + l == null || l.card != card || l.items.some(x => x.id == it.id) ? l : { card: card, items: [...l.items, it] } +fn has(cs: Card[] | null, id: number): boolean => cs != null && cs.some(c => c.id == id) +fn inCol(b: Row[], col: string): Row[] => b.filter(r => r.col == col && !r.gone).sort((x, y) => x.pos - y.pos) +// the place between the rows at i-1 and i (the column without the card being moved), as the example's keyAt +fn placeAt(rows: Row[], i: number): number => + rows.length == 0 ? 0 : i <= 0 ? rows[0].pos - 1 : i >= rows.length ? rows[rows.length - 1].pos + 1 : (rows[i - 1].pos + rows[i].pos) / 2 + +// Where a move puts a card, worked out from the board as shown: `col` set, to the bottom of that column; `col` empty, +// `by` places up or down in its own. +type Place = { col: string; index: number; pos: number } +fn placeOf(b: Row[], id: number, col: string, by: number): Place => + col != "" ? ({ col: col, index: inCol(b, col).length, pos: placeAt(inCol(b, col), inCol(b, col).length) }) : stepped(b, id, b.find(r => r.id == id)?.col ?? "todo", by) +fn stepped(b: Row[], id: number, col: string, by: number): Place => + ({ col: col, index: inCol(b, col).findIndex(r => r.id == id) + by, + pos: placeAt(inCol(b, col).filter(r => r.id != id), inCol(b, col).findIndex(r => r.id == id) + by) }) + +component Column(props: { + col: string; ans: Card[] | null; rows: Row[]; retrying: boolean; left: string; right: string; + leftOpen: boolean; rightOpen: boolean; onRetry: () => void; onSelect: (id: number) => void; onMove: (id: number, col: string, by: number) => void; +}) { + <section data-testid={`col-${props.col}`} style="min-width: 14rem"> + {errored fallback(e) { + <h2 data-testid={`head-${props.col}`}>{colName(props.col)}</h2> + {if props.retrying { + <p>loading</p> + } else { + <p>error: {e.message}{" "}<button data-testid={`retry-${props.col}`} onClick={() => { props.onRetry() }}>retry</button></p> + }} + } { + {if props.ans == null { + <h2 data-testid={`head-${props.col}`}>{colName(props.col)}</h2> + <p>loading</p> + } else { + <h2 data-testid={`head-${props.col}`}>{colName(props.col)} ({props.rows.length})</h2> + <ul> + {for r in props.rows { + <li> + {if r.adding { + <span data-testid="card">{r.title} (adding…)</span> + } else { + <button data-testid="card" onClick={() => { props.onSelect(r.id) }}>#{r.id} {r.title} · {r.done}/{r.total}</button> + {if props.left != "" { + {" "}<button data-testid="left" disabled={!props.leftOpen} onClick={() => { props.onMove(r.id, props.left, 0) }}>left</button> + }} + {if props.right != "" { + {" "}<button data-testid="right" disabled={!props.rightOpen} onClick={() => { props.onMove(r.id, props.right, 0) }}>right</button> + }} + {if props.rows[0].id != r.id { + {" "}<button data-testid="up" onClick={() => { props.onMove(r.id, "", -1) }}>up</button> + }} + {if props.rows[props.rows.length - 1].id != r.id { + {" "}<button data-testid="down" onClick={() => { props.onMove(r.id, "", 1) }}>down</button> + }} + }} + </li> + }} + </ul> + }} + }} + </section> +} + +export component BoardPage() { + // 1. The server's truth: one question per column, `null` until its first answer, so one column loading or failing + // never holds another. Sources with a question: an accepted add writes its answer here (the answer is the new card), + // until the next re-ask brings the server's list. + source todoAns = await fetchCards("todo") -> null + source doingAns = await fetchCards("doing") -> null + source doneAns = await fetchCards("done") -> null + + // 2. The one store over it, flat; every guess is a path write into it. + board key id = [...rowsOf(todoSeen, "todo"), ...rowsOf(doingSeen, "doing"), ...rowsOf(doneSeen, "done")] + todo = inCol(board, "todo") + doing = inCol(board, "doing") + done = inCol(board, "done") + // each answer as the other panes read it: `null` while it loads or after it failed + todoSeen = todoAns catch (e) => null + doingSeen = doingAns catch (e) => null + doneSeen = doneAns catch (e) => null + todoUp = todoSeen != null + doingUp = doingSeen != null + doneUp = doneSeen != null + // the work-in-progress limit counts the cards Doing shows, moves still out included + intoDoing = doingUp && doing.length < 3 + // the column whose answer holds a card: what a write to it changes on the server, whatever the guesses show + fn home(id: number): string => has(todoSeen, id) ? "todo" : has(doingSeen, id) ? "doing" : "done" + + // The selection and its checklist, the other store. + source selected: number | null := null + // the answer names its card, so an item add answered after another card was selected leaves it alone + source listAns = selected == null ? null : { card: selected, items: await fetchChecklist(selected) } + checklist key id = listAns == null ? [] : listAns.items.map(it => ({ id: it.id, text: it.text, done: it.done, adding: false })) + sel = selected == null ? null : board.find(r => r.id == selected && !r.gone && !r.adding) ?? null + + // Inputs, and what went wrong: plain client state of this visit. + source newCard: string := "" + source titleInput: string := "" + source itemInput: string := "" + source minted: number := 0 + source moveError: string := "" + source addError: string := "" + source archiveError: string := "" + source renameError: string := "" + source itemError: string := "" + + // selecting starts the detail as it starts: its inputs empty, its errors gone + action pick(id: number) { + selected := id + titleInput := "" + itemInput := "" + renameError := "" + itemError := "" + } + + // An accepted card write ends by re-asking the answers it changed: for a move the one that held the card and the + // one it joined, for an edit the one that holds it. + action reask(a: string, b: string) { + if (a == "todo" || b == "todo") { refresh todoAns } + if (a == "doing" || b == "doing") { refresh doingAns } + if (a == "done" || b == "done") { refresh doneAns } + } + + action retry(col: string) { + if (col == "todo") { + affects todoAns + refresh todoAns + } else if (col == "doing") { + affects doingAns + refresh doingAns + } else { + affects doneAns + refresh doneAns + } + } + + // The place is worked out by the click, from the board as shown, as the example's `nudge` does: an action that read + // another action's guess and guessed beside it would be one change with it (D-33). + action move(id: number, from: string, col: string, index: number, pos: number) { + try { + let i = board.findIndex(r => r.id == id) + guess board[i].col := col + guess board[i].pos := pos + onFlush(() => { moveError := "" }) + await moveCard(id, col, index) + await reask(from, col) + } catch (e) { + moveError := e.message + onFlush(() => { moveError := e.message }) + } + } + + action add(key: number, title: string, at: number, pos: number) { + try { + guess board[at] := { id: key, title: title, done: 0, total: 0, col: "todo", pos: pos, adding: true, gone: false } + onFlush(() => { addError := "" }) + let r = await addCard("todo", title) + todoAns := cs => withCard(cs, { id: r.id, title: r.title, done: 0, total: 0 }) + } catch (e) { + addError := e.message + onFlush(() => { addError := e.message }) + } + } + + action rename(id: number, title: string, col: string) { + let i = board.findIndex(r => r.id == id) + try { + guess board[i].title := title + onFlush(() => { renameError := "" }) + await renameCard(id, title) + await reask(col, col) + } catch (e) { + renameError := e.message + onFlush(() => { renameError := e.message }) + } + } + + action archive(id: number, col: string) { + let i = board.findIndex(r => r.id == id) + try { + guess board[i].gone := true + onFlush(() => { + selected := null + archiveError := "" + }) + await archiveCard(id) + await reask(col, col) + } catch (e) { + archiveError := e.message + onFlush(() => { archiveError := e.message }) + } + } + + action toggle(card: number, item: number, to: boolean, col: string) { + try { + let i = board.findIndex(r => r.id == card) + let j = checklist.findIndex(it => it.id == item) + guess checklist[j].done := to + guess board[i].done := n => n + (to ? 1 : -1) + onFlush(() => { itemError := "" }) + await toggleItem(card, item, to) + refresh listAns + await reask(col, col) + } catch (e) { + itemError := e.message + onFlush(() => { itemError := e.message }) + } + } + + action addLine(card: number, key: number, text: string) { + let i = board.findIndex(r => r.id == card) + try { + guess checklist := ls => [...ls, { id: key, text: text, done: false, adding: true }] + guess board[i].total := n => n + 1 + onFlush(() => { itemError := "" }) + let it = await addItem(card, text) + listAns := l => withItem(l, card, it) + todoAns := cs => counted(cs, card) + doingAns := cs => counted(cs, card) + doneAns := cs => counted(cs, card) + } catch (e) { + itemError := e.message + onFlush(() => { itemError := e.message }) + } + } + + <div> + <div style="display: flex; gap: 1.5rem; align-items: flex-start"> + <div> + <Column col="todo" ans={todoAns} rows={todo} retrying={pending? todoAns} left="" right="doing" leftOpen={false} rightOpen={intoDoing} + onRetry={() => { retry("todo") }} onSelect={id => { pick(id) }} onMove={(id, c, by) => { let p = placeOf(board, id, c, by); move(id, home(id), p.col, p.index, p.pos) }} /> + <div> + <input data-testid="new-card" value={newCard} onInput={e => { newCard := e.target.value }} placeholder="new card" />{" "} + <button data-testid="add-card" disabled={!todoUp} onClick={() => { + if (newCard != "") { + let t = newCard + let key = -(minted + 1) + onFlush(() => { + newCard := "" + minted := n => n + 1 + }) + add(key, t, board.length, placeAt(todo, todo.length)) + } + }}>add</button> + {if addError != "" { + <p data-testid="add-error">add error: {addError}</p> + }} + </div> + </div> + <Column col="doing" ans={doingAns} rows={doing} retrying={pending? doingAns} left="todo" right="done" leftOpen={todoUp} rightOpen={doneUp} + onRetry={() => { retry("doing") }} onSelect={id => { pick(id) }} onMove={(id, c, by) => { let p = placeOf(board, id, c, by); move(id, home(id), p.col, p.index, p.pos) }} /> + <Column col="done" ans={doneAns} rows={done} retrying={pending? doneAns} left="doing" right="" leftOpen={intoDoing} rightOpen={false} + onRetry={() => { retry("done") }} onSelect={id => { pick(id) }} onMove={(id, c, by) => { let p = placeOf(board, id, c, by); move(id, home(id), p.col, p.index, p.pos) }} /> + <section data-testid="detail" style="min-width: 16rem"> + {if sel == null { + <p>select a card</p> + } else { + <h2>#{sel.id} {sel.title}</h2> + <p>in {colName(sel.col)}</p> + <div> + <input data-testid="title-input" value={titleInput} onInput={e => { titleInput := e.target.value }} placeholder="new title" />{" "} + <button data-testid="rename" onClick={() => { + if (titleInput != "") { + let t = titleInput + onFlush(() => { titleInput := "" }) + rename(sel.id, t, home(sel.id)) + } + }}>rename</button>{" "} + <button data-testid="archive" onClick={() => { archive(sel.id, home(sel.id)) }}>archive</button> + </div> + {if renameError != "" { + <p>rename error: {renameError}</p> + }} + <div data-testid="checklist"> + {loading on(selected) fallback { <p>loading checklist</p> } { + {errored fallback(e) { <p>checklist error: {e.message}</p> } { + <ul> + {for it in checklist { + <li> + {if it.adding { + <span data-testid="item">[ ] {it.text} (adding…)</span> + } else { + <span data-testid="item">[{it.done ? "x" : " "}] {it.text}</span>{" "} + <button data-testid="toggle" disabled={pending? checklist} onClick={() => { toggle(sel.id, it.id, !it.done, home(sel.id)) }}>toggle</button> + }} + </li> + }} + </ul> + <p data-testid="progress">{checklist.filter(it => it.done).length}/{checklist.length} done</p> + <input data-testid="item-input" value={itemInput} onInput={e => { itemInput := e.target.value }} placeholder="new item" />{" "} + <button data-testid="add-item" disabled={pending? checklist} onClick={() => { + if (itemInput != "") { + let t = itemInput + let key = -(minted + 1) + onFlush(() => { + itemInput := "" + minted := n => n + 1 + }) + addLine(sel.id, key, t) + } + }}>add item</button> + }} + }} + </div> + {if itemError != "" { + <p>item error: {itemError}</p> + }} + }} + </section> + </div> + {if moveError != "" { + <p data-testid="move-error">move error: {moveError}</p> + }} + {if archiveError != "" { + <p data-testid="archive-error">archive error: {archiveError}</p> + }} + </div> +} diff --git a/eval/reference/board/rx-store/main.rx b/eval/reference/board/rx-store/main.rx new file mode 100644 index 00000000..a6b7cdf1 --- /dev/null +++ b/eval/reference/board/rx-store/main.rx @@ -0,0 +1,21 @@ +// The sprint board: two pages behind tabs. Only the open page is mounted, so leaving one discards it, and opening it +// asks for its data again. + +import { BoardPage } from "./board.rx" +import { ArchivePage } from "./archive.rx" + +export default component App() { + source page: string := "board" + + <div> + <nav> + <button data-testid="tab-board" onClick={() => { page := "board" }}>Board</button>{" "} + <button data-testid="tab-archive" onClick={() => { page := "archive" }}>Archive</button> + </nav> + {if page == "board" { + <BoardPage /> + } else { + <ArchivePage /> + }} + </div> +} diff --git a/eval/results/r15prep/repro/pending-probe-inside-held-region.rx b/eval/results/r15prep/repro/pending-probe-inside-held-region.rx new file mode 100644 index 00000000..6b9c4d86 --- /dev/null +++ b/eval/results/r15prep/repro/pending-probe-inside-held-region.rx @@ -0,0 +1,31 @@ +// H-11 says: a guess on `list` (whose question reads `sel`) holds a later `sel := 1`, and while it does +// `pending? list` is true. Observed: the probe stays false, so `disabled={pending? list}` (AC-3a's repair) never +// disables, and a second click guesses on a value that is loading a new question. +// Observed (probe-rx): after "select 1" the probe outside the region reads "pending: yes", the same probe in the +// toggles' disabled={pending? list} inside `loading on(sel)` stays false: [false, false]. Board seed 14. +import pure { fetchChecklist } from "./api" +import { toggleItem } from "./api" +export default component App() { + source sel: number := 2 + list key id = await fetchChecklist(sel) + action flip(item: number, to: boolean) { + try { + let j = list.findIndex(it => it.id == item) + guess list[j].done := to + await toggleItem(sel, item, to) + } catch (e) { + log(e.message) + } + } + <div> + <button data-testid="sel1" onClick={() => { sel := 1 }}>sel 1</button> + <p data-testid="probe">pending: {pending? list ? "yes" : "no"} sel: {sel}</p> + {loading on(sel) fallback { <p>loading</p> } { + {errored fallback(e) { <p>err</p> } { + {for it in list { + <button data-testid="toggle" disabled={pending? list} onClick={() => { flip(it.id, !it.done) }}>{it.text}:{it.done ? "x" : "_"}</button> + }} + }} + }} + </div> +} diff --git a/eval/results/r15prep/repro/pending-probe-inside-held-region.steps.mjs b/eval/results/r15prep/repro/pending-probe-inside-held-region.steps.mjs new file mode 100644 index 00000000..03c6f8c1 --- /dev/null +++ b/eval/results/r15prep/repro/pending-probe-inside-held-region.steps.mjs @@ -0,0 +1,7 @@ +import { land } from "/tmp/claude-0/runs/r15prep/board-rx3/scratch/common.mjs"; +export default [ + ["load 2", land("fetchChecklist:2", [{ id: 21, text: "repro", done: true }, { id: 22, text: "patch", done: false }])], + ["toggle patch", p => p.locator("[data-testid=toggle]").nth(1).click()], + ["select 1 (held by the toggle's guess)", p => p.click("[data-testid=sel1]")], + ["toggle is disabled?", async p => console.log(" disabled:", await p.locator("[data-testid=toggle]").evaluateAll(bs => bs.map(b => b.disabled)))], +]; From 27377f7f938661b4efc96632fb1fedecd8a633db Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 14 Nov 2026 22:50:13 +0000 Subject: [PATCH 21/32] catch reads in handlers are snapshots; ARGUMENT_COUNT and props of C(); guide: one store over the server - Runtime: `catchRead` with no observer (a component fn called from a handler or action) is a snapshot (04 R-8): the committed value, or the alternative on a matching failure. A fresh error boundary there had no value while a refresh was out and threw NotReadyError (S-catch-snapshot). - Checker (ref/ and crate): ARGUMENT_COUNT (06 AC-1f) for a call of a declared action with the wrong number of arguments; `props` in `component C()` is UNBOUND (03 RG-4b). AST gains `noProps`. S-check-1 rejected variants, a check.rs unit test, guide/diagnostics.md sections. - pending? inside a held `loading on(sel)` region reads false: Solid's, under 05 DV-22 (plain-Solid repro saved). Guide and GUESS_WHILE_LOADING text say to test `pending? x` in the handler too (R-12). - guide/async.md: "One store over the server". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- CHANGELOG.md | 11 ++++ compiler/src/ast.rs | 5 +- compiler/src/check.rs | 62 +++++++++++++++++-- compiler/src/parse.rs | 3 +- .../catch-read-in-handler-during-refresh.rx | 1 + .../repro/pending-probe-inside-held-region.rx | 2 + .../pending-probe-inside-held-region.spec.tsx | 51 +++++++++++++++ guide/async.md | 27 +++++++- guide/diagnostics.md | 45 ++++++++++++++ guide/reference.md | 1 + ref/compile/rt-server.ts | 6 +- ref/compile/rt-web.ts | 8 ++- ref/compile/rt.ts | 8 ++- ref/src/ast.ts | 2 +- ref/src/check.ts | 11 +++- ref/src/parse.ts | 3 +- scenarios/S-catch-snapshot.md | 41 ++++++++++++ scenarios/S-check-1.md | 24 +++++++ spec/03-regions.md | 4 +- spec/05-holds.md | 5 ++ spec/06-stores.md | 8 +++ spec/99-diagnostics.md | 3 +- 22 files changed, 311 insertions(+), 20 deletions(-) create mode 100644 eval/results/r15prep/repro/pending-probe-inside-held-region.spec.tsx create mode 100644 scenarios/S-catch-snapshot.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 72ea93c6..e9d09d38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,17 @@ row, so a row's readers re-run only when its own leaves change (#51). - The guide's action example (`guide/async.md` §3) lost an error thrown before the first `await`: the `onFlush` clear ran after the catch. The catch now writes the error through `onFlush` as well (#52). +- A `catch` expression read with no observer (a component `fn` called from a handler or an action) built a fresh error + boundary, which had no value while a `refresh` was out, so the click threw `NotReadyError` and was lost. It is now a + snapshot: the committed value, or the alternative on a matching failure (R-8, S-catch-snapshot). + +**New** + +- `ARGUMENT_COUNT` (AC-1f): a call of an action declared in the module with another number of arguments than its + parameters is refused. +- `props` in a component declared without props (`component C()`) is `UNBOUND` (RG-4b); it read `undefined`. +- `guide/async.md`: "One store over the server"; the `GUESS_WHILE_LOADING` advice says what holds when a selection + changes while a guess is out (05 DV-22). ## 0.3.1 diff --git a/compiler/src/ast.rs b/compiler/src/ast.rs index 66bdff89..79e82bb7 100644 --- a/compiler/src/ast.rs +++ b/compiler/src/ast.rs @@ -216,7 +216,7 @@ pub enum Decl { TypeDecl { name: String, line: usize, span: Span, text: String, exported: bool }, Effect { inputs: Vec<Expr>, body: Vec<Stmt>, line: usize, span: Span }, Fn { name: String, params: Vec<Param>, decls: Vec<Decl>, tail: Expr, line: usize, span: Span, ret: Option<String>, exported: bool }, - Component { name: String, props: String, decls: Vec<Decl>, view: Vec<Markup>, line: usize, span: Span, props_type: Option<String>, exported: bool, is_default: bool }, + Component { name: String, props: String, decls: Vec<Decl>, view: Vec<Markup>, line: usize, span: Span, props_type: Option<String>, no_props: bool, exported: bool, is_default: bool }, Action { name: String, params: Vec<String>, ptypes: Vec<Option<String>>, body: Vec<Stmt>, line: usize, span: Span, exported: bool }, Server { name: String, params: Vec<String>, ptypes: Vec<Option<String>>, ret: Option<String>, body: Vec<Stmt>, line: usize, span: Span, exported: bool }, Provide { name: String, e: Expr, line: usize, span: Span }, @@ -584,7 +584,7 @@ impl Decl { ("ret", os(ret)), ("exported", flag(*exported)), ]), - Decl::Component { name, props, decls, view, line, props_type, exported, is_default, .. } => obj(vec![ + Decl::Component { name, props, decls, view, line, props_type, no_props, exported, is_default, .. } => obj(vec![ ("k", s("component")), ("name", s(name)), ("props", s(props)), @@ -592,6 +592,7 @@ impl Decl { ("view", Some(markups_json(view))), ("line", num(*line)), ("propsType", os(props_type)), + ("noProps", flag(*no_props)), ("exported", flag(*exported)), ("isDefault", flag(*is_default)), ]), diff --git a/compiler/src/check.rs b/compiler/src/check.rs index 25a4c713..6928edc3 100644 --- a/compiler/src/check.rs +++ b/compiler/src/check.rs @@ -243,6 +243,10 @@ struct Sym<'a> { foreign: Option<FShape>, /// MO-10d: bound from a foreign accessor — it may hold, as a question's reader does reader: bool, + /// an action declared here: how many arguments a call passes (AC-1f) + arity: Option<usize>, + /// RG-4b: the props of `component C()`, which declares none — C's name: reading them is UNBOUND + no_props: Option<String>, } impl<'a> Sym<'a> { fn new(kind: SymKind, name: &str, region: RId, line: i64) -> Self { @@ -278,6 +282,8 @@ impl<'a> Sym<'a> { decl_env: None, foreign: None, reader: false, + arity: None, + no_props: None, } } } @@ -1199,7 +1205,10 @@ impl<'a> Checker<'a> { match d { Decl::Server { params, .. } => sym.params = Some(params.iter().map(|n| SigParam { name: n.clone(), r#ref: false, ty: None }).collect()), Decl::Source { question, .. } => sym.question = *question, - Decl::Action { .. } => sym.io = true, + Decl::Action { params, .. } => { + sym.io = true; // R-io-2: an action is io + sym.arity = Some(params.len()); // AC-1f: its parameters + } Decl::Fn { params, decls: fdecls, .. } => { sym.params = Some(params.iter().map(|p| SigParam { name: p.name.clone(), r#ref: p.r#ref, ty: p.ty.clone() }).collect()); sym.instantiating = params.iter().any(|p| p.r#ref) || !fdecls.is_empty(); @@ -1865,11 +1874,15 @@ impl<'a> Checker<'a> { sym.fails_t = Some(FailsT::Expr { e: tail, env: inner, cache: None, busy: false }); sym.rows = Some(RowsT::Expr { e: tail, env: inner, cache: None, busy: false }); } - Decl::Component { name, props, decls, view, line, .. } => { + Decl::Component { name, props, decls, view, line, no_props, .. } => { let s = self.lookup(env, name).unwrap(); let r = self.syms[s].comp_region.unwrap(); let inner = self.env(Some(env), r); - self.bind(inner, props, Sym::new(SymKind::Props, props, r, *line as i64)); + let mut ps = Sym::new(SymKind::Props, props, r, *line as i64); + if *no_props { + ps.no_props = Some(name.clone()); + } + self.bind(inner, props, ps); self.syms[s].comp_env = Some(inner); self.block(decls, view, inner, Ctx::reactive()); self.later.push(Later::Content { ms: view, env: inner, anc: vec![] }); @@ -1995,6 +2008,9 @@ impl<'a> Checker<'a> { // ---- expressions ---- fn resolve(&mut self, name: &str, env: EId, ctx: Ctx) -> Option<SId> { if let Some(s) = self.lookup(env, name) { + if let Some(c) = self.syms[s].no_props.clone() { + self.err("UNBOUND", "RG-4b", format!("{} is not declared here: component {} declares no props — declare what it takes, `component {}(props: {{ … }})`, and pass it where it is applied", name, c, c)); + } self.sided(s, ctx); return Some(s); } @@ -2264,9 +2280,9 @@ impl<'a> Checker<'a> { return; } let s = self.lookup(env, fname); - let (io, kind, side, io_via, instantiating) = match s { - Some(s) => (self.syms[s].io, Some(self.syms[s].kind), self.syms[s].side, self.syms[s].io_via.clone(), self.syms[s].instantiating), - None => (false, None, None, None, false), + let (io, kind, side, io_via, instantiating, arity) = match s { + Some(s) => (self.syms[s].io, Some(self.syms[s].kind), self.syms[s].side, self.syms[s].io_via.clone(), self.syms[s].instantiating, self.syms[s].arity), + None => (false, None, None, None, false, None), }; if !ctx.imperative && (io || (s.is_none() && fname == "log")) { if ctx.in_fn { @@ -2289,6 +2305,16 @@ impl<'a> Checker<'a> { if kind == Some(SymKind::Comp) { self.err("COMPONENT_AS_VALUE", "RG-4a", format!("component {} called as a function: apply it in a view", fname)); } + if let (Some(SymKind::Action), Some(n)) = (kind, arity) { + if args.len() != n { + let takes = match n { + 0 => "no arguments".to_string(), + 1 => "1 argument".to_string(), + n => format!("{} arguments", n), + }; + self.err("ARGUMENT_COUNT", "AC-1f", format!("{} takes {} and is called with {}: an action's parameters are its whole input — pass each one, and nothing it does not declare", fname, takes, args.len())); + } + } self.model.call(s, kind.map(|k| k.as_str()).unwrap_or("")); } self.expr(f, env, ctx, None); @@ -4511,4 +4537,28 @@ export default component App() { // outer `on(sel)` region's own read of `detail` beside the inner one assert_eq!(got, vec![8, 24]); } + + /// 06 AC-1f: a call of a declared action passes one argument per parameter; 03 RG-4b: `component C()` has no + /// `props` (the eval board's check-accepts-arity-and-missing-props.rx). + #[test] + fn argument_count_and_missing_props() { + let src = r#"component Lane() { <h3>{props.col}</h3> } +component Card(props: { col: string }) { <p>{props.col}</p> } +export default component App() { + source n := 0 + action go() { n := 1 } + action by(k: number, m: number) { n := k + m } + <div> + <Lane col="todo" /> + <Card col="done" /> + <button onClick={() => { go(1, 2) }}>a</button> + <button onClick={() => { go() }}>b</button> + <button onClick={() => { by(1) }}>c</button> + <button onClick={() => { by(1, 2) }}>d</button> + </div> +} +"#; + let got: Vec<(i64, String)> = codes(src, false).into_iter().filter(|(_, c)| c == "ARGUMENT_COUNT" || c == "UNBOUND").collect(); + assert_eq!(got, vec![(1, "UNBOUND".to_string()), (10, "ARGUMENT_COUNT".to_string()), (12, "ARGUMENT_COUNT".to_string())], "{:?}", codes(src, false)); + } } diff --git a/compiler/src/parse.rs b/compiler/src/parse.rs index 45660c1b..52052e43 100644 --- a/compiler/src/parse.rs +++ b/compiler/src/parse.rs @@ -1164,6 +1164,7 @@ fn parse_decl_at(c: &mut Cursor) -> R<Decl> { c.ws(true); // RG-4b: `component C({ a, b })` — each name a projection of props let sig = if c.peek() == '{' { Some(parse_pattern(c)?) } else { None }; + let no_props = sig.is_none() && c.peek() == ')'; // `component C()`: no props (RG-4b) let props = if sig.is_some() || c.peek() == ')' { "props".to_string() } else { c.ident_or("props name")? }; let props_type = opt_type(c, false)?; c.ws(true); @@ -1177,7 +1178,7 @@ fn parse_decl_at(c: &mut Cursor) -> R<Decl> { let pre: Vec<Decl> = names.into_iter().map(|(name, e, line, span)| Decl::Eq { name, e, line, span, deferred: false, key: None, ty: None, exported: false }).collect(); decls.splice(0..0, pre); } - return Ok(Decl::Component { name, props, decls, view, line, span: Span::NONE, props_type, exported: false, is_default: false }); + return Ok(Decl::Component { name, props, decls, view, line, span: Span::NONE, props_type, no_props, exported: false, is_default: false }); } let save = c.i; let name = c.ident(); diff --git a/eval/results/r15prep/repro/catch-read-in-handler-during-refresh.rx b/eval/results/r15prep/repro/catch-read-in-handler-during-refresh.rx index ac203965..832780c0 100644 --- a/eval/results/r15prep/repro/catch-read-in-handler-during-refresh.rx +++ b/eval/results/r15prep/repro/catch-read-in-handler-during-refresh.rx @@ -2,6 +2,7 @@ // fresh memo; while an action's `refresh x` is in flight that memo has no committed value, so the click throws NotReadyError // (uncaught page error, the click is lost). The same read without `catch` gives the answer on screen; so does plain Solid // (untrack(ans) during refresh -> committed value). Also: the checker reports neither R-8 nor GUESS_WHILE_LOADING through the fn. +// Fixed 2026-11-14: with no observer `catchRead` is a snapshot (R-8; ref/compile/rt-*.ts, scenarios/S-catch-snapshot.md). // Run: probe-rx.mjs <desk with this as src/main.rx> catch-read-in-handler-during-refresh.steps.mjs import pure { fetchCards } from "./api" import { moveCard } from "./api" diff --git a/eval/results/r15prep/repro/pending-probe-inside-held-region.rx b/eval/results/r15prep/repro/pending-probe-inside-held-region.rx index 6b9c4d86..bd7faa85 100644 --- a/eval/results/r15prep/repro/pending-probe-inside-held-region.rx +++ b/eval/results/r15prep/repro/pending-probe-inside-held-region.rx @@ -3,6 +3,8 @@ // disables, and a second click guesses on a value that is loading a new question. // Observed (probe-rx): after "select 1" the probe outside the region reads "pending: yes", the same probe in the // toggles' disabled={pending? list} inside `loading on(sel)` stays false: [false, false]. Board seed 14. +// Verdict (2026-11-14): Solid's, not the .rx lowering — plain Solid does the same (pending-probe-inside-held-region.spec.tsx); +// recorded under spec/05 DV-22 (H-11's ruled `true`, not built). The guide's AC-3a text now says what holds in this case. import pure { fetchChecklist } from "./api" import { toggleItem } from "./api" export default component App() { diff --git a/eval/results/r15prep/repro/pending-probe-inside-held-region.spec.tsx b/eval/results/r15prep/repro/pending-probe-inside-held-region.spec.tsx new file mode 100644 index 00000000..12d40d54 --- /dev/null +++ b/eval/results/r15prep/repro/pending-probe-inside-held-region.spec.tsx @@ -0,0 +1,51 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * Plain Solid for pending-probe-inside-held-region.rx (no .rx runtime): a guess on `list` is out, then `sel := 1` (an + * input of list's question) joins its hold (rx 05 H-11). The ruled verdict is `pending? list` true (H-11; Solid's + * #4695 / S-44); Solid reads it true outside the Loading on(sel) region (with the whole read the .rx lowering makes) and + * false in the same probe inside it, so `disabled={pending? list}` never disables there. rx records it as DV-22. + * Run: copy to solid-next/packages/web/test/, `npx vitest run test/<file>`. + */ +import { test, expect } from "vitest"; +import { Loading, Errored, For, action, createSignal, createOptimisticStore, isPending, flush } from "solid-js"; +import { render } from "../src/index.js"; +const tick = async () => { for (let i = 0; i < 4; i++) { await new Promise(r => setTimeout(r, 0)); flush(); } }; +test("isPending(list) inside a Loading on(sel) region reads as outside it while a guess's action holds a sel change", async () => { + const asks: { key: string; res: (v: any) => void }[] = []; + const ask = (key: string) => new Promise<any>(res => asks.push({ key, res })); + const take = (key: string) => { const i = asks.findIndex(a => a.key === key); if (i < 0) throw new Error("no ask " + key + " in " + asks.map(a => a.key)); return asks.splice(i, 1)[0]; }; + const c = document.body.appendChild(document.createElement("div")); + + const [sel, setSel] = createSignal(2); + const [list, setList] = createOptimisticStore<any[]>(async (draft: any[]) => { + const items = await ask(`checklist:${sel()}`); + draft.length = 0; draft.push(...items); + }, [], { key: "id" } as any); + const flip = action(function* (item: number, to: boolean) { + const j = list.findIndex((it: any) => it.id === item); + setList((d: any) => { d[j].done = to; }); + yield ask(`toggle:${item}`); + }); + + render(() => <div> + <p id="probe">pending: {isPending(() => { Reflect.ownKeys(list); return list; }) ? "yes" : "no"} sel: {sel()}</p> + <Loading on={sel()} fallback={<p>loading</p>}> + <Errored fallback={<p>err</p>}> + <For each={list}>{(it: any) => <button disabled={isPending(() => { Reflect.ownKeys(list); return list; })} onClick={() => flip(it.id, !it.done)}>{it.text}:{it.done ? "x" : "_"}</button>}</For> + </Errored> + </Loading> + </div>, c); + const screen = () => c.textContent!.replace(/\s+/g, " ").trim(); + const disabled = () => [...c.querySelectorAll("button")].map(b => (b as HTMLButtonElement).disabled); + + take("checklist:2").res([{ id: 21, text: "repro", done: true }, { id: 22, text: "patch", done: false }]); await tick(); + console.log("loaded:", screen(), disabled()); + flip(22, true); await tick(); + console.log("after toggle:", screen(), disabled()); + setSel(1); await tick(); + console.log("after select 1:", screen(), disabled(), "| air:", asks.map(a => a.key).join(", ")); + expect(c.querySelector("#probe")!.textContent).toContain("pending: yes"); // outside the region: true + expect(disabled()).toEqual([true, true]); // inside: false on Solid (fails here) +}); diff --git a/guide/async.md b/guide/async.md index 5465243e..99af22fa 100644 --- a/guide/async.md +++ b/guide/async.md @@ -277,7 +277,32 @@ export action add(id: string, title: string) { looks lost. A write against a value that isn't shown yet needs a place to show: keep the control out until the value is shown (under its `loading` boundary, or disabled while it is pending), or keep the write in a client-owned layer of its own ("three layers" above) and guess there. The compiler flags a control that can guess - while its value loads (`GUESS_WHILE_LOADING`). + while its value loads (`GUESS_WHILE_LOADING`). One case it can't see: a selection changed while a guess on the + value is out joins the guess's hold (H-11), so the `loading on(sel)` region keeps its rows, and + `disabled={pending? x}` inside it stays `false` on Solid today (05 DV-22). Test `pending? x` in the handler as + well; there it reads `true` (R-12). + +### One store over the server + +When several views show one kind of row (a board's columns, a list and its detail, a count), keep one store of them, +not a copy per view: + +- **Lay the answers flat in one keyed equation.** Each server answer stays a question of its own; one equation joins + them into one list with a `key`, and every view is a `filter` or a sort of it. A row is then one row wherever it + shows, and a guess on it shows in all of them. +- **One write is one path guess on one row** (`guess rows[i].field := v`, "Writing one row of a list" above). Not a + guess on the whole list, and not one per view. +- **Work out positions in the handler and pass them in.** An index, a count, a place in the target: compute them in + the handler from the screen (a snapshot, R-8) and pass them as arguments. An action that reads a value another + action guessed and then guesses is one change with that action (H-11, Solid's D-33; solidjs/solid#4788): the two + end together, so a declined write stays up until the other one ends. +- **On accept, re-ask the answers that held the row and the one it joined.** Name them from the answers and the + action's arguments, not from the store, whose row already shows the guess. On a decline re-ask nothing: the guess + reverts by itself. +- **A create whose answer is the new row** writes that row into its answer source after the `await` (a source with a + question takes a write until its next answer, ST-5a, so the row is never there twice). Until then show a placeholder + from a guess list of its own, inert: no controls, since the server doesn't know its id yet. +- **Errors stay plain sources.** A guess reverts with its action; an error must outlive it. ## 4. Things that look like bugs and aren't diff --git a/guide/diagnostics.md b/guide/diagnostics.md index 97a68638..f5532df0 100644 --- a/guide/diagnostics.md +++ b/guide/diagnostics.md @@ -656,6 +656,27 @@ export default component App() { } ``` +### ARGUMENT_COUNT + +A call of an action passes one argument per parameter (AC-1f). A missing argument is `undefined` in the body and an +extra one is dropped, both silently, so the count is checked where the action is declared in the module. + +```rx before +export default component App() { + source n := 0 + action go(by: number) { n := n + by } + <button onClick={() => { go(1, 2) }}>{n}</button> +} +``` + +```rx after +export default component App() { + source n := 0 + action go(by: number) { n := n + by } + <button onClick={() => { go(1) }}>{n}</button> +} +``` + ### IO_IN_FN A `fn` is pure by definition (R-io-2). Code that calls io is an `action`, or the io call moves into the handler. @@ -826,6 +847,13 @@ user's change disappears while its action is out. Put the element inside the val `disabled={pending? x}`, or keep the pending change in a source of its own until it lands. Solid warns `OPTIMISTIC_WRITE_WHILE_LOADING` at run time for the same case. +Neither cover holds when the selection changes while a guess on `x` is out. That change joins the guess's hold +(H-11), so a `loading on(sel)` region keeps its content (H-6), and `pending? x` read in that region's markup is +`false` on Solid today although the rule says `true` (05 DV-22): the control stays enabled and a second click guesses +on the old answer. The check accepts it, since the element is covered. Test the verdict in the handler too, where it +reads `true` (R-12): `onClick={() => { if (!(pending? x)) { flip(it.id) } }}`, or keep the pending change in a source +of its own. + ```rx before export default component App() { source sel := 1 @@ -923,6 +951,23 @@ export default component App() { } ``` +`props` is a name only in a component that declares it: `component Lane()` has none (RG-4b), and a read of +`props.col` there would be `undefined`. Declare what it takes. + +```rx before +component Lane() { <h3>{props.col}</h3> } +export default component App() { + <Lane col="todo" /> +} +``` + +```rx after +component Lane(props: { col: string }) { <h3>{props.col}</h3> } +export default component App() { + <Lane col="todo" /> +} +``` + ### UNKNOWN_COMPONENT A capitalised tag names a component declared or imported in this module (RG-4a). diff --git a/guide/reference.md b/guide/reference.md index 8ce22b7a..a33bd30e 100644 --- a/guide/reference.md +++ b/guide/reference.md @@ -51,6 +51,7 @@ Expr ::= TypeScript's, without assignment, `this`, `function`, `new` on rea launched it, is held until the body returns (AC-1). A handler that guesses, declares `affects`, or writes and awaits is an action too (AC-1b). - An action with no `await` (`select(id) { selected := id }`) is an ordinary instant. +- A call passes one argument per parameter (AC-1f, `ARGUMENT_COUNT`). - A handler launches an action and goes on without waiting for it. An action that awaits another action waits for it; that await can't fail, since every action catches its own failures (AC-1, R-f-6). - `await` is a statement: `await f(x)` or `let r = await f(x)`, optionally `… catch (e) => alt` (AC-1). diff --git a/ref/compile/rt-server.ts b/ref/compile/rt-server.ts index 2ecf15bf..e3013b24 100644 --- a/ref/compile/rt-server.ts +++ b/ref/compile/rt-server.ts @@ -52,6 +52,10 @@ function catchAcc(f: () => unknown, match: (err: unknown) => boolean, alt: (err: function tryCatch(f: () => unknown, match: (err: unknown) => boolean, alt: (err: unknown) => unknown): unknown { try { return f(); } catch (err) { if (err instanceof NotReadyError || !match(err)) throw err; return alt(err); } } +/** As the client's: with no observer the read is a snapshot (04 R-8) and the client builds no boundary, so no ids. */ +function catchRead(f: () => unknown, match: (err: unknown) => boolean, alt: (err: unknown) => unknown): unknown { + return getObserver() ? catchAcc(f, match, alt)() : tryCatch(f, match, alt); +} // ---------------------------------------------------------------- the wire (12 SF-6; a copy of ref/src/failure.ts's) function wireCopy(v: unknown, where: string, fail: (msg: string) => never): unknown { @@ -218,7 +222,7 @@ export function createApp(opts: { api?: Record<string, (...a: any[]) => unknown> snapshot(get: Acc) { try { return untrack(get); } catch (e) { if (e instanceof NotReadyError) return PENDING_MARK; throw e; } }, errorClass: (name: string, params: string[], message: (...args: unknown[]) => unknown) => errorClass(name, params, message, `${name}#${classSeq++}`), // a declaration's identity, sent with its failures (SV-5a) - isA, PANIC, catchMemo: catchAcc, tryCatch, catchRead: (f: () => unknown, match: (err: unknown) => boolean, alt: (err: unknown) => unknown) => catchAcc(f, match, alt)(), + isA, PANIC, catchMemo: catchAcc, tryCatch, catchRead, throw: (e: unknown): never => { throw e; }, eq: (a: unknown, b: unknown) => a === b, untrack, omit, latest: (f: () => unknown) => latest(() => latestWhole(f())), isPending: (f: () => unknown) => isPending(() => readWhole(f())), diff --git a/ref/compile/rt-web.ts b/ref/compile/rt-web.ts index 044fc5e4..c3590116 100644 --- a/ref/compile/rt-web.ts +++ b/ref/compile/rt-web.ts @@ -72,6 +72,12 @@ function catchAcc(f: () => unknown, match: (err: unknown) => boolean, alt: (err: function tryCatch(f: () => unknown, match: (err: unknown) => boolean, alt: (err: unknown) => unknown): unknown { try { return f(); } catch (err) { if (err instanceof NotReadyError || !match(err)) throw err; return alt(err); } } +/** `e catch (x: C) => alt` read where it was written. In reactive scope it is `catchAcc`'s boundary. Read with no observer — + * a component `fn` called from a handler — it is a snapshot (04 R-8): the committed value, or `alt` on a matching failure + * (R-f-7), as `tryCatch`; a fresh boundary there would have no committed value while a question is out (S-catch-snapshot). */ +function catchRead(f: () => unknown, match: (err: unknown) => boolean, alt: (err: unknown) => unknown): unknown { + return getObserver() ? catchAcc(f, match, alt)() : tryCatch(f, match, alt); +} class Fallback { get: () => unknown; constructor(g: () => unknown) { this.get = g; } } function unwrapFallback(v0: unknown): unknown { let v = v0; if (v instanceof Fallback) v = v.get(); if (v instanceof Forward) throw (v as Forward).err; return v; } const NO_PROVISION: Entry = {}; @@ -290,7 +296,7 @@ export function createApp(opts: { root: any; api?: Record<string, (...a: any[]) snapshot(get: Acc) { try { return untrack(get); } catch (e) { if (e instanceof NotReadyError) return PENDING_MARK; throw e; } }, // ---- failures (R-f) ---- errorClass: (name: string, params: string[], message: (...args: unknown[]) => unknown) => errorClass(name, params, message, `${name}#${classSeq++}`), // a declaration's identity: the same on both sides (SV-5a) - isA, PANIC, catchMemo: catchAcc, tryCatch, catchRead: (f: () => unknown, match: (err: unknown) => boolean, alt: (err: unknown) => unknown) => catchAcc(f, match, alt)(), + isA, PANIC, catchMemo: catchAcc, tryCatch, catchRead, throw: (e: unknown): never => { throw e; }, eq: (a: unknown, b: unknown) => raw(a) === raw(b), untrack, omit, latest: (f: () => unknown) => latest(() => latestWhole(f())), isPending: (f: () => unknown) => isPending(() => readWhole(f())), diff --git a/ref/compile/rt.ts b/ref/compile/rt.ts index 5eecb915..1b32e7f1 100644 --- a/ref/compile/rt.ts +++ b/ref/compile/rt.ts @@ -63,6 +63,12 @@ function catchAcc(f: () => unknown, match: (err: unknown) => boolean, alt: (err: function tryCatch(f: () => unknown, match: (err: unknown) => boolean, alt: (err: unknown) => unknown): unknown { try { return f(); } catch (err) { if (err instanceof NotReadyError || !match(err)) throw err; return alt(err); } } +/** `e catch (x: C) => alt` read where it was written. In reactive scope it is `catchAcc`'s boundary. Read with no observer — + * a component `fn` called from a handler — it is a snapshot (04 R-8): the committed value, or `alt` on a matching failure + * (R-f-7), as `tryCatch`; a fresh boundary there would have no committed value while a question is out (S-catch-snapshot). */ +function catchRead(f: () => unknown, match: (err: unknown) => boolean, alt: (err: unknown) => unknown): unknown { + return getObserver() ? catchAcc(f, match, alt)() : tryCatch(f, match, alt); +} class Fallback { get: () => unknown; constructor(g: () => unknown) { this.get = g; } } function unwrapFallback(v0: unknown): unknown { let v = v0; if (v instanceof Fallback) v = v.get(); if (v instanceof Forward) throw (v as Forward).err; return v; } const NO_PROVISION: Entry = {}; @@ -297,7 +303,7 @@ export function createApp(opts: { api?: Record<string, (...a: any[]) => unknown> /** An imperative read sees σ (H-10): a pending value is a value; a snapshot of an error throws it (R-f-7). */ snapshot(get: Acc) { try { return untrack(get); } catch (e) { if (e instanceof NotReadyError) return PENDING_MARK; throw e; } }, // ---- failures (R-f) ---- - errorClass, isA, PANIC, catchMemo: catchAcc, tryCatch, catchRead: (f: () => unknown, match: (err: unknown) => boolean, alt: (err: unknown) => unknown) => catchAcc(f, match, alt)(), + errorClass, isA, PANIC, catchMemo: catchAcc, tryCatch, catchRead, throw: (e: unknown): never => { throw e; }, eq: (a: unknown, b: unknown) => raw(a) === raw(b), untrack, omit, latest: (f: () => unknown) => latest(() => latestWhole(f())), isPending: (f: () => unknown) => isPending(() => readWhole(f())), diff --git a/ref/src/ast.ts b/ref/src/ast.ts index 37ce9a1a..8c6a1a39 100644 --- a/ref/src/ast.ts +++ b/ref/src/ast.ts @@ -52,7 +52,7 @@ export type Decl = | { k: "typedecl"; name: string; line: number; text?: string; exported?: boolean } // `type T = …`: text only | { k: "effect"; inputs: Expr[]; body: Stmt[]; line: number } | { k: "fn"; name: string; params: Param[]; decls: Decl[]; tail: Expr; line: number; ret?: string; exported?: boolean } // body: decls then a value - | { k: "component"; name: string; props: string; decls: Decl[]; view: Markup[]; line: number; propsType?: string; exported?: boolean; isDefault?: boolean } // body: decls then a view; isDefault: `export default component App` — the app's entry (MO-1c) + | { k: "component"; name: string; props: string; decls: Decl[]; view: Markup[]; line: number; propsType?: string; noProps?: boolean; exported?: boolean; isDefault?: boolean } // body: decls then a view; noProps: `component C()` (RG-4b); isDefault: `export default component App` — the app's entry (MO-1c) | { k: "action"; name: string; params: string[]; ptypes?: (string | undefined)[]; body: Stmt[]; line: number; exported?: boolean } // AC-1 | { k: "server"; name: string; params: string[]; ptypes?: (string | undefined)[]; ret?: string; body: Stmt[]; line: number; exported?: boolean } // 12 SF-1: `server fn f(a) { … }` — the body runs on the server; it is io when its body is | { k: "provide"; name: string; e: Expr; line: number } // RG-7a: `name` resolves to a context declaration diff --git a/ref/src/check.ts b/ref/src/check.ts index 73e03757..97d782ee 100644 --- a/ref/src/check.ts +++ b/ref/src/check.ts @@ -78,6 +78,7 @@ type Sym = { param?: boolean; // a fn's parameter: never a write target (O-4) instantiating?: boolean; // fn with ref params or declarations (E-9) params?: { name: string; ref: boolean }[]; + arity?: number; // an action declared here: how many arguments a call passes (AC-1f) captures?: Sym[]; // a value holding a closure: the reactive names it captured (O-3) retCaptures?: Sym[]; // what calling that value can return holding (O-3) propOf?: { comp: Region; field: string }; // O-3: not a name but a prop's argument, stored — its captures are the caller's ("*": all of props) @@ -96,6 +97,7 @@ type Sym = { serverRejects?: () => Set<string>; // SF-6b: what a call's promise rejects with — its body's failures, and Error (transit) foreign?: Shape; // MO-10: a declared JS primitive (a fn) or an instance of one (an equation): what its result is reader?: boolean; // MO-10d: bound from a foreign accessor — it may hold, as a question's reader does + noProps?: string; // RG-4b: the props of `component C()`, which declares none — C's name: reading them is UNBOUND fnDecl?: Extract<Decl, { k: "fn" }>; declEnv?: Env; // a fn's declaration and the scope it names from (SF-3: what it reaches) // a context (RG-7): its identity, whether it carries a source, and its declared rows (null: inferred from its provisions) }; const reactiveKinds = new Set<SymKind>(["source", "eq", "props"]); @@ -485,7 +487,7 @@ class Checker { const sym: Sym = { kind: d.k === "fn" ? "fn" : d.k === "component" ? "comp" : d.k === "action" ? "action" : d.k === "server" ? "server" : d.k === "source" ? "source" : d.k === "error" ? "errclass" : "eq", name: d.name, region: env.region, line: d.line }; if (d.k === "server") sym.params = d.params.map(name => ({ name, ref: false })); if (d.k === "source") sym.question = !!d.question; - if (d.k === "action") sym.io = true; // R-io-2: an action is io + if (d.k === "action") { sym.io = true; sym.arity = d.params.length; } // R-io-2: an action is io; AC-1f: its parameters if (d.k === "fn") { sym.params = d.params; sym.instantiating = d.params.some(p => p.ref) || d.decls.length > 0; sym.fnDecl = d; sym.declEnv = env; } if (d.k === "component") { sym.comp = d; sym.compRegion = this.region(env.region, "component", { component: true, name: d.name }); this.compSyms.set(sym.compRegion, sym); } if (d.k === "eq") sym.eqDecl = d; @@ -733,7 +735,7 @@ class Checker { case "component": { const sym = env.lookup(d.name)!; const r = sym.compRegion!; const inner = new Env(env, r); - this.bind(inner, d.props, { kind: "props", name: d.props, region: r, line: d.line }); + this.bind(inner, d.props, { kind: "props", name: d.props, region: r, line: d.line, ...(d.noProps ? { noProps: d.name } : {}) }); sym.compEnv = inner; this.block(d.decls, d.view, inner, { scope: "reactive" }); this.later.push(() => this.contentCheck(d.view, inner, [])); @@ -807,7 +809,9 @@ class Checker { // ---- expressions ---- private resolve(name: string, env: Env, ctx: Ctx): Sym | undefined { - const s = env.lookup(name); if (s) { this.sided(s, ctx); return s; } + const s = env.lookup(name); + if (s?.noProps) this.err("UNBOUND", "RG-4b", `${name} is not declared here: component ${s.noProps} declares no props — declare what it takes, \`component ${s.noProps}(props: { … })\`, and pass it where it is applied`); + if (s) { this.sided(s, ctx); return s; } if (BUILTINS.has(name)) return undefined; if (ctx.scope === "imperative" && (name === "log" || /^\$\d+$/.test(name))) return undefined; // the apply block's parameters; `log` is io and only in imperative scope this.err("UNBOUND", "SCOPE", `${name} is not declared here (a component sees its declaration site, not its caller — RG-7a)`); @@ -897,6 +901,7 @@ class Checker { if (s?.side === "client" && ctx.scope === "reactive" && !ctx.server && !this.awaitedCalls.has(e)) this.err("CLIENT_NOT_AWAITED", "SF-5b", `${e.f.name}(…) is \`client\` JS: in reactive scope it is awaited — a client question, asked once the browser has the page (SV-7b) — never called for its value now`); if (s?.kind === "fn" && s.instantiating) this.err("INSTANTIATION_NOT_BOUND", "E-9", `${e.f.name}(…) declares state: bind it, \`x = ${e.f.name}(…)\` (RG-6)`); if (s?.kind === "comp") this.err("COMPONENT_AS_VALUE", "RG-4a", `component ${e.f.name} called as a function: apply it in a view`); + if (s?.kind === "action" && s.arity !== undefined && e.args.length !== s.arity) this.err("ARGUMENT_COUNT", "AC-1f", `${e.f.name} takes ${s.arity === 0 ? "no arguments" : s.arity === 1 ? "1 argument" : `${s.arity} arguments`} and is called with ${e.args.length}: an action's parameters are its whole input — pass each one, and nothing it does not declare`); this.model.call(s); } go(e.f); e.args.forEach(go); return; diff --git a/ref/src/parse.ts b/ref/src/parse.ts index 69cb7c91..2933d0cb 100644 --- a/ref/src/parse.ts +++ b/ref/src/parse.ts @@ -352,10 +352,11 @@ function parseDecl(c: Cursor): Decl { if (c.keyword("component")) { c.ws(); const name = c.ident() ?? c.fail("component name"); c.ws(); c.expect("("); c.ws(); const sig = c.peek() === "{" ? parsePattern(c) : null; // RG-4b: `component C({ a, b })` — each name a projection of props + const noneDeclared = !sig && c.peek() === ")"; const props = sig || c.peek() === ")" ? "props" : (c.ident() ?? c.fail("props name")); const propsType = optType(c); c.ws(); c.expect(")"); c.ws(); c.expect("{"); // `component C()`: no props (found by S-ai2-tracker) const { decls, body } = parseBlockBody(c); if (sig) { const names: { name: string; e: Expr; line: number }[] = []; expandPattern(sig, { k: "id", name: props }, names); decls.unshift(...names.map(n => ({ k: "eq" as const, name: n.name, e: n.e, line: n.line }))); } - return { k: "component", name, props, decls, view: body, line, propsType }; + return { k: "component", name, props, decls, view: body, line, propsType, ...(!sig && props === "props" && noneDeclared ? { noProps: true } : {}) }; } const save = c.i; const name = c.ident(); diff --git a/scenarios/S-catch-snapshot.md b/scenarios/S-catch-snapshot.md new file mode 100644 index 00000000..d60ff1fb --- /dev/null +++ b/scenarios/S-catch-snapshot.md @@ -0,0 +1,41 @@ +# S-catch-snapshot — a `catch` read in a `fn` called from a handler is a snapshot: the committed value while a question is out + +Rules exercised: R-8, R-f-4, R-f-7, AC-4. + +## Program + +```rx +export default component App() { + source ans = await fetch("a") -> 0 + fn cur() => ans catch (e) => -1 + source seen := 0 + action reload() { try { await fetch("save"); refresh ans } catch (e) {} } + action note(d: number) { seen := cur() + d } + <p>{ans catch (e) => "err"} seen {seen}</p> +} +``` + +`cur` is written once and read from two scopes. Called from an action (or +any handler) it is imperative scope, where every read is a snapshot (R-8): an equation with a +new question out reads its committed value, and a matching failure is the +`catch`'s alternative (R-f-4, R-f-7). Instant 4: the action's `refresh` is +out, and `note`'s `cur()` reads `2`, what the screen shows. Instant 6: +the answer failed, so the snapshot takes the `catch` and reads `-1`. + +Found by the eval board (r15prep, `catch-read-in-handler-during-refresh.rx`): +the compiled `catch` built a fresh error boundary at each read, which in a +handler had no committed value while the refresh was out, so the click +threw `NotReadyError` and was lost. + +## Frames + +| instant | action | frame | +| ------- | ------ | ----- | +| 0 | mount | `0 seen 0` | +| 1 | resolve "a" 2 | `2 seen 0` | +| 2 | handler `note(0)` | `2 seen 2` | +| 3 | handler `reload()` | `2 seen 2` | +| 4 | resolve "save" 0 | `2 seen 2` | +| 5 | handler `note(1)` | `2 seen 3` | +| 6 | reject ans "down" | `err seen 3` | +| 7 | handler `note(0)` | `err seen -1` | diff --git a/scenarios/S-check-1.md b/scenarios/S-check-1.md index 32496d2c..ef74ba88 100644 --- a/scenarios/S-check-1.md +++ b/scenarios/S-check-1.md @@ -136,6 +136,30 @@ export default component App() { `Row` sees its declaration site, not its caller (RG-7a). +```rx +-- reject: UNBOUND +component Lane() { <h3>{props.col}</h3> } + +export default component App() { + <Lane col="todo" /> +} +``` + +`component Lane()` declares no props, so `props` is not a name in it +(RG-4b): the read was `undefined` on screen. + +```rx +-- reject: ARGUMENT_COUNT +export default component App() { + source n := 0 + action go(by: number) { n := n + by } + + <button onClick={() => { go(1, 2) }}>{n}</button> +} +``` + +`go` takes one argument (AC-1f); `go()` is refused the same way. + ```rx -- reject: UNKNOWN_COMPONENT export default component App() { diff --git a/spec/03-regions.md b/spec/03-regions.md index d0e35868..ea056fa2 100644 --- a/spec/03-regions.md +++ b/spec/03-regions.md @@ -132,7 +132,9 @@ A component is a function from a props value to a region whose tail is a view. keep updating. A prop is never a snapshot unless the body takes one (E-4, `peek`). Spread, rest, `merge`, and `omit` are operations on this value (06-stores treats its path semantics). Named parameters are not a separate - form; destructuring covers the signature use. + form; destructuring covers the signature use. `component C()` declares no + props: `props` is not a name in its body, and reading it is `UNBOUND` + (S-check-1; before 2026-11-14 it read `undefined` silently). - **RG-4c (Children).** `children` is a view value evaluated in the *caller's* region: its readers are the caller's, its lifetime is the caller's, and the child only decides where it is placed. A child that needs to create its diff --git a/spec/05-holds.md b/spec/05-holds.md index 98424529..aa3a6619 100644 --- a/spec/05-holds.md +++ b/spec/05-holds.md @@ -436,6 +436,11 @@ repro in `ref/oracle/repros/` still reproduces on that commit. not built** (#4695, S-44). An outside input change under a guess reads `pending? x` `false` (S-solid-44 instant 3); the ruled verdict is `true`. The screen is the ruled one; `latest x` there is DV-19. + Inside a `loading on(sel)` region whose `on` reads the held input, the + same probe reads `false` even where one outside it reads `true`, so + `disabled={pending? x}` there does not disable (found by the eval board, + 2026-11-14: `eval/results/r15prep/repro/pending-probe-inside-held-region.*`, + plain Solid in its `.spec.tsx`). - **DV-23 (a born-held branch under a display-ahead condition) — Solid's ruling, not built** (#4695, decision 4: D-1; S-46's plain row). A branch a probe opens whose content read the held write plainly attaches with its diff --git a/spec/06-stores.md b/spec/06-stores.md index 4b74e2fc..bbece1ac 100644 --- a/spec/06-stores.md +++ b/spec/06-stores.md @@ -224,6 +224,14 @@ selected = draft(pre selected -> {}) { d[id] := true; d[prevId] := false } its 3 .rx defects, and a latent one in a run that passed every test; on all 76 eval .rx apps and the repository's examples it fires on those 3 runs only (S-infer-6). +- **AC-1f (Calls pass the parameters).** [taken by default 2026-11-14] A + call of an action declared in the module passes one argument per + parameter; any other count is `ARGUMENT_COUNT` (static). The parameters + are the action's whole input: a missing one is `undefined` in the body + and an extra one is dropped, silently either way. An action imported from + an `.rx` module or passed as a prop is not checked (its signature carries + no parameters yet, MO-3). Found by the eval board (r15prep, + `check-accepts-arity-and-missing-props.rx`; S-check-1). - **AC-2 (Not special).** An action is only a way to tie writes to async work; the hold is the thing (H-2). `refresh`, `affects` and probes behave the same inside and outside one. diff --git a/spec/99-diagnostics.md b/spec/99-diagnostics.md index e4a3b5bf..09e0d988 100644 --- a/spec/99-diagnostics.md +++ b/spec/99-diagnostics.md @@ -88,7 +88,8 @@ named. | `SHADOWED_IMPORT` (new) | S | MO-1d | S-module-1 | | `CYCLE` | S | C-1 (CN-3a: union of branches; only an equation's own `pre` breaks it) | S-check-1, S-core-1 | | `PRE_OF_NON_REACTIVE` | S | C-2 | S-check-1 | -| `UNBOUND` | S | lexical scope (a component sees its declaration site, RG-7a) | S-check-1 | +| `UNBOUND` | S | lexical scope (a component sees its declaration site, RG-7a; `props` of a component that declares none, RG-4b) | S-check-1 | +| `ARGUMENT_COUNT` (new) | S | AC-1f: a call of a declared action with another number of arguments than its parameters | S-check-1 | | `UNKNOWN_COMPONENT`, `COMPONENT_AS_VALUE` | S | RG-4a | S-check-1, S-region-5 | | `INSTANTIATION_NOT_BOUND` | S | E-9 / RG-6 | S-region-6 | | `SNAPSHOT_ARGUMENT` | S | E-10 | S-region-6 | From b5ddae523548769248a32b277b9cd4af11bb5c19 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 15 Nov 2026 03:41:11 +0000 Subject: [PATCH 22/32] eval: store board fixes (placeholder list, pending? in handlers, reask shown column), fuzz B3 declined count hold, repro for solidjs/solid#4887 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- eval/harness/fuzz-board.mjs | 10 ++- eval/reference/board/rx-store/board.rx | 78 ++++++++++--------- .../column-memo-loses-guess.variant.spec.tsx | 73 +++++++++++++++++ .../repro/column-memo-misses-guess.spec.tsx | 75 ++++++++++++++++++ .../repro/column-memo-misses-guess.steps.mjs | 14 ++++ .../detail-memo-misses-guess.attempt.spec.tsx | 51 ++++++++++++ 6 files changed, 261 insertions(+), 40 deletions(-) create mode 100644 eval/results/r15prep/repro/column-memo-loses-guess.variant.spec.tsx create mode 100644 eval/results/r15prep/repro/column-memo-misses-guess.spec.tsx create mode 100644 eval/results/r15prep/repro/column-memo-misses-guess.steps.mjs create mode 100644 eval/results/r15prep/repro/detail-memo-misses-guess.attempt.spec.tsx diff --git a/eval/harness/fuzz-board.mjs b/eval/harness/fuzz-board.mjs index 4e2a6d4c..73c2480b 100644 --- a/eval/harness/fuzz-board.mjs +++ b/eval/harness/fuzz-board.mjs @@ -22,7 +22,8 @@ // card where it showed it cannot know an order the server made from answers it applied in another order. // - B3 (H-11, as desk9's A5: a switch made while work is in flight may hold): while anything is in the air, the // detail may still show a card selected earlier in that busy period, checked against that card; and an add's -// "(adding…)" row may stay after its answer (accepted or declined) while other work is in the air. At rest neither. +// "(adding…)" row may stay after its answer (accepted or declined) while other work is in the air; and a card's +// count may keep a declined toggle or item add while other work is in the air. At rest none of these. // - Two accepted writes to one field: settled, the field must show the server's last answer (Ryan, 2026-11-14). // Usage: node harness/fuzz-board.mjs <dir> [--seeds N] [--steps N] [--from S] [--json out.json] [--log] // (--r9 and --r15 are accepted and ignored, so harness/grade.sh can pass its round flag.) @@ -107,6 +108,7 @@ async function session(browser, url, seed, steps, log) { let lateVisit = false; // B1 let looseOrder = false; // B2 const heldSel = new Set(); // B3: selections made while something was in the air + const heldDeclined = []; // B3: declined toggles and item adds in this busy period (their card's count may keep them) const trail = []; const found = []; let made = 0; // added cards and items get distinct texts: two adds of one text would be one flight to the harness const pending = () => page.evaluate(() => globalThis.__flights.pending()); @@ -150,7 +152,7 @@ async function session(browser, url, seed, steps, log) { for (const x of c.rows) if (!x.adding) { if (seen.has(x.id) && !lateVisit) note("duplicate card", `#${x.id} shows in ${seen.get(x.id)} and ${col}`); seen.set(x.id, col); } } const busy = (await pending()).length > 0; - if (!busy) heldSel.clear(); + if (!busy) { heldSel.clear(); heldDeclined.length = 0; } const placeholders = COLS.flatMap(col => (b[col]?.rows ?? []).filter(x => x.adding).map(x => x.title)); const addsOut = (await pending()).filter(k => k.startsWith("addCard:")).map(k => k.slice(k.indexOf(",") + 1)); for (const t of new Set(placeholders)) { @@ -164,7 +166,8 @@ async function session(browser, url, seed, steps, log) { for (const col of COLS) for (const x of b[col]?.rows ?? []) { if (x.adding) continue; const xs = srv.items[x.id] ?? [], sd = xs.filter(y => y.done).length; - const t = out.filter(k => k.startsWith(`toggleItem:${x.id},`)).length, ad = out.filter(k => k.startsWith(`addItem:${x.id},`)).length; + const mine = [...out, ...heldDeclined]; // B3: a declined one may still show while other work is in the air + const t = mine.filter(k => k.startsWith(`toggleItem:${x.id},`)).length, ad = mine.filter(k => k.startsWith(`addItem:${x.id},`)).length; if (x.total < xs.length || x.total > xs.length + ad || x.done < sd - t || x.done > sd + t) note("card count", `card "${x.text}", server ${sd}/${xs.length} with ${t} toggle(s) and ${ad} item add(s) of it in the air`); } } @@ -218,6 +221,7 @@ async function session(browser, url, seed, steps, log) { settledCount.set(key, (settledCount.get(key) ?? 0) + times); let a; for (let t = 0; t < times; t++) a = answer(srv, key, fail); if (WRITES.has(name) && !a.fail && writeVisit.get(key) !== ui.visit) lateVisit = true; // B1 + if (a.fail && (name === "toggleItem" || name === "addItem")) heldDeclined.push(key); // B3 writeVisit.delete(key); trail.push(`${a.fail ? "fail" : "land"} ${key}`); await page.evaluate(([k, a]) => a.fail ? globalThis.__flights.fail(k, "declined") : globalThis.__flights.land(k, a.value), [key, a]); diff --git a/eval/reference/board/rx-store/board.rx b/eval/reference/board/rx-store/board.rx index 478931a0..5539aa4f 100644 --- a/eval/reference/board/rx-store/board.rx +++ b/eval/reference/board/rx-store/board.rx @@ -10,11 +10,14 @@ // there was only ever the guess, and removing it is the undo. Errors are plain client state under the guesses: a // refusal is a fact about one request, and no answer has a place for it. // -// An add has no id until the server answers. Its placeholder is guessed into the store under a key minted here, for -// this visit only, and is inert (no move buttons, not selectable). The server's answer is the new card itself, so the -// action records it in To do's answer (a write whose answer is the new value re-asks nothing) and ends: in one frame -// the placeholder goes with its guess and the real card shows from the truth. An added item is the same over the -// checklist's answer. +// An add has no id until the server answers, so it has no row to guess on: its placeholder is guessed into a list of +// its own (`adding`, shown under To do's cards) under a key minted here, for this visit only, and is inert (no move +// buttons, not selectable). The server's answer is the new card itself, so the action records it in To do's answer (a +// write whose answer is the new value re-asks nothing) and ends: in one frame the placeholder goes with its guess and +// the real card shows from the truth. An added item is the same over the checklist's answer. +// +// Indexes, places and columns are worked out in the handler, from the board as shown, and passed in: an action that +// read the store another action guessed on would be one change with it (D-33). // // Everything here is the visit's: the page is mounted only while it is open, so leaving it discards the store, its // guesses and its errors, and opening it again asks the server again. @@ -103,7 +106,9 @@ export component BoardPage() { // 2. The one store over it, flat; every guess is a path write into it. board key id = [...rowsOf(todoSeen, "todo"), ...rowsOf(doingSeen, "doing"), ...rowsOf(doneSeen, "done")] - todo = inCol(board, "todo") + // 3. adds in flight: a new card has no row to guess on, so its placeholder is a guess list of its own, shown under To do + source adding: Row[] key id = [] + todo = [...inCol(board, "todo"), ...adding] doing = inCol(board, "doing") done = inCol(board, "done") // each answer as the other panes read it: `null` while it loads or after it failed @@ -146,11 +151,13 @@ export component BoardPage() { } // An accepted card write ends by re-asking the answers it changed: for a move the one that held the card and the - // one it joined, for an edit the one that holds it. - action reask(a: string, b: string) { - if (a == "todo" || b == "todo") { refresh todoAns } - if (a == "doing" || b == "doing") { refresh doingAns } - if (a == "done" || b == "done") { refresh doneAns } + // one it joined, for an edit the one that holds it. Which answer holds it is read in the handler, as committed, and + // an accepted move still re-asking is not committed yet (its answers land together), so the column the card shows + // in is re-asked too, which is where that move put it. + action reask(cols: string[]) { + if (cols.includes("todo")) { refresh todoAns } + if (cols.includes("doing")) { refresh doingAns } + if (cols.includes("done")) { refresh doneAns } } action retry(col: string) { @@ -168,23 +175,22 @@ export component BoardPage() { // The place is worked out by the click, from the board as shown, as the example's `nudge` does: an action that read // another action's guess and guessed beside it would be one change with it (D-33). - action move(id: number, from: string, col: string, index: number, pos: number) { + action move(i: number, id: number, from: string, shown: string, col: string, index: number, pos: number) { try { - let i = board.findIndex(r => r.id == id) guess board[i].col := col guess board[i].pos := pos onFlush(() => { moveError := "" }) await moveCard(id, col, index) - await reask(from, col) + await reask([from, shown, col]) } catch (e) { moveError := e.message onFlush(() => { moveError := e.message }) } } - action add(key: number, title: string, at: number, pos: number) { + action add(key: number, title: string) { try { - guess board[at] := { id: key, title: title, done: 0, total: 0, col: "todo", pos: pos, adding: true, gone: false } + guess adding := as => [...as, { id: key, title: title, done: 0, total: 0, col: "todo", pos: 0, adding: true, gone: false }] onFlush(() => { addError := "" }) let r = await addCard("todo", title) todoAns := cs => withCard(cs, { id: r.id, title: r.title, done: 0, total: 0 }) @@ -194,21 +200,19 @@ export component BoardPage() { } } - action rename(id: number, title: string, col: string) { - let i = board.findIndex(r => r.id == id) + action rename(i: number, id: number, title: string, col: string, shown: string) { try { guess board[i].title := title onFlush(() => { renameError := "" }) await renameCard(id, title) - await reask(col, col) + await reask([col, shown]) } catch (e) { renameError := e.message onFlush(() => { renameError := e.message }) } } - action archive(id: number, col: string) { - let i = board.findIndex(r => r.id == id) + action archive(i: number, id: number, col: string, shown: string) { try { guess board[i].gone := true onFlush(() => { @@ -216,31 +220,28 @@ export component BoardPage() { archiveError := "" }) await archiveCard(id) - await reask(col, col) + await reask([col, shown]) } catch (e) { archiveError := e.message onFlush(() => { archiveError := e.message }) } } - action toggle(card: number, item: number, to: boolean, col: string) { + action toggle(i: number, j: number, card: number, item: number, to: boolean, col: string, shown: string) { try { - let i = board.findIndex(r => r.id == card) - let j = checklist.findIndex(it => it.id == item) guess checklist[j].done := to guess board[i].done := n => n + (to ? 1 : -1) onFlush(() => { itemError := "" }) await toggleItem(card, item, to) refresh listAns - await reask(col, col) + await reask([col, shown]) } catch (e) { itemError := e.message onFlush(() => { itemError := e.message }) } } - action addLine(card: number, key: number, text: string) { - let i = board.findIndex(r => r.id == card) + action addLine(i: number, card: number, key: number, text: string) { try { guess checklist := ls => [...ls, { id: key, text: text, done: false, adding: true }] guess board[i].total := n => n + 1 @@ -260,7 +261,7 @@ export component BoardPage() { <div style="display: flex; gap: 1.5rem; align-items: flex-start"> <div> <Column col="todo" ans={todoAns} rows={todo} retrying={pending? todoAns} left="" right="doing" leftOpen={false} rightOpen={intoDoing} - onRetry={() => { retry("todo") }} onSelect={id => { pick(id) }} onMove={(id, c, by) => { let p = placeOf(board, id, c, by); move(id, home(id), p.col, p.index, p.pos) }} /> + onRetry={() => { retry("todo") }} onSelect={id => { pick(id) }} onMove={(id, c, by) => { let p = placeOf(board, id, c, by); move(board.findIndex(r => r.id == id), id, home(id), board.find(r => r.id == id)?.col ?? "", p.col, p.index, p.pos) }} /> <div> <input data-testid="new-card" value={newCard} onInput={e => { newCard := e.target.value }} placeholder="new card" />{" "} <button data-testid="add-card" disabled={!todoUp} onClick={() => { @@ -271,7 +272,7 @@ export component BoardPage() { newCard := "" minted := n => n + 1 }) - add(key, t, board.length, placeAt(todo, todo.length)) + add(key, t) } }}>add</button> {if addError != "" { @@ -280,9 +281,9 @@ export component BoardPage() { </div> </div> <Column col="doing" ans={doingAns} rows={doing} retrying={pending? doingAns} left="todo" right="done" leftOpen={todoUp} rightOpen={doneUp} - onRetry={() => { retry("doing") }} onSelect={id => { pick(id) }} onMove={(id, c, by) => { let p = placeOf(board, id, c, by); move(id, home(id), p.col, p.index, p.pos) }} /> + onRetry={() => { retry("doing") }} onSelect={id => { pick(id) }} onMove={(id, c, by) => { let p = placeOf(board, id, c, by); move(board.findIndex(r => r.id == id), id, home(id), board.find(r => r.id == id)?.col ?? "", p.col, p.index, p.pos) }} /> <Column col="done" ans={doneAns} rows={done} retrying={pending? doneAns} left="doing" right="" leftOpen={intoDoing} rightOpen={false} - onRetry={() => { retry("done") }} onSelect={id => { pick(id) }} onMove={(id, c, by) => { let p = placeOf(board, id, c, by); move(id, home(id), p.col, p.index, p.pos) }} /> + onRetry={() => { retry("done") }} onSelect={id => { pick(id) }} onMove={(id, c, by) => { let p = placeOf(board, id, c, by); move(board.findIndex(r => r.id == id), id, home(id), board.find(r => r.id == id)?.col ?? "", p.col, p.index, p.pos) }} /> <section data-testid="detail" style="min-width: 16rem"> {if sel == null { <p>select a card</p> @@ -295,10 +296,10 @@ export component BoardPage() { if (titleInput != "") { let t = titleInput onFlush(() => { titleInput := "" }) - rename(sel.id, t, home(sel.id)) + rename(board.findIndex(r => r.id == sel.id), sel.id, t, home(sel.id), sel.col) } }}>rename</button>{" "} - <button data-testid="archive" onClick={() => { archive(sel.id, home(sel.id)) }}>archive</button> + <button data-testid="archive" onClick={() => { archive(board.findIndex(r => r.id == sel.id), sel.id, home(sel.id), sel.col) }}>archive</button> </div> {if renameError != "" { <p>rename error: {renameError}</p> @@ -313,7 +314,10 @@ export component BoardPage() { <span data-testid="item">[ ] {it.text} (adding…)</span> } else { <span data-testid="item">[{it.done ? "x" : " "}] {it.text}</span>{" "} - <button data-testid="toggle" disabled={pending? checklist} onClick={() => { toggle(sel.id, it.id, !it.done, home(sel.id)) }}>toggle</button> + <button data-testid="toggle" disabled={pending? checklist} onClick={() => { + // `disabled` reads false while a newly selected card's checklist loads (05 DV-22); the handler reads true + if (!(pending? checklist)) { toggle(board.findIndex(r => r.id == sel.id), checklist.findIndex(x => x.id == it.id), sel.id, it.id, !it.done, home(sel.id), sel.col) } + }}>toggle</button> }} </li> }} @@ -321,14 +325,14 @@ export component BoardPage() { <p data-testid="progress">{checklist.filter(it => it.done).length}/{checklist.length} done</p> <input data-testid="item-input" value={itemInput} onInput={e => { itemInput := e.target.value }} placeholder="new item" />{" "} <button data-testid="add-item" disabled={pending? checklist} onClick={() => { - if (itemInput != "") { + if (itemInput != "" && !(pending? checklist)) { let t = itemInput let key = -(minted + 1) onFlush(() => { itemInput := "" minted := n => n + 1 }) - addLine(sel.id, key, t) + addLine(board.findIndex(r => r.id == sel.id), sel.id, key, t) } }}>add item</button> }} diff --git a/eval/results/r15prep/repro/column-memo-loses-guess.variant.spec.tsx b/eval/results/r15prep/repro/column-memo-loses-guess.variant.spec.tsx new file mode 100644 index 00000000..970558eb --- /dev/null +++ b/eval/results/r15prep/repro/column-memo-loses-guess.variant.spec.tsx @@ -0,0 +1,73 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +// Fuzz seeds 24 and 39 (board-rx3), "duplicate card": one card shows in two columns. One keyed optimistic store +// (`board`) over three async answers; each column is a memo filtering it by `col`. Two adds are out, guessed on a +// list of their own (whole-list guesses, so one change); the first is accepted and writes its card into To do's +// answer (held with the other add). Move A (card 3, a pos guess) is accepted and re-asks all three answers; move B +// (card 4 to done, a col guess) is out. Then move C guesses card 3's col = "done": the Done memo shows card 3, and the +// Doing memo still shows it too. +import { test, expect } from "vitest"; +import { For, action, createMemo, createSignal, createOptimisticStore, flush, refresh } from "solid-js"; +import { render } from "../src/index.js"; +const tick = async () => { for (let i = 0; i < 4; i++) { await new Promise(r => setTimeout(r, 0)); flush(); } }; +type Row = { id: number; title: string; col: string; pos: number }; +// the .rx runtime's keyed landing (rt-web.ts landDraft): rows matched by key, leaves merged +function landDraft(draft: any[], v: any[]) { + const had = new Map<unknown, any>(); for (const row of draft) had.set(row.id, row); + v.forEach((x, i) => { const row = had.get(x.id); if (row) { for (const k of Object.keys(x)) if (row[k] !== x[k]) row[k] = x[k]; if (draft[i] !== row) draft[i] = row; } else draft[i] = x; }); + draft.length = v.length; +} +test("a column memo over a store misses a col guess while a re-ask and a held add write are out", async () => { + const fl: Record<string, ((v: any) => void)[]> = {}; + const ask = (k: string) => new Promise<any>(r => (fl[k] ??= []).push(r)); + const land = (k: string, v: any) => { const rs = fl[k] ?? []; if (!rs.length) throw new Error("no ask " + k); fl[k] = []; rs.forEach(r => r(structuredClone(v))); }; + const c = document.body.appendChild(document.createElement("div")); + + const [todoAns, setTodoAns] = createSignal<any>(() => ask("todo"), { loadingValue: null } as any); + const [doingAns] = createSignal<any>(() => ask("doing"), { loadingValue: null } as any); + const [doneAns] = createSignal<any>(() => ask("done"), { loadingValue: null } as any); + const rowsOf = (cs: any[] | null, col: string) => (cs ?? []).map((x, i) => ({ ...x, col, pos: i })); + const [board, setBoard] = createOptimisticStore<Row[]>((d: any) => { landDraft(d, [...rowsOf(todoAns(), "todo"), ...rowsOf(doingAns(), "doing"), ...rowsOf(doneAns(), "done")]); }, [], { key: "id" } as any); + const [adding, setAdding] = createOptimisticStore<Row[]>((d: any) => { landDraft(d, []); }, [], { key: "id" } as any); + const inCol = (col: string) => board.filter(r => r.col === col).sort((x, y) => x.pos - y.pos); + const todo = createMemo(() => [...inCol("todo"), ...adding]); + const doing = createMemo(() => inCol("doing")); + const done = createMemo(() => inCol("done")); + + const reask = action(function* () { refresh(todoAns); refresh(doingAns); refresh(doneAns); }); + const move = action(function* (i: number, id: number, col: string, pos: number) { + try { + setBoard((d: any) => { d[i].col = col; }); + setBoard((d: any) => { d[i].pos = pos; }); + yield ask(`move:${id},${col}`); + yield reask(); + } catch { /* declined */ } + }); + const add = action(function* (key: number, title: string) { + try { + setAdding((d: any) => [...d, { id: key, title, col: "todo", pos: 0 }]); + const r: any = yield ask(`add:${title}`); + setTodoAns((cs: any[]) => [...cs, { id: r.id, title: r.title }]); + } catch { /* declined */ } + }); + const at = (id: number) => board.findIndex(r => r.id === id); // the handler's index, as the app passes it + + const col = (rows: () => Row[]) => <ul><For each={rows()}>{(r: any) => <li>{r.id}</li>}</For></ul>; + render(() => <div>{col(todo)}{col(doing)}{col(done)}</div>, c); + const state = () => [...c.querySelectorAll("ul")].map(u => [...u.querySelectorAll("li")].map(l => l.textContent).join(",")).join(" | "); + + land("todo", [{ id: 1, title: "a" }, { id: 2, title: "b" }]); land("doing", [{ id: 3, title: "c" }, { id: 4, title: "d" }]); land("done", [{ id: 5, title: "e" }]); await tick(); + expect(state()).toBe("1,2 | 3,4 | 5"); + add(-1, "A"); await tick(); add(-2, "B"); await tick(); + land("add:A", { id: 10, title: "A" }); await tick(); // held with B (one change on `adding`) + move(at(3), 3, "doing", 2); await tick(); // down #3 + expect(state()).toBe("1,2,-1,-2 | 4,3 | 5"); + + + land("move:3,doing", {}); await tick(); // accepted: re-asks out + move(at(3), 3, "done", 2); await tick(); // right #3 + console.log("after right #3:", state()); + expect(state()).toBe("1,2,-1,-2 | 4 | 5,3"); +}); diff --git a/eval/results/r15prep/repro/column-memo-misses-guess.spec.tsx b/eval/results/r15prep/repro/column-memo-misses-guess.spec.tsx new file mode 100644 index 00000000..022a72bb --- /dev/null +++ b/eval/results/r15prep/repro/column-memo-misses-guess.spec.tsx @@ -0,0 +1,75 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +// Fuzz seeds 24 and 39 (board-rx3), "duplicate card": one card shows in two columns. One keyed optimistic store +// (`board`) over three async answers; each column is a memo filtering it by `col`. Two adds are out, guessed on a +// list of their own (whole-list guesses, so one change); the first is accepted and writes its card into To do's +// answer (held with the other add). Move A (card 3, a pos guess) is accepted and re-asks all three answers; move B +// (card 4 to done, a col guess) is out. Then move C guesses card 3's col = "done": the Done memo shows card 3, and the +// Doing memo still shows it too. Needs: the held add write (one add out, or none landed: passes) and a re-ask of To do +// plus one more answer (re-asking only one: passes). Variant column-memo-loses-guess.variant.spec.tsx (no move B): +// card 3 shows in neither column. +import { test, expect } from "vitest"; +import { For, action, createMemo, createSignal, createOptimisticStore, flush, refresh } from "solid-js"; +import { render } from "../src/index.js"; +const tick = async () => { for (let i = 0; i < 4; i++) { await new Promise(r => setTimeout(r, 0)); flush(); } }; +type Row = { id: number; title: string; col: string; pos: number }; +// the .rx runtime's keyed landing (rt-web.ts landDraft): rows matched by key, leaves merged +function landDraft(draft: any[], v: any[]) { + const had = new Map<unknown, any>(); for (const row of draft) had.set(row.id, row); + v.forEach((x, i) => { const row = had.get(x.id); if (row) { for (const k of Object.keys(x)) if (row[k] !== x[k]) row[k] = x[k]; if (draft[i] !== row) draft[i] = row; } else draft[i] = x; }); + draft.length = v.length; +} +test("a column memo over a store misses a col guess while a re-ask and a held add write are out", async () => { + const fl: Record<string, ((v: any) => void)[]> = {}; + const ask = (k: string) => new Promise<any>(r => (fl[k] ??= []).push(r)); + const land = (k: string, v: any) => { const rs = fl[k] ?? []; if (!rs.length) throw new Error("no ask " + k); fl[k] = []; rs.forEach(r => r(structuredClone(v))); }; + const c = document.body.appendChild(document.createElement("div")); + + const [todoAns, setTodoAns] = createSignal<any>(() => ask("todo"), { loadingValue: null } as any); + const [doingAns] = createSignal<any>(() => ask("doing"), { loadingValue: null } as any); + const [doneAns] = createSignal<any>(() => ask("done"), { loadingValue: null } as any); + const rowsOf = (cs: any[] | null, col: string) => (cs ?? []).map((x, i) => ({ ...x, col, pos: i })); + const [board, setBoard] = createOptimisticStore<Row[]>((d: any) => { landDraft(d, [...rowsOf(todoAns(), "todo"), ...rowsOf(doingAns(), "doing"), ...rowsOf(doneAns(), "done")]); }, [], { key: "id" } as any); + const [adding, setAdding] = createOptimisticStore<Row[]>((d: any) => { landDraft(d, []); }, [], { key: "id" } as any); + const inCol = (col: string) => board.filter(r => r.col === col).sort((x, y) => x.pos - y.pos); + const todo = createMemo(() => [...inCol("todo"), ...adding]); + const doing = createMemo(() => inCol("doing")); + const done = createMemo(() => inCol("done")); + + const reask = action(function* () { refresh(todoAns); refresh(doingAns); refresh(doneAns); }); + const move = action(function* (i: number, id: number, col: string, pos: number) { + try { + setBoard((d: any) => { d[i].col = col; }); + setBoard((d: any) => { d[i].pos = pos; }); + yield ask(`move:${id},${col}`); + yield reask(); + } catch { /* declined */ } + }); + const add = action(function* (key: number, title: string) { + try { + setAdding((d: any) => [...d, { id: key, title, col: "todo", pos: 0 }]); + const r: any = yield ask(`add:${title}`); + setTodoAns((cs: any[]) => [...cs, { id: r.id, title: r.title }]); + } catch { /* declined */ } + }); + const at = (id: number) => board.findIndex(r => r.id === id); // the handler's index, as the app passes it + + const col = (rows: () => Row[]) => <ul><For each={rows()}>{(r: any) => <li>{r.id}</li>}</For></ul>; + render(() => <div>{col(todo)}{col(doing)}{col(done)}</div>, c); + const state = () => [...c.querySelectorAll("ul")].map(u => [...u.querySelectorAll("li")].map(l => l.textContent).join(",")).join(" | "); + + land("todo", [{ id: 1, title: "a" }, { id: 2, title: "b" }]); land("doing", [{ id: 3, title: "c" }, { id: 4, title: "d" }]); land("done", [{ id: 5, title: "e" }]); await tick(); + expect(state()).toBe("1,2 | 3,4 | 5"); + add(-1, "A"); await tick(); add(-2, "B"); await tick(); + land("add:A", { id: 10, title: "A" }); await tick(); // held with B (one change on `adding`) + move(at(3), 3, "doing", 2); await tick(); // down #3 + expect(state()).toBe("1,2,-1,-2 | 4,3 | 5"); + move(at(4), 4, "done", 1); await tick(); // right #4 + expect(state()).toBe("1,2,-1,-2 | 3 | 5,4"); + land("move:3,doing", {}); await tick(); // accepted: re-asks out + move(at(3), 3, "done", 2); await tick(); // right #3 + console.log("after right #3:", state()); + expect(state()).toBe("1,2,-1,-2 | | 5,4,3"); +}); diff --git a/eval/results/r15prep/repro/column-memo-misses-guess.steps.mjs b/eval/results/r15prep/repro/column-memo-misses-guess.steps.mjs new file mode 100644 index 00000000..9e6ca5e7 --- /dev/null +++ b/eval/results/r15prep/repro/column-memo-misses-guess.steps.mjs @@ -0,0 +1,14 @@ +// board-rx3 (eval/reference/board/rx-store) repro of column-memo-misses-guess (fuzz seeds 24, 39): node probe-rx.mjs <desk> $PWD/column-memo-misses-guess.steps.mjs +// last dump: #3 shows in Doing and in Done. Seed 39 reduced: land done/doing/todo ; add Delta 1 ; add Bravo 2 ; land addCard:todo,Delta 1 ; down #3 ; right #4 ; land moveCard:3,doing,1 ; right #3 +const land = (k, v) => p => p.evaluate(([k, v]) => globalThis.__flights.land(k, v), [k, v]); +const TODO = [{ id: 1, title: "Write spec", done: 0, total: 2 }, { id: 2, title: "Fix login", done: 1, total: 3 }]; +const DOING = [{ id: 3, title: "Review PR", done: 2, total: 2 }, { id: 4, title: "Deploy", done: 0, total: 1 }]; +const DONE = [{ id: 5, title: "Kickoff", done: 3, total: 3 }]; +const loaded = async p => { await land("fetchCards:todo", TODO)(p); await land("fetchCards:doing", DOING)(p); await land("fetchCards:done", DONE)(p); }; +const near = (p, id, b) => p.locator('li', { has: p.locator(`[data-testid=card]:text-matches("^#${id} ")`) }).locator(`[data-testid=${b}]`).click(); +const typeClick = (tid, text, btn) => async p => { await p.fill(`[data-testid=${tid}]`, text); await p.click(`[data-testid=${btn}]`); }; +const s = [["load", loaded], ["add A", typeClick("new-card", "A", "add-card")], ["add B", typeClick("new-card", "B", "add-card")], + ["land A", land("addCard:todo,A", { id: 10, title: "A" })], ["down #3", p => near(p, 3, "down")]]; +s.push(["right #4", p => near(p, 4, "right")]); +s.push(["land move3", land("moveCard:3,doing,1", { id: 3, column: "doing", index: 1 })], ["right #3", p => near(p, 3, "right")]); +export default s; diff --git a/eval/results/r15prep/repro/detail-memo-misses-guess.attempt.spec.tsx b/eval/results/r15prep/repro/detail-memo-misses-guess.attempt.spec.tsx new file mode 100644 index 00000000..fccec8b0 --- /dev/null +++ b/eval/results/r15prep/repro/detail-memo-misses-guess.attempt.spec.tsx @@ -0,0 +1,51 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +// Fuzz seed 46 (board-rx3), "two views". One keyed optimistic store over two async answers. Move A (row 3) is +// accepted and its action re-asks both answers (held until they land). While the re-asks are out, rename B guesses +// row 5's title. The row's own text shows the guess; a memo that finds row 5 in the store (the detail's `sel`) and +// a text reading `sel().title` keeps the old title. NOT REPRODUCED here (passes): in the app it needed the add +// placeholder guessed into the store as a row; with the placeholder in a list of its own (the guide) it is gone. +import { test, expect } from "vitest"; +import { For, action, createMemo, createSignal, createOptimisticStore, flush, refresh } from "solid-js"; +import { render } from "../src/index.js"; +const tick = async () => { for (let i = 0; i < 4; i++) { await new Promise(r => setTimeout(r, 0)); flush(); } }; +type Row = { id: number; title: string; col: string }; +test("a memo finding a row in a store whose truth is re-asked misses another action's guess on that row", async () => { + const fl: Record<string, ((v: any) => void)[]> = {}; + const ask = (k: string) => new Promise<any>(r => (fl[k] ??= []).push(r)); + const land = (k: string, v: any) => { const rs = fl[k] ?? []; fl[k] = []; rs.forEach(r => r(structuredClone(v))); }; + const c = document.body.appendChild(document.createElement("div")); + + const [todoAns] = createSignal<any>(() => ask("todo"), { loadingValue: null } as any); + const [doneAns] = createSignal<any>(() => ask("done"), { loadingValue: null } as any); + const [board, guess] = createOptimisticStore<Row[]>(() => [ + ...(todoAns() ?? []).map((r: any) => ({ ...r, col: "todo" })), + ...(doneAns() ?? []).map((r: any) => ({ ...r, col: "done" })), + ], [], { key: "id" }); + const sel = createMemo(() => board.find(r => r.id === 5) ?? null); + const reask = action(function* () { refresh(todoAns); refresh(doneAns); }); + const move = action(function* (i: number) { + guess(b => { b[i].col = "done"; }); + yield ask("move"); + yield reask(); + }); + const rename = action(function* (i: number, t: string) { + guess(b => { b[i].title = t; }); + yield ask("rename"); + }); + render(() => <div> + <ul><For each={board}>{r => <li>{r().id}:{r().title}</li>}</For></ul> + <h2>{sel()?.title}</h2> + </div>, c); + const state = () => `${[...c.querySelectorAll("li")].map(l => l.textContent).join(",")} | ${c.querySelector("h2")!.textContent}`; + + land("todo", [{ id: 3, title: "R" }]); land("done", [{ id: 5, title: "K" }]); await tick(); + expect(state()).toBe("3:R,5:K | K"); + move(0); await tick(); + land("move", {}); await tick(); // accepted: both re-asks out + rename(board.findIndex(r => r.id === 5), "B"); await tick(); + console.log("after rename guess:", state()); + expect(state()).toBe("3:R,5:B | B"); +}); From 38b2125eab754f79f95103c302c673d81af6b596 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 15 Nov 2026 05:55:18 +0000 Subject: [PATCH 23/32] eval: board .rx placeholder back in the store, per-column store version, two Solid repros Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- eval/reference/board/rx-cols/FUZZ-21-60.txt | 17 + eval/reference/board/rx-cols/GRADE.txt | 14 + eval/reference/board/rx-cols/archive.rx | 46 +++ eval/reference/board/rx-cols/board.rx | 359 ++++++++++++++++++ eval/reference/board/rx-cols/main.rx | 21 + eval/reference/board/rx-store/board.rx | 15 +- .../cols-move-guess-missing.attempt.spec.tsx | 59 +++ .../repro/cols-move-guess-missing.steps.mjs | 17 + .../detail-memo-misses-guess.attempt.spec.tsx | 51 --- .../repro/detail-memo-misses-guess.steps.mjs | 19 + .../selection-memo-misses-guess.spec.tsx | 61 +++ guide/async.md | 6 +- 12 files changed, 623 insertions(+), 62 deletions(-) create mode 100644 eval/reference/board/rx-cols/FUZZ-21-60.txt create mode 100644 eval/reference/board/rx-cols/GRADE.txt create mode 100644 eval/reference/board/rx-cols/archive.rx create mode 100644 eval/reference/board/rx-cols/board.rx create mode 100644 eval/reference/board/rx-cols/main.rx create mode 100644 eval/results/r15prep/repro/cols-move-guess-missing.attempt.spec.tsx create mode 100644 eval/results/r15prep/repro/cols-move-guess-missing.steps.mjs delete mode 100644 eval/results/r15prep/repro/detail-memo-misses-guess.attempt.spec.tsx create mode 100644 eval/results/r15prep/repro/detail-memo-misses-guess.steps.mjs create mode 100644 eval/results/r15prep/repro/selection-memo-misses-guess.spec.tsx diff --git a/eval/reference/board/rx-cols/FUZZ-21-60.txt b/eval/reference/board/rx-cols/FUZZ-21-60.txt new file mode 100644 index 00000000..5e5cf664 --- /dev/null +++ b/eval/reference/board/rx-cols/FUZZ-21-60.txt @@ -0,0 +1,17 @@ +board-rx3: 8/40 seeds found a hole + seed 24 step 26: duplicate card: #1 shows in todo and doing + land fetchCards:done ; land fetchCards:doing ; land fetchCards:todo ; add Bravo 1 ; land addCard:todo,Bravo 1 ; right #10 ; add Bravo 2 ; land addCard:todo,Bravo 2 ; up #10 ; land moveCard:10,doing,2 ; add Alpha 3 ; land fetchCards:doing ; left #3 ; add Bravo 4 ; down #11 ; right #4 ; fail addCard:todo,Bravo 4 ; right #3 ; add Delta 5 ; land addCard:todo,Delta 5 ; right #10 ; land moveCard:10,done,2 ; down #5 ; down #2 ; fail moveCard:2,todo,2 ; right #1 + seed 25 step 11: duplicate card: "Delta 1 (adding…)" shows 1 time(s) with 0 add(s) of it in the air, beside its added card + tab archive ; tab board ; land fetchCards:todo ; up #2 ; land fetchCards:doing ; fail moveCard:2,todo,0 ; land fetchCards:done ; add Delta 1 ; add Alpha 2 ; land fetchArchived: ; land addCard:todo,Delta 1 + seed 28 step 32: duplicate card: #4 shows in doing and doing + tab archive ; tab board ; land fetchCards:doing ; land fetchArchived: ; add Delta 1 ; land fetchCards:todo ; select #1 ; select #2 ; land fetchChecklist:1 ; land fetchChecklist:2 ; rename Alpha ; rename Delta ; land renameCard:2,Delta ; rename Alpha ; land fetchCards:todo ; rename Bravo ; fail renameCard:2,Alpha ; fail renameCard:2,Bravo ; up #4 ; up #2 ; add Delta 2 ; fail moveCard:2,todo,0 ; left #4 ; archive #2 ; land addCard:todo,Delta 2 ; land archiveCard:2 ; land moveCard:4,todo,3 ; la + seed 36 step 28: two views: card "#5 Delta · 3/3", detail "#5 Kickoffin Doing rename archivechecklist error: declined" + tab archive ; land fetchCards:todo ; tab board ; tab archive ; land fetchArchived: ; fail fetchCards:todo ; tab board ; add Delta 1 ; add Alpha 2 ; land fetchCards:doing ; land fetchCards:done ; land fetchCards:todo ; left #5 ; land moveCard:5,doing,2 ; land fetchCards:doing ; land fetchCards:done ; select #5 ; add Delta 3 ; land addCard:todo,Delta 3 ; down #1 ; down #3 ; add Bravo 4 ; down #10 ; land moveCard:10,todo,3 ; fail fetchChecklist:5 ; rename Delta + seed 39 step 6: duplicate card: "Delta 1 (adding…)" shows 1 time(s) with 0 add(s) of it in the air, beside its added card + land fetchCards:done ; land fetchCards:doing ; land fetchCards:todo ; add Delta 1 ; add Bravo 2 ; land addCard:todo,Delta 1 + seed 46 step 14: two views: card "#5 Bravo · 3/3", detail "#5 Kickoffin Done rename archive[x] invite toggle[x] agenda toggle[x] notes togg" + land fetchCards:done ; select #5 ; select #5 ; land fetchChecklist:5 ; land fetchCards:doing ; land fetchCards:todo ; down #3 ; right #1 ; left #3 ; add Delta 1 ; land moveCard:3,todo,1 ; down #4 ; up #3 ; rename Bravo + seed 48 step 32: card count: card "#5 Bravo · 3/4", server 3/3 with 0 toggle(s) and 0 item add(s) of it in the air + add Alpha 1 ; land fetchCards:doing ; land fetchCards:todo ; left #3 ; right #3 ; land moveCard:3,doing,1 ; land fetchCards:done ; land moveCard:3,todo,2 ; select #3 ; land fetchCards:doing ; tab archive ; land fetchChecklist:3 ; tab board ; add Bravo 2 ; land fetchArchived: ; add Bravo 3 ; land fetchCards:done ; select #5 ; select #5 ; land fetchCards:todo ; land fetchCards:doing ; land fetchChecklist:5 ; rename Bravo ; up #3 ; fail renameCard:5,Bravo ; land moveCard:3,todo,1 ; rename Bravo + seed 57 step 28: count: card "#1 Alpha · 1/3", progress "0/3 done" + add Alpha 1 ; add Bravo 2 ; land fetchCards:doing ; down #3 ; land fetchCards:todo ; left #3 ; add Delta 3 ; left #4 ; right #3 ; select #1 ; rename Alpha ; land moveCard:4,todo,4 ; land fetchChecklist:1 ; up #4 ; left #3 ; down #4 ; add-item ship 4 ; select #4 ; right #3 ; land fetchCards:doing ; down #2 ; land addCard:todo,Delta 3 ; land moveCard:4,todo,3 ; fail moveCard:3,todo,4 ; land renameCard:1,Alpha ; toggle 0 diff --git a/eval/reference/board/rx-cols/GRADE.txt b/eval/reference/board/rx-cols/GRADE.txt new file mode 100644 index 00000000..e8f2fbab --- /dev/null +++ b/eval/reference/board/rx-cols/GRADE.txt @@ -0,0 +1,14 @@ +== hidden +✗ two adds show both, each taking its own id +44/45 passed +== overlap +✗ a toggle declined after another card was selected puts the first card's count back +25/26 passed +== fuzz +board-rx3: 3/20 seeds found a hole + seed 11 step 46: duplicate card: "# · / (adding…)" shows 1 time(s) with 0 add(s) of it in the air + add Bravo 1 ; (move failed: locator.click: Timeout 2000ms exceeded.) ; tab archive ; tab board ; add Bravo 2 ; (move failed: locator.click: Timeout 2000ms exceeded.) ; tab archive ; tab board ; add Alpha 3 ; (move failed: locator.click: Timeout 2000ms exceeded.) ; land fetchCards:doing ; select #4 ; down #3 ; rename Alpha ; up #3 ; archive #4 ; land fetchCards:done ; land moveCard:3,doing,1 ; + seed 14 step 17: count: card "#2 Fix login · 1/4", progress "2/4 done" + add Bravo 1 ; (move failed: locator.click: Timeout 2000ms exceeded.) ; land fetchCards:todo ; select #2 ; up #2 ; select #2 ; land fetchCards:done ; land fetchChecklist:2 ; down #2 ; add-item ship 2 ; land fetchCards:doing ; land moveCard:2,todo,0 ; land moveCard:2,todo,1 ; toggle 2 ; right #3 ; select #4 ; toggle 0 + seed 15 step 16: duplicate card: "Alpha 1 (adding…)" shows 1 time(s) with 0 add(s) of it in the air, beside its added card + land fetchCards:doing ; select #4 ; up #4 ; land fetchCards:done ; fail moveCard:4,doing,0 ; land fetchCards:todo ; rename Bravo ; fail fetchChecklist:4 ; select #4 ; land renameCard:4,Bravo ; add Alpha 1 ; add Alpha 2 ; land fetchCards:doing ; add Bravo 3 ; select #5 ; land addCard:todo,Alpha 1 diff --git a/eval/reference/board/rx-cols/archive.rx b/eval/reference/board/rx-cols/archive.rx new file mode 100644 index 00000000..f2102204 --- /dev/null +++ b/eval/reference/board/rx-cols/archive.rx @@ -0,0 +1,46 @@ +// The archive page: one optimistic store over the server's archive, as the board page is. A restore's guess is a +// path write into it; an accepted restore re-asks the archive and holds until it lands. Everything here is the +// visit's: the page is mounted only while it is open. + +import pure { fetchArchived } from "./api" +import { restoreCard } from "./api" + +type Shown = { id: number; title: string; gone: boolean } + +export component ArchivePage() { + archived key id = (await fetchArchived()).map(a => ({ id: a.id, title: a.title, gone: false })) + source restoreError: string := "" + + action restore(id: number) { + try { + let i = archived.findIndex(a => a.id == id) + guess archived[i].gone := true + onFlush(() => { restoreError := "" }) + await restoreCard(id) + refresh archived + } catch (e) { + restoreError := e.message + onFlush(() => { restoreError := e.message }) + } + } + + <section> + <div data-testid="archived"> + {loading fallback { <p>loading archive</p> } { + {errored fallback(e) { <p>archive error: {e.message}</p> } { + <ul> + {for a in archived.filter(a => !a.gone) { + <li> + <span data-testid="archived-card">#{a.id} {a.title}</span>{" "} + <button data-testid="restore" onClick={() => { restore(a.id) }}>restore</button> + </li> + }} + </ul> + }} + }} + </div> + {if restoreError != "" { + <p data-testid="restore-error">restore error: {restoreError}</p> + }} + </section> +} diff --git a/eval/reference/board/rx-cols/board.rx b/eval/reference/board/rx-cols/board.rx new file mode 100644 index 00000000..58e1b8c1 --- /dev/null +++ b/eval/reference/board/rx-cols/board.rx @@ -0,0 +1,359 @@ +// The board page: one optimistic store per server answer. +// +// The server answers per column, so its board is three ordered lists, and the page keeps them as three keyed stores +// (`todo`, `doing`, `done`), each the answer it stands for, row for row. A card shows where its column's list puts it, +// so the order on screen is the server's order and a move is what the server does: the card leaves one list and goes +// into another (or the same one) at an index. +// +// Every write has the same life. Its guess is a write into the stores (or into the checklist), shown at once: a path +// write on one row for an edit, the lists' new shape for a move. The write goes to the server; when it is accepted, +// the action re-asks the answers it changed, and holds until they land (D-34), so the guess is dropped onto data that +// already agrees with it. When it is declined there was only ever the guess, and removing it is the undo. Errors are +// plain client state under the guesses: a refusal is a fact about one request, and no answer has a place for it. +// +// An add has no id until the server answers. Its placeholder is guessed into To do's store under a key minted here, +// for this visit only, and is inert (no move buttons, not selectable). The server's answer is the new card itself, so +// the action records it in To do's answer (a write whose answer is the new value re-asks nothing) and ends: in one +// frame the placeholder goes with its guess and the real card shows from the truth. An added item is the same over +// the checklist's answer. +// +// Indexes and columns are worked out in the handler, from the board as shown, and passed in: an action that read a +// store another action guessed on would be one change with it (D-33). +// +// Everything here is the visit's: the page is mounted only while it is open, so leaving it discards the stores, their +// guesses and its errors, and opening it again asks the server again. + +import pure { fetchCards, fetchChecklist } from "./api" +import { moveCard, renameCard, addCard, archiveCard, toggleItem, addItem, Card, Item } from "./api" + +export type Row = { id: number; title: string; done: number; total: number; adding: boolean } + +fn colName(c: string): string => c == "todo" ? "To do" : c == "doing" ? "Doing" : "Done" +fn rowsOf(cs: Card[] | null): Row[] => + cs == null ? [] : cs.map(c => ({ id: c.id, title: c.title, done: c.done, total: c.total, adding: false })) +type List = { card: number; items: Item[] } +fn withCard(cs: Card[] | null, c: Card): Card[] | null => cs == null || cs.some(x => x.id == c.id) ? cs : [...cs, c] +fn counted(cs: Card[] | null, id: number): Card[] | null => cs == null ? cs : cs.map(c => c.id == id ? { ...c, total: c.total + 1 } : c) +fn withItem(l: List | null, card: number, it: Item): List | null => + l == null || l.card != card || l.items.some(x => x.id == it.id) ? l : { card: card, items: [...l.items, it] } +fn has(cs: Card[] | null, id: number): boolean => cs != null && cs.some(c => c.id == id) + +// A move as the server makes it: the card leaves `from` and goes into `to` at `index`, counted without it. +type Move = { id: number; from: string; to: string; index: number; row: Row } +fn without(rs: Row[], id: number): Row[] => rs.filter(r => r.id != id) +fn insertAt(rs: Row[], r: Row, i: number): Row[] => [...rs.slice(0, i), r, ...rs.slice(i)] +// one column's list after the move +fn shifted(rs: Row[], col: string, m: Move): Row[] => + col == m.to ? insertAt(col == m.from ? without(rs, m.id) : rs, m.row, m.index) : col == m.from ? without(rs, m.id) : rs + +component Column(props: { + col: string; ans: Card[] | null; rows: Row[]; retrying: boolean; left: string; right: string; + leftOpen: boolean; rightOpen: boolean; onRetry: () => void; onSelect: (id: number) => void; onMove: (id: number, col: string, by: number) => void; +}) { + <section data-testid={`col-${props.col}`} style="min-width: 14rem"> + {errored fallback(e) { + <h2 data-testid={`head-${props.col}`}>{colName(props.col)}</h2> + {if props.retrying { + <p>loading</p> + } else { + <p>error: {e.message}{" "}<button data-testid={`retry-${props.col}`} onClick={() => { props.onRetry() }}>retry</button></p> + }} + } { + {if props.ans == null { + <h2 data-testid={`head-${props.col}`}>{colName(props.col)}</h2> + <p>loading</p> + } else { + <h2 data-testid={`head-${props.col}`}>{colName(props.col)} ({props.rows.length})</h2> + <ul> + {for r in props.rows { + <li> + {if r.adding { + <span data-testid="card">{r.title} (adding…)</span> + } else { + <button data-testid="card" onClick={() => { props.onSelect(r.id) }}>#{r.id} {r.title} · {r.done}/{r.total}</button> + {if props.left != "" { + {" "}<button data-testid="left" disabled={!props.leftOpen} onClick={() => { props.onMove(r.id, props.left, 0) }}>left</button> + }} + {if props.right != "" { + {" "}<button data-testid="right" disabled={!props.rightOpen} onClick={() => { props.onMove(r.id, props.right, 0) }}>right</button> + }} + {if props.rows[0].id != r.id { + {" "}<button data-testid="up" onClick={() => { props.onMove(r.id, "", -1) }}>up</button> + }} + {if props.rows[props.rows.length - 1].id != r.id { + {" "}<button data-testid="down" onClick={() => { props.onMove(r.id, "", 1) }}>down</button> + }} + }} + </li> + }} + </ul> + }} + }} + </section> +} + +export component BoardPage() { + // 1. The server's truth: one question per column, `null` until its first answer, so one column loading or failing + // never holds another. Sources with a question: an accepted add writes its answer here (the answer is the new card), + // until the next re-ask brings the server's list. + source todoAns = await fetchCards("todo") -> null + source doingAns = await fetchCards("doing") -> null + source doneAns = await fetchCards("done") -> null + + // 2. One store over each answer, keyed by card: what the column shows, guesses included. + todo key id = rowsOf(todoSeen) + doing key id = rowsOf(doingSeen) + done key id = rowsOf(doneSeen) + // each answer as the other panes read it: `null` while it loads or after it failed + todoSeen = todoAns catch (e) => null + doingSeen = doingAns catch (e) => null + doneSeen = doneAns catch (e) => null + todoUp = todoSeen != null + doingUp = doingSeen != null + doneUp = doneSeen != null + // the work-in-progress limit counts the cards Doing shows, moves still out included + intoDoing = doingUp && doing.length < 3 + // the column whose answer holds a card: what a write to it changes on the server, whatever the guesses show + fn home(id: number): string => has(todoSeen, id) ? "todo" : has(doingSeen, id) ? "doing" : "done" + + // The selection and its checklist, the other store. + source selected: number | null := null + // the answer names its card, so an item add answered after another card was selected leaves it alone + source listAns = selected == null ? null : { card: selected, items: await fetchChecklist(selected) } + checklist key id = listAns == null ? [] : listAns.items.map(it => ({ id: it.id, text: it.text, done: it.done, adding: false })) + // the column a card shows in, and its row there + fn shownIn(id: number): string => todo.some(r => r.id == id) ? "todo" : doing.some(r => r.id == id) ? "doing" : done.some(r => r.id == id) ? "done" : "" + fn rowsIn(col: string): Row[] => col == "todo" ? todo : col == "doing" ? doing : col == "done" ? done : [] + selCol = selected == null ? "" : shownIn(selected) + sel = selected == null ? null : rowsIn(selCol).find(r => r.id == selected && !r.adding) ?? null + + // Inputs, and what went wrong: plain client state of this visit. + source newCard: string := "" + source titleInput: string := "" + source itemInput: string := "" + source minted: number := 0 + source moveError: string := "" + source addError: string := "" + source archiveError: string := "" + source renameError: string := "" + source itemError: string := "" + + // selecting starts the detail as it starts: its inputs empty, its errors gone + action pick(id: number) { + selected := id + titleInput := "" + itemInput := "" + renameError := "" + itemError := "" + } + + // An accepted card write ends by re-asking the answers it changed: for a move the one that held the card and the + // one it joined, for an edit the one that holds it. Which answer holds it is read in the handler, as committed, and + // an accepted move still re-asking is not committed yet (its answers land together), so the column the card shows + // in is re-asked too, which is where that move put it. + action reask(cols: string[]) { + if (cols.includes("todo")) { refresh todoAns } + if (cols.includes("doing")) { refresh doingAns } + if (cols.includes("done")) { refresh doneAns } + } + + action retry(col: string) { + if (col == "todo") { + affects todoAns + refresh todoAns + } else if (col == "doing") { + affects doingAns + refresh doingAns + } else { + affects doneAns + refresh doneAns + } + } + + // The move is worked out by the click, from the board as shown, and guessed as the server will make it: each + // column's list after it (the lists it leaves alone don't change). + action move(m: Move, from: string) { + try { + guess todo := rs => shifted(rs, "todo", m) + guess doing := rs => shifted(rs, "doing", m) + guess done := rs => shifted(rs, "done", m) + onFlush(() => { moveError := "" }) + await moveCard(m.id, m.to, m.index) + await reask([from, m.from, m.to]) + } catch (e) { + moveError := e.message + onFlush(() => { moveError := e.message }) + } + } + + action add(key: number, title: string, at: number) { + try { + guess todo[at] := { id: key, title: title, done: 0, total: 0, adding: true } + onFlush(() => { addError := "" }) + let r = await addCard("todo", title) + todoAns := cs => withCard(cs, { id: r.id, title: r.title, done: 0, total: 0 }) + } catch (e) { + addError := e.message + onFlush(() => { addError := e.message }) + } + } + + action rename(i: number, id: number, title: string, col: string, shown: string) { + try { + if (shown == "todo") { guess todo[i].title := title } else if (shown == "doing") { guess doing[i].title := title } else { guess done[i].title := title } + onFlush(() => { renameError := "" }) + await renameCard(id, title) + await reask([col, shown]) + } catch (e) { + renameError := e.message + onFlush(() => { renameError := e.message }) + } + } + + action archive(i: number, id: number, col: string, shown: string) { + try { + if (shown == "todo") { guess todo := rs => without(rs, id) } else if (shown == "doing") { guess doing := rs => without(rs, id) } else { guess done := rs => without(rs, id) } + onFlush(() => { + selected := null + archiveError := "" + }) + await archiveCard(id) + await reask([col, shown]) + } catch (e) { + archiveError := e.message + onFlush(() => { archiveError := e.message }) + } + } + + action toggle(i: number, j: number, card: number, item: number, to: boolean, col: string, shown: string) { + try { + guess checklist[j].done := to + if (shown == "todo") { guess todo[i].done := n => n + (to ? 1 : -1) } else if (shown == "doing") { guess doing[i].done := n => n + (to ? 1 : -1) } else { guess done[i].done := n => n + (to ? 1 : -1) } + onFlush(() => { itemError := "" }) + await toggleItem(card, item, to) + refresh listAns + await reask([col, shown]) + } catch (e) { + itemError := e.message + onFlush(() => { itemError := e.message }) + } + } + + action addLine(i: number, card: number, key: number, text: string, shown: string) { + try { + guess checklist := ls => [...ls, { id: key, text: text, done: false, adding: true }] + if (shown == "todo") { guess todo[i].total := n => n + 1 } else if (shown == "doing") { guess doing[i].total := n => n + 1 } else { guess done[i].total := n => n + 1 } + onFlush(() => { itemError := "" }) + let it = await addItem(card, text) + listAns := l => withItem(l, card, it) + todoAns := cs => counted(cs, card) + doingAns := cs => counted(cs, card) + doneAns := cs => counted(cs, card) + } catch (e) { + itemError := e.message + onFlush(() => { itemError := e.message }) + } + } + + // a move button: `col` set, to the bottom of that column; `col` empty, `by` places up or down in its own + fn moveOf(id: number, col: string, by: number): Move | null => + placed(rowsIn(shownIn(id)), id, shownIn(id), col != "" ? col : shownIn(id), + col != "" ? rowsIn(col).length : rowsIn(shownIn(id)).findIndex(r => r.id == id) + by) + fn placed(rs: Row[], id: number, from: string, to: string, index: number): Move | null => + rs.some(r => r.id == id) ? ({ id: id, from: from, to: to, index: index, row: rs.filter(r => r.id == id)[0] }) : null + + <div> + <div style="display: flex; gap: 1.5rem; align-items: flex-start"> + <div> + <Column col="todo" ans={todoAns} rows={todo} retrying={pending? todoAns} left="" right="doing" leftOpen={false} rightOpen={intoDoing} + onRetry={() => { retry("todo") }} onSelect={id => { pick(id) }} onMove={(id, c, by) => { let m = moveOf(id, c, by); if (m != null) { move(m, home(id)) } }} /> + <div> + <input data-testid="new-card" value={newCard} onInput={e => { newCard := e.target.value }} placeholder="new card" />{" "} + <button data-testid="add-card" disabled={!todoUp} onClick={() => { + if (newCard != "") { + let t = newCard + let key = -(minted + 1) + onFlush(() => { + newCard := "" + minted := n => n + 1 + }) + add(key, t, todo.length) + } + }}>add</button> + {if addError != "" { + <p data-testid="add-error">add error: {addError}</p> + }} + </div> + </div> + <Column col="doing" ans={doingAns} rows={doing} retrying={pending? doingAns} left="todo" right="done" leftOpen={todoUp} rightOpen={doneUp} + onRetry={() => { retry("doing") }} onSelect={id => { pick(id) }} onMove={(id, c, by) => { let m = moveOf(id, c, by); if (m != null) { move(m, home(id)) } }} /> + <Column col="done" ans={doneAns} rows={done} retrying={pending? doneAns} left="doing" right="" leftOpen={intoDoing} rightOpen={false} + onRetry={() => { retry("done") }} onSelect={id => { pick(id) }} onMove={(id, c, by) => { let m = moveOf(id, c, by); if (m != null) { move(m, home(id)) } }} /> + <section data-testid="detail" style="min-width: 16rem"> + {if sel == null { + <p>select a card</p> + } else { + <h2>#{sel.id} {sel.title}</h2> + <p>in {colName(selCol)}</p> + <div> + <input data-testid="title-input" value={titleInput} onInput={e => { titleInput := e.target.value }} placeholder="new title" />{" "} + <button data-testid="rename" onClick={() => { + if (titleInput != "") { + let t = titleInput + onFlush(() => { titleInput := "" }) + rename(rowsIn(selCol).findIndex(r => r.id == sel.id), sel.id, t, home(sel.id), selCol) + } + }}>rename</button>{" "} + <button data-testid="archive" onClick={() => { archive(rowsIn(selCol).findIndex(r => r.id == sel.id), sel.id, home(sel.id), selCol) }}>archive</button> + </div> + {if renameError != "" { + <p>rename error: {renameError}</p> + }} + <div data-testid="checklist"> + {loading on(selected) fallback { <p>loading checklist</p> } { + {errored fallback(e) { <p>checklist error: {e.message}</p> } { + <ul> + {for it in checklist { + <li> + {if it.adding { + <span data-testid="item">[ ] {it.text} (adding…)</span> + } else { + <span data-testid="item">[{it.done ? "x" : " "}] {it.text}</span>{" "} + <button data-testid="toggle" disabled={pending? checklist} onClick={() => { + // `disabled` reads false while a newly selected card's checklist loads (05 DV-22); the handler reads true + if (!(pending? checklist)) { toggle(rowsIn(selCol).findIndex(r => r.id == sel.id), checklist.findIndex(x => x.id == it.id), sel.id, it.id, !it.done, home(sel.id), selCol) } + }}>toggle</button> + }} + </li> + }} + </ul> + <p data-testid="progress">{checklist.filter(it => it.done).length}/{checklist.length} done</p> + <input data-testid="item-input" value={itemInput} onInput={e => { itemInput := e.target.value }} placeholder="new item" />{" "} + <button data-testid="add-item" disabled={pending? checklist} onClick={() => { + if (itemInput != "" && !(pending? checklist)) { + let t = itemInput + let key = -(minted + 1) + onFlush(() => { + itemInput := "" + minted := n => n + 1 + }) + addLine(rowsIn(selCol).findIndex(r => r.id == sel.id), sel.id, key, t, selCol) + } + }}>add item</button> + }} + }} + </div> + {if itemError != "" { + <p>item error: {itemError}</p> + }} + }} + </section> + </div> + {if moveError != "" { + <p data-testid="move-error">move error: {moveError}</p> + }} + {if archiveError != "" { + <p data-testid="archive-error">archive error: {archiveError}</p> + }} + </div> +} diff --git a/eval/reference/board/rx-cols/main.rx b/eval/reference/board/rx-cols/main.rx new file mode 100644 index 00000000..a6b7cdf1 --- /dev/null +++ b/eval/reference/board/rx-cols/main.rx @@ -0,0 +1,21 @@ +// The sprint board: two pages behind tabs. Only the open page is mounted, so leaving one discards it, and opening it +// asks for its data again. + +import { BoardPage } from "./board.rx" +import { ArchivePage } from "./archive.rx" + +export default component App() { + source page: string := "board" + + <div> + <nav> + <button data-testid="tab-board" onClick={() => { page := "board" }}>Board</button>{" "} + <button data-testid="tab-archive" onClick={() => { page := "archive" }}>Archive</button> + </nav> + {if page == "board" { + <BoardPage /> + } else { + <ArchivePage /> + }} + </div> +} diff --git a/eval/reference/board/rx-store/board.rx b/eval/reference/board/rx-store/board.rx index 5539aa4f..9db2b124 100644 --- a/eval/reference/board/rx-store/board.rx +++ b/eval/reference/board/rx-store/board.rx @@ -10,9 +10,8 @@ // there was only ever the guess, and removing it is the undo. Errors are plain client state under the guesses: a // refusal is a fact about one request, and no answer has a place for it. // -// An add has no id until the server answers, so it has no row to guess on: its placeholder is guessed into a list of -// its own (`adding`, shown under To do's cards) under a key minted here, for this visit only, and is inert (no move -// buttons, not selectable). The server's answer is the new card itself, so the action records it in To do's answer (a +// An add has no id until the server answers. Its placeholder is guessed into the store under a key minted here, for +// this visit only, and is inert (no move buttons, not selectable). The server's answer is the new card itself, so the action records it in To do's answer (a // write whose answer is the new value re-asks nothing) and ends: in one frame the placeholder goes with its guess and // the real card shows from the truth. An added item is the same over the checklist's answer. // @@ -106,9 +105,7 @@ export component BoardPage() { // 2. The one store over it, flat; every guess is a path write into it. board key id = [...rowsOf(todoSeen, "todo"), ...rowsOf(doingSeen, "doing"), ...rowsOf(doneSeen, "done")] - // 3. adds in flight: a new card has no row to guess on, so its placeholder is a guess list of its own, shown under To do - source adding: Row[] key id = [] - todo = [...inCol(board, "todo"), ...adding] + todo = inCol(board, "todo") doing = inCol(board, "doing") done = inCol(board, "done") // each answer as the other panes read it: `null` while it loads or after it failed @@ -188,9 +185,9 @@ export component BoardPage() { } } - action add(key: number, title: string) { + action add(key: number, title: string, at: number, pos: number) { try { - guess adding := as => [...as, { id: key, title: title, done: 0, total: 0, col: "todo", pos: 0, adding: true, gone: false }] + guess board[at] := { id: key, title: title, done: 0, total: 0, col: "todo", pos: pos, adding: true, gone: false } onFlush(() => { addError := "" }) let r = await addCard("todo", title) todoAns := cs => withCard(cs, { id: r.id, title: r.title, done: 0, total: 0 }) @@ -272,7 +269,7 @@ export component BoardPage() { newCard := "" minted := n => n + 1 }) - add(key, t) + add(key, t, board.length, placeAt(todo, todo.length)) } }}>add</button> {if addError != "" { diff --git a/eval/results/r15prep/repro/cols-move-guess-missing.attempt.spec.tsx b/eval/results/r15prep/repro/cols-move-guess-missing.attempt.spec.tsx new file mode 100644 index 00000000..78bf556c --- /dev/null +++ b/eval/results/r15prep/repro/cols-move-guess-missing.attempt.spec.tsx @@ -0,0 +1,59 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +import { test, expect } from "vitest"; +import { For, Show, action, createSignal, createOptimisticStore, flush, refresh } from "solid-js"; +import { render } from "../src/index.js"; +const tick = async () => { for (let i = 0; i < 4; i++) { await new Promise(r => setTimeout(r, 0)); flush(); } }; +type Row = { id: number; title: string; adding?: boolean }; +// the .rx runtime's keyed landing (rt-web.ts landDraft): rows matched by key, leaves merged +function landDraft(draft: any[], v: any[]) { + const had = new Map<unknown, any>(); for (const row of draft) had.set(row.id, row); + v.forEach((x, i) => { const row = had.get(x.id); if (row) { for (const k of Object.keys(x)) if (row[k] !== x[k]) row[k] = x[k]; if (draft[i] !== row) draft[i] = row; } else draft[i] = x; }); + draft.length = v.length; +} +test("a row guessed into a store doesn't show while a held add's write and an accepted move's refresh are out", async () => { + const fl: Record<string, ((v: any) => void)[]> = {}; + const ask = (k: string) => new Promise<any>(r => (fl[k] ??= []).push(r)); + const land = (k: string, v: any) => { const rs = fl[k] ?? []; if (!rs.length) throw new Error("no ask " + k); fl[k] = []; rs.forEach(r => r(structuredClone(v))); }; + const c = document.body.appendChild(document.createElement("div")); + + const [todoAns, setTodoAns] = createSignal<any>(() => ask("todo"), { loadingValue: null } as any); + const [doingAns] = createSignal<any>(() => ask("doing"), { loadingValue: null } as any); + const [todo, setTodo] = createOptimisticStore<Row[]>((d: any) => { landDraft(d, todoAns() ?? []); }, [], { key: "id" } as any); + const [doing, setDoing] = createOptimisticStore<Row[]>((d: any) => { landDraft(d, doingAns() ?? []); }, [], { key: "id" } as any); + + // a move as the server makes it: each list's new shape, as a function guess (#4724: one write per differing index) + const move = action(function* (id: number) { + const row = todo.find(r => r.id === id)!; + setTodo((rs: Row[]) => rs.filter(r => r.id !== id)); + setDoing((rs: Row[]) => [...rs, { ...row }]); + yield ask("move"); + refresh(todoAns); refresh(doingAns); + }); + // an add: a placeholder guessed at the end of To do; the answer is the new card, written into To do's answer + const add = action(function* (key: number, title: string, at: number) { + setTodo((d: any) => { d[at] = { id: key, title, adding: true }; }); + const r: any = yield ask(`add:${title}`); + setTodoAns((cs: any[]) => [...cs, { id: r.id, title: r.title }]); + }); + + const col = (rows: Row[]) => <ul><For each={rows}>{(r: any) => <li>{r.adding ? `(${r.title})` : r.id}</li>}</For></ul>; + render(() => <div><Show when={todoAns() != null} fallback={<ul><li>loading</li></ul>}>{col(todo)}</Show><Show when={doingAns() != null} fallback={<ul><li>loading</li></ul>}>{col(doing)}</Show></div>, c); + const state = () => [...c.querySelectorAll("ul")].map(u => [...u.querySelectorAll("li")].map(l => l.textContent).join(",")).join(" | "); + + land("todo", [{ id: 1, title: "a" }, { id: 2, title: "b" }]); land("doing", [{ id: 3, title: "c" }]); await tick(); + add(-1, "A", todo.length); await tick(); + land("add:A", { id: 10, title: "A" }); await tick(); + expect(state()).toBe("1,2,10 | 3"); + move(10); await tick(); + expect(state()).toBe("1,2 | 3,10"); + add(-2, "B", todo.length); await tick(); + land("add:B", { id: 11, title: "B" }); await tick(); // held with the move (same slot, todo[2]) + land("move", {}); await tick(); // accepted: refreshes of both answers out + console.log("before C:", state()); + add(-3, "C", todo.length); await tick(); + console.log("after C:", state()); + expect(state()).toContain("(C)"); +}); diff --git a/eval/results/r15prep/repro/cols-move-guess-missing.steps.mjs b/eval/results/r15prep/repro/cols-move-guess-missing.steps.mjs new file mode 100644 index 00000000..4033ee5a --- /dev/null +++ b/eval/results/r15prep/repro/cols-move-guess-missing.steps.mjs @@ -0,0 +1,17 @@ +// rx-cols (one keyed store per column answer) fuzz seed 24: after `right #4`, #4 shows in Doing and in Done. +// node probe-rx.mjs <desk> $PWD/cols-move-guess-missing.steps.mjs +import { land, near, typeClick, TODO, DOING, DONE } from "/tmp/claude-0/runs/r15prep/board-rx3/scratch/common.mjs"; +export default [ + ["land done", land("fetchCards:done", DONE)], ["land doing", land("fetchCards:doing", DOING)], ["land todo", land("fetchCards:todo", TODO)], + ["add Bravo 1", typeClick("new-card", "Bravo 1", "add-card")], ["land add 1", land("addCard:todo,Bravo 1", { id: 10, title: "Bravo 1" })], + ["right #10", p => near(p, 10, "right")], + ["add Bravo 2", typeClick("new-card", "Bravo 2", "add-card")], ["land add 2", land("addCard:todo,Bravo 2", { id: 11, title: "Bravo 2" })], + ["up #10", p => near(p, 10, "up")], + ["land move 10", land("moveCard:10,doing,2", { id: 10, column: "doing", index: 2 })], + ["add Alpha 3", typeClick("new-card", "Alpha 3", "add-card")], + ["land doing again", land("fetchCards:doing", [...DOING, { id: 10, title: "Bravo 1", done: 0, total: 0 }])], + ["left #3", p => near(p, 3, "left")], + ["add Bravo 4", typeClick("new-card", "Bravo 4", "add-card")], + ["down #3", p => near(p, 3, "down")], + ["right #4", p => near(p, 4, "right")], +]; diff --git a/eval/results/r15prep/repro/detail-memo-misses-guess.attempt.spec.tsx b/eval/results/r15prep/repro/detail-memo-misses-guess.attempt.spec.tsx deleted file mode 100644 index fccec8b0..00000000 --- a/eval/results/r15prep/repro/detail-memo-misses-guess.attempt.spec.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/** - * @jsxImportSource @solidjs/web - * @vitest-environment jsdom - */ -// Fuzz seed 46 (board-rx3), "two views". One keyed optimistic store over two async answers. Move A (row 3) is -// accepted and its action re-asks both answers (held until they land). While the re-asks are out, rename B guesses -// row 5's title. The row's own text shows the guess; a memo that finds row 5 in the store (the detail's `sel`) and -// a text reading `sel().title` keeps the old title. NOT REPRODUCED here (passes): in the app it needed the add -// placeholder guessed into the store as a row; with the placeholder in a list of its own (the guide) it is gone. -import { test, expect } from "vitest"; -import { For, action, createMemo, createSignal, createOptimisticStore, flush, refresh } from "solid-js"; -import { render } from "../src/index.js"; -const tick = async () => { for (let i = 0; i < 4; i++) { await new Promise(r => setTimeout(r, 0)); flush(); } }; -type Row = { id: number; title: string; col: string }; -test("a memo finding a row in a store whose truth is re-asked misses another action's guess on that row", async () => { - const fl: Record<string, ((v: any) => void)[]> = {}; - const ask = (k: string) => new Promise<any>(r => (fl[k] ??= []).push(r)); - const land = (k: string, v: any) => { const rs = fl[k] ?? []; fl[k] = []; rs.forEach(r => r(structuredClone(v))); }; - const c = document.body.appendChild(document.createElement("div")); - - const [todoAns] = createSignal<any>(() => ask("todo"), { loadingValue: null } as any); - const [doneAns] = createSignal<any>(() => ask("done"), { loadingValue: null } as any); - const [board, guess] = createOptimisticStore<Row[]>(() => [ - ...(todoAns() ?? []).map((r: any) => ({ ...r, col: "todo" })), - ...(doneAns() ?? []).map((r: any) => ({ ...r, col: "done" })), - ], [], { key: "id" }); - const sel = createMemo(() => board.find(r => r.id === 5) ?? null); - const reask = action(function* () { refresh(todoAns); refresh(doneAns); }); - const move = action(function* (i: number) { - guess(b => { b[i].col = "done"; }); - yield ask("move"); - yield reask(); - }); - const rename = action(function* (i: number, t: string) { - guess(b => { b[i].title = t; }); - yield ask("rename"); - }); - render(() => <div> - <ul><For each={board}>{r => <li>{r().id}:{r().title}</li>}</For></ul> - <h2>{sel()?.title}</h2> - </div>, c); - const state = () => `${[...c.querySelectorAll("li")].map(l => l.textContent).join(",")} | ${c.querySelector("h2")!.textContent}`; - - land("todo", [{ id: 3, title: "R" }]); land("done", [{ id: 5, title: "K" }]); await tick(); - expect(state()).toBe("3:R,5:K | K"); - move(0); await tick(); - land("move", {}); await tick(); // accepted: both re-asks out - rename(board.findIndex(r => r.id === 5), "B"); await tick(); - console.log("after rename guess:", state()); - expect(state()).toBe("3:R,5:B | B"); -}); diff --git a/eval/results/r15prep/repro/detail-memo-misses-guess.steps.mjs b/eval/results/r15prep/repro/detail-memo-misses-guess.steps.mjs new file mode 100644 index 00000000..5c908085 --- /dev/null +++ b/eval/results/r15prep/repro/detail-memo-misses-guess.steps.mjs @@ -0,0 +1,19 @@ +// board-rx3 (rx-store, placeholder in the store) fuzz seed 46, "two views": the card shows the rename's guess, the +// detail heading (from `sel = board.find(...)`) shows the old title. node probe-rx.mjs <desk> $PWD/detail-memo-misses-guess.steps.mjs +import { land, near, card, typeClick, TODO, DOING, DONE } from "/tmp/claude-0/runs/r15prep/board-rx3/scratch/common.mjs"; +const ITEMS5 = [{ id: 51, text: "invite", done: true }, { id: 52, text: "agenda", done: true }, { id: 53, text: "notes", done: true }]; +export default [ + ["land done", land("fetchCards:done", DONE)], + ["select #5", p => card(p, 5)], + ["land checklist 5", land("fetchChecklist:5", ITEMS5)], + ["land doing", land("fetchCards:doing", DOING)], + ["land todo", land("fetchCards:todo", TODO)], + ["down #3", p => near(p, 3, "down")], + ["right #1", p => near(p, 1, "right")], + ["left #3", p => near(p, 3, "left")], + ["add Delta 1", typeClick("new-card", "Delta 1", "add-card")], + ["land move 3 -> todo", land("moveCard:3,todo,1", { id: 3, column: "todo", index: 1 })], + ["down #4", p => near(p, 4, "down")], + ["up #3", p => near(p, 3, "up")], + ["rename Bravo", typeClick("title-input", "Bravo", "rename")], +]; diff --git a/eval/results/r15prep/repro/selection-memo-misses-guess.spec.tsx b/eval/results/r15prep/repro/selection-memo-misses-guess.spec.tsx new file mode 100644 index 00000000..a63171a6 --- /dev/null +++ b/eval/results/r15prep/repro/selection-memo-misses-guess.spec.tsx @@ -0,0 +1,61 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +import { test, expect } from "vitest"; +import { For, action, createMemo, createSignal, createOptimisticStore, flush, refresh } from "solid-js"; +import { render } from "../src/index.js"; +const tick = async () => { for (let i = 0; i < 4; i++) { await new Promise(r => setTimeout(r, 0)); flush(); } }; +type Row = { id: number; title: string; col: string }; +function landDraft(draft: any[], v: any[]) { + const had = new Map<unknown, any>(); for (const row of draft) had.set(row.id, row); + v.forEach((x, i) => { const row = had.get(x.id); if (row) { for (const k of Object.keys(x)) if (row[k] !== x[k]) row[k] = x[k]; if (draft[i] !== row) draft[i] = row; } else draft[i] = x; }); + draft.length = v.length; +} +// Fuzz seed 46 (eval/reference/board/rx-store): the card shows a rename's guess, the detail heading the old title. +// Needs all of: the selection memo reading a signal (a constant id: passes), a memo filtering the store that is +// rendered (no column memo: passes), the placeholder row guess still out (no add: passes), and the accepted move's +// refreshes still out. Reading board.find(...) in the markup instead of through the memo shows the guess. +test("a memo over an optimistic store misses a path guess while a row guess and an accepted write's refresh are out", async () => { + const fl: Record<string, ((v: any) => void)[]> = {}; + const ask = (k: string) => new Promise<any>(r => (fl[k] ??= []).push(r)); + const land = (k: string, v: any) => { const rs = fl[k] ?? []; if (!rs.length) throw new Error("no ask " + k); fl[k] = []; rs.forEach(r => r(structuredClone(v))); }; + const c = document.body.appendChild(document.createElement("div")); + + const [a] = createSignal<any>(() => ask("a"), { loadingValue: null } as any); + const [b] = createSignal<any>(() => ask("b"), { loadingValue: null } as any); + const rowsOf = (xs: any[] | null, col: string) => (xs ?? []).map(x => ({ ...x, col })); + const [board, setBoard] = createOptimisticStore<Row[]>((d: any) => { landDraft(d, [...rowsOf(a(), "a"), ...rowsOf(b(), "b")]); }, [], { key: "id" } as any); + const [selected, setSelected] = createSignal<number | null>(null); + const sel = createMemo(() => selected() == null ? null : board.find(r => r.id === selected()) ?? null); + + const colA = createMemo(() => board.filter(r => r.col === "a")); + const colB = createMemo(() => board.filter(r => r.col === "b")); + // a move: a path guess, then the server, then re-ask both answers + const move = action(function* (i: number, col: string) { + setBoard((d: any) => { d[i].col = col; }); + yield ask("move"); + refresh(a); refresh(b); + }); + // an add: a placeholder row guessed into the store; its answer never comes in this test + const add = action(function* (at: number) { + setBoard((d: any) => { d[at] = { id: -1, title: "new", col: "a" }; }); + yield ask("add"); + }); + const rename = action(function* (i: number, t: string) { + setBoard((d: any) => { d[i].title = t; }); + yield ask("rename"); + }); + + render(() => <div><p><For each={colA()}>{(r: any) => <i>{r.id}:{r.title} </i>}</For>|<For each={colB()}>{(r: any) => <i>{r.id}:{r.title} </i>}</For></p><h2>{sel()?.title}</h2></div>, c); + const state = () => `${c.querySelector("p")!.textContent} || ${c.querySelector("h2")!.textContent}`; + + land("a", [{ id: 1, title: "one" }]); land("b", [{ id: 5, title: "five" }]); await tick(); + setSelected(5); await tick(); + expect(state()).toBe("1:one |5:five || five"); + move(0, "b"); await tick(); + add(board.length); await tick(); + land("move", {}); await tick(); // accepted: refreshes of a and b out + rename(board.findIndex(r => r.id === 5), "FIVE"); await tick(); + expect(state()).toBe("-1:new |1:one 5:FIVE || FIVE"); // got "… || five": the column shows the guess, the memo's row doesn't // got "… || five": the memo's row misses the guess +}); diff --git a/guide/async.md b/guide/async.md index 99af22fa..263e5610 100644 --- a/guide/async.md +++ b/guide/async.md @@ -300,8 +300,10 @@ not a copy per view: action's arguments, not from the store, whose row already shows the guess. On a decline re-ask nothing: the guess reverts by itself. - **A create whose answer is the new row** writes that row into its answer source after the `await` (a source with a - question takes a write until its next answer, ST-5a, so the row is never there twice). Until then show a placeholder - from a guess list of its own, inert: no controls, since the server doesn't know its id yet. + question takes a write until its next answer, ST-5a, so the row is never there twice). Until then guess a placeholder + into the same store under a key minted on the client, so it sits where the row will. With a server id it is inert + (no controls, since the server doesn't know it yet) and is replaced by the real row; a client-minted id the server + keeps lets the placeholder be the row. - **Errors stay plain sources.** A guess reverts with its action; an error must outlive it. ## 4. Things that look like bugs and aren't From 9a7b2943070e3908fc4d3a389d414bec7c5040ac Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 15 Nov 2026 05:55:30 +0000 Subject: [PATCH 24/32] eval: rx-cols grade notes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- eval/reference/board/rx-cols/FUZZ-21-60.txt | 17 ----------------- eval/reference/board/rx-cols/GRADE.txt | 18 ++++++++---------- 2 files changed, 8 insertions(+), 27 deletions(-) delete mode 100644 eval/reference/board/rx-cols/FUZZ-21-60.txt diff --git a/eval/reference/board/rx-cols/FUZZ-21-60.txt b/eval/reference/board/rx-cols/FUZZ-21-60.txt deleted file mode 100644 index 5e5cf664..00000000 --- a/eval/reference/board/rx-cols/FUZZ-21-60.txt +++ /dev/null @@ -1,17 +0,0 @@ -board-rx3: 8/40 seeds found a hole - seed 24 step 26: duplicate card: #1 shows in todo and doing - land fetchCards:done ; land fetchCards:doing ; land fetchCards:todo ; add Bravo 1 ; land addCard:todo,Bravo 1 ; right #10 ; add Bravo 2 ; land addCard:todo,Bravo 2 ; up #10 ; land moveCard:10,doing,2 ; add Alpha 3 ; land fetchCards:doing ; left #3 ; add Bravo 4 ; down #11 ; right #4 ; fail addCard:todo,Bravo 4 ; right #3 ; add Delta 5 ; land addCard:todo,Delta 5 ; right #10 ; land moveCard:10,done,2 ; down #5 ; down #2 ; fail moveCard:2,todo,2 ; right #1 - seed 25 step 11: duplicate card: "Delta 1 (adding…)" shows 1 time(s) with 0 add(s) of it in the air, beside its added card - tab archive ; tab board ; land fetchCards:todo ; up #2 ; land fetchCards:doing ; fail moveCard:2,todo,0 ; land fetchCards:done ; add Delta 1 ; add Alpha 2 ; land fetchArchived: ; land addCard:todo,Delta 1 - seed 28 step 32: duplicate card: #4 shows in doing and doing - tab archive ; tab board ; land fetchCards:doing ; land fetchArchived: ; add Delta 1 ; land fetchCards:todo ; select #1 ; select #2 ; land fetchChecklist:1 ; land fetchChecklist:2 ; rename Alpha ; rename Delta ; land renameCard:2,Delta ; rename Alpha ; land fetchCards:todo ; rename Bravo ; fail renameCard:2,Alpha ; fail renameCard:2,Bravo ; up #4 ; up #2 ; add Delta 2 ; fail moveCard:2,todo,0 ; left #4 ; archive #2 ; land addCard:todo,Delta 2 ; land archiveCard:2 ; land moveCard:4,todo,3 ; la - seed 36 step 28: two views: card "#5 Delta · 3/3", detail "#5 Kickoffin Doing rename archivechecklist error: declined" - tab archive ; land fetchCards:todo ; tab board ; tab archive ; land fetchArchived: ; fail fetchCards:todo ; tab board ; add Delta 1 ; add Alpha 2 ; land fetchCards:doing ; land fetchCards:done ; land fetchCards:todo ; left #5 ; land moveCard:5,doing,2 ; land fetchCards:doing ; land fetchCards:done ; select #5 ; add Delta 3 ; land addCard:todo,Delta 3 ; down #1 ; down #3 ; add Bravo 4 ; down #10 ; land moveCard:10,todo,3 ; fail fetchChecklist:5 ; rename Delta - seed 39 step 6: duplicate card: "Delta 1 (adding…)" shows 1 time(s) with 0 add(s) of it in the air, beside its added card - land fetchCards:done ; land fetchCards:doing ; land fetchCards:todo ; add Delta 1 ; add Bravo 2 ; land addCard:todo,Delta 1 - seed 46 step 14: two views: card "#5 Bravo · 3/3", detail "#5 Kickoffin Done rename archive[x] invite toggle[x] agenda toggle[x] notes togg" - land fetchCards:done ; select #5 ; select #5 ; land fetchChecklist:5 ; land fetchCards:doing ; land fetchCards:todo ; down #3 ; right #1 ; left #3 ; add Delta 1 ; land moveCard:3,todo,1 ; down #4 ; up #3 ; rename Bravo - seed 48 step 32: card count: card "#5 Bravo · 3/4", server 3/3 with 0 toggle(s) and 0 item add(s) of it in the air - add Alpha 1 ; land fetchCards:doing ; land fetchCards:todo ; left #3 ; right #3 ; land moveCard:3,doing,1 ; land fetchCards:done ; land moveCard:3,todo,2 ; select #3 ; land fetchCards:doing ; tab archive ; land fetchChecklist:3 ; tab board ; add Bravo 2 ; land fetchArchived: ; add Bravo 3 ; land fetchCards:done ; select #5 ; select #5 ; land fetchCards:todo ; land fetchCards:doing ; land fetchChecklist:5 ; rename Bravo ; up #3 ; fail renameCard:5,Bravo ; land moveCard:3,todo,1 ; rename Bravo - seed 57 step 28: count: card "#1 Alpha · 1/3", progress "0/3 done" - add Alpha 1 ; add Bravo 2 ; land fetchCards:doing ; down #3 ; land fetchCards:todo ; left #3 ; add Delta 3 ; left #4 ; right #3 ; select #1 ; rename Alpha ; land moveCard:4,todo,4 ; land fetchChecklist:1 ; up #4 ; left #3 ; down #4 ; add-item ship 4 ; select #4 ; right #3 ; land fetchCards:doing ; down #2 ; land addCard:todo,Delta 3 ; land moveCard:4,todo,3 ; fail moveCard:3,todo,4 ; land renameCard:1,Alpha ; toggle 0 diff --git a/eval/reference/board/rx-cols/GRADE.txt b/eval/reference/board/rx-cols/GRADE.txt index e8f2fbab..5537a57e 100644 --- a/eval/reference/board/rx-cols/GRADE.txt +++ b/eval/reference/board/rx-cols/GRADE.txt @@ -1,14 +1,12 @@ +rx-cols (one keyed store per column answer), Solid next fab8bd9a4, TASK=board FUZZ_ROUND=--r15 == hidden -✗ two adds show both, each taking its own id +✗ two adds show both, each taking its own id (legal H-11) 44/45 passed == overlap -✗ a toggle declined after another card was selected puts the first card's count back -25/26 passed +✗ one of two moves declined puts back only its card (moves touching one column share index slots, end together) +✗ a toggle declined after another card was selected puts the first card's count back (legal) +✗ a move into Doing that fills it while a move out is pending disables the rest (as the first, untraced) +23/26 passed == fuzz -board-rx3: 3/20 seeds found a hole - seed 11 step 46: duplicate card: "# · / (adding…)" shows 1 time(s) with 0 add(s) of it in the air - add Bravo 1 ; (move failed: locator.click: Timeout 2000ms exceeded.) ; tab archive ; tab board ; add Bravo 2 ; (move failed: locator.click: Timeout 2000ms exceeded.) ; tab archive ; tab board ; add Alpha 3 ; (move failed: locator.click: Timeout 2000ms exceeded.) ; land fetchCards:doing ; select #4 ; down #3 ; rename Alpha ; up #3 ; archive #4 ; land fetchCards:done ; land moveCard:3,doing,1 ; - seed 14 step 17: count: card "#2 Fix login · 1/4", progress "2/4 done" - add Bravo 1 ; (move failed: locator.click: Timeout 2000ms exceeded.) ; land fetchCards:todo ; select #2 ; up #2 ; select #2 ; land fetchCards:done ; land fetchChecklist:2 ; down #2 ; add-item ship 2 ; land fetchCards:doing ; land moveCard:2,todo,0 ; land moveCard:2,todo,1 ; toggle 2 ; right #3 ; select #4 ; toggle 0 - seed 15 step 16: duplicate card: "Alpha 1 (adding…)" shows 1 time(s) with 0 add(s) of it in the air, beside its added card - land fetchCards:doing ; select #4 ; up #4 ; land fetchCards:done ; fail moveCard:4,doing,0 ; land fetchCards:todo ; rename Bravo ; fail fetchChecklist:4 ; select #4 ; land renameCard:4,Bravo ; add Alpha 1 ; add Alpha 2 ; land fetchCards:doing ; add Bravo 3 ; select #5 ; land addCard:todo,Alpha 1 +seeds 1-20: 2 holes (15, 17: an answered add's placeholder stays beside its card) +seeds 21-60: 4 holes (24, 28 duplicate card; 36 two views; 41 count) From a78c74695267d66725cc63bd681bd85057b43c08 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 15 Nov 2026 05:55:39 +0000 Subject: [PATCH 25/32] eval: rx-store grade notes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- eval/reference/board/rx-store/FUZZ-21-60.txt | 17 ----------------- eval/reference/board/rx-store/GRADE.txt | 14 +++++--------- 2 files changed, 5 insertions(+), 26 deletions(-) delete mode 100644 eval/reference/board/rx-store/FUZZ-21-60.txt diff --git a/eval/reference/board/rx-store/FUZZ-21-60.txt b/eval/reference/board/rx-store/FUZZ-21-60.txt deleted file mode 100644 index 5e5cf664..00000000 --- a/eval/reference/board/rx-store/FUZZ-21-60.txt +++ /dev/null @@ -1,17 +0,0 @@ -board-rx3: 8/40 seeds found a hole - seed 24 step 26: duplicate card: #1 shows in todo and doing - land fetchCards:done ; land fetchCards:doing ; land fetchCards:todo ; add Bravo 1 ; land addCard:todo,Bravo 1 ; right #10 ; add Bravo 2 ; land addCard:todo,Bravo 2 ; up #10 ; land moveCard:10,doing,2 ; add Alpha 3 ; land fetchCards:doing ; left #3 ; add Bravo 4 ; down #11 ; right #4 ; fail addCard:todo,Bravo 4 ; right #3 ; add Delta 5 ; land addCard:todo,Delta 5 ; right #10 ; land moveCard:10,done,2 ; down #5 ; down #2 ; fail moveCard:2,todo,2 ; right #1 - seed 25 step 11: duplicate card: "Delta 1 (adding…)" shows 1 time(s) with 0 add(s) of it in the air, beside its added card - tab archive ; tab board ; land fetchCards:todo ; up #2 ; land fetchCards:doing ; fail moveCard:2,todo,0 ; land fetchCards:done ; add Delta 1 ; add Alpha 2 ; land fetchArchived: ; land addCard:todo,Delta 1 - seed 28 step 32: duplicate card: #4 shows in doing and doing - tab archive ; tab board ; land fetchCards:doing ; land fetchArchived: ; add Delta 1 ; land fetchCards:todo ; select #1 ; select #2 ; land fetchChecklist:1 ; land fetchChecklist:2 ; rename Alpha ; rename Delta ; land renameCard:2,Delta ; rename Alpha ; land fetchCards:todo ; rename Bravo ; fail renameCard:2,Alpha ; fail renameCard:2,Bravo ; up #4 ; up #2 ; add Delta 2 ; fail moveCard:2,todo,0 ; left #4 ; archive #2 ; land addCard:todo,Delta 2 ; land archiveCard:2 ; land moveCard:4,todo,3 ; la - seed 36 step 28: two views: card "#5 Delta · 3/3", detail "#5 Kickoffin Doing rename archivechecklist error: declined" - tab archive ; land fetchCards:todo ; tab board ; tab archive ; land fetchArchived: ; fail fetchCards:todo ; tab board ; add Delta 1 ; add Alpha 2 ; land fetchCards:doing ; land fetchCards:done ; land fetchCards:todo ; left #5 ; land moveCard:5,doing,2 ; land fetchCards:doing ; land fetchCards:done ; select #5 ; add Delta 3 ; land addCard:todo,Delta 3 ; down #1 ; down #3 ; add Bravo 4 ; down #10 ; land moveCard:10,todo,3 ; fail fetchChecklist:5 ; rename Delta - seed 39 step 6: duplicate card: "Delta 1 (adding…)" shows 1 time(s) with 0 add(s) of it in the air, beside its added card - land fetchCards:done ; land fetchCards:doing ; land fetchCards:todo ; add Delta 1 ; add Bravo 2 ; land addCard:todo,Delta 1 - seed 46 step 14: two views: card "#5 Bravo · 3/3", detail "#5 Kickoffin Done rename archive[x] invite toggle[x] agenda toggle[x] notes togg" - land fetchCards:done ; select #5 ; select #5 ; land fetchChecklist:5 ; land fetchCards:doing ; land fetchCards:todo ; down #3 ; right #1 ; left #3 ; add Delta 1 ; land moveCard:3,todo,1 ; down #4 ; up #3 ; rename Bravo - seed 48 step 32: card count: card "#5 Bravo · 3/4", server 3/3 with 0 toggle(s) and 0 item add(s) of it in the air - add Alpha 1 ; land fetchCards:doing ; land fetchCards:todo ; left #3 ; right #3 ; land moveCard:3,doing,1 ; land fetchCards:done ; land moveCard:3,todo,2 ; select #3 ; land fetchCards:doing ; tab archive ; land fetchChecklist:3 ; tab board ; add Bravo 2 ; land fetchArchived: ; add Bravo 3 ; land fetchCards:done ; select #5 ; select #5 ; land fetchCards:todo ; land fetchCards:doing ; land fetchChecklist:5 ; rename Bravo ; up #3 ; fail renameCard:5,Bravo ; land moveCard:3,todo,1 ; rename Bravo - seed 57 step 28: count: card "#1 Alpha · 1/3", progress "0/3 done" - add Alpha 1 ; add Bravo 2 ; land fetchCards:doing ; down #3 ; land fetchCards:todo ; left #3 ; add Delta 3 ; left #4 ; right #3 ; select #1 ; rename Alpha ; land moveCard:4,todo,4 ; land fetchChecklist:1 ; up #4 ; left #3 ; down #4 ; add-item ship 4 ; select #4 ; right #3 ; land fetchCards:doing ; down #2 ; land addCard:todo,Delta 3 ; land moveCard:4,todo,3 ; fail moveCard:3,todo,4 ; land renameCard:1,Alpha ; toggle 0 diff --git a/eval/reference/board/rx-store/GRADE.txt b/eval/reference/board/rx-store/GRADE.txt index e8f2fbab..5e3c11c1 100644 --- a/eval/reference/board/rx-store/GRADE.txt +++ b/eval/reference/board/rx-store/GRADE.txt @@ -1,14 +1,10 @@ +rx-store (one flat keyed store, placeholder in the store), Solid next fab8bd9a4, TASK=board FUZZ_ROUND=--r15 == hidden -✗ two adds show both, each taking its own id +✗ two adds show both, each taking its own id (legal H-11) 44/45 passed == overlap -✗ a toggle declined after another card was selected puts the first card's count back +✗ a toggle declined after another card was selected puts the first card's count back (legal) 25/26 passed == fuzz -board-rx3: 3/20 seeds found a hole - seed 11 step 46: duplicate card: "# · / (adding…)" shows 1 time(s) with 0 add(s) of it in the air - add Bravo 1 ; (move failed: locator.click: Timeout 2000ms exceeded.) ; tab archive ; tab board ; add Bravo 2 ; (move failed: locator.click: Timeout 2000ms exceeded.) ; tab archive ; tab board ; add Alpha 3 ; (move failed: locator.click: Timeout 2000ms exceeded.) ; land fetchCards:doing ; select #4 ; down #3 ; rename Alpha ; up #3 ; archive #4 ; land fetchCards:done ; land moveCard:3,doing,1 ; - seed 14 step 17: count: card "#2 Fix login · 1/4", progress "2/4 done" - add Bravo 1 ; (move failed: locator.click: Timeout 2000ms exceeded.) ; land fetchCards:todo ; select #2 ; up #2 ; select #2 ; land fetchCards:done ; land fetchChecklist:2 ; down #2 ; add-item ship 2 ; land fetchCards:doing ; land moveCard:2,todo,0 ; land moveCard:2,todo,1 ; toggle 2 ; right #3 ; select #4 ; toggle 0 - seed 15 step 16: duplicate card: "Alpha 1 (adding…)" shows 1 time(s) with 0 add(s) of it in the air, beside its added card - land fetchCards:doing ; select #4 ; up #4 ; land fetchCards:done ; fail moveCard:4,doing,0 ; land fetchCards:todo ; rename Bravo ; fail fetchChecklist:4 ; select #4 ; land renameCard:4,Bravo ; add Alpha 1 ; add Alpha 2 ; land fetchCards:doing ; add Bravo 3 ; select #5 ; land addCard:todo,Alpha 1 +seeds 1-20: 1 hole (11) +seeds 21-60: 8 holes (24, 28, 34, 36, 41, 46, 57, 60); 46 = Solid, repro/selection-memo-misses-guess.spec.tsx From c58dba791a9dac45ad1dd87a22257299b79c1092 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 15 Nov 2026 06:12:16 +0000 Subject: [PATCH 26/32] ST-4a: a key applies at any depth, as Solid's (lists under a record land by key; record seed) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- compiler/src/emit.rs | 8 +++++--- ref/compile/emit.ts | 6 +++--- ref/compile/rt-server.ts | 13 ++++++++++--- ref/compile/rt-web.ts | 13 ++++++++++--- ref/compile/rt.ts | 13 ++++++++++--- ref/oracle/lower.ts | 11 +++++++++-- spec/06-stores.md | 3 +++ 7 files changed, 50 insertions(+), 17 deletions(-) diff --git a/compiler/src/emit.rs b/compiler/src/emit.rs index ffc44a3e..fb2a1bb1 100644 --- a/compiler/src/emit.rs +++ b/compiler/src/emit.rs @@ -982,7 +982,7 @@ impl<'p> Gen<'p> { } Decl::Source { name, init, key, .. } => { let lv = self.loading_value(init, env)?; - let mode = obj(vec![("optimistic", E::Bool(self.u.optimistic.contains(name))), ("structured", E::Bool(self.u.paths.contains(name)))]); + let mode = obj(vec![("optimistic", E::Bool(self.u.optimistic.contains(name))), ("structured", E::Bool(self.u.paths.contains(name))), ("record", E::Bool(matches!(init.k, ExprKind::Rec(_))))]); let x = self.expr(init, env, &Ctx { tail: true, self_: Some(name.clone()), ..Ctx::default() })?; let mut args = vec![str(name.clone()), arrow(&["__prev"], x), lv, mode]; if let Some(k) = key { @@ -995,7 +995,9 @@ impl<'p> Gen<'p> { Decl::Eq { name, e, key, deferred, .. } if self.guessed_eq(name, e, *deferred) => { // AC-3 over an equation: createOptimistic(fn) / createOptimisticStore(fn, seed) — derived, its setter only guesses let lv = self.loading_value(e, env)?; - let mode = obj(vec![("optimistic", E::Bool(true)), ("structured", E::Bool(self.u.paths.contains(name) || key.is_some())), ("derived", E::Bool(true))]); + // ST-4a: a key applies at any depth (Solid's); a record result is the store's root, its keyed lists inside it + let record = matches!(e.k, ExprKind::Rec(_)); + let mode = obj(vec![("optimistic", E::Bool(true)), ("structured", E::Bool(self.u.paths.contains(name) || key.is_some())), ("derived", E::Bool(true)), ("record", E::Bool(record))]); let x = self.expr(e, env, &Ctx { tail: true, self_: Some(name.clone()), ..Ctx::default() })?; let mut args = vec![str(name.clone()), arrow(&["__prev"], x), lv, mode]; if let Some(k) = key { @@ -1037,7 +1039,7 @@ impl<'p> Gen<'p> { } if let Some(k) = key { let x = self.expr(e, env, &Ctx { tail: true, ..Ctx::default() })?; - return fin(vec![konst(b.js.clone(), rtc("keyed", vec![str(name.clone()), str(k.clone()), thunk(x)]))], self); + return fin(vec![konst(b.js.clone(), rtc("keyed", vec![str(name.clone()), str(k.clone()), thunk(x), E::Bool(matches!(e.k, ExprKind::Rec(_)))]))], self); } // R-f-4 over a tail await: the flight is an inner memo, read through a boundary if let (ExprKind::Catch { e: ce, name: cn, classes, alt }, false) = (&e.k, deferred) { diff --git a/ref/compile/emit.ts b/ref/compile/emit.ts index 34191077..91016e32 100644 --- a/ref/compile/emit.ts +++ b/ref/compile/emit.ts @@ -323,13 +323,13 @@ class Gen { if (d.k === "source") { if (!d.question) return `${pad}const [${b.js}, ${b.w}] = __rt.source(${J(d.name)}, __rt.untrack(() => ${this.expr(d.init, env, { imp: false })}));\n${pad}__rt.reg(${J(d.name)}, { get: ${b.js}, w: ${b.w} });${prev}`; const lv = this.loadingValue(d.init, env); - const mode = `{ optimistic: ${this.u.optimistic.has(d.name)}, structured: ${this.u.paths.has(d.name)} }`; + const mode = `{ optimistic: ${this.u.optimistic.has(d.name)}, structured: ${this.u.paths.has(d.name)}, record: ${d.init.k === "rec"} }`; return `${pad}const [${b.js}, ${b.w}] = __rt.question(${J(d.name)}, (__prev) => ${this.expr(d.init, env, { imp: false, tail: true, self: d.name })}, ${lv}, ${mode}${d.key ? `, ${J(d.key)}` : ""});\n${pad}__rt.reg(${J(d.name)}, { get: ${b.js}, w: ${b.w} });${prev}`; } if (d.k !== "eq") throw new CompileError("unreachable"); if (this.guessedEq(d)) { // AC-3 over an equation: createOptimistic(fn) / createOptimisticStore(fn, seed) — derived, its setter only guesses const lv = this.loadingValue(d.e, env); - const mode = `{ optimistic: true, structured: ${this.u.paths.has(d.name) || !!d.key}, derived: true }`; + const mode = `{ optimistic: true, structured: ${this.u.paths.has(d.name) || !!d.key}, derived: true, record: ${d.e.k === "rec"} }`; return `${pad}const [${b.js}, ${b.w}] = __rt.question(${J(d.name)}, (__prev) => ${this.expr(d.e, env, { imp: false, tail: true, self: d.name })}, ${lv}, ${mode}${d.key ? `, ${J(d.key)}` : ""});\n${pad}__rt.reg(${J(d.name)}, { get: ${b.js}, w: ${b.w} });${prev}`; } const reg = `\n${pad}__rt.reg(${J(d.name)}, { get: ${b.js} });${prev}`; @@ -340,7 +340,7 @@ class Gen { } if (d.e.k === "call" && d.e.f.k === "id" && env.lookup(d.e.f.name)?.kind === "ifn") return `${pad}const ${b.js} = ${this.instantiate(d.e, env)};${reg}`; // RG-6 const fx = this.foreign(d, env, b, pad); if (fx !== null) return `${fx}${reg}`; - if (d.key) return `${pad}const ${b.js} = __rt.keyed(${J(d.name)}, ${J(d.key)}, () => ${this.expr(d.e, env, { imp: false, tail: true })});${reg}`; + if (d.key) return `${pad}const ${b.js} = __rt.keyed(${J(d.name)}, ${J(d.key)}, () => ${this.expr(d.e, env, { imp: false, tail: true })}, ${d.e.k === "rec"});${reg}`; if (d.e.k === "catch" && !d.deferred) { // R-f-4 over a tail await: the flight is an inner memo, read through a boundary const c = d.e; const inner = new Env(env); if (c.name) inner.vars.set(c.name, { kind: "val", js: mg(c.name) }); return `${pad}const i$${d.name} = __rt.memo(${J(d.name)}, (__prev) => ${this.expr(c.e, env, { imp: false, tail: true, self: d.name })}, ${this.loadingValue(c.e, env)});\n${pad}const ${b.js} = __rt.catchMemo(() => i$${d.name}(), (__e) => __rt.isA(__e, ${this.classes(c.classes, env)}), (${c.name ? mg(c.name) : "__e"}) => ${this.expr(c.alt, inner, { imp: false })});${reg}`; diff --git a/ref/compile/rt-server.ts b/ref/compile/rt-server.ts index e3013b24..ba9d68cf 100644 --- a/ref/compile/rt-server.ts +++ b/ref/compile/rt-server.ts @@ -138,9 +138,9 @@ export function createApp(opts: { api?: Record<string, (...a: any[]) => unknown> if (typeof init === "object" && init !== null) { const [store] = createStore(init as any); const [get] = createSignal<unknown>(store); return ro(get as Acc); } const [get] = createSignal(init); return ro(get as Acc); }, - question(name: string, compute: (prev: unknown) => unknown, o: { loadingValue?: unknown } | undefined, mode: { optimistic: boolean; structured: boolean; derived?: boolean }, key?: string): [Acc, Writer] { + question(name: string, compute: (prev: unknown) => unknown, o: { loadingValue?: unknown } | undefined, mode: { optimistic: boolean; structured: boolean; derived?: boolean; record?: boolean }, key?: string): [Acc, Writer] { const fn = named(name, compute); - if (mode.optimistic && mode.structured) { const [store] = (createOptimisticStore as any)((draft: any) => { const r = fn(draft); if (r instanceof Flight || r instanceof Promise) return (async () => landDraft(draft, r instanceof Flight ? await r.first : await r, key))(); landDraft(draft, r, key); }, key ? [] : {}, key ? { key } : undefined); return ro(() => store); } + if (mode.optimistic && mode.structured) { const [store] = (createOptimisticStore as any)((draft: any) => { const r = fn(draft); if (r instanceof Flight || r instanceof Promise) return (async () => landDraft(draft, r instanceof Flight ? await r.first : await r, key))(); landDraft(draft, r, key); }, key && !mode.record ? [] : {}, key ? { key } : undefined); return ro(() => store); } if (mode.optimistic && mode.derived) { const [get] = createOptimistic(fn as any, o as any); return ro(get as Acc); } // AC-3 over an equation: the client's createOptimistic(fn), so the owner trees (and hydration keys) match if (mode.optimistic) { const [truth] = createSignal(fn as any, o as any); const [get] = createOptimistic(() => (truth as Acc)()); return ro(get as Acc); } if (mode.structured) { @@ -151,7 +151,7 @@ export function createApp(opts: { api?: Record<string, (...a: any[]) => unknown> }, memo: (name: string | null, fn: (prev: unknown) => unknown, o?: { loadingValue?: unknown }): Acc => createMemo(named(name, fn) as any, o as any) as Acc, deferred: (name: string, fn: (prev: unknown) => unknown, o?: { loadingValue?: unknown }): Acc => createDeferred(named(name, fn) as any, o as any) as Acc, - keyed: (name: string, key: string, fn: () => unknown): Acc => { const src = createMemo(named(name, () => fn()) as any); const p = createProjection(() => (src as Acc)() as any, [] as any, { key } as any); return () => p; }, + keyed: (name: string, key: string, fn: () => unknown, record = false): Acc => { const src = createMemo(named(name, () => fn()) as any); const p = createProjection(() => (src as Acc)() as any, (record ? {} : []) as any, { key } as any); return () => p; }, draft: (seed: unknown, body: (d: any) => void): Acc => { const p = createProjection(body as any, seed as any); return () => p; }, pre: (get: Acc): Acc => { const m = createMemo((st: any) => ({ cur: get(), prev: st ? st.cur : undefined })); return () => (m() as { prev: unknown }).prev; }, firstPaint: (f: () => unknown) => ({ loadingValue: untrack(f) }), @@ -250,7 +250,14 @@ export type ServerApp = ReturnType<typeof createApp>; * assigned. * TODO(Solid rc.22): return the keyed answer from the derive and let Solid reconcile it, dropping this merge. On * 2.0.0-rc.21 that path throws in holdStaging (memo over latest() of the rows); fixed on Solid next (dba2aad). */ +const isRecord = (x: unknown): x is Record<string, unknown> => !!x && typeof x === "object" && !Array.isArray(x); function landDraft(draft: any, v: unknown, key: string | undefined) { + // ST-4a: the key applies at any depth, as Solid's: lists under a record land by key too + if (key && isRecord(v) && isRecord(draft)) { + for (const k of Object.keys(draft)) if (!(k in v)) delete draft[k]; + for (const [k, x] of Object.entries(v)) { const d = draft[k]; if (x && typeof x === "object" && d && typeof d === "object" && Array.isArray(x) === Array.isArray(d)) landDraft(d, x, key); else if (d !== x) draft[k] = x; } + return; + } if (!key || !Array.isArray(v) || !Array.isArray(draft)) return assignDraft(draft, v); const had = new Map<unknown, any>(); for (const row of draft) if (row && typeof row === "object") had.set(row[key], row); v.forEach((x: any, i) => { const row = x && typeof x === "object" ? had.get(x[key]) : undefined; if (row) { mergeLeaves(row, x); if (draft[i] !== row) draft[i] = row; } else draft[i] = x; }); diff --git a/ref/compile/rt-web.ts b/ref/compile/rt-web.ts index c3590116..508b41bc 100644 --- a/ref/compile/rt-web.ts +++ b/ref/compile/rt-web.ts @@ -180,10 +180,10 @@ export function createApp(opts: { root: any; api?: Record<string, (...a: any[]) }, /** `source x = e` (ST-5): createSignal(fn); with a guess written to it, a createOptimistic layer over that truth (AC-3); * written or probed by path, createStore(fn, {}) whose derive writes its draft. */ - question(name: string, compute: (prev: unknown) => unknown, opts: { loadingValue?: unknown } | undefined, mode: { optimistic: boolean; structured: boolean; derived?: boolean }, key?: string): [Acc, Writer] { + question(name: string, compute: (prev: unknown) => unknown, opts: { loadingValue?: unknown } | undefined, mode: { optimistic: boolean; structured: boolean; derived?: boolean; record?: boolean }, key?: string): [Acc, Writer] { const fn = named(name, compute); if (mode.optimistic && mode.structured) { // AC-3 path targets: createOptimisticStore(fn, seed) — the derive writes its draft; the setter guesses per key - const [store, setStore] = (createOptimisticStore as any)((draft: any) => { const r = fn(draft); if (r instanceof Flight || r instanceof Promise) return (async () => landDraft(draft, r instanceof Flight ? await r.first : await r, key))(); landDraft(draft, r, key); }, key ? [] : {}, key ? { key } : undefined); + const [store, setStore] = (createOptimisticStore as any)((draft: any) => { const r = fn(draft); if (r instanceof Flight || r instanceof Promise) return (async () => landDraft(draft, r instanceof Flight ? await r.first : await r, key))(); landDraft(draft, r, key); }, key && !mode.record ? [] : {}, key ? { key } : undefined); const get = () => store; storeAccs.add(get); const plain = () => { throw new Error("a plain write to an optimistic store is not lowered yet"); }; return [get, { set: plain, path: plain, guess: (v, keys = []) => setStore((d: any) => { if (keys.length === 0) return typeof v === "function" ? (v as (x: unknown) => unknown)(d) : v; /* a whole guess is the setter\'s return, which Solid diffs shallowly against the view as guesses */ let o = d; for (const k of keys.slice(0, -1)) o = o[k]; const last = keys[keys.length - 1]; o[last] = typeof v === "function" ? (v as (x: unknown) => unknown)(o[last]) : v; }) }]; @@ -210,7 +210,7 @@ export function createApp(opts: { root: any; api?: Record<string, (...a: any[]) memo: (name: string | null, fn: (prev: unknown) => unknown, opts?: { loadingValue?: unknown }): Acc => createMemo(named(name, fn) as any, opts as any) as Acc, deferred: (name: string, fn: (prev: unknown) => unknown, opts?: { loadingValue?: unknown }): Acc => createDeferred(named(name, fn) as any, opts as any) as Acc, /** `x key k = e` (ST-4): a keyed projection. */ - keyed: (name: string, key: string, fn: () => unknown): Acc => { const src = createMemo(named(name, () => fn()) as any); const p = createProjection(() => (src as Acc)() as any, [] as any, { key } as any); const acc = () => p; truthOf.set(acc, src as Acc); return acc; }, // AC-4: `refresh rows` re-asks the memo under the projection // the answer is a memo (sync, or the tail await's); the projection reconciles it by key + keyed: (name: string, key: string, fn: () => unknown, record = false): Acc => { const src = createMemo(named(name, () => fn()) as any); const p = createProjection(() => (src as Acc)() as any, (record ? {} : []) as any, { key } as any); const acc = () => p; truthOf.set(acc, src as Acc); return acc; }, // AC-4: `refresh rows` re-asks the memo under the projection // the answer is a memo (sync, or the tail await's); the projection reconciles it by key /** `x = draft(pre x -> seed) { … }` (ST-3). */ draft: (seed: unknown, body: (d: any) => void): Acc => { const p = createProjection(body as any, seed as any); return () => p; }, /** `pre y` of another node (C-2): a companion memo carrying (current, previous), owned where `y` is declared. */ @@ -327,7 +327,14 @@ export type App = ReturnType<typeof createApp>; * assigned. * TODO(Solid rc.22): return the keyed answer from the derive and let Solid reconcile it, dropping this merge. On * 2.0.0-rc.21 that path throws in holdStaging (memo over latest() of the rows); fixed on Solid next (dba2aad). */ +const isRecord = (x: unknown): x is Record<string, unknown> => !!x && typeof x === "object" && !Array.isArray(x); function landDraft(draft: any, v: unknown, key: string | undefined) { + // ST-4a: the key applies at any depth, as Solid's: lists under a record land by key too + if (key && isRecord(v) && isRecord(draft)) { + for (const k of Object.keys(draft)) if (!(k in v)) delete draft[k]; + for (const [k, x] of Object.entries(v)) { const d = draft[k]; if (x && typeof x === "object" && d && typeof d === "object" && Array.isArray(x) === Array.isArray(d)) landDraft(d, x, key); else if (d !== x) draft[k] = x; } + return; + } if (!key || !Array.isArray(v) || !Array.isArray(draft)) return assignDraft(draft, v); const had = new Map<unknown, any>(); for (const row of draft) if (row && typeof row === "object") had.set(row[key], row); v.forEach((x: any, i) => { const row = x && typeof x === "object" ? had.get(x[key]) : undefined; if (row) { mergeLeaves(row, x); if (draft[i] !== row) draft[i] = row; } else draft[i] = x; }); diff --git a/ref/compile/rt.ts b/ref/compile/rt.ts index 1b32e7f1..58a066cf 100644 --- a/ref/compile/rt.ts +++ b/ref/compile/rt.ts @@ -190,10 +190,10 @@ export function createApp(opts: { api?: Record<string, (...a: any[]) => unknown> }, /** `source x = e` (ST-5): createSignal(fn); with a guess written to it, a createOptimistic layer over that truth (AC-3); * written or probed by path, createStore(fn, {}) whose derive writes its draft. */ - question(name: string, compute: (prev: unknown) => unknown, opts: { loadingValue?: unknown } | undefined, mode: { optimistic: boolean; structured: boolean; derived?: boolean }, key?: string): [Acc, Writer] { + question(name: string, compute: (prev: unknown) => unknown, opts: { loadingValue?: unknown } | undefined, mode: { optimistic: boolean; structured: boolean; derived?: boolean; record?: boolean }, key?: string): [Acc, Writer] { const fn = named(name, compute); if (mode.optimistic && mode.structured) { // AC-3 path targets: createOptimisticStore(fn, seed) — the derive writes its draft; the setter guesses per key - const [store, setStore] = (createOptimisticStore as any)((draft: any) => { const r = fn(draft); if (r instanceof Flight || r instanceof Promise) return (async () => landDraft(draft, r instanceof Flight ? await r.first : await r, key))(); landDraft(draft, r, key); }, key ? [] : {}, key ? { key } : undefined); + const [store, setStore] = (createOptimisticStore as any)((draft: any) => { const r = fn(draft); if (r instanceof Flight || r instanceof Promise) return (async () => landDraft(draft, r instanceof Flight ? await r.first : await r, key))(); landDraft(draft, r, key); }, key && !mode.record ? [] : {}, key ? { key } : undefined); const get = () => store; storeAccs.add(get); const plain = () => { throw new Error("a plain write to an optimistic store is not lowered yet"); }; return [get, { set: plain, path: plain, guess: (v, keys = []) => setStore((d: any) => { if (keys.length === 0) return typeof v === "function" ? (v as (x: unknown) => unknown)(d) : v; /* a whole guess is the setter\'s return, which Solid diffs shallowly against the view as guesses */ let o = d; for (const k of keys.slice(0, -1)) o = o[k]; const last = keys[keys.length - 1]; o[last] = typeof v === "function" ? (v as (x: unknown) => unknown)(o[last]) : v; }) }]; @@ -220,7 +220,7 @@ export function createApp(opts: { api?: Record<string, (...a: any[]) => unknown> memo: (name: string | null, fn: (prev: unknown) => unknown, opts?: { loadingValue?: unknown }): Acc => createMemo(named(name, fn) as any, opts as any) as Acc, deferred: (name: string, fn: (prev: unknown) => unknown, opts?: { loadingValue?: unknown }): Acc => createDeferred(named(name, fn) as any, opts as any) as Acc, /** `x key k = e` (ST-4): a keyed projection. */ - keyed: (name: string, key: string, fn: () => unknown): Acc => { const src = createMemo(named(name, () => fn()) as any); const p = createProjection(() => (src as Acc)() as any, [] as any, { key } as any); const acc = () => p; truthOf.set(acc, src as Acc); return acc; }, // AC-4: `refresh rows` re-asks the memo under the projection // the answer is a memo (sync, or the tail await's); the projection reconciles it by key + keyed: (name: string, key: string, fn: () => unknown, record = false): Acc => { const src = createMemo(named(name, () => fn()) as any); const p = createProjection(() => (src as Acc)() as any, (record ? {} : []) as any, { key } as any); const acc = () => p; truthOf.set(acc, src as Acc); return acc; }, // AC-4: `refresh rows` re-asks the memo under the projection // the answer is a memo (sync, or the tail await's); the projection reconciles it by key /** `x = draft(pre x -> seed) { … }` (ST-3). */ draft: (seed: unknown, body: (d: any) => void): Acc => { const p = createProjection(body as any, seed as any); return () => p; }, /** `pre y` of another node (C-2): a companion memo carrying (current, previous), owned where `y` is declared. */ @@ -335,7 +335,14 @@ export type App = ReturnType<typeof createApp>; * assigned. * TODO(Solid rc.22): return the keyed answer from the derive and let Solid reconcile it, dropping this merge. On * 2.0.0-rc.21 that path throws in holdStaging (memo over latest() of the rows); fixed on Solid next (dba2aad). */ +const isRecord = (x: unknown): x is Record<string, unknown> => !!x && typeof x === "object" && !Array.isArray(x); function landDraft(draft: any, v: unknown, key: string | undefined) { + // ST-4a: the key applies at any depth, as Solid's: lists under a record land by key too + if (key && isRecord(v) && isRecord(draft)) { + for (const k of Object.keys(draft)) if (!(k in v)) delete draft[k]; + for (const [k, x] of Object.entries(v)) { const d = draft[k]; if (x && typeof x === "object" && d && typeof d === "object" && Array.isArray(x) === Array.isArray(d)) landDraft(d, x, key); else if (d !== x) draft[k] = x; } + return; + } if (!key || !Array.isArray(v) || !Array.isArray(draft)) return assignDraft(draft, v); const had = new Map<unknown, any>(); for (const row of draft) if (row && typeof row === "object") had.set(row[key], row); v.forEach((x: any, i) => { const row = x && typeof x === "object" ? had.get(x[key]) : undefined; if (row) { mergeLeaves(row, x); if (draft[i] !== row) draft[i] = row; } else draft[i] = x; }); diff --git a/ref/oracle/lower.ts b/ref/oracle/lower.ts index e867ee0a..5f713fd3 100644 --- a/ref/oracle/lower.ts +++ b/ref/oracle/lower.ts @@ -42,7 +42,14 @@ function unwrapFallback(v0: unknown): unknown { let v = v0; if (v instanceof Fal * assigned. * TODO(Solid rc.22): return the keyed answer from the derive and let Solid reconcile it, dropping this merge. On * 2.0.0-rc.21 that path throws in holdStaging (memo over latest() of the rows); fixed on Solid next (dba2aad). */ +const isRecord = (x: unknown): x is Record<string, unknown> => !!x && typeof x === "object" && !Array.isArray(x); function landDraft(draft: any, v: unknown, key: string | undefined) { + // ST-4a: the key applies at any depth, as Solid's: lists under a record land by key too + if (key && isRecord(v) && isRecord(draft)) { + for (const k of Object.keys(draft)) if (!(k in v)) delete draft[k]; + for (const [k, x] of Object.entries(v)) { const d = draft[k]; if (x && typeof x === "object" && d && typeof d === "object" && Array.isArray(x) === Array.isArray(d)) landDraft(d, x, key); else if (d !== x) draft[k] = x; } + return; + } if (!key || !Array.isArray(v) || !Array.isArray(draft)) return assignDraft(draft, v); const had = new Map<unknown, any>(); for (const row of draft) if (row && typeof row === "object") had.set(row[key], row); v.forEach((x: any, i) => { const row = x && typeof x === "object" ? had.get(x[key]) : undefined; if (row) { mergeLeaves(row, x); if (draft[i] !== row) draft[i] = row; } else draft[i] = x; }); @@ -273,7 +280,7 @@ export class Oracle { const compute = this.computeOf(d.name, d.init, scope); if (this.optimisticTargets.has(d.name) && this.pathTargets.has(d.name)) { // AC-3 path targets over ST-5 structured: createOptimisticStore(fn, seed) — the derive writes its draft; the setter guesses per key const derive = async (draft: any) => { const r = compute(draft); const v = r instanceof Flight ? await r.first : await r; landDraft(draft, v, d.key); }; - const [store, setStore] = (createOptimisticStore as any)(derive, d.key ? [] : {}, d.key ? { key: d.key } : undefined); + const [store, setStore] = (createOptimisticStore as any)(derive, d.key && (d.init ?? d.e)?.k !== "rec" ? [] : {}, d.key ? { key: d.key } : undefined); const plain = () => { throw new Unsupported("a plain write to an optimistic store"); }; scope.vars.set(d.name, { k: "store", get: () => store, set: plain, setPath: plain, question: true, setGuess: setStore }); } @@ -310,7 +317,7 @@ export class Oracle { const plain = () => { throw new Unsupported("a plain write to an equation"); }; if (this.pathTargets.has(d.name) || d.key) { const derive = async (draft: any) => { const r = compute(draft); const v = r instanceof Flight ? await r.first : await r; landDraft(draft, v, d.key); }; - const [store, setStore] = (createOptimisticStore as any)(derive, d.key ? [] : {}, d.key ? { key: d.key } : undefined); + const [store, setStore] = (createOptimisticStore as any)(derive, d.key && (d.init ?? d.e)?.k !== "rec" ? [] : {}, d.key ? { key: d.key } : undefined); scope.vars.set(d.name, { k: "store", get: () => store, set: plain, setPath: plain, question: true, setGuess: setStore }); } else { const [get, setGuess] = createOptimistic(compute as any, this.loadingValue(d.e, scope) as any); diff --git a/spec/06-stores.md b/spec/06-stores.md index bbece1ac..1b87042a 100644 --- a/spec/06-stores.md +++ b/spec/06-stores.md @@ -63,6 +63,9 @@ selected = draft(pre selected -> {}) { d[id] := true; d[prevId] := false } per-key path updates (their readers run only for changed keys); new keys create, missing keys remove; a reorder moves rows (S-store-3). `pending?` and `latest` on an element follow the key. + The key applies at any depth, as Solid's: on a record (`x key k = { a: …, + b: … }`) every list inside it is reconciled by `k` (taken by default, + 2026-11-15). - **ST-4b.** `for u in x` over a keyed collection uses the key for row identity (RG-2a). A collection derived from a keyed one by selection (`filter(rows, …)`) keeps the rows' identity and needs no key of its own; From 2bd5cd912b054dd66f14c8f0bf14224a26588d51 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 15 Nov 2026 06:15:09 +0000 Subject: [PATCH 27/32] eval: repros for solidjs/solid#4888 and #4889 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- ...> guess-lost-over-error-boundary.spec.tsx} | 33 ++++++++++++------- .../selection-memo-misses-guess.spec.tsx | 2 +- 2 files changed, 22 insertions(+), 13 deletions(-) rename eval/results/r15prep/repro/{cols-move-guess-missing.attempt.spec.tsx => guess-lost-over-error-boundary.spec.tsx} (60%) diff --git a/eval/results/r15prep/repro/cols-move-guess-missing.attempt.spec.tsx b/eval/results/r15prep/repro/guess-lost-over-error-boundary.spec.tsx similarity index 60% rename from eval/results/r15prep/repro/cols-move-guess-missing.attempt.spec.tsx rename to eval/results/r15prep/repro/guess-lost-over-error-boundary.spec.tsx index 78bf556c..a69dd544 100644 --- a/eval/results/r15prep/repro/cols-move-guess-missing.attempt.spec.tsx +++ b/eval/results/r15prep/repro/guess-lost-over-error-boundary.spec.tsx @@ -2,8 +2,14 @@ * @jsxImportSource @solidjs/web * @vitest-environment jsdom */ +// Fuzz seed 24 (eval/reference/board/rx-cols: one keyed optimistic store per fetch). Each store derives from its +// answer through an error boundary (the .rx `answer catch (e) => null`). An add is held with a move (both guessed on +// To do's slot 2); the move is accepted and refreshes both answers. A new add's placeholder guess, To do's slot 3, +// then never shows while the refreshes are out (its action is running: its request is out). Needs the boundary under +// To do's store (stores reading the answers directly: shows; a boundary under To do's alone: still lost) and the held +// add B (no B: shows). import { test, expect } from "vitest"; -import { For, Show, action, createSignal, createOptimisticStore, flush, refresh } from "solid-js"; +import { For, action, createSignal, createErrorBoundary, untrack, createOptimisticStore, flush, refresh } from "solid-js"; import { render } from "../src/index.js"; const tick = async () => { for (let i = 0; i < 4; i++) { await new Promise(r => setTimeout(r, 0)); flush(); } }; type Row = { id: number; title: string; adding?: boolean }; @@ -13,7 +19,10 @@ function landDraft(draft: any[], v: any[]) { v.forEach((x, i) => { const row = had.get(x.id); if (row) { for (const k of Object.keys(x)) if (row[k] !== x[k]) row[k] = x[k]; if (draft[i] !== row) draft[i] = row; } else draft[i] = x; }); draft.length = v.length; } -test("a row guessed into a store doesn't show while a held add's write and an accepted move's refresh are out", async () => { +// `x catch (e) => null`, as the .rx runtime lowers it (rt-web.ts catchAcc) +const orNull = (f: () => any) => { const acc: any = untrack(() => createErrorBoundary(() => f(), () => ({ failed: true }))); return () => { const v = acc(); return v?.failed ? null : v; }; }; + +test("a guess into a store over an error boundary doesn't show while an accepted move's refreshes are out", async () => { const fl: Record<string, ((v: any) => void)[]> = {}; const ask = (k: string) => new Promise<any>(r => (fl[k] ??= []).push(r)); const land = (k: string, v: any) => { const rs = fl[k] ?? []; if (!rs.length) throw new Error("no ask " + k); fl[k] = []; rs.forEach(r => r(structuredClone(v))); }; @@ -21,10 +30,11 @@ test("a row guessed into a store doesn't show while a held add's write and an ac const [todoAns, setTodoAns] = createSignal<any>(() => ask("todo"), { loadingValue: null } as any); const [doingAns] = createSignal<any>(() => ask("doing"), { loadingValue: null } as any); - const [todo, setTodo] = createOptimisticStore<Row[]>((d: any) => { landDraft(d, todoAns() ?? []); }, [], { key: "id" } as any); - const [doing, setDoing] = createOptimisticStore<Row[]>((d: any) => { landDraft(d, doingAns() ?? []); }, [], { key: "id" } as any); + const todoSeen = orNull(todoAns), doingSeen = orNull(doingAns); + const [todo, setTodo] = createOptimisticStore<Row[]>((d: any) => { landDraft(d, todoSeen() ?? []); }, [], { key: "id" } as any); + const [doing, setDoing] = createOptimisticStore<Row[]>((d: any) => { landDraft(d, doingSeen() ?? []); }, [], { key: "id" } as any); - // a move as the server makes it: each list's new shape, as a function guess (#4724: one write per differing index) + // a move as the server makes it: each list's new shape (#4724: one write per differing index) const move = action(function* (id: number) { const row = todo.find(r => r.id === id)!; setTodo((rs: Row[]) => rs.filter(r => r.id !== id)); @@ -32,7 +42,7 @@ test("a row guessed into a store doesn't show while a held add's write and an ac yield ask("move"); refresh(todoAns); refresh(doingAns); }); - // an add: a placeholder guessed at the end of To do; the answer is the new card, written into To do's answer + // an add: a placeholder at the end of To do; the answer is the new card, written into To do's answer const add = action(function* (key: number, title: string, at: number) { setTodo((d: any) => { d[at] = { id: key, title, adding: true }; }); const r: any = yield ask(`add:${title}`); @@ -40,7 +50,7 @@ test("a row guessed into a store doesn't show while a held add's write and an ac }); const col = (rows: Row[]) => <ul><For each={rows}>{(r: any) => <li>{r.adding ? `(${r.title})` : r.id}</li>}</For></ul>; - render(() => <div><Show when={todoAns() != null} fallback={<ul><li>loading</li></ul>}>{col(todo)}</Show><Show when={doingAns() != null} fallback={<ul><li>loading</li></ul>}>{col(doing)}</Show></div>, c); + render(() => <div>{col(todo)}{col(doing)}</div>, c); const state = () => [...c.querySelectorAll("ul")].map(u => [...u.querySelectorAll("li")].map(l => l.textContent).join(",")).join(" | "); land("todo", [{ id: 1, title: "a" }, { id: 2, title: "b" }]); land("doing", [{ id: 3, title: "c" }]); await tick(); @@ -50,10 +60,9 @@ test("a row guessed into a store doesn't show while a held add's write and an ac move(10); await tick(); expect(state()).toBe("1,2 | 3,10"); add(-2, "B", todo.length); await tick(); - land("add:B", { id: 11, title: "B" }); await tick(); // held with the move (same slot, todo[2]) - land("move", {}); await tick(); // accepted: refreshes of both answers out - console.log("before C:", state()); + land("add:B", { id: 11, title: "B" }); await tick(); // held with the move (both on To do's slot 2) + expect(state()).toBe("1,2,(B) | 3,10"); + land("move", {}); await tick(); // accepted: both refreshes out add(-3, "C", todo.length); await tick(); - console.log("after C:", state()); - expect(state()).toContain("(C)"); + expect(state()).toBe("1,2,(B),(C) | 3,10"); // got "1,2,(B) | 3,10": C's guess never shows }); diff --git a/eval/results/r15prep/repro/selection-memo-misses-guess.spec.tsx b/eval/results/r15prep/repro/selection-memo-misses-guess.spec.tsx index a63171a6..91a45a54 100644 --- a/eval/results/r15prep/repro/selection-memo-misses-guess.spec.tsx +++ b/eval/results/r15prep/repro/selection-memo-misses-guess.spec.tsx @@ -57,5 +57,5 @@ test("a memo over an optimistic store misses a path guess while a row guess and add(board.length); await tick(); land("move", {}); await tick(); // accepted: refreshes of a and b out rename(board.findIndex(r => r.id === 5), "FIVE"); await tick(); - expect(state()).toBe("-1:new |1:one 5:FIVE || FIVE"); // got "… || five": the column shows the guess, the memo's row doesn't // got "… || five": the memo's row misses the guess + expect(state()).toBe("-1:new |1:one 5:FIVE || FIVE"); // got "… || five": the column shows the guess, the memo's row doesn't }); From 4a192c57d720d3991930865c99292ce873cad1a5 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 15 Nov 2026 09:36:34 +0000 Subject: [PATCH 28/32] eval: rx-cols re-asks the card's server column after a move's answer (seed 28); repro for solidjs/solid#4897 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- eval/reference/board/rx-cols/GRADE.txt | 11 ++--- eval/reference/board/rx-cols/board.rx | 3 +- .../cols-placeholder-beside-card.steps.mjs | 12 +++++ .../placeholder-stays-beside-card.spec.tsx | 48 +++++++++++++++++++ 4 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 eval/results/r15prep/repro/cols-placeholder-beside-card.steps.mjs create mode 100644 eval/results/r15prep/repro/placeholder-stays-beside-card.spec.tsx diff --git a/eval/reference/board/rx-cols/GRADE.txt b/eval/reference/board/rx-cols/GRADE.txt index 5537a57e..48637e76 100644 --- a/eval/reference/board/rx-cols/GRADE.txt +++ b/eval/reference/board/rx-cols/GRADE.txt @@ -1,12 +1,11 @@ -rx-cols (one keyed store per column answer), Solid next fab8bd9a4, TASK=board FUZZ_ROUND=--r15 +rx-cols (one keyed store per fetch), Solid next f675c66cd + #4889 fix (4ea97b7bc), TASK=board FUZZ_ROUND=--r15 == hidden ✗ two adds show both, each taking its own id (legal H-11) 44/45 passed == overlap -✗ one of two moves declined puts back only its card (moves touching one column share index slots, end together) +✗ one of two moves declined puts back only its card (two moves sharing two lists end together: by design) ✗ a toggle declined after another card was selected puts the first card's count back (legal) -✗ a move into Doing that fills it while a move out is pending disables the rest (as the first, untraced) +✗ a move into Doing that fills it while a move out is pending disables the rest (as the first) 23/26 passed -== fuzz -seeds 1-20: 2 holes (15, 17: an answered add's placeholder stays beside its card) -seeds 21-60: 4 holes (24, 28 duplicate card; 36 two views; 41 count) +== fuzz (60 seeds) +3 holes: 15, 17 = solidjs/solid#4897; 41 = an item add declined after its card moved to another column's store (untraced) diff --git a/eval/reference/board/rx-cols/board.rx b/eval/reference/board/rx-cols/board.rx index 58e1b8c1..e2c66e06 100644 --- a/eval/reference/board/rx-cols/board.rx +++ b/eval/reference/board/rx-cols/board.rx @@ -179,7 +179,8 @@ export component BoardPage() { guess done := rs => shifted(rs, "done", m) onFlush(() => { moveError := "" }) await moveCard(m.id, m.to, m.index) - await reask([from, m.from, m.to]) + // and the column the server had it in when it answered: another move of this card may have been answered first + await reask([from, m.from, m.to, has(latest todoSeen, m.id) ? "todo" : has(latest doingSeen, m.id) ? "doing" : "done"]) } catch (e) { moveError := e.message onFlush(() => { moveError := e.message }) diff --git a/eval/results/r15prep/repro/cols-placeholder-beside-card.steps.mjs b/eval/results/r15prep/repro/cols-placeholder-beside-card.steps.mjs new file mode 100644 index 00000000..4b56e2c7 --- /dev/null +++ b/eval/results/r15prep/repro/cols-placeholder-beside-card.steps.mjs @@ -0,0 +1,12 @@ +// rx-cols fuzz seed 17: an answered add's "(adding…)" placeholder shows beside its real card. +// node probe-rx.mjs <desk> $PWD/cols-placeholder-beside-card.steps.mjs +import { land, near, typeClick, TODO, DOING, DONE } from "/tmp/claude-0/runs/r15prep/board-rx3/scratch/common.mjs"; +export default [ + ["land todo", land("fetchCards:todo", TODO)], ["land done", land("fetchCards:done", DONE)], + ["add Alpha 2", typeClick("new-card", "Alpha 2", "add-card")], + ["land doing", land("fetchCards:doing", DOING)], + ["right #3", p => near(p, 3, "right")], + ["up #2", p => near(p, 2, "up")], + ["right #1", p => near(p, 1, "right")], + ["land add", land("addCard:todo,Alpha 2", { id: 10, title: "Alpha 2" })], +]; diff --git a/eval/results/r15prep/repro/placeholder-stays-beside-card.spec.tsx b/eval/results/r15prep/repro/placeholder-stays-beside-card.spec.tsx new file mode 100644 index 00000000..47d07fcb --- /dev/null +++ b/eval/results/r15prep/repro/placeholder-stays-beside-card.spec.tsx @@ -0,0 +1,48 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +// Fuzz seeds 15, 17 (eval/reference/board/rx-cols): an answered add's placeholder stays beside the card it became. +// One keyed optimistic store over one async answer. An add guesses a placeholder at the end; a reorder of the two +// rows above it (a function guess changing slots 0 and 1 only) is out; the add's answer is written into the answer +// and the add ends. Both the placeholder and the new card show. Without the reorder: only the card. +import { test, expect } from "vitest"; +import { For, action, createSignal, createOptimisticStore, flush } from "solid-js"; +import { render } from "../src/index.js"; +const tick = async () => { for (let i = 0; i < 4; i++) { await new Promise(r => setTimeout(r, 0)); flush(); } }; +type Row = { id: number; title: string; adding?: boolean }; +// keyed landing: rows matched by key, leaves merged +function landDraft(draft: any[], v: any[]) { + const had = new Map<unknown, any>(); for (const row of draft) had.set(row.id, row); + v.forEach((x, i) => { const row = had.get(x.id); if (row) { for (const k of Object.keys(x)) if (row[k] !== x[k]) row[k] = x[k]; if (draft[i] !== row) draft[i] = row; } else draft[i] = x; }); + draft.length = v.length; +} +test("an answered add's placeholder stays beside its card while a reorder above it is out", async () => { + const fl: Record<string, ((v: any) => void)[]> = {}; + const ask = (k: string) => new Promise<any>(r => (fl[k] ??= []).push(r)); + const land = (k: string, v: any) => { const rs = fl[k] ?? []; if (!rs.length) throw new Error("no ask " + k); fl[k] = []; rs.forEach(r => r(structuredClone(v))); }; + const c = document.body.appendChild(document.createElement("div")); + const [ans, setAns] = createSignal<any>(() => ask("list"), { loadingValue: null } as any); + const [rows, setRows] = createOptimisticStore<Row[]>((d: any) => { landDraft(d, ans() ?? []); }, [], { key: "id" } as any); + // a reorder: the list's new shape (#4724: one write per differing index) + const up = action(function* (id: number) { + setRows((rs: Row[]) => { const i = rs.findIndex(r => r.id === id); const out = rs.slice(); [out[i - 1], out[i]] = [out[i], out[i - 1]]; return out; }); + yield ask("move"); + }); + // an add: a placeholder guessed at the end; the answer is the new row, written into the answer + const add = action(function* (key: number, title: string, at: number) { + setRows((d: any) => { d[at] = { id: key, title, adding: true }; }); + const r: any = yield ask("add"); + setAns((cs: any[]) => [...cs, { id: r.id, title: r.title }]); + }); + render(() => <ul><For each={rows}>{(r: any) => <li>{r.adding ? `(${r.title})` : r.id}</li>}</For></ul>, c); + const state = () => [...c.querySelectorAll("li")].map(l => l.textContent).join(","); + + land("list", [{ id: 1, title: "a" }, { id: 2, title: "b" }]); await tick(); + add(-1, "A", rows.length); await tick(); + expect(state()).toBe("1,2,(A)"); + up(2); await tick(); + expect(state()).toBe("2,1,(A)"); + land("add", { id: 10, title: "A" }); await tick(); + expect(state()).toBe("2,1,10"); // got "2,1,(A),10" +}); From 4a2b41afd88a50c469e3b9b696be7f101d802919 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 15 Nov 2026 09:37:47 +0000 Subject: [PATCH 29/32] eval: seed 41 traced to solidjs/solid#4897 (declined variant) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- eval/reference/board/rx-cols/GRADE.txt | 2 +- .../placeholder-stays-after-decline.spec.tsx | 49 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 eval/results/r15prep/repro/placeholder-stays-after-decline.spec.tsx diff --git a/eval/reference/board/rx-cols/GRADE.txt b/eval/reference/board/rx-cols/GRADE.txt index 48637e76..8ccbfd50 100644 --- a/eval/reference/board/rx-cols/GRADE.txt +++ b/eval/reference/board/rx-cols/GRADE.txt @@ -8,4 +8,4 @@ rx-cols (one keyed store per fetch), Solid next f675c66cd + #4889 fix (4ea97b7bc ✗ a move into Doing that fills it while a move out is pending disables the rest (as the first) 23/26 passed == fuzz (60 seeds) -3 holes: 15, 17 = solidjs/solid#4897; 41 = an item add declined after its card moved to another column's store (untraced) +3 holes: 15, 17 = solidjs/solid#4897; 41 = a declined item add stays in the checklist, solidjs/solid#4897 (declined variant) diff --git a/eval/results/r15prep/repro/placeholder-stays-after-decline.spec.tsx b/eval/results/r15prep/repro/placeholder-stays-after-decline.spec.tsx new file mode 100644 index 00000000..6b78b068 --- /dev/null +++ b/eval/results/r15prep/repro/placeholder-stays-after-decline.spec.tsx @@ -0,0 +1,49 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +// Fuzz seeds 15, 17 (eval/reference/board/rx-cols): an answered add's placeholder stays beside the card it became. +// One keyed optimistic store over one async answer. An add guesses a placeholder at the end; a reorder of the two +// rows above it (a function guess changing slots 0 and 1 only) is out; the add's answer is written into the answer +// and the add ends. Both the placeholder and the new card show. Without the reorder: only the card. +import { test, expect } from "vitest"; +import { For, action, createSignal, createOptimisticStore, flush } from "solid-js"; +import { render } from "../src/index.js"; +const tick = async () => { for (let i = 0; i < 4; i++) { await new Promise(r => setTimeout(r, 0)); flush(); } }; +type Row = { id: number; title: string; adding?: boolean }; +// keyed landing: rows matched by key, leaves merged +function landDraft(draft: any[], v: any[]) { + const had = new Map<unknown, any>(); for (const row of draft) had.set(row.id, row); + v.forEach((x, i) => { const row = had.get(x.id); if (row) { for (const k of Object.keys(x)) if (row[k] !== x[k]) row[k] = x[k]; if (draft[i] !== row) draft[i] = row; } else draft[i] = x; }); + draft.length = v.length; +} +test("a declined add's placeholder stays beside its card while a reorder above it is out", async () => { + const fl: Record<string, ((v: any) => void)[]> = {}; + const ask = (k: string) => new Promise<any>((r, j) => { (fl[k] ??= []).push(r); ((fl as any)["!" + k] ??= []).push(j); }); + const fail = (k: string) => { (fl as any)["!" + k]?.forEach((j: any) => j(new Error("no"))); }; + const land = (k: string, v: any) => { const rs = fl[k] ?? []; if (!rs.length) throw new Error("no ask " + k); fl[k] = []; rs.forEach(r => r(structuredClone(v))); }; + const c = document.body.appendChild(document.createElement("div")); + const [ans, setAns] = createSignal<any>(() => ask("list"), { loadingValue: null } as any); + const [rows, setRows] = createOptimisticStore<Row[]>((d: any) => { landDraft(d, ans() ?? []); }, [], { key: "id" } as any); + // a reorder: the list's new shape (#4724: one write per differing index) + const up = action(function* (id: number) { + setRows((rs: Row[]) => { const i = rs.findIndex(r => r.id === id); const out = rs.slice(); [out[i - 1], out[i]] = [out[i], out[i - 1]]; return out; }); + yield ask("move"); + }); + // an add: a placeholder guessed at the end; the answer is the new row, written into the answer + const add = action(function* (key: number, title: string, at: number) { + setRows((d: any) => { d[at] = { id: key, title, adding: true }; }); + try { const r: any = yield ask("add"); + setAns((cs: any[]) => [...cs, { id: r.id, title: r.title }]); } catch {} + }); + render(() => <ul><For each={rows}>{(r: any) => <li>{r.adding ? `(${r.title})` : r.id}</li>}</For></ul>, c); + const state = () => [...c.querySelectorAll("li")].map(l => l.textContent).join(","); + + land("list", [{ id: 1, title: "a" }, { id: 2, title: "b" }]); await tick(); + add(-1, "A", rows.length); await tick(); + expect(state()).toBe("1,2,(A)"); + up(2); await tick(); + expect(state()).toBe("2,1,(A)"); + fail("add"); await tick(); + expect(state()).toBe("2,1"); +}); From 8876be0fb13a9f41d4a69975674e47d5660994ae Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 15 Nov 2026 09:47:40 +0000 Subject: [PATCH 30/32] guide: one keyed store per fetch; moves guess each list they change; re-ask the row's current answer; task-neutral examples Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- guide/async.md | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/guide/async.md b/guide/async.md index 263e5610..aaaadec7 100644 --- a/guide/async.md +++ b/guide/async.md @@ -284,20 +284,26 @@ export action add(id: string, title: string) { ### One store over the server -When several views show one kind of row (a board's columns, a list and its detail, a count), keep one store of them, -not a copy per view: - -- **Lay the answers flat in one keyed equation.** Each server answer stays a question of its own; one equation joins - them into one list with a `key`, and every view is a `filter` or a sort of it. A row is then one row wherever it - shows, and a guess on it shows in all of them. -- **One write is one path guess on one row** (`guess rows[i].field := v`, "Writing one row of a list" above). Not a - guess on the whole list, and not one per view. +When the server answers a kind of row in several fetches (one list per status, per folder, per page), keep one keyed store +per fetch, each the answer it stands for, row for row, and show each from its own store: + +- **A store per fetch, keyed.** `open key id = rowsOf(openAns catch (e) => null)`: the order on screen is the server's + order, and a row lives in exactly one store, so it shows once. +- **An edit is one path guess on one row** (`guess open[i].title := t`, "Writing one row of a list" above), in the + store that shows the row. Not a guess on the whole list. +- **A write that moves rows guesses each list it changes, as the server will make it.** A row moving between lists + leaves one and goes into another at an index: `guess open := rs => moved(rs, "open", m)` for each store. Two + such writes that change the same list share its slots (one write per differing index, #4724), so they end together: + a declined move stays up until the other one ends. Writes on lists they don't share stay apart. +- When one write shows in several fetches (a title in a list and in a detail fetched on its own), or on a row that + may not be on screen yet, lay it over the answers instead ("Every view of a write" below). - **Work out positions in the handler and pass them in.** An index, a count, a place in the target: compute them in the handler from the screen (a snapshot, R-8) and pass them as arguments. An action that reads a value another action guessed and then guesses is one change with that action (H-11, Solid's D-33; solidjs/solid#4788): the two end together, so a declined write stays up until the other one ends. - **On accept, re-ask the answers that held the row and the one it joined.** Name them from the answers and the - action's arguments, not from the store, whose row already shows the guess. On a decline re-ask nothing: the guess + action's arguments, not from the store, whose row already shows the guess, and add the answer that holds the row + now (`latest`): another write of the same row may have been answered first. On a decline re-ask nothing: the guess reverts by itself. - **A create whose answer is the new row** writes that row into its answer source after the `await` (a source with a question takes a write until its next answer, ST-5a, so the row is never there twice). Until then guess a placeholder From 20589f428fa133ccdbcc3e662813676c5294e8bc Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 15 Nov 2026 10:16:24 +0000 Subject: [PATCH 31/32] prereg: shared-list holds excluded as by design (Ryan, 2026-11-15) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- eval/PREREG-15.md | 1 + 1 file changed, 1 insertion(+) diff --git a/eval/PREREG-15.md b/eval/PREREG-15.md index 4e556bc1..64b647a0 100644 --- a/eval/PREREG-15.md +++ b/eval/PREREG-15.md @@ -44,6 +44,7 @@ All six arms have a hand-written reference that grades 0 on both tasks (see "Ref ## Grading - **What counts:** only defects (Ryan, 2026-11-10). Holds that are by design (H-11) and failures the brief caused are excluded from every total. Each exclusion is listed with its trace. + - **Shared-list holds (Ryan, 2026-11-15):** two writes that guess the same list share its slots (#4724) and end together, so a declined one stays up, consistently, until the other ends. These are by-design holds and excluded like H-11. On board, the per-fetch .rx reference fails two overlap tests this way ("one of two moves declined puts back only its card", "a move into Doing that fills it while a move out is pending disables the rest"). Both are excluded, with traces. - **Defects per app:** failed hidden tests, plus failed overlap tests, plus fuzz seeds with a hole, with a task's tests and fuzz counted together (as `harness/grade.sh`). - **Fuzz rules,** the same for every arm: - **Same field written twice:** at rest, the field must show the server's last answer (Ryan, 2026-11-14, final). This is why `--r15` drops desk9's A2 allowance. From d2bfd96082b76ffe64881869345c2dbec7d75a22 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 15 Nov 2026 12:08:42 +0000 Subject: [PATCH 32/32] eval: rx-cols re-asks every column whose answer has the moved card; 0/60 on next 3dd86e8f2 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt --- eval/reference/board/rx-cols/GRADE.txt | 11 +++++++---- eval/reference/board/rx-cols/board.rx | 6 ++++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/eval/reference/board/rx-cols/GRADE.txt b/eval/reference/board/rx-cols/GRADE.txt index 8ccbfd50..6e240751 100644 --- a/eval/reference/board/rx-cols/GRADE.txt +++ b/eval/reference/board/rx-cols/GRADE.txt @@ -1,11 +1,14 @@ -rx-cols (one keyed store per fetch), Solid next f675c66cd + #4889 fix (4ea97b7bc), TASK=board FUZZ_ROUND=--r15 +rx-cols (one keyed store per fetch), Solid next 3dd86e8f2 (#4889, #4897 fixed), TASK=board FUZZ_ROUND=--r15 == hidden ✗ two adds show both, each taking its own id (legal H-11) 44/45 passed == overlap -✗ one of two moves declined puts back only its card (two moves sharing two lists end together: by design) +✗ one of two moves declined puts back only its card (shared-list hold, excluded: Ryan 2026-11-15) ✗ a toggle declined after another card was selected puts the first card's count back (legal) -✗ a move into Doing that fills it while a move out is pending disables the rest (as the first) +✗ a move into Doing that fills it while a move out is pending disables the rest (shared-list hold, excluded) 23/26 passed == fuzz (60 seeds) -3 holes: 15, 17 = solidjs/solid#4897; 41 = a declined item add stays in the checklist, solidjs/solid#4897 (declined variant) +0/60 +Known, not hit by these seeds: a toggle's or item add's count guess is a path write on the row in the column store +where it was clicked, so it doesn't follow the card if the card moves before the write ends (seed 41 hit it before the +re-ask fix changed its path). Fix pending Ryan's call. diff --git a/eval/reference/board/rx-cols/board.rx b/eval/reference/board/rx-cols/board.rx index e2c66e06..3d455334 100644 --- a/eval/reference/board/rx-cols/board.rx +++ b/eval/reference/board/rx-cols/board.rx @@ -37,6 +37,8 @@ fn counted(cs: Card[] | null, id: number): Card[] | null => cs == null ? cs : cs fn withItem(l: List | null, card: number, it: Item): List | null => l == null || l.card != card || l.items.some(x => x.id == it.id) ? l : { card: card, items: [...l.items, it] } fn has(cs: Card[] | null, id: number): boolean => cs != null && cs.some(c => c.id == id) +fn holders(t: Card[] | null, d: Card[] | null, n: Card[] | null, id: number): string[] => + [...(has(t, id) ? ["todo"] : []), ...(has(d, id) ? ["doing"] : []), ...(has(n, id) ? ["done"] : [])] // A move as the server makes it: the card leaves `from` and goes into `to` at `index`, counted without it. type Move = { id: number; from: string; to: string; index: number; row: Row } @@ -179,8 +181,8 @@ export component BoardPage() { guess done := rs => shifted(rs, "done", m) onFlush(() => { moveError := "" }) await moveCard(m.id, m.to, m.index) - // and the column the server had it in when it answered: another move of this card may have been answered first - await reask([from, m.from, m.to, has(latest todoSeen, m.id) ? "todo" : has(latest doingSeen, m.id) ? "doing" : "done"]) + // and every column whose answer has it now: another move of this card may have been answered first + await reask([from, m.from, m.to, ...holders(latest todoSeen, latest doingSeen, latest doneSeen, m.id)]) } catch (e) { moveError := e.message onFlush(() => { moveError := e.message })