diff --git a/.github/workflows/info.yml b/.github/workflows/info.yml index 16929bdf..cf9c48fa 100644 --- a/.github/workflows/info.yml +++ b/.github/workflows/info.yml @@ -45,7 +45,7 @@ jobs: . + [{"vercel_project_name": env.DESIGN_PROJECT}] else . end | if ($p | contains(["@ageorgedev/game-tools"])) then - . + [{"vercel_project_name": env.GAME_TOOLS_PROJECT}] + . + [{"vercel_project_name": env.GAME_TOOLS_PROJECT, "e2e_project": "@ageorgedev/game-tools-e2e"}] else . end ') echo "production_deploy_matrix=$matrix" >> "$GITHUB_OUTPUT" @@ -66,8 +66,8 @@ jobs: if ($p | contains(["@ageorgedev/design-docs"])) then . + [{"vercel_project_name": env.DESIGN_PROJECT, "label": "Design System"}] else . end | - if ($p | contains(["@ageorgedev/game-tools"])) then - . + [{"vercel_project_name": env.GAME_TOOLS_PROJECT, "label": "Game Tools"}] + if (($p | contains(["@ageorgedev/game-tools"])) or ($p | contains(["@ageorgedev/game-tools-e2e"]))) then + . + [{"vercel_project_name": env.GAME_TOOLS_PROJECT, "e2e_project": "@ageorgedev/game-tools-e2e", "label": "Game Tools"}] else . end ') echo "pr_deploy_matrix=$matrix" >> "$GITHUB_OUTPUT" diff --git a/apps/game-tools-e2e/.gitignore b/apps/game-tools-e2e/.gitignore new file mode 100644 index 00000000..87364a27 --- /dev/null +++ b/apps/game-tools-e2e/.gitignore @@ -0,0 +1 @@ +test-results diff --git a/apps/game-tools-e2e/package.json b/apps/game-tools-e2e/package.json new file mode 100644 index 00000000..7b695ff6 --- /dev/null +++ b/apps/game-tools-e2e/package.json @@ -0,0 +1,11 @@ +{ + "name": "@ageorgedev/game-tools-e2e", + "version": "0.0.1", + "scripts": { + "e2e": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.60.0", + "concurrently": "10.0.3" + } +} diff --git a/apps/game-tools-e2e/playwright.config.ts b/apps/game-tools-e2e/playwright.config.ts new file mode 100644 index 00000000..58378492 --- /dev/null +++ b/apps/game-tools-e2e/playwright.config.ts @@ -0,0 +1,14 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + use: { + baseURL: process.env.BASE_URL ?? 'http://localhost:3001', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/apps/game-tools-e2e/tests/smoke.spec.ts b/apps/game-tools-e2e/tests/smoke.spec.ts new file mode 100644 index 00000000..a4879b4d --- /dev/null +++ b/apps/game-tools-e2e/tests/smoke.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from '@playwright/test'; + +test('home page renders', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('heading', { name: 'Game Tools' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Characters' })).toBeVisible(); +}); + +test('character list page renders', async ({ page }) => { + await page.goto('/dnd/characters'); + await expect(page.getByRole('heading', { name: 'Characters' })).toBeVisible(); + await expect(page.locator('body')).not.toBeEmpty(); +}); + +test('claw character sheet renders', async ({ page }) => { + await page.goto('/dnd/characters/claw'); + await expect(page.getByRole('heading', { name: 'Claw' })).toBeVisible(); +}); diff --git a/apps/game-tools-e2e/tsconfig.json b/apps/game-tools-e2e/tsconfig.json new file mode 100644 index 00000000..41b204e9 --- /dev/null +++ b/apps/game-tools-e2e/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@ageorgedev/ts-config/base.json", + "include": ["tests", "playwright.config.ts"], + "exclude": ["node_modules", "test-results"], + "compilerOptions": { + "outDir": "dist", + "types": ["node"] + } +} diff --git a/openspec/changes/extract-public-layout-components/.openspec.yaml b/openspec/changes/archive/2026-07-03-add-game-tools-e2e-smoke/.openspec.yaml similarity index 100% rename from openspec/changes/extract-public-layout-components/.openspec.yaml rename to openspec/changes/archive/2026-07-03-add-game-tools-e2e-smoke/.openspec.yaml diff --git a/openspec/changes/archive/2026-07-03-add-game-tools-e2e-smoke/design.md b/openspec/changes/archive/2026-07-03-add-game-tools-e2e-smoke/design.md new file mode 100644 index 00000000..a8cd53aa --- /dev/null +++ b/openspec/changes/archive/2026-07-03-add-game-tools-e2e-smoke/design.md @@ -0,0 +1,55 @@ +## Context + +The main site (`apps/ageorgedev`) has a dedicated Playwright e2e app at `apps/ageorgedev-e2e` that runs against Vercel preview deployments via the shared `run-e2e` composite action. The `game-tools` app has no equivalent — routes can silently break on deploy without any signal. + +The existing e2e pattern is intentionally lightweight: a separate app, `chromium`-only, no `webServer` config (CI drives it against a deployed `BASE_URL`, developers start dev servers manually). The CI orchestration lives in `.github/workflows/info.yml`, which builds a matrix of `{ vercel_project_name, e2e_project }` entries; the deploy workflow consumes that matrix and, when `e2e_project` is set, invokes the `run-e2e` action against the deployed URL. + +## Goals / Non-Goals + +**Goals:** +- Provide fast smoke coverage that game-tools boots and its main routes render. +- Reuse the existing CI plumbing (composite action, matrix pattern, `BASE_URL` env) with zero new infrastructure. +- Detect regressions on PR previews before merge, matching the ageorgedev flow. + +**Non-Goals:** +- Deep functional testing of individual character sheets (stats, spellbooks, print layouts). +- Visual regression testing. +- Cross-browser coverage — chromium-only mirrors the existing standard. +- Testing every character sheet; one representative sheet (Claw) suffices for smoke. + +## Decisions + +**Separate `apps/game-tools-e2e` app (not colocated in `apps/game-tools`).** +Matches the ageorgedev convention exactly. Colocation would require entangling Playwright deps with the app's runtime deps and complicate the CI matrix (the matrix keys off e2e project name). Alternative rejected: colocation adds inconsistency for no gain. + +**Chromium-only, no `webServer` config.** +Mirrors `apps/ageorgedev-e2e/playwright.config.ts`. CI always targets a deployed `BASE_URL`; the composite action handles browser install. A `webServer` block would slow local runs and diverge from the existing pattern. Alternative rejected: adding `webServer` for developer convenience — the `yarn turbo dev` step is already the standard local workflow. + +**Default `BASE_URL` to `http://localhost:3001`.** +Game-tools runs on port 3001 (per `CLAUDE.md` and app config). Matches the "default to local dev port" pattern from `apps/ageorgedev-e2e`. + +**Content-based assertions, not `body not empty`.** +The ageorgedev smoke suite uses specific heading matches for the home page and looser `body not empty` for less-critical pages. For game-tools, the character sheets *are* the main feature — a blank render that returns HTTP 200 would falsely pass a `body not empty` check. Assert on visible headings/names (`"Game Tools"`, `"Characters"`, `"Claw"`) so a broken data pipeline still fails the test. + +**Test one character (Claw), not all four.** +Smoke = "does it boot," not "is every character correct." Per-character validation, if wanted later, belongs in a separate spec file (mirroring how `talks.spec.ts` extends `smoke.spec.ts` for the talks section). + +**CI wiring in `info.yml`: extend the game-tools matrix entries with `e2e_project`, and treat `@ageorgedev/game-tools-e2e` as an affected trigger for the game-tools preview.** +Direct parallel to the ageorgedev wiring — same shape, same guard. When only the e2e app changes, still deploy the game-tools preview so the tests have something to hit. + +## Risks / Trade-offs + +- **[Flaky character-name assertion if Claw's route is renamed]** → Mitigation: assert on the character name text (`"Claw"`) which is stable content, not the route path alone. If Claw is ever removed, swap to another character in one line. +- **[Local dev requires manually starting `game-tools` on port 3001]** → Mitigation: document in the new app's absent-by-design (matches the ageorgedev-e2e pattern developers already know). Not a regression. +- **[CI cost: extra chromium install + preview deploy on every game-tools PR]** → Mitigation: chromium install is already cached in the composite action; the game-tools preview already deploys on affected PRs — this only *adds* the e2e run, not a new deploy. +- **[Smoke test could pass while a specific character sheet is broken]** → Accepted trade-off: this is smoke, not functional. Broadening coverage later is a separate change. + +## Migration Plan + +No migration needed — additive change. Rollout: + +1. Land `apps/game-tools-e2e/` with tests. +2. Update `.github/workflows/info.yml` in the same PR. +3. The PR's own CI run will exercise the new wiring end-to-end (game-tools preview deploy → e2e run against it). + +Rollback: revert the PR. No data or runtime state involved. diff --git a/openspec/changes/archive/2026-07-03-add-game-tools-e2e-smoke/proposal.md b/openspec/changes/archive/2026-07-03-add-game-tools-e2e-smoke/proposal.md new file mode 100644 index 00000000..e637fe3b --- /dev/null +++ b/openspec/changes/archive/2026-07-03-add-game-tools-e2e-smoke/proposal.md @@ -0,0 +1,26 @@ +## Why + +The `game-tools` app has no automated verification that its routes render on deployed previews. The main site is covered by `@ageorgedev/ageorgedev-e2e` smoke tests wired into the PR + production deploy pipelines, but game-tools regressions (broken routes, blank pages, missing character data) currently go undetected until a manual click-through. As the character catalog grows, a smoke-level safety net is cheap insurance. + +## What Changes + +- Add a new `apps/game-tools-e2e` Playwright app mirroring the structure of `apps/ageorgedev-e2e`. +- Add smoke tests covering the game-tools home page (`/`), the character list (`/dnd/characters`), and a representative character sheet (`/dnd/characters/claw`). +- Default `BASE_URL` to `http://localhost:3001` (the game-tools dev port), overridable via env for CI runs against Vercel preview URLs. +- Wire the new e2e app into `.github/workflows/info.yml` so it runs against game-tools preview deploys on PRs and production deploys on `main`. + +## Capabilities + +### New Capabilities +- `game-tools-smoke-tests`: End-to-end smoke tests for the game-tools app that verify key routes render on a configurable base URL, executed against Vercel preview and production deployments via the shared CI matrix. + +### Modified Capabilities +- `ci-matrix-deploy`: The PR and production deploy matrices SHALL associate `@ageorgedev/game-tools-e2e` with the game-tools Vercel project, and SHALL treat changes to `@ageorgedev/game-tools-e2e` itself as an affected trigger for that project (mirroring the ageorgedev-e2e wiring). + +## Impact + +- **New app**: `apps/game-tools-e2e/` (`package.json`, `playwright.config.ts`, `tsconfig.json`, `tests/smoke.spec.ts`). +- **CI**: `.github/workflows/info.yml` PR + production matrix blocks updated to include the new e2e project. +- **Dependencies**: Adds `@playwright/test@1.60.0` to the new workspace (already in use by ageorgedev-e2e — no version drift). +- **No runtime changes** to the game-tools app itself. +- **Local dev**: developers running the smoke tests locally must start `yarn turbo dev --filter=@ageorgedev/game-tools` first (matches existing pattern). diff --git a/openspec/changes/archive/2026-07-03-add-game-tools-e2e-smoke/specs/ci-matrix-deploy/spec.md b/openspec/changes/archive/2026-07-03-add-game-tools-e2e-smoke/specs/ci-matrix-deploy/spec.md new file mode 100644 index 00000000..b688b4c9 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-add-game-tools-e2e-smoke/specs/ci-matrix-deploy/spec.md @@ -0,0 +1,41 @@ +## MODIFIED Requirements + +### Requirement: Info job outputs production deploy matrix +The Info job SHALL compute and output a `production_deploy_matrix` JSON array containing one entry per deployable app where the corresponding `should_deploy_*` condition is true. Each entry SHALL contain `vercel_project_name` (resolved from vars) and an optional `e2e_project` field. When no apps qualify, the output SHALL be an empty JSON array `[]`. + +#### Scenario: Site changed on production push +- **WHEN** `@ageorgedev/ageorgedev` is in the affected projects list +- **THEN** `production_deploy_matrix` contains one entry with `vercel_project_name` set to the resolved site project name and `e2e_project` set to `@ageorgedev/ageorgedev-e2e` + +#### Scenario: Design system changed on production push +- **WHEN** `@ageorgedev/design-docs` is in the affected projects list +- **THEN** `production_deploy_matrix` contains one entry with `vercel_project_name` set to the resolved design system project name and no `e2e_project` field + +#### Scenario: Game-tools changed on production push +- **WHEN** `@ageorgedev/game-tools` is in the affected projects list +- **THEN** `production_deploy_matrix` contains one entry with `vercel_project_name` set to the resolved game-tools project name and `e2e_project` set to `@ageorgedev/game-tools-e2e` + +#### Scenario: Nothing deployable changed +- **WHEN** none of site, design-system, or game-tools is in the affected projects list +- **THEN** `production_deploy_matrix` is `[]` + +--- + +### Requirement: Info job outputs PR deploy matrix +The Info job SHALL compute and output a `pr_deploy_matrix` JSON array using the same structure as `production_deploy_matrix`, except an app entry SHALL be included when either the app itself OR its associated e2e project is in the affected projects list. This ensures e2e-only changes still trigger a preview deploy for the test target. + +#### Scenario: Only ageorgedev e2e tests changed on PR +- **WHEN** `@ageorgedev/ageorgedev-e2e` is in the affected projects but `@ageorgedev/ageorgedev` is not +- **THEN** `pr_deploy_matrix` contains the site entry (to run e2e against a deployed preview) + +#### Scenario: Only game-tools e2e tests changed on PR +- **WHEN** `@ageorgedev/game-tools-e2e` is in the affected projects but `@ageorgedev/game-tools` is not +- **THEN** `pr_deploy_matrix` contains the game-tools entry with `e2e_project` set to `@ageorgedev/game-tools-e2e` + +#### Scenario: Game-tools app changed on PR +- **WHEN** `@ageorgedev/game-tools` is in the affected projects +- **THEN** `pr_deploy_matrix` contains the game-tools entry with `vercel_project_name` set to the resolved game-tools project name and `e2e_project` set to `@ageorgedev/game-tools-e2e` + +#### Scenario: Neither game-tools nor its e2e changed on PR +- **WHEN** neither `@ageorgedev/game-tools` nor `@ageorgedev/game-tools-e2e` is in the affected projects +- **THEN** the game-tools entry is absent from `pr_deploy_matrix` diff --git a/openspec/changes/archive/2026-07-03-add-game-tools-e2e-smoke/specs/game-tools-smoke-tests/spec.md b/openspec/changes/archive/2026-07-03-add-game-tools-e2e-smoke/specs/game-tools-smoke-tests/spec.md new file mode 100644 index 00000000..0e951398 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-add-game-tools-e2e-smoke/specs/game-tools-smoke-tests/spec.md @@ -0,0 +1,50 @@ +## ADDED Requirements + +### Requirement: Game-tools home page renders +The system SHALL render the game-tools home page with identifiable content proving the app booted and top-level navigation is present. + +#### Scenario: Home page loads successfully +- **WHEN** a user navigates to `/` +- **THEN** the page SHALL display a heading with the text "Game Tools" +- **THEN** the page SHALL display a link to the character list + +--- + +### Requirement: Character list page renders +The system SHALL render the D&D character list with at least one character visible, proving the dynamic route-collection logic resolved successfully. + +#### Scenario: Character list loads successfully +- **WHEN** a user navigates to `/dnd/characters` +- **THEN** the page SHALL display a heading with the text "Characters" +- **THEN** the page SHALL display at least one character entry with a visible character name + +--- + +### Requirement: Individual character sheet renders +The system SHALL render an individual character sheet with the character's name visible, proving both routing and character-data hydration succeeded. + +#### Scenario: Claw character sheet loads successfully +- **WHEN** a user navigates to `/dnd/characters/claw` +- **THEN** the page SHALL display the character name "Claw" + +--- + +### Requirement: Configurable base URL +The system SHALL target the host defined by the `BASE_URL` environment variable, defaulting to the local game-tools dev port when unset. + +#### Scenario: BASE_URL env var used when set +- **WHEN** `BASE_URL` is set to a deployed URL +- **THEN** all test requests SHALL be made against that URL + +#### Scenario: Defaults to localhost when BASE_URL is unset +- **WHEN** `BASE_URL` is not set +- **THEN** all test requests SHALL default to `http://localhost:3001` + +--- + +### Requirement: Chromium-only execution +The test suite SHALL run under a single `chromium` Playwright project, matching the convention established by `@ageorgedev/ageorgedev-e2e`. + +#### Scenario: Default project configuration +- **WHEN** `playwright test` is invoked with no `--project` flag +- **THEN** tests SHALL execute against Desktop Chrome only diff --git a/openspec/changes/archive/2026-07-03-add-game-tools-e2e-smoke/tasks.md b/openspec/changes/archive/2026-07-03-add-game-tools-e2e-smoke/tasks.md new file mode 100644 index 00000000..3eae7d8d --- /dev/null +++ b/openspec/changes/archive/2026-07-03-add-game-tools-e2e-smoke/tasks.md @@ -0,0 +1,31 @@ +## 1. Scaffold the e2e app + +- [x] 1.1 Create `apps/game-tools-e2e/package.json` mirroring `apps/ageorgedev-e2e/package.json` — name `@ageorgedev/game-tools-e2e`, `e2e` script running `playwright test`, `@playwright/test@1.60.0` and `concurrently@10.0.3` as devDependencies +- [x] 1.2 Create `apps/game-tools-e2e/playwright.config.ts` mirroring the ageorgedev-e2e config, with `baseURL` defaulting to `http://localhost:3001` and a single `chromium` project +- [x] 1.3 Create `apps/game-tools-e2e/tsconfig.json` matching the ageorgedev-e2e tsconfig +- [x] 1.4 Run `yarn install` at the repo root to register the new workspace + +## 2. Write the smoke tests + +- [x] 2.1 Create `apps/game-tools-e2e/tests/smoke.spec.ts` +- [x] 2.2 Add test: `/` renders — assert heading with name "Game Tools" is visible and a link to `/dnd/characters` is present +- [x] 2.3 Add test: `/dnd/characters` renders — assert heading "Characters" is visible and at least one character name is visible +- [x] 2.4 Add test: `/dnd/characters/claw` renders — assert the character name "Claw" is visible on the sheet + +## 3. Verify tests locally + +- [x] 3.1 Start game-tools dev server in a separate terminal: `yarn turbo dev --filter=@ageorgedev/game-tools` +- [x] 3.2 Run `yarn turbo e2e --filter=@ageorgedev/game-tools-e2e` and confirm all three tests pass +- [x] 3.3 Confirm `yarn format-and-lint:fix` reports clean + +## 4. Wire into CI + +- [x] 4.1 Update `.github/workflows/info.yml` production matrix: add `"e2e_project": "@ageorgedev/game-tools-e2e"` to the game-tools entry +- [x] 4.2 Update `.github/workflows/info.yml` PR matrix: add `"e2e_project": "@ageorgedev/game-tools-e2e"` to the game-tools entry AND extend the condition so `@ageorgedev/game-tools-e2e` in affected projects also triggers the game-tools preview deploy (mirroring the ageorgedev-e2e OR-condition pattern) +- [x] 4.3 Sanity-check the `jq` expressions by running them locally against a sample `affected` array or by staging the PR and inspecting the workflow's Debug output + +## 5. Validate on PR + +- [ ] 5.1 Open the PR and confirm the game-tools preview deploys +- [ ] 5.2 Confirm the e2e job runs against the deployed preview URL and passes +- [ ] 5.3 Confirm no regressions in the ageorgedev preview + e2e flow diff --git a/openspec/changes/archive/2026-07-03-dnd-character-sheet-unit-tests/.openspec.yaml b/openspec/changes/archive/2026-07-03-dnd-character-sheet-unit-tests/.openspec.yaml new file mode 100644 index 00000000..5f6b34de --- /dev/null +++ b/openspec/changes/archive/2026-07-03-dnd-character-sheet-unit-tests/.openspec.yaml @@ -0,0 +1,2 @@ +schema: my-workflow +created: 2026-07-03 diff --git a/openspec/changes/archive/2026-07-03-dnd-character-sheet-unit-tests/design.md b/openspec/changes/archive/2026-07-03-dnd-character-sheet-unit-tests/design.md new file mode 100644 index 00000000..9856e2f3 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-dnd-character-sheet-unit-tests/design.md @@ -0,0 +1,35 @@ +## Context + +`packages/dnd-character-sheet` has no tests. The package contains pure calculation functions (`abilityModifier`, `proficiencyBonus`, `calculateStats`) alongside formatting utilities and data extraction helpers. These functions encode D&D 5e rules directly and are the highest-value testing targets. The monorepo already has a shared Vitest/jsdom config in `packages/testing-config`. + +## Goals / Non-Goals + +**Goals:** +- Test all pure functions with meaningful assertions (no fluff) +- Cover all three `statMod` variant types in `calculateStats` +- Use minimal inline fixtures (not the example character) for stability +- Compress related assertions into single tests with multiple `expect()`s + +**Non-Goals:** +- Testing React components +- Testing DOMPurify's sanitisation behaviour (third-party responsibility) +- 100% line coverage — coverage is a byproduct, not the goal + +## Decisions + +**Decision: Minimal inline fixtures over reusing `example-wizard.data.ts`** +The example character is narrative data maintained for UI demonstration. Coupling tests to it makes assertions brittle when the character's stats are updated for storytelling reasons. Minimal fixtures with controlled values make test intent explicit. + +**Decision: Multiple `expect()`s per test for related assertions** +Functions like `abilityModifier` and `proficiencyBonus` have a small, enumerable input space. A single test with compressed assertions (e.g., all level tiers in one block) is more readable than 5 separate `it()` blocks testing the same function. + +**Decision: Test `enrichCharacterData` selection logic, not EJS internals** +The value in testing `enrichCharacterData` is verifying *which fields* on the character get template-processed (feature descriptions) and which don't (ability scores, etc.). EJS interpolation correctness is tested as a side-effect of exercising a real template token. + +**Decision: Test all three `statMod` types in `calculateStats`** +`'static-skill-additions'`, `'skill-function'`, and `'generic-derived'` each take a distinct code path. All three are exercised with a minimal character fixture that isolates each variant. + +## Risks / Trade-offs + +- [Risk] `calculateStats` signature changes → Tests will catch this at compile time (TypeScript) and fail fast +- [Risk] EJS version changes affect template syntax → Single `enrichCharacterData` test contains the blast radius diff --git a/openspec/changes/archive/2026-07-03-dnd-character-sheet-unit-tests/proposal.md b/openspec/changes/archive/2026-07-03-dnd-character-sheet-unit-tests/proposal.md new file mode 100644 index 00000000..09899331 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-dnd-character-sheet-unit-tests/proposal.md @@ -0,0 +1,25 @@ +## Why + +The `dnd-character-sheet` package contains non-trivial calculation logic with no test coverage. Adding targeted unit tests protects the core math (ability modifiers, proficiency bonuses, skill derivation) from regressions as character data and features evolve. + +## What Changes + +- Add a Vitest test file for `calculate-derived-stats.ts` covering `abilityModifier`, `proficiencyBonus`, and `calculateStats` (with all three `statMod` variant types) +- Add a Vitest test file for `utils.ts` covering `formatMod` and `formatModIgnoreZero` +- Add a Vitest test file for `character-brief.ts` covering `getCharacterBrief` +- Add a Vitest test file for `text-enrichment.ts` covering `enrichCharacterData` +- All tests use minimal inline fixtures; no dependency on the example character data file + +## Capabilities + +### New Capabilities + +- `dnd-unit-tests`: Unit tests for the pure utility and calculation functions in `packages/dnd-character-sheet` + +### Modified Capabilities + +## Impact + +- **Package**: `packages/dnd-character-sheet` — test files added alongside source, no API changes +- **Test config**: Uses existing `packages/testing-config` Vitest/jsdom setup already present in the monorepo +- **CI**: Tests will be picked up by `yarn test` / `yarn turbo test --filter=@ageorgedev/dnd-character-sheet` diff --git a/openspec/changes/archive/2026-07-03-dnd-character-sheet-unit-tests/specs/dnd-unit-tests/spec.md b/openspec/changes/archive/2026-07-03-dnd-character-sheet-unit-tests/specs/dnd-unit-tests/spec.md new file mode 100644 index 00000000..5af1b804 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-dnd-character-sheet-unit-tests/specs/dnd-unit-tests/spec.md @@ -0,0 +1,61 @@ +## ADDED Requirements + +### Requirement: abilityModifier returns correct modifier for any ability score +The system SHALL convert D&D ability scores to modifiers using `Math.floor((score - 10) / 2)`, covering the full valid range. + +#### Scenario: Boundary and representative scores produce correct modifiers +- **WHEN** `abilityModifier` is called with scores: 1, 3, 10, 11, 15, 20 +- **THEN** it returns -5, -4, 0, 0, +2, +5 respectively (multiple expects in one test) + +### Requirement: proficiencyBonus returns correct bonus for each level tier +The system SHALL return the correct proficiency bonus for all five D&D 5e level tiers. + +#### Scenario: All tier boundaries produce correct bonuses +- **WHEN** `proficiencyBonus` is called with levels 1, 4, 5, 8, 9, 12, 13, 16, 17, 20 +- **THEN** it returns +2 for 1–4, +3 for 5–8, +4 for 9–12, +5 for 13–16, +6 for 17–20 (multiple expects in one test) + +### Requirement: calculateStats applies static-skill-additions statMod +The system SHALL add flat bonuses to specified skills when a feature has a `'static-skill-additions'` statMod. + +#### Scenario: Feature with static skill addition increases the target skill bonus +- **WHEN** `calculateStats` is called with a character whose feature has `statMod.type === 'static-skill-additions'` adding +3 to Perception +- **THEN** the resulting `skills.Perception` value is 3 higher than the base ability modifier + +### Requirement: calculateStats applies skill-function statMod +The system SHALL invoke the provided function to compute skill bonuses when a feature has a `'skill-function'` statMod. + +#### Scenario: Feature with skill-function overrides the skill calculation +- **WHEN** `calculateStats` is called with a character whose feature has `statMod.type === 'skill-function'` returning a fixed value for Stealth +- **THEN** the resulting `skills.Stealth` value matches the value returned by the function + +### Requirement: calculateStats applies generic-derived statMod +The system SHALL invoke the provided function to mutate the entire `DerivedStats` object when a feature has a `'generic-derived'` statMod. + +#### Scenario: Feature with generic-derived modifies derived stats freely +- **WHEN** `calculateStats` is called with a character whose feature has `statMod.type === 'generic-derived'` that sets initiative to 99 +- **THEN** the resulting `initiative` value is 99 + +### Requirement: formatMod and formatModIgnoreZero format modifiers correctly +The system SHALL format positive modifiers with a leading `+`, negative with `-`, and zero as `+0` (formatMod) or empty string (formatModIgnoreZero). + +#### Scenario: Both formatters handle positive, negative, and zero inputs +- **WHEN** `formatMod` is called with -3, 0, 5 and `formatModIgnoreZero` is called with -3, 0, 5 +- **THEN** `formatMod` returns "-3", "+0", "+5" and `formatModIgnoreZero` returns "-3", "", "+5" + +### Requirement: getCharacterBrief extracts name, level, and description +The system SHALL return a summary object with the character's name, total level (summed across all classes), and description. + +#### Scenario: Single-class and multi-class characters produce correct level totals +- **WHEN** `getCharacterBrief` is called with a level-5 single-class character and a multi-class character with 4 levels in one class and 3 in another +- **THEN** it returns total level 5 and 7 respectively, with the correct name in each case + +### Requirement: enrichCharacterData interpolates DerivedStats tokens in feature descriptions +The system SHALL process EJS tokens in feature description strings, replacing stat references with computed values, while leaving non-template fields unchanged. + +#### Scenario: Feature description with EJS token is interpolated using derived stats +- **WHEN** `enrichCharacterData` is called with a character whose feature description contains `<%= proficiencyBonus %>` +- **THEN** the returned character's feature description contains the numeric proficiency bonus value, not the raw EJS token + +#### Scenario: Non-template character fields are not mutated +- **WHEN** `enrichCharacterData` is called with a character +- **THEN** fields like ability scores and character name are unchanged in the returned value diff --git a/openspec/changes/archive/2026-07-03-dnd-character-sheet-unit-tests/tasks.md b/openspec/changes/archive/2026-07-03-dnd-character-sheet-unit-tests/tasks.md new file mode 100644 index 00000000..2621c948 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-dnd-character-sheet-unit-tests/tasks.md @@ -0,0 +1,23 @@ +## 1. Verify test infrastructure + +- [x] 1.1 Confirm `packages/dnd-character-sheet` has a `vitest.config.ts` (or inherits from root); add one using `packages/testing-config` if missing +- [x] 1.2 Confirm `test` script exists in `packages/dnd-character-sheet/package.json`; add if missing + +## 2. Calculation engine tests + +- [x] 2.1 Create `packages/dnd-character-sheet/src/lib/calculate-derived-stats.test.ts` +- [x] 2.2 Write `abilityModifier` test — boundary and representative scores in one compressed block +- [x] 2.3 Write `proficiencyBonus` test — all five tier boundaries in one compressed block +- [x] 2.4 Write `calculateStats` test for `'static-skill-additions'` statMod using minimal inline fixture +- [x] 2.5 Write `calculateStats` test for `'skill-function'` statMod using minimal inline fixture +- [x] 2.6 Write `calculateStats` test for `'generic-derived'` statMod using minimal inline fixture + +## 3. Utility and helper tests + +- [x] 3.1 Create `packages/dnd-character-sheet/src/lib/utils.test.ts` with one compressed test for `formatMod` and `formatModIgnoreZero` +- [x] 3.2 Create `packages/dnd-character-sheet/src/lib/character-brief.test.ts` with one compressed test covering single-class and multi-class level totals +- [x] 3.3 Create `packages/dnd-character-sheet/src/lib/text-enrichment.test.ts` with two tests: EJS interpolation in feature descriptions, and non-template fields unchanged + +## 4. Verify + +- [x] 4.1 Run `yarn turbo test --filter=@ageorgedev/dnd-character-sheet` and confirm all tests pass with no type errors diff --git a/openspec/changes/archive/2026-07-03-dnd-component-tests/.openspec.yaml b/openspec/changes/archive/2026-07-03-dnd-component-tests/.openspec.yaml new file mode 100644 index 00000000..5f6b34de --- /dev/null +++ b/openspec/changes/archive/2026-07-03-dnd-component-tests/.openspec.yaml @@ -0,0 +1,2 @@ +schema: my-workflow +created: 2026-07-03 diff --git a/openspec/changes/archive/2026-07-03-dnd-component-tests/design.md b/openspec/changes/archive/2026-07-03-dnd-component-tests/design.md new file mode 100644 index 00000000..38b17fb5 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-dnd-component-tests/design.md @@ -0,0 +1,47 @@ +## Context + +`packages/dnd-character-sheet` has Vitest + jsdom configured and lib-level tests for derived stat calculations. No component tests exist. The `vitest.config.ts` glob only matches `.test.ts`, missing `.test.tsx` files. The package lacks `@testing-library/react` and `@testing-library/jest-dom` as devDependencies (they're workspace-root installed but not declared locally, unlike `design-system` and `brand-components` which declare them explicitly). + +All components consume `useCharacter()` which requires a `CharacterSheet` context provider wrapping the rendered tree. + +## Goals / Non-Goals + +**Goals:** +- Add component-level regression tests for `CharacterSheet`, `AttackList`, `SpellList`, `AbilityBox` +- Exercise all conditional logic branches in `AttackList` (3 attack kinds) and `SpellList` (sorting, prep states, casting time, alt ability) +- Fix vitest config to pick up `.tsx` test files +- Declare testing dependencies explicitly in the package + +**Non-Goals:** +- Snapshot tests — brittle and high-maintenance +- Testing pure layout/display components (`Panel`, `RichTextDisplay`, `CombatRow`) +- Visual or CSS assertions +- Re-testing lib logic already covered by `calculate-derived-stats.test.ts` + +## Decisions + +### Test rendering approach: `@testing-library/react` + +Use `render()` + `screen` queries from `@testing-library/react`. Already the established pattern in `design-system` and `brand-components`. Alternatives: direct JSDOM with ReactDOM.render (non-standard in this codebase), Enzyme (unmaintained for React 19). + +### Context provision: real `CharacterSheet` wrapper, not mocked hook + +Components are wrapped in `` in each test. This exercises the actual context wiring. Mocking `useCharacter()` would be faster to write but would miss wiring bugs — exactly the category the context smoke test is designed to catch. + +### Fixtures: minimal inline per test + +Each test declares only the `Character` fields the component reads. The existing `example-wizard.data.ts` is intentionally not used as a base — it would create invisible coupling and make tests harder to read. Shared `makeCharacter()` helper can be introduced per test file if needed to satisfy TypeScript's required fields. + +### File placement: co-located + +`AttackList/AttackList.test.tsx` alongside `AttackList.tsx`. Consistent with the component directory structure already in place. + +### vitest include glob: `src/**/*.test.{ts,tsx}` + +Replaces `src/**/*.test.ts`. Minimal change, picks up all new `.tsx` test files without affecting existing `.test.ts` files. + +## Risks / Trade-offs + +- **CSS module class names in jsdom**: Class assertions are avoided; tests use text content and ARIA queries instead. → No mitigation needed given the test strategy. +- **Fixture completeness**: `Character` type has many required fields; minimal fixtures may need a base factory to satisfy TypeScript. → Each test file may introduce a local `makeCharacter()` helper with safe defaults. +- **Context re-calculation cost**: `CharacterSheet` re-runs `calculateStats()` on each render in tests. Acceptable for unit test scale. → No mitigation needed. diff --git a/openspec/changes/archive/2026-07-03-dnd-component-tests/proposal.md b/openspec/changes/archive/2026-07-03-dnd-component-tests/proposal.md new file mode 100644 index 00000000..2d5d6e07 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-dnd-component-tests/proposal.md @@ -0,0 +1,28 @@ +## Why + +The `dnd-character-sheet` package has solid lib/utility test coverage but zero component rendering tests. Several components (`AttackList`, `SpellList`, `AbilityBox`) contain non-trivial conditional logic that currently has no regression protection — a bug in attack bonus calculation or spell sorting would only surface at runtime. + +## What Changes + +- Add `@testing-library/react`, `@testing-library/jest-dom`, and `@ageorgedev/testing-config` to `dnd-character-sheet` devDependencies +- Update `vitest.config.ts` include glob from `src/**/*.test.ts` to `src/**/*.test.{ts,tsx}` to pick up new `.tsx` test files +- Add co-located component test files: + - `CharacterSheet.test.tsx` — smoke test that the provider wires derived stats correctly + - `AttackList/AttackList.test.tsx` — all three attack kind branches, mastery column logic, damage formatting, empty state + - `SpellcastingBlock/SpellList.test.tsx` — spell sorting, prep-state display, casting time normalisation, alternate ability display, empty row padding + - `AbilityBox/AbilityBox.test.tsx` — proficiency/expertise states, saving throw, modifier display rules + +## Capabilities + +### New Capabilities + +- `dnd-component-tests`: UI regression and logic tests for `CharacterSheet`, `AttackList`, `SpellList`, and `AbilityBox` components + +### Modified Capabilities + +## Impact + +- **Package**: `packages/dnd-character-sheet` +- **Config**: `vitest.config.ts` include glob updated +- **Dependencies**: `@testing-library/react@16.3.2`, `@testing-library/jest-dom@6.9.1`, `@ageorgedev/testing-config@workspace:*` added as devDependencies +- **No production code changes** — test files and config only diff --git a/openspec/changes/archive/2026-07-03-dnd-component-tests/specs/dnd-component-tests/spec.md b/openspec/changes/archive/2026-07-03-dnd-component-tests/specs/dnd-component-tests/spec.md new file mode 100644 index 00000000..b1bc1511 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-dnd-component-tests/specs/dnd-component-tests/spec.md @@ -0,0 +1,169 @@ +## ADDED Requirements + +### Requirement: AttackList renders nothing when no attacks +AttackList SHALL return null and render no DOM when the character has an empty attacks array. + +#### Scenario: No attacks +- **WHEN** `AttackList` is rendered with a character whose `attacks` array is empty +- **THEN** nothing is rendered to the DOM + +--- + +### Requirement: AttackList calculates weapon attack bonus correctly +AttackList SHALL display the attack bonus for a `weapon` kind attack as `abilityModifier + proficiencyBonus + attackBonusMod`. + +#### Scenario: Proficient weapon attack +- **WHEN** a weapon attack has a known ability modifier, the character is proficient, and `attackBonusMod` is set +- **THEN** the displayed bonus equals `abilityMod + profBonus + attackBonusMod` + +#### Scenario: Non-proficient weapon attack +- **WHEN** a weapon attack has `notProficient: true` +- **THEN** proficiency bonus is excluded from the displayed bonus + +--- + +### Requirement: AttackList calculates spell attack bonuses correctly +AttackList SHALL display spell attack bonuses using derived `spellAttackBonus`, or a per-attack ability override when `attack.ability` is set. + +#### Scenario: Spell-with-attack using derived spellAttackBonus +- **WHEN** a `spell-with-attack` has no `attack.ability` +- **THEN** the displayed bonus equals `derived.spellAttackBonus` + +#### Scenario: Spell-with-save formats DC string +- **WHEN** a `spell-with-save` has a `saveAbility` of DEX and `derived.spellSaveDC` is a known value +- **THEN** the displayed text is `DEX save, DC ` + +--- + +### Requirement: AttackList shows mastery column conditionally +AttackList SHALL show a Mastery column only when at least one attack has a `masteryProperty`. + +#### Scenario: Mastery column present +- **WHEN** at least one attack has a `masteryProperty` +- **THEN** a "Mastery" column header is rendered + +#### Scenario: Mastery column absent +- **WHEN** no attacks have a `masteryProperty` +- **THEN** no "Mastery" column header is rendered + +--- + +### Requirement: AttackList formats damage with modifier correctly +AttackList SHALL append the damage bonus to each damage entry unless `disableModifier` is true. + +#### Scenario: Damage with modifier +- **WHEN** a damage entry has `disableModifier: false` and a non-zero modifier +- **THEN** the damage string includes the formatted modifier (e.g. `1d8+3`) + +#### Scenario: Damage with disableModifier +- **WHEN** a damage entry has `disableModifier: true` +- **THEN** the modifier is not appended to the damage string + +--- + +### Requirement: SpellList sorts spells by level, then prepared status, then name +SpellList SHALL sort spells ascending by level, with `alwaysPrepared` spells appearing before others at the same level, then alphabetically by name. + +#### Scenario: Spells sorted by level +- **WHEN** spells of different levels are provided +- **THEN** lower-level spells appear before higher-level spells in the rendered table + +#### Scenario: alwaysPrepared before regular at same level +- **WHEN** two spells share the same level, one `alwaysPrepared` and one not +- **THEN** the `alwaysPrepared` spell row appears first + +#### Scenario: Alphabetical within same level and prep status +- **WHEN** two spells share the same level and neither is `alwaysPrepared` +- **THEN** they appear in alphabetical order by name + +--- + +### Requirement: SpellList displays preparation status correctly +SpellList SHALL show "AP" for always-prepared spells, an empty cell for cantrips (level 0), and a circle checkbox for other prepared spells. + +#### Scenario: Always prepared spell +- **WHEN** a spell has `alwaysPrepared: true` +- **THEN** the prep cell contains "AP" + +#### Scenario: Cantrip (level 0) +- **WHEN** a spell has `level: 0` +- **THEN** the prep cell is empty (no checkbox, no "AP") + +--- + +### Requirement: SpellList normalises casting time display +SpellList SHALL display "Action" for `castingTime: "action"`, "Bonus" for `castingTime: "bonus action"`, and pass other values through unchanged. + +#### Scenario: Action casting time +- **WHEN** a spell has `castingTime: "action"` (any case) +- **THEN** the rendered text is "Action" + +#### Scenario: Bonus action casting time +- **WHEN** a spell has `castingTime: "bonus action"` (any case) +- **THEN** the rendered text is "Bonus" + +--- + +### Requirement: SpellList shows alternate ability stats +SpellList SHALL display the computed to-hit bonus and save DC for spells with an `alternativeAbility`. + +#### Scenario: Alternate ability displayed +- **WHEN** a spell has `alternativeAbility` set +- **THEN** the row contains the ability's short name, to-hit bonus, and DC derived from `abilityMod + proficiencyBonus` + +--- + +### Requirement: AbilityBox displays ability score and modifier +AbilityBox SHALL render the raw ability score and the formatted ability modifier from derived stats. + +#### Scenario: Score and modifier rendered +- **WHEN** `AbilityBox` is rendered for an ability with a known score and modifier +- **THEN** both the score value and formatted modifier (e.g. "+3") are present in the DOM + +--- + +### Requirement: AbilityBox shows saving throw proficiency state +AbilityBox SHALL render the saving throw row as checked when the character is proficient, and unchecked otherwise. + +#### Scenario: Proficient saving throw +- **WHEN** the character includes the ability in `savingThrowProficiencies` +- **THEN** the saving throw row is rendered in a checked state + +#### Scenario: Non-proficient saving throw +- **WHEN** the ability is not in `savingThrowProficiencies` +- **THEN** the saving throw row is rendered unchecked + +--- + +### Requirement: AbilityBox shows skill proficiency and expertise states +AbilityBox SHALL render linked skills with the correct `CheckedState`: unchecked for no proficiency, checked for proficiency, and 'special' for expertise. + +#### Scenario: No proficiency +- **WHEN** a skill is not in `skillProficiencies` or `skillExpertise` +- **THEN** its checkbox is unchecked + +#### Scenario: Proficiency +- **WHEN** a skill is in `skillProficiencies` but not `skillExpertise` +- **THEN** its checkbox is in the checked state + +#### Scenario: Expertise +- **WHEN** a skill is in `skillExpertise` +- **THEN** its checkbox is in the 'special' state + +--- + +### Requirement: AbilityBox suppresses modifier when equal to ability modifier +AbilityBox SHALL render a non-breaking space (not a number) for a skill's modifier column when the skill total equals the raw ability modifier. + +#### Scenario: Skill modifier equals ability modifier +- **WHEN** a skill has no proficiency (total = ability modifier) +- **THEN** the modifier column for that skill shows whitespace, not a numeric modifier + +--- + +### Requirement: CharacterSheet provider wires derived stats to consumers +The `CharacterSheet` component SHALL compute and expose derived stats via context so that consuming components receive non-null, correctly typed data. + +#### Scenario: Context provides derived stats +- **WHEN** `CharacterSheet` is rendered with a valid `Character` and a child component reads the context via `useCharacter()` +- **THEN** `derived.abilityModifiers`, `derived.proficiencyBonus`, and `derived.skills` are all defined and non-null diff --git a/openspec/changes/archive/2026-07-03-dnd-component-tests/tasks.md b/openspec/changes/archive/2026-07-03-dnd-component-tests/tasks.md new file mode 100644 index 00000000..0572e67c --- /dev/null +++ b/openspec/changes/archive/2026-07-03-dnd-component-tests/tasks.md @@ -0,0 +1,50 @@ +## 1. Package Setup + +- [x] 1.1 Add `@testing-library/react@16.3.2`, `@testing-library/jest-dom@6.9.1`, and `@ageorgedev/testing-config@workspace:*` to `packages/dnd-character-sheet/package.json` devDependencies +- [x] 1.2 Update `packages/dnd-character-sheet/vitest.config.ts` include glob from `src/**/*.test.ts` to `src/**/*.test.{ts,tsx}` +- [x] 1.3 Run `yarn install` to resolve new dependencies + +## 2. CharacterSheet Context Test + +- [x] 2.1 Create `packages/dnd-character-sheet/src/components/CharacterSheet.test.tsx` +- [x] 2.2 Write a minimal `makeCharacter()` fixture helper that satisfies the `Character` type with safe defaults +- [x] 2.3 Write test: render `CharacterSheet` with fixture, read context via a test child component, assert `derived.abilityModifiers`, `derived.proficiencyBonus`, and `derived.skills` are defined + +## 3. AttackList Tests + +- [x] 3.1 Create `packages/dnd-character-sheet/src/components/AttackList/AttackList.test.tsx` +- [x] 3.2 Write test: no attacks → renders nothing +- [x] 3.3 Write test: `weapon` kind → correct bonus = `abilityMod + profBonus + attackBonusMod` +- [x] 3.4 Write test: `weapon` kind with `notProficient: true` → profBonus excluded +- [x] 3.5 Write test: `spell-with-attack` → displays `derived.spellAttackBonus` +- [x] 3.6 Write test: `spell-with-save` → displays `" save, DC "` string +- [x] 3.7 Write test: mastery column appears when any attack has `masteryProperty` +- [x] 3.8 Write test: mastery column absent when no attack has `masteryProperty` +- [x] 3.9 Write test: `disableModifier: true` → modifier not appended to damage string + +## 4. SpellList Tests + +- [x] 4.1 Create `packages/dnd-character-sheet/src/components/SpellcastingBlock/SpellList.test.tsx` +- [x] 4.2 Write test: spells sorted ascending by level +- [x] 4.3 Write test: `alwaysPrepared` spell appears before regular spell at same level +- [x] 4.4 Write test: alphabetical order within same level and prep status +- [x] 4.5 Write test: `alwaysPrepared` spell shows "AP" in prep column +- [x] 4.6 Write test: cantrip (level 0) shows empty prep cell +- [x] 4.7 Write test: `castingTime: "action"` → displays "Action" +- [x] 4.8 Write test: `castingTime: "bonus action"` → displays "Bonus" +- [x] 4.9 Write test: spell with `alternativeAbility` → shows ability short name, to-hit, and DC + +## 5. AbilityBox Tests + +- [x] 5.1 Create `packages/dnd-character-sheet/src/components/AbilityBox/AbilityBox.test.tsx` +- [x] 5.2 Write test: ability score and formatted modifier are rendered +- [x] 5.3 Write test: ability in `savingThrowProficiencies` → saving throw row is checked +- [x] 5.4 Write test: ability not in `savingThrowProficiencies` → saving throw row is unchecked +- [x] 5.5 Write test: skill in `skillProficiencies` → checkbox in checked state +- [x] 5.6 Write test: skill in `skillExpertise` → checkbox in 'special' state +- [x] 5.7 Write test: skill with no proficiency → modifier column shows whitespace, not a number + +## 6. Verification + +- [x] 6.1 Run `yarn turbo test --filter=@ageorgedev/dnd-character-sheet` and confirm all new tests pass +- [x] 6.2 Run `yarn format-and-lint` from the repo root and fix any issues diff --git a/openspec/changes/archive/2026-07-03-extract-public-layout-components/.openspec.yaml b/openspec/changes/archive/2026-07-03-extract-public-layout-components/.openspec.yaml new file mode 100644 index 00000000..5f6b34de --- /dev/null +++ b/openspec/changes/archive/2026-07-03-extract-public-layout-components/.openspec.yaml @@ -0,0 +1,2 @@ +schema: my-workflow +created: 2026-07-03 diff --git a/openspec/changes/extract-public-layout-components/design.md b/openspec/changes/archive/2026-07-03-extract-public-layout-components/design.md similarity index 100% rename from openspec/changes/extract-public-layout-components/design.md rename to openspec/changes/archive/2026-07-03-extract-public-layout-components/design.md diff --git a/openspec/changes/extract-public-layout-components/proposal.md b/openspec/changes/archive/2026-07-03-extract-public-layout-components/proposal.md similarity index 100% rename from openspec/changes/extract-public-layout-components/proposal.md rename to openspec/changes/archive/2026-07-03-extract-public-layout-components/proposal.md diff --git a/openspec/changes/extract-public-layout-components/specs/game-tools-nav-shell/spec.md b/openspec/changes/archive/2026-07-03-extract-public-layout-components/specs/game-tools-nav-shell/spec.md similarity index 100% rename from openspec/changes/extract-public-layout-components/specs/game-tools-nav-shell/spec.md rename to openspec/changes/archive/2026-07-03-extract-public-layout-components/specs/game-tools-nav-shell/spec.md diff --git a/openspec/changes/extract-public-layout-components/tasks.md b/openspec/changes/archive/2026-07-03-extract-public-layout-components/tasks.md similarity index 100% rename from openspec/changes/extract-public-layout-components/tasks.md rename to openspec/changes/archive/2026-07-03-extract-public-layout-components/tasks.md diff --git a/openspec/specs/ci-matrix-deploy/spec.md b/openspec/specs/ci-matrix-deploy/spec.md index f03a8db1..234c0d93 100644 --- a/openspec/specs/ci-matrix-deploy/spec.md +++ b/openspec/specs/ci-matrix-deploy/spec.md @@ -17,20 +17,32 @@ The Info job SHALL compute and output a `production_deploy_matrix` JSON array co - **WHEN** `@ageorgedev/design-docs` is in the affected projects list - **THEN** `production_deploy_matrix` contains one entry with `vercel_project_name` set to the resolved design system project name and no `e2e_project` field +#### Scenario: Game-tools changed on production push +- **WHEN** `@ageorgedev/game-tools` is in the affected projects list +- **THEN** `production_deploy_matrix` contains one entry with `vercel_project_name` set to the resolved game-tools project name and `e2e_project` set to `@ageorgedev/game-tools-e2e` + #### Scenario: Nothing deployable changed -- **WHEN** neither site nor design-system is in the affected projects list +- **WHEN** none of site, design-system, or game-tools is in the affected projects list - **THEN** `production_deploy_matrix` is `[]` ### Requirement: Info job outputs PR deploy matrix -The Info job SHALL compute and output a `pr_deploy_matrix` JSON array using the same structure as `production_deploy_matrix`, except the site entry SHALL be included when either `should_deploy_site` OR `should_run_e2e` is true. +The Info job SHALL compute and output a `pr_deploy_matrix` JSON array using the same structure as `production_deploy_matrix`, except an app entry SHALL be included when either the app itself OR its associated e2e project is in the affected projects list. This ensures e2e-only changes still trigger a preview deploy for the test target. -#### Scenario: Only e2e tests changed on PR +#### Scenario: Only ageorgedev e2e tests changed on PR - **WHEN** `@ageorgedev/ageorgedev-e2e` is in the affected projects but `@ageorgedev/ageorgedev` is not - **THEN** `pr_deploy_matrix` contains the site entry (to run e2e against a deployed preview) -#### Scenario: Neither site nor e2e changed on PR -- **WHEN** neither `@ageorgedev/ageorgedev` nor `@ageorgedev/ageorgedev-e2e` is in the affected projects -- **THEN** the site entry is absent from `pr_deploy_matrix` +#### Scenario: Only game-tools e2e tests changed on PR +- **WHEN** `@ageorgedev/game-tools-e2e` is in the affected projects but `@ageorgedev/game-tools` is not +- **THEN** `pr_deploy_matrix` contains the game-tools entry with `e2e_project` set to `@ageorgedev/game-tools-e2e` + +#### Scenario: Game-tools app changed on PR +- **WHEN** `@ageorgedev/game-tools` is in the affected projects +- **THEN** `pr_deploy_matrix` contains the game-tools entry with `vercel_project_name` set to the resolved game-tools project name and `e2e_project` set to `@ageorgedev/game-tools-e2e` + +#### Scenario: Neither game-tools nor its e2e changed on PR +- **WHEN** neither `@ageorgedev/game-tools` nor `@ageorgedev/game-tools-e2e` is in the affected projects +- **THEN** the game-tools entry is absent from `pr_deploy_matrix` ### Requirement: Info job removes is_hotfix and dead outputs The Info job SHALL NOT output `is_hotfix`, `affected_base`, `affected_head`, or `should_preview_site`. These outputs SHALL be removed entirely. diff --git a/openspec/specs/dnd-component-tests/spec.md b/openspec/specs/dnd-component-tests/spec.md new file mode 100644 index 00000000..b1bc1511 --- /dev/null +++ b/openspec/specs/dnd-component-tests/spec.md @@ -0,0 +1,169 @@ +## ADDED Requirements + +### Requirement: AttackList renders nothing when no attacks +AttackList SHALL return null and render no DOM when the character has an empty attacks array. + +#### Scenario: No attacks +- **WHEN** `AttackList` is rendered with a character whose `attacks` array is empty +- **THEN** nothing is rendered to the DOM + +--- + +### Requirement: AttackList calculates weapon attack bonus correctly +AttackList SHALL display the attack bonus for a `weapon` kind attack as `abilityModifier + proficiencyBonus + attackBonusMod`. + +#### Scenario: Proficient weapon attack +- **WHEN** a weapon attack has a known ability modifier, the character is proficient, and `attackBonusMod` is set +- **THEN** the displayed bonus equals `abilityMod + profBonus + attackBonusMod` + +#### Scenario: Non-proficient weapon attack +- **WHEN** a weapon attack has `notProficient: true` +- **THEN** proficiency bonus is excluded from the displayed bonus + +--- + +### Requirement: AttackList calculates spell attack bonuses correctly +AttackList SHALL display spell attack bonuses using derived `spellAttackBonus`, or a per-attack ability override when `attack.ability` is set. + +#### Scenario: Spell-with-attack using derived spellAttackBonus +- **WHEN** a `spell-with-attack` has no `attack.ability` +- **THEN** the displayed bonus equals `derived.spellAttackBonus` + +#### Scenario: Spell-with-save formats DC string +- **WHEN** a `spell-with-save` has a `saveAbility` of DEX and `derived.spellSaveDC` is a known value +- **THEN** the displayed text is `DEX save, DC ` + +--- + +### Requirement: AttackList shows mastery column conditionally +AttackList SHALL show a Mastery column only when at least one attack has a `masteryProperty`. + +#### Scenario: Mastery column present +- **WHEN** at least one attack has a `masteryProperty` +- **THEN** a "Mastery" column header is rendered + +#### Scenario: Mastery column absent +- **WHEN** no attacks have a `masteryProperty` +- **THEN** no "Mastery" column header is rendered + +--- + +### Requirement: AttackList formats damage with modifier correctly +AttackList SHALL append the damage bonus to each damage entry unless `disableModifier` is true. + +#### Scenario: Damage with modifier +- **WHEN** a damage entry has `disableModifier: false` and a non-zero modifier +- **THEN** the damage string includes the formatted modifier (e.g. `1d8+3`) + +#### Scenario: Damage with disableModifier +- **WHEN** a damage entry has `disableModifier: true` +- **THEN** the modifier is not appended to the damage string + +--- + +### Requirement: SpellList sorts spells by level, then prepared status, then name +SpellList SHALL sort spells ascending by level, with `alwaysPrepared` spells appearing before others at the same level, then alphabetically by name. + +#### Scenario: Spells sorted by level +- **WHEN** spells of different levels are provided +- **THEN** lower-level spells appear before higher-level spells in the rendered table + +#### Scenario: alwaysPrepared before regular at same level +- **WHEN** two spells share the same level, one `alwaysPrepared` and one not +- **THEN** the `alwaysPrepared` spell row appears first + +#### Scenario: Alphabetical within same level and prep status +- **WHEN** two spells share the same level and neither is `alwaysPrepared` +- **THEN** they appear in alphabetical order by name + +--- + +### Requirement: SpellList displays preparation status correctly +SpellList SHALL show "AP" for always-prepared spells, an empty cell for cantrips (level 0), and a circle checkbox for other prepared spells. + +#### Scenario: Always prepared spell +- **WHEN** a spell has `alwaysPrepared: true` +- **THEN** the prep cell contains "AP" + +#### Scenario: Cantrip (level 0) +- **WHEN** a spell has `level: 0` +- **THEN** the prep cell is empty (no checkbox, no "AP") + +--- + +### Requirement: SpellList normalises casting time display +SpellList SHALL display "Action" for `castingTime: "action"`, "Bonus" for `castingTime: "bonus action"`, and pass other values through unchanged. + +#### Scenario: Action casting time +- **WHEN** a spell has `castingTime: "action"` (any case) +- **THEN** the rendered text is "Action" + +#### Scenario: Bonus action casting time +- **WHEN** a spell has `castingTime: "bonus action"` (any case) +- **THEN** the rendered text is "Bonus" + +--- + +### Requirement: SpellList shows alternate ability stats +SpellList SHALL display the computed to-hit bonus and save DC for spells with an `alternativeAbility`. + +#### Scenario: Alternate ability displayed +- **WHEN** a spell has `alternativeAbility` set +- **THEN** the row contains the ability's short name, to-hit bonus, and DC derived from `abilityMod + proficiencyBonus` + +--- + +### Requirement: AbilityBox displays ability score and modifier +AbilityBox SHALL render the raw ability score and the formatted ability modifier from derived stats. + +#### Scenario: Score and modifier rendered +- **WHEN** `AbilityBox` is rendered for an ability with a known score and modifier +- **THEN** both the score value and formatted modifier (e.g. "+3") are present in the DOM + +--- + +### Requirement: AbilityBox shows saving throw proficiency state +AbilityBox SHALL render the saving throw row as checked when the character is proficient, and unchecked otherwise. + +#### Scenario: Proficient saving throw +- **WHEN** the character includes the ability in `savingThrowProficiencies` +- **THEN** the saving throw row is rendered in a checked state + +#### Scenario: Non-proficient saving throw +- **WHEN** the ability is not in `savingThrowProficiencies` +- **THEN** the saving throw row is rendered unchecked + +--- + +### Requirement: AbilityBox shows skill proficiency and expertise states +AbilityBox SHALL render linked skills with the correct `CheckedState`: unchecked for no proficiency, checked for proficiency, and 'special' for expertise. + +#### Scenario: No proficiency +- **WHEN** a skill is not in `skillProficiencies` or `skillExpertise` +- **THEN** its checkbox is unchecked + +#### Scenario: Proficiency +- **WHEN** a skill is in `skillProficiencies` but not `skillExpertise` +- **THEN** its checkbox is in the checked state + +#### Scenario: Expertise +- **WHEN** a skill is in `skillExpertise` +- **THEN** its checkbox is in the 'special' state + +--- + +### Requirement: AbilityBox suppresses modifier when equal to ability modifier +AbilityBox SHALL render a non-breaking space (not a number) for a skill's modifier column when the skill total equals the raw ability modifier. + +#### Scenario: Skill modifier equals ability modifier +- **WHEN** a skill has no proficiency (total = ability modifier) +- **THEN** the modifier column for that skill shows whitespace, not a numeric modifier + +--- + +### Requirement: CharacterSheet provider wires derived stats to consumers +The `CharacterSheet` component SHALL compute and expose derived stats via context so that consuming components receive non-null, correctly typed data. + +#### Scenario: Context provides derived stats +- **WHEN** `CharacterSheet` is rendered with a valid `Character` and a child component reads the context via `useCharacter()` +- **THEN** `derived.abilityModifiers`, `derived.proficiencyBonus`, and `derived.skills` are all defined and non-null diff --git a/openspec/specs/dnd-unit-tests/spec.md b/openspec/specs/dnd-unit-tests/spec.md new file mode 100644 index 00000000..5af1b804 --- /dev/null +++ b/openspec/specs/dnd-unit-tests/spec.md @@ -0,0 +1,61 @@ +## ADDED Requirements + +### Requirement: abilityModifier returns correct modifier for any ability score +The system SHALL convert D&D ability scores to modifiers using `Math.floor((score - 10) / 2)`, covering the full valid range. + +#### Scenario: Boundary and representative scores produce correct modifiers +- **WHEN** `abilityModifier` is called with scores: 1, 3, 10, 11, 15, 20 +- **THEN** it returns -5, -4, 0, 0, +2, +5 respectively (multiple expects in one test) + +### Requirement: proficiencyBonus returns correct bonus for each level tier +The system SHALL return the correct proficiency bonus for all five D&D 5e level tiers. + +#### Scenario: All tier boundaries produce correct bonuses +- **WHEN** `proficiencyBonus` is called with levels 1, 4, 5, 8, 9, 12, 13, 16, 17, 20 +- **THEN** it returns +2 for 1–4, +3 for 5–8, +4 for 9–12, +5 for 13–16, +6 for 17–20 (multiple expects in one test) + +### Requirement: calculateStats applies static-skill-additions statMod +The system SHALL add flat bonuses to specified skills when a feature has a `'static-skill-additions'` statMod. + +#### Scenario: Feature with static skill addition increases the target skill bonus +- **WHEN** `calculateStats` is called with a character whose feature has `statMod.type === 'static-skill-additions'` adding +3 to Perception +- **THEN** the resulting `skills.Perception` value is 3 higher than the base ability modifier + +### Requirement: calculateStats applies skill-function statMod +The system SHALL invoke the provided function to compute skill bonuses when a feature has a `'skill-function'` statMod. + +#### Scenario: Feature with skill-function overrides the skill calculation +- **WHEN** `calculateStats` is called with a character whose feature has `statMod.type === 'skill-function'` returning a fixed value for Stealth +- **THEN** the resulting `skills.Stealth` value matches the value returned by the function + +### Requirement: calculateStats applies generic-derived statMod +The system SHALL invoke the provided function to mutate the entire `DerivedStats` object when a feature has a `'generic-derived'` statMod. + +#### Scenario: Feature with generic-derived modifies derived stats freely +- **WHEN** `calculateStats` is called with a character whose feature has `statMod.type === 'generic-derived'` that sets initiative to 99 +- **THEN** the resulting `initiative` value is 99 + +### Requirement: formatMod and formatModIgnoreZero format modifiers correctly +The system SHALL format positive modifiers with a leading `+`, negative with `-`, and zero as `+0` (formatMod) or empty string (formatModIgnoreZero). + +#### Scenario: Both formatters handle positive, negative, and zero inputs +- **WHEN** `formatMod` is called with -3, 0, 5 and `formatModIgnoreZero` is called with -3, 0, 5 +- **THEN** `formatMod` returns "-3", "+0", "+5" and `formatModIgnoreZero` returns "-3", "", "+5" + +### Requirement: getCharacterBrief extracts name, level, and description +The system SHALL return a summary object with the character's name, total level (summed across all classes), and description. + +#### Scenario: Single-class and multi-class characters produce correct level totals +- **WHEN** `getCharacterBrief` is called with a level-5 single-class character and a multi-class character with 4 levels in one class and 3 in another +- **THEN** it returns total level 5 and 7 respectively, with the correct name in each case + +### Requirement: enrichCharacterData interpolates DerivedStats tokens in feature descriptions +The system SHALL process EJS tokens in feature description strings, replacing stat references with computed values, while leaving non-template fields unchanged. + +#### Scenario: Feature description with EJS token is interpolated using derived stats +- **WHEN** `enrichCharacterData` is called with a character whose feature description contains `<%= proficiencyBonus %>` +- **THEN** the returned character's feature description contains the numeric proficiency bonus value, not the raw EJS token + +#### Scenario: Non-template character fields are not mutated +- **WHEN** `enrichCharacterData` is called with a character +- **THEN** fields like ability scores and character name are unchanged in the returned value diff --git a/openspec/specs/game-tools-nav-shell/spec.md b/openspec/specs/game-tools-nav-shell/spec.md index 76e38e9c..d73abecf 100644 --- a/openspec/specs/game-tools-nav-shell/spec.md +++ b/openspec/specs/game-tools-nav-shell/spec.md @@ -26,7 +26,7 @@ The navigation header SHALL include a link to `/dnd/characters`. - **THEN** the browser navigates to `/dnd/characters` ### Requirement: Navigation header renders a breadcrumb trail for the active route -The `_public` layout header SHALL render a breadcrumb trail (built from `@ageorgedev/design-system/ui/breadcrumb`) that reflects the active route hierarchy. The trail SHALL be derived from the current TanStack Router matches — not hard-coded — so that nested routes automatically contribute crumbs. The final crumb SHALL render as `BreadcrumbPage` (non-link, marked `aria-current="page"`); all preceding crumbs SHALL render as `BreadcrumbLink` that navigate to the corresponding route. +The `_public` layout header SHALL render a breadcrumb trail (built from `@ageorgedev/design-system/ui/breadcrumb`) that reflects the active route hierarchy. The trail SHALL be derived from the current TanStack Router matches — not hard-coded — so that nested routes automatically contribute crumbs. The final crumb SHALL render as `BreadcrumbPage` (non-link, marked `aria-current="page"`); all preceding crumbs SHALL render as `BreadcrumbLink` that navigate to the corresponding route. The layout SHALL delegate this rendering to a dedicated `HeaderBreadcrumbs` component that reads `useMatches()` internally, so that `_public.tsx` itself does not derive or render breadcrumb items. #### Scenario: Character index shows current-page crumb - **WHEN** a user navigates to `/dnd/characters` @@ -44,9 +44,28 @@ The `_public` layout header SHALL render a breadcrumb trail (built from `@ageorg - **WHEN** a user navigates to `/` - **THEN** no breadcrumb list items are rendered in the header +#### Scenario: Breadcrumb rendering is encapsulated in a dedicated component +- **WHEN** a developer inspects `apps/game-tools/src/routes/_public.tsx` +- **THEN** the file mounts a `HeaderBreadcrumbs` component instead of calling `useMatches()` or deriving crumbs inline, and the breadcrumb derivation and JSX live in a sibling file that owns them + ### Requirement: Breadcrumb link on non-leaf crumb navigates The `BreadcrumbLink` used for non-leaf crumbs SHALL be wired to TanStack Router `Link` (via the component's `asChild` prop) so that activating the crumb performs client-side navigation to the target route. #### Scenario: Clicking the DnD Characters crumb navigates back to the index - **WHEN** a user is on `/dnd/characters/zoynari` and clicks the `DnD Characters` breadcrumb link - **THEN** the app navigates to `/dnd/characters` without a full page reload + +### Requirement: DnD-specific header actions are encapsulated in a dedicated component +The `_public` layout SHALL delegate rendering of DnD-specific header controls (the spellbook download link and the character-sheet print button) to a dedicated `DndHeaderActions` component. The layout SHALL mount this component unconditionally; the component itself SHALL read `useMatches()` and decide, per-control, whether to render — the layout SHALL NOT compute `isCharacterSheet`, `spellBookUrl`, or any equivalent flag on behalf of the component. When neither control applies to the active route, the component SHALL render no visible output. + +#### Scenario: Layout does not compute DnD-action visibility itself +- **WHEN** a developer inspects `apps/game-tools/src/routes/_public.tsx` +- **THEN** the file mounts a `DndHeaderActions` component with no props derived from router matches, and the file itself contains no logic inspecting `staticData.spellBookUrl` or `_sheet` route identifiers + +#### Scenario: Component renders nothing on non-DnD routes +- **WHEN** a user navigates to `/` (a route with no `spellBookUrl` and no `_sheet` in its route id) +- **THEN** `DndHeaderActions` produces no visible header controls, while the rest of the header (brand link, breadcrumbs area, theme switcher) is unaffected + +#### Scenario: Component still honors existing print and spellbook visibility rules +- **WHEN** a user navigates to a character sheet route whose match hierarchy exposes a `staticData.spellBookUrl` and whose `routeId` includes `_sheet` +- **THEN** `DndHeaderActions` renders both the spellbook download link and the print button, matching the behavior described in the `character-sheet-print` and `character-spellbook-download` capabilities diff --git a/openspec/specs/game-tools-smoke-tests/spec.md b/openspec/specs/game-tools-smoke-tests/spec.md new file mode 100644 index 00000000..195d7be0 --- /dev/null +++ b/openspec/specs/game-tools-smoke-tests/spec.md @@ -0,0 +1,56 @@ +# Game Tools Smoke Tests + +## Overview + +This spec defines smoke tests for the `game-tools` application, verifying that core pages render correctly and that the app is functional end-to-end. These tests are separate from the main site smoke tests and target the game-tools app specifically. + +## Requirements + +### Requirement: Game-tools home page renders +The system SHALL render the game-tools home page with identifiable content proving the app booted and top-level navigation is present. + +#### Scenario: Home page loads successfully +- **WHEN** a user navigates to `/` +- **THEN** the page SHALL display a heading with the text "Game Tools" +- **THEN** the page SHALL display a link to the character list + +--- + +### Requirement: Character list page renders +The system SHALL render the D&D character list with at least one character visible, proving the dynamic route-collection logic resolved successfully. + +#### Scenario: Character list loads successfully +- **WHEN** a user navigates to `/dnd/characters` +- **THEN** the page SHALL display a heading with the text "Characters" +- **THEN** the page SHALL display at least one character entry with a visible character name + +--- + +### Requirement: Individual character sheet renders +The system SHALL render an individual character sheet with the character's name visible, proving both routing and character-data hydration succeeded. + +#### Scenario: Claw character sheet loads successfully +- **WHEN** a user navigates to `/dnd/characters/claw` +- **THEN** the page SHALL display the character name "Claw" + +--- + +### Requirement: Configurable base URL +The system SHALL target the host defined by the `BASE_URL` environment variable, defaulting to the local game-tools dev port when unset. + +#### Scenario: BASE_URL env var used when set +- **WHEN** `BASE_URL` is set to a deployed URL +- **THEN** all test requests SHALL be made against that URL + +#### Scenario: Defaults to localhost when BASE_URL is unset +- **WHEN** `BASE_URL` is not set +- **THEN** all test requests SHALL default to `http://localhost:3001` + +--- + +### Requirement: Chromium-only execution +The test suite SHALL run under a single `chromium` Playwright project, matching the convention established by `@ageorgedev/ageorgedev-e2e`. + +#### Scenario: Default project configuration +- **WHEN** `playwright test` is invoked with no `--project` flag +- **THEN** tests SHALL execute against Desktop Chrome only diff --git a/packages/dnd-character-sheet/package.json b/packages/dnd-character-sheet/package.json index 62d3d1e6..72ea5275 100644 --- a/packages/dnd-character-sheet/package.json +++ b/packages/dnd-character-sheet/package.json @@ -5,7 +5,8 @@ "scripts": { "clean": "rm -rf ./dist", "build": "tsc && cpx \"src/**/*.css\" dist", - "dev": "concurrently 'tsc --watch' 'cpx \"src/**/*.css\" dist --watch'" + "dev": "concurrently 'tsc --watch' 'cpx \"src/**/*.css\" dist --watch'", + "test": "vitest run" }, "exports": { ".": { @@ -22,13 +23,18 @@ "react-dom": "19.2.7" }, "devDependencies": { + "@ageorgedev/testing-config": "workspace:*", "@ageorgedev/ts-config": "workspace:*", + "@testing-library/jest-dom": "6.9.1", + "@testing-library/react": "16.3.2", "@types/ejs": "^3", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "6.0.2", "concurrently": "10.0.3", "cpx2": "9.0.0", - "typescript": "5.8.3" + "typescript": "5.8.3", + "vitest": "4.1.8" }, "dependencies": { "@ageorgedev/toolbelt": "workspace:*", diff --git a/packages/dnd-character-sheet/src/components/AbilityBox/AbilityBox.test.tsx b/packages/dnd-character-sheet/src/components/AbilityBox/AbilityBox.test.tsx new file mode 100644 index 00000000..4166f6e0 --- /dev/null +++ b/packages/dnd-character-sheet/src/components/AbilityBox/AbilityBox.test.tsx @@ -0,0 +1,123 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { Ability } from '../../lib/models/abilities'; +import type { Character } from '../../lib/models/character'; +import { CharacterClass } from '../../lib/models/character-classes'; +import { Skill } from '../../lib/models/skills'; +import { CharacterSheet } from '../CharacterSheet'; +import { AbilityBox } from './AbilityBox'; + +function makeCharacter(overrides: Partial = {}): Character { + return { + name: 'Test', + species: 'Human', + background: 'Soldier', + classes: [{ name: CharacterClass.Fighter, level: 1 }], + abilities: { + [Ability.Strength]: 10, + [Ability.Dexterity]: 10, + [Ability.Constitution]: 10, + [Ability.Intelligence]: 10, + [Ability.Wisdom]: 10, + [Ability.Charisma]: 10, + }, + savingThrowProficiencies: [], + skillProficiencies: [], + skillExpertise: [], + baseArmorClass: 10, + speed: 30, + hitPoints: { maximum: 10 }, + attacks: [], + equipment: [], + features: [], + armorProficiencies: [], + weaponProficiencies: [], + toolProficiencies: [], + languages: [], + ...overrides, + }; +} + +function renderAbilityBox(ability: Ability, character: Character) { + return render( + + + + ); +} + +/** Navigate from a label text to its preceding CircleCheck span. + * SkillRow renders: CircleCheck | modifier span | label span */ +function getCheckboxForLabel(labelText: string): Element { + const labelEl = screen.getByText(labelText); + // label text may be directly in the span or inside a child + const labelSpan = labelEl.tagName === 'B' ? labelEl.parentElement : labelEl; + if (!labelSpan) throw new Error(`No parent element for "${labelText}"`); + const modifierSpan = labelSpan.previousElementSibling; + if (!modifierSpan) throw new Error(`No modifier sibling for "${labelText}"`); + const checkbox = modifierSpan.previousElementSibling; + if (!checkbox) throw new Error(`No checkbox sibling for "${labelText}"`); + return checkbox; +} + +describe('AbilityBox', () => { + it('displays the raw ability score', () => { + renderAbilityBox( + Ability.Strength, + makeCharacter({ + abilities: { ...makeCharacter().abilities, [Ability.Strength]: 16 }, + }) + ); + expect(screen.getByText('16')).toBeInTheDocument(); + }); + + it('displays the formatted ability modifier', () => { + // STR 16 → mod +3 + renderAbilityBox( + Ability.Strength, + makeCharacter({ + abilities: { ...makeCharacter().abilities, [Ability.Strength]: 16 }, + }) + ); + expect(screen.getByText('+3')).toBeInTheDocument(); + }); + + it('saving throw checkbox is checked when ability is in savingThrowProficiencies', () => { + const character = makeCharacter({ + savingThrowProficiencies: [Ability.Strength], + }); + renderAbilityBox(Ability.Strength, character); + const checkbox = getCheckboxForLabel('Saving Throw'); + expect(checkbox).toHaveClass('bg-primary-surface'); + }); + + it('saving throw checkbox is unchecked when ability is not in savingThrowProficiencies', () => { + renderAbilityBox(Ability.Strength, makeCharacter()); + const checkbox = getCheckboxForLabel('Saving Throw'); + expect(checkbox).not.toHaveClass('bg-primary-surface'); + }); + + it('skill checkbox is checked when skill is in skillProficiencies', () => { + // STR ability → Athletics skill + const character = makeCharacter({ skillProficiencies: [Skill.Athletics] }); + renderAbilityBox(Ability.Strength, character); + const checkbox = getCheckboxForLabel('Athletics'); + expect(checkbox).toHaveClass('bg-primary-surface'); + }); + + it('skill checkbox is in special state when skill is in skillExpertise', () => { + // DEX ability → Acrobatics skill + const character = makeCharacter({ skillExpertise: [Skill.Acrobatics] }); + renderAbilityBox(Ability.Dexterity, character); + const checkbox = getCheckboxForLabel('Acrobatics'); + expect(checkbox).toHaveClass('bg-secondary-surface-2'); + }); + + it('modifier column shows whitespace when skill total equals ability modifier', () => { + // STR 10 → mod 0, Athletics not proficient → total 0 = abilityMod → non-breaking space shown + renderAbilityBox(Ability.Strength, makeCharacter()); + const athleticsSpan = screen.getByText('Athletics'); + // modifier span is the immediate previous sibling of the label span + expect(athleticsSpan.previousElementSibling?.textContent).toBe(' '); + }); +}); diff --git a/packages/dnd-character-sheet/src/components/AttackList/AttackList.test.tsx b/packages/dnd-character-sheet/src/components/AttackList/AttackList.test.tsx new file mode 100644 index 00000000..a00b955a --- /dev/null +++ b/packages/dnd-character-sheet/src/components/AttackList/AttackList.test.tsx @@ -0,0 +1,166 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { Ability } from '../../lib/models/abilities'; +import type { Attack } from '../../lib/models/attacks'; +import type { Character } from '../../lib/models/character'; +import { CharacterClass } from '../../lib/models/character-classes'; +import { CharacterSheet } from '../CharacterSheet'; +import { AttackList } from './AttackList'; + +function makeCharacter(overrides: Partial = {}): Character { + return { + name: 'Test', + species: 'Human', + background: 'Soldier', + classes: [{ name: CharacterClass.Fighter, level: 1 }], + abilities: { + [Ability.Strength]: 10, + [Ability.Dexterity]: 10, + [Ability.Constitution]: 10, + [Ability.Intelligence]: 10, + [Ability.Wisdom]: 10, + [Ability.Charisma]: 10, + }, + savingThrowProficiencies: [], + skillProficiencies: [], + skillExpertise: [], + baseArmorClass: 10, + speed: 30, + hitPoints: { maximum: 10 }, + attacks: [], + equipment: [], + features: [], + armorProficiencies: [], + weaponProficiencies: [], + toolProficiencies: [], + languages: [], + ...overrides, + }; +} + +function renderAttackList(character: Character) { + return render( + + + + ); +} + +describe('AttackList', () => { + it('renders nothing when character has no attacks', () => { + const { container } = renderAttackList(makeCharacter()); + expect(container.firstChild).toBeNull(); + }); + + it('weapon attack: bonus = abilityMod + profBonus + attackBonusMod', () => { + // STR 16 → mod +3, level 1 → profBonus +2, attackBonusMod +1 → total +6 + const attack: Attack = { + name: 'Longsword', + kind: 'weapon', + ability: Ability.Strength, + attackBonusMod: 1, + damage: [{ dice: '1d8', type: 'Slashing' }], + }; + renderAttackList( + makeCharacter({ + abilities: { ...makeCharacter().abilities, [Ability.Strength]: 16 }, + attacks: [attack], + }) + ); + expect(screen.getByText('+6')).toBeInTheDocument(); + }); + + it('weapon attack with notProficient: profBonus excluded from bonus', () => { + // STR 16 → mod +3, notProficient → no profBonus → bonus = +3 + const attack: Attack = { + name: 'Longsword', + kind: 'weapon', + ability: Ability.Strength, + notProficient: true, + damage: [{ dice: '1d8', type: 'Slashing' }], + }; + renderAttackList( + makeCharacter({ + abilities: { ...makeCharacter().abilities, [Ability.Strength]: 16 }, + attacks: [attack], + }) + ); + expect(screen.getByText('+3')).toBeInTheDocument(); + }); + + it('spell-with-attack: displays derived spellAttackBonus', () => { + // INT 16 → mod +3, level 1 → profBonus +2 → spellAttackBonus = +5 + const attack: Attack = { + name: 'Firebolt', + kind: 'spell-with-attack', + damage: [{ dice: '1d10', type: 'Fire' }], + }; + renderAttackList( + makeCharacter({ + abilities: { ...makeCharacter().abilities, [Ability.Intelligence]: 16 }, + attacks: [attack], + spellcasting: { ability: Ability.Intelligence, spells: [] }, + }) + ); + expect(screen.getByText('+5')).toBeInTheDocument(); + }); + + it('spell-with-save: displays saveAbility short name and DC', () => { + // INT 16 → mod +3, level 1 → profBonus +2 → spellSaveDC = 8+2+3 = 13 + const attack: Attack = { + name: 'Thunderwave', + kind: 'spell-with-save', + saveAbility: Ability.Constitution, + damage: [{ dice: '2d8', type: 'Thunder' }], + }; + renderAttackList( + makeCharacter({ + abilities: { ...makeCharacter().abilities, [Ability.Intelligence]: 16 }, + attacks: [attack], + spellcasting: { ability: Ability.Intelligence, spells: [] }, + }) + ); + expect(screen.getByText('Con. save, DC 13')).toBeInTheDocument(); + }); + + it('mastery column appears when any attack has masteryProperty', () => { + const attack: Attack = { + name: 'Rapier', + kind: 'weapon', + ability: Ability.Dexterity, + masteryProperty: 'Vex', + damage: [{ dice: '1d8', type: 'Piercing' }], + }; + renderAttackList(makeCharacter({ attacks: [attack] })); + expect(screen.getByText('Mastery')).toBeInTheDocument(); + }); + + it('mastery column absent when no attack has masteryProperty', () => { + const attack: Attack = { + name: 'Dagger', + kind: 'weapon', + ability: Ability.Dexterity, + damage: [{ dice: '1d4', type: 'Piercing' }], + }; + renderAttackList(makeCharacter({ attacks: [attack] })); + expect(screen.queryByText('Mastery')).not.toBeInTheDocument(); + }); + + it('damage with disableModifier omits the bonus', () => { + // STR 16 → mod +3 (damageBonus), disableModifier=true → suffix omitted + const attack: Attack = { + name: 'Sneak Attack', + kind: 'weapon', + ability: Ability.Strength, + damage: [{ dice: '2d6', type: 'Piercing', disableModifier: true }], + }; + renderAttackList( + makeCharacter({ + abilities: { ...makeCharacter().abilities, [Ability.Strength]: 16 }, + attacks: [attack], + }) + ); + expect(screen.getByText('2d6 Piercing')).toBeInTheDocument(); + expect(screen.queryByText(/\+3/)).not.toBeInTheDocument(); + }); +}); diff --git a/packages/dnd-character-sheet/src/components/CharacterSheet.test.tsx b/packages/dnd-character-sheet/src/components/CharacterSheet.test.tsx new file mode 100644 index 00000000..d8176556 --- /dev/null +++ b/packages/dnd-character-sheet/src/components/CharacterSheet.test.tsx @@ -0,0 +1,79 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { Ability } from '../lib/models/abilities'; +import type { Character } from '../lib/models/character'; +import { CharacterClass } from '../lib/models/character-classes'; +import { Skill } from '../lib/models/skills'; +import { CharacterSheet, useCharacter } from './CharacterSheet'; + +function makeCharacter(overrides: Partial = {}): Character { + return { + name: 'Test', + species: 'Human', + background: 'Soldier', + classes: [{ name: CharacterClass.Fighter, level: 1 }], + abilities: { + [Ability.Strength]: 10, + [Ability.Dexterity]: 10, + [Ability.Constitution]: 10, + [Ability.Intelligence]: 10, + [Ability.Wisdom]: 10, + [Ability.Charisma]: 10, + }, + savingThrowProficiencies: [], + skillProficiencies: [], + skillExpertise: [], + baseArmorClass: 10, + speed: 30, + hitPoints: { maximum: 10 }, + attacks: [], + equipment: [], + features: [], + armorProficiencies: [], + weaponProficiencies: [], + toolProficiencies: [], + languages: [], + ...overrides, + }; +} + +function ContextDisplay() { + const { derived } = useCharacter(); + return ( +
+ {derived.proficiencyBonus} + + {derived.abilityModifiers[Ability.Strength]} + + {derived.skills[Skill.Athletics]} +
+ ); +} + +describe('CharacterSheet', () => { + it('provides derived stats to context consumers', () => { + render( + + + + ); + + expect(screen.getByTestId('prof-bonus')).toHaveTextContent('2'); + expect(screen.getByTestId('str-mod')).toHaveTextContent('0'); + expect(screen.getByTestId('athletics')).toHaveTextContent('0'); + }); + + it('recomputes proficiency bonus based on character level', () => { + render( + + + + ); + + expect(screen.getByTestId('prof-bonus')).toHaveTextContent('3'); + }); +}); diff --git a/packages/dnd-character-sheet/src/components/SpellcastingBlock/SpellList.test.tsx b/packages/dnd-character-sheet/src/components/SpellcastingBlock/SpellList.test.tsx new file mode 100644 index 00000000..de70ca6d --- /dev/null +++ b/packages/dnd-character-sheet/src/components/SpellcastingBlock/SpellList.test.tsx @@ -0,0 +1,150 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { Ability } from '../../lib/models/abilities'; +import type { Character } from '../../lib/models/character'; +import { CharacterClass } from '../../lib/models/character-classes'; +import type { Spell } from '../../lib/models/spellcasting'; +import { CharacterSheet } from '../CharacterSheet'; +import { VisualAdjustmentsContext } from '../VisualAdjustmentsContext'; +import { SpellList } from './SpellList'; + +function makeCharacter(overrides: Partial = {}): Character { + return { + name: 'Test', + species: 'Human', + background: 'Acolyte', + classes: [{ name: CharacterClass.Wizard, level: 1 }], + abilities: { + [Ability.Strength]: 10, + [Ability.Dexterity]: 10, + [Ability.Constitution]: 10, + [Ability.Intelligence]: 10, + [Ability.Wisdom]: 10, + [Ability.Charisma]: 10, + }, + savingThrowProficiencies: [], + skillProficiencies: [], + skillExpertise: [], + baseArmorClass: 10, + speed: 30, + hitPoints: { maximum: 8 }, + attacks: [], + equipment: [], + features: [], + armorProficiencies: [], + weaponProficiencies: [], + toolProficiencies: [], + languages: [], + spellcasting: { ability: Ability.Intelligence, spells: [] }, + ...overrides, + }; +} + +function renderSpellList( + spells: Spell[], + characterOverrides: Partial = {} +) { + const character = makeCharacter({ + ...characterOverrides, + spellcasting: { ability: Ability.Intelligence, spells }, + }); + return render( + + + + + + ); +} + +describe('SpellList', () => { + describe('sorting', () => { + it('renders spells sorted ascending by level', () => { + const spells: Spell[] = [ + { name: 'Fireball', level: 3 }, + { name: 'Magic Missile', level: 1 }, + { name: 'Mage Hand', level: 0 }, + ]; + renderSpellList(spells); + + const rows = screen.getAllByRole('row'); + // row 0 = header, row 1 = first spell, etc. + expect(rows[1]).toHaveTextContent('Mage Hand'); + expect(rows[2]).toHaveTextContent('Magic Missile'); + expect(rows[3]).toHaveTextContent('Fireball'); + }); + + it('alwaysPrepared spell appears before regular spell at the same level', () => { + const spells: Spell[] = [ + { name: 'Magic Missile', level: 1 }, + { name: 'Shield', level: 1, alwaysPrepared: true }, + ]; + renderSpellList(spells); + + const rows = screen.getAllByRole('row'); + expect(rows[1]).toHaveTextContent('Shield'); + expect(rows[2]).toHaveTextContent('Magic Missile'); + }); + + it('sorts alphabetically within the same level and prep status', () => { + const spells: Spell[] = [ + { name: 'Thunderwave', level: 1 }, + { name: 'Burning Hands', level: 1 }, + ]; + renderSpellList(spells); + + const rows = screen.getAllByRole('row'); + expect(rows[1]).toHaveTextContent('Burning Hands'); + expect(rows[2]).toHaveTextContent('Thunderwave'); + }); + }); + + describe('prep column', () => { + it('shows "AP" for alwaysPrepared spells', () => { + renderSpellList([{ name: 'Shield', level: 1, alwaysPrepared: true }]); + expect(screen.getByText('AP')).toBeInTheDocument(); + }); + + it('shows no checkbox and no "AP" for cantrips (level 0)', () => { + renderSpellList([{ name: 'Firebolt', level: 0 }]); + expect(screen.queryByText('AP')).not.toBeInTheDocument(); + // CircleCheck renders a span with rounded-full; cantrips render an empty span instead + expect(document.querySelector('.rounded-full')).toBeNull(); + }); + }); + + describe('casting time display', () => { + it('normalises "action" (any case) to "Action"', () => { + renderSpellList([{ name: 'Firebolt', level: 0, castingTime: 'action' }]); + expect(screen.getByText('Action')).toBeInTheDocument(); + }); + + it('normalises "bonus action" (any case) to "Bonus"', () => { + renderSpellList([ + { name: 'Healing Word', level: 1, castingTime: 'bonus action' }, + ]); + expect(screen.getByText('Bonus')).toBeInTheDocument(); + }); + }); + + describe('alternate ability', () => { + it('shows short name, to-hit, and DC for spells with alternativeAbility', () => { + // STR 16 → mod +3, level 1 → profBonus +2 → toHit +5, DC 13 + renderSpellList( + [ + { + name: 'Shillelagh', + level: 0, + alternativeAbility: Ability.Strength, + }, + ], + { abilities: { ...makeCharacter().abilities, [Ability.Strength]: 16 } } + ); + expect(screen.getByText(/Str\. spell/)).toBeInTheDocument(); + expect(screen.getByText(/\+5 to Hit/)).toBeInTheDocument(); + expect(screen.getByText(/DC 13/)).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/dnd-character-sheet/src/lib/calculate-derived-stats.test.ts b/packages/dnd-character-sheet/src/lib/calculate-derived-stats.test.ts new file mode 100644 index 00000000..3cf4119d --- /dev/null +++ b/packages/dnd-character-sheet/src/lib/calculate-derived-stats.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest'; +import { + abilityModifier, + calculateStats, + proficiencyBonus, +} from './calculate-derived-stats'; +import { Ability } from './models/abilities'; +import type { Character } from './models/character'; +import { CharacterClass } from './models/character-classes'; +import { Skill } from './models/skills'; + +const baseCharacter: Character = { + name: 'Test Character', + species: 'Human', + background: 'Soldier', + classes: [{ name: CharacterClass.Fighter, level: 5 }], + abilities: { + [Ability.Strength]: 10, + [Ability.Dexterity]: 10, + [Ability.Constitution]: 10, + [Ability.Intelligence]: 10, + [Ability.Wisdom]: 14, + [Ability.Charisma]: 10, + }, + savingThrowProficiencies: [], + skillProficiencies: [], + skillExpertise: [], + baseArmorClass: 10, + speed: 30, + hitPoints: { maximum: 40 }, + attacks: [], + equipment: [], + features: [], + armorProficiencies: [], + weaponProficiencies: [], + toolProficiencies: [], + languages: [], +}; + +describe('abilityModifier', () => { + it('returns correct modifier for boundary and representative scores', () => { + expect(abilityModifier(1)).toBe(-5); + expect(abilityModifier(3)).toBe(-4); + expect(abilityModifier(10)).toBe(0); + expect(abilityModifier(11)).toBe(0); + expect(abilityModifier(15)).toBe(2); + expect(abilityModifier(20)).toBe(5); + }); +}); + +describe('proficiencyBonus', () => { + it('returns correct bonus for all five level tiers', () => { + expect(proficiencyBonus(1)).toBe(2); + expect(proficiencyBonus(4)).toBe(2); + expect(proficiencyBonus(5)).toBe(3); + expect(proficiencyBonus(8)).toBe(3); + expect(proficiencyBonus(9)).toBe(4); + expect(proficiencyBonus(12)).toBe(4); + expect(proficiencyBonus(13)).toBe(5); + expect(proficiencyBonus(16)).toBe(5); + expect(proficiencyBonus(17)).toBe(6); + expect(proficiencyBonus(20)).toBe(6); + }); +}); + +describe('calculateStats', () => { + it('applies static-skill-additions statMod to the target skill', () => { + // WIS is 14 → modifier +2. Perception is a WIS skill. No proficiency. + // Base Perception = +2. Feature adds +3 → expect +5. + const character: Character = { + ...baseCharacter, + features: [ + { + name: 'Keen Senses', + description: 'You have keen senses.', + statMod: { + kind: 'static-skill-additions', + mods: [{ skill: Skill.Perception, modifier: 3 }], + }, + }, + ], + }; + + const stats = calculateStats(character); + expect(stats.skills[Skill.Perception]).toBe(5); + }); + + it('applies skill-function statMod to override a skill bonus', () => { + const character: Character = { + ...baseCharacter, + features: [ + { + name: 'Shadow Step', + description: 'Your stealth is supernatural.', + statMod: { + kind: 'skill-function', + mod: ({ skill, currentBonus }) => + skill === Skill.Stealth ? 42 : currentBonus, + }, + }, + ], + }; + + const stats = calculateStats(character); + expect(stats.skills[Skill.Stealth]).toBe(42); + }); + + it('applies generic-derived statMod to mutate the full DerivedStats object', () => { + const character: Character = { + ...baseCharacter, + features: [ + { + name: 'Uncanny Reflexes', + description: 'Your reflexes defy explanation.', + statMod: { + kind: 'generic-derived', + mod: (stats) => ({ ...stats, initiative: 99 }), + }, + }, + ], + }; + + const stats = calculateStats(character); + expect(stats.initiative).toBe(99); + }); +}); diff --git a/packages/dnd-character-sheet/src/lib/calculate-derived-stats.ts b/packages/dnd-character-sheet/src/lib/calculate-derived-stats.ts index 345cef7f..232426b3 100644 --- a/packages/dnd-character-sheet/src/lib/calculate-derived-stats.ts +++ b/packages/dnd-character-sheet/src/lib/calculate-derived-stats.ts @@ -6,11 +6,11 @@ import type { DerivedStats } from './models/derived-stats'; import type { Feature } from './models/feature'; import { AbilitySkillGrouping, Skill } from './models/skills'; -function abilityModifier(score: number): number { +export function abilityModifier(score: number): number { return Math.floor((score - 10) / 2); } -function proficiencyBonus(level: number): number { +export function proficiencyBonus(level: number): number { if (level <= 4) return 2; if (level <= 8) return 3; if (level <= 12) return 4; diff --git a/packages/dnd-character-sheet/src/lib/character-brief.test.ts b/packages/dnd-character-sheet/src/lib/character-brief.test.ts new file mode 100644 index 00000000..938888c2 --- /dev/null +++ b/packages/dnd-character-sheet/src/lib/character-brief.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { getCharacterBrief } from './character-brief'; +import { Ability } from './models/abilities'; +import type { Character } from './models/character'; +import { CharacterClass } from './models/character-classes'; + +const baseCharacter: Character = { + name: 'Aria', + species: 'Elf', + background: 'Sage', + classes: [{ name: CharacterClass.Wizard, level: 5 }], + abilities: { + [Ability.Strength]: 8, + [Ability.Dexterity]: 14, + [Ability.Constitution]: 12, + [Ability.Intelligence]: 18, + [Ability.Wisdom]: 13, + [Ability.Charisma]: 10, + }, + savingThrowProficiencies: [], + skillProficiencies: [], + skillExpertise: [], + baseArmorClass: 12, + speed: 30, + hitPoints: { maximum: 30 }, + attacks: [], + equipment: [], + features: [], + armorProficiencies: [], + weaponProficiencies: [], + toolProficiencies: [], + languages: [], +}; + +describe('getCharacterBrief', () => { + it('returns correct name and summed level for single-class and multi-class characters', () => { + const singleClass = getCharacterBrief(baseCharacter); + expect(singleClass.name).toBe('Aria'); + expect(singleClass.level).toBe(5); + + const multiClass = getCharacterBrief({ + ...baseCharacter, + classes: [ + { name: CharacterClass.Wizard, level: 4 }, + { name: CharacterClass.Sorcerer, level: 3 }, + ], + }); + expect(multiClass.name).toBe('Aria'); + expect(multiClass.level).toBe(7); + }); +}); diff --git a/packages/dnd-character-sheet/src/lib/text-enrichment.test.ts b/packages/dnd-character-sheet/src/lib/text-enrichment.test.ts new file mode 100644 index 00000000..be0b3044 --- /dev/null +++ b/packages/dnd-character-sheet/src/lib/text-enrichment.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { Ability } from './models/abilities'; +import type { Character } from './models/character'; +import { CharacterClass } from './models/character-classes'; +import type { DerivedStats } from './models/derived-stats'; +import { Skill } from './models/skills'; +import { enrichCharacterData } from './text-enrichment'; + +const baseCharacter: Character = { + name: 'Kira', + species: 'Halfling', + background: 'Criminal', + classes: [{ name: CharacterClass.Rogue, level: 4 }], + abilities: { + [Ability.Strength]: 10, + [Ability.Dexterity]: 16, + [Ability.Constitution]: 12, + [Ability.Intelligence]: 10, + [Ability.Wisdom]: 10, + [Ability.Charisma]: 10, + }, + savingThrowProficiencies: [], + skillProficiencies: [], + skillExpertise: [], + baseArmorClass: 13, + speed: 25, + hitPoints: { maximum: 25 }, + attacks: [], + equipment: [], + features: [], + armorProficiencies: [], + weaponProficiencies: [], + toolProficiencies: [], + languages: [], +}; + +const stubStats: DerivedStats = { + abilityModifiers: { + [Ability.Strength]: 0, + [Ability.Dexterity]: 3, + [Ability.Constitution]: 1, + [Ability.Intelligence]: 0, + [Ability.Wisdom]: 0, + [Ability.Charisma]: 0, + }, + proficiencyBonus: 2, + savingThrows: { + [Ability.Strength]: 0, + [Ability.Dexterity]: 5, + [Ability.Constitution]: 1, + [Ability.Intelligence]: 0, + [Ability.Wisdom]: 0, + [Ability.Charisma]: 0, + }, + skills: Object.fromEntries( + Object.values(Skill).map((s) => [s, 0]) + ) as DerivedStats['skills'], + initiative: 3, + passivePerception: 10, + level: { total: 4 }, + hitDice: [{ dice: 'd8', count: 4 }], +}; + +describe('enrichCharacterData', () => { + it('interpolates EJS tokens in feature descriptions using derived stats', () => { + const character: Character = { + ...baseCharacter, + features: [ + { + name: 'Cunning Action', + description: 'Your proficiency bonus is <%= proficiencyBonus %>.', + }, + ], + }; + + const result = enrichCharacterData(character, stubStats); + expect(result.features[0].description).toBe('Your proficiency bonus is 2.'); + }); + + it('does not mutate non-template character fields', () => { + const character: Character = { + ...baseCharacter, + features: [{ name: 'Sneak Attack', description: 'Static description.' }], + }; + + const result = enrichCharacterData(character, stubStats); + expect(result.name).toBe('Kira'); + expect(result.abilities[Ability.Dexterity]).toBe(16); + expect(result.features[0].description).toBe('Static description.'); + }); +}); diff --git a/packages/dnd-character-sheet/src/lib/utils.test.ts b/packages/dnd-character-sheet/src/lib/utils.test.ts new file mode 100644 index 00000000..ab982a8e --- /dev/null +++ b/packages/dnd-character-sheet/src/lib/utils.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { formatMod, formatModIgnoreZero } from './utils'; + +describe('formatMod and formatModIgnoreZero', () => { + it('formats positive, negative, and zero modifiers correctly', () => { + expect(formatMod(-3)).toBe('-3'); + expect(formatMod(0)).toBe('+0'); + expect(formatMod(5)).toBe('+5'); + + expect(formatModIgnoreZero(-3)).toBe('-3'); + expect(formatModIgnoreZero(0)).toBe(''); + expect(formatModIgnoreZero(5)).toBe('+5'); + }); +}); diff --git a/packages/dnd-character-sheet/tsconfig.json b/packages/dnd-character-sheet/tsconfig.json index 2274edf9..613a8ead 100644 --- a/packages/dnd-character-sheet/tsconfig.json +++ b/packages/dnd-character-sheet/tsconfig.json @@ -4,6 +4,7 @@ "exclude": ["node_modules", "dist"], "compilerOptions": { "rootDir": "./src", - "outDir": "dist" + "outDir": "dist", + "types": ["@testing-library/jest-dom"] } } diff --git a/packages/dnd-character-sheet/vitest.config.ts b/packages/dnd-character-sheet/vitest.config.ts new file mode 100644 index 00000000..0cc4508b --- /dev/null +++ b/packages/dnd-character-sheet/vitest.config.ts @@ -0,0 +1,11 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + setupFiles: ['@ageorgedev/testing-config/react-jsdom-test-setup.ts'], + include: ['src/**/*.test.{ts,tsx}'], + }, +}); diff --git a/yarn.lock b/yarn.lock index 13f4e5b2..84b7268f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -155,17 +155,22 @@ __metadata: version: 0.0.0-use.local resolution: "@ageorgedev/dnd-character-sheet@workspace:packages/dnd-character-sheet" dependencies: + "@ageorgedev/testing-config": "workspace:*" "@ageorgedev/toolbelt": "workspace:*" "@ageorgedev/ts-config": "workspace:*" + "@testing-library/jest-dom": "npm:6.9.1" + "@testing-library/react": "npm:16.3.2" "@types/ejs": "npm:^3" "@types/react": "npm:19.2.17" "@types/react-dom": "npm:19.2.3" + "@vitejs/plugin-react": "npm:6.0.2" concurrently: "npm:10.0.3" cpx2: "npm:9.0.0" dompurify: "npm:3.4.11" ejs: "npm:^3.1.10" ramda: "npm:0.32.0" typescript: "npm:5.8.3" + vitest: "npm:4.1.8" peerDependencies: react: 19.2.7 react-dom: 19.2.7 @@ -189,6 +194,15 @@ __metadata: languageName: unknown linkType: soft +"@ageorgedev/game-tools-e2e@workspace:apps/game-tools-e2e": + version: 0.0.0-use.local + resolution: "@ageorgedev/game-tools-e2e@workspace:apps/game-tools-e2e" + dependencies: + "@playwright/test": "npm:1.60.0" + concurrently: "npm:10.0.3" + languageName: unknown + linkType: soft + "@ageorgedev/game-tools@workspace:apps/game-tools": version: 0.0.0-use.local resolution: "@ageorgedev/game-tools@workspace:apps/game-tools"