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 7748a960d0fa3b24afe609eb591f9c2eda29320e
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/memory-match/LICENSE b/apps/memory-match/LICENSE
new file mode 100644
index 0000000..812de8f
--- /dev/null
+++ b/apps/memory-match/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 CorvidLabs
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/apps/memory-match/package.json b/apps/memory-match/package.json
new file mode 100644
index 0000000..4d6b732
--- /dev/null
+++ b/apps/memory-match/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "memory-match",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "license": "MIT",
+ "scripts": {
+ "test": "bun test",
+ "typecheck": "tsc --noEmit -p tsconfig.json",
+ "build": "bun build.ts",
+ "start": "bun serve.ts"
+ },
+ "devDependencies": {
+ "typescript": "5.9.3"
+ }
+}
diff --git a/apps/memory-match/src/model.ts b/apps/memory-match/src/model.ts
new file mode 100644
index 0000000..c2f07b7
--- /dev/null
+++ b/apps/memory-match/src/model.ts
@@ -0,0 +1,29 @@
+// Red-phase API stubs. Root commits these failure inputs before implementation.
+export const PAIR_COUNT = 6;
+export const MISMATCH_DELAY_MS = 1_000;
+export type RandomSource = () => number;
+export type CardFace = "down" | "up" | "matched";
+export type MemoryPhase = "playing" | "complete";
+export interface MemoryCard {
+ readonly id: number;
+ readonly pairId: number;
+ readonly face: CardFace;
+}
+export interface MemoryState {
+ readonly phase: MemoryPhase;
+ readonly cards: readonly MemoryCard[];
+ readonly firstCardId: number | null;
+ readonly mismatchUntilMs: number | null;
+}
+export function createGame(random: RandomSource): MemoryState {
+ throw new Error("Not implemented: createGame");
+}
+export function revealCard(state: MemoryState, cardId: number, nowMs: number): MemoryState {
+ throw new Error("Not implemented: revealCard");
+}
+export function advanceGame(state: MemoryState, nowMs: number): MemoryState {
+ throw new Error("Not implemented: advanceGame");
+}
+export function resetGame(random: RandomSource): MemoryState {
+ throw new Error("Not implemented: resetGame");
+}
diff --git a/apps/memory-match/tests/model.test.ts b/apps/memory-match/tests/model.test.ts
new file mode 100644
index 0000000..ba7fdd7
--- /dev/null
+++ b/apps/memory-match/tests/model.test.ts
@@ -0,0 +1,131 @@
+import { describe, expect, test } from "bun:test";
+import { PAIR_COUNT, MISMATCH_DELAY_MS, createGame, revealCard, advanceGame, resetGame, type MemoryState, type MemoryCard } from "../src/model";
+const random = (): number => 0.25;
+function card(state: MemoryState, id: number): MemoryCard {
+ const found = state.cards.find((item) => item.id === id);
+ if (!found) throw new Error(`Missing card ${id}`);
+ return found;
+}
+function frozen(state: MemoryState): MemoryState {
+ state.cards.forEach(Object.freeze);
+ Object.freeze(state.cards);
+ return Object.freeze(state);
+}
+function complete(): MemoryState {
+ let state = createGame(random);
+ for (let pair = 0; pair < 6; pair += 1) {
+ state = revealCard(state, pair * 2, pair * 10);
+ state = revealCard(state, pair * 2 + 1, pair * 10 + 1);
+ }
+ return state;
+}
+
+describe("REQ-memory-match-001", () => {
+ test("shuffle preserves exactly six pairs, unique identities and down faces", () => {
+ const state = createGame(random);
+ expect(PAIR_COUNT).toBe(6);
+ expect(MISMATCH_DELAY_MS).toBe(1_000);
+ expect(state.cards).toHaveLength(12);
+ expect(new Set(state.cards.map((item) => item.id)).size).toBe(12);
+ for (let pair = 0; pair < 6; pair += 1) expect(state.cards.filter((item) => item.pairId === pair)).toHaveLength(2);
+ expect(state.cards.every((item) => item.face === "down")).toBe(true);
+ expect(state.phase).toBe("playing");
+ expect(state.firstCardId).toBeNull();
+ expect(state.mismatchUntilMs).toBeNull();
+ expect(createGame(random)).toEqual(state);
+ const low = createGame(() => 0).cards.map((item) => item.id);
+ const high = createGame(() => 0.999999).cards.map((item) => item.id);
+ expect(low).not.toEqual(high);
+ expect([...low].sort((a, b) => a - b)).toEqual([...high].sort((a, b) => a - b));
+ });
+ test("same-card repeat and unknown IDs cannot create a match", () => {
+ const initial = frozen(createGame(random));
+ const first = frozen(revealCard(initial, 0, 0));
+ expect(first.firstCardId).toBe(0);
+ expect(first.cards.filter((item) => item.face === "up")).toHaveLength(1);
+ expect(revealCard(first, 0, 1)).toEqual(first);
+ expect(revealCard(first, 99, 1)).toEqual(first);
+ expect(first.phase).toBe("playing");
+ expect(initial.cards.every((item) => item.face === "down")).toBe(true);
+ });
+ test("distinct partners match and matched cards remain protected", () => {
+ const first = frozen(revealCard(createGame(random), 0, 0));
+ const matched = frozen(revealCard(first, 1, 1));
+ expect(card(matched, 0).face).toBe("matched");
+ expect(card(matched, 1).face).toBe("matched");
+ expect(matched.firstCardId).toBeNull();
+ expect(matched.mismatchUntilMs).toBeNull();
+ expect(revealCard(matched, 0, 2)).toEqual(matched);
+ expect(matched.cards.filter((item) => item.face === "down")).toHaveLength(10);
+ expect(card(first, 0).face).toBe("up");
+ });
+ test("mismatch locks third input until exact deadline without extending it", () => {
+ const first = revealCard(createGame(random), 0, 100);
+ const mismatch = frozen(revealCard(first, 2, 200));
+ expect(mismatch.mismatchUntilMs).toBe(1_200);
+ expect(mismatch.firstCardId).toBeNull();
+ expect(mismatch.cards.filter((item) => item.face === "up")).toHaveLength(2);
+ expect(revealCard(mismatch, 4, 1_199)).toEqual(mismatch);
+ expect(advanceGame(mismatch, 1_199)).toEqual(mismatch);
+ const settled = advanceGame(mismatch, 1_200);
+ expect(settled.cards.every((item) => item.face === "down")).toBe(true);
+ expect(settled.mismatchUntilMs).toBeNull();
+ const next = revealCard(mismatch, 4, 1_200);
+ expect(card(next, 4).face).toBe("up");
+ expect(card(next, 0).face).toBe("down");
+ expect(card(next, 2).face).toBe("down");
+ expect(next.firstCardId).toBe(4);
+ expect(mismatch.mismatchUntilMs).toBe(1_200);
+ });
+ test("settling a mismatch preserves earlier matched pairs", () => {
+ let state = revealCard(revealCard(createGame(random), 0, 0), 1, 1);
+ state = revealCard(revealCard(state, 2, 100), 4, 200);
+ const settled = advanceGame(frozen(state), 1_200);
+ expect(card(settled, 0).face).toBe("matched");
+ expect(card(settled, 1).face).toBe("matched");
+ expect(card(settled, 2).face).toBe("down");
+ expect(card(settled, 4).face).toBe("down");
+ });
+ test("only the sixth pair completes the game; completion is stable", () => {
+ let state = createGame(random);
+ for (let pair = 0; pair < 5; pair += 1) {
+ state = revealCard(revealCard(state, pair * 2, pair * 10), pair * 2 + 1, pair * 10 + 1);
+ expect(state.phase).toBe("playing");
+ }
+ expect(state.cards.filter((item) => item.face === "matched")).toHaveLength(10);
+ const done = frozen(revealCard(revealCard(state, 10, 100), 11, 101));
+ expect(done.phase).toBe("complete");
+ expect(done.cards.every((item) => item.face === "matched")).toBe(true);
+ expect(done.firstCardId).toBeNull();
+ expect(done.mismatchUntilMs).toBeNull();
+ expect(revealCard(done, 0, 200)).toEqual(done);
+ expect(advanceGame(done, 10_000)).toEqual(done);
+ });
+ test("reset clears pending and completed games; old expiry cannot affect fresh state", () => {
+ const pending = frozen(revealCard(revealCard(createGame(random), 0, 100), 2, 200));
+ const done = frozen(complete());
+ for (const previous of [pending, done]) {
+ const snapshot = structuredClone(previous);
+ const reset = frozen(resetGame(random));
+ expect(reset.phase).toBe("playing");
+ expect(reset.cards.every((item) => item.face === "down")).toBe(true);
+ expect(reset.firstCardId).toBeNull();
+ expect(reset.mismatchUntilMs).toBeNull();
+ expect(advanceGame(reset, 1_200)).toEqual(reset);
+ expect(previous).toEqual(snapshot);
+ }
+ });
+ test("invalid random values require RangeError, not any stub error", () => {
+ for (const value of [-1, 1, NaN, Infinity]) {
+ expect(() => createGame(() => value)).toThrow(RangeError);
+ expect(() => resetGame(() => value)).toThrow(RangeError);
+ }
+ });
+ test("invalid clock values require RangeError", () => {
+ const state: MemoryState = Object.freeze({ phase: "playing", cards: Object.freeze([]), firstCardId: null, mismatchUntilMs: null });
+ for (const value of [-1, NaN, Infinity]) {
+ expect(() => revealCard(state, 0, value)).toThrow(RangeError);
+ expect(() => advanceGame(state, value)).toThrow(RangeError);
+ }
+ });
+});
diff --git a/apps/memory-match/tsconfig.json b/apps/memory-match/tsconfig.json
new file mode 100644
index 0000000..3a66cf3
--- /dev/null
+++ b/apps/memory-match/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": [
+ "ES2022",
+ "DOM"
+ ],
+ "strict": true,
+ "noEmit": true,
+ "types": [],
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true
+ },
+ "include": [
+ "src/**/*.ts"
+ ]
+}