diff --git a/.gitignore b/.gitignore index d80c1bd..aedf67f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules/ .learndeck/ .env .DS_Store +dist/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fb76d70..113477e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,9 +7,14 @@ course standard, seed templates, source references, and learner experience. - Bugs and scoped work → open an issue. - Course ideas and learner stories → [GitHub Discussions](https://github.com/learn-deck/learndeck/discussions). -- Course submissions → a pull request against the [course authoring standard](docs/course-authoring.md). +- Course submissions → a pull request against the public course repository; + follow the steps in [public course + distribution](docs/public-course-distribution.md) and the [course authoring + standard](docs/course-authoring.md). - Security reports → follow [SECURITY.md](SECURITY.md). +## Ground rules + - Keep the UI local-only and preserve the separation between course content, learner workspace, and ignored progress data. - Keep the tracker course-agnostic. Add or change a course through its manifest diff --git a/README.md b/README.md index 53ae123..583c9e1 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,14 @@ useful question, evaluates a visible answer against an author-written rubric, and never writes the learner's solution for them. Answers, evidence, feedback, and learning records stay in local SQLite. -The included **DDD and Hexagonal Architecture** pack is the v0.1 flagship: -six to eight hours of Node.js + TypeScript, structured into satisfying -45–60-minute building sessions. It is the first focused developer course, not -the limit of the app. The format remains course-agnostic while the default -catalogue grows deliberately and stays curated for quality. See the [product +Courses live in the public catalogue at +[learn-deck/courses](https://github.com/learn-deck/courses) and sync into the +app; the v0.1 flagship is **DDD and Hexagonal Architecture** — six to eight +hours of Node.js + TypeScript in satisfying 45–60-minute building sessions. +This repository bundles only a small [format example +pack](courses/example-course/course.md) for documentation and development. +The format remains course-agnostic while the default catalogue grows +deliberately and stays curated for quality. See the [product position](docs/product-positioning.md) and [catalogue quality rubric](docs/catalogue-quality-rubric.md). @@ -45,8 +48,10 @@ database. - A connection adds exactly one `learndeck` MCP entry to the selected guide. Disconnect removes only that entry through `DELETE /api/integrations/:id/connect`. - Per-path progress can be exported with `GET /api/paths/:id/export` or reset with `DELETE /api/paths/:id`. -The commands above load the bundled Markdown packs. To select the public -catalogue, copy the release configuration before `bun run app`: +The primary catalogue is the public GitHub course repository; while no +repository is configured, only the bundled `example-course` format pack +loads, which keeps development working offline. To select the public catalogue, copy the release +configuration before `bun run app`: ```sh cp .env.example .env @@ -56,8 +61,10 @@ The release configuration selects `github:learn-deck/courses@main`. When the learner clicks **Start Now**, LearnDeck syncs only Markdown under `courses/` and `references/` into its local cache. If GitHub is unavailable, it uses the last complete cache. A fork can set a different -`LEARNDECK_COURSE_REPOSITORY=github:your-org/courses@main`. See [public course -distribution](docs/public-course-distribution.md) and [troubleshooting](docs/troubleshooting.md). +`LEARNDECK_COURSE_REPOSITORY=github:your-org/courses@main`. To publish a +course of your own, follow [public course +distribution](docs/public-course-distribution.md); for failures, see +[troubleshooting](docs/troubleshooting.md). On first launch, LearnDeck: @@ -81,13 +88,14 @@ server, runs submitted code, or changes unrelated MCP servers. ## Add a course ```sh -bun run seed -- testing-fundamentals "Testing Fundamentals" +bun run seed -- api-design-basics "API Design Basics" ``` -This creates a Markdown-only course pack: +This creates a Markdown-only course pack (seeding fails if the course ID +already exists, so pick a new one): ```text -courses/testing-fundamentals/ +courses/api-design-basics/ course.md modules/00-orient.md ``` @@ -101,7 +109,7 @@ Markdown. The loader validates local source links are real `.md` files. See and authoring checklist. The browser UI is a dark-first learning environment with local theme preference, -Zen Mode, section-based progress, and accessible source-rendered lesson blocks. +Focus Mode, section-based progress, and accessible source-rendered lesson blocks. Its maintainable design rules live in [the UI system](docs/ui-system.md). ## Agent integration @@ -130,10 +138,35 @@ evidence, and submitted-answer evaluation. See [MCP integration](docs/mcp.md). bun run verify ``` +## Optional: a local macOS app + +On macOS you can build a double-clickable LearnDeck.app for your own machine. +It needs `swiftc` (Xcode Command Line Tools) and Bun: + +```sh +bash scripts/package-macos.sh +open dist/LearnDeck.app +``` + +The script compiles the server into a standalone binary, stages `public/`, +`courses/`, and `references/` inside the bundle, and compiles a native +AppKit/WKWebView shell (`native/macos/LearnDeckApp.swift`). Launching the app +starts the server on a free local port and opens a native window; quitting the +app stops the server. The app's data lives outside the bundle at +`~/Library/Application Support/LearnDeck/` (`progress.db`, `course-cache/`, +and `server.log`), so rebuilds never touch progress. + +This is developer tooling, not a distribution channel: the app is unsigned and +not notarized, and the supported install remains cloning the repository. One +known limitation: connecting an AI guide from the packaged app writes an MCP +entry that points at this repository checkout, so keep the clone in place or +reconnect after moving it. + ## Privacy and scope The browser binds only to `127.0.0.1`. Progress, answers, workspace paths, and reported evidence remain in the local database and are ignored by Git. By default, that database and the public-course cache live under `.learndeck/`; -see the [local progress database](references/progress-database.md). See also -[SECURITY.md](SECURITY.md). +the packaged macOS app keeps them under +`~/Library/Application Support/LearnDeck/` instead. See the [local progress +database](references/progress-database.md) and [SECURITY.md](SECURITY.md). diff --git a/course/README.md b/course/README.md index 4039606..3dffc68 100644 --- a/course/README.md +++ b/course/README.md @@ -1,10 +1,15 @@ -# DDD seed sources +# Where courses live now -These Markdown modules and references are the teaching evidence for the shipped -DDD seed course. Its tracker manifest is -[`../courses/ddd-backend-foundations.json`](../courses/ddd-backend-foundations.json). +Real, learner-facing course packs — including the DDD and Hexagonal +Architecture flagship — live in the public catalogue repository at +[learn-deck/courses](https://github.com/learn-deck/courses) and sync into the +app when `LEARNDECK_COURSE_REPOSITORY` is configured. -The adjacent Markdown modules contain the learner-facing explanations, -questions, rubrics, and later-review prompts. See -[Course authoring](../docs/course-authoring.md) before adding another manifest, -source set, or question contract. +This repository bundles only the +[`example-course`](../courses/example-course/course.md) pack: a reference +implementation of the format kept for documentation and development. JSON +course manifests are no longer used. + +See [course authoring](../docs/course-authoring.md) for the pack standard and +[public course distribution](../docs/public-course-distribution.md) for how to +contribute a course to the catalogue. diff --git a/courses/README.md b/courses/README.md index 72cef53..d92779e 100644 --- a/courses/README.md +++ b/courses/README.md @@ -1,14 +1,20 @@ # Course packs Every direct child directory is one LearnDeck course pack. A valid pack has a -`course.md` file and one or more ordered `modules/*.md` files. The DDD pack is -the included example; it can be removed or copied without changing the runner. +`course.md` file and one or more ordered `modules/*.md` files. -Create a new pack with: +This repository bundles exactly one pack: [`example-course`](example-course/course.md), +a small reference implementation of the format kept for documentation, +development, and tests. Real, learner-facing courses live in the public +catalogue at [learn-deck/courses](https://github.com/learn-deck/courses) and +sync into the app when `LEARNDECK_COURSE_REPOSITORY` is configured. + +Create a new pack skeleton with: ```sh bun run seed -- "Course title" ``` -Read [the course-pack standard](../docs/course-authoring.md) before authoring -or publishing a course. +Read [the course-pack standard](../docs/course-authoring.md) before authoring, +and follow [public course distribution](../docs/public-course-distribution.md) +to contribute it to the catalogue. diff --git a/courses/ddd-backend-foundations/course.md b/courses/ddd-backend-foundations/course.md deleted file mode 100644 index fd0bece..0000000 --- a/courses/ddd-backend-foundations/course.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -schemaVersion: 1 -id: ddd-backend-foundations -title: "DDD Backend Foundations with Node.js and TypeScript" -description: "Build one small, well-shaped backend. Learn to name the domain, keep business rules independent, expose a useful HTTP boundary, and prove the behaviour with tests." -category: Backend engineering -tags: - - TypeScript - - Node.js - - Domain-driven design - - Hexagonal architecture -overview: - duration: 6–8 hours - sessionLength: 45–60 minutes per module - level: Early-career backend developers - outcomes: - - Model a small business rule in TypeScript before choosing tables or routes. - - Organise a Node.js backend around domain, application, ports, and adapters. - - Deliver one HTTP workflow, persistence boundary, and test suite with confidence. - - Explain the trade-offs in your own words instead of copying a folder structure. - prerequisites: - - Node.js 22 or newer and npm. - - Basic TypeScript syntax and a code editor. - - A quiet workspace for one small backend project. -paths: - - id: node-typescript - label: Node.js + TypeScript - serverCommand: npm run dev - testCommand: npm test - workspaceHint: ../ddd-backend ---- - -# DDD Backend Foundations - -This is LearnDeck's reference course pack. It is deliberately one path: Node.js -and TypeScript. Each module asks for one visible change, one short explanation, -and one piece of learner-owned evidence. You will build a small backend, not a -generic architecture diagram. diff --git a/courses/ddd-backend-foundations/modules/00-start-a-path.md b/courses/ddd-backend-foundations/modules/00-start-a-path.md deleted file mode 100644 index db55be4..0000000 --- a/courses/ddd-backend-foundations/modules/00-start-a-path.md +++ /dev/null @@ -1,126 +0,0 @@ ---- -id: start -title: Set up your backend -goal: Confirm one Node.js + TypeScript workspace and make a tiny status route visible. -action: Create src/domain/, src/application/, src/ports/, and src/adapters/ plus one health/status route in your project folder. Run npm run dev yourself when you are ready. -sources: - - ./00-start-a-path.md - - ../../../references/language-paths.md - - ../../../references/progress-database.md -questions: - - id: start-boundary - kind: diagnostic - prompt: Why should LearnDeck and your backend project live in different folders? Name one problem that separation avoids. - reference: ./00-start-a-path.md - rubric: - - Distinguishes LearnDeck's local progress from the learner's application code. - - Names one concrete risk avoided by separation, such as accidental writes or losing progress when replacing a project. - - id: start-evidence - kind: exit - prompt: Name your project folder, development command, and status route. Why does LearnDeck keep progress tied to this project? - reference: ./00-start-a-path.md - rubric: - - Names a concrete project folder, learner-run development command, and observable status route. - - Explains that the workspace ties answers and evidence to the backend actually being built. ---- - -# 00 · Set up your backend - -You only need one project for this course: a small Node.js + TypeScript backend. -LearnDeck remembers your answers locally; your project folder holds the code you -will build. Keeping them separate lets you retry, rename, or delete the project -without touching the course itself. - -> [!SCENARIO] -> Imagine a booking service with one rule: a room cannot be booked twice for -> the same time. We will grow that small idea into a testable backend, one -> decision at a time. - -## Your first visible result - -1. Choose an empty or new folder for the backend, separate from LearnDeck. -2. Use the Node.js + TypeScript checks in - [`language-paths.md`](../../../references/language-paths.md). They only tell - you what is present; they never install or run anything for you. -3. In your project, create these four areas: `src/domain/`, - `src/application/`, `src/ports/`, and `src/adapters/`. Empty folders are - enough today. -4. If the folder is empty, copy this minimal setup. It uses `tsx` to run - TypeScript directly; run `npm install` yourself. LearnDeck never installs - packages or starts your server. - -`package.json` - -```json -{ - "name": "ddd-backend", - "private": true, - "type": "module", - "scripts": { "dev": "tsx --watch src/server.ts" }, - "devDependencies": { "tsx": "latest", "typescript": "latest" } -} -``` - -`src/server.ts` - -```ts -import { createServer } from "node:http"; - -const server = createServer((request, response) => { - if (request.method === "GET" && request.url === "/health") { - response.writeHead(200, { "content-type": "application/json" }); - response.end(JSON.stringify({ status: "ok" })); - return; - } - response.writeHead(404); - response.end(); -}); - -server.listen(3000); -``` - -Run `npm install`, then `npm run dev`, and check `GET /health`. The expected -response is verbatim: `200 {"status":"ok"}`. - -5. When the project is ready, run `npm run dev` yourself and look at the - endpoint. If a guide is connected, tell it what you ran and observed so it - can record that evidence; otherwise record it with the evidence form or in - `NOTES.md` in your workspace. - -> [!TIP] -> Do not design the perfect server today. A plain status response is valuable -> because it gives you a known-good starting point for every later change. - -```learndeck -type: checklist -id: start-ready -label: Before you continue -items: - - Node.js and npm are available on my Mac. - - My backend project folder is separate from LearnDeck. - - I know the status route I will make visible. -``` - -## Keep the boundary clear - -LearnDeck stores its progress in a local SQLite database, described in -[`progress-database.md`](../../../references/progress-database.md). It records -the project folder so feedback and evidence stay associated with the backend -you actually built. It does not write your application code, start its server, -or collect your project outside the folder you confirm. - -When you are ready, answer the question below in your own words. A short, -concrete answer is better than architecture vocabulary. - -## Definition of done - -Before answering, check that: - -- The project contains `src/domain/`, `src/application/`, `src/ports/`, and `src/adapters/`. -- A learner-run development command starts the backend, such as `npm run dev`. -- A visible `GET /health` route returns `200 {"status":"ok"}`. -- You can name the project folder, command, route, and observed response. - -After you submit your answer, choose **Mark as self-reviewed and continue** if -you are working without a connected guide. Guide evaluation is optional, not -required; if a guide is connected, you may request feedback instead. diff --git a/courses/ddd-backend-foundations/modules/01-model-the-domain.md b/courses/ddd-backend-foundations/modules/01-model-the-domain.md deleted file mode 100644 index 72da7b5..0000000 --- a/courses/ddd-backend-foundations/modules/01-model-the-domain.md +++ /dev/null @@ -1,112 +0,0 @@ ---- -id: domain -title: Model the domain -goal: Protect one business invariant before choosing routes or tables. -action: Write a short ubiquitous-language note and implement one domain type or aggregate with no framework or database import. -sources: - - ./01-model-the-domain.md - - ../../../references/source-index.md#ddd - - ../../../references/source-index.md#hexagonal -questions: - - id: domain-diagnostic - kind: diagnostic - prompt: What is the difference between a business invariant and HTTP input validation? Give one example of each. - reference: ./01-model-the-domain.md - rubric: - - Distinguishes an always-true domain rule from malformed or incomplete transport input. - - Gives one concrete example of each for the chosen workflow. - - id: domain-exit - kind: exit - prompt: Name one invariant your domain owns, one transport validation rule, and why they are different responsibilities. - reference: ./01-model-the-domain.md - rubric: - - Names a specific invariant owned by the domain and a distinct HTTP/transport validation rule. - - Explains why the two rules belong at different boundaries. ---- - -# 01 · Model the domain - -## Outcome - -I can describe a small backend problem in domain language, identify invariants, -and name use cases before choosing tables or routes. - -## Diagnostic question - -What is the difference between a business invariant and an HTTP validation -rule? Give one example of each for a small task or booking service. - -## Build - -> [!SCENARIO] -> A booking is not just a row with dates. The business statement is: “a room -> cannot be booked twice for the same time.” That sentence is the invariant -> your domain must protect—even when tomorrow's client is not HTTP. - -1. Choose a deliberately small problem: task tracking, booking, inventory - reservation, or another bounded workflow. Record the choice in your answer - or in `NOTES.md` in your workspace. -2. Write a short domain note in your workspace: nouns, verbs, state changes, - and what must always be true. -3. Define one use case in application language: input, successful outcome, and - expected domain failures. -4. Create a domain type or aggregate that protects one invariant without - importing an HTTP framework, database client, or logger. -5. If a guide is connected, ask it to record the domain-note and code paths; - otherwise record those paths in the evidence form or in `NOTES.md` in your - workspace. Then explain the invariant in your own words. - -## Worked example: protect the overlap rule - -Here is one small domain decision fully worked for the booking service: - -```ts -type Booking = { - roomId: string; - startsAt: number; - endsAt: number; -}; - -export function overlaps(existing: Booking, candidate: Booking): boolean { - return existing.roomId === candidate.roomId - && existing.startsAt < candidate.endsAt - && candidate.startsAt < existing.endsAt; -} -``` - -Your next analogous decision: decide what your domain operation should return -when `overlaps` is true. Write the result type and one expectation for a second -booking attempt; keep HTTP and database concerns out of it. - -Why this decision? The predicate compares the same room and intersecting time -intervals, so it protects the booking invariant independently of how a request -or row is represented. Its small boundary lets the application turn `true` -into a domain rejection without asking the domain to know about status codes or -SQL. - -Use [Vaughn Vernon's aggregate guidance](../../../references/source-index.md#ddd) -and [the hexagonal architecture reference](../../../references/source-index.md#hexagonal) -as anchors; do not copy their examples as your product model. - -## Exit question - -Name one invariant your domain code owns, one input error the HTTP adapter can -reject first, and explain why they are not the same responsibility. - -## Later review - -Given a new route, decide whether its rule belongs in the adapter, application -use case, or domain model—and say why. - -## Definition of done - -Before answering, check that: - -- A domain note names the booking or other workflow's nouns, verbs, state changes, and invariant. -- One domain type or aggregate protects that invariant without HTTP, database, or logger imports. -- One use case names its input, successful outcome, and expected domain failure. -- You can distinguish the domain invariant from one transport validation rule. - -After you submit your answer, choose **Mark as self-reviewed and continue** if -you are working without a connected guide. Guide evaluation is optional, not -required; if a guide is connected, you may request feedback instead. diff --git a/courses/ddd-backend-foundations/modules/02-draw-the-hexagon.md b/courses/ddd-backend-foundations/modules/02-draw-the-hexagon.md deleted file mode 100644 index c546960..0000000 --- a/courses/ddd-backend-foundations/modules/02-draw-the-hexagon.md +++ /dev/null @@ -1,143 +0,0 @@ ---- -id: hexagon -title: Draw the hexagon -goal: Make dependency direction visible through domain, application, ports, and adapters. -action: Create one use case, one port owned by the inner layer, and one in-memory adapter. Wire them together at the outer composition point. -sources: - - ./02-draw-the-hexagon.md - - ../../../references/source-index.md#hexagonal -questions: - - id: hexagon-diagnostic - kind: diagnostic - prompt: What dependency direction is reversed when a domain object imports a database client, and why is that costly? - reference: ./02-draw-the-hexagon.md - rubric: - - States that inner domain/application code now depends directly on an outer infrastructure detail. - - Names a concrete cost to testing, replacement, or changing the database technology. - - id: hexagon-exit - kind: exit - prompt: For one dependency, name the caller, port owner, and adapter. Why is the port shaped by the inside need? - reference: ./02-draw-the-hexagon.md - rubric: - - Maps one real dependency to a caller, inner port owner, and outer adapter. - - Explains that the port describes the capability the use case needs rather than a vendor API. ---- - -# 02 · Draw the hexagon - -## Outcome - -I can keep domain rules independent of frameworks and connect them through -application use cases, ports, and adapters. - -## Diagnostic question - -If a domain object imports a PostgreSQL client to save itself, what dependency -direction has been reversed? What makes that costly to change or test? - -## Build - -> [!SCENARIO] -> `CreateBooking` needs to ask whether a room is free. It owns a small -> `BookingRepository` port. An in-memory collection can answer today; SQLite -> can answer later. The use case should not need to know which one it uses. - -1. Draw four named areas for your workspace: domain, application, ports, and - adapters. Record the chosen paths before creating files. -2. Move or create one use case that receives an input and invokes a domain - operation. -3. Define a repository or clock port owned by the application/domain boundary. - It should describe the capability needed, not a vendor API. -4. Implement one in-memory adapter behind that port and wire it at the outer - application composition point. -5. Confirm that the inner code has no imports of the web framework, SQL driver, - or environment package. - -## Worked example: let the inside name the port - -Here is one small dependency decision fully worked for `CreateBooking`: - -```ts -type Booking = { roomId: string; startsAt: number; endsAt: number }; - -export interface BookingRepository { - findOverlapping(roomId: string, startsAt: number, endsAt: number): Promise; -} - -export class InMemoryBookingRepository implements BookingRepository { - constructor(private readonly bookings: Booking[]) {} - - async findOverlapping(roomId: string, startsAt: number, endsAt: number) { - return this.bookings.find((booking) => - booking.roomId === roomId && booking.startsAt < endsAt && startsAt < booking.endsAt, - ) ?? null; - } -} -``` - -Your next analogous decision: choose the next capability this use case needs, -such as saving a new booking. Write its port signature and the in-memory method, -keeping the operation named after booking behaviour rather than SQL. - -Why this decision? The use case asks whether the room and time are available, -so the port exposes exactly that capability. The in-memory adapter implements -the same inside-facing contract; a database adapter can replace it at the edge -without changing the booking decision. - -## What this is NOT - -The wrong direction makes the domain depend on the HTTP framework: - -```ts -// domain/booking.ts — wrong -import type { Request } from "express"; - -export function canBook(request: Request): boolean { - return request.body.roomId !== undefined; -} -``` - -The correct direction translates HTTP at the edge and keeps the domain plain: - -```ts -// domain/booking.ts — right -type BookingCommand = { roomId: string; startsAt: number; endsAt: number }; - -export function canBook(command: BookingCommand, existing: readonly Booking[]) { - return existing.every((booking) => - booking.roomId !== command.roomId || booking.endsAt <= command.startsAt || command.endsAt <= booking.startsAt, - ); -} -``` - -The wrong version lets Express's request shape reach a domain decision, so the -rule depends on transport. The right version accepts domain data; an outer -adapter translates the request and the inner code remains callable without -Express. - -Use the original [Ports and Adapters article](../../../references/source-index.md#hexagonal) -for the direction, not as a folder-name ritual. - -## Exit question - -For one dependency in your project, name the caller, the port owner, and the -adapter. Why should the port be shaped by the inside need rather than the -database's API? - -## Later review - -Classify each as a port or adapter: `TaskRepository`, PostgreSQL query client, -system clock, and Fastify handler. - -## Definition of done - -Before answering, check that: - -- One use case calls a port owned by the inner application/domain boundary. -- One in-memory adapter implements that port and is wired at the outer composition point. -- The inner code has no web framework, SQL driver, or environment-package imports. -- You can name the caller, port owner, adapter, and dependency direction for one booking decision. - -After you submit your answer, choose **Mark as self-reviewed and continue** if -you are working without a connected guide. Guide evaluation is optional, not -required; if a guide is connected, you may request feedback instead. diff --git a/courses/ddd-backend-foundations/modules/03-make-an-api-useful.md b/courses/ddd-backend-foundations/modules/03-make-an-api-useful.md deleted file mode 100644 index 859892c..0000000 --- a/courses/ddd-backend-foundations/modules/03-make-an-api-useful.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -id: http -title: Make an API useful -goal: Translate one HTTP request into a use case without mixing transport, business rules, and persistence. -action: Create one command endpoint plus a status route. Map malformed transport input and known domain failures deliberately. -sources: - - ./03-make-an-api-useful.md - - ../../../references/source-index.md#http -questions: - - id: http-diagnostic - kind: diagnostic - prompt: Why is a handler that holds business rules, SQL, and JSON formatting difficult to change or test? - reference: ./03-make-an-api-useful.md - rubric: - - Identifies mixed responsibilities and multiple reasons for the handler to change. - - Explains one testing or substitution cost created by the coupling. - - id: http-exit - kind: exit - prompt: Trace one request from HTTP input to a domain decision and back. Where do malformed input and invariant violations stop? - reference: ./03-make-an-api-useful.md - rubric: - - Traces a coherent transport-to-use-case-to-domain-to-response flow. - - Stops malformed input at the HTTP boundary and a violated business invariant at the domain/application decision. ---- - -# 03 · Make an API useful - -## Outcome - -I can build one thin HTTP boundary that translates a request into a use-case -call and returns a deliberate success or problem response. - -## Diagnostic question - -Why is a route handler that contains business decisions, SQL queries, and JSON -formatting difficult to test and change independently? - -## Build - -> [!SCENARIO] -> `POST /bookings` can reject malformed JSON before it reaches the use case. -> The domain rejects a genuine double booking. These are different failures, -> so they deserve different messages and tests. - -1. Choose one command endpoint for the use case from step 01. -2. In the HTTP adapter, parse and validate only transport-shaped input. -3. Translate the request to the application input, call the use case, and map - known domain failures to stable responses. -4. Add a status/health route. If your project includes a tiny frontend, have it - call or display that route; it is a visibility aid, not the course product. -5. If a guide is connected, ask it to check the documented Node.js development - command. Run `npm run dev` yourself and record the route, command, and - observed result; otherwise keep that record in the evidence form or in - `NOTES.md` in your workspace. - -Use the HTTP references in [the source index](../../../references/source-index.md#http) -to reason about resource semantics and problem responses. - -## Exit question - -Trace one request from HTTP input to domain decision and back to a response. -Where should a malformed JSON body stop, and where should a violated invariant -stop? - -## Later review - -Given a new error, decide whether it is a transport error, application decision, -or domain failure before choosing its HTTP response. - -## Definition of done - -Before answering, check that: - -- One command endpoint and the status route are reachable with the documented development command. -- Malformed transport input stops at the HTTP boundary with a deliberate response. -- A double booking or other invariant failure produces a distinct stable response. -- You recorded the command, route, and observed results in your workspace or answer. - -After you submit your answer, choose **Mark as self-reviewed and continue** if -you are working without a connected guide. Guide evaluation is optional, not -required; if a guide is connected, you may request feedback instead. diff --git a/courses/ddd-backend-foundations/modules/04-persist-through-a-port.md b/courses/ddd-backend-foundations/modules/04-persist-through-a-port.md deleted file mode 100644 index 8a28efc..0000000 --- a/courses/ddd-backend-foundations/modules/04-persist-through-a-port.md +++ /dev/null @@ -1,134 +0,0 @@ ---- -id: persistence -title: Persist through a port -goal: Swap an in-memory repository for persistence without leaking storage into domain code. -action: Implement one persistence adapter, map data at its edge, and document a transaction boundary for one use case. -sources: - - ./04-persist-through-a-port.md - - ../../../references/source-index.md#persistence -questions: - - id: persistence-diagnostic - kind: diagnostic - prompt: How can a domain identity differ from a database primary key, even when both happen to use the same value? - reference: ./04-persist-through-a-port.md - rubric: - - Distinguishes the model's business identity from a storage mechanism's primary key. - - Explains why matching values do not make the domain dependent on the database representation. - - id: persistence-exit - kind: exit - prompt: Which layer knows the database library, which layer defines the persistence need, and how does that protect an invariant? - reference: ./04-persist-through-a-port.md - rubric: - - Places database-library knowledge in an outer persistence adapter and the need in the inner port/use case. - - Explains how this protects the domain from storage concerns while preserving its invariant. ---- - -# 04 · Persist through a port - -## Outcome - -I can replace an in-memory repository with persistence without moving storage -concerns into domain code. - -## Diagnostic question - -What is the difference between a domain identity and a database primary key? -When might they be the same value, and why should the model not depend on that? - -## Build - -> [!SCENARIO] -> Your `CreateBooking` use case still calls the same repository port. Only the -> outer adapter changes: an in-memory collection becomes a SQLite or other -> local store, with mapping contained at that edge. - -1. List the operations your existing repository port really needs; delete - speculative CRUD operations. -2. Choose one local persistence adapter for this Node.js project and record - its location and configuration boundary. -3. Map persisted data at the adapter edge. Reconstruct the domain object before - handing it to inner code. -4. Decide and document the transaction boundary for one use case. -5. Run the relevant tests and your status route. Record the evidence and any - persistence-specific failures. - -## Worked example: map storage at the edge - -Here is one small persistence decision fully worked for a booking row: - -```ts -type Booking = { roomId: string; startsAt: number; endsAt: number }; -type BookingRow = { room_id: string; starts_at: number; ends_at: number }; - -function toDomain(row: BookingRow): Booking { - return { - roomId: row.room_id, - startsAt: row.starts_at, - endsAt: row.ends_at, - }; -} -``` - -Your next analogous decision: write the `toRow` function for saving a booking -and decide which identity and time fields must survive a round trip. Keep the -row type and database naming outside the port signature. - -Why this decision? `toDomain` reconstructs the object the booking rule -understands before it reaches inner code. SQL names and storage types stay in the -adapter, so replacing the store does not force a change to the invariant. - -## What this is NOT - -This port leaks infrastructure details into the inside: - -```ts -// wrong -import type { Pool } from "pg"; -type BookingRow = { room_id: string; starts_at: number; ends_at: number }; - -interface BookingRepository { - findOne(db: Pool, tableName: string, sql: string): Promise; -} -``` - -This port exposes the capability the booking use case actually needs: - -```ts -// right -type Booking = { roomId: string; startsAt: number; endsAt: number }; - -interface BookingRepository { - findOverlapping(roomId: string, startsAt: number, endsAt: number): Promise; -} -``` - -The wrong version makes callers know a driver, table name, SQL, and row shape; -it is an infrastructure API disguised as a port. The right version speaks in -booking terms, while the adapter owns SQL and maps rows at the edge. - -Use [Fowler's Repository pattern](../../../references/source-index.md#persistence) -as a vocabulary reference, while keeping the port shaped by your use case. - -## Exit question - -Which layer knows SQL or the database library in your project? Which layer -defines what it needs from persistence, and how does that protect a domain -invariant? - -## Later review - -If an adapter returns a duplicate-key error, which layer should translate it -into the application's language before it reaches HTTP? - -## Definition of done - -Before answering, check that: - -- The existing repository port still expresses only the operations this use case needs. -- A persistence adapter maps storage rows to domain objects at its edge. -- One transaction boundary is written down for a booking use case. -- Tests and the status route were run, with persistence-specific results recorded honestly. - -After you submit your answer, choose **Mark as self-reviewed and continue** if -you are working without a connected guide. Guide evaluation is optional, not -required; if a guide is connected, you may request feedback instead. diff --git a/courses/ddd-backend-foundations/modules/05-prove-behaviour.md b/courses/ddd-backend-foundations/modules/05-prove-behaviour.md deleted file mode 100644 index 00b50a0..0000000 --- a/courses/ddd-backend-foundations/modules/05-prove-behaviour.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -id: proof -title: Prove behaviour -goal: Use fast tests to protect domain rules and a small number of boundaries. -action: Add one domain test, one use-case test with an in-memory adapter, and one HTTP boundary test. Run the suite yourself. -sources: - - ./05-prove-behaviour.md - - ../../../references/source-index.md#testing -questions: - - id: proof-diagnostic - kind: diagnostic - prompt: What can a fast domain test prove that a route-level test might obscure? - reference: ./05-prove-behaviour.md - rubric: - - Names the business invariant or domain behaviour a fast test can state directly. - - Explains why HTTP, database, or other boundary details can obscure that claim in a route-level test. - - id: proof-exit - kind: exit - prompt: For your domain, use-case, and HTTP test, state one behaviour each proves and one thing it does not prove. - reference: ./05-prove-behaviour.md - rubric: - - States one honest behaviour and one explicit limitation for each of the domain, use-case, and HTTP tests. - - Explains why an in-memory adapter or test double is acceptable at the port boundary. ---- - -# 05 · Prove behaviour - -## Outcome - -I can use tests to protect a business rule and a boundary without making the -test suite depend on a running production stack. - -## Diagnostic question - -What would a fast test of an invariant prove that a route-level test alone -might obscure? - -## Build - -> [!SCENARIO] -> A fast domain test can say “the second booking for this room and time is -> rejected.” It does not need an HTTP server or database to make that business -> promise easy to understand. - -1. Write one domain-level test for the invariant from step 01. -2. Write one use-case test using the in-memory adapter from step 02. -3. Add one HTTP boundary test for a deliberate input or error mapping. -4. Run the project test command, `npm test`, yourself. -5. Record test paths, command output summary, and what each test is allowed to - prove. Do not label a passing test as proof of every production concern. - -## What this is NOT - -The wrong test asserts private implementation details and call order: - -```ts -// wrong -it("checks before saving", async () => { - const find = vi.spyOn(repo, "findOverlapping"); - const save = vi.spyOn(repo, "save"); - await createBooking(input, repo); - expect(find.mock.invocationCallOrder[0]).toBeLessThan(save.mock.invocationCallOrder[0]); -}); -``` - -The right test asserts the booking behaviour a learner or caller can observe: - -```ts -// right -it("rejects an overlapping booking", async () => { - const result = await createBooking(input, repoWithExistingBooking); - expect(result).toEqual({ kind: "rejected", reason: "room-already-booked" }); -}); -``` - -The wrong version can fail after a harmless refactor even when the booking rule -still works, because it prescribes internals and order. The right version stays -valuable when the implementation changes because it checks the observable -rejection of a second booking. - -Use [Google's testing guidance](../../../references/source-index.md#testing) for -test-value trade-offs, not as a mandated testing pyramid. - -## Exit question - -For each of your three tests, name the behaviour it proves and one thing it -does not prove. Why is a test double acceptable at the port boundary here? - -## Later review - -Given a slow flaky integration test, decide whether it belongs in a fast inner -loop, a boundary suite, or a separate environment check. - -## Definition of done - -Before answering, check that: - -- A fast domain test rejects a second booking for the same room and time. -- A use-case test runs through the in-memory adapter. -- An HTTP boundary test covers one deliberate input or error mapping. -- `npm test` was run and each test's evidence and limitation are recorded. - -After you submit your answer, choose **Mark as self-reviewed and continue** if -you are working without a connected guide. Guide evaluation is optional, not -required; if a guide is connected, you may request feedback instead. diff --git a/courses/ddd-backend-foundations/modules/06-handle-failure-deliberately.md b/courses/ddd-backend-foundations/modules/06-handle-failure-deliberately.md deleted file mode 100644 index 285c3ce..0000000 --- a/courses/ddd-backend-foundations/modules/06-handle-failure-deliberately.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -id: failure -title: Handle failure deliberately -goal: Differentiate rejection, transient failure, duplicate delivery, and unsafe retries. -action: Model one expected rejection, choose an idempotency boundary for one side effect, and test a retry or duplicate decision. -sources: - - ./06-handle-failure-deliberately.md - - ../../../references/source-index.md#reliability -questions: - - id: failure-diagnostic - kind: diagnostic - prompt: Why is catch-everything-and-retry unsafe for a backend with external side effects? - reference: ./06-handle-failure-deliberately.md - rubric: - - Names a duplicate or harmful external effect such as a repeat charge, reservation, or message. - - Explains that retry safety depends on the failure class and a deliberate idempotency or deduplication boundary. - - id: failure-exit - kind: exit - prompt: Classify one failure as domain rejection, transient failure, or duplicate. Is a retry safe, and what makes it safe? - reference: ./06-handle-failure-deliberately.md - rubric: - - Correctly classifies one concrete failure in the learner's project. - - States whether a retry is safe and the precondition, such as an idempotency key or no side effect, that makes it safe. ---- - -# 06 · Handle failure deliberately - -## Outcome - -I can distinguish expected domain rejection, transient infrastructure failure, -duplicate delivery, and an unsafe retry. - -## Diagnostic question - -Why is “catch everything and retry” unsafe for a backend that can charge money, -reserve inventory, or send a message? - -## Build - -> [!SCENARIO] -> A client retries `POST /bookings` after losing the response. An idempotency -> key lets you recognise the same request; it does not make an unrelated -> network or payment failure safe to repeat. - -1. List three failure cases for your use case: one rejected by the domain, one - transient adapter failure, and one duplicate request/message. -2. Make the expected domain failure explicit in the application result rather - than hiding it as a generic exception. -3. Choose an idempotency key or deduplication boundary for one side effect. -4. Define which failures are safe to retry, the limit, and what evidence is - recorded. Do not add background retries just to satisfy the exercise. -5. Add one test for the duplicate or retry decision and record the result. - -Use [AWS's retry and backoff guidance](../../../references/source-index.md#reliability) -and [the Transactional Outbox pattern](../../../references/source-index.md#reliability) -to reason about failure modes, not to claim exactly-once delivery. - -## Exit question - -Classify one failure in your project as domain rejection, transient failure, or -duplicate. Is a retry safe? State the precondition that makes your answer true. - -## Later review - -Explain why an idempotency key changes the effect of a retry but does not make -all work automatically safe to repeat. - -## Definition of done - -Before answering, check that: - -- Three failures are classified as domain rejection, transient adapter failure, and duplicate delivery. -- One expected domain failure is explicit in the application result. -- One side effect has a named idempotency or deduplication boundary and retry rule. -- A duplicate or retry decision has a test and recorded result. - -After you submit your answer, choose **Mark as self-reviewed and continue** if -you are working without a connected guide. Guide evaluation is optional, not -required; if a guide is connected, you may request feedback instead. diff --git a/courses/ddd-backend-foundations/modules/07-observe-and-ship.md b/courses/ddd-backend-foundations/modules/07-observe-and-ship.md deleted file mode 100644 index 730f150..0000000 --- a/courses/ddd-backend-foundations/modules/07-observe-and-ship.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -id: operate -title: Observe and ship -goal: Make the small backend diagnosable and explainable to another developer. -action: Add minimal structured logging, keep a health route separate from correctness, and write a short runbook in the workspace. -sources: - - ./07-observe-and-ship.md - - ../../../references/source-index.md#observability - - ../../../references/source-index.md#operations -questions: - - id: operate-diagnostic - kind: diagnostic - prompt: Why can logging every request body be both unhelpful and unsafe? - reference: ./07-observe-and-ship.md - rubric: - - Explains that full bodies create noisy, low-signal logs. - - Names a privacy, security, or secret-exposure risk. - - id: operate-exit - kind: exit - prompt: Name one diagnostic signal, one value you intentionally do not log, and one condition a health route cannot prove. - reference: ./07-observe-and-ship.md - rubric: - - Names a useful signal such as an operation ID, outcome, or duration and one deliberately excluded sensitive value. - - States a business-correctness or dependency condition that a health route alone cannot prove. ---- - -# 07 · Observe and ship - -## Outcome - -I can make the small backend diagnosable and describe what must be checked -before another person can run it. - -## Diagnostic question - -Why are logs that expose every request body both hard to use and potentially -unsafe? - -## Build - -> [!SCENARIO] -> When a booking fails, an operation ID, outcome, and duration make a useful -> trail. A full request body can expose private data without helping you find -> the problem. - -1. Add structured, minimally useful logs around one use case boundary. Include - a request or operation identifier, outcome, and duration where meaningful; - exclude secrets and unnecessary personal data. -2. Keep the health/status route separate from business correctness. -3. Write a short runbook in your workspace: dependencies, development command, - test command, configuration required, and one expected failure signal. -4. Run the server and test suite yourself; record the commands and outcomes. -5. If a guide is connected, ask it for an evidence review limited to this - course's boundaries, tests, failure decision, and runbook; otherwise keep - that review in the evidence form or in `NOTES.md` in your workspace. - -## Final evidence - -Finish this course with four concrete items: - -- A small, honest repository containing only the code and documentation you can explain. -- A runnable status route, with the command and observed response recorded. -- A passing test suite, with its command and scope stated honestly. -- A short architecture explanation in your own words covering the domain, ports, adapters, and one trade-off. - -This is **not production readiness**. It is a bounded learning project with -evidence that another developer can inspect and run; it does not prove scale, -security, reliability, or operational readiness. - -Use [OpenTelemetry's observability overview](../../../references/source-index.md#observability) -and [The Twelve-Factor App](../../../references/source-index.md#operations) as -practical references, not as a claim that one small course service is operated -at scale. - -## Exit question - -Name one signal that helps diagnose a failure, one value you intentionally do -not log, and one condition your health route cannot prove. - -## Later review - -From a cold checkout of your workspace, what minimum instructions let another -developer run tests and the development server safely? - -## Definition of done - -Before answering, check that: - -- Structured logs include a useful operation signal while omitting secrets and unnecessary personal data. -- A status route runs separately from a business-correctness check. -- A workspace runbook names dependencies, development and test commands, configuration, and one failure signal. -- The final evidence list contains a small repository, runnable status route, passing suite, and your own architecture explanation. - -After you submit your answer, choose **Mark as self-reviewed and continue** if -you are working without a connected guide. Guide evaluation is optional, not -required; if a guide is connected, you may request feedback instead. diff --git a/courses/example-course/course.md b/courses/example-course/course.md new file mode 100644 index 0000000..4e334c3 --- /dev/null +++ b/courses/example-course/course.md @@ -0,0 +1,49 @@ +--- +schemaVersion: 1 +id: example-course +title: "Course Format Example" +description: "A two-module reference pack that documents the LearnDeck course format by being one. Real courses live in the public catalogue at github.com/learn-deck/courses." +category: Documentation +tags: + - Authoring + - Reference +overview: + duration: 1–2 hours + sessionLength: 30–45 minutes + level: Course authors + outcomes: + - Explain every required field of a LearnDeck course pack. + - Draft one module with source-backed questions and observable rubrics. + prerequisites: + - A clone of a course repository you can edit. + - A text editor. +paths: + - id: default + label: Author a course pack + serverCommand: bun run app + testCommand: bun test test/course-pack.test.ts + workspaceHint: ../my-course-pack +--- + +# Course Format Example + +This bundled pack exists for documentation: it demonstrates the LearnDeck +course format by using every part of it. Read it alongside +[course authoring](../../docs/course-authoring.md). + +Real, learner-facing courses are not bundled with the app. They live in the +public catalogue at [learn-deck/courses](https://github.com/learn-deck/courses) +and sync into LearnDeck when `LEARNDECK_COURSE_REPOSITORY` is configured. + +A pack is one directory: + +```text +courses// + course.md # identity, overview, and workspace paths (this file) + modules/*.md # ordered learning sessions with questions and rubrics + notes/*.md # optional shared reference material +``` + +`course.md` owns identity and paths. Modules own the learning. Nothing in a +pack stores learner state: answers, evidence, and progress stay in the +learner's local database. diff --git a/courses/example-course/modules/00-start-here.md b/courses/example-course/modules/00-start-here.md new file mode 100644 index 0000000..ef70115 --- /dev/null +++ b/courses/example-course/modules/00-start-here.md @@ -0,0 +1,60 @@ +--- +id: start +title: Separate the course from the workspace +goal: State what a course pack owns, what the learner's workspace owns, and what visible evidence connects them. +action: Create the directory skeleton for a new pack — course.md, modules/, notes/ — next to (not inside) the workspace the course will teach in, and write one sentence in course.md's description that names the evidence a learner will produce. +sources: + - ./00-start-here.md + - ../notes/format-checklist.md +questions: + - id: start-boundary + kind: diagnostic + prompt: In your own words, what belongs in the course pack, what belongs in the learner's workspace, and where does learner progress live? + reference: ../notes/format-checklist.md + rubric: + - Places instructional Markdown in the pack and working code in the learner's workspace. + - States that answers, evidence, and progress stay in the learner's local database, not in the pack. + - id: start-evidence + kind: exit + prompt: Show the skeleton you created and name the observable evidence your course will ask learners to produce. + reference: ./00-start-here.md + rubric: + - Shows a pack directory with course.md and a modules/ directory outside the learner workspace. + - Names evidence a guide could actually see, such as command output or a committed file, rather than a feeling of progress. +--- + +# 00 · Separate the course from the workspace + +A LearnDeck course draws one boundary before anything else: the **pack** holds +instruction, and the **workspace** holds the learner's own work. The pack is +Markdown that anyone can read and version. The workspace is code, notes, or +exercises that only the learner touches. Progress — answers, feedback, +evidence — lives in neither; it stays in the learner's local database. + +That boundary is why a course can be updated, forked, or synced from GitHub +without ever touching what a learner has built or claimed. + +## Build it + +1. Create the pack skeleton: + + ```text + my-course-pack/ + course.md + modules/ + notes/ + ``` + +2. Fill in `course.md` front matter. Every field in this pack's own + [course.md](../course.md) is required by the loader except `tags` and the + per-path `serverCommand`, `testCommand`, and `workspaceHint` — though the + public catalogue expects at least one fully specified path. + +3. In `description`, write the sentence that names the evidence learners will + produce. If you cannot name evidence, the course promise is not yet + concrete enough. + +## Outcome + +You can explain which files belong where, and your `course.md` makes a +promise a guide could verify. diff --git a/courses/example-course/modules/01-author-a-module.md b/courses/example-course/modules/01-author-a-module.md new file mode 100644 index 0000000..19dcc13 --- /dev/null +++ b/courses/example-course/modules/01-author-a-module.md @@ -0,0 +1,60 @@ +--- +id: author-a-module +title: Author a module with observable rubrics +goal: Write one module whose questions an AI guide can evaluate against visible evidence, not vibes. +action: Draft one modules/NN-name.md file in your pack with a goal, an action, one source, and at least a diagnostic and an exit question whose rubric lines each name something observable. +sources: + - ./01-author-a-module.md + - ../notes/format-checklist.md +questions: + - id: author-a-module-review + kind: review + prompt: A rubric line reads "understands the topic deeply." Rewrite it so a guide could check it against something the learner shows, and explain what you changed. + reference: ../notes/format-checklist.md + rubric: + - Rewrites the line to reference visible evidence, such as a named file, command output, or a stated decision. + - Explains that a guide can only evaluate what the learner makes visible. + - id: author-a-module-exit + kind: exit + prompt: Paste your drafted module's front matter and point out which rubric line is the most observable and which still needs work. + reference: ./01-author-a-module.md + rubric: + - Front matter parses with id, title, goal, action, sources, and at least one diagnostic and one exit question. + - Judges their own rubric lines by whether a guide could verify them from shown evidence. +--- + +# 01 · Author a module with observable rubrics + +Modules are the unit of learning. Filenames determine order (`00-`, `01-`, …), +and the front matter `id` is the stable identity that learner progress +attaches to — rename the file freely, but never reuse an `id` for different +content. + +Each module needs: + +- **goal** — the outcome of one 30–60 minute session, stated as ability. +- **action** — one observable activity in the learner's own workspace. +- **sources** — local Markdown the questions can cite. Keep sources inside + the pack (`./this-module.md`, `../notes/…`) so the course works offline. +- **questions** — each with a `kind`, a `prompt`, a `reference`, and a + `rubric`. + +## Question kinds + +| Kind | When the guide asks it | What it does | +| --- | --- | --- | +| `diagnostic` | Entering a module | Surfaces what the learner already knows; never gates progress. | +| `review` | Revisiting earlier material | Checks that a distinction still holds after time has passed. | +| `exit` | Leaving a module | The only kind whose correct evaluation completes the section. | + +## Rubrics are the contract + +A rubric line is a claim a guide can check against what the learner shows. +"Names the boundary between pack and workspace" is checkable; "understands +the architecture" is not. Write every line so that a stranger reading only +the learner's visible answer could say yes or no. + +## Outcome + +Your pack has one complete module, and every rubric line in it names +something a guide could actually see. diff --git a/courses/example-course/notes/format-checklist.md b/courses/example-course/notes/format-checklist.md new file mode 100644 index 0000000..049af7b --- /dev/null +++ b/courses/example-course/notes/format-checklist.md @@ -0,0 +1,32 @@ +# Course pack checklist + +A quick reference for authors. The loader enforces the structural rules; the +[catalogue quality rubric](../../../docs/catalogue-quality-rubric.md) governs +what the public catalogue accepts. + +## course.md front matter + +- `schemaVersion: 1` — the parser format, not your content version. +- `id` — must equal the directory name. Permanent. +- `title`, `description`, `category` — non-empty strings. +- `overview` — `duration`, `sessionLength`, `level`, `outcomes[]`, + `prerequisites[]`. Keep durations honest. +- `paths[]` — at least one entry; the public catalogue expects one path with + `id`, `label`, `serverCommand`, `testCommand`, and `workspaceHint` all set. + +## Module front matter + +- `id` — unique within the course, permanent, decoupled from the filename. +- `title`, `goal`, `action` — non-empty strings; the action must be + observable. +- `sources[]` — local Markdown paths that exist. +- `questions[]` — unique `id`, a `kind` of `diagnostic`, `review`, or + `exit`, a `prompt`, a local `reference`, and a `rubric` list. + +## Boundaries + +- The pack holds instruction; the learner's workspace holds their work. +- Learner answers, evidence, feedback, and progress live in the learner's + local database — never in the pack. +- Only a correct `exit` evaluation (or the learner's explicit self-review) + moves a section forward; nothing else may downgrade completed progress. diff --git a/courses/testing-fundamentals/course.md b/courses/testing-fundamentals/course.md deleted file mode 100644 index f7a4b23..0000000 --- a/courses/testing-fundamentals/course.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -schemaVersion: 1 -id: testing-fundamentals -title: "Testing Fundamentals" -description: "Build a small parcel-pricing service and learn to make its tests focused, honest, and useful." -category: Engineering practice -tags: - - Testing - - TypeScript - - Node.js - - Vitest -overview: - duration: 6–7 hours - sessionLength: 45–60 minutes per module - level: Early-career backend developers - outcomes: - - Write focused tests that state one parcel-pricing behaviour clearly. - - Choose a useful unit or HTTP boundary without mocking away the behaviour under test. - - Exercise boundaries, options, and failure paths with readable test cases. - - Refactor a small service under a green suite and explain what the suite does not prove. - prerequisites: - - Node.js 20 or newer, npm, and a TypeScript-capable editor. - - Basic TypeScript functions, objects, and arrays. - - Basic familiarity with HTTP requests and JSON. - - A workspace you control for one small backend project. -paths: - - id: node-typescript-vitest - label: Node.js + TypeScript with Vitest - serverCommand: npm run dev - testCommand: npm test - workspaceHint: ../parcel-pricing ---- - -# Testing Fundamentals - -You will build a small parcel-pricing service. It prices a shipment from its -weight, destination zone, and delivery options, then exposes that decision -through one HTTP edge. The project is intentionally small: the point is to -learn what a test proves, where to put it, and how to keep confidence honest. - -LearnDeck holds your learning record. Your confirmed workspace holds the -service, tests, commands, and final evidence. You will run commands yourself. -If a guide is connected, it can help you interpret what you observe; guide -evaluation is optional. Without a connected guide, answer the visible question, -review your own evidence, and use the app's **Mark as self-reviewed and -continue** action. - -The course follows one parcel through seven short sessions: first a failing -test, then focused behaviour tests, boundary choices, honest test doubles, -deliberate edge cases, a refactor under green, and a final evidence review. diff --git a/courses/testing-fundamentals/modules/00-start-with-a-failure.md b/courses/testing-fundamentals/modules/00-start-with-a-failure.md deleted file mode 100644 index 4bf8d2d..0000000 --- a/courses/testing-fundamentals/modules/00-start-with-a-failure.md +++ /dev/null @@ -1,209 +0,0 @@ ---- -id: start-with-a-failure -title: Start with a failing test -goal: Create a small Vitest workspace and make one parcel-pricing behaviour fail before it passes. -action: Create the parcel-pricing project at the documented paths, write one failing pricing test, implement the smallest behaviour that makes it pass, and record both test outputs. -sources: - - ./00-start-with-a-failure.md - - ../notes/testing-principles.md -questions: - - id: start-failure-diagnostic - kind: diagnostic - prompt: What would a failing test tell you here that a green test written after the implementation might not? - reference: ./00-start-with-a-failure.md - rubric: - - Distinguishes a test that demonstrates a missing behaviour from a test that only confirms an implementation already written. - - Names an observable part of the failure, such as the expected parcel price or the failing test name. - - id: start-failure-exit - kind: exit - prompt: Describe the parcel input you tested, the expected price, the failure you first saw, and the evidence that the corrected test now passes. - reference: ../notes/testing-principles.md - rubric: - - Names concrete weight, zone, options, and expected total from the shared scenario. - - Distinguishes the first failing run from the later passing run and names visible test output as evidence. ---- - -# 00 · Start with a failing test - -## Outcome - -You can point to a real test that failed for a useful reason and then passed -after the smallest implementation change. - -## The parcel you are pricing - -> [!SCENARIO] -> A 2 kg parcel going to zone B with no options costs €10: €8 for the zone and -> €2 for the weight band. This is a small enough decision to test before you -> design a larger service. - -Choose a separate project folder for the service. The project belongs to you; -LearnDeck does not create it, install its dependencies, or run its commands. -Use a Node.js + TypeScript project with Vitest. The minimal bootstrap below -creates the exact files and learner-run commands needed by this course. It is -deliberately one source file; later modules may separate the HTTP adapter. - -## Copyable empty-folder setup - -From the parent directory of your project, choose the folder name -`parcel-pricing`, then run: - -```sh -mkdir -p parcel-pricing/src parcel-pricing/test -cd parcel-pricing -``` - -Create `package.json` with this content: - -```json -{ - "name": "parcel-pricing", - "private": true, - "type": "module", - "scripts": { - "dev": "tsx src/parcel-pricing.ts", - "test": "vitest run --reporter=verbose" - } -} -``` - -Install the learner-run tools yourself: - -```sh -npm install --save-dev vitest tsx @vitest/coverage-v8 -``` - -Create `test/parcel-pricing.test.ts`: - -```ts -import { describe, expect, it } from "vitest"; -import { priceParcel } from "../src/parcel-pricing"; - -describe("parcel pricing", () => { - it("prices a 2 kg zone B parcel at €10", () => { - const result = priceParcel({ weightKg: 2, zone: "B", options: [] }); - - expect(result.total).toBe(10); - }); -}); -``` - -Create `src/parcel-pricing.ts` with this temporary implementation first: - -```ts -import { createServer } from "node:http"; -import { fileURLToPath } from "node:url"; - -export type Parcel = { - weightKg: number; - zone: "A" | "B" | "C"; - options: string[]; -}; - -export type ParcelPrice = { total: number }; - -export function priceParcel(_parcel: Parcel): ParcelPrice { - throw new Error("priceParcel is not implemented"); -} - -function startServer() { - createServer((request, response) => { - if (request.method === "GET" && request.url === "/health") { - response.writeHead(200, { "content-type": "application/json" }); - response.end(JSON.stringify({ status: "ok" })); - return; - } - - response.writeHead(404, { "content-type": "application/json" }); - response.end(JSON.stringify({ error: "not found" })); - }).listen(3000, "127.0.0.1", () => { - console.log("Parcel-pricing server listening at http://127.0.0.1:3000"); - }); -} - -if (process.argv[1] === fileURLToPath(import.meta.url)) startServer(); -``` - -Run the first test before implementing the behaviour: - -```sh -npm test -``` - -The exact useful output is one named failure and this summary: - -```text -❯ test/parcel-pricing.test.ts > parcel pricing > prices a 2 kg zone B parcel at €10 - → Error: priceParcel is not implemented -Test Files 1 failed (1) -Tests 1 failed (1) -``` - -Now replace only `priceParcel` in `src/parcel-pricing.ts` with: - -```ts -export function priceParcel(parcel: Parcel): ParcelPrice { - const baseRates: Record = { A: 5, B: 8, C: 12 }; - const weightSurcharge = parcel.weightKg > 1 ? 2 : 0; - - return { total: baseRates[parcel.zone] + weightSurcharge }; -} -``` - -Run `npm test` again. The exact useful output is now: - -```text -✓ test/parcel-pricing.test.ts > parcel pricing > prices a 2 kg zone B parcel at €10 -Test Files 1 passed (1) -Tests 1 passed (1) -``` - -Vitest may add its version, timing, colours, and stack-location lines around -these stable lines. The required evidence is the named failure followed by the -named pass; do not replace either assertion with a weaker one. - -The front-matter commands are now executable: `npm test` runs the test above, -and `npm run dev` starts the status server from `src/parcel-pricing.ts`. Verify -the second command yourself with: - -```sh -npm run dev -curl http://127.0.0.1:3000/health -``` - -The terminal must print -`Parcel-pricing server listening at http://127.0.0.1:3000`, and `curl` must -return exactly `{"status":"ok"}`. Stop the server after checking it. - -## Build - -1. Create the project at `package.json`, `src/parcel-pricing.ts`, and - `test/parcel-pricing.test.ts`. Keep the production code and tests easy to - find; do not create an architecture catalogue. -2. Write a test for the 2 kg, zone B, no-options example. Write the expected - €10 before implementing the pricing behaviour. -3. Run `npm test` and keep the visible failure: the test name and the mismatch - are useful evidence. -4. Implement only enough pricing logic to make that test pass. Run `npm test` - again and record the green output. -5. Verify that `npm run dev` starts the server and that `GET /health` returns - `200 {"status":"ok"}`. The HTTP pricing edge comes in module 02; do not - build it now. - -The output for this module is not “I have tests”. It is a before-and-after -record at `test/parcel-pricing.test.ts`: one named behaviour, one meaningful -failure, one meaningful pass, and the health check response above. If a guide -is connected, you may tell it the command and output so it can optionally -review the evidence. If no guide is connected, write the same record in your -own notes, answer the question, and select **Mark as self-reviewed and -continue** in the app. - -## Definition of done - -- A separate parcel-pricing workspace contains `package.json`, - `src/parcel-pricing.ts`, and `test/parcel-pricing.test.ts`; the test runs with - Vitest. -- The 2 kg, zone B, no-options behaviour failed before its implementation existed. -- The same test passes after the smallest implementation change. -- `npm run dev` returns `200 {"status":"ok"}` from `GET /health`. -- You recorded the `npm test` command and the visible failing and passing results. diff --git a/courses/testing-fundamentals/modules/01-name-the-behaviour.md b/courses/testing-fundamentals/modules/01-name-the-behaviour.md deleted file mode 100644 index 8234784..0000000 --- a/courses/testing-fundamentals/modules/01-name-the-behaviour.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -id: name-the-behaviour -title: Name what a test proves -goal: Turn a passing example into focused arrange–act–assert tests that each describe one parcel-pricing behaviour. -action: Add focused cases in `test/parcel-pricing.test.ts` for ordinary pricing and one option, with test names that state the behaviour and output that shows the cases separately. -sources: - - ./01-name-the-behaviour.md - - ../notes/testing-principles.md -questions: - - id: behaviour-aaa-diagnostic - kind: diagnostic - prompt: In a parcel-pricing test, what belongs in arrange, act, and assert, and why does that shape help a future reader? - reference: ../notes/testing-principles.md - rubric: - - Places the parcel input and any dependency setup in arrange, one pricing call in act, and observable price or failure checks in assert. - - Explains that the shape makes the behaviour and the reason for the assertions readable. - - id: behaviour-aaa-exit - kind: exit - prompt: Choose one of your tests and state the single behaviour it proves. What would be a different test rather than another assertion in this one? - reference: ./01-name-the-behaviour.md - rubric: - - States one specific parcel-pricing behaviour and connects it to the test's input and outcome. - - Gives a plausible separate behaviour, such as an option surcharge or an invalid zone, instead of splitting an inseparable outcome mechanically. ---- - -# 01 · Name what a test proves - -## Outcome - -You can read a test title and its arrange–act–assert sections and say exactly -what behaviour the test proves. - -## Keep the scenario concrete - -> [!SCENARIO] -> The 2 kg zone B parcel costs €10 without options. The same parcel with -> `express` costs €16. Those are related examples, but they answer different -> questions: the base-and-weight rule, and whether the express option changes -> the outcome correctly. - -One behaviour per test is a naming tool, not a ban on useful assertions. Keep -assertions together when they explain one outcome; split the test when a -failure would leave a reader unsure which behaviour broke. - -## Worked example 1 — make one decision, then fade it - -Here is a complete decision for the express option: - -```ts -it("adds the express surcharge to the parcel price", () => { - // arrange - const parcel = { weightKg: 2, zone: "B", options: ["express"] }; - - // act - const result = priceParcel(parcel); - - // assert - expect(result.total).toBe(16); -}); -``` - -Pause before reading on. Write the next focused test yourself for the same -parcel with the `fragile` option. What should its total be, and which part of -the test should change? - -The answer is €13: zone B €8, the 2 kg surcharge €2, and fragile €3. Keep the -arrange–act–assert shape, change the option and expected outcome, and give the -test a name about fragile pricing. The test should not also decide whether the -HTTP endpoint parses JSON; that is a different boundary and a later module. - -## Build - -1. Rename the first test so its title states the behaviour rather than the - function name alone. Keep it in `test/parcel-pricing.test.ts`. -2. Add focused tests for an ordinary zone-and-weight price and one option in - that file. Use names such as `prices a 2 kg zone B parcel at €10` and - `adds the express surcharge to a 2 kg zone B parcel`. Keep the input visible - in arrange, one pricing call in act, and the price or deliberate failure in - assert. -3. Run `npm test`. The visible output must list those two case names separately - and end with `Test Files 1 passed` and `Tests 2 passed`. If - a test fails, fix the behaviour or its stated expectation deliberately; - do not weaken the assertion just to get green. -4. Ask yourself whether each test could fail for a different reason. If the - answer is yes, split it and make the new behaviour explicit. - -## Definition of done - -- Tests use a visible arrange–act–assert shape for parcel-pricing decisions. -- The focused cases live in `test/parcel-pricing.test.ts` and their names are - visible in `npm test` output. -- Each test name states one behaviour a learner can explain in one sentence. -- At least one base price and one option price have separate observable cases. -- `npm test` passes and its output makes the focused cases visible. diff --git a/courses/testing-fundamentals/modules/02-choose-a-boundary.md b/courses/testing-fundamentals/modules/02-choose-a-boundary.md deleted file mode 100644 index dc9290e..0000000 --- a/courses/testing-fundamentals/modules/02-choose-a-boundary.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -id: choose-a-boundary -title: Choose a useful test boundary -goal: Keep pure pricing decisions fast and use one HTTP integration test to prove the transport translation. -action: Keep the direct pricing test in `test/parcel-pricing.test.ts` and add one real HTTP test in `test/http-price.test.ts` for `POST /price`, recording the distinction in the test output. -sources: - - ./02-choose-a-boundary.md - - ../notes/testing-principles.md -questions: - - id: boundary-diagnostic - kind: diagnostic - prompt: Which part of parcel pricing should be tested without starting a server, and which part is worth crossing the HTTP boundary to test? - reference: ../notes/testing-principles.md - rubric: - - Places the pure price decision in a fast isolated test and the JSON/request/response translation at the HTTP boundary. - - Explains why a boundary test can catch a translation mistake that a direct function test cannot. - - id: boundary-exit - kind: exit - prompt: Trace your valid POST /price test from request to response. What does it prove, and what important behaviour does it intentionally leave to the unit tests? - reference: ./02-choose-a-boundary.md - rubric: - - Names the request shape, HTTP edge, and observable response that the integration test exercises. - - Separates transport translation from the full set of pricing rules and names a behaviour left to direct tests. ---- - -# 02 · Choose a useful test boundary - -## Outcome - -You can choose a boundary because it protects a behaviour, not because a test -taxonomy says every file needs its own test. - -## The parcel edge - -> [!SCENARIO] -> A direct pricing test can prove that 2 kg in zone B costs €10. It cannot -> prove that `POST /price` reads `weightKg` from JSON, passes the right value to -> the pricing decision, and returns the result in the response. One HTTP test -> can prove that translation without turning every rule into a slow end-to-end -> test. - -Use the smallest useful split: - -- A unit test calls the pricing decision directly with an ordinary parcel, - option, or invalid input. -- Add the adapter at `src/server.ts`, keep `priceParcel` in - `src/parcel-pricing.ts`, and change `package.json` so `npm run dev` starts - `src/server.ts`. -- An integration test in `test/http-price.test.ts` crosses that HTTP edge with - a real `Request`/`Response` or a locally running server and checks the - response status and body. For the shared case, send - `POST /price` with `{"weightKg":2,"zone":"B","options":[]}` and expect - `200` with a JSON body whose `total` is `10`. -- Do not mock the pricing function inside the test that claims to prove the - `/price` behaviour. That would prove only that the handler called a mock. - -## Build - -1. Keep one direct test for the shared parcel-pricing rule in - `test/parcel-pricing.test.ts`. -2. Add `src/server.ts` and the valid `POST /price` test in - `test/http-price.test.ts`. Use the real HTTP adapter. If your test harness - starts the local server, use `fetch`; if it invokes the adapter in-process, - still construct a real `Request` and inspect its real `Response`. -3. Run `npm test`. Give the boundary case a name such as - `translates a valid POST price request into a €10 response`; that exact name - must be visible alongside `Test Files` and `Tests` passing summaries. -4. Write down one transport failure you will test later, such as malformed JSON - returning a 400 response, and one domain failure such as an invalid weight - returning a distinct 422 response. Keep those as separate promises. - -## What this is NOT - -It is not “unit test every line and mock everything else.” A unit test is -useful when it isolates a decision; a boundary test is useful when it protects -the translation at that boundary. Neither is a score for how many test files -you created. - -## Definition of done - -- A direct pricing test and an HTTP-edge test both run with the same test command. -- `test/http-price.test.ts` sends the documented JSON through `POST /price` and - checks `200` plus a response `total` of `10`. -- The HTTP test does not replace the pricing decision with a mock. -- Your answer names one transport failure and one domain failure that deserve distinct tests. diff --git a/courses/testing-fundamentals/modules/03-use-doubles-honestly.md b/courses/testing-fundamentals/modules/03-use-doubles-honestly.md deleted file mode 100644 index c6d374b..0000000 --- a/courses/testing-fundamentals/modules/03-use-doubles-honestly.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -id: use-doubles-honestly -title: Use test doubles honestly -goal: Choose a small fake for replaceable data and recognise when a mock or spy can make a test pass without proving the outcome. -action: Put one deterministic rate fake at `test/fixtures/zone-rates.ts`, use it at a real pricing seam, and assert the resulting price or failure in `test/parcel-pricing.test.ts`. -sources: - - ./03-use-doubles-honestly.md - - ../notes/testing-principles.md -questions: - - id: doubles-diagnostic - kind: diagnostic - prompt: What is the difference between a fake, a mock, and a spy in the parcel-pricing scenario, and which one would you start with for zone rates? - reference: ../notes/testing-principles.md - rubric: - - Distinguishes a working substitute, scripted or interaction-checked substitute, and call-recording wrapper. - - Chooses an in-memory fake for zone rates and explains that it keeps the price outcome meaningful. - - id: doubles-exit - kind: exit - prompt: Show how your test double lets you make a pricing decision, then explain one way a spy or mock assertion could pass while the customer-facing price is wrong. - reference: ./03-use-doubles-honestly.md - rubric: - - Describes a small fake or fixture with believable zone-rate behaviour and an asserted parcel outcome. - - Gives a concrete interaction-only failure, such as verifying a lookup call while never checking the returned total. ---- - -# 03 · Use test doubles honestly - -## Outcome - -You can substitute a dependency without pretending that an interaction proves -the parcel price. - -## A small seam, not a new architecture - -> [!SCENARIO] -> Imagine that zone base prices come from a rate lookup. For this course, an -> in-memory rate table is enough. It lets you test the pricing decision without -> a network, database, or “mock everything” setup. The service should still -> return the right total for the parcel. - -If your current implementation has the zone rates inline, you do not need to -invent an external service. You can pass a small rates object or fixture into -the pricing function, or keep the fixture at the unit boundary. The point is to -make the substitute believable and small. - -For example, the shape might be as simple as this: - -```ts -const rates = { - getBase(zone: "A" | "B" | "C") { - return { A: 5, B: 8, C: 12 }[zone]; - }, -}; -``` - -The important assertion is the parcel outcome: a 2 kg zone B parcel still -costs €10. A test that only says `getBase` was called with `"B"` can pass even -if the result is ignored or a wrong surcharge is added. - -## Build - -1. Identify one replaceable input in `src/parcel-pricing.ts`: a zone-rate - lookup, a clock, or another small dependency that genuinely exists. -2. Create the deterministic in-memory fake in - `test/fixtures/zone-rates.ts`. Keep its behaviour obvious; do not reproduce - the whole production implementation. -3. Write or revise one test in `test/parcel-pricing.test.ts` so it checks the - resulting parcel price or the deliberate failure, not only the fake's call - history. Name it, for example, `prices zone B through the in-memory rate fake`. -4. Run `npm test` and inspect that case name plus the passing `Test Files` and - `Tests` summaries. If you keep a spy, say what contract the interaction - itself protects; otherwise remove it. - -## What this is NOT - -It is not a test where every collaborator is a mock and every assertion checks -call count. That style can report green while the price a caller receives is -wrong. A fake is not automatically better either: use it because it isolates a -real boundary while preserving a meaningful outcome. - -## Definition of done - -- One test uses a deterministic fake or fixture at a real seam in the project. -- The fake is at `test/fixtures/zone-rates.ts` and the outcome assertion is in - `test/parcel-pricing.test.ts`. -- The fake is smaller than the production dependency and has believable behaviour. -- The test asserts a parcel price or deliberate failure, not only an interaction. -- `npm test` passes and your explanation names when a spy would be misleading. diff --git a/courses/testing-fundamentals/modules/04-drive-the-edges.md b/courses/testing-fundamentals/modules/04-drive-the-edges.md deleted file mode 100644 index 83113bf..0000000 --- a/courses/testing-fundamentals/modules/04-drive-the-edges.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -id: drive-the-edges -title: Drive the edges deliberately -goal: Use table-driven cases to make pricing boundaries, options, and failure paths visible. -action: Add a table to `test/parcel-pricing.test.ts` covering both sides of at least two pricing boundaries plus deliberate invalid-input cases, then run the suite and inspect the named cases. -sources: - - ./04-drive-the-edges.md - - ../notes/testing-principles.md -questions: - - id: edges-diagnostic - kind: diagnostic - prompt: Which values around the 1 kg, 5 kg, and 10 kg thresholds are more useful than a single typical parcel, and why? - reference: ../notes/testing-principles.md - rubric: - - Names values on both sides of at least one threshold, including the threshold itself where the rule includes it. - - Explains that adjacent values can reveal an incorrect comparison or band assignment. - - id: edges-exit - kind: exit - prompt: Choose one table case and one failure case. What rule does each protect, and what would a vague “it throws” assertion fail to tell you? - reference: ./04-drive-the-edges.md - rubric: - - Connects the table case to a specific pricing boundary, option, or expected total. - - Names the invalid input and the promised error distinction rather than only saying that an exception occurred. ---- - -# 04 · Drive the edges deliberately - -## Outcome - -You can choose cases because they protect a rule, not because a test generator -produced a large number of examples. - -## Make the boundaries visible - -> [!SCENARIO] -> The 1 kg band includes 1 kg, but 1.01 kg moves to the next surcharge. The -> same question exists at 5 kg and 10 kg. A table keeps those decisions in one -> readable place and gives each case a useful name. - -Start with a small table like this, then add the invalid inputs that matter to -your service: - -| Case name | Weight | Zone | Options | Expected | -| --- | ---: | :---: | :--- | ---: | -| `one-kg-is-in-first-band` | 1.00 | A | none | €5 | -| `just-over-one-kg-adds-surcharge` | 1.01 | A | none | €7 | -| `five-kg-stays-in-second-band` | 5.00 | B | none | €10 | -| `just-over-five-kg-uses-third-band` | 5.01 | B | none | €13 | -| `ten-kg-stays-in-third-band` | 10.00 | C | none | €17 | -| `just-over-ten-kg-uses-fourth-band` | 10.01 | C | none | €21 | - -Add at least one case for `express` or `fragile`, an invalid weight, and an -unknown zone or option. For failures, assert the stable distinction your -caller needs: an error category, status, or documented message fragment. Do -not make the test depend on a private stack trace. - -## Build - -1. Put related pricing examples in a Vitest table or equivalent data-driven - structure in `test/parcel-pricing.test.ts`. Give each row a case name that - will appear in test output, including the six names shown above. -2. Include values at and immediately beside at least two thresholds. -3. Add deliberate failure cases for invalid input. Keep transport failures in - the HTTP test; these cases exercise the pricing rule itself. -4. Run `npm test`. Read the six boundary case names, the option case, and the - invalid-input case—not only the final count. The output must end with - passing `Test Files` and `Tests` summaries. If a boundary fails, decide - whether the rule or the test expectation is wrong before changing either. - -## Definition of done - -- A readable table covers both sides of at least two pricing thresholds. -- The table is in `test/parcel-pricing.test.ts` and its important case names - appear in `npm test` output. -- Options have a focused case and invalid input has a deliberate failure case. -- Failure assertions identify a useful distinction beyond “something threw”. -- `npm test` passes and the output exposes the names of the important cases. diff --git a/courses/testing-fundamentals/modules/05-refactor-under-green.md b/courses/testing-fundamentals/modules/05-refactor-under-green.md deleted file mode 100644 index 0884151..0000000 --- a/courses/testing-fundamentals/modules/05-refactor-under-green.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -id: refactor-under-green -title: Refactor under green -goal: Change internal structure while a behaviour-focused suite protects the parcel-pricing contract. -action: Refactor one internal helper in `src/parcel-pricing.ts` while `test/parcel-pricing.test.ts` stays green before and after, then replace one implementation-detail assertion with an observable outcome assertion. -sources: - - ./05-refactor-under-green.md - - ../notes/testing-principles.md -questions: - - id: refactor-diagnostic - kind: diagnostic - prompt: What makes a test a safety net during a refactor, and what kind of assertion makes that net brittle? - reference: ../notes/testing-principles.md - rubric: - - Explains that a passing behaviour test gives evidence that an observable contract stayed intact across an internal change. - - Distinguishes a brittle assertion about private calls, order, or structure from an assertion about a parcel outcome. - - id: refactor-exit - kind: exit - prompt: What did you change internally, what stayed observable, and which test result gives you confidence without claiming that every possible implementation is safe? - reference: ./05-refactor-under-green.md - rubric: - - Names one internal refactor and one unchanged parcel-pricing or HTTP behaviour. - - Uses passing test evidence while stating a limit, such as untested inputs, dependencies, or production conditions. ---- - -# 05 · Refactor under green - -## Outcome - -You can change names, helpers, or internal structure while preserving the -behaviour a caller relies on. - -## Protect the outcome, not the shape - -> [!SCENARIO] -> You can replace nested weight `if` statements with a small band table, or -> extract a `weightSurcharge` helper. If the parcel totals and deliberate -> failures stay the same, the refactor should not require a new customer-facing -> contract. - -## Worked example 2 — fade from an interaction to an outcome - -Here is the brittle decision a test author might make: - -```ts -expect(rateLookup.getBase).toHaveBeenCalledWith("B"); -expect(rateLookup.getBase).toHaveBeenCalledTimes(1); -``` - -Your turn: rewrite the meaningful part of this test so it proves what the -caller receives for a 2 kg zone B parcel. Keep an interaction assertion only if -the lookup call itself is a documented contract. - -The stronger default is an outcome such as `expect(result.total).toBe(10)`, -with the parcel input visible in arrange. The implementation may later cache, -batch, or replace the lookup without changing the price contract. A call-order -assertion is useful only when ordering is observable and required, not because -the spy makes it easy to inspect. - -## Build - -1. Run `npm test` and note the green baseline for `test/parcel-pricing.test.ts` - before editing internals. The output must show the existing parcel case - names and passing `Test Files` and `Tests` summaries. -2. Make one refactor in `src/parcel-pricing.ts` that should not change the - parcel-pricing contract: name a helper, extract a weight-band table, or - simplify a branch. -3. In `test/parcel-pricing.test.ts`, replace one assertion about a private call, - order, or helper with an observable price, response, or deliberate failure - assertion. -4. Run `npm test` again. The same named behaviour must pass. If it fails, use - the failure to decide whether you - found a real behaviour change or a test coupled to the old implementation. -5. Record the before-and-after commands and the refactor in your evidence. - -## What this is NOT - -It is not “green means production-ready,” and it is not “never assert an -interaction.” Green means only that the exercised contracts held for the cases -you wrote. Assert a call when the call is itself the contract; otherwise prefer -the outcome a parcel-pricing caller can observe. - -## Definition of done - -- The suite was green before and after one internal refactor. -- `src/parcel-pricing.ts` and `test/parcel-pricing.test.ts` show the changed - implementation and the outcome-focused protection. -- At least one brittle implementation-detail assertion became a behaviour assertion. -- The changed code still covers a parcel total or deliberate failure that a caller can observe. -- Your evidence states what the green suite does not prove. diff --git a/courses/testing-fundamentals/modules/06-read-confidence-honestly.md b/courses/testing-fundamentals/modules/06-read-confidence-honestly.md deleted file mode 100644 index 22b58ec..0000000 --- a/courses/testing-fundamentals/modules/06-read-confidence-honestly.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -id: read-confidence-honestly -title: Read confidence honestly -goal: Finish with a test and evidence report that separates executed coverage from confidence about untested behaviour. -action: Run `npm test` and `npm test -- --coverage`, then write `TEST-EVIDENCE.md` in the project root listing tested behaviours, observed output, and limits. -sources: - - ./06-read-confidence-honestly.md - - ../notes/testing-principles.md -questions: - - id: confidence-diagnostic - kind: diagnostic - prompt: What can a coverage percentage tell you about the parcel-pricing suite, and what can it not tell you? - reference: ../notes/testing-principles.md - rubric: - - States that coverage reports which code was executed by the selected tests. - - Names a missing guarantee, such as correct assertions, untested boundaries, HTTP translation, dependencies, or production traffic. - - id: confidence-exit - kind: exit - prompt: "Give your final evidence list: commands and outcomes, important parcel cases, one HTTP observation, and two things your suite does not prove." - reference: ./06-read-confidence-honestly.md - rubric: - - Names learner-run commands with observed pass or coverage output and identifies important behaviour cases. - - Includes an HTTP observation and two specific limits instead of treating coverage or a green suite as production proof. ---- - -# 06 · Read confidence honestly - -## Outcome - -You can hand another developer a compact record of what you tested, what you -saw, and what remains outside the claim. - -## Coverage is a map, not a verdict - -> [!SCENARIO] -> A high line percentage might mean that a happy-path test executed every -> branch while never checking whether the HTTP adapter maps an invalid parcel -> correctly. A lower percentage can still accompany a thoughtful suite if the -> important boundaries and outcomes are covered. Read the report alongside -> the cases, not instead of them. - -## Build - -1. Run `npm test` and record the command plus its passing `Test Files` and - `Tests` summaries. -2. Run `npm test -- --coverage`. Module 00 installed - `@vitest/coverage-v8`, so this command should produce a `coverage/` report - and a coverage table. If you intentionally used a different provider, - record its command and observed result; never invent a percentage. -3. Read the report for the pricing decision and HTTP edge. Note which important - cases executed and which behaviours remain untested. -4. In the project root, write `TEST-EVIDENCE.md` with the commands, the exact - observed pass/coverage summaries, the boundary and edge cases covered, one - health or `POST /price` observation, and at least two limits on the - confidence claim. -5. If a guide is connected, you may ask it to review the evidence, not to - certify the project; guide evaluation is optional. If no guide is connected, - review `TEST-EVIDENCE.md` yourself, answer the question, and select **Mark as - self-reviewed and continue** in the app. Your final record should be - explainable from the repository and your observations. - -## Final evidence - -Finish with four concrete items, recorded in `TEST-EVIDENCE.md`: - -- a small parcel-pricing repository you can explain; -- a passing Vitest command and the important behaviours it exercises; -- one observed HTTP-edge result, including the status route or price request; -- `TEST-EVIDENCE.md` with an honest coverage reading and explicit limits. - -This is **not production readiness**. The course does not prove scale, -security, resilience, financial correctness, or every possible parcel input. -It proves that you built and inspected a bounded suite with enough evidence to -discuss its strengths and gaps. - -## Definition of done - -- `npm test` passes and its command and output are recorded. -- Coverage is reported when configured, or its absence is recorded without a made-up number. -- `TEST-EVIDENCE.md` lists important pricing, failure, and HTTP observations. -- The final report names at least two behaviours or conditions the suite does not prove. diff --git a/courses/testing-fundamentals/notes/testing-principles.md b/courses/testing-fundamentals/notes/testing-principles.md deleted file mode 100644 index 3eb4ba7..0000000 --- a/courses/testing-fundamentals/notes/testing-principles.md +++ /dev/null @@ -1,75 +0,0 @@ -# Testing Fundamentals: shared scenario and source notes - -This file is a compact source for the course's parcel-pricing decisions. The -module that names a claim should still explain it in context; this note keeps -the shared scenario stable across the pack. - -## Parcel-pricing behaviour - -The service prices one parcel from three inputs: - -- `weightKg`, a positive number no greater than 20; -- `zone`, one of `A`, `B`, or `C`; and -- zero or more options, currently `express` and `fragile`. - -The base prices are €5 for zone A, €8 for zone B, and €12 for zone C. Add a -weight surcharge of €0 for weight up to and including 1 kg, €2 for weight over -1 kg up to and including 5 kg, €5 for weight over 5 kg up to and including -10 kg, and €9 for weight over 10 kg up to and including 20 kg. Add €6 for -`express` and €3 for `fragile`. The result is an integer euro total and should -also make the chosen inputs visible enough for a caller to explain the price. - -Reject a weight that is zero, negative, not numeric, or greater than 20. Reject -an unknown zone and an unknown option. The service should represent these as a -deliberate domain failure; the exact error type or message is the learner's -choice if the observable distinction stays clear. - -The first useful examples are: - -| Weight | Zone | Options | Expected total | -| ---: | :---: | :--- | ---: | -| 1 kg | A | none | €5 | -| 2 kg | B | none | €10 | -| 2 kg | B | express | €16 | -| 2 kg | B | fragile | €13 | -| 10 kg | C | express, fragile | €26 | - -The HTTP edge should accept a JSON `POST /price` request and return a success -response containing the price for valid input. Malformed transport input should -stop at the HTTP boundary with a 400-level response. A well-formed request -whose parcel violates a pricing rule should receive a different deliberate -4xx response. A separate `GET /health` or equivalent status route may prove -that the process is reachable; it cannot prove that pricing is correct. - -## Testing decisions - -Arrange–act–assert gives a test a readable shape: establish the inputs and -dependencies, perform one meaningful operation, then check the observable -outcome. “One behaviour per test” does not mean one assertion per test. It -means that a reader can name the behaviour and understand why the assertions -belong together. - -A unit boundary is useful when it keeps a small decision fast and isolated. An -integration test is useful when it crosses a boundary whose translation could -be wrong, such as JSON input becoming a pricing request or a domain failure -becoming an HTTP response. Tests should not mock away the code whose behaviour -they claim to prove. - -A fake is a small working substitute with believable behaviour, such as an -in-memory rate table. A mock records or scripts interactions and can be useful -at a genuine boundary, but a mock-heavy test may pass while the real outcome is -wrong. A spy records calls; a call count or call order is only meaningful when -that interaction is itself the contract. Otherwise, assert the result a user -can observe. - -Table-driven cases make a family of related inputs visible. Boundary cases -should include the values on both sides of a rule, not only a typical middle -value. Failure-path tests should name the invalid input and the promised -distinction, rather than merely asserting that “something threw”. - -Coverage is a map of executed code, not a certificate of correct behaviour. -High line coverage can coexist with missing boundaries, weak assertions, an -untested HTTP translation, or a test suite that exercises only happy paths. -The final evidence should state the command, the result, the important cases, -and the limits of the confidence claim. - diff --git a/docs/course-authoring.md b/docs/course-authoring.md index b56a003..726ac5d 100644 --- a/docs/course-authoring.md +++ b/docs/course-authoring.md @@ -2,7 +2,9 @@ LearnDeck is a reusable learning app, not a DDD-only course. A **course pack** is a portable folder of Markdown that the local UI and MCP server load in the -same order. The bundled DDD course proves the format; a fork can remove it and +same order. The bundled [`example-course`](../courses/example-course/course.md) pack +demonstrates the format; real courses live in the public catalogue at +[learn-deck/courses](https://github.com/learn-deck/courses), and a fork can seed an entirely different course without changing application code. ```mermaid @@ -29,12 +31,13 @@ their local answers and evidence, and the app owns only progress state. ```text courses/ - ddd-backend-foundations/ + example-course/ course.md modules/ - 00-start-a-path.md - 01-model-the-domain.md - ... + 00-start-here.md + 01-author-a-module.md + notes/ + format-checklist.md ``` LearnDeck loads one direct child folder per course pack. Each pack must contain @@ -42,13 +45,16 @@ LearnDeck loads one direct child folder per course pack. Each pack must contain learning order, so use a zero-padded numeric prefix. Course files and local sources are Markdown; JSON course manifests are intentionally not supported. -Run this to create a valid starting pack: +Run this to create a valid starting pack (the ID must not already exist under +`courses/`): ```sh -bun run seed -- testing-fundamentals "Testing Fundamentals" +bun run seed -- api-design-basics "API Design Basics" ``` -The command writes a `course.md` and an `00-orient.md` module. Set +The command writes a `course.md` and an `00-orient.md` module from +[`templates/course.md`](../templates/course.md) and +[`templates/module.md`](../templates/module.md). Set `LEARNDECK_COURSES_DIR` to seed or load packs from another folder. The older `PATCHQUEST_COURSES_DIR` variable is accepted only as a transition alias. @@ -61,8 +67,8 @@ learner's briefing, and declares the runtime LearnDeck will resolve for them. ```md --- schemaVersion: 1 -id: testing-fundamentals -title: Testing Fundamentals +id: my-first-course +title: My First Course description: Learn to make small, trustworthy tests. category: Engineering practice tags: @@ -235,6 +241,7 @@ the source body for optional context: through MCP. 8. Run `bun run verify` before sharing the pack. -The DDD pack at -[`courses/ddd-backend-foundations`](../courses/ddd-backend-foundations) is the -reference implementation of this contract. +The bundled [`courses/example-course`](../courses/example-course) pack is a +minimal reference implementation of this contract; the DDD pack in +[learn-deck/courses](https://github.com/learn-deck/courses) is the full-scale +one. diff --git a/docs/course-versioning.md b/docs/course-versioning.md new file mode 100644 index 0000000..3768b0d --- /dev/null +++ b/docs/course-versioning.md @@ -0,0 +1,423 @@ +# Course versioning (design proposal) + +Status: proposal — not yet implemented. This document designs how course packs +gain versions, changelogs learners can act on, and per-course pinning, grounded +in the current `src/course.ts`, `src/store.ts`, and the GitHub sync described in +[public course distribution](public-course-distribution.md). + +## 1. Versioning scheme + +### Current code constraints + +`src/course.ts` currently treats `schemaVersion` as the parser/schema version: +`loadCoursePack()` reads it from `course.md`, and `validateCourse()` only +accepts `schemaVersion: 1`. It is not a content version and should not be +repurposed. + +A course is currently loaded from: + +- `course.md` +- all sorted `modules/*.md` files +- local Markdown files referenced by module `sources` or question `reference` fields + +Module filenames determine learning order, while module frontmatter `id` +becomes `CourseSection.id`. The authoring guide already requires stable module +and question IDs. + +### Proposed release version + +Add a required author-facing field named `contentVersion` to `course.md`: + +```yaml +schemaVersion: 1 +contentVersion: 1.2.0 +id: testing-fundamentals +``` + +Use Semantic Versioning: + +- `PATCH`: typo fixes, clarifications, corrected links, or non-semantic prose changes. +- `MINOR`: new modules, new examples, new review questions, or additive material that preserves existing module/question meaning. +- `MAJOR`: changed learning outcomes, changed exit criteria, removed material, module splits/merges, or other incompatible restructuring. + +`contentVersion` describes one coherent release of the entire learning pack, +not an individual Markdown file. It covers `course.md`, all modules, their +questions/rubrics, and local Markdown references that affect the course. A +shared file under `references/` may affect multiple courses; authors must bump +and document every affected course. + +The existing `schemaVersion` remains a separate parser-format number. A parser +change may require `schemaVersion: 2` without representing a new learning +release. + +For backwards compatibility, an initial implementation may treat missing +`contentVersion` as a legacy `0.0.0` release and expose a warning. New or +published packs should require the field. `templates/course.md` should generate +`contentVersion: 1.0.0`. + +### Git ref and content identity + +The configured source remains a moving ref such as: + +```sh +LEARNDECK_COURSE_REPOSITORY=github:learn-deck/courses@main +``` + +That ref is appropriate for following updates but is not sufficient for +pinning. `CourseCatalog.loadConfigured()` currently caches by owner, +repository, and branch, and records only `syncedAt` in its metadata. + +Add two machine identities: + +1. Resolve the configured GitHub branch/tag to an immutable commit SHA. +2. Compute a deterministic SHA-256 content hash over the course's relevant Markdown files. + +The learner-facing identity is `contentVersion`; the commit SHA and content +hash provide reproducibility and detect an author changing content without +bumping the version. + +Git tags may be supported as friendly aliases, but the stored pin must be the +resolved commit SHA. A branch name such as `main` must never be stored as the +immutable pin. + +External `https` references remain outside the guarantee of a Markdown release +because their contents can change independently. The authoring guide already +recommends local source snapshots for material likely to change. + +## 2. Changelog + +### Author format + +Add `courses//CHANGELOG.md`. This remains compatible with the public +repository contract because `src/course.ts` already syncs every `.md` file +under `courses/` and `references/`. + +Use Markdown with machine-readable YAML frontmatter: + +```md +--- +formatVersion: 1 +entries: + - version: 1.2.0 + released: 2026-07-18 + severity: new-material + summary: Added a practical boundary-mapping exercise. + moduleIds: + - model-boundary + questionIds: [] + learnerAction: reread + - version: 1.1.1 + released: 2026-06-30 + severity: content-fix + summary: Corrected the example transaction boundary. + moduleIds: + - transactions + questionIds: [] + learnerAction: skim +--- + +# Changelog + +Human-readable release notes may follow. +``` + +Proposed fields: + +- `version`: must match a `contentVersion` release. +- `released`: ISO date. +- `severity`: one of `content-fix`, `new-material`, `rubric-change`, `breaking-restructure`. +- `summary`: concise learner-facing explanation. +- `moduleIds`: values matching module frontmatter `id`, which becomes `CourseSection.id`. +- `questionIds`: values matching question frontmatter IDs. +- `learnerAction`: `none`, `skim`, `reread`, or `redo`. + +The loader should validate that affected IDs exist in the new release, except +for IDs explicitly listed in a restructuring migration. + +### Severity semantics + +| Severity | Typical version impact | Default learner interpretation | +| --- | --- | --- | +| `content-fix` | Patch | Usually skim; existing completion remains valid. | +| `new-material` | Minor | Read the affected module if it is relevant or not yet studied. | +| `rubric-change` | Minor or major | Revisit the affected question or module; completion may need review. | +| `breaking-restructure` | Major | Treat as a migration; do not silently carry completion forward. | + +Authors should use `rubric-change` when a prompt, reference, or rubric changes +the evidence expected from the learner. This matters because +`question_attempts` stores the question ID, answer, result, feedback, and +reference, but does not currently store a content version. + +### Learner presentation + +Add a course-update summary to the course and path views: + +- "Updated from v1.0.0 to v1.2.0." +- Number of changes by severity. +- Short summaries grouped by affected module. +- Explicit actions such as "skim," "reread," or "redo." +- Prominent warnings for `rubric-change` and `breaking-restructure`. +- A separate "new module" indicator for modules with no prior `section_progress` row. + +The current `section_progress.updated_at` timestamp is not sufficient to answer +"which content version did this learner study?" because it is updated by +answers and evidence writes. + +Add a proposed nullable column: + +```sql +ALTER TABLE section_progress +ADD COLUMN last_studied_version TEXT; +``` + +For the first useful implementation, update it when the learner submits an +answer or records evidence for that section. Those operations already exist in +`CourseStore.submitAnswer()`, `recordEvidence()`, and +`recordLearnerEvidence()`. A later version can add an explicit "mark as +reviewed" event. + +The app compares each section's `last_studied_version` with the current +`contentVersion` and displays only changelog entries after that version. A +changelog entry without affected IDs applies to the whole course. + +If the content hash changes but `contentVersion` does not, show an "unreleased +content change" warning rather than silently claiming that nothing changed. + +## 3. Pinning + +### Storage model + +Add a new SQLite table in `CourseStore.migrate()`: + +```sql +CREATE TABLE IF NOT EXISTS course_pins ( + course_id TEXT PRIMARY KEY, + repository TEXT NOT NULL, + requested_ref TEXT NOT NULL, + resolved_commit TEXT NOT NULL, + content_version TEXT NOT NULL, + content_hash TEXT NOT NULL, + pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +``` + +This is a new table; the existing database currently contains +`learning_paths`, `section_progress`, `question_attempts`, `activity_log`, and +`evidence`. + +The pin is course-level rather than path-level. That matches the current +`CourseCatalog` shape: it holds one `CourseDefinition` per course ID in a `Map` +and resolves paths with `catalog.get(courseId)`. All learning paths for a +course therefore see the same selected release. + +The stored `resolved_commit`, not `requested_ref`, is authoritative. For +example: + +```text +requested_ref: main +resolved_commit: 6c8f...a91 +content_version: 1.2.0 +``` + +### Sync behavior + +`CourseCatalog.loadConfigured()` should accept the active pins when +constructing the catalog. The current MCP startup creates the catalog before +the store, so startup order must change to open the store first, read pins, +and then load the catalog. + +For an unpinned course: + +- Sync the configured repository ref, normally `main`. +- Use the existing `.next` staging directory. +- Replace the cache only after the complete download succeeds. +- On failure, use the last complete branch cache, as currently documented and implemented. + +For a pinned course: + +- Download or load the exact `resolved_commit`. +- Cache it under a ref-specific directory, not the current branch-only cache directory. +- If the network fails, use the last complete cache for that exact commit. +- Never fall back from a pinned commit to `main`; that would violate the pin. + +The cache metadata should be extended from the existing `{ syncedAt }` shape to +include the resolved commit, content version, and content hash. + +### Learner workflow + +Add new store operations, clearly distinct from existing methods: + +- `pinCourse(courseId, currentOrSelectedRelease)` +- `getCoursePin(courseId)` +- `unpinCourse(courseId)` +- `listCourseChanges(courseId, pathId)` + +The UI should support: + +1. **Pin this version** — stores the currently loaded commit and version. +2. **Stay pinned** — leaves the existing row unchanged. +3. **Upgrade and keep pinned** — resolves the latest configured ref, shows its changelog, then replaces the pin with the new commit. +4. **Unpin and follow latest** — deletes the row; the next catalog load follows the configured branch. + +Unpinning or upgrading must never delete answers, evidence, or section +progress. The existing `resetPath()` is explicitly destructive and deletes the +learning path and its dependent records; unpinning must not call it. + +## 4. Impact on progress data + +### Existing progress model + +`createPath()` creates one `section_progress` row for every section in the +course at path creation time. Progress is keyed by `(path_id, section_id)`. + +Attempts and evidence also retain `section_id` and question IDs, but are not +foreign-keyed to the current Markdown definition. `nextActivity()` matches +current sections to stored progress by section ID, while +`hasEvaluatedAttempt()` considers any correct attempt with the same question +ID. + +This means an update cannot simply replace the Markdown directory. New +sections need progress rows, removed sections need historical treatment, and +changed question semantics must not accidentally inherit an old correct +answer. + +### Compatibility rule + +Authors must follow this rule: + +> A module ID and question ID are permanent compatibility identifiers. Rename +> files, titles, or prose without changing IDs when the learning contract +> remains the same. Never reuse an old ID for different learning content. + +The filename may change, but because filenames determine order, reordering +modules should be declared in the changelog. A module with the same +frontmatter `id` retains its progress even if its filename or title changes. + +### Module changes + +#### Rename or reorder + +If the module ID remains stable: + +- Keep the existing `section_progress` row. +- Keep all attempts and evidence attached to that ID. +- Reconcile the row into the new filename order. +- Show a changelog entry if the learner-facing sequence or material changed. + +If the ID changes without an explicit migration, treat it as removal plus +addition rather than guessing that the records correspond. + +#### Split or merge + +A split or merge is a `breaking-restructure` and requires an explicit +changelog migration: + +```yaml +migrations: + - fromModuleId: domain-model + toModuleIds: + - entities + - invariants + policy: review +``` + +For a split, the safe default is: + +- Preserve the old section, attempts, and evidence as historical. +- Create new `section_progress` rows for each target. +- Set targets to `revision`, even if the old module was `complete`. +- Do not duplicate old evidence into every target. + +A learner may already understand the new material, but the app cannot infer +that safely from a previous module's completion. + +For a merge, preserve the source records and create one target row with +`revision` unless an author supplies an explicit, reviewable migration rule. + +#### Removal and fallback + +Add proposed nullable archival columns: + +```sql +ALTER TABLE section_progress ADD COLUMN orphaned_at TEXT; +ALTER TABLE section_progress ADD COLUMN orphaned_in_version TEXT; +``` + +When a section ID disappears: + +- Do not delete its `section_progress` row. +- Mark it orphaned. +- Keep its attempts and evidence unchanged. +- Exclude it from current completion counts and `nextActivity()`. +- Expose it under "Retired modules" and include it in path exports. + +`PathOverview.totalSections` currently uses the current course's section +count, while completed sections are counted from progress; filtering orphaned +rows prevents removed modules from inflating completion. + +When a new section ID appears, insert a `not_started` row. If no migration map +exists, do not carry completion forward. This is the fallback for unsafe +renames, splits, merges, and removals. + +For changed question semantics, add a proposed `content_version` column to +`question_attempts`: + +```sql +ALTER TABLE question_attempts ADD COLUMN content_version TEXT; +``` + +New attempts record the active version. A `rubric-change` entry causes the +containing section to enter `revision` or display an explicit review +requirement; old answers remain historical rather than being overwritten. + +## 5. Minimal implementation path + +### V1 — smallest useful slice + +| Rank | Deliverable | Rough effort | Existing files | +| --- | --- | ---: | --- | +| 1 | Add `contentVersion`, legacy fallback, and structured `CHANGELOG.md` parsing. | 1–2 days | `src/course.ts`, `src/types.ts`, `templates/course.md`, `src/seed.ts`, `docs/course-authoring.md` | +| 2 | Store `last_studied_version`, expose changed entries, and show severity/action summaries. | 2–3 days | `src/store.ts`, `src/server.ts`, `src/mcp.ts`, `public/app.js`, `public/index.html`, `public/app.css` | +| 3 | Resolve branch refs to commit SHAs and cache exact snapshots while preserving current cache fallback. | 2–4 days | `src/course.ts`, `src/types.ts`, `docs/public-course-distribution.md` | +| 4 | Add course-level pin/unpin/upgrade state in SQLite and make catalog loading honor it. | 2–3 days | `src/store.ts`, `src/server.ts`, `src/mcp.ts`, `public/app.js` | +| 5 | Reconcile current section IDs, add rows for new modules, and archive removed IDs without deleting history. | 1–2 days | `src/store.ts`, `src/types.ts` | + +V1 should support pinning the current resolved commit and upgrading after +reviewing the changelog. It does not need GitHub tag browsing, rich diffs, or +automatic split/merge migration. + +Tests should extend the existing focused suites: + +- `test/course-pack.test.ts` for version and changelog validation. +- `test/provenance.test.ts` for commit-aware cache and fallback behavior. +- `test/store.test.ts` for pins, last-studied versions, and reconciliation. +- `test/server.test.ts` and `test/mcp.test.ts` for new operations. +- `test/seed.test.ts` for the generated `contentVersion`. + +### V2 — explicit migrations and release selection + +Add: + +- Changelog migration maps for module splits, merges, and removals. +- Automatic conversion of mapped progress into `revision`. +- Version-scoped attempt evaluation. +- Per-module "reviewed" acknowledgements instead of relying only on answer/evidence writes. +- Selection of historical releases by semver or Git tag. +- "Upgrade and keep pinned" against a selected release. +- Version and pin information in `exportPath()` output, which currently exports course identity, path, progress, attempts, and evidence. + +Primary files remain `src/course.ts`, `src/store.ts`, `src/types.ts`, +`src/server.ts`, `src/mcp.ts`, the public UI, and their tests. + +### Later + +Consider: + +- Markdown diffs between two commits. +- Per-file and per-reference impact analysis. +- Shared-reference dependency tracking across courses. +- Content hashes for external HTTPS references. +- Multiple simultaneous pinned releases for separate learning paths. +- Authoring-time verification that every content version has a matching changelog entry and migration data. +- A release browser that lists available course tags without requiring learners to know Git terminology. diff --git a/docs/mcp.md b/docs/mcp.md index 03ef34c..d4903a6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -24,6 +24,11 @@ The learner may select a different connected guide later from **AI guides**. All guides share the same local SQLite progress, so switching never loses course state or answer history. +LearnDeck reports each guide as `connected`, `stale`, `detected`, or +`not_found`. A `stale` guide has a `learndeck` entry that points at a script +path other than this clone's `src/mcp.ts` (for example after moving the +clone); reconnecting repairs only that entry. + ## Manual setup For another MCP client, use its equivalent of: @@ -56,6 +61,15 @@ for existing local runs. | `learndeck_record_evidence` | Store learner-reported code paths or command results. | | `learndeck_evaluate_answer` | Evaluate exactly one answer submitted through the UI. | +`learndeck_evaluate_answer` accepts a `result` of `correct`, `partial`, or +`incorrect`, requires feedback of at least 20 characters, and only applies to +attempts still in the `submitted` state. A `correct` result on an `exit` +question completes the module; a `partial` or `incorrect` result marks it for +revision. +Self-review is a learner action in the browser, not an MCP tool: without a +connected guide, the learner can mark a submitted answer self-reviewed and +continue. + ## Required agent behavior 1. Read the course briefing and progress before teaching. For the bundled DDD diff --git a/docs/public-course-distribution.md b/docs/public-course-distribution.md index 319f62b..d3d3611 100644 --- a/docs/public-course-distribution.md +++ b/docs/public-course-distribution.md @@ -5,6 +5,12 @@ application; public courses live in a second repository that contains only Markdown content and references. This lets educators fork, review, and publish courses without changing application code. +The GitHub-synced repository is the primary catalogue: when +`LEARNDECK_COURSE_REPOSITORY` is set, LearnDeck loads courses from it (or from +the last complete local cache when GitHub is unreachable). The Markdown packs +bundled in the app repository load only when no repository is configured — a +development fallback, not the catalogue. + ```mermaid flowchart LR A[learn-deck/learndeck] -->|loads| B[github:learn-deck/courses@main] @@ -29,8 +35,12 @@ courses/ ddd-backend-foundations/ course.md modules/ - 00-start.md + 00-start-a-path.md ... + testing-fundamentals/ + course.md + modules/ + notes/ references/ source-index.md ``` @@ -54,7 +64,9 @@ On the learner's **Start Now** action, LearnDeck reads the repository tree, downloads only allowed Markdown files to `.learndeck/course-cache/`, and loads the cache. It replaces the cache only after a full sync succeeds. If GitHub cannot be reached later, the last complete local cache remains available. If no -repository is configured, LearnDeck uses bundled local packs for development. +repository is configured, LearnDeck loads only the bundled +[`example-course`](../courses/example-course/course.md) format pack, which +keeps development and tests working offline. The release `.env.example` points to `github:learn-deck/courses@main`; copy it to `.env` for the default public catalogue. A fork may replace that value with @@ -64,6 +76,36 @@ The source is public, but learner progress is never placed in it. Answers, evidence, workspaces, and agent feedback remain in the separate local progress database. +## Contribute a course + +The **Add your course** action on the courses page links to the public course +repository. To contribute a new pack: + +1. **Fork the course repository** (`learn-deck/courses`, or the repository + your fork of the app names in `LEARNDECK_COURSE_REPOSITORY`) on GitHub. +2. **Author the pack** as `courses//course.md` plus ordered + `modules/*.md` files. Start from + [`templates/course.md`](../templates/course.md) and + [`templates/module.md`](../templates/module.md) — or run + `bun run seed -- "Course title"` in a LearnDeck clone and copy + the seeded folder — then follow the + [course-pack standard](course-authoring.md). Keep every local source a + Markdown file under `courses/` or `references/`; nothing else is synced. +3. **Meet the [catalogue quality rubric](catalogue-quality-rubric.md)**: + truthful outcomes, one working project, source-backed questions, and an + author-written rubric for each question. +4. **Test against your fork.** Point a local LearnDeck at it and take one real + path through the browser (and MCP, if a guide is connected): + + ```sh + LEARNDECK_COURSE_REPOSITORY=github:your-user/courses@your-branch bun run app + ``` + + Loading fails loudly when front matter, sources, or rubrics are invalid, so + a clean **Start Now** is the pack-level check. +5. **Open a pull request** against the course repository. A maintainer reviews + it against the rubric before it joins the default catalogue. + ## Open-source release shape Create two public repositories under the `learndeck` GitHub organisation: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 2d56d66..8fb84a2 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -91,6 +91,53 @@ file it changed. ask it to use LearnDeck. If it was not connected from the app, return to the AI guides screen and connect it first. +## The workspace path is rejected + +**Symptom:** Confirming a workspace fails with `workspacePath must be +absolute: ...`, `workspacePath parent does not exist ...`, or `workspacePath +already exists but is not a directory: ...`. + +**Cause:** When a learning path is created, `src/server.ts` requires an +absolute workspace path whose parent directory already exists. LearnDeck +creates the final folder itself when it is missing, but it never creates the +parent chain, and it refuses a path that points at an existing file. + +**Fix:** Enter an absolute path (for example `/Users/you/projects/my-course`), +create the parent folder first if needed, and pick a path that is either free +or already a directory. + +## A course pack fails to load + +**Symptom:** Startup or **Start Now** reports an error such as `... must begin +with YAML front matter fenced by --- lines.`, `... needs a non-empty .`, +`... references a missing Markdown source: ...`, `... is not a supported +LearnDeck course pack.`, or `No LearnDeck course packs found in ...`. + +**Cause:** `src/course.ts` validates every pack when it loads: `course.md` +front matter must carry `schemaVersion: 1` and the required identity, overview, +and paths fields; every module needs front matter with `id`, `title`, `goal`, +`action`, `sources`, and at least one question; and every local source or +question reference must resolve to a real `.md` file relative to its module. +One invalid pack stops the whole catalogue from loading. + +**Fix:** Read the error message — it names the file and field. Repair the pack +against [the course-pack standard](course-authoring.md), or remove the broken +folder from `courses/`, then restart the app so both the UI and MCP server +reload it. + +## An answer cannot be evaluated or self-reviewed + +**Symptom:** The agent's `learndeck_evaluate_answer` call, or the UI's +self-review action, fails with `Only submitted answers may be evaluated.` or +`Only submitted answers may be self-reviewed.` + +**Cause:** Each attempt is evaluated at most once. Once its result is +`correct`, `partial`, `incorrect`, or `self_reviewed`, it can no longer be +evaluated or self-reviewed; a revision is a new attempt. + +**Fix:** Submit a new answer in the browser and evaluate that attempt. Nothing +is lost — earlier attempts and their feedback stay in the answer history. + ## GitHub course sync is unavailable or offline **Symptom:** Starting the app reports a public repository error such as @@ -144,3 +191,40 @@ and delete `.learndeck/progress.db`; the next start creates a new empty database. Deleting it removes all local learning paths, section progress, question attempts, and activity-log records. Do not delete the file while the app is running. + +## The macOS app does not build or start + +**Symptom:** `scripts/package-macos.sh` exits with `swiftc not found` or +`bun not found`, or the built `LearnDeck.app` shows a "LearnDeck could not +start" alert. + +**Cause:** The packaging script requires macOS with the Xcode Command Line +Tools (`swiftc`) and Bun on `PATH`. The built app starts the compiled server +on a free local port and waits up to 20 seconds for `/` to answer HTTP 200; if +that fails, it shows the alert and quits. + +**Fix:** Install the missing tool (`xcode-select --install` for `swiftc`) and +rebuild with `bash scripts/package-macos.sh`. For a startup alert, read the +server log the alert names: + +```text +~/Library/Application Support/LearnDeck/server.log +``` + +The app's database and course cache also live in that folder (`progress.db` +and `course-cache/`), outside the bundle, so rebuilding or deleting +`dist/LearnDeck.app` never touches progress. + +## Connecting a guide from the macOS app stops working + +**Symptom:** A guide connected from `LearnDeck.app` reports a `stale` LearnDeck +entry, or the agent cannot start the `learndeck` MCP server. + +**Cause:** The packaged app bakes the repository path into its +`Contents/Info.plist` (`LearnDeckRoot`) at build time. The MCP entry it writes +points at that checkout's `src/mcp.ts`, which also needs the repository's +installed dependencies. Moving or deleting the clone breaks the entry. + +**Fix:** Keep the clone where it was when you built the app, or rebuild the +app from the repository's new location and reconnect the guide so the entry is +repaired. diff --git a/native/macos/LearnDeckApp.swift b/native/macos/LearnDeckApp.swift new file mode 100644 index 0000000..d3a7f03 --- /dev/null +++ b/native/macos/LearnDeckApp.swift @@ -0,0 +1,412 @@ +import AppKit +import Darwin +import Foundation +import WebKit + +private enum LaunchError: LocalizedError { + case missingResource(String) + case unableToCreateDirectory(String) + case unableToCreateLog(String) + case unableToFindPort + case unableToStartServer(String) + + var errorDescription: String? { + switch self { + case .missingResource(let resource): + return "The app bundle is missing \(resource)." + case .unableToCreateDirectory(let path): + return "LearnDeck could not create its data directory at \(path)." + case .unableToCreateLog(let path): + return "LearnDeck could not open its server log at \(path)." + case .unableToFindPort: + return "LearnDeck could not find a free local TCP port." + case .unableToStartServer(let message): + return "LearnDeck could not start its local server: \(message)" + } + } +} + +private final class WebViewCoordinator: NSObject, WKNavigationDelegate, WKUIDelegate { + private let localServerURL: URL + + init(localServerURL: URL) { + self.localServerURL = localServerURL + super.init() + } + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + guard let url = navigationAction.request.url else { + decisionHandler(.allow) + return + } + + let opensInNewWindow = navigationAction.targetFrame == nil + let isLocalServerURL = url.scheme == localServerURL.scheme + && url.host == localServerURL.host + && url.port == localServerURL.port + + if opensInNewWindow || !isLocalServerURL { + if let scheme = url.scheme?.lowercased(), ["http", "https", "mailto", "tel"].contains(scheme) { + NSWorkspace.shared.open(url) + } + decisionHandler(.cancel) + return + } + + decisionHandler(.allow) + } + + func webView( + _ webView: WKWebView, + createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures + ) -> WKWebView? { + if let url = navigationAction.request.url { + NSWorkspace.shared.open(url) + } + return nil + } +} + +private func responderSelector(_ name: String) -> Selector { + NSSelectorFromString(name) +} + +@MainActor +private final class LearnDeckAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { + private var window: NSWindow? + private var webView: WKWebView? + private var webViewCoordinator: WebViewCoordinator? + private var serverProcess: Process? + private var serverLogHandle: FileHandle? + private var serverProcessGroupIsolated = false + private var signalSources: [DispatchSourceSignal] = [] + + private var dataDirectory = URL(fileURLWithPath: NSHomeDirectory()) + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Application Support", isDirectory: true) + .appendingPathComponent("LearnDeck", isDirectory: true) + private var logURL = URL(fileURLWithPath: NSHomeDirectory()) + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Application Support", isDirectory: true) + .appendingPathComponent("LearnDeck", isDirectory: true) + .appendingPathComponent("server.log") + private var portFileURL: URL? + private var pidFileURL: URL? + + func applicationDidFinishLaunching(_ notification: Notification) { + configureMenuBar() + installSignalHandlers() + + do { + let serverURL = try launchServer() + waitForServer(at: serverURL) + } catch { + showStartupFailure(error.localizedDescription) + } + } + + func applicationWillTerminate(_ notification: Notification) { + stopServer() + signalSources.forEach { $0.cancel() } + signalSources.removeAll() + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + true + } + + private func configureMenuBar() { + let menuBar = NSMenu() + + let applicationMenuItem = NSMenuItem() + let applicationMenu = NSMenu(title: "LearnDeck") + applicationMenu.addItem(withTitle: "About LearnDeck", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "") + applicationMenu.addItem(.separator()) + applicationMenu.addItem(withTitle: "Quit LearnDeck", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + applicationMenuItem.submenu = applicationMenu + menuBar.addItem(applicationMenuItem) + + let editMenuItem = NSMenuItem() + let editMenu = NSMenu(title: "Edit") + editMenu.addItem(withTitle: "Undo", action: responderSelector("undo:"), keyEquivalent: "z") + let redoItem = editMenu.addItem(withTitle: "Redo", action: responderSelector("redo:"), keyEquivalent: "Z") + redoItem.keyEquivalentModifierMask = [.command, .shift] + editMenu.addItem(.separator()) + editMenu.addItem(withTitle: "Cut", action: responderSelector("cut:"), keyEquivalent: "x") + editMenu.addItem(withTitle: "Copy", action: responderSelector("copy:"), keyEquivalent: "c") + editMenu.addItem(withTitle: "Paste", action: responderSelector("paste:"), keyEquivalent: "v") + editMenu.addItem(withTitle: "Select All", action: responderSelector("selectAll:"), keyEquivalent: "a") + editMenuItem.submenu = editMenu + menuBar.addItem(editMenuItem) + + let windowMenuItem = NSMenuItem() + let windowMenu = NSMenu(title: "Window") + windowMenu.addItem(withTitle: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m") + windowMenu.addItem(withTitle: "Zoom", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "") + windowMenu.addItem(withTitle: "Bring All to Front", action: #selector(NSApplication.arrangeInFront(_:)), keyEquivalent: "") + windowMenuItem.submenu = windowMenu + menuBar.addItem(windowMenuItem) + + NSApp.mainMenu = menuBar + NSApp.windowsMenu = windowMenu + } + + private func installSignalHandlers() { + for signalNumber in [SIGTERM, SIGINT, SIGHUP] { + Darwin.signal(signalNumber, SIG_IGN) + let source = DispatchSource.makeSignalSource(signal: signalNumber, queue: .main) + source.setEventHandler { [weak self] in + self?.stopServer() + NSApp.terminate(nil) + } + source.resume() + signalSources.append(source) + } + } + + private func launchServer() throws -> URL { + let fileManager = FileManager.default + let applicationSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Application Support", isDirectory: true) + dataDirectory = applicationSupport.appendingPathComponent("LearnDeck", isDirectory: true) + logURL = dataDirectory.appendingPathComponent("server.log") + portFileURL = dataDirectory.appendingPathComponent("server.port") + pidFileURL = dataDirectory.appendingPathComponent("server.pid") + + do { + try fileManager.createDirectory(at: dataDirectory, withIntermediateDirectories: true) + } catch { + throw LaunchError.unableToCreateDirectory(dataDirectory.path) + } + + guard let resourceURL = Bundle.main.resourceURL else { + throw LaunchError.missingResource("Contents/Resources") + } + let payloadURL = resourceURL.appendingPathComponent("learndeck", isDirectory: true) + let serverExecutableURL = payloadURL.appendingPathComponent("learndeck-server") + guard fileManager.fileExists(atPath: serverExecutableURL.path) else { + throw LaunchError.missingResource("Contents/Resources/learndeck/learndeck-server") + } + + guard let packageRoot = Bundle.main.object(forInfoDictionaryKey: "LearnDeckRoot") as? String, + !packageRoot.isEmpty else { + throw LaunchError.missingResource("LearnDeckRoot in Contents/Info.plist") + } + + let port = try Self.findFreePort() + let portURL = dataDirectory.appendingPathComponent("server.port") + let pidURL = dataDirectory.appendingPathComponent("server.pid") + try? fileManager.removeItem(at: portURL) + try? fileManager.removeItem(at: pidURL) + try String(port).write(to: portURL, atomically: true, encoding: .utf8) + + do { + try Data().write(to: logURL, options: .atomic) + } catch { + throw LaunchError.unableToCreateLog(logURL.path) + } + guard let logHandle = FileHandle(forWritingAtPath: logURL.path) else { + throw LaunchError.unableToCreateLog(logURL.path) + } + logHandle.seekToEndOfFile() + + var environment = ProcessInfo.processInfo.environment + environment["LEARNDECK_PUBLIC_DIR"] = payloadURL.appendingPathComponent("public", isDirectory: true).path + environment["LEARNDECK_COURSES_DIR"] = payloadURL.appendingPathComponent("courses", isDirectory: true).path + environment["LEARNDECK_DB_PATH"] = dataDirectory.appendingPathComponent("progress.db").path + environment["LEARNDECK_COURSE_CACHE_DIR"] = dataDirectory.appendingPathComponent("course-cache", isDirectory: true).path + environment["LEARNDECK_ROOT"] = packageRoot + environment["PORT"] = String(port) + + let process = Process() + process.executableURL = serverExecutableURL + process.currentDirectoryURL = payloadURL + process.environment = environment + process.standardOutput = logHandle + process.standardError = logHandle + + do { + try process.run() + } catch { + try? logHandle.close() + throw LaunchError.unableToStartServer(error.localizedDescription) + } + + serverProcess = process + serverLogHandle = logHandle + let processID = process.processIdentifier + serverProcessGroupIsolated = Darwin.setpgid(processID, processID) == 0 + try? String(processID).write(to: pidURL, atomically: true, encoding: .utf8) + + return URL(string: "http://127.0.0.1:\(port)/")! + } + + private func waitForServer(at url: URL) { + Task { [weak self] in + let ready = await Self.serverIsReady(at: url) + guard let self else { return } + if ready { + self.showWindow(for: url) + } else { + self.showStartupFailure("The server did not return HTTP 200 from / before the startup timeout.") + } + } + } + + private nonisolated static func serverIsReady(at url: URL) async -> Bool { + let deadline = Date().addingTimeInterval(20) + while Date() < deadline { + var request = URLRequest(url: url) + request.timeoutInterval = 1 + do { + let (_, response) = try await URLSession.shared.data(for: request) + if (response as? HTTPURLResponse)?.statusCode == 200 { + return true + } + } catch { + // The server may still be binding its port; keep polling until the deadline. + } + try? await Task.sleep(nanoseconds: 250_000_000) + } + return false + } + + private func showWindow(for serverURL: URL) { + let configuration = WKWebViewConfiguration() + let coordinator = WebViewCoordinator(localServerURL: serverURL) + let view = WKWebView(frame: .zero, configuration: configuration) + view.navigationDelegate = coordinator + view.uiDelegate = coordinator + view.autoresizingMask = [.width, .height] + + let newWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1280, height: 860), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + newWindow.title = "LearnDeck" + newWindow.minSize = NSSize(width: 980, height: 700) + newWindow.contentView = view + newWindow.delegate = self + newWindow.isReleasedWhenClosed = false + newWindow.center() + newWindow.makeKeyAndOrderFront(nil) + + webViewCoordinator = coordinator + webView = view + window = newWindow + view.load(URLRequest(url: serverURL)) + NSApp.activate(ignoringOtherApps: true) + } + + private func showStartupFailure(_ detail: String) { + stopServer() + + let alert = NSAlert() + alert.alertStyle = .critical + alert.messageText = "LearnDeck could not start" + alert.informativeText = "\(detail)\n\nServer log: \(logURL.path)" + alert.addButton(withTitle: "Quit") + alert.runModal() + NSApp.terminate(nil) + } + + private func stopServer() { + guard let process = serverProcess else { + cleanupServerFiles() + return + } + + let processID = process.processIdentifier + if process.isRunning { + if serverProcessGroupIsolated { + _ = Darwin.kill(-processID, SIGTERM) + } + process.terminate() + + let deadline = Date().addingTimeInterval(2) + while process.isRunning && Date() < deadline { + usleep(50_000) + } + if process.isRunning { + if serverProcessGroupIsolated { + _ = Darwin.kill(-processID, SIGKILL) + } + _ = Darwin.kill(processID, SIGKILL) + } + } + process.waitUntilExit() + serverProcess = nil + serverProcessGroupIsolated = false + + try? serverLogHandle?.close() + serverLogHandle = nil + cleanupServerFiles() + } + + private func cleanupServerFiles() { + if let portFileURL { + try? FileManager.default.removeItem(at: portFileURL) + } + if let pidFileURL { + try? FileManager.default.removeItem(at: pidFileURL) + } + portFileURL = nil + pidFileURL = nil + } + + private nonisolated static func findFreePort() throws -> Int { + let socketDescriptor = Darwin.socket(AF_INET, SOCK_STREAM, 0) + guard socketDescriptor >= 0 else { + throw LaunchError.unableToFindPort + } + defer { Darwin.close(socketDescriptor) } + + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = in_port_t(0).bigEndian + address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + + let bindResult = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketAddress in + Darwin.bind(socketDescriptor, socketAddress, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { + throw LaunchError.unableToFindPort + } + + var assignedAddress = sockaddr_in() + var addressLength = socklen_t(MemoryLayout.size) + let nameResult = withUnsafeMutablePointer(to: &assignedAddress) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketAddress in + Darwin.getsockname(socketDescriptor, socketAddress, &addressLength) + } + } + guard nameResult == 0 else { + throw LaunchError.unableToFindPort + } + + return Int(UInt16(bigEndian: assignedAddress.sin_port)) + } +} + +@main +@MainActor +private struct LearnDeckApp { + static func main() { + let application = NSApplication.shared + let delegate = LearnDeckAppDelegate() + application.delegate = delegate + application.setActivationPolicy(.regular) + application.run() + } +} diff --git a/public/app.css b/public/app.css index 1ef0487..52be26b 100644 --- a/public/app.css +++ b/public/app.css @@ -117,6 +117,7 @@ button[type="submit"]:hover, #start-course:hover, #connect-selected-guides:hover .integration-connect[disabled] { cursor: default; opacity: .45; transform: none; } #connect-selected-guides[disabled] { cursor: default; opacity: .45; transform: none; } .form-note { margin: 0; color: var(--color-content-secondary); font-size: .8rem; line-height: 1.55; } +.form-note.is-error { color: var(--color-feedback-error); } /* The initial view is a complete, quiet screen. The course library is a separate mode. */ .app-home { min-height: calc(100dvh - 88px); } @@ -144,6 +145,14 @@ body:has(#welcome-screen:not(.hidden)) .welcome-screen { min-height: 100dvh; } .category-filters button:hover { color: var(--color-content-primary); border-color: var(--color-border-strong); } .category-filters button[aria-pressed="true"] { color: var(--color-interactive-contrast); background: var(--color-interactive); border-color: var(--color-interactive); } .course-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); gap: var(--space-md); } +.course-grid > .empty { grid-column: 1 / -1; margin: var(--space-md) 0; font-size: .9rem; } +#library-status { margin-top: var(--space-md); } +#library-status:empty { display: none; } +.library-contribute { display: grid; gap: var(--space-sm); justify-items: start; margin-top: var(--space-xl); padding: var(--space-lg); background: var(--color-surface-raised); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-sm); } +.library-contribute p { max-width: 78ch; margin: 0; color: var(--color-content-secondary); font-size: .85rem; line-height: 1.55; } +.library-contribute strong { color: var(--color-content-primary); } +.library-contribute code { padding: 2px 5px; background: var(--color-surface-subtle); border-radius: 4px; font-family: var(--font-mono); font-size: .8em; } +.library-contribute a { font-size: .82rem; font-weight: 700; } .course-card { display: flex; flex-direction: column; align-items: stretch; min-height: 292px; padding: var(--space-lg); color: var(--color-content-primary); background: var(--color-surface-raised); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-sm); text-align: left; transition: transform var(--transition-fast), border-color var(--transition-fast), background var(--transition-fast); } .course-card:hover { background: color-mix(in srgb, var(--color-surface-raised) 88%, var(--color-interactive)); border-color: color-mix(in srgb, var(--color-interactive) 55%, var(--color-border-strong)); transform: translateY(-2px); } .course-card-top, .course-card-meta, .course-card-tags { display: flex; flex-wrap: wrap; gap: var(--space-sm); } @@ -158,18 +167,10 @@ body:has(#welcome-screen:not(.hidden)) .welcome-screen { min-height: 100dvh; } .course-card-action { display: flex; justify-content: space-between; align-items: center; margin-top: var(--space-lg); padding-top: var(--space-md); color: var(--color-interactive); border-top: 1px solid var(--color-border-subtle); font-size: .8rem; font-weight: 750; } .course-card-action b { font-size: 1rem; } -/* First start makes its effects and AI-host changes legible before a course begins. */ -.agent-setup { display: grid; grid-template-columns: minmax(0, 1fr) minmax(360px, .72fr); gap: clamp(var(--space-xl), 10vw, 160px); align-items: center; min-height: calc(100vh - 88px); padding: clamp(72px, 10vw, 148px) 0; } -.agent-setup-copy { max-width: 680px; } -.agent-setup-copy h1 { max-width: 11ch; margin: var(--space-md) 0 var(--space-lg); font-size: clamp(2.9rem, 5.65vw, 5.25rem); line-height: 1.01; letter-spacing: -.062em; } -.agent-setup-copy > p:not(.eyebrow) { max-width: 56ch; margin: 0; color: var(--color-content-secondary); font-size: 1.08rem; line-height: 1.68; } -.activation-steps { display: grid; gap: 0; margin: var(--space-xl) 0 0; padding: 0; list-style: none; border-top: 1px solid var(--color-border-subtle); } -.activation-steps li { display: grid; grid-template-columns: 32px 1fr; gap: var(--space-sm); padding: var(--space-md) 0; border-bottom: 1px solid var(--color-border-subtle); } -.activation-steps li > span { color: var(--color-interactive); font-family: var(--font-mono); font-size: .7rem; font-weight: 700; line-height: 1.8; } -.activation-steps strong { color: var(--color-content-primary); font-size: .88rem; } -.activation-steps p { margin: 2px 0 0; color: var(--color-content-secondary); font-size: .82rem; line-height: 1.45; } -.agent-setup-card { padding: clamp(var(--space-lg), 4vw, var(--space-xl)); background: var(--color-surface-raised); border: 1px solid var(--color-border-strong); border-top: 3px solid var(--color-interactive); box-shadow: var(--shadow-raised); } -.agent-setup-card h2 { margin: var(--space-sm) 0; font-size: clamp(1.9rem, 3.2vw, 2.55rem); line-height: 1.08; letter-spacing: -.04em; } +/* First start is a single concise decision: connect a guide, or skip. */ +.agent-setup { display: grid; place-items: center; min-height: calc(100vh - 88px); padding: clamp(48px, 8vw, 96px) 0; } +.agent-setup-card { width: min(560px, 100%); padding: clamp(var(--space-lg), 4vw, var(--space-xl)); background: var(--color-surface-raised); border: 1px solid var(--color-border-strong); border-top: 3px solid var(--color-interactive); box-shadow: var(--shadow-raised); } +.agent-setup-card h1 { margin: var(--space-sm) 0; font-size: clamp(1.9rem, 3.2vw, 2.55rem); line-height: 1.08; letter-spacing: -.04em; } .agent-setup-card > p:not(.eyebrow):not(.form-note) { margin: 0; color: var(--color-content-secondary); font-size: .88rem; line-height: 1.55; } .agent-setup-list { display: grid; margin: var(--space-lg) 0 0; border-top: 1px solid var(--color-border-subtle); } .guide-setup-option { display: grid; grid-template-columns: 1fr auto; gap: var(--space-sm); align-items: center; padding: var(--space-md) 0; border-bottom: 1px solid var(--color-border-subtle); } @@ -422,7 +423,6 @@ body.is-answering .lesson-progress { top: 0; } .library-heading { align-items: flex-start; flex-direction: column; } .library-actions { width: 100%; justify-content: space-between; } .briefing-intro h1, .workspace-setup h1 { font-size: clamp(2.65rem, 13vw, 3.8rem); } - .agent-setup-copy h1 { font-size: clamp(2.65rem, 13vw, 3.8rem); } .course-facts { grid-template-columns: 1fr; } .course-facts > div + div { padding-left: 0; border-top: 1px solid var(--color-border-subtle); border-left: 0; } .brief-roadmap { grid-template-columns: 1fr; } diff --git a/public/app.js b/public/app.js index 29a022d..b8a569d 100644 --- a/public/app.js +++ b/public/app.js @@ -24,15 +24,25 @@ const $ = (selector) => document.querySelector(selector); const escape = (value) => String(value).replace(/[&<>"]/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[character]); async function api(path, options) { - const response = await fetch(path, { - ...options, - headers: { "content-type": "application/json", ...(options?.headers ?? {}) }, - }); - const body = await response.json(); - if (!response.ok) throw new Error(body.error ?? "LearnDeck could not complete that request."); + let response; + try { + response = await fetch(path, { + ...options, + headers: { "content-type": "application/json", ...(options?.headers ?? {}) }, + }); + } catch { + throw new Error("The local LearnDeck server is not responding. Start it with `bun run app`, then try again."); + } + const body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error([body.error ?? "LearnDeck could not complete that request.", body.userAction].filter(Boolean).join(" ")); return body; } +function setNote(element, message, isError = false) { + element.textContent = message; + element.classList.toggle("is-error", isError); +} + async function boot() { try { initializeTheme(); @@ -89,7 +99,7 @@ function bindEvents() { if (button?.dataset.guideId) setActiveGuide(button.dataset.guideId); }); $("#connect-selected-guides").addEventListener("click", connectSelectedGuides); - $("#continue-to-library").addEventListener("click", () => { setActiveGuide("none"); showLibrary(); }); + $("#continue-to-library").addEventListener("click", () => { ensureActiveGuide(); updateGuideButton(); showLibrary(); }); $("#continue-without-guide").addEventListener("click", continueWithoutGuide); $("#path-form").addEventListener("submit", createPath); $("#change-workspace").addEventListener("click", showWorkspaceSetup); @@ -223,13 +233,15 @@ async function connectIntegration(id) { persistActiveGuide(); renderIntegrations(); renderAgentSetup(); + updateGuideButton(); $("#integration-status").textContent = `${integration.label} connected. Config file changed: ${result.configPath}. ${result.nextStep}`; $("#agent-setup-status").textContent = `${integration.label} connected. Config file changed: ${result.configPath}. ${result.nextStep}`; focusSurface("#integration-status"); } catch (error) { $("#integration-status").textContent = error.message; + $("#agent-setup-status").textContent = error.message; button.disabled = false; - button.textContent = "Connect"; + button.textContent = integration.status === "stale" ? "Reconnect" : "Connect"; } } @@ -246,6 +258,7 @@ async function disconnectIntegration(id) { } renderIntegrations(); renderAgentSetup(); + updateGuideButton(); const message = result.message || `Disconnected ${integration.label}. File changed: ${result.configPath}.`; $("#integration-status").textContent = `${message} No active guide is selected.`; $("#agent-setup-status").textContent = message; @@ -365,6 +378,7 @@ function renderAgentSetup() { const options = $("#active-guide-options"); const configured = state.integrations.filter((integration) => integration.status === "connected"); + options.classList.toggle("hidden", !configured.length); options.replaceChildren(); const copy = document.createElement("p"); copy.className = "active-guide-copy"; @@ -394,9 +408,9 @@ function renderAgentSetup() { const selected = selectable.filter((integration) => state.guideSelection.has(integration.id)); connect.innerHTML = selected.length ? `Connect ${selected.length === 1 ? selected[0].label : `${selected.length} selected guides`} ` : "Connect selected guides"; $("#agent-setup-status").textContent = state.guideSetupMessage || (activeGuideIntegration() - ? `${activeGuideIntegration().label} is selected. Add another guide or choose No active guide; connected guides share local progress.` - : selectable.length ? "Choose any detected guide, or choose No active guide. Connecting changes only LearnDeck's local MCP entry." - : "No active guide is selected. You can continue now and connect one later."); + ? `${activeGuideIntegration().label} is selected. Connected guides share local progress.` + : selectable.length ? "Pick any detected guide, or skip and connect one later. Connecting changes only LearnDeck's local MCP entry." + : "No guides detected on this Mac yet. You can skip for now and connect one later."); } async function connectSelectedGuides() { @@ -416,7 +430,11 @@ async function connectSelectedGuides() { failures.push(`${integration.label}: ${error.message}`); } } - state.integrations = await api("/api/integrations"); + try { + state.integrations = await api("/api/integrations"); + } catch (error) { + failures.push(`Guide status refresh failed: ${error.message}`); + } state.guideSelection.clear(); if (connected.length) { state.activeGuideId = connected[0].id; @@ -430,15 +448,27 @@ async function connectSelectedGuides() { renderAgentSetup(); renderIntegrations(); updateGuideButton(); + if (connected.length && !failures.length) { + state.guideSetupMessage = ""; + showLibrary(); + setNote($("#library-status"), `${connected.map((result) => result.label).join(" and ")} connected. ${connected.map((result) => result.nextStep).join(" ")}`); + return; + } focusSurface("#agent-setup-status"); } -function continueToCourse() { - if (state.paths.length) { - selectPath(state.paths[0].id); - return; +async function continueToCourse() { + const button = $("#start-course"); + button.disabled = true; + try { + if (state.paths.length) await selectPath(state.paths[0].id); + else showWorkspaceSetup(); + } catch (error) { + $("#brief-resume-note").textContent = `Could not open the course: ${error.message}`; + focusSurface("#brief-resume-note"); + } finally { + button.disabled = false; } - showWorkspaceSetup(); } function showHome() { @@ -465,6 +495,7 @@ function showLibrary() { $("#home").classList.remove("hidden"); $("#welcome-screen").classList.add("hidden"); $("#course-library").classList.remove("hidden"); + setNote($("#library-status"), ""); renderHome(); focusSurface("#library-title"); } @@ -506,9 +537,13 @@ async function startLearnDeck() { state.guideSelection = new Set(state.integrations.filter((integration) => integration.status === "detected").map((integration) => integration.id)); ensureActiveGuide(); updateGuideButton(); - showAgentSetup(); + button.disabled = false; + button.innerHTML = "Browse courses →"; + $("#start-status").textContent = "LearnDeck is ready. Your progress stays on this Mac."; + if (state.integrations.some((integration) => integration.status === "connected")) showLibrary(); + else showAgentSetup(); } catch (error) { - $("#start-status").textContent = error.message; + $("#start-status").textContent = `${error.message} Then press the button to try again.`; button.disabled = false; button.innerHTML = "Try starting again →"; } @@ -547,11 +582,15 @@ async function selectCourse(courseId) { } async function openCourse(courseId) { + const status = $("#library-status"); try { + setNote(status, "Opening course…"); await selectCourse(courseId); + setNote(status, ""); showCourseBriefing(); } catch (error) { - alert(error.message); + setNote(status, `Could not open this course: ${error.message} Go back and reopen the library to refresh it.`, true); + focusSurface("#library-status"); } } @@ -570,6 +609,15 @@ function renderHome() { const visibleCourses = state.category === "All" ? state.courses : state.courses.filter((course) => course.category === state.category); $("#library-count").textContent = `${visibleCourses.length} ${visibleCourses.length === 1 ? "course" : "courses"}`; const grid = $("#course-grid"); + if (!visibleCourses.length) { + const empty = document.createElement("p"); + empty.className = "empty"; + empty.textContent = state.courses.length + ? "No courses in this category yet. Pick another category above." + : "No courses are available yet. Check the catalogue configuration, restart LearnDeck, or contribute one below."; + grid.replaceChildren(empty); + return; + } grid.replaceChildren(...visibleCourses.map((course) => courseCard(course))); } @@ -586,10 +634,10 @@ function renderCatalogueProvenance() { } const synced = catalogue.syncedAt ? formatCatalogueTime(catalogue.syncedAt) : ""; label.textContent = catalogue.source === "bundled" - ? "Bundled local courses" + ? "Bundled course packs · offline fallback" : catalogue.source === "live" - ? synced ? `Live catalogue · synced ${synced}` : "Live catalogue" - : "Cached catalogue"; + ? synced ? `GitHub catalogue · synced ${synced}` : "GitHub catalogue" + : "GitHub catalogue · cached copy"; warning.textContent = catalogue.warning || ""; warning.classList.toggle("hidden", !catalogue.warning); panel.classList.remove("hidden"); @@ -653,12 +701,16 @@ function renderWorkspaceSetup() { const coursePath = defaultCoursePath(); $("#setup-course-title").textContent = state.course.title; $("#setup-server-command").textContent = coursePath.serverCommand || "Use the course instructions"; - $("#workspace").placeholder = coursePath.workspaceHint || "../ddd-backend"; + $("#workspace").placeholder = coursePath.workspaceHint || "../my-workspace"; } async function createPath(event) { event.preventDefault(); const form = new FormData(event.currentTarget); + const submit = event.currentTarget.querySelector("button[type=submit]"); + const status = $("#path-form-status"); + submit.disabled = true; + setNote(status, "Preparing your workspace…"); try { const workspacePath = String(form.get("workspacePath") || "").trim(); const path = await api(`/api/courses/${encodeURIComponent(state.course.id)}/paths`, { @@ -666,13 +718,16 @@ async function createPath(event) { body: JSON.stringify({ coursePathId: defaultCoursePath().id, workspacePath, - label: "My DDD backend", + label: "My course workspace", }), }); state.paths = await api(`/api/courses/${encodeURIComponent(state.course.id)}/paths`); + setNote(status, ""); await selectPath(path.id); } catch (error) { - alert(error.message); + setNote(status, `${error.message} Use an absolute path to a folder whose parent already exists, then try again.`, true); + } finally { + submit.disabled = false; } } @@ -752,12 +807,19 @@ function renderLesson() { $("#sources-list").innerHTML = section.sources.map((source) => source.startsWith("http") ? `${escape(source)}` : `${escape(source.split("/").at(-1))}`).join(""); - $("#question-kind").textContent = `${question.kind} question`; - $("#question-reference").textContent = question.reference; - $("#question-prompt").textContent = question.prompt; - $("#answer").value = ""; - state.answerDirty = false; - $("#answer-form").dataset.questionId = question.id; + const answerForm = $("#answer-form"); + answerForm.classList.toggle("hidden", !question); + if (question) { + $("#question-kind").textContent = `${question.kind} question`; + $("#question-reference").textContent = question.reference; + $("#question-prompt").textContent = question.prompt; + if (answerForm.dataset.questionId !== question.id) { + $("#answer").value = ""; + state.answerDirty = false; + setNote($("#answer-status"), ""); + } + answerForm.dataset.questionId = question.id; + } renderAttempts(section.id); renderEvidence(section.id); observeLessonProgress(index); @@ -857,7 +919,7 @@ function renderEvidence(sectionId) { const time = document.createElement("time"); if (record.recordedAt) { time.dateTime = record.recordedAt; - time.textContent = record.recordedAt; + time.textContent = formatCatalogueTime(record.recordedAt); } heading.append(source, time); const note = document.createElement("p"); @@ -876,9 +938,13 @@ function renderEvidence(sectionId) { async function recordEvidence(event) { event.preventDefault(); const form = event.currentTarget; - const note = String(new FormData(form).get("note") || "").trim(); - const ref = String(new FormData(form).get("ref") || "").trim(); + const data = new FormData(form); + const note = String(data.get("note") || "").trim(); + const ref = String(data.get("ref") || "").trim(); const status = $("#evidence-status"); + const submit = form.querySelector("button[type=submit]"); + submit.disabled = true; + setNote(status, "Recording evidence…"); try { await api(`/api/paths/${encodeURIComponent(state.pathId)}/evidence`, { method: "POST", @@ -887,11 +953,13 @@ async function recordEvidence(event) { state.overview = await api(`/api/paths/${encodeURIComponent(state.pathId)}/overview`); form.reset(); render(); - status.textContent = "Learner evidence recorded."; + setNote(status, "Learner evidence recorded."); focusSurface("#evidence-status"); } catch (error) { - status.textContent = `Evidence could not be recorded: ${error.message}`; + setNote(status, `Evidence could not be recorded: ${error.message} Your note is still in the form — try again.`, true); focusSurface("#evidence-status"); + } finally { + submit.disabled = false; } } @@ -1159,7 +1227,11 @@ function persistEmbeddedControl(event) { async function submitAnswer(event) { event.preventDefault(); - const questionId = event.currentTarget.dataset.questionId; + const form = event.currentTarget; + const questionId = form.dataset.questionId; + const submit = form.querySelector("button[type=submit]"); + submit.disabled = true; + setNote($("#answer-status"), "Submitting your answer…"); try { const attempt = await api("/api/attempts", { method: "POST", @@ -1171,23 +1243,36 @@ async function submitAnswer(event) { }), }); state.overview = await api(`/api/paths/${encodeURIComponent(state.pathId)}/overview`); + $("#answer").value = ""; state.answerDirty = false; + setNote($("#answer-status"), ""); state.statusMessage = attempt.result === "submitted" ? "Submitted — waiting for optional guide feedback" : `Answer ${attempt.result}.`; render(); focusSurface("#progress-summary"); } catch (error) { - alert(error.message); + setNote($("#answer-status"), `Your answer was not submitted: ${error.message} It is still in the box — try again.`, true); + } finally { + submit.disabled = false; } } boot(); setInterval(async () => { - if (!state.pathId || state.answerDirty) return; + if (!state.pathId || state.answerDirty || document.hidden || $("#course").classList.contains("hidden")) return; + // Skip while the learner is interacting with lesson controls so a re-render never steals focus mid-typing. + const active = document.activeElement; + if (active && active.closest("#course") && active.matches("input, textarea, select, button")) return; + const pathId = state.pathId; + const before = state.overview; try { - state.overview = await api(`/api/paths/${encodeURIComponent(state.pathId)}/overview`); + const overview = await api(`/api/paths/${encodeURIComponent(pathId)}/overview`); + // Discard stale responses: the path changed or another action refreshed the overview mid-flight. + if (state.pathId !== pathId || state.overview !== before || state.answerDirty) return; + if (JSON.stringify(overview) === JSON.stringify(before)) return; + state.overview = overview; render(); } catch { // A local server restart should not interrupt an answer the learner is writing. diff --git a/public/index.html b/public/index.html index f8bce91..d71d243 100644 --- a/public/index.html +++ b/public/index.html @@ -57,41 +57,36 @@

Your App For Learning With AI

@@ -108,7 +103,7 @@

-

+

Connect an AI guide optional @@ -150,14 +145,15 @@

Choose the folder where you’ll build.

Where will this backend live?

- +

Use a folder separate from LearnDeck. We only store this location with your local course progress.

+

-