Bug 1 — loreResolver no longer uses lore-index chunks as randomizable values. A disposition placeholder resolved to the 'All NPCs' index (Overview + [[wiki- links]]), corrupting the opening narrative. Filter out index-like chunks (Overview/Entries/Index header OR >=2 [[links]]) from candidates; fall back if only index chunks remain. TDD: tests/unit/loreResolver.test.ts (4 tests). (Follow-up: short-value randomizables like dispositions should use source:vocabulary — the engine guard is the safety net.) Bug 2 — begin announcement now precedes the opening narrative. handleStartBtn uses interaction.update (edits the lobby embed to the announcement) BEFORE beginEncounter posts the opening, so the thread reads announcement -> opening. Bug 3 — lobby close updates the UI + renames the thread. On Begin: the lobby embed is closed in place (Join/Begin buttons removed, replaced by the announcement) + the thread is renamed '... — underway'. On Cancel: the thread is renamed '... — cancelled' before archive. Live test AC-8 asserts the embed is closed (no buttons + announcement) + the thread rename. Verified: tsc clean, 543 unit tests pass (+4), live multiplayer E2E 3/3. Co-Authored-By: Claude <noreply@anthropic.com>
Tests
This directory holds the project's automated test suite.
Layout
tests/
├── fixtures/ Shared test fixtures (spec, session, etc.)
├── integration/ Integration tests (require live infrastructure)
├── unit/ Unit tests (default CI gate)
└── README.md You are here
unit/— fast, isolated tests for individual modules. No network, no Redis, no Discord gateway. The CI default runs only this directory.integration/— slower tests that exercise real services (or mocks close to the wire). Run explicitly; not part of the default test command.fixtures/— reusable mocks (mockSession,mockSpec) shared by multiple unit tests.
Running
npm test # vitest run — runs ALL tests, including integration (requires live infra)
npm run test:unit # run only tests/unit
npm run test:int # run only tests/integration (requires live infra)
npm run test:coverage # run unit tests with v8 coverage report
npm run test:watch # vitest in watch mode
CI default:
.gitea/workflows/test.ymlrunsnpm run test:unit(and the coverage report), notnpm test— so a missing Redis or GraphMCP endpoint will not fail CI. Runnpm run test:intexplicitly only when you intend to exercise the live-infrastructure path.
Conventions
1. One module per file
A test file covers one source module. File name: <moduleName>.test.ts,
placed under tests/unit/. If a source module exports multiple functions
worth testing, group them with describe blocks in the same file.
2. Mock before import — always
vi.mock calls must appear before the import of the module under test,
otherwise the unmocked module is already cached. The pattern:
import { vi, describe, it, expect, beforeEach } from 'vitest';
const { mockFn } = vi.hoisted(() => ({ mockFn: vi.fn() }));
vi.mock('../../src/lib/logger.js', () => ({
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
// ...more mocks...
import { myFunction } from '../../src/some/module.js'; // AFTER mocks
vi.hoisted lets you share mock state between a vi.mock factory and the
test body — both run in the same scope.
3. vi.clearAllMocks() in beforeEach
Prevents test bleed-through. If you also mutate config or module-level
state, reset it explicitly in beforeEach.
4. Reuse mockSession and mockSpec
Import from ../fixtures/spec.js. Don't redefine session shape per file —
schema drift is one of the easier ways for tests to silently rot.
5. Test the behavior, not the implementation
Assert outcomes (return values, side effects on real collaborators, error messages) rather than calling patterns. When a test would only pass with a specific internal implementation, ask whether the contract is what's documented in the source's doc comment.
6. Don't hit the network
fetch→ usevi.stubGlobal('fetch', ...)(seefoundryClientRetry.test.ts).- Discord client → pass a hand-rolled mock with only the methods the code uses
(e.g.
messages.fetch,send,sendTyping,setArchived). - Redis → use
ioredis-mock(seesessionManager.test.ts). - LLM SDKs → mock the constructor (see
litellmClient.test.ts,ollamaClient.test.ts). - Filesystem → use
mkdtempSyncfromnode:os.tmpdir()(seepersonaLoader.test.ts).
7. Player-facing strings
When a test asserts on a string the bot would say to a player, prefer
in-world language over utility terms. (Same rule that applies to production
code — see feedback-in-world-voice memory.)
Anti-patterns
- Asserting private state. Reach for behaviour-side assertions first.
- Resetting state with
vi.resetModules()for the sake of it. It breaks shared mock state. Use it only when a module-scoped cache (e.g. a lazy client) needs to be re-constructed. - Catching all errors in a test. If a test passes by accident because an unhandled rejection was swallowed, it's not testing anything.
- Mocking the module under test. If you have to mock the file you're testing, the test is asserting nothing.
- Timeouts in
it()callbacks. Usevi.useFakeTimers()andvi.advanceTimersByTimeAsyncto step time deterministically (seemessageRouterRunLLMTurn.test.tsfor the typing-indicator pattern).
Adding a new test
- Create
tests/unit/<name>.test.ts. - Use the closest existing test as a template —
goalRegister.test.tsfor tool plugins,foundryClientRetry.test.tsfor HTTP,relaySession.test.tsfornode:https/node:crypto,sessionManager.test.tsfor Redis. - Run
npm run test:unit -- <your-file>to iterate quickly. - When green, run the full suite:
npm run test:unit. - Optional: check
npm run test:coverageto confirm the file's coverage.
Coverage
npm run test:coverage produces a v8 coverage report in the terminal.
Directories worth watching:
src/bot/handlers/— message routing;runLLMTurnis the runtime heart.src/harness/tools/— the tool plugin contracts.src/vtt/— Foundry relay;foundryClientis the biggest single file.
Coverage is informational, not a gate. The goal is to grow the unit test surface for the modules that own irreversible or user-facing behavior.