← Tiny Tasks history

test(examples): add failing behavioral tests before app implementation

Actual project-only Git commit. Email addresses are redacted; source bytes and recorded timestamps are preserved. This is a code record, not human acceptance.

commit 6bae5399f81de2a775e1a36272beef77dd879f00
Author:     0xLeif <redacted@invalid>
AuthorDate: Sun Nov 8 19:26:05 2026 -0700
Commit:     0xLeif <redacted@invalid>
CommitDate: Sun Nov 8 19:26:05 2026 -0700

    test(examples): add failing behavioral tests before app implementation

diff --git a/apps/tiny-tasks/package.json b/apps/tiny-tasks/package.json
new file mode 100644
index 0000000..3fc31dc
--- /dev/null
+++ b/apps/tiny-tasks/package.json
@@ -0,0 +1,16 @@
+{
+  "name": "@corvid-examples/tiny-tasks",
+  "version": "0.1.0",
+  "private": true,
+  "type": "module",
+  "license": "MIT",
+  "scripts": {
+    "test": "bun test",
+    "typecheck": "tsc --project tsconfig.json",
+    "build": "bun run build.ts",
+    "start": "bun run serve.ts"
+  },
+  "devDependencies": {
+    "typescript": "5.9.3"
+  }
+}
diff --git a/apps/tiny-tasks/src/model.ts b/apps/tiny-tasks/src/model.ts
new file mode 100644
index 0000000..1777df3
--- /dev/null
+++ b/apps/tiny-tasks/src/model.ts
@@ -0,0 +1,33 @@
+export type ModelResult<Value> =
+    | { readonly ok: true; readonly value: Value }
+    | { readonly ok: false; readonly error: string };
+
+export interface FileExport {
+    readonly filename: string;
+    readonly mediaType: "application/json" | "text/plain";
+    readonly contents: string;
+}
+
+export type TaskEventType = "added" | "completed" | "reopened";
+export interface TaskEvent { readonly type: TaskEventType; readonly at: number; }
+export interface Task {
+    readonly id: string;
+    readonly text: string;
+    readonly completed: boolean;
+    readonly history: readonly TaskEvent[];
+}
+export interface TasksState { readonly version: 1; readonly tasks: readonly Task[]; }
+export const MAX_TASK_TEXT_LENGTH = 200;
+
+export function createTasksState(): TasksState { throw new Error("Not implemented"); }
+export function validateTasksState(_value: unknown): ModelResult<TasksState> { throw new Error("Not implemented"); }
+export function addTask(_state: TasksState, _text: string, _id: string, _at: number): ModelResult<TasksState> {
+    throw new Error("Not implemented");
+}
+export function completeTask(_state: TasksState, _id: string, _at: number): ModelResult<TasksState> {
+    throw new Error("Not implemented");
+}
+export function reopenTask(_state: TasksState, _id: string, _at: number): ModelResult<TasksState> {
+    throw new Error("Not implemented");
+}
+export function createTasksExport(_state: TasksState): FileExport { throw new Error("Not implemented"); }
diff --git a/apps/tiny-tasks/src/storage.ts b/apps/tiny-tasks/src/storage.ts
new file mode 100644
index 0000000..ce5d87e
--- /dev/null
+++ b/apps/tiny-tasks/src/storage.ts
@@ -0,0 +1,21 @@
+import type { TasksState } from "./model";
+
+export interface StorageAdapter {
+    getItem(key: string): string | null;
+    setItem(key: string, value: string): void;
+}
+export type LoadResult<Value> =
+    | { readonly status: "loaded"; readonly value: Value }
+    | { readonly status: "empty" }
+    | { readonly status: "warning"; readonly message: string };
+export type SaveResult =
+    | { readonly ok: true }
+    | { readonly ok: false; readonly message: string };
+
+export const TASKS_STORAGE_KEY = "corvid-examples:tiny-tasks:v1";
+export function loadTasks(_storage: StorageAdapter): LoadResult<TasksState> {
+    throw new Error("Not implemented");
+}
+export function saveTasks(_storage: StorageAdapter, _state: TasksState): SaveResult {
+    throw new Error("Not implemented");
+}
diff --git a/apps/tiny-tasks/tests/model.test.ts b/apps/tiny-tasks/tests/model.test.ts
new file mode 100644
index 0000000..6cabba0
--- /dev/null
+++ b/apps/tiny-tasks/tests/model.test.ts
@@ -0,0 +1,161 @@
+import { describe, expect, test } from "bun:test";
+import {
+    addTask, completeTask, createTasksExport, createTasksState, reopenTask, validateTasksState,
+    type ModelResult, type TasksState,
+} from "../src/model";
+
+const open: TasksState = { version: 1, tasks: [
+    { id: "a", text: "Read", completed: false, history: [{ type: "added", at: 10 }] },
+    { id: "b", text: "Write", completed: false, history: [{ type: "added", at: 11 }] },
+] };
+const done: TasksState = { version: 1, tasks: [
+    { id: "a", text: "Read", completed: true,
+      history: [{ type: "added", at: 10 }, { type: "completed", at: 20 }] },
+] };
+function value<Value>(result: ModelResult<Value>): Value {
+    if (!result.ok) throw new Error(result.error);
+    return result.value;
+}
+describe("Tiny Tasks actual model contract", () => {
+    test("creates an empty versioned state", () => {
+        expect(createTasksState()).toEqual({ version: 1, tasks: [] });
+    });
+    test("adds trimmed text with exact identity and initial history without mutating input", () => {
+        const before = structuredClone(open);
+        const result = value(addTask(open, "  Ship  ", "c", 12));
+        expect(result.tasks).toEqual([...open.tasks, {
+            id: "c", text: "Ship", completed: false, history: [{ type: "added", at: 12 }],
+        }]);
+        expect(open).toEqual(before);
+        expect(result).not.toBe(open);
+    });
+    test("completes a task and preserves its sibling", () => {
+        const before = structuredClone(open);
+        const result = value(completeTask(open, "a", 20));
+        expect(result.tasks[0]).toEqual({
+            id: "a", text: "Read", completed: true,
+            history: [{ type: "added", at: 10 }, { type: "completed", at: 20 }],
+        });
+        expect(result.tasks[1]).toEqual(open.tasks[1]);
+        expect(open).toEqual(before);
+    });
+    test("reopens retaining every earlier event", () => {
+        const before = structuredClone(done);
+        const result = value(reopenTask(done, "a", 30));
+        expect(result.tasks[0]).toEqual({ ...done.tasks[0], completed: false,
+            history: [...([{ type: "added", at: 10 }, { type: "completed", at: 20 }] as const), { type: "reopened", at: 30 }] });
+        expect(done).toEqual(before);
+    });
+    test("add complete reopen produces three ordered events", () => {
+        const added = value(addTask({ version: 1, tasks: [] }, "Read", "a", 10));
+        const completed = value(completeTask(added, "a", 20));
+        const reopened = value(reopenTask(completed, "a", 30));
+        expect(reopened.tasks[0]?.history).toEqual([
+            { type: "added", at: 10 }, { type: "completed", at: 20 }, { type: "reopened", at: 30 },
+        ]);
+        expect(reopened.tasks[0]?.completed).toBe(false);
+    });
+    test("accepts the exact 200-character boundary", () => {
+        expect(value(addTask(open, "x".repeat(200), "c", 20)).tasks[2]?.text).toHaveLength(200);
+    });
+    for (const text of ["", "   ", "x".repeat(201)]) {
+        test("rejects invalid task input without changing the state: " + text.length, () => {
+            const before = structuredClone(open);
+            {
+                const previous = structuredClone(open);
+                const rejected = addTask(open, text, "c", 20);
+                expect(rejected.ok).toBe(false);
+                if (!rejected.ok) expect(rejected.error.length).toBeGreaterThan(0);
+                expect(open).toEqual(previous);
+            }
+            expect(open).toEqual(before);
+        });
+    }
+    for (const id of ["", "bad id", "x".repeat(65), "a"]) {
+        test("rejects invalid or duplicate ID: " + id, () => {
+            {
+                const previous = structuredClone(open);
+                const rejected = addTask(open, "Read", id, 20);
+                expect(rejected.ok).toBe(false);
+                if (!rejected.ok) expect(rejected.error.length).toBeGreaterThan(0);
+                expect(open).toEqual(previous);
+            }
+        });
+    }
+    for (const at of [-1, NaN, Infinity, 1.5, Number.MAX_SAFE_INTEGER + 1]) {
+        test("rejects invalid time: " + at, () => {
+            {
+                const previous = structuredClone(open);
+                const rejected = addTask(open, "Read", "c", at);
+                expect(rejected.ok).toBe(false);
+                if (!rejected.ok) expect(rejected.error.length).toBeGreaterThan(0);
+                expect(open).toEqual(previous);
+            }
+        });
+    }
+    test("rejects missing IDs and illegal repeated transitions", () => {
+        {
+            const previous = structuredClone(open);
+            const rejected = completeTask(open, "missing", 20);
+            expect(rejected.ok).toBe(false);
+            if (!rejected.ok) expect(rejected.error.length).toBeGreaterThan(0);
+            expect(open).toEqual(previous);
+        }
+        {
+            const previous = structuredClone(done);
+            const rejected = completeTask(done, "a", 30);
+            expect(rejected.ok).toBe(false);
+            if (!rejected.ok) expect(rejected.error.length).toBeGreaterThan(0);
+            expect(done).toEqual(previous);
+        }
+        {
+            const previous = structuredClone(open);
+            const rejected = reopenTask(open, "a", 30);
+            expect(rejected.ok).toBe(false);
+            if (!rejected.ok) expect(rejected.error.length).toBeGreaterThan(0);
+            expect(open).toEqual(previous);
+        }
+    });
+    test("rejects backwards transition time while allowing equal milliseconds", () => {
+        {
+            const previous = structuredClone(open);
+            const rejected = completeTask(open, "a", 9);
+            expect(rejected.ok).toBe(false);
+            if (!rejected.ok) expect(rejected.error.length).toBeGreaterThan(0);
+            expect(open).toEqual(previous);
+        }
+        expect(value(completeTask(open, "a", 10)).tasks[0]?.completed).toBe(true);
+    });
+    test("validates a nonempty fixture by value without aliasing external data", () => {
+        const fixture = structuredClone(done);
+        const result = value(validateTasksState(fixture));
+        fixture.tasks = [];
+        expect(result).toEqual(done);
+    });
+    const invalid: readonly unknown[] = [
+        null, [], {}, { version: 2, tasks: [] }, { ...open, extra: true },
+        { version: 1, tasks: [...open.tasks, open.tasks[0]] },
+        { version: 1, tasks: [{ ...open.tasks[0], history: [] }] },
+        { version: 1, tasks: [{ ...done.tasks[0], completed: false }] },
+        { version: 1, tasks: [{ ...open.tasks[0], text: " untrimmed " }] },
+        { version: 1, tasks: [{ ...open.tasks[0], history: [{ type: "added", at: NaN }] }] },
+        { version: 1, tasks: [{ ...open.tasks[0], history: [
+            { type: "added", at: 10 }, { type: "reopened", at: 20 },
+        ] }] },
+        { version: 1, tasks: [{ ...done.tasks[0], history: [
+            { type: "added", at: 10 }, { type: "completed", at: 9 },
+        ] }] },
+    ];
+    invalid.forEach((input, index) => test("rejects malformed persisted state " + index, () => {
+        const result = validateTasksState(input);
+        expect(result.ok).toBe(false);
+        if (!result.ok) expect(result.error.length).toBeGreaterThan(0);
+    }));
+    test("exports current tasks including complete history as versioned JSON", () => {
+        const result = createTasksExport(done);
+        expect(result.filename).toBe("tiny-tasks.json");
+        expect(result.mediaType).toBe("application/json");
+        expect(result.contents).toBe(JSON.stringify(done, null, 2) + "\n");
+        expect(JSON.parse(result.contents)).toEqual(done);
+    });
+});
diff --git a/apps/tiny-tasks/tests/storage.test.ts b/apps/tiny-tasks/tests/storage.test.ts
new file mode 100644
index 0000000..a9bc838
--- /dev/null
+++ b/apps/tiny-tasks/tests/storage.test.ts
@@ -0,0 +1,76 @@
+import { expect, test } from "bun:test";
+import { completeTask, type TasksState } from "../src/model";
+import { loadTasks, saveTasks, TASKS_STORAGE_KEY, type StorageAdapter } from "../src/storage";
+
+const fixture: TasksState = { version: 1, tasks: [{ id: "a", text: "Read", completed: true, history: [{ type: "added", at: 10 }, { type: "completed", at: 20 }] }] };
+const expectedKey = "corvid-examples:tiny-tasks:v1";
+
+test("loads absent storage without writes or destructive reset", () => {
+    const reads: string[] = [];
+    let writes = 0;
+    const result = loadTasks({ getItem(key) { reads.push(key); return null; }, setItem() { writes++; } });
+    expect(result).toEqual({ status: "empty" });
+    expect(reads).toEqual([expectedKey]);
+    expect(writes).toBe(0);
+});
+test("loads actual nonempty JSON through model validation", () => {
+    const result = loadTasks({ getItem(key) { expect(key).toBe(expectedKey); return JSON.stringify(fixture); }, setItem() { throw new Error("Unexpected write"); } });
+    expect(result).toEqual({ status: "loaded", value: fixture });
+});
+for (const raw of ["{", JSON.stringify({ version: 9 }), "null", "[]"]) {
+    test("warns on malformed or unsupported persisted data: " + raw, () => {
+        let writes = 0;
+        const result = loadTasks({ getItem() { return raw; }, setItem() { writes++; } });
+        expect(result.status).toBe("warning");
+        if (result.status === "warning") expect(result.message.length).toBeGreaterThan(0);
+        expect("value" in result).toBe(false);
+        expect(writes).toBe(0);
+    });
+}
+test("warns when the actual adapter read throws", () => {
+    const result = loadTasks({ getItem() { throw new Error("Storage denied"); }, setItem() { throw new Error("Unexpected write"); } });
+    expect(result.status).toBe("warning");
+    if (result.status === "warning") expect(result.message.length).toBeGreaterThan(0);
+    expect("value" in result).toBe(false);
+});
+test("writes correct key and round-trips exact state through real helpers", () => {
+    const map = new Map<string, string>();
+    const writes: [string, string][] = [];
+    const adapter: StorageAdapter = {
+        getItem(key) { return map.get(key) ?? null; },
+        setItem(key, contents) { writes.push([key, contents]); map.set(key, contents); },
+    };
+    expect(saveTasks(adapter, fixture)).toEqual({ ok: true });
+    expect(writes).toHaveLength(1);
+    expect(writes[0]?.[0]).toBe(expectedKey);
+    expect(map.get(TASKS_STORAGE_KEY)).toBe(JSON.stringify(fixture));
+    expect(loadTasks(adapter)).toEqual({ status: "loaded", value: fixture });
+});
+test("actual write failure preserves current state and returns useful warning", () => {
+    const before = structuredClone(fixture);
+    let calls = 0;
+    const result = saveTasks({ getItem() { return null; }, setItem(key) { expect(key).toBe(expectedKey); calls++; throw new Error("Quota exceeded"); } }, fixture);
+    expect(result.ok).toBe(false);
+    if (!result.ok) expect(result.message.length).toBeGreaterThan(0);
+    expect(calls).toBe(1);
+    expect(fixture).toEqual(before);
+});
+test("save validates state before touching adapter", () => {
+    let calls = 0;
+    const invalid = { ...fixture, version: 2 } as unknown as TasksState;
+    const result = saveTasks({ getItem() { return null; }, setItem() { calls++; } }, invalid);
+    expect(result.ok).toBe(false);
+    expect(calls).toBe(0);
+});
+test("current real model edit survives failure then saves and reloads on recovery", () => {
+    const edited = completeTask({ version: 1, tasks: [{ id: "a", text: "Read", completed: false, history: [{ type: "added", at: 10 }] }] }, "a", 20);
+    if (!edited.ok) throw new Error(edited.error);
+    const before = structuredClone(edited.value);
+    const failed = saveTasks({ getItem() { return null; }, setItem() { throw new Error("Unavailable"); } }, edited.value);
+    expect(failed.ok).toBe(false);
+    expect(edited.value).toEqual(before);
+    let saved: string | null = null;
+    const adapter: StorageAdapter = { getItem() { return saved; }, setItem(_key, contents) { saved = contents; } };
+    expect(saveTasks(adapter, edited.value)).toEqual({ ok: true });
+    expect(loadTasks(adapter)).toEqual({ status: "loaded", value: before });
+});
diff --git a/apps/tiny-tasks/tsconfig.json b/apps/tiny-tasks/tsconfig.json
new file mode 100644
index 0000000..5c09ca1
--- /dev/null
+++ b/apps/tiny-tasks/tsconfig.json
@@ -0,0 +1,19 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "module": "ESNext",
+    "moduleResolution": "Bundler",
+    "lib": [
+      "ES2022",
+      "DOM",
+      "DOM.Iterable"
+    ],
+    "strict": true,
+    "noEmit": true,
+    "skipLibCheck": true,
+    "types": []
+  },
+  "include": [
+    "src/**/*.ts"
+  ]
+}