Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

42KB

Spec Driven Development — Chapter-17: 7.5 - Completing and Reopening a Task in Practice: the Whole Loop, File by File

  • Source: /library/Spec Driven Development/source-file.pdf
  • PDF pages: 216–273
  • Pages without text: none

7.5 - Completing and Reopening a Task in Practice: the Whole Loop, File by File How to read this chapter Chapter 7 followed the second lap of the cycle with the spotlight on clarify . Here you see the whole lap, the way it was recorded in the example app's repository, in the 002-concluir-tarefa feature. The prompt that went in, the specification that came out (with the four clarify answers already incorporated), the plan, the state model, the tasks and every code and test file the agent produced. Like 6.5, this is a reference chapter. And here it has an extra purpose: to make it visible that the second feature invents no new architecture. It adds one field, two use cases and one repository operation, and it all fits into the same places 6.5 showed. Several files below are modifications of files you already saw on the previous lap; where that is the case, the text points out what changed. The artifacts appear as markdown (headings lowered so as not to compete with the ones in the material); the code, in blocks, each preceded by a short sentence. The input: the /speckit-specify prompt

“Complete and reopen a task in a personal to-do list. A person can mark an existing task as done and go back by reopening it. Out of scope for this feature: a history of completions, saved completion date and time, multi-level undo, filtering and editing tasks.” The sentence is short and seems obvious: mark as done. Chapter 7 showed that the obvious hid decisions. What specify and clarify returned: spec.md Notice how the four clarify questions (disappears or stays, reorders or not, reversible or not, what to do with the repeated operation) turned into specific functional requirements. Without them, the feature would have been implemented in four different ways, all defensible. Feature Specification: Complete and reopen a task Feature Branch: 002-concluir-tarefa Created: 2026-06-27 Status: Draft Input: User description: “Complete and reopen a task in a personal to-do list. A person can mark an existing task as done and go back by reopening it. Out of scope for this feature: a history of completions, saved completion date and time, multi- level undo, filtering and editing tasks.” Clarifications

Session 2026-06-27 Q: When a task is completed, does it disappear from the list or stay visible? → A: It stays on the list, shown as completed (struck through/marked). There is no separate view of completed tasks. Q: Does completing a task change its position in the list? → A: No. The task stays in the same position (creation order is kept); completing does not reorder the list. Q: Is completing reversible? → A: Yes, by an explicit reopen action, which takes the task from completed back to open. Q: What happens when you complete an already completed task (or reopen an already open one)? → A: It is an idempotent no-op: the operation succeeds and the state stays the same, with no additional effect. User Scenarios & Testing (mandatory) User Story 1 - Complete a task (Priority: P1) The person using the To-Do finished a chore and wants to record that it is done. They mark the corresponding task as completed and the list starts to show that this item has been fulfilled, without it disappearing from the radar. Why this priority: It is the core of the feature and the reason it exists: to give the person the sense of progress from closing what they finished. Delivered on its own, it already produces value. Independent Test: It can be tested in isolation by completing an open task and checking that it moves to the completed state. Acceptance Scenarios:

  1. Given an open task on the list, When the person marks it as completed, Then the task moves to the completed state and

remains on the list. User Story 2 - Reopen a completed task (Priority: P1) The person completed a task by mistake, or the chore became necessary again. They reopen the task, which goes back to the open state, as if the completion had not happened. Why this priority: Completing with no way to go back is a trap; reversibility makes the action safe to use. It forms a pair with completing. Independent Test: It can be tested by completing a task and then reopening it, checking that it returns to the open state. Acceptance Scenarios:

  1. Given a completed task, When the person reopens it, Then the task goes back to the open state and remains on the list. Edge Cases Completing a task that is already completed is an idempotent no-op: the operation succeeds and the state does not change (FR-005). Reopening a task that is already open is an idempotent no-op, symmetric to the previous one (FR-005). A completed task stays visible on the list, shown as completed, in the same position it was in (FR-006). Requirements (mandatory) Functional Requirements FR-001: The system MUST allow marking an existing task as completed.

FR-002: The system MUST allow reopening a completed task, taking it back to the open state. FR-003: The system MUST represent the state of a task as one of two values: open or completed. FR-004: The system MUST preserve the state (open/completed) of tasks between usage sessions, together with the rest of the task's information. FR-005: The system MUST treat completing an already completed task (and reopening an already open one) as an idempotent operation: successful and without changing the state already in effect. FR-006: The system MUST keep the completed task visible on the list, in the same position (creation order preserved); completing MUST NOT reorder or hide the task. Non-Goals Explicitly out of scope: history of completions, saved completion date/time, multi-level undo, filtering and editing tasks (YAGNI). Key Entities Task: besides the title and description that already exist, it now has a state: open or completed. A task is born open; completing takes it to completed and reopening takes it back to open (inverse actions). A completed task stays on the list, in the same position. Completing what is already completed (or reopening what is already open) is idempotent. Success Criteria (mandatory) Measurable Outcomes

SC-001: The person can mark an open task as completed and see that state reflected on the list. SC-002: The person can reopen a completed task and see it go back to the open state. SC-003: The state of each task stays correct after closing and reopening the app. Assumptions The feature extends the create-and-list-tasks one (001-criar- tarefa), already integrated into main ; it reuses the Task entity, the repository and the existing layered architecture. The To-Do constitution governs the feature: Simplicity/YAGNI, layered architecture, logic isolated from the UI, error as value and TDD. The storage medium stays the one decided in the previous feature's Plan; here we only add the new state to what is already persisted. What plan decided: plan.md The high point of this plan is explicit traceability: a whole table links each clarify decision to a concrete effect in the design. That is how the conversation of the previous step survives: by turning into structure. Two of those decisions, error as value and the boundary between layers, get treatment of their own in FOCUS Architecture (2026, https://books.kodel.com.br/en/books/focus/), the second volume in this trilogy, which answers where each rule lives and why dependencies point inward. You do not need it to read the plan below: it carries each decision with the reasoning beside it, which is all this chapter asks of you.

Implementation Plan: Complete and reopen a task Branch: 002-concluir-tarefa | Spec: spec.md Input: Feature specification in specs/002-concluir-tarefa/spec.md Summary Add to the task a state (open/completed) and two inverse actions, complete and reopen, keeping the completed task visible on the list, in the same position. The decisions fixed in clarify shape the design: since completing is reversible, the plan foresees a reopen use case symmetric to complete (one action alone would not be enough); since the state needs to persist and the task stays in the same position, the repository gains an update operation that preserves the order; and since completing what is already completed is idempotent, the use cases handle the no-op without error. The rule stays isolated in the domain layer, with the UI only reflecting the state and triggering the actions. Technical Context Language/Version: TypeScript 5.x on Node 20+; target: browser (React web). Inherited from 001. Main dependencies: React 18, Vite, Zustand. No new dependencies. Persistence: localStorage behind TaskRepository , as in 001. The new state field goes into the same persisted JSON; old tasks without the field are read as open (backward compatibility). Tests: Vitest for the use cases (no DOM) and for the store.

Project type: single-page, single-user web application, no backend. Constitution Check Principle How the plan meets it I. Layered Architecture New use cases complete-task / reopen-task in the domain; data gains update ; presentation only reflects. II. Isolated Business Logic The state transition and idempotency live in the pure-TS use cases; React/Zustand decide nothing. III. Error as Value The predictable failure (nonexistent task) is Result / CompleteTaskFailure ; the idempotent no-op is success, not error. IV. TDD Tests of the use cases (complete, reopen, idempotency, nonexistent) before the implementation (see tasks.md). V. Simplicity/YAGNI No history, no saved date/time, no multi- level undo, no filter or editing; a single state field. VI. Technology Agnosticism The spec and the clarify do not name a stack; the decision (a boolean field persisted in the same medium) belongs to this plan. Result: PASS, no violation. State modeling decision

The state is represented by a boolean field completed on the Task entity (open = false , completed = true ). Two states, with no elaborate state machine (YAGNI). The alternative of an enum “open” | “done” was considered and dropped as overkill for two values; a named boolean is enough and keeps serialization trivial. Traceability clarify → plan clarify decision Effect on the plan Completing is reversible Two inverse use cases: complete-task and reopen- task . Stays in the same position getAll keeps ordering by createdAt ; update swaps the item in place, without reordering. Stays visible, struck through TaskList renders the completed state (without hiding it); the action toggles complete/reopen. Idempotency Completing an already completed one and reopening an already open one return success without changing the state. Project Structure (additions) src/ ├── domain/ │ ├── entities/task.ts # + completed field │ ├── failures/complete-task-failure.ts # new: task-not-found │ ├── repositories/task-repository.ts # + update(task) │ └── usecases/ │ ├── complete-task.ts # new │ └── reopen-task.ts # new ├── data/

│ └── local-storage-task-repository.ts # + update; backward-compatible read └── presentation/ ├── store/task-store.ts # + completeTask/reopenTask └── components/TaskList.tsx # + action and completed styling test/ └── domain/ ├── complete-task.test.ts # new └── reopen-task.test.ts # new Phase 1 - Design State model in data-model.md. Manual validation in quickstart.md. The plan ‘s state model: data-model.md On this lap, the design artifact that matters is the state model. It draws the transitions and, above all, fixes that the idempotent no-op is success, not error, a distinction Chapter 7 highlighted and that here becomes a contract. State model: Complete and reopen a task Task entity (addition) The Task gains a completed: boolean field. false → open task (the initial state of every created task). true → completed task.

Tasks persisted by the previous feature do not have the field; when read, the absent one is interpreted as false (open). Backward compatibility with no migration. Transitions complete open ─────────▶ completed ▲ │ └────────────────┘ reopen complete: open → completed. On an already completed one, no-op (idempotent). reopen: completed → open. On an already open one, no-op (idempotent). The position on the list does not change in any transition (order by createdAt preserved). Predictable failure task-not-found : the target task (by id) does not exist. Represented as a Result /value error, not an exception. The idempotent no-op is NOT a failure: it is success with no state change. The tasks execution list: tasks.md The task list is shorter than 001's, because the setup already exists. Notice that it starts with the domain (field, failure, contract), crosses data and ends in presentation, always with the

test before the implementation. Tasks: Complete and reopen a task Branch: 002-concluir-tarefa | Plan: plan.md TDD order: tests before the corresponding implementation. [P] marks tasks that can run in parallel (distinct files, no dependency). Phase 1 - Domain (core, pure TS) T001 src/domain/entities/task.ts : add completed: boolean field. T002 src/domain/failures/complete-task-failure.ts : CompleteTaskFailure union ( task-not-found ). T003 src/domain/repositories/task-repository.ts : add update(task: Task): Promise and getById(id) to the contract. T004 test/helpers/in-memory-task-repository.ts : implement update / getById in the fake, preserving the order. T005 test/domain/complete-task.test.ts : tests for CompleteTask (open→completed; idempotent on already completed; nonexistent → task-not-found ). Fails first. T006 src/domain/usecases/complete-task.ts : implement until T005 passes. T007 test/domain/reopen-task.test.ts : tests for ReopenTask (completed→open; idempotent on already open; nonexistent → task-not-found ). Fails first. T008 src/domain/usecases/reopen-task.ts : implement until T007 passes. Phase 2 - Data

T009 src/data/local-storage-task-repository.ts : implement update (replaces in place, without reordering) and getById ; backward- compatible read ( completed absent → false ). Phase 3 - Presentation T010 test/presentation/task-store.test.ts : store tests (complete updates the state; reopen reverts; idempotency does not break). Fails first. T011 src/presentation/store/task-store.ts : completeTask / reopenTask actions that orchestrate the use cases and reload the list. T012 src/presentation/components/TaskList.tsx : show the completed task as struck through, in the same position, with the action toggling complete/reopen. T013 src/main.tsx : compose the new use cases at the edge. Phase 4 - Validation T014 npm test (all green) and npm run build (clean type-check). T015 Validation of Success Criteria SC-001..SC-003. The code implement generated From here on it is all code, in the state it was in at the end of this lap. Where a file already existed on the previous lap, the text points out what changed. Domain The Task entity gains a single new field, completed , with the rest identical to 6.5: a boolean is enough for two states.

// src/domain/entities/task.ts /**

  • A chore recorded by the person .
  • The title is required and not repeated among the existing tasks; th e
  • description is optional. createdAt (ISO 8601) defines the display orde r
  • (oldest first). completed holds the state: false is open (the initia l
  • state of every created task), true is completed .

/ export type Task = { readonly id: string; readonly title: string; readonly description: string;

readonly createdAt: string; readonly completed: boolean; }; The predictable failure of the two new actions. It is shared by complete and reopen, and the comment makes explicit what is not a failure: the idempotent operation. // src/domain/failures/complete-task-failure.ts /**

  • Predictable failure when completing or reopening a task .
  • task-not-found: no task exists with the given id. The idempotent no-o p
  • (completing the already completed one, reopening the already open one) is NOT
  • a failure: it is success with no state change .

/

export type CompleteTaskFailure = { readonly kind: “task-not-found” }; The repository grows: besides getAll and add , it now declares getById (to find the target task) and update (to swap it in place, without reordering). The contract changes; whoever implements it, in the data layer, has to keep up. // src/domain/repositories/task-repository.ts import type { Task } from “../entities/task”; /**

  • Domain repository for persisting and reading tasks .
  • The domain declares this interface; the data layer implements it. That way the
  • business rule does not know the storage medium .

/ export interface TaskRepository { /** All tasks, in creation order (oldest first). */

getAll(): Promise<Task[]>; /** A task by id, or null if it does not exist. */ getById(id: string): Promise<Task | null>; /** Persists a new task. */ add(task: Task): Promise; /** Updates an existing task in place, without changing the order of the li st. */ update(task: Task): Promise; } The complete use case, the direct translation of the state model: a nonexistent task is a failure; an already completed one is success with no effect (idempotency); an open one becomes completed through an immutable copy. // src/domain/usecases/complete-task.ts import type { Task } from “../entities/task”;

import type { CompleteTaskFailure } from “../failures/complete-task-failure”; import type { TaskRepository } from “../repositories/task-repository”; import { failure, success, type Result } from “../result”; export type CompleteTask = (id: string) => Promise<Result<Task, CompleteTaskF ailure»; /**

  • Marks a task as completed (FR-001) .
  • Completing an already completed task is an idempotent no-op: it returns su ccess
  • without touching the state (FR-005). The nonexistent task is error as valu e, not
  • an exception (FR-003 of the constitution). The position on the list does n ot
  • change: the repository updates in place .

/

export const makeCompleteTask = (repository: TaskRepository): CompleteTask => async (id) => { const task = await repository.getById(id); if (task === null) { return failure({ kind: “task-not-found” }); } if (task.completed) { return success(task); } const completed: Task = { ...task, completed: true }; await repository.update(completed); return success(completed);

}; The reopen use case is the exact mirror of the previous one. The symmetry is no coincidence: the plan foresaw two inverse actions, and the code writes them almost identical, swapping only the target value of completed . // src/domain/usecases/reopen-task.ts import type { Task } from “../entities/task”; import type { CompleteTaskFailure } from “../failures/complete-task-failure”; import type { TaskRepository } from “../repositories/task-repository”; import { failure, success, type Result } from “../result”; export type ReopenTask = (id: string) => Promise<Result<Task, CompleteTaskFai lure»; /**

  • Reopens a completed task, taking it back to open (FR-002). It is the inver se of
  • CompleteTask .
  • Reopening an already open task is an idempotent no-op (FR-005); the nonexi stent
  • task is error as value (task-not-found). The position on the list does n ot change.

/ export const makeReopenTask = (repository: TaskRepository): ReopenTask => async (id) => { const task = await repository.getById(id); if (task === null) { return failure({ kind: “task-not-found” }); } if (!task.completed) { return success(task); }

const reopened: Task = { ...task, completed: false }; await repository.update(reopened); return success(reopened); }; CreateTask changes in a single line: the new task is born with completed: false . Everything else is what 6.5 showed. // src/domain/usecases/create-task.ts import type { Task } from “../entities/task”; import type { CreateTaskFailure } from “../failures/create-task-failure”; import type { TaskRepository } from “../repositories/task-repository”; import { failure, success, type Result } from “../result”; export type CreateTaskInput = { title: string; description?: string }; export type CreateTask = (input: CreateTaskInput) => Promise<Result<Task, Cre ateTaskFailure»;

/**

  • Creates a task applying the two business rules and returning error as valu e:
  • required title (FR-002) and non-repeated title (FR-003) .
  • generateId and now are injected to keep the use case pure and testable .

/ export const makeCreateTask = ( repository: TaskRepository, generateId: () => string = () => crypto.randomUUID(), now: () => Date = () => new Date() ): CreateTask => async ({ title, description = "” }) => { const trimmedTitle = title.trim();

if (trimmedTitle.length === 0) { return failure({ kind: “title-required” }); } const existing = await repository.getAll(); const isDuplicate = existing.some((task) => task.title.trim() === trimmed Title); if (isDuplicate) { return failure({ kind: “duplicate-title” }); } const task: Task = { id: generateId(), title: trimmedTitle, description: description.trim(), createdAt: now().toISOString(),

completed: false }; await repository.add(task); return success(task); }; Data The localStorage repository gains getById and update , and the read starts treating old tasks (without completed ) as open, the backward compatibility the plan promised, solved with a ?? false in the deserialization. // src/data/local-storage-task-repository.ts import type { Task } from “../domain/entities/task”; import type { TaskRepository } from “../domain/repositories/task-repository”; const STORAGE_KEY = “todo-app.tasks”; /**

  • Persists the tasks as JSON in the browser's localStorage. It is the we b
  • equivalent of “persist on the device” (FR-006): it survives closing an d
  • reopening the app. The I/O exceptions stay contained at this boundar y
  • (constitution, Principle III); the domain never sees localStorage .

/ export class LocalStorageTaskRepository implements TaskRepository { constructor(private readonly storage: Storage = localStorage) {} async getAll(): Promise<Task[]> { const raw = this.storage.getItem(STORAGE_KEY); if (raw === null || raw.trim().length === 0) { return []; }

const stored = JSON.parse(raw) as Array<Omit<Task, “completed"> & { compl eted?: boolean }>; // Tasks written by the previous feature do not have completed; reading the // absent one as false (open) keeps compatibility with no migration. const tasks: Task[] = stored.map((task) => ({ ...task, completed: task.co mpleted ?? false })); return tasks.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); } async getById(id: string): Promise<Task | null> { const tasks = await this.getAll(); return tasks.find((task) => task.id === id) ?? null; } async add(task: Task): Promise {

const tasks = await this.getAll(); const updated = [...tasks, task]; this.storage.setItem(STORAGE_KEY, JSON.stringify(updated)); } async update(task: Task): Promise { const tasks = await this.getAll(); const updated = tasks.map((existing) => (existing.id === task.id ? task : existing)); this.storage.setItem(STORAGE_KEY, JSON.stringify(updated)); } } Presentation The store gets two new actions ( completeTask / reopenTask ) that just call the use cases and reload the list. messageFor , the error- translation part, stays exactly as in 6.5.

// src/presentation/store/task-store.ts import { createStore } from “zustand/vanilla”; import type { Task } from “../../domain/entities/task”; import type { CreateTaskFailure } from “../../domain/failures/create-task-fai lure”; import type { CompleteTask } from “../../domain/usecases/complete-task”; import type { CreateTask } from “../../domain/usecases/create-task”; import type { ListTasks } from “../../domain/usecases/list-tasks”; import type { ReopenTask } from “../../domain/usecases/reopen-task”; export type TaskStoreState = { readonly tasks: Task[]; readonly lastError: string | null; loadTasks: () => Promise; createTask: (title: string, description: string) => Promise; completeTask: (id: string) => Promise;

reopenTask: (id: string) => Promise; }; const messageFor = (failure: CreateTaskFailure): string => { switch (failure.kind) { case “title-required”: return “The title is required."; case “duplicate-title”: return “A task with this title already exists."; } }; /**

  • Presentation store (Zustand) that orchestrates the domain use cases and ex poses
  • the state to the UI. It contains no business rule: it only trigger s
  • createTask/listTasks and translates the typed failure into a message f or
  • the screen .

/ export const createTaskStore = ( createTask: CreateTask, listTasks: ListTasks, completeTask: CompleteTask, reopenTask: ReopenTask ) => createStore((set) => ({ tasks: [], lastError: null, loadTasks: async () => { const tasks = await listTasks();

set({ tasks, lastError: null }); }, createTask: async (title, description) => { const result = await createTask({ title, description }); if (result.kind === “failure”) { set({ lastError: messageFor(result.error) }); return false; } const tasks = await listTasks(); set({ tasks, lastError: null }); return true; },

completeTask: async (id) => { await completeTask(id); const tasks = await listTasks(); set({ tasks }); }, reopenTask: async (id) => { await reopenTask(id); const tasks = await listTasks(); set({ tasks }); } })); TaskList starts drawing the state: a checkbox that toggles complete/reopen and a --done class for the struck-through look. The completed task stays in the same position, never disappears. The aria-label changes with the state, an accessibility touch.

// src/presentation/components/TaskList.tsx import { useTaskStore } from “../store/task-store-context”; /**

  • Lists the existing tasks in creation order; an empty list is a valid state .
  • The completed task stays in the same position, shown as struck through, wi th
  • the action toggling between complete and reopen .

/ export function TaskList() { const tasks = useTaskStore((state) => state.tasks); const completeTask = useTaskStore((state) => state.completeTask); const reopenTask = useTaskStore((state) => state.reopenTask); if (tasks.length === 0) {

return

No tasks yet. Create the first one

above.

;
}
return (
    {tasks.map((task) => (
    <li
    key={task.id}
    className={task.completed ? “task-list__item task-list__item--done”
    “task-list__item”} > <input type="checkbox” checked={task.completed} aria-label={task.completed ? Reopen ${task.title} : Complete ${task.title}}

    onChange={() => (task.completed ? reopenTask(task.id) : complet eTask(task.id))} />

    {task.title}

    {task.description.length > 0 &&

    {task.description}

    } ))} ); } main.tsx only composes the two new use cases at the edge and injects them into the store. The composition pattern is the same as 6.5's, with two more lines. // src/main.tsx

    import { StrictMode } from “react”; import { createRoot } from “react-dom/client”; import { LocalStorageTaskRepository } from “./data/local-storage-task-reposit ory”; import { makeCompleteTask } from “./domain/usecases/complete-task”; import { makeCreateTask } from “./domain/usecases/create-task”; import { makeListTasks } from “./domain/usecases/list-tasks”; import { makeReopenTask } from “./domain/usecases/reopen-task”; import { App } from “./presentation/App”; import { createTaskStore } from “./presentation/store/task-store”; import { TaskStoreProvider } from “./presentation/store/task-store-context”; import “./index.css”; // Composition at the edge: the concrete repository is instantiated here and // injected into the use cases; only at this point do the layers meet. const repository = new LocalStorageTaskRepository();

    const store = createTaskStore( makeCreateTask(repository), makeListTasks(repository), makeCompleteTask(repository), makeReopenTask(repository) ); const rootElement = document.getElementById(“root”); if (rootElement === null) { throw new Error(“Element #root not found in index.html."); } createRoot(rootElement).render(

    ); Tests The InMemoryTaskRepository fake gains getById and update , mirroring what the real repository started offering. The two need to stay in sync, otherwise the tests would stop proving the production behavior. // test/helpers/in-memory-task-repository.ts import type { Task } from “../../src/domain/entities/task”; import type { TaskRepository } from “../../src/domain/repositories/task-repos itory”; /** In-memory implementation of TaskRepository for domain and store tests. */ export class InMemoryTaskRepository implements TaskRepository { private readonly tasks: Task[] = [];

    async getAll(): Promise<Task[]> { return [...this.tasks].sort((a, b) => a.createdAt.localeCompare(b.created At)); } async getById(id: string): Promise<Task | null> { return this.tasks.find((task) => task.id === id) ?? null; } async add(task: Task): Promise { this.tasks.push(task); } async update(task: Task): Promise { const index = this.tasks.findIndex((existing) => existing.id === task.id) ; if (index >= 0) {

    this.tasks[index] = task; } } } The CompleteTask tests cover the four cases of the state model, including the one that proves completing does not reorder the list, and the one that proves idempotency. // test/domain/complete-task.test.ts import { describe, expect, it } from “vitest”; import { makeCompleteTask } from “../../src/domain/usecases/complete-task”; import { makeCreateTask } from “../../src/domain/usecases/create-task”; import { InMemoryTaskRepository } from “../helpers/in-memory-task-repository” ; const seedOpenTask = async (repository: InMemoryTaskRepository) => { const createTask = makeCreateTask(repository, () => “id-1”); const result = await createTask({ title: “Wash the dishes” });

    if (result.kind === “failure”) { throw new Error(“seed failed”); } return result.value; }; describe(“CompleteTask”, () => { it(“takes an open task to the completed state”, async () => { const repository = new InMemoryTaskRepository(); const task = await seedOpenTask(repository); const completeTask = makeCompleteTask(repository); const result = await completeTask(task.id); expect(result.kind).toBe(“success”);

    expect((await repository.getById(task.id))?.completed).toBe(true); }); it(“is idempotent: completing an already completed task stays success, with out changing the state”, async () => { const repository = new InMemoryTaskRepository(); const task = await seedOpenTask(repository); const completeTask = makeCompleteTask(repository); await completeTask(task.id); const result = await completeTask(task.id); expect(result.kind).toBe(“success”); expect((await repository.getById(task.id))?.completed).toBe(true); }); it(“does not change the task's position on the list when completing”, async () => {

    const repository = new InMemoryTaskRepository(); let tick = 0; const createTask = makeCreateTask(repository, () => id-${tick}, () => n ew Date(2026, 0, 1, 0, 0, tick++)); await createTask({ title: “First” }); await createTask({ title: “Second” }); await createTask({ title: “Third” }); const completeTask = makeCompleteTask(repository); await completeTask(“id-1”); const titles = (await repository.getAll()).map((task) => task.title); expect(titles).toEqual([“First”, “Second”, “Third”]); }); it(“returns task-not-found when the task does not exist”, async () => { const repository = new InMemoryTaskRepository();

    const completeTask = makeCompleteTask(repository); const result = await completeTask(“nonexistent”); expect(result).toEqual({ kind: “failure”, error: { kind: “task-not-found” } }); }); }); The ReopenTask tests, symmetric to the complete ones. // test/domain/reopen-task.test.ts import { describe, expect, it } from “vitest”; import { makeCompleteTask } from “../../src/domain/usecases/complete-task”; import { makeCreateTask } from “../../src/domain/usecases/create-task”; import { makeReopenTask } from “../../src/domain/usecases/reopen-task”; import { InMemoryTaskRepository } from “../helpers/in-memory-task-repository” ;

    const seedCompletedTask = async (repository: InMemoryTaskRepository) => { const createTask = makeCreateTask(repository, () => “id-1”); const result = await createTask({ title: “Wash the dishes” }); if (result.kind === “failure”) { throw new Error(“seed failed”); } await makeCompleteTask(repository)(result.value.id); return result.value; }; describe(“ReopenTask”, () => { it(“takes a completed task back to the open state”, async () => { const repository = new InMemoryTaskRepository();

    const task = await seedCompletedTask(repository); const reopenTask = makeReopenTask(repository); const result = await reopenTask(task.id); expect(result.kind).toBe(“success”); expect((await repository.getById(task.id))?.completed).toBe(false); }); it(“is idempotent: reopening an already open task stays success, without ch anging the state”, async () => { const repository = new InMemoryTaskRepository(); const createTask = makeCreateTask(repository, () => “id-1”); const created = await createTask({ title: “Already open” }); const taskId = created.kind === “success” ? created.value.id : “"; const reopenTask = makeReopenTask(repository);

    const result = await reopenTask(taskId); expect(result.kind).toBe(“success”); expect((await repository.getById(taskId))?.completed).toBe(false); }); it(“returns task-not-found when the task does not exist”, async () => { const repository = new InMemoryTaskRepository(); const reopenTask = makeReopenTask(repository); const result = await reopenTask(“nonexistent”); expect(result).toEqual({ kind: “failure”, error: { kind: “task-not-found” } }); }); });

    The production repository test gains two new cases: the update that does not reorder and the backward-compatible read of old tasks without completed . // test/data/local-storage-task-repository.test.ts import { describe, expect, it } from “vitest”; import { LocalStorageTaskRepository } from “../../src/data/local-storage-task -repository”; import { makeCreateTask } from “../../src/domain/usecases/create-task”; import { MemoryStorage } from “../helpers/memory-storage”; describe(“LocalStorageTaskRepository”, () => { it(“returns an empty list when nothing was saved”, async () => { const repository = new LocalStorageTaskRepository(new MemoryStorage()); expect(await repository.getAll()).toEqual([]); });

    it(“preserves the tasks between sessions (closing and reopening the app)", async () => { const storage = new MemoryStorage(); let tick = 0; const createTask = makeCreateTask( new LocalStorageTaskRepository(storage), () => id-${tick}, () => new Date(2026, 0, 1, 0, 0, tick++) ); await createTask({ title: “First” }); await createTask({ title: “Second” }); // A new instance over the same Storage simulates reopening the app. const reopened = new LocalStorageTaskRepository(storage); const titles = (await reopened.getAll()).map((task) => task.title); expect(titles).toEqual([“First”, “Second”]);

    }); it(“update persists the new state without reordering the list”, async () => { const storage = new MemoryStorage(); let tick = 0; const repository = new LocalStorageTaskRepository(storage); const createTask = makeCreateTask(repository, () => id-${tick}, () => n ew Date(2026, 0, 1, 0, 0, tick++)); await createTask({ title: “First” }); await createTask({ title: “Second” }); const second = await repository.getById(“id-1”); await repository.update({ ...second!, completed: true }); const reopened = new LocalStorageTaskRepository(storage); const all = await reopened.getAll(); expect(all.map((task) => task.title)).toEqual([“First”, “Second”]);

    expect(all.find((task) => task.id === “id-1”)?.completed).toBe(true); }); it(“reads old tasks without the completed field as open”, async () => { const storage = new MemoryStorage(); storage.setItem( “todo-app.tasks”, JSON.stringify([{ id: “x”, title: “Old”, description: “", createdAt: “2 026-01-01T00:00:00.000Z” }]) ); const tasks = await new LocalStorageTaskRepository(storage).getAll(); expect(tasks[0].completed).toBe(false); }); });

    Finally, the store tests gain two cases (complete reflects; reopen reverts) on top of the four that already existed from 6.5, and buildStore starts injecting the two new use cases. // test/presentation/task-store.test.ts import { describe, expect, it } from “vitest”; import { makeCompleteTask } from “../../src/domain/usecases/complete-task”; import { makeCreateTask } from “../../src/domain/usecases/create-task”; import { makeListTasks } from “../../src/domain/usecases/list-tasks”; import { makeReopenTask } from “../../src/domain/usecases/reopen-task”; import { createTaskStore } from “../../src/presentation/store/task-store”; import { InMemoryTaskRepository } from “../helpers/in-memory-task-repository” ; const buildStore = () => { const repository = new InMemoryTaskRepository(); return createTaskStore(

    makeCreateTask(repository), makeListTasks(repository), makeCompleteTask(repository), makeReopenTask(repository) ); }; describe(“task store”, () => { it(“loads the list of existing tasks”, async () => { const store = buildStore(); await store.getState().createTask(“Task A”, “"); await store.getState().loadTasks(); expect(store.getState().tasks.map((task) => task.title)).toEqual([“Task A “]); });

    it(“creating successfully updates the list and clears the error”, async () => { const store = buildStore(); const created = await store.getState().createTask(“New”, “a description”) ; expect(created).toBe(true); expect(store.getState().tasks).toHaveLength(1); expect(store.getState().lastError).toBeNull(); }); it(“a required-title failure exposes a message without changing the list”, async () => { const store = buildStore(); const created = await store.getState().createTask(” “, “"); expect(created).toBe(false);

    expect(store.getState().tasks).toHaveLength(0); expect(store.getState().lastError).toBe(“The title is required."); }); it(“a duplicate-title failure exposes a specific message”, async () => { const store = buildStore(); await store.getState().createTask(“Duplicate”, “"); const created = await store.getState().createTask(“Duplicate”, “"); expect(created).toBe(false); expect(store.getState().lastError).toBe(“A task with this title already e xists."); expect(store.getState().tasks).toHaveLength(1); }); it(“completing a task reflects the completed state on the list”, async () =

    {

    const store = buildStore(); await store.getState().createTask(“Finish the report”, “"); const taskId = store.getState().tasks[0].id; await store.getState().completeTask(taskId); expect(store.getState().tasks[0].completed).toBe(true); }); it(“reopening a completed task returns it to the open state”, async () => { const store = buildStore(); await store.getState().createTask(“Finish the report”, “"); const taskId = store.getState().tasks[0].id; await store.getState().completeTask(taskId); await store.getState().reopenTask(taskId);

    expect(store.getState().tasks[0].completed).toBe(false); }); }); The portrait of the lap A one-line sentence went in. clarify pulled out of it four decisions that were hidden, and each one turned into a requirement, then design, then code. The domain grew without contorting itself, and the data and presentation layers followed behind, fitting into the same places as always. The comparison with 6.5 is what is worth keeping: the architecture did not move. In Chapter 8.5, the filtering feature shows an even leaner case, in which almost nothing new is needed in the domain, because the right decision is to derive the view from the state that already exists, instead of duplicating it.

    Powered by TurnKey Linux.