diff --git a/.agents/docs/architecture/auth-and-permissions.md b/.agents/docs/architecture/auth-and-permissions.md index 563916dec..90b153eec 100644 --- a/.agents/docs/architecture/auth-and-permissions.md +++ b/.agents/docs/architecture/auth-and-permissions.md @@ -210,12 +210,12 @@ add it to the `$GroupScopableSubjectName` exclusion. _and_ a deny. - `src/auth/__tests__/ability.utils.test.ts` — pins the `undefined` ability behavior and `forcedAppSubject`. -- `src/auth/guards/__tests__/jwt-auth.guard.spec.ts` — the guard's own branches. +- `src/auth/guards/__tests__/jwt-auth.guard.test.ts` — the guard's own branches. Service tests mock the Prisma layer, so they verify the arguments passed to the model, not the database's answer. Assert the `where` clause the same way the existing specs assert `data`: `model..mock.lastCall?.[0]` plus `toMatchObject` — see -`src/groups/__tests__/groups.service.spec.ts`. +`src/groups/__tests__/groups.service.test.ts`. Row-level scoping is not observable in a unit test with a mocked model, which is why an end-to-end test in `testing/` is required for anything that changes who can see what. diff --git a/.agents/docs/architecture/testing-strategy.md b/.agents/docs/architecture/testing-strategy.md index 735955abf..6a4768b8c 100644 --- a/.agents/docs/architecture/testing-strategy.md +++ b/.agents/docs/architecture/testing-strategy.md @@ -17,11 +17,11 @@ of operations. ## Tiers -| Tier | Runner | Where | Command | -| ---------- | ---------- | -------------------------------------------------------- | -------------------- | -| Unit | vitest | `src/__tests__/` (or `test/`) in a participating package | `pnpm test:coverage` | -| End-to-end | Playwright | `testing/src/specs/` | `pnpm test:e2e` | -| Type-check | `tsc` | — | `pnpm lint` | +| Tier | Runner | Where | Command | +| ---------- | ---------- | --------------------------------------------------------------------- | -------------------- | +| Unit | vitest | `__tests__/.test.ts` beside `.ts`, or a package's `test/` | `pnpm test:coverage` | +| End-to-end | Playwright | `testing/src/specs/` | `pnpm test:e2e` | +| Type-check | `tsc` | — | `pnpm lint` | There is no integration tier. `apps/api` unit tests mock the Prisma layer entirely; the only code path exercised against a real database is Playwright. @@ -33,7 +33,7 @@ package's config, which is **not** always the directory name. | Project | Package | Notable config | | ------------------------ | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `api` | `apps/api` | libnest SWC plugin; `globals: true`; extra `include` for `src/**/*.spec.ts` | +| `api` | `apps/api` | libnest SWC plugin, confined to `apps/api` sources; `globals: true` | | `web` | `apps/web` | `environment: 'happy-dom'`; redeclares the `@` alias | | `gateway` | `apps/gateway` | node environment; redeclares the `@` alias. Request schemas, `resolveLanguage`, and the HTML `src/entry-server.tsx` renders — the routers and hydration are covered by `testing/src/specs/gateway-*.spec.ts`. A component test opts into happy-dom per file with a `// @vitest-environment happy-dom` docblock | | `instrument-bundler` | `packages/instrument-bundler` | | @@ -75,7 +75,7 @@ Read the canonical file before writing a test in that tier. | Package | Canonical file | Shape | | ----------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `apps/api` | `src/groups/__tests__/groups.service.spec.ts` | `Test.createTestingModule` + `MockFactory.createForModelToken(getModelToken('Group'))` from `@douglasneuroinformatics/libnest/testing`; mocks typed `MockedInstance>`; assert with `model.create.mock.lastCall?.[0]` and `toMatchObject`. A fresh module per test, so no mock state to reset. | +| `apps/api` | `src/groups/__tests__/groups.service.test.ts` | `Test.createTestingModule` + `MockFactory.createForModelToken(getModelToken('Group'))` from `@douglasneuroinformatics/libnest/testing`; mocks typed `MockedInstance>`; assert with `model.create.mock.lastCall?.[0]` and `toMatchObject`. A fresh module per test, so no mock state to reset. | | `apps/web` | `src/hooks/__tests__/useInstrumentBundle.test.ts` | `vi.hoisted` + `vi.mock` for `axios` and `@/store`; a **real** `QueryClient` per test with `retry: false`, wrapped in `QueryClientProvider`. | | `packages/react-core` | `src/components/InstrumentRenderer/__tests__/SeriesInstrumentRenderer.test.tsx` | Renders a component against hand-written bundles — an async IIFE per instrument, evaluated by the real interpreter. `i18n.init({ translations: {} })` in `beforeAll`, `afterEach(cleanup)`, and `fireEvent` rather than `userEvent`, which is not installed. | | `packages/schemas` | `src/instrument/__tests__/instrument.form.test.ts` | Parse fixtures imported from `@opendatacapture/instrument-stubs/*`, never hand-written literals, so schema and fixture cannot drift. | diff --git a/.agents/docs/packages/libnest.md b/.agents/docs/packages/libnest.md index b8938a608..ed6e27709 100644 --- a/.agents/docs/packages/libnest.md +++ b/.agents/docs/packages/libnest.md @@ -111,7 +111,7 @@ return { client } satisfies PrismaModuleOptions; Everywhere else, inject a model: `@InjectModel('Group') private readonly groupModel: Model<'Group'>`. -### Unit tests (`apps/api/src/**/__tests__/*.spec.ts`) +### Unit tests (`apps/api/src/**/__tests__/*.test.ts`) ```ts import { getModelToken } from '@douglasneuroinformatics/libnest'; diff --git a/.agents/docs/playbooks/add-api-endpoint.md b/.agents/docs/playbooks/add-api-endpoint.md index 0396b1418..4f858197c 100644 --- a/.agents/docs/playbooks/add-api-endpoint.md +++ b/.agents/docs/playbooks/add-api-endpoint.md @@ -62,8 +62,8 @@ AppSubject` in `apps/api/prisma/schema.prisma` and `$AppSubjectName` in (`apps/api/src/auth/ability.factory.ts`); that table is typed over `$GroupScopableSubjectName`, so `tsc` reports the omission. -9. **Service spec** at `apps/api/src//__tests__/.service.spec.ts`. Copy the setup - from `apps/api/src/groups/__tests__/groups.service.spec.ts`: +9. **Service spec** at `apps/api/src//__tests__/.service.test.ts`. Copy the setup + from `apps/api/src/groups/__tests__/groups.service.test.ts`: `MockFactory.createForModelToken(getModelToken('X'))` per injected model, then assert with `model..mock.lastCall?.[0]` and `toMatchObject`. Assert the `where` clause, not only `data`. diff --git a/.agents/docs/playbooks/add-vitest-project.md b/.agents/docs/playbooks/add-vitest-project.md index a5cbe6070..da582ff6e 100644 --- a/.agents/docs/playbooks/add-vitest-project.md +++ b/.agents/docs/playbooks/add-vitest-project.md @@ -90,13 +90,11 @@ of your file means the config is absent. the projects directly and never reads this script; `vitest` is a root devDependency, so the package declares nothing. -7. **Place the test file inside the inherited globs.** Those are - `**/*.{test,spec}.?(c|m)[jt]s?(x)` and `**/*/test.?(c|m)[jt]s?(x)`, both relative to the package - `root`; the house convention is `src/__tests__/.test.ts`. Only reach for a project-level - `include` when a file must live outside them — `mergeConfig` concatenates arrays, so a project - `include` _adds_ to the root globs rather than replacing them. `apps/api` is the proof: its own - `include` is `['src/**/*.spec.ts', 'test/**/*.test.ts']`, and - `src/auth/__tests__/ability.utils.test.ts` matches neither, yet is collected. +7. **Place the test file inside the inherited glob.** That is `**/*.{test,spec}.?(c|m)[jt]s?(x)`, + relative to the package `root`; the house convention is a `__tests__/` folder beside the subject, + with the spec named after it (`src/__tests__/index.test.ts` tests `src/index.ts`). Only reach for + a project-level `include` when a file must live outside that glob — `mergeConfig` concatenates + arrays, so a project `include` _adds_ to the root glob rather than replacing it. 8. **Prove the file is collected, then prove it can fail.** If the package imports `instrument-library`, `runtime-core` or `runtime/v1`, run `pnpm build` once first — those three diff --git a/.agents/docs/playbooks/add-web-route.md b/.agents/docs/playbooks/add-web-route.md index 62e4a47dc..445cc29ec 100644 --- a/.agents/docs/playbooks/add-web-route.md +++ b/.agents/docs/playbooks/add-web-route.md @@ -63,9 +63,10 @@ That error is the expected state of a correct new route, not a bug to fix. Never right `ability.can(...)`. `NavItem.url` is typed `url?: string`, so a wrong URL there compiles and 404s at runtime — copy it from your `createFileRoute` literal. -9. **Put nothing else in `src/routes/`.** Tests go in `apps/web/src/__tests__/`, helpers in - `src/components/` or `src/utils/`. Importing `vitest` or `@testing-library/*` from `src/routes/` - is an eslint error. +9. **Put nothing else in `src/routes/` but the route's `__tests__/` folder.** Helpers go in + `src/components/` or `src/utils/`. The generator skips `__tests__/` folders only because of + `routeFileIgnorePattern` in `apps/web/vite.config.ts`, and importing `vitest` or + `@testing-library/*` anywhere else under `src/routes/` is an eslint error. 10. **Ask the user to regenerate the route tree** (it happens when they run `pnpm dev` for `web`). Steps 11 and 12, and `pnpm lint`, cannot pass before this. @@ -75,7 +76,8 @@ That error is the expected state of a correct new route, not a bug to fix. Never `testing/src/generated/route.d.ts`. The new route literal does not exist for `getPageModel` or the `pageModels` map until this runs. You may run this one yourself. -12. **Add the tests.** A unit test under `apps/web/src/__tests__/`, and an e2e test — +12. **Add the tests.** A unit test named after the route file, in a `__tests__/` folder beside it + (`routes/_app/admin/__tests__/mail.test.tsx` tests `routes/_app/admin/mail.tsx`), and an e2e test — `.agents/docs/playbooks/add-e2e-test.md`, which covers the page object and registering it in `testing/src/support/fixtures.ts`. diff --git a/.agents/skills/odc-agent-docs/SKILL.md b/.agents/skills/odc-agent-docs/SKILL.md index 2262cc2be..cd15937a2 100644 --- a/.agents/skills/odc-agent-docs/SKILL.md +++ b/.agents/skills/odc-agent-docs/SKILL.md @@ -72,7 +72,7 @@ is optional and holds what no step owns (`add-blog-post.md`). - **The trap comes before the conventions**, under `## Traps` or a heading that states the trap as a sentence (`## Nothing in this directory is checked by anything`, `vendor/AGENTS.md`). - **Name the canonical file to read** for each recurring task, by path, the way `apps/api/AGENTS.md` - names `src/groups/__tests__/groups.service.spec.ts`. + names `src/groups/__tests__/groups.service.test.ts`. - **Close on how the work is checked** — `## Tests` giving `pnpm exec vitest --project `, or stating there is no project and what covers the behaviour instead (`packages/react-core/AGENTS.md`). Where vitest is not the answer the section keeps another name (`docs/AGENTS.md`, diff --git a/.agents/skills/odc-testing/SKILL.md b/.agents/skills/odc-testing/SKILL.md index 4c19cfd60..e129acf06 100644 --- a/.agents/skills/odc-testing/SKILL.md +++ b/.agents/skills/odc-testing/SKILL.md @@ -34,7 +34,7 @@ and the e2e test carries the change, or this change adds its `vitest.config.ts`. | A type contract — inference, a discriminated union | The type-check, which is `tsc` inside lint | `pnpm lint` | **There is no integration tier, and the middle row is why.** `apps/api` unit tests mock the Prisma -layer entirely (`apps/api/src/groups/__tests__/groups.service.spec.ts`), so the only code path +layer entirely (`apps/api/src/groups/__tests__/groups.service.test.ts`), so the only code path exercised against a database is Playwright: a permissions or scoping change that a green unit suite appears to cover has not been tested at all. The type-check tier has no file of its own — a type that must hold is asserted by code that would not compile if it broke. @@ -73,9 +73,7 @@ package. Two things it does not carry: and `env-cmd` exits before vitest starts when `.env` is absent. - **happy-dom, which `apps/web` runs under, computes no layout**: `getBoundingClientRect` and `offsetWidth` return 0, so assert on rendered content, never on geometry or visibility. happy-dom - ≥ 20 supplies its own no-op `ResizeObserver`, so the stub in - `apps/web/src/__tests__/data-table-server-mode.test.tsx` never assigns — copying it buys nothing, - whatever `apps/web/AGENTS.md` §Tests still says about needing one. + ≥ 20 supplies its own no-op `ResizeObserver`, so no test needs to stub one. Writing a component test under `apps/web`? That same §Tests adds the `@/services/i18n` side-effect import libui controls need. diff --git a/.agents/skills/odc-web-route/SKILL.md b/.agents/skills/odc-web-route/SKILL.md index d4a391a5f..a1cc95240 100644 --- a/.agents/skills/odc-web-route/SKILL.md +++ b/.agents/skills/odc-web-route/SKILL.md @@ -61,8 +61,9 @@ component's hook passes. Add a `data-testid` to whatever the e2e test will click or assert on, then open `.agents/docs/playbooks/add-e2e-test.md` before writing the spec — it owns the selector contract and -the fixture registration. The unit test goes where the change is: `src/hooks/__tests__/` for a query -hook, which is the usual case for a route, otherwise `src/utils/__tests__/` or `src/__tests__/`. +the fixture registration. The unit test goes in a `__tests__/` folder beside the file that changed, +named after it: `src/hooks/__tests__/` for a query hook, `src/routes/**/__tests__/` for the route +itself. The playbook's `## Verify` block is the check to run, with one ordering caveat: `pnpm test:e2e` boots `apps/web` through `pnpm dev:test` (`testing/playwright.config.ts`), which regenerates diff --git a/AGENTS.md b/AGENTS.md index bd1d66e07..259b7a3e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -203,7 +203,11 @@ explains it. tooling rearrange it. Where ordering is load-bearing, the existing code carries an explicit `eslint-disable` comment; follow that pattern rather than fighting the rule. -**Tests.** Keep test bodies short; each verifies one behavior. Descriptions are concise, grammatical +**Tests.** A unit test is `.test.ts(x)` in a `__tests__/` folder beside the file it tests: +`src/index.ts` is tested by `src/__tests__/index.test.ts`. A suite that drives a whole package, like +`apps/api/test/`, lives in that package's `test/` folder instead. Two eslint rules (`odc/*` in +`eslint.config.js`) enforce the layout; `packages/instrument-library` is the one exception, for the +reason its `AGENTS.md` gives. Keep test bodies short; each verifies one behavior. Descriptions are concise, grammatical sentences stating the behavioral reason, e.g. `'should key the cache on every parameter, so paging does not serve a stale page'`. diff --git a/apps/api/AGENTS.md b/apps/api/AGENTS.md index a886feff3..ec418bb23 100644 --- a/apps/api/AGENTS.md +++ b/apps/api/AGENTS.md @@ -39,7 +39,7 @@ src// .module.ts @Module({ controllers, exports: [Service], providers: [Service] }) .controller.ts @Controller(path); @RouteAccess on every handler .service.ts @Injectable; @InjectModel(...) per model - __tests__/.service.spec.ts + __tests__/.service.test.ts ``` There is no repository layer and no `entity/` folder — `Model<'X'>` _is_ the repository. @@ -170,7 +170,7 @@ which the client factory awaits after `$connect()`. MongoDB creates a collection with only its `_id_` index, so without that step a deployed instance scans whole collections and enforces none of the schema's unique constraints. Adding an index means editing **both** `schema.prisma` and `DATABASE_INDEXES`, which -`src/core/__tests__/prisma.spec.ts` asserts agree — `db push` drops any index the schema +`src/core/__tests__/prisma.test.ts` asserts agree — `db push` drops any index the schema does not declare, and a runtime-only index would not survive one. **Do not point `prisma db push` at a database this application uses.** `InstrumentRecord.assignmentId` @@ -214,8 +214,8 @@ Adding a variable touches several files that must agree — follow `pnpm exec vitest --project api` runs two kinds of test, both in the `api` project. -**Unit tests** — `src/**/*.spec.ts`, the bulk of the suite — mock the Prisma layer. -`src/groups/__tests__/groups.service.spec.ts` is the canonical example. Build a testing module with +**Unit tests** — `src/**/__tests__/*.test.ts`, the bulk of the suite — mock the Prisma layer. +`src/groups/__tests__/groups.service.test.ts` is the canonical example. Build a testing module with `MockFactory.createForModelToken(getModelToken('Group'))` from `@douglasneuroinformatics/libnest/testing`, type mocks as `MockedInstance>`, and assert Prisma arguments with `model.create.mock.lastCall?.[0]` and `toMatchObject`. A fresh module is diff --git a/apps/api/src/assignments/__tests__/assignments.controller.spec.ts b/apps/api/src/assignments/__tests__/assignments.controller.test.ts similarity index 100% rename from apps/api/src/assignments/__tests__/assignments.controller.spec.ts rename to apps/api/src/assignments/__tests__/assignments.controller.test.ts diff --git a/apps/api/src/assignments/__tests__/assignments.service.spec.ts b/apps/api/src/assignments/__tests__/assignments.service.test.ts similarity index 100% rename from apps/api/src/assignments/__tests__/assignments.service.spec.ts rename to apps/api/src/assignments/__tests__/assignments.service.test.ts diff --git a/apps/api/src/audit/__tests__/audit.controller.spec.ts b/apps/api/src/audit/__tests__/audit.controller.test.ts similarity index 100% rename from apps/api/src/audit/__tests__/audit.controller.spec.ts rename to apps/api/src/audit/__tests__/audit.controller.test.ts diff --git a/apps/api/src/audit/__tests__/audit.logger.spec.ts b/apps/api/src/audit/__tests__/audit.logger.test.ts similarity index 100% rename from apps/api/src/audit/__tests__/audit.logger.spec.ts rename to apps/api/src/audit/__tests__/audit.logger.test.ts diff --git a/apps/api/src/audit/__tests__/audit.service.spec.ts b/apps/api/src/audit/__tests__/audit.service.test.ts similarity index 100% rename from apps/api/src/audit/__tests__/audit.service.spec.ts rename to apps/api/src/audit/__tests__/audit.service.test.ts diff --git a/apps/api/src/auth/__tests__/auth.controller.spec.ts b/apps/api/src/auth/__tests__/auth.controller.test.ts similarity index 100% rename from apps/api/src/auth/__tests__/auth.controller.spec.ts rename to apps/api/src/auth/__tests__/auth.controller.test.ts diff --git a/apps/api/src/auth/__tests__/auth.service.spec.ts b/apps/api/src/auth/__tests__/auth.service.test.ts similarity index 100% rename from apps/api/src/auth/__tests__/auth.service.spec.ts rename to apps/api/src/auth/__tests__/auth.service.test.ts diff --git a/apps/api/src/auth/guards/__tests__/jwt-auth.guard.spec.ts b/apps/api/src/auth/guards/__tests__/jwt-auth.guard.test.ts similarity index 100% rename from apps/api/src/auth/guards/__tests__/jwt-auth.guard.spec.ts rename to apps/api/src/auth/guards/__tests__/jwt-auth.guard.test.ts diff --git a/apps/api/src/core/__tests__/prisma.spec.ts b/apps/api/src/core/__tests__/prisma.test.ts similarity index 100% rename from apps/api/src/core/__tests__/prisma.spec.ts rename to apps/api/src/core/__tests__/prisma.test.ts diff --git a/apps/api/src/core/__tests__/secret-cipher.spec.ts b/apps/api/src/core/__tests__/secret-cipher.test.ts similarity index 100% rename from apps/api/src/core/__tests__/secret-cipher.spec.ts rename to apps/api/src/core/__tests__/secret-cipher.test.ts diff --git a/apps/api/src/core/decorators/__tests__/throttle-login-request.decorator.spec.ts b/apps/api/src/core/decorators/__tests__/throttle-login-request.decorator.test.ts similarity index 100% rename from apps/api/src/core/decorators/__tests__/throttle-login-request.decorator.spec.ts rename to apps/api/src/core/decorators/__tests__/throttle-login-request.decorator.test.ts diff --git a/apps/api/src/core/interceptors/__tests__/msgpack.interceptor.spec.ts b/apps/api/src/core/interceptors/__tests__/msgpack.interceptor.test.ts similarity index 100% rename from apps/api/src/core/interceptors/__tests__/msgpack.interceptor.spec.ts rename to apps/api/src/core/interceptors/__tests__/msgpack.interceptor.test.ts diff --git a/apps/api/src/core/schemas/__tests__/env.schema.spec.ts b/apps/api/src/core/schemas/__tests__/env.schema.test.ts similarity index 100% rename from apps/api/src/core/schemas/__tests__/env.schema.spec.ts rename to apps/api/src/core/schemas/__tests__/env.schema.test.ts diff --git a/apps/api/src/demo/__tests__/demo.service.spec.ts b/apps/api/src/demo/__tests__/demo.service.test.ts similarity index 100% rename from apps/api/src/demo/__tests__/demo.service.spec.ts rename to apps/api/src/demo/__tests__/demo.service.test.ts diff --git a/apps/api/src/gateway/__tests__/gateway.controller.spec.ts b/apps/api/src/gateway/__tests__/gateway.controller.test.ts similarity index 100% rename from apps/api/src/gateway/__tests__/gateway.controller.spec.ts rename to apps/api/src/gateway/__tests__/gateway.controller.test.ts diff --git a/apps/api/src/gateway/__tests__/gateway.http.spec.ts b/apps/api/src/gateway/__tests__/gateway.http.test.ts similarity index 100% rename from apps/api/src/gateway/__tests__/gateway.http.spec.ts rename to apps/api/src/gateway/__tests__/gateway.http.test.ts diff --git a/apps/api/src/gateway/__tests__/gateway.service.spec.ts b/apps/api/src/gateway/__tests__/gateway.service.test.ts similarity index 100% rename from apps/api/src/gateway/__tests__/gateway.service.spec.ts rename to apps/api/src/gateway/__tests__/gateway.service.test.ts diff --git a/apps/api/src/gateway/__tests__/gateway.synchronizer.spec.ts b/apps/api/src/gateway/__tests__/gateway.synchronizer.test.ts similarity index 100% rename from apps/api/src/gateway/__tests__/gateway.synchronizer.spec.ts rename to apps/api/src/gateway/__tests__/gateway.synchronizer.test.ts diff --git a/apps/api/src/groups/__tests__/groups.controller.spec.ts b/apps/api/src/groups/__tests__/groups.controller.test.ts similarity index 100% rename from apps/api/src/groups/__tests__/groups.controller.spec.ts rename to apps/api/src/groups/__tests__/groups.controller.test.ts diff --git a/apps/api/src/groups/__tests__/groups.service.spec.ts b/apps/api/src/groups/__tests__/groups.service.test.ts similarity index 100% rename from apps/api/src/groups/__tests__/groups.service.spec.ts rename to apps/api/src/groups/__tests__/groups.service.test.ts diff --git a/apps/api/src/instrument-records/__tests__/export-worker.spec.ts b/apps/api/src/instrument-records/__tests__/export-worker.test.ts similarity index 100% rename from apps/api/src/instrument-records/__tests__/export-worker.spec.ts rename to apps/api/src/instrument-records/__tests__/export-worker.test.ts diff --git a/apps/api/src/instrument-records/__tests__/instrument-measures.service.spec.ts b/apps/api/src/instrument-records/__tests__/instrument-measures.service.test.ts similarity index 100% rename from apps/api/src/instrument-records/__tests__/instrument-measures.service.spec.ts rename to apps/api/src/instrument-records/__tests__/instrument-measures.service.test.ts diff --git a/apps/api/src/instrument-records/__tests__/instrument-records.controller.spec.ts b/apps/api/src/instrument-records/__tests__/instrument-records.controller.test.ts similarity index 100% rename from apps/api/src/instrument-records/__tests__/instrument-records.controller.spec.ts rename to apps/api/src/instrument-records/__tests__/instrument-records.controller.test.ts diff --git a/apps/api/src/instrument-records/__tests__/instrument-records.service.spec.ts b/apps/api/src/instrument-records/__tests__/instrument-records.service.test.ts similarity index 100% rename from apps/api/src/instrument-records/__tests__/instrument-records.service.spec.ts rename to apps/api/src/instrument-records/__tests__/instrument-records.service.test.ts diff --git a/apps/api/src/instrument-records/files/__tests__/files.controller.spec.ts b/apps/api/src/instrument-records/files/__tests__/files.controller.test.ts similarity index 100% rename from apps/api/src/instrument-records/files/__tests__/files.controller.spec.ts rename to apps/api/src/instrument-records/files/__tests__/files.controller.test.ts diff --git a/apps/api/src/instrument-records/files/__tests__/files.service.spec.ts b/apps/api/src/instrument-records/files/__tests__/files.service.test.ts similarity index 100% rename from apps/api/src/instrument-records/files/__tests__/files.service.spec.ts rename to apps/api/src/instrument-records/files/__tests__/files.service.test.ts diff --git a/apps/api/src/instrument-repos/__tests__/instrument-repos.controller.spec.ts b/apps/api/src/instrument-repos/__tests__/instrument-repos.controller.test.ts similarity index 100% rename from apps/api/src/instrument-repos/__tests__/instrument-repos.controller.spec.ts rename to apps/api/src/instrument-repos/__tests__/instrument-repos.controller.test.ts diff --git a/apps/api/src/instrument-repos/__tests__/instrument-repos.service.spec.ts b/apps/api/src/instrument-repos/__tests__/instrument-repos.service.test.ts similarity index 100% rename from apps/api/src/instrument-repos/__tests__/instrument-repos.service.spec.ts rename to apps/api/src/instrument-repos/__tests__/instrument-repos.service.test.ts diff --git a/apps/api/src/instruments/__tests__/instruments.controller.spec.ts b/apps/api/src/instruments/__tests__/instruments.controller.test.ts similarity index 100% rename from apps/api/src/instruments/__tests__/instruments.controller.spec.ts rename to apps/api/src/instruments/__tests__/instruments.controller.test.ts diff --git a/apps/api/src/instruments/__tests__/instruments.module.spec.ts b/apps/api/src/instruments/__tests__/instruments.module.test.ts similarity index 100% rename from apps/api/src/instruments/__tests__/instruments.module.spec.ts rename to apps/api/src/instruments/__tests__/instruments.module.test.ts diff --git a/apps/api/src/instruments/__tests__/instruments.service.spec.ts b/apps/api/src/instruments/__tests__/instruments.service.test.ts similarity index 100% rename from apps/api/src/instruments/__tests__/instruments.service.spec.ts rename to apps/api/src/instruments/__tests__/instruments.service.test.ts diff --git a/apps/api/src/mail/__tests__/mail.controller.spec.ts b/apps/api/src/mail/__tests__/mail.controller.test.ts similarity index 100% rename from apps/api/src/mail/__tests__/mail.controller.spec.ts rename to apps/api/src/mail/__tests__/mail.controller.test.ts diff --git a/apps/api/src/mail/__tests__/mail.service.spec.ts b/apps/api/src/mail/__tests__/mail.service.test.ts similarity index 100% rename from apps/api/src/mail/__tests__/mail.service.spec.ts rename to apps/api/src/mail/__tests__/mail.service.test.ts diff --git a/apps/api/src/mail/__tests__/mail.utils.spec.ts b/apps/api/src/mail/__tests__/mail.utils.test.ts similarity index 100% rename from apps/api/src/mail/__tests__/mail.utils.spec.ts rename to apps/api/src/mail/__tests__/mail.utils.test.ts diff --git a/apps/api/src/sessions/__tests__/sessions.controller.spec.ts b/apps/api/src/sessions/__tests__/sessions.controller.test.ts similarity index 100% rename from apps/api/src/sessions/__tests__/sessions.controller.spec.ts rename to apps/api/src/sessions/__tests__/sessions.controller.test.ts diff --git a/apps/api/src/sessions/__tests__/sessions.service.spec.ts b/apps/api/src/sessions/__tests__/sessions.service.test.ts similarity index 100% rename from apps/api/src/sessions/__tests__/sessions.service.spec.ts rename to apps/api/src/sessions/__tests__/sessions.service.test.ts diff --git a/apps/api/src/setup/__tests__/setup.controller.spec.ts b/apps/api/src/setup/__tests__/setup.controller.test.ts similarity index 100% rename from apps/api/src/setup/__tests__/setup.controller.spec.ts rename to apps/api/src/setup/__tests__/setup.controller.test.ts diff --git a/apps/api/src/setup/__tests__/setup.service.spec.ts b/apps/api/src/setup/__tests__/setup.service.test.ts similarity index 100% rename from apps/api/src/setup/__tests__/setup.service.spec.ts rename to apps/api/src/setup/__tests__/setup.service.test.ts diff --git a/apps/api/src/storage/__tests__/storage.module.spec.ts b/apps/api/src/storage/__tests__/storage.module.test.ts similarity index 100% rename from apps/api/src/storage/__tests__/storage.module.spec.ts rename to apps/api/src/storage/__tests__/storage.module.test.ts diff --git a/apps/api/src/storage/__tests__/storage.service.spec.ts b/apps/api/src/storage/__tests__/storage.service.test.ts similarity index 100% rename from apps/api/src/storage/__tests__/storage.service.spec.ts rename to apps/api/src/storage/__tests__/storage.service.test.ts diff --git a/apps/api/src/subjects/__tests__/subjects.controller.spec.ts b/apps/api/src/subjects/__tests__/subjects.controller.test.ts similarity index 100% rename from apps/api/src/subjects/__tests__/subjects.controller.spec.ts rename to apps/api/src/subjects/__tests__/subjects.controller.test.ts diff --git a/apps/api/src/subjects/__tests__/subjects.service.spec.ts b/apps/api/src/subjects/__tests__/subjects.service.test.ts similarity index 100% rename from apps/api/src/subjects/__tests__/subjects.service.spec.ts rename to apps/api/src/subjects/__tests__/subjects.service.test.ts diff --git a/apps/api/src/summary/__tests__/summary.controller.spec.ts b/apps/api/src/summary/__tests__/summary.controller.test.ts similarity index 100% rename from apps/api/src/summary/__tests__/summary.controller.spec.ts rename to apps/api/src/summary/__tests__/summary.controller.test.ts diff --git a/apps/api/src/summary/__tests__/summary.service.spec.ts b/apps/api/src/summary/__tests__/summary.service.test.ts similarity index 100% rename from apps/api/src/summary/__tests__/summary.service.spec.ts rename to apps/api/src/summary/__tests__/summary.service.test.ts diff --git a/apps/api/src/users/__tests__/users.controller.spec.ts b/apps/api/src/users/__tests__/users.controller.test.ts similarity index 100% rename from apps/api/src/users/__tests__/users.controller.spec.ts rename to apps/api/src/users/__tests__/users.controller.test.ts diff --git a/apps/api/src/users/__tests__/users.service.spec.ts b/apps/api/src/users/__tests__/users.service.test.ts similarity index 100% rename from apps/api/src/users/__tests__/users.service.spec.ts rename to apps/api/src/users/__tests__/users.service.test.ts diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index 14f28a084..8c09a176c 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -40,7 +40,6 @@ export default mergeConfig( root: import.meta.dirname, test: { globals: true, - include: ['src/**/*.spec.ts', 'test/**/*.test.ts'], name: 'api' } }) diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 1a178d53b..808ec008f 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -10,17 +10,17 @@ Read the root `AGENTS.md` first for the rules that apply everywhere. The organizing principle is **layer folders, not feature folders**. There is no `src/features`, `src/lib` or `src/api`. -| Directory | Holds | -| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -| `src/routes/` | **Route files and nothing else** — see below | -| `src/components/` | Shared presentational components | -| `src/hooks/` | All data fetching — one file per hook, named after its export | -| `src/services/` | Module-level side-effect singletons: `axios.ts`, `i18n.ts`, `react-query.ts`, `zod.ts` | -| `src/store/` | The single Zustand store: `index.ts`, `types.ts`, `slices/*.slice.ts` | -| `src/providers/` | Context/HOC providers used by `_app/route.tsx` | -| `src/utils/` | Pure helpers only | -| `src/translations/` | Namespace JSON, registered in `src/services/i18n.ts` | -| `src/__tests__/`, `src/hooks/__tests__/`, `src/utils/__tests__/` | Tests | +| Directory | Holds | +| ------------------- | -------------------------------------------------------------------------------------- | +| `src/routes/` | **Route files and nothing else** — see below | +| `src/components/` | Shared presentational components | +| `src/hooks/` | All data fetching — one file per hook, named after its export | +| `src/services/` | Module-level side-effect singletons: `axios.ts`, `i18n.ts`, `react-query.ts`, `zod.ts` | +| `src/store/` | The single Zustand store: `index.ts`, `types.ts`, `slices/*.slice.ts` | +| `src/providers/` | Context/HOC providers used by `_app/route.tsx` | +| `src/utils/` | Pure helpers only | +| `src/translations/` | Namespace JSON, registered in `src/services/i18n.ts` | +| `__tests__/` | Unit tests, beside the code under test — see "Tests" | `@/*` aliases `src/*` and is declared in **three files that must agree**: `vite.config.ts`, `vitest.config.ts` and `tsconfig.json`. Use `@/` across layers, relative imports within a folder. @@ -30,10 +30,12 @@ The organizing principle is **layer folders, not feature folders**. There is no `src/route-tree.ts` is generated and git-tracked. **Never hand-edit it and never run the generator** — the user does that manually after route changes. -**Only route files may live under `src/routes/`.** The generator scans the directory -indiscriminately and warns about any file that does not `export const Route`. Tests importing -`vitest` or `@testing-library/*` from there is an eslint error. Put a test in `src/hooks/__tests__/`, -`src/utils/__tests__/` or `src/__tests__/`, and a helper in `src/hooks/` or `src/utils/`. +**Only route files and their `__tests__/` folders may live under `src/routes/`.** The generator +scans the directory indiscriminately and warns about any file that does not `export const Route`; +`routeFileIgnorePattern: '^__tests__$'` in `vite.config.ts` is the only thing keeping it out of the +test folders. Importing `vitest` or `@testing-library/*` anywhere else under `src/routes/` is an +eslint error. Put a route's test in a `__tests__/` folder beside it, and a helper in `src/hooks/` or +`src/utils/`. Naming: `__root.tsx` is the root; `_app/` is a pathless layout (contributes no URL segment); `route.tsx` is a directory's layout; `index.tsx` is its `/`; `$param` is dynamic; ordinary routes are @@ -183,7 +185,7 @@ add UI that an e2e test will drive. repo, and `vite.config.ts` is **not** loaded during tests — `vitest.config.ts` supersedes it, which is why the `@` alias is redeclared there. Nothing that depends on `import.meta.env` injection or the runtime plugin works in a unit test; mock `@/config` instead, and use `vi.stubEnv` for Vite's -built-in `DEV` and `MODE` (`src/__tests__/app-route-guard.test.ts`). +built-in `DEV` and `MODE` (`src/routes/_app/__tests__/route.test.ts`). `src/hooks/__tests__/useInstrumentBundle.test.ts` is the canonical test — read it before writing one. Mock `axios` and `@/store` with `vi.hoisted` + `vi.mock`; wrap hooks in a fresh `QueryClient` with diff --git a/apps/web/src/__tests__/data-table-server-mode.test.tsx b/apps/web/src/__tests__/data-table-server-mode.test.tsx deleted file mode 100644 index 5721b8e26..000000000 --- a/apps/web/src/__tests__/data-table-server-mode.test.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from 'react'; - -import { DataTable } from '@douglasneuroinformatics/libui/components'; -import { fireEvent, render, screen } from '@testing-library/react'; -import { beforeAll, describe, expect, it, vi } from 'vitest'; - -// Initialises the shared libui translator, which the table's controls read on render. -import '@/services/i18n'; - -const noop = () => undefined; - -type Row = { id: string }; - -const columns = [{ accessorKey: 'id', header: 'ID' }]; - -/** - * Exercises the contract the audit logs route depends on: in server mode the table renders exactly - * the rows it is given, derives its controls from `pageCount`, and reports page changes back rather - * than slicing the data itself. - */ -const renderServerTable = (props: { - data: Row[]; - onPaginationChange: (state: { pageIndex: number; pageSize: number }) => void; - pageCount: number; -}) => render( columns={columns} mode="server" {...props} />); - -describe('DataTable in server mode', () => { - beforeAll(() => { - // libui measures the table container; happy-dom has no layout engine. - globalThis.ResizeObserver ??= class { - disconnect = noop; - observe = noop; - unobserve = noop; - } as never; - }); - - it('should render every row it is handed, without paginating them itself', () => { - const data = Array.from({ length: 10 }, (_, i) => ({ id: `log-${i}` })); - renderServerTable({ data, onPaginationChange: vi.fn(), pageCount: 500 }); - for (const row of data) { - expect(screen.getByText(row.id)).toBeTruthy(); - } - }); - - it('should report the new page index when the user pages forward', () => { - const onPaginationChange = vi.fn(); - renderServerTable({ data: [{ id: 'log-0' }], onPaginationChange, pageCount: 5 }); - const pageTwo = screen.getAllByRole('button', { hidden: true }).filter((el) => el.textContent === '2'); - expect(pageTwo.length).toBeGreaterThan(0); - pageTwo.forEach((button) => fireEvent.click(button)); - expect(onPaginationChange).toHaveBeenCalledWith(expect.objectContaining({ pageIndex: 1 })); - }); - - it('should build its page controls from pageCount rather than from the rows it holds', () => { - // A single row, but the server reports three pages: the controls must come from `pageCount`. - renderServerTable({ data: [{ id: 'log-0' }], onPaginationChange: vi.fn(), pageCount: 3 }); - expect(screen.getAllByText('1').length).toBeGreaterThan(0); - expect(screen.getAllByText('3').length).toBeGreaterThan(0); - expect(screen.queryAllByText('4')).toHaveLength(0); - }); -}); diff --git a/apps/web/src/__tests__/gateway-route-guards.test.ts b/apps/web/src/__tests__/gateway-route-guards.test.ts deleted file mode 100644 index 1ee4269a6..000000000 --- a/apps/web/src/__tests__/gateway-route-guards.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { isRedirect } from '@tanstack/react-router'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const mocks = vi.hoisted(() => ({ - config: { setup: { apiBaseUrl: '', isGatewayEnabled: true } } -})); - -vi.mock('@/config', () => ({ config: mocks.config })); - -/** - * Every route whose page exists only to serve remote assignments, which the API mounts behind - * GATEWAY_ENABLED. Each entry is the module holding the route and where its guard sends a user - * whose instance was deployed without the gateway. - */ -const GATEWAY_ROUTES = [ - { - importRoute: async () => (await import('@/routes/_app/session/remote-assignment')).Route, - params: {}, - redirectsTo: '/dashboard' - }, - { - importRoute: async () => (await import('@/routes/_app/datahub/$subjectId/assignments')).Route, - params: { subjectId: '123' }, - redirectsTo: '/datahub/$subjectId/table' - }, - { - importRoute: async () => (await import('@/routes/_app/group/email-templates')).Route, - params: {}, - redirectsTo: '/dashboard' - }, - { - importRoute: async () => (await import('@/routes/_app/group/remote-assignments')).Route, - params: {}, - redirectsTo: '/dashboard' - } -] as const; - -/** - * Awaited, because a guard that has to consult setup state is async — a redirect it throws arrives - * as a rejection rather than synchronously. Guards that stay synchronous are unaffected. - */ -const runGuard = async ( - route: { options: { beforeLoad?: unknown } }, - params: object, - setupState: { isBulkRemoteAssignmentsEnabled?: boolean } = { isBulkRemoteAssignmentsEnabled: true } -) => { - const beforeLoad = route.options.beforeLoad as (opts: { - context: { queryClient: { ensureQueryData: () => Promise } }; - params: object; - }) => Promise | void; - try { - await beforeLoad({ - context: { queryClient: { ensureQueryData: () => Promise.resolve(setupState) } }, - params - }); - } catch (err) { - return err; - } - return null; -}; - -beforeEach(() => { - mocks.config.setup.isGatewayEnabled = true; -}); - -describe.each(GATEWAY_ROUTES)('$redirectsTo guard', ({ importRoute, params, redirectsTo }) => { - it('should redirect away when the gateway is not deployed, so a bookmarked link cannot reach a page whose endpoints are not mounted', async () => { - mocks.config.setup.isGatewayEnabled = false; - const thrown = await runGuard(await importRoute(), params); - expect(isRedirect(thrown)).toBe(true); - expect((thrown as { options: { to: string } }).options.to).toBe(redirectsTo); - }); - - it('should allow the route when the gateway is deployed', async () => { - expect(await runGuard(await importRoute(), params)).toBeNull(); - }); -}); - -describe('bulk remote assignments guard', () => { - const importRoute = async () => (await import('@/routes/_app/group/remote-assignments')).Route; - - // Hiding the nav link is not enough: the page must not be reachable by url when an admin has - // turned the feature off. - it('should redirect away when the instance toggle is off', async () => { - const thrown = await runGuard(await importRoute(), {}, { isBulkRemoteAssignmentsEnabled: false }); - expect(isRedirect(thrown)).toBe(true); - expect((thrown as { options: { to: string } }).options.to).toBe('/dashboard'); - }); - - it('should allow the route when the toggle is on and the gateway is deployed', async () => { - expect(await runGuard(await importRoute(), {}, { isBulkRemoteAssignmentsEnabled: true })).toBeNull(); - }); -}); diff --git a/apps/web/src/__tests__/group-email-templates.test.tsx b/apps/web/src/__tests__/group-email-templates.test.tsx deleted file mode 100644 index fb386373b..000000000 --- a/apps/web/src/__tests__/group-email-templates.test.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { cleanup, render, screen } from '@testing-library/react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { Route } from '@/routes/_app/group/email-templates'; - -import '@/services/i18n'; - -vi.mock('@/components/GroupEmailTemplates', () => ({ - GroupEmailTemplates: () =>
-})); - -afterEach(cleanup); - -describe('group email templates route', () => { - it('should title the page and render the group email template manager beneath it', () => { - const Component = Route.options.component!; - render(); - expect(screen.getByTestId('page-header').textContent).toBe('Email Templates'); - expect(screen.getByTestId('group-email-templates')).toBeTruthy(); - }); -}); diff --git a/apps/web/src/__tests__/language-toggle.test.tsx b/apps/web/src/__tests__/language-toggle.test.tsx deleted file mode 100644 index 57ea40e9b..000000000 --- a/apps/web/src/__tests__/language-toggle.test.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; -import { i18n } from '@douglasneuroinformatics/libui/i18n'; -import { LanguageToggle } from '@opendatacapture/react-core'; -import type { ActiveLanguages } from '@opendatacapture/schemas/core'; -import { act, cleanup, render, screen } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import '@/services/i18n'; - -describe('LanguageToggle', () => { - beforeEach(() => { - i18n.changeLanguage('en'); - }); - - afterEach(cleanup); - - const renderToggle = (activeLanguages: ActiveLanguages) => - render(); - - it('should render nothing when the instance offers only one language, so no control is a no-op', () => { - const { container } = renderToggle(['en']); - expect(container.firstChild).toBeNull(); - }); - - it('should render a trigger when the instance offers more than one language', () => { - renderToggle(['en', 'es']); - expect(screen.getByRole('button')).toBeTruthy(); - }); - - it('should move a user off a language the instance no longer offers', () => { - i18n.changeLanguage('es'); - renderToggle(['en', 'fr']); - expect(i18n.resolvedLanguage).toBe('en'); - }); - - it('should leave a user on a language the instance still offers', () => { - i18n.changeLanguage('fr'); - renderToggle(['en', 'fr']); - expect(i18n.resolvedLanguage).toBe('fr'); - }); - - it('should move a stranded user even when only one language remains, where no toggle renders', () => { - i18n.changeLanguage('fr'); - renderToggle(['es']); - expect(i18n.resolvedLanguage).toBe('es'); - }); - - // The sidebar renders the toggle as a descendant while translating its own strings, so it is the - // ancestor that has to re-render for the fix to be worth anything — asserting `resolvedLanguage` - // alone passed while the sidebar stayed in the deactivated language. - const Ancestor = ({ activeLanguages }: { activeLanguages: ActiveLanguages }) => { - const { t } = useTranslation(); - return ( -
- {t({ en: 'Dashboard', es: 'Panel de control', fr: 'Tableau' })} - -
- ); - }; - - it('should re-render an ancestor when a language is deactivated mid-session', () => { - const { rerender } = render(); - act(() => i18n.changeLanguage('es')); - expect(screen.getByTestId('ancestor-label').textContent).toBe('Panel de control'); - - rerender(); - expect(i18n.resolvedLanguage).toBe('en'); - expect(screen.getByTestId('ancestor-label').textContent).toBe('Dashboard'); - }); - - it('should leave a reader alone when the deactivated language was not theirs', () => { - const { rerender } = render(); - act(() => i18n.changeLanguage('fr')); - - rerender(); - expect(i18n.resolvedLanguage).toBe('fr'); - expect(screen.getByTestId('ancestor-label').textContent).toBe('Tableau'); - }); -}); diff --git a/apps/web/src/__tests__/reconcile-interface-language.test.ts b/apps/web/src/__tests__/reconcile-interface-language.test.ts deleted file mode 100644 index 1132472a3..000000000 --- a/apps/web/src/__tests__/reconcile-interface-language.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { i18n } from '@douglasneuroinformatics/libui/i18n'; -import type { ActiveLanguages } from '@opendatacapture/schemas/core'; -import type { QueryClient } from '@tanstack/react-query'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { Route } from '@/routes/_app/route'; -import { reconcileInterfaceLanguage } from '@/services/i18n'; -import { useAppStore } from '@/store'; - -vi.mock('@/config', () => ({ - config: { - dev: {}, - meta: { contactEmail: '', docsUrl: '', githubRepoUrl: '', licenseUrl: '' }, - setup: { apiBaseUrl: '', isGatewayEnabled: true } - } -})); - -describe('reconcileInterfaceLanguage', () => { - beforeEach(() => { - i18n.changeLanguage('en'); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should move a reader off a language the instance no longer offers', () => { - i18n.changeLanguage('es'); - reconcileInterfaceLanguage(['en', 'fr']); - expect(i18n.resolvedLanguage).toBe('en'); - }); - - it('should leave a reader on a language the instance still offers', () => { - i18n.changeLanguage('fr'); - reconcileInterfaceLanguage(['en', 'fr']); - expect(i18n.resolvedLanguage).toBe('fr'); - }); - - it('should not change the language when nothing moved, so it does not notify every translated component', () => { - const changeLanguage = vi.spyOn(i18n, 'changeLanguage'); - reconcileInterfaceLanguage(['en', 'es', 'fr']); - expect(changeLanguage).not.toHaveBeenCalled(); - }); -}); - -describe('_app beforeLoad', () => { - const runBeforeLoad = async (activeLanguages: ActiveLanguages) => { - const queryClient = { fetchQuery: vi.fn().mockResolvedValue({ activeLanguages, isSetup: true }) }; - const beforeLoad = Route.options.beforeLoad as (opts: { - context: { queryClient: Pick }; - }) => Promise; - await beforeLoad({ context: { queryClient } }); - }; - - beforeEach(() => { - i18n.changeLanguage('en'); - useAppStore.setState({ accessToken: 'token', currentUser: null }); - }); - - it('should move a stranded reader before the app renders, so every component mounts in an offered language', async () => { - i18n.changeLanguage('es'); - await runBeforeLoad(['en', 'fr']); - expect(i18n.resolvedLanguage).toBe('en'); - }); - - it('should keep a reader on a language the instance still offers', async () => { - i18n.changeLanguage('fr'); - await runBeforeLoad(['en', 'fr']); - expect(i18n.resolvedLanguage).toBe('fr'); - }); -}); diff --git a/apps/web/src/__tests__/record-array-initial-values.test.tsx b/apps/web/src/__tests__/record-array-initial-values.test.tsx deleted file mode 100644 index f095161d6..000000000 --- a/apps/web/src/__tests__/record-array-initial-values.test.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import React, { useMemo, useState } from 'react'; - -import { Form } from '@douglasneuroinformatics/libui/components'; -import type { FormTypes } from '@opendatacapture/runtime-core'; -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { z } from 'zod/v4'; - -// Initialises the shared libui translator, which the form's controls read on render. -import '@/services/i18n'; - -type Permission = { action: string; subject: string }; - -// Test scaffolding rather than copy, so it is not translated -- `jsx-no-literals` still applies here. -const FORCE_RENDER_LABEL = 'Force Render'; - -const $TestFormData = z.object({ - permissions: z.array(z.object({ action: z.string(), subject: z.string() })) -}); - -const buildFieldset = () => - ({ - action: { kind: 'string', label: 'Action', options: { read: 'Read', update: 'Update' }, variant: 'select' }, - subject: { kind: 'string', label: 'Resource', options: { Group: 'Group', User: 'User' }, variant: 'select' } - }) satisfies FormTypes.RecordArrayField['fieldset']; - -/** - * Exercises a libui contract any form seeding a `record-array` depends on: records seeded from - * `initialValues` survive a re-render only while the `fieldset` holds its identity, so such a form - * must memoize that object rather than write it inline. - */ -const RecordArrayForm = ({ - onSubmit, - permissions, - stableFieldset -}: { - onSubmit: (data: { permissions: Permission[] }) => void; - permissions: Permission[]; - stableFieldset: boolean; -}) => { - const [, forceRender] = useState(0); - const memoizedFieldset = useMemo(buildFieldset, []); - return ( - - -
- - ); -}; - -describe('a record-array field seeded from initialValues', () => { - const permissions: Permission[] = [ - { action: 'read', subject: 'User' }, - { action: 'update', subject: 'Group' } - ]; - - // There are no setup files in this repo, so testing-library's auto-cleanup never runs. - afterEach(cleanup); - - it('should render one row per seeded record', () => { - render(); - expect(screen.getByText('Permission 1')).toBeTruthy(); - expect(screen.getByText('Permission 2')).toBeTruthy(); - }); - - it('should keep the seeded records when its parent re-renders', () => { - render(); - fireEvent.click(screen.getByRole('button', { name: FORCE_RENDER_LABEL })); - expect(screen.getByText('Permission 1')).toBeTruthy(); - expect(screen.getByText('Permission 2')).toBeTruthy(); - }); - - it('should submit the seeded records unchanged after its parent re-renders', async () => { - const onSubmit = vi.fn(); - render(); - fireEvent.click(screen.getByRole('button', { name: FORCE_RENDER_LABEL })); - fireEvent.click(screen.getByRole('button', { name: 'Submit' })); - await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ permissions })); - }); - - // The hazard the memo exists to avoid: libui resets the field to one blank record whenever the - // fieldset changes identity, so an inline literal silently discards what the user was shown. - it('should discard the seeded records when the fieldset changes identity', () => { - render(); - fireEvent.click(screen.getByRole('button', { name: FORCE_RENDER_LABEL })); - expect(screen.getByText('Permission 1')).toBeTruthy(); - expect(screen.queryByText('Permission 2')).toBeNull(); - }); -}); diff --git a/apps/web/src/__tests__/zod-error-maps.test.ts b/apps/web/src/__tests__/zod-error-maps.test.ts deleted file mode 100644 index c6c65e36e..000000000 --- a/apps/web/src/__tests__/zod-error-maps.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { createZodErrorMaps, localizeZodErrors } from '@opendatacapture/react-core'; -import { afterEach, describe, expect, it } from 'vitest'; -import { z as z3 } from 'zod/v3'; -import { z as z4 } from 'zod/v4'; - -import i18n from '@/services/i18n'; - -const maps = createZodErrorMaps(i18n); - -// Both majors accept an error map for a single parse, so these exercise the maps without mutating -// the global registries the app configures at startup. -const v3Message = (schema: z3.ZodTypeAny, value: unknown) => { - return schema.safeParse(value, { errorMap: maps.v3 }).error?.issues[0]?.message; -}; - -const v4Message = (schema: z4.ZodType, value: unknown) => { - return schema.safeParse(value, { error: maps.v4 }).error?.issues[0]?.message; -}; - -const REQUIRED = 'This field is required'; -const INVALID = 'The value entered is not valid'; - -afterEach(() => { - i18n.changeLanguage('en'); -}); - -describe('zod v3 error map', () => { - it.each([ - ['a missing field is the required message', z3.object({ x: z3.string() }), {}, REQUIRED], - ['a null value reads as missing rather than a type error', z3.object({ x: z3.string() }), { x: null }, REQUIRED], - ['an empty string against min(1), which untouched text fields submit', z3.string().min(1), '', REQUIRED], - [ - 'an untouched union of literals, which zod would otherwise report as "Invalid input"', - z3.object({ x: z3.union([z3.literal(1), z3.literal(2)]) }), - {}, - REQUIRED - ], - ['a decimal in an integer field', z3.number().int(), 1.5, 'Must be a whole number'], - ['a number where text is expected', z3.string(), 1, 'Must be text'], - ['text where a number is expected', z3.number(), 'x', 'Must be a number'], - ['text where a boolean is expected', z3.boolean(), 'x', 'Must be a valid selection'], - ['an unparseable date', z3.date(), new Date('nope'), 'Must be a valid date'], - ['a number below an inclusive minimum', z3.number().gte(1), 0, 'Must be 1 or greater'], - ['a number at an exclusive minimum', z3.number().gt(1), 1, 'Must be greater than 1'], - ['a number above an inclusive maximum', z3.number().lte(10), 12, 'Must be 10 or less'], - ['a large bound, grouped for readability', z3.number().lte(1000000), 2000000, 'Must be 1,000,000 or less'], - ['a string below its minimum length', z3.string().min(3), 'ab', 'Must be at least 3 characters'], - ['a string of the wrong exact length', z3.string().length(5), 'ab', 'Must be exactly 5 characters'], - ['a set below its minimum size', z3.set(z3.string()).min(2), new Set(['a']), 'Must select at least 2 options'], - [ - 'a single-option minimum, in the singular', - z3.set(z3.string()).min(1), - new Set(), - 'Must select at least 1 option' - ], - ['an array above its maximum size', z3.array(z3.string()).max(1), ['a', 'b'], 'Must select at most 1 option'], - [ - 'a value failing a regex, without leaking the pattern', - z3.string().regex(/^[A-Z]$/), - 'zz', - 'Does not match the expected format' - ], - ['a malformed email address', z3.string().email(), 'x', 'Must be a valid email address'], - ['a malformed url', z3.string().url(), 'x', 'Must be a valid web address'], - ['a string missing its required prefix', z3.string().startsWith('ab'), 'zz', 'Must start with "ab"'], - ['a value outside an enum', z3.enum(['A', 'B']), 'C', 'Must be a valid selection'], - ['a number that is not a multiple of the divisor', z3.number().multipleOf(5), 7, 'Must be a multiple of 5'], - [ - 'a union whose branches disagree, which has no single explanation', - z3.union([z3.string(), z3.number()]), - true, - INVALID - ], - ['an issue the map does not model', z3.number().finite(), Infinity, INVALID], - [ - 'an unexpected key, which no form field can produce', - z3.object({ x: z3.string() }).strict(), - { x: 'a', y: 1 }, - INVALID - ] - ])('should describe %s', (_, schema, value, expected) => { - expect(v3Message(schema, value)).toBe(expected); - }); - - it('should format a date bound as a date rather than a timestamp', () => { - expect(v3Message(z3.date().min(new Date(2020, 0, 15)), new Date(2019, 0, 1))).toBe( - 'Must be on or after January 15, 2020' - ); - }); - - it('should leave a message written by the schema author untouched', () => { - expect(v3Message(z3.string().min(3, { message: 'Trop court' }), 'ab')).toBe('Trop court'); - }); - - it('should follow the language the reader switched to, since messages are built at parse time', () => { - i18n.changeLanguage('fr'); - expect(v3Message(z3.object({ x: z3.string() }), {})).toBe('Ce champ est obligatoire'); - expect(v3Message(z3.number().int(), 1.5)).toBe('Doit être un nombre entier'); - expect(v3Message(z3.string().min(3), 'ab')).toBe('Doit contenir au moins 3 caractères'); - }); - - it('should put zero in the singular in French, where English keeps it plural', () => { - expect(v3Message(z3.array(z3.string()).max(0), ['a'])).toBe('Must select at most 0 options'); - i18n.changeLanguage('fr'); - expect(v3Message(z3.array(z3.string()).max(0), ['a'])).toBe('Doit sélectionner au plus 0 option'); - }); -}); - -describe('zod v4 error map', () => { - it.each([ - ['a missing field is the required message', z4.object({ x: z4.string() }), {}, REQUIRED], - ['a null value reads as missing rather than a type error', z4.object({ x: z4.string() }), { x: null }, REQUIRED], - ['an empty string against min(1), which untouched text fields submit', z4.string().min(1), '', REQUIRED], - [ - 'an untouched union of literals, which zod would otherwise report as "Invalid input"', - z4.object({ x: z4.union([z4.literal(1), z4.literal(2)]) }), - {}, - REQUIRED - ], - ['a decimal in an integer field', z4.number().int(), 1.5, 'Must be a whole number'], - ['a number where text is expected', z4.string(), 1, 'Must be text'], - ['text where a number is expected', z4.number(), 'x', 'Must be a number'], - ['text where a boolean is expected', z4.boolean(), 'x', 'Must be a valid selection'], - ['a number below an inclusive minimum', z4.number().gte(1), 0, 'Must be 1 or greater'], - ['a number at an exclusive minimum', z4.number().gt(1), 1, 'Must be greater than 1'], - ['a number above an inclusive maximum', z4.number().lte(10), 12, 'Must be 10 or less'], - ['a large bound, grouped for readability', z4.number().lte(1000000), 2000000, 'Must be 1,000,000 or less'], - ['a string below its minimum length', z4.string().min(3), 'ab', 'Must be at least 3 characters'], - ['a string of the wrong exact length', z4.string().length(5), 'ab', 'Must be exactly 5 characters'], - ['a set below its minimum size', z4.set(z4.string()).min(2), new Set(['a']), 'Must select at least 2 options'], - [ - 'a single-option minimum, in the singular', - z4.set(z4.string()).min(1), - new Set(), - 'Must select at least 1 option' - ], - ['an array above its maximum size', z4.array(z4.string()).max(1), ['a', 'b'], 'Must select at most 1 option'], - [ - 'a value failing a regex, without leaking the pattern', - z4.string().regex(/^[A-Z]$/), - 'zz', - 'Does not match the expected format' - ], - ['a malformed email address', z4.email(), 'x', 'Must be a valid email address'], - ['a malformed url', z4.url(), 'x', 'Must be a valid web address'], - ['a string missing its required prefix', z4.string().startsWith('ab'), 'zz', 'Must start with "ab"'], - ['a value outside an enum', z4.enum(['A', 'B']), 'C', 'Must be a valid selection'], - ['a number that is not a multiple of the divisor', z4.number().multipleOf(5), 7, 'Must be a multiple of 5'], - [ - 'a union whose branches disagree, which has no single explanation', - z4.union([z4.string(), z4.number()]), - true, - INVALID - ], - [ - 'an unexpected key, which no form field can produce', - z4.strictObject({ x: z4.string() }), - { x: 'a', y: 1 }, - INVALID - ] - ])('should describe %s', (_, schema, value, expected) => { - expect(v4Message(schema, value)).toBe(expected); - }); - - it('should format a date bound as a date rather than a timestamp', () => { - expect(v4Message(z4.date().min(new Date(2020, 0, 15)), new Date(2019, 0, 1))).toBe( - 'Must be on or after January 15, 2020' - ); - }); - - it('should leave a message written by the schema author untouched', () => { - expect(v4Message(z4.string().min(3, { message: 'Trop court' }), 'ab')).toBe('Trop court'); - }); - - it('should follow the language the reader switched to, since messages are built at parse time', () => { - i18n.changeLanguage('fr'); - expect(v4Message(z4.object({ x: z4.string() }), {})).toBe('Ce champ est obligatoire'); - expect(v4Message(z4.number().int(), 1.5)).toBe('Doit être un nombre entier'); - expect(v4Message(z4.string().min(3), 'ab')).toBe('Doit contenir au moins 3 caractères'); - }); - - it('should translate to Spanish, which the language table covers but few inline strings do', () => { - i18n.changeLanguage('es'); - expect(v4Message(z4.object({ x: z4.string() }), {})).toBe('Este campo es obligatorio'); - expect(v4Message(z4.string().min(3), 'ab')).toBe('Debe tener al menos 3 caracteres'); - }); -}); - -describe('localizeZodErrors', () => { - it("should register on this bundle's zod, so app forms need no per-parse map", async () => { - await localizeZodErrors({ targets: ['app'], translator: i18n }); - expect(z3.object({ x: z3.string() }).safeParse({}).error?.issues[0]?.message).toBe(REQUIRED); - expect(z4.object({ x: z4.string() }).safeParse({}).error?.issues[0]?.message).toBe(REQUIRED); - }); -}); diff --git a/apps/web/src/__tests__/assignment-email-form.test.tsx b/apps/web/src/components/AssignmentEmailForm/__tests__/AssignmentEmailForm.test.tsx similarity index 100% rename from apps/web/src/__tests__/assignment-email-form.test.tsx rename to apps/web/src/components/AssignmentEmailForm/__tests__/AssignmentEmailForm.test.tsx diff --git a/apps/web/src/__tests__/bulk-step-layout.test.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/StepLayout.test.tsx similarity index 100% rename from apps/web/src/__tests__/bulk-step-layout.test.tsx rename to apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/StepLayout.test.tsx diff --git a/apps/web/src/__tests__/delete-remote-assignments.test.tsx b/apps/web/src/components/DeleteRemoteAssignments/__tests__/DeleteRemoteAssignments.test.tsx similarity index 100% rename from apps/web/src/__tests__/delete-remote-assignments.test.tsx rename to apps/web/src/components/DeleteRemoteAssignments/__tests__/DeleteRemoteAssignments.test.tsx diff --git a/apps/web/src/__tests__/demo-banner.test.tsx b/apps/web/src/components/DemoBanner/__tests__/DemoBanner.test.tsx similarity index 100% rename from apps/web/src/__tests__/demo-banner.test.tsx rename to apps/web/src/components/DemoBanner/__tests__/DemoBanner.test.tsx diff --git a/apps/web/src/__tests__/filter-menu.test.tsx b/apps/web/src/components/FilterMenu/__tests__/FilterMenu.test.tsx similarity index 100% rename from apps/web/src/__tests__/filter-menu.test.tsx rename to apps/web/src/components/FilterMenu/__tests__/FilterMenu.test.tsx diff --git a/apps/web/src/__tests__/group-switcher.test.tsx b/apps/web/src/components/GroupSwitcher/__tests__/GroupSwitcher.test.tsx similarity index 100% rename from apps/web/src/__tests__/group-switcher.test.tsx rename to apps/web/src/components/GroupSwitcher/__tests__/GroupSwitcher.test.tsx diff --git a/apps/web/src/__tests__/search-submit-guard.test.tsx b/apps/web/src/components/SearchSubmitGuard/__tests__/SearchSubmitGuard.test.tsx similarity index 100% rename from apps/web/src/__tests__/search-submit-guard.test.tsx rename to apps/web/src/components/SearchSubmitGuard/__tests__/SearchSubmitGuard.test.tsx diff --git a/apps/web/src/__tests__/start-session-form.test.tsx b/apps/web/src/components/StartSessionForm/__tests__/StartSessionForm.test.tsx similarity index 100% rename from apps/web/src/__tests__/start-session-form.test.tsx rename to apps/web/src/components/StartSessionForm/__tests__/StartSessionForm.test.tsx diff --git a/apps/web/src/__tests__/user-permissions-editor.test.tsx b/apps/web/src/components/UserPermissionsEditor/__tests__/UserPermissionsEditor.test.tsx similarity index 100% rename from apps/web/src/__tests__/user-permissions-editor.test.tsx rename to apps/web/src/components/UserPermissionsEditor/__tests__/UserPermissionsEditor.test.tsx diff --git a/apps/web/src/__tests__/navigation-blocker.test.tsx b/apps/web/src/components/__tests__/NavigationBlocker.test.tsx similarity index 100% rename from apps/web/src/__tests__/navigation-blocker.test.tsx rename to apps/web/src/components/__tests__/NavigationBlocker.test.tsx diff --git a/apps/web/src/__tests__/save-status.test.tsx b/apps/web/src/components/__tests__/SaveStatus.test.tsx similarity index 100% rename from apps/web/src/__tests__/save-status.test.tsx rename to apps/web/src/components/__tests__/SaveStatus.test.tsx diff --git a/apps/web/src/__tests__/DisclaimerProvider.test.tsx b/apps/web/src/providers/__tests__/DisclaimerProvider.test.tsx similarity index 100% rename from apps/web/src/__tests__/DisclaimerProvider.test.tsx rename to apps/web/src/providers/__tests__/DisclaimerProvider.test.tsx diff --git a/apps/web/src/__tests__/ForceClearQueryCacheProvider.test.tsx b/apps/web/src/providers/__tests__/ForceClearQueryCacheProvider.test.tsx similarity index 100% rename from apps/web/src/__tests__/ForceClearQueryCacheProvider.test.tsx rename to apps/web/src/providers/__tests__/ForceClearQueryCacheProvider.test.tsx diff --git a/apps/web/src/__tests__/WalkthroughProvider.test.tsx b/apps/web/src/providers/__tests__/WalkthroughProvider.test.tsx similarity index 100% rename from apps/web/src/__tests__/WalkthroughProvider.test.tsx rename to apps/web/src/providers/__tests__/WalkthroughProvider.test.tsx diff --git a/apps/web/src/__tests__/root-route.test.tsx b/apps/web/src/routes/__tests__/__root.test.tsx similarity index 100% rename from apps/web/src/__tests__/root-route.test.tsx rename to apps/web/src/routes/__tests__/__root.test.tsx diff --git a/apps/web/src/__tests__/setup-route.test.tsx b/apps/web/src/routes/__tests__/setup.test.tsx similarity index 100% rename from apps/web/src/__tests__/setup-route.test.tsx rename to apps/web/src/routes/__tests__/setup.test.tsx diff --git a/apps/web/src/__tests__/about-route.test.tsx b/apps/web/src/routes/_app/__tests__/about.test.tsx similarity index 100% rename from apps/web/src/__tests__/about-route.test.tsx rename to apps/web/src/routes/_app/__tests__/about.test.tsx diff --git a/apps/web/src/__tests__/contact-route.test.tsx b/apps/web/src/routes/_app/__tests__/contact.test.tsx similarity index 100% rename from apps/web/src/__tests__/contact-route.test.tsx rename to apps/web/src/routes/_app/__tests__/contact.test.tsx diff --git a/apps/web/src/__tests__/dashboard-route.test.tsx b/apps/web/src/routes/_app/__tests__/dashboard.test.tsx similarity index 100% rename from apps/web/src/__tests__/dashboard-route.test.tsx rename to apps/web/src/routes/_app/__tests__/dashboard.test.tsx diff --git a/apps/web/src/__tests__/app-index-route.test.ts b/apps/web/src/routes/_app/__tests__/index.test.ts similarity index 100% rename from apps/web/src/__tests__/app-index-route.test.ts rename to apps/web/src/routes/_app/__tests__/index.test.ts diff --git a/apps/web/src/__tests__/app-route-guard.test.ts b/apps/web/src/routes/_app/__tests__/route.test.ts similarity index 95% rename from apps/web/src/__tests__/app-route-guard.test.ts rename to apps/web/src/routes/_app/__tests__/route.test.ts index 4c3073d64..b7c35eff5 100644 --- a/apps/web/src/__tests__/app-route-guard.test.ts +++ b/apps/web/src/routes/_app/__tests__/route.test.ts @@ -131,6 +131,13 @@ describe('_app guard', () => { await runGuard({ activeLanguages: ['fr'] }); expect(i18n.resolvedLanguage).toBe('fr'); }); + + it('should keep a reader on a language the instance still offers', async () => { + i18n.changeLanguage('fr'); + mocks.store.accessToken = 'token'; + await runGuard({ activeLanguages: ['en', 'fr'] }); + expect(i18n.resolvedLanguage).toBe('fr'); + }); }); describe('_app layout', () => { diff --git a/apps/web/src/__tests__/user-route.test.tsx b/apps/web/src/routes/_app/__tests__/user.test.tsx similarity index 100% rename from apps/web/src/__tests__/user-route.test.tsx rename to apps/web/src/routes/_app/__tests__/user.test.tsx diff --git a/apps/web/src/__tests__/admin-instruments.test.tsx b/apps/web/src/routes/_app/admin/__tests__/instruments.test.tsx similarity index 100% rename from apps/web/src/__tests__/admin-instruments.test.tsx rename to apps/web/src/routes/_app/admin/__tests__/instruments.test.tsx diff --git a/apps/web/src/__tests__/admin-mail.test.tsx b/apps/web/src/routes/_app/admin/__tests__/mail.test.tsx similarity index 100% rename from apps/web/src/__tests__/admin-mail.test.tsx rename to apps/web/src/routes/_app/admin/__tests__/mail.test.tsx diff --git a/apps/web/src/__tests__/admin-settings.test.tsx b/apps/web/src/routes/_app/admin/__tests__/settings.test.tsx similarity index 100% rename from apps/web/src/__tests__/admin-settings.test.tsx rename to apps/web/src/routes/_app/admin/__tests__/settings.test.tsx diff --git a/apps/web/src/__tests__/admin-audit-logs.test.tsx b/apps/web/src/routes/_app/admin/audit/__tests__/logs.test.tsx similarity index 95% rename from apps/web/src/__tests__/admin-audit-logs.test.tsx rename to apps/web/src/routes/_app/admin/audit/__tests__/logs.test.tsx index b8778faa4..4140eaffa 100644 --- a/apps/web/src/__tests__/admin-audit-logs.test.tsx +++ b/apps/web/src/routes/_app/admin/audit/__tests__/logs.test.tsx @@ -334,6 +334,20 @@ describe('audit logs table', () => { expect(cellTexts().slice(1, 3)).toEqual(['N/A', 'N/A']); }); + it('should show every log of the page the server returned, without paginating them again', () => { + const logs = Array.from({ length: 25 }, (_, index) => logFixture({ id: `log-${index}` })); + mocks.auditLogsPage = { data: logs, pageCount: 500, total: 12500 }; + renderPage(); + expect(rows()).toHaveLength(25); + }); + + it('should build the page controls from the page count the server reports, not from the logs on screen', () => { + renderPage(); + const pageLabels = screen.getAllByRole('button', { hidden: true }).map((button) => button.textContent); + expect(pageLabels).toContain('3'); + expect(pageLabels).not.toContain('4'); + }); + it('should show an empty table until the first page arrives', () => { mocks.auditLogsPage = undefined; renderPage(); diff --git a/apps/web/src/__tests__/admin-branding.test.tsx b/apps/web/src/routes/_app/admin/branding/__tests__/index.test.tsx similarity index 74% rename from apps/web/src/__tests__/admin-branding.test.tsx rename to apps/web/src/routes/_app/admin/branding/__tests__/index.test.tsx index 91684ebbf..60ff11d79 100644 --- a/apps/web/src/__tests__/admin-branding.test.tsx +++ b/apps/web/src/routes/_app/admin/branding/__tests__/index.test.tsx @@ -3,9 +3,7 @@ import type { ReactNode } from 'react'; import { cleanup, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { LoginPageEditor } from '@/components/LoginPageEditor'; -import { Route as BrandingRoute } from '@/routes/_app/admin/branding/index'; -import { Route as LoginPageRoute } from '@/routes/_app/admin/branding/login-page'; +import { Route } from '@/routes/_app/admin/branding/index'; import '@/services/i18n'; @@ -17,12 +15,11 @@ vi.mock('@tanstack/react-router', async (importOriginal) => ({ ) })); -vi.mock('@/components/LoginPageEditor', () => ({ LoginPageEditor: () => null })); afterEach(cleanup); describe('branding route', () => { - const BrandingPage = BrandingRoute.options.component!; + const BrandingPage = Route.options.component!; it('should title the page as the branding section', () => { render(); @@ -51,9 +48,3 @@ describe('branding route', () => { expect(fillClasses).toEqual([['fill-slate-300'], ['fill-sky-900', 'dark:fill-slate-300']]); }); }); - -describe('login page branding route', () => { - it('should render the login page editor, so the route adds nothing around it', () => { - expect(LoginPageRoute.options.component).toBe(LoginPageEditor); - }); -}); diff --git a/apps/web/src/routes/_app/admin/branding/__tests__/login-page.test.tsx b/apps/web/src/routes/_app/admin/branding/__tests__/login-page.test.tsx new file mode 100644 index 000000000..d652f509b --- /dev/null +++ b/apps/web/src/routes/_app/admin/branding/__tests__/login-page.test.tsx @@ -0,0 +1,12 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { LoginPageEditor } from '@/components/LoginPageEditor'; +import { Route } from '@/routes/_app/admin/branding/login-page'; + +vi.mock('@/components/LoginPageEditor', () => ({ LoginPageEditor: () => null })); + +describe('login page branding route', () => { + it('should render the login page editor, so the route adds nothing around it', () => { + expect(Route.options.component).toBe(LoginPageEditor); + }); +}); diff --git a/apps/web/src/__tests__/admin-groups-create.test.tsx b/apps/web/src/routes/_app/admin/groups/__tests__/create.test.tsx similarity index 100% rename from apps/web/src/__tests__/admin-groups-create.test.tsx rename to apps/web/src/routes/_app/admin/groups/__tests__/create.test.tsx diff --git a/apps/web/src/__tests__/admin-groups.test.tsx b/apps/web/src/routes/_app/admin/groups/__tests__/index.test.tsx similarity index 100% rename from apps/web/src/__tests__/admin-groups.test.tsx rename to apps/web/src/routes/_app/admin/groups/__tests__/index.test.tsx diff --git a/apps/web/src/__tests__/admin-instrument-repos.test.tsx b/apps/web/src/routes/_app/admin/instrument-repos/__tests__/index.test.tsx similarity index 100% rename from apps/web/src/__tests__/admin-instrument-repos.test.tsx rename to apps/web/src/routes/_app/admin/instrument-repos/__tests__/index.test.tsx diff --git a/apps/web/src/__tests__/admin-user-edit.test.tsx b/apps/web/src/routes/_app/admin/users/__tests__/$userId.test.tsx similarity index 100% rename from apps/web/src/__tests__/admin-user-edit.test.tsx rename to apps/web/src/routes/_app/admin/users/__tests__/$userId.test.tsx diff --git a/apps/web/src/__tests__/admin-users-create.test.tsx b/apps/web/src/routes/_app/admin/users/__tests__/create.test.tsx similarity index 100% rename from apps/web/src/__tests__/admin-users-create.test.tsx rename to apps/web/src/routes/_app/admin/users/__tests__/create.test.tsx diff --git a/apps/web/src/__tests__/admin-users-table.test.tsx b/apps/web/src/routes/_app/admin/users/__tests__/index.test.tsx similarity index 100% rename from apps/web/src/__tests__/admin-users-table.test.tsx rename to apps/web/src/routes/_app/admin/users/__tests__/index.test.tsx diff --git a/apps/web/src/__tests__/datahub-subject-assignments.test.tsx b/apps/web/src/routes/_app/datahub/$subjectId/__tests__/assignments.test.tsx similarity index 100% rename from apps/web/src/__tests__/datahub-subject-assignments.test.tsx rename to apps/web/src/routes/_app/datahub/$subjectId/__tests__/assignments.test.tsx diff --git a/apps/web/src/__tests__/datahub-subject-graph.test.tsx b/apps/web/src/routes/_app/datahub/$subjectId/__tests__/graph.test.tsx similarity index 100% rename from apps/web/src/__tests__/datahub-subject-graph.test.tsx rename to apps/web/src/routes/_app/datahub/$subjectId/__tests__/graph.test.tsx diff --git a/apps/web/src/__tests__/datahub-subject-layout.test.tsx b/apps/web/src/routes/_app/datahub/$subjectId/__tests__/route.test.tsx similarity index 100% rename from apps/web/src/__tests__/datahub-subject-layout.test.tsx rename to apps/web/src/routes/_app/datahub/$subjectId/__tests__/route.test.tsx diff --git a/apps/web/src/__tests__/datahub-subject-record.test.tsx b/apps/web/src/routes/_app/datahub/$subjectId/table/__tests__/$recordId.test.tsx similarity index 100% rename from apps/web/src/__tests__/datahub-subject-record.test.tsx rename to apps/web/src/routes/_app/datahub/$subjectId/table/__tests__/$recordId.test.tsx diff --git a/apps/web/src/__tests__/datahub-subject-table.test.tsx b/apps/web/src/routes/_app/datahub/$subjectId/table/__tests__/index.test.tsx similarity index 100% rename from apps/web/src/__tests__/datahub-subject-table.test.tsx rename to apps/web/src/routes/_app/datahub/$subjectId/table/__tests__/index.test.tsx diff --git a/apps/web/src/__tests__/datahub-table-controls.test.tsx b/apps/web/src/routes/_app/datahub/__tests__/index.test.tsx similarity index 100% rename from apps/web/src/__tests__/datahub-table-controls.test.tsx rename to apps/web/src/routes/_app/datahub/__tests__/index.test.tsx diff --git a/apps/web/src/routes/_app/group/__tests__/email-templates.test.tsx b/apps/web/src/routes/_app/group/__tests__/email-templates.test.tsx new file mode 100644 index 000000000..c0f11ce6b --- /dev/null +++ b/apps/web/src/routes/_app/group/__tests__/email-templates.test.tsx @@ -0,0 +1,54 @@ +import { isRedirect } from '@tanstack/react-router'; +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Route } from '@/routes/_app/group/email-templates'; + +import '@/services/i18n'; + +const mocks = vi.hoisted(() => ({ + config: { setup: { isGatewayEnabled: true } } +})); + +vi.mock('@/config', () => ({ config: mocks.config })); +vi.mock('@/components/GroupEmailTemplates', () => ({ + GroupEmailTemplates: () =>
+})); + +const runGuard = () => { + const beforeLoad = Route.options.beforeLoad as () => void; + try { + beforeLoad(); + } catch (err) { + return err; + } + return null; +}; + +beforeEach(() => { + mocks.config.setup.isGatewayEnabled = true; +}); + +afterEach(cleanup); + +describe('group email templates route guard', () => { + it('should redirect to the dashboard when the gateway is not deployed, so a bookmarked link cannot reach a page whose endpoints are not mounted', () => { + mocks.config.setup.isGatewayEnabled = false; + const thrown = runGuard(); + expect(isRedirect(thrown)).toBe(true); + expect(thrown).toMatchObject({ options: { to: '/dashboard' } }); + }); + + it('should allow the route when the gateway is deployed', () => { + expect(runGuard()).toBeNull(); + }); +}); + +describe('group email templates route', () => { + it('should title the page and render the group email template manager beneath it', () => { + const Component = Route.options.component!; + render(); + expect(screen.getByTestId('page-header').textContent).toBe('Email Templates'); + expect(screen.getByTestId('group-email-templates')).toBeTruthy(); + }); +}); diff --git a/apps/web/src/__tests__/manage.test.tsx b/apps/web/src/routes/_app/group/__tests__/manage.test.tsx similarity index 100% rename from apps/web/src/__tests__/manage.test.tsx rename to apps/web/src/routes/_app/group/__tests__/manage.test.tsx diff --git a/apps/web/src/__tests__/remote-assignments-route.test.tsx b/apps/web/src/routes/_app/group/__tests__/remote-assignments.test.tsx similarity index 100% rename from apps/web/src/__tests__/remote-assignments-route.test.tsx rename to apps/web/src/routes/_app/group/__tests__/remote-assignments.test.tsx diff --git a/apps/web/src/__tests__/accessible-instruments-route.test.tsx b/apps/web/src/routes/_app/instruments/__tests__/accessible-instruments.test.tsx similarity index 100% rename from apps/web/src/__tests__/accessible-instruments-route.test.tsx rename to apps/web/src/routes/_app/instruments/__tests__/accessible-instruments.test.tsx diff --git a/apps/web/src/__tests__/instrument-render-route.test.tsx b/apps/web/src/routes/_app/instruments/render/__tests__/$id.test.tsx similarity index 100% rename from apps/web/src/__tests__/instrument-render-route.test.tsx rename to apps/web/src/routes/_app/instruments/render/__tests__/$id.test.tsx diff --git a/apps/web/src/__tests__/remote-assignment-route.test.tsx b/apps/web/src/routes/_app/session/__tests__/remote-assignment.test.tsx similarity index 100% rename from apps/web/src/__tests__/remote-assignment-route.test.tsx rename to apps/web/src/routes/_app/session/__tests__/remote-assignment.test.tsx diff --git a/apps/web/src/__tests__/start-session-route.test.tsx b/apps/web/src/routes/_app/session/__tests__/start-session.test.tsx similarity index 100% rename from apps/web/src/__tests__/start-session-route.test.tsx rename to apps/web/src/routes/_app/session/__tests__/start-session.test.tsx diff --git a/apps/web/src/__tests__/upload-instrument-id.test.tsx b/apps/web/src/routes/_app/upload/__tests__/$instrumentId.test.tsx similarity index 100% rename from apps/web/src/__tests__/upload-instrument-id.test.tsx rename to apps/web/src/routes/_app/upload/__tests__/$instrumentId.test.tsx diff --git a/apps/web/src/__tests__/upload-index.test.tsx b/apps/web/src/routes/_app/upload/__tests__/index.test.tsx similarity index 100% rename from apps/web/src/__tests__/upload-index.test.tsx rename to apps/web/src/routes/_app/upload/__tests__/index.test.tsx diff --git a/apps/web/src/__tests__/auth-login.test.tsx b/apps/web/src/routes/auth/__tests__/login.test.tsx similarity index 100% rename from apps/web/src/__tests__/auth-login.test.tsx rename to apps/web/src/routes/auth/__tests__/login.test.tsx diff --git a/apps/web/src/__tests__/auth-reset-password.test.tsx b/apps/web/src/routes/auth/__tests__/reset-password.test.tsx similarity index 100% rename from apps/web/src/__tests__/auth-reset-password.test.tsx rename to apps/web/src/routes/auth/__tests__/reset-password.test.tsx diff --git a/apps/web/src/__tests__/axios.test.ts b/apps/web/src/services/__tests__/axios.test.ts similarity index 100% rename from apps/web/src/__tests__/axios.test.ts rename to apps/web/src/services/__tests__/axios.test.ts diff --git a/apps/web/src/services/__tests__/i18n.test.ts b/apps/web/src/services/__tests__/i18n.test.ts new file mode 100644 index 000000000..f1a43d53c --- /dev/null +++ b/apps/web/src/services/__tests__/i18n.test.ts @@ -0,0 +1,32 @@ +import { i18n } from '@douglasneuroinformatics/libui/i18n'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { reconcileInterfaceLanguage } from '@/services/i18n'; + +describe('reconcileInterfaceLanguage', () => { + beforeEach(() => { + i18n.changeLanguage('en'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should move a reader off a language the instance no longer offers', () => { + i18n.changeLanguage('es'); + reconcileInterfaceLanguage(['en', 'fr']); + expect(i18n.resolvedLanguage).toBe('en'); + }); + + it('should leave a reader on a language the instance still offers', () => { + i18n.changeLanguage('fr'); + reconcileInterfaceLanguage(['en', 'fr']); + expect(i18n.resolvedLanguage).toBe('fr'); + }); + + it('should not change the language when nothing moved, so it does not notify every translated component', () => { + const changeLanguage = vi.spyOn(i18n, 'changeLanguage'); + reconcileInterfaceLanguage(['en', 'es', 'fr']); + expect(changeLanguage).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/__tests__/react-query.test.ts b/apps/web/src/services/__tests__/react-query.test.ts similarity index 100% rename from apps/web/src/__tests__/react-query.test.ts rename to apps/web/src/services/__tests__/react-query.test.ts diff --git a/apps/web/src/__tests__/zod.test.ts b/apps/web/src/services/__tests__/zod.test.ts similarity index 100% rename from apps/web/src/__tests__/zod.test.ts rename to apps/web/src/services/__tests__/zod.test.ts diff --git a/apps/web/src/__tests__/store.test.ts b/apps/web/src/store/__tests__/index.test.ts similarity index 100% rename from apps/web/src/__tests__/store.test.ts rename to apps/web/src/store/__tests__/index.test.ts diff --git a/apps/web/src/__tests__/auth.slice.test.ts b/apps/web/src/store/slices/__tests__/auth.slice.test.ts similarity index 100% rename from apps/web/src/__tests__/auth.slice.test.ts rename to apps/web/src/store/slices/__tests__/auth.slice.test.ts diff --git a/apps/web/src/__tests__/connectivity.slice.test.ts b/apps/web/src/store/slices/__tests__/connectivity.slice.test.ts similarity index 100% rename from apps/web/src/__tests__/connectivity.slice.test.ts rename to apps/web/src/store/slices/__tests__/connectivity.slice.test.ts diff --git a/apps/web/src/__tests__/disclaimer.slice.test.ts b/apps/web/src/store/slices/__tests__/disclaimer.slice.test.ts similarity index 100% rename from apps/web/src/__tests__/disclaimer.slice.test.ts rename to apps/web/src/store/slices/__tests__/disclaimer.slice.test.ts diff --git a/apps/web/src/__tests__/preferences.slice.test.ts b/apps/web/src/store/slices/__tests__/preferences.slice.test.ts similarity index 100% rename from apps/web/src/__tests__/preferences.slice.test.ts rename to apps/web/src/store/slices/__tests__/preferences.slice.test.ts diff --git a/apps/web/src/__tests__/session.slice.test.ts b/apps/web/src/store/slices/__tests__/session.slice.test.ts similarity index 100% rename from apps/web/src/__tests__/session.slice.test.ts rename to apps/web/src/store/slices/__tests__/session.slice.test.ts diff --git a/apps/web/src/__tests__/walkthrough.slice.test.ts b/apps/web/src/store/slices/__tests__/walkthrough.slice.test.ts similarity index 100% rename from apps/web/src/__tests__/walkthrough.slice.test.ts rename to apps/web/src/store/slices/__tests__/walkthrough.slice.test.ts diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index eb8255f85..8ab517d40 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -45,6 +45,7 @@ export default defineConfig(async ({ command }) => { tanstackRouter({ autoCodeSplitting: true, generatedRouteTree: './src/route-tree.ts', + routeFileIgnorePattern: '^__tests__$', target: 'react' }), react(), diff --git a/eslint.config.js b/eslint.config.js index 0a7b20a8b..3e7f5ff10 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,3 +1,6 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + import { config } from '@douglasneuroinformatics/eslint-config'; // Restricted imports and syntax are declared as constants because flat config *replaces* a rule's @@ -20,12 +23,6 @@ const NO_TAILWIND_MERGE = { name: 'tailwind-merge' }; -const NO_TESTS_IN_ROUTES = { - group: ['vitest', '@testing-library/*'], - message: - 'The TanStack route generator scans every file under src/routes and warns on any that does not export a Route. Put tests in src/hooks/__tests__/, src/utils/__tests__/ or src/__tests__/.' -}; - const NO_AXIOS_INSTANCE = { message: 'apps/web uses the default axios instance; the retry, offline and error-notification interceptors in src/services/axios.ts are installed on it. A separate instance silently bypasses all of them.', @@ -45,6 +42,70 @@ const REQUIRE_ROUTE_ACCESS = { 'MethodDefinition:has(Decorator > CallExpression > Identifier.callee[name=/^(Delete|Get|Patch|Post|Put)$/]):not(:has(Decorator > CallExpression > Identifier.callee[name="RouteAccess"]))' }; +const TEST_TOOLING = /^(?:vitest|@testing-library\/[^/]+)(?:\/|$)/; + +const UNIT_TEST_FILENAME = /^(?.+)\.(?test|spec)\.[jt]sx?$/; + +const SUBJECT_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx']; + +const unitTestLayout = { + rules: { + 'test-tooling-in-tests-folder': { + create(context) { + const folders = context.filename.split(path.sep); + if (folders.includes('__tests__') || folders.includes('__mocks__')) { + return {}; + } + return { + ImportDeclaration(node) { + if (TEST_TOOLING.test(node.source.value)) { + context.report({ data: { source: node.source.value }, messageId: 'testTooling', node }); + } + } + }; + }, + meta: { + messages: { + testTooling: "'{{source}}' is test tooling; only a file in a __tests__/ or __mocks__/ folder may import it." + }, + schema: [], + type: 'problem' + } + }, + 'unit-test-names-subject': { + create(context) { + const { base, dir } = path.parse(context.filename); + const { subject, suffix } = UNIT_TEST_FILENAME.exec(base).groups; + return { + Program(node) { + if (path.basename(dir) !== '__tests__') { + context.report({ messageId: 'outsideTestsFolder', node }); + return; + } + if (suffix === 'spec') { + context.report({ messageId: 'specSuffix', node }); + } + const subjectPath = path.join(dir, '..', subject); + if (!SUBJECT_EXTENSIONS.some((extension) => fs.existsSync(subjectPath + extension))) { + context.report({ data: { subject }, messageId: 'noSubject', node }); + } + } + }; + }, + meta: { + messages: { + noSubject: + 'A unit test is named after the file it tests, but there is no {{subject}}.ts, .tsx, .js or .jsx beside this __tests__/ folder.', + outsideTestsFolder: 'A unit test lives in a __tests__/ folder beside the file it tests.', + specSuffix: 'Name a unit test *.test.*; *.spec.* is the suffix of the Playwright suite in testing/.' + }, + schema: [], + type: 'problem' + } + } + } +}; + export default config( { astro: { @@ -114,12 +175,28 @@ export default config( } }, { - files: ['apps/web/src/routes/**/*'], + plugins: { + odc: unitTestLayout + } + }, + { + files: ['{apps,packages,runtime}/*/src/**/*.{js,jsx,ts,tsx}'], rules: { - 'no-restricted-imports': [ - 'error', - { paths: [NO_BARE_ZOD, NO_CLSX, NO_TAILWIND_MERGE], patterns: [NO_TESTS_IN_ROUTES] } - ] + 'odc/test-tooling-in-tests-folder': 'error' + } + }, + { + files: ['{apps,packages,runtime}/*/src/**/*.{test,spec}.{js,jsx,ts,tsx}'], + rules: { + 'odc/unit-test-names-subject': 'error' + } + }, + { + // A __tests__/ folder inside an instrument directory fails the build, so an instrument's tests sit + // in the top-level folder, named after the instrument rather than a file beside them. + files: ['packages/instrument-library/src/__tests__/*'], + rules: { + 'odc/unit-test-names-subject': 'off' } }, { diff --git a/packages/instrument-bundler/AGENTS.md b/packages/instrument-bundler/AGENTS.md index e093123b2..35574ef71 100644 --- a/packages/instrument-bundler/AGENTS.md +++ b/packages/instrument-bundler/AGENTS.md @@ -65,7 +65,7 @@ bundler may delete any standalone top-level statement. Writing the switch as an assigns to a hoisted `var` puts the initialization in such a statement: `apps/api`'s production bundle dropped the entire module and every instrument import failed with `ReferenceError: build is not defined` — a failure that only appears in a bundled build, never under -`pnpm dev`. `src/__tests__/vendor.test.ts` bundles the module with tree shaking to guard this. +`pnpm dev`. `src/vendor/__tests__/esbuild.test.ts` bundles the module with tree shaking to guard this. **`src/parse.ts` is vendored** from `parse-imports` (Apache-2.0, adapted to TypeScript and to run in a browser). Treat it as third-party: fix it upstream-style or not at all. diff --git a/packages/instrument-bundler/package.json b/packages/instrument-bundler/package.json index adf0b61ab..80f6e0652 100644 --- a/packages/instrument-bundler/package.json +++ b/packages/instrument-bundler/package.json @@ -21,7 +21,7 @@ "instrument-bundler": "./src/cli.ts" }, "files": [ - "!/src/__tests__", + "!/src/**/__tests__", "/src" ], "scripts": { diff --git a/packages/instrument-bundler/src/__tests__/vendor.test.ts b/packages/instrument-bundler/src/vendor/__tests__/esbuild.test.ts similarity index 86% rename from packages/instrument-bundler/src/__tests__/vendor.test.ts rename to packages/instrument-bundler/src/vendor/__tests__/esbuild.test.ts index a46460c16..84d302511 100644 --- a/packages/instrument-bundler/src/__tests__/vendor.test.ts +++ b/packages/instrument-bundler/src/vendor/__tests__/esbuild.test.ts @@ -2,9 +2,9 @@ import * as path from 'node:path'; import { describe, expect, it } from 'vitest'; -import * as esbuild from '../vendor/esbuild.js'; +import * as esbuild from '../esbuild.js'; -const VENDOR_DIR = path.resolve(import.meta.dirname, '../vendor'); +const VENDOR_DIR = path.resolve(import.meta.dirname, '..'); describe('vendor/esbuild', () => { it('should keep initializing its exports when tree shaken, so a bundled API does not lose the build binding', async () => { diff --git a/packages/instrument-library/AGENTS.md b/packages/instrument-library/AGENTS.md index 4511f1e8a..76d0e901d 100644 --- a/packages/instrument-library/AGENTS.md +++ b/packages/instrument-library/AGENTS.md @@ -74,7 +74,9 @@ grouped by kind; `--title` prints a flat list. **It reads `dist`, not `src`, so - **Put tests in the top-level `src/__tests__/` only.** The build turns every directory holding an `index.*` into a bundle and reads each entry in it as a file, so a `__tests__/` inside an - instrument directory fails `pnpm build` (see "Instrument directories must be flat"). + instrument directory fails `pnpm build` (see "Instrument directories must be flat"). Name each + test after the instrument it covers (`DNP_STROOP_TASK.test.tsx`); this folder is why + `eslint.config.js` turns `odc/unit-test-names-subject` off here. - **Import the instrument's source default export**, with its extension: `import instrument from '../forms/DNP_HAPPINESS_QUESTIONNAIRE/index.ts'` (`.tsx` where the entry is). That is the `defineInstrument` result, not the bundle string `dist` exports. diff --git a/packages/playground-url/AGENTS.md b/packages/playground-url/AGENTS.md index 1e8059309..b3c818288 100644 --- a/packages/playground-url/AGENTS.md +++ b/packages/playground-url/AGENTS.md @@ -32,9 +32,9 @@ query string is sent to the server, and a large instrument pushes the request li ## Tests -`pnpm exec vitest --project playground-url`. Both test files sit beside the source rather than in a -`__tests__/` folder. `src/share-url.test.ts` is round-trip based: encode, decode, assert equality — -follow that shape for the library. `src/cli.test.ts` runs the CLI by importing `src/cli.ts` with a +`pnpm exec vitest --project playground-url`. `src/__tests__/share-url.test.ts` is round-trip based: +encode, decode, assert equality — follow that shape for the library. `src/__tests__/cli.test.ts` runs +the CLI by importing `src/cli.ts` with a stubbed `process.argv` and a mocked `spawn`. Pin `process.platform` in any test that reaches `openInBrowser`: CI runs on Linux, so a branch only the host OS takes is uncovered there and fails the 100% coverage gate. diff --git a/packages/playground-url/src/cli.test.ts b/packages/playground-url/src/__tests__/cli.test.ts similarity index 91% rename from packages/playground-url/src/cli.test.ts rename to packages/playground-url/src/__tests__/cli.test.ts index ee5bc884b..bfc748d62 100644 --- a/packages/playground-url/src/cli.test.ts +++ b/packages/playground-url/src/__tests__/cli.test.ts @@ -32,7 +32,7 @@ describe('cli', () => { it('should write a share URL to stdout for a directory of shareable source files', async () => { fs.writeFileSync(path.join(tmpDir, 'index.ts'), 'export default {};'); process.argv = ['node', 'cli.js', tmpDir]; - await import('./cli.js'); + await import('../cli.js'); expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('https://playground.opendatacapture.org')); }); @@ -40,7 +40,7 @@ describe('cli', () => { fs.writeFileSync(path.join(tmpDir, 'index.ts'), 'export default {};'); fs.writeFileSync(path.join(tmpDir, 'icon.png'), Buffer.from([0, 1, 2])); process.argv = ['node', 'cli.js', tmpDir]; - await import('./cli.js'); + await import('../cli.js'); expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining("Skipping 'icon.png'")); }); @@ -48,13 +48,13 @@ describe('cli', () => { fs.writeFileSync(path.join(tmpDir, 'index.ts'), 'export default {};'); fs.mkdirSync(path.join(tmpDir, 'nested')); process.argv = ['node', 'cli.js', tmpDir]; - await import('./cli.js'); + await import('../cli.js'); expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('https://playground.opendatacapture.org')); }); it('should set a non-zero exit code and write no URL when the directory has nothing shareable', async () => { process.argv = ['node', 'cli.js', tmpDir]; - await import('./cli.js'); + await import('../cli.js'); expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('No shareable source files found')); expect(process.exitCode).toBe(1); process.exitCode = 0; @@ -63,8 +63,8 @@ describe('cli', () => { it('should use the directory name as the label when none is given', async () => { fs.writeFileSync(path.join(tmpDir, 'index.ts'), 'export default {};'); process.argv = ['node', 'cli.js', tmpDir]; - const { decodeShareURL } = await import('./share-url.js'); - await import('./cli.js'); + const { decodeShareURL } = await import('../share-url.js'); + await import('../cli.js'); const href = stdoutSpy.mock.calls .map(([chunk]: [unknown]) => chunk) .find((chunk: unknown) => typeof chunk === 'string' && chunk.includes('http')); @@ -75,8 +75,8 @@ describe('cli', () => { it('should use the given label and mark the link fullscreen when requested', async () => { fs.writeFileSync(path.join(tmpDir, 'index.ts'), 'export default {};'); process.argv = ['node', 'cli.js', tmpDir, '--label', 'My Label', '--fullscreen']; - const { decodeShareURL, isFullscreenShareURL } = await import('./share-url.js'); - await import('./cli.js'); + const { decodeShareURL, isFullscreenShareURL } = await import('../share-url.js'); + await import('../cli.js'); const href = stdoutSpy.mock.calls .map(([chunk]: [unknown]) => chunk) .find((chunk: unknown) => typeof chunk === 'string' && chunk.includes('http')); @@ -90,7 +90,7 @@ describe('cli', () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin'); process.argv = ['node', 'cli.js', tmpDir, '--open']; const { spawn } = await import('node:child_process'); - await import('./cli.js'); + await import('../cli.js'); expect(spawn).toHaveBeenLastCalledWith('open', expect.anything(), expect.objectContaining({ shell: false })); }); @@ -99,7 +99,7 @@ describe('cli', () => { const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); process.argv = ['node', 'cli.js', tmpDir, '--open']; const { spawn } = await import('node:child_process'); - await import('./cli.js'); + await import('../cli.js'); expect(spawn).toHaveBeenCalledWith('start', expect.anything(), expect.objectContaining({ shell: true })); platformSpy.mockRestore(); }); @@ -109,7 +109,7 @@ describe('cli', () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); process.argv = ['node', 'cli.js', tmpDir, '--open']; const { spawn } = await import('node:child_process'); - await import('./cli.js'); + await import('../cli.js'); expect(spawn).toHaveBeenLastCalledWith('xdg-open', expect.anything(), expect.objectContaining({ shell: false })); }); @@ -117,7 +117,7 @@ describe('cli', () => { fs.writeFileSync(path.join(tmpDir, 'index.ts'), 'export default {};'); fs.writeFileSync(path.join(tmpDir, 'README.md'), '# Notes'); process.argv = ['node', 'cli.js', tmpDir]; - await import('./cli.js'); + await import('../cli.js'); expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Encoded 1 file ')); expect(stderrSpy).not.toHaveBeenCalledWith(expect.stringContaining('README.md')); }); @@ -125,7 +125,7 @@ describe('cli', () => { it('should not pluralize the file count when exactly one file was encoded', async () => { fs.writeFileSync(path.join(tmpDir, 'index.ts'), 'export default {};'); process.argv = ['node', 'cli.js', tmpDir]; - await import('./cli.js'); + await import('../cli.js'); expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Encoded 1 file ')); }); @@ -133,14 +133,14 @@ describe('cli', () => { fs.writeFileSync(path.join(tmpDir, 'index.ts'), 'export default {};'); fs.writeFileSync(path.join(tmpDir, 'styles.css'), 'body {}'); process.argv = ['node', 'cli.js', tmpDir]; - await import('./cli.js'); + await import('../cli.js'); expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Encoded 2 files ')); }); it('should resolve --base-url against its origin, dropping any path', async () => { fs.writeFileSync(path.join(tmpDir, 'index.ts'), 'export default {};'); process.argv = ['node', 'cli.js', tmpDir, '--base-url', 'https://example.org/some/path']; - await import('./cli.js'); + await import('../cli.js'); expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('https://example.org/#')); }); @@ -148,14 +148,14 @@ describe('cli', () => { fs.writeFileSync(path.join(tmpDir, 'index.ts'), 'export default {};'); const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); process.argv = ['node', 'cli.js', tmpDir, '--base-url', 'not-a-url']; - await expect(import('./cli.js')).rejects.toThrow(/EXIT/); + await expect(import('../cli.js')).rejects.toThrow(/EXIT/); errorSpy.mockRestore(); }); it('should exit with an error when the target directory does not exist', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); process.argv = ['node', 'cli.js', path.join(tmpDir, 'missing')]; - await expect(import('./cli.js')).rejects.toThrow(/EXIT/); + await expect(import('../cli.js')).rejects.toThrow(/EXIT/); errorSpy.mockRestore(); }); @@ -164,7 +164,7 @@ describe('cli', () => { fs.writeFileSync(filePath, ''); const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); process.argv = ['node', 'cli.js', filePath]; - await expect(import('./cli.js')).rejects.toThrow(/EXIT/); + await expect(import('../cli.js')).rejects.toThrow(/EXIT/); errorSpy.mockRestore(); }); }); diff --git a/packages/playground-url/src/share-url.test.ts b/packages/playground-url/src/__tests__/share-url.test.ts similarity index 96% rename from packages/playground-url/src/share-url.test.ts rename to packages/playground-url/src/__tests__/share-url.test.ts index 398bf6f2f..2f14ca684 100644 --- a/packages/playground-url/src/share-url.test.ts +++ b/packages/playground-url/src/__tests__/share-url.test.ts @@ -6,9 +6,9 @@ import { encodeShareURL, generatePlaygroundURL, isFullscreenShareURL -} from './share-url.js'; +} from '../share-url.js'; -import type { PlaygroundInstrument } from './models.js'; +import type { PlaygroundInstrument } from '../models.js'; const instrument: PlaygroundInstrument = { files: [ diff --git a/packages/react-core/AGENTS.md b/packages/react-core/AGENTS.md index e1493d40d..533bb8152 100644 --- a/packages/react-core/AGENTS.md +++ b/packages/react-core/AGENTS.md @@ -68,9 +68,9 @@ frontend shows — instruments and the apps' own forms alike. `apps/web/src/serv - A message written by the schema author always wins — both majors skip the error map entirely when an issue already carries one. -Most tests live in `apps/web/src/__tests__/zod-error-maps.test.ts`, on the adapter side of the seam; -`src/utils/__tests__/zodErrorMap.test.ts` covers only the cases the web suite does not reach. Both -drive `createZodErrorMaps` through per-parse maps rather than registering globally. +`src/utils/__tests__/zodErrorMap.test.ts` drives `createZodErrorMaps` through per-parse maps rather +than registering globally. A case that depends on the reader switching language mid-session builds +its maps over libui's real `i18n`; every other case uses a fixed-language translator. ## `@tanstack/react-router` is an optional peer — never import it diff --git a/packages/react-core/src/components/LanguageToggle/__tests__/LanguageToggle.test.tsx b/packages/react-core/src/components/LanguageToggle/__tests__/LanguageToggle.test.tsx index 4a2a7de19..1cba7fbd2 100644 --- a/packages/react-core/src/components/LanguageToggle/__tests__/LanguageToggle.test.tsx +++ b/packages/react-core/src/components/LanguageToggle/__tests__/LanguageToggle.test.tsx @@ -1,12 +1,17 @@ +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; import { i18n } from '@douglasneuroinformatics/libui/i18n'; -import { cleanup, render, screen, waitFor } from '@testing-library/react'; -import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import type { ActiveLanguages } from '@opendatacapture/schemas/core'; +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { LanguageToggle } from '../LanguageToggle'; describe('LanguageToggle', () => { beforeAll(() => { i18n.init({ translations: {} }); + }); + + beforeEach(() => { i18n.changeLanguage('en'); }); @@ -23,11 +28,53 @@ describe('LanguageToggle', () => { }); it('should move off a language that has been deactivated, rather than strand a reader on it', async () => { - i18n.changeLanguage('en'); render(); await waitFor(() => { expect(i18n.resolvedLanguage).toBe('es'); }); - i18n.changeLanguage('en'); + }); + + it('should leave a reader on a language that is still active', () => { + i18n.changeLanguage('fr'); + render(); + expect(i18n.resolvedLanguage).toBe('fr'); + }); + + it('should move a stranded reader even when only one language remains, where no toggle renders', () => { + i18n.changeLanguage('fr'); + render(); + expect(i18n.resolvedLanguage).toBe('es'); + }); + + // The sidebar renders the toggle as a descendant while translating its own strings, so it is the + // ancestor that has to re-render for the fix to be worth anything — asserting `resolvedLanguage` + // alone passed while the sidebar stayed in the deactivated language. + const Ancestor = ({ activeLanguages }: { activeLanguages: ActiveLanguages }) => { + const { t } = useTranslation(); + return ( +
+ {t({ en: 'Dashboard', es: 'Panel de control', fr: 'Tableau' })} + +
+ ); + }; + + it('should re-render an ancestor when a language is deactivated mid-session', () => { + const { rerender } = render(); + act(() => i18n.changeLanguage('es')); + expect(screen.getByTestId('ancestor-label').textContent).toBe('Panel de control'); + + rerender(); + expect(i18n.resolvedLanguage).toBe('en'); + expect(screen.getByTestId('ancestor-label').textContent).toBe('Dashboard'); + }); + + it('should leave a reader alone when the deactivated language was not theirs', () => { + const { rerender } = render(); + act(() => i18n.changeLanguage('fr')); + + rerender(); + expect(i18n.resolvedLanguage).toBe('fr'); + expect(screen.getByTestId('ancestor-label').textContent).toBe('Tableau'); }); }); diff --git a/packages/react-core/src/utils/__tests__/zodErrorMap.test.ts b/packages/react-core/src/utils/__tests__/zodErrorMap.test.ts index e09167df4..68aade8b0 100644 --- a/packages/react-core/src/utils/__tests__/zodErrorMap.test.ts +++ b/packages/react-core/src/utils/__tests__/zodErrorMap.test.ts @@ -1,7 +1,8 @@ import { format } from '@douglasneuroinformatics/libjs'; +import { i18n } from '@douglasneuroinformatics/libui/i18n'; import type { TranslateOptions, TranslationValue } from '@douglasneuroinformatics/libui/i18n'; import type { Language } from '@opendatacapture/schemas/core'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { z as z3 } from 'zod/v3'; import { z as z4 } from 'zod/v4'; @@ -40,11 +41,68 @@ const describeV4 = (issue: z4.core.$ZodRawIssue) => maps.v4(issue); const JANUARY_15_2020 = new Date(2020, 0, 15); -// Behaviour a form field reaches every day (required, type, size, format and union messages) is -// tested once, on the adapter side, in apps/web/src/__tests__/zod-error-maps.test.ts. These are -// the issue shapes that suite does not produce. +beforeAll(() => { + i18n.init({ translations: {} }); +}); + +afterEach(() => { + i18n.changeLanguage('en'); +}); + describe('zod v3 error map', () => { it.each([ + ['a missing field is the required message', z3.object({ x: z3.string() }), {}, REQUIRED], + ['a null value reads as missing rather than a type error', z3.object({ x: z3.string() }), { x: null }, REQUIRED], + ['an empty string against min(1), which untouched text fields submit', z3.string().min(1), '', REQUIRED], + [ + 'an untouched union of literals, which zod would otherwise report as "Invalid input"', + z3.object({ x: z3.union([z3.literal(1), z3.literal(2)]) }), + {}, + REQUIRED + ], + ['a decimal in an integer field', z3.number().int(), 1.5, 'Must be a whole number'], + ['a number where text is expected', z3.string(), 1, 'Must be text'], + ['text where a number is expected', z3.number(), 'x', 'Must be a number'], + ['text where a boolean is expected', z3.boolean(), 'x', 'Must be a valid selection'], + ['an unparseable date', z3.date(), new Date('nope'), 'Must be a valid date'], + ['a number below an inclusive minimum', z3.number().gte(1), 0, 'Must be 1 or greater'], + ['a number at an exclusive minimum', z3.number().gt(1), 1, 'Must be greater than 1'], + ['a number above an inclusive maximum', z3.number().lte(10), 12, 'Must be 10 or less'], + ['a large bound, grouped for readability', z3.number().lte(1000000), 2000000, 'Must be 1,000,000 or less'], + ['a string below its minimum length', z3.string().min(3), 'ab', 'Must be at least 3 characters'], + ['a string of the wrong exact length', z3.string().length(5), 'ab', 'Must be exactly 5 characters'], + ['a set below its minimum size', z3.set(z3.string()).min(2), new Set(['a']), 'Must select at least 2 options'], + [ + 'a single-option minimum, in the singular', + z3.set(z3.string()).min(1), + new Set(), + 'Must select at least 1 option' + ], + ['an array above its maximum size', z3.array(z3.string()).max(1), ['a', 'b'], 'Must select at most 1 option'], + [ + 'a value failing a regex, without leaking the pattern', + z3.string().regex(/^[A-Z]$/), + 'zz', + 'Does not match the expected format' + ], + ['a malformed email address', z3.string().email(), 'x', 'Must be a valid email address'], + ['a malformed url', z3.string().url(), 'x', 'Must be a valid web address'], + ['a string missing its required prefix', z3.string().startsWith('ab'), 'zz', 'Must start with "ab"'], + ['a value outside an enum', z3.enum(['A', 'B']), 'C', 'Must be a valid selection'], + ['a number that is not a multiple of the divisor', z3.number().multipleOf(5), 7, 'Must be a multiple of 5'], + [ + 'a union whose branches disagree, which has no single explanation', + z3.union([z3.string(), z3.number()]), + true, + INVALID + ], + ['an issue the map does not model', z3.number().finite(), Infinity, INVALID], + [ + 'an unexpected key, which no form field can produce', + z3.object({ x: z3.string() }).strict(), + { x: 'a', y: 1 }, + INVALID + ], ['a missing literal as required', z3.literal(1), undefined, REQUIRED], ['a null literal as required', z3.literal(1), null, REQUIRED], ['a wrong literal as an invalid selection', z3.literal(1), 2, 'Must be a valid selection'], @@ -64,6 +122,29 @@ describe('zod v3 error map', () => { expect(v3Message(schema, value)).toBe(expected); }); + it('should format a date bound as a date rather than a timestamp', () => { + expect(v3Message(z3.date().min(JANUARY_15_2020), new Date(2019, 0, 1))).toBe( + 'Must be on or after January 15, 2020' + ); + }); + + it('should leave a message written by the schema author untouched', () => { + expect(v3Message(z3.string().min(3, { message: 'Trop court' }), 'ab')).toBe('Trop court'); + }); + + it('should follow the language the reader switched to, since messages are built at parse time', () => { + const { v3 } = createZodErrorMaps(i18n); + i18n.changeLanguage('fr'); + expect(v3Message(z3.object({ x: z3.string() }), {}, v3)).toBe('Ce champ est obligatoire'); + expect(v3Message(z3.number().int(), 1.5, v3)).toBe('Doit être un nombre entier'); + expect(v3Message(z3.string().min(3), 'ab', v3)).toBe('Doit contenir au moins 3 caractères'); + }); + + it('should put zero in the singular in French, where English keeps it plural', () => { + expect(v3Message(z3.array(z3.string()).max(0), ['a'])).toBe('Must select at most 0 options'); + expect(v3Message(z3.array(z3.string()).max(0), ['a'], mapsFor('fr').v3)).toBe('Doit sélectionner au plus 0 option'); + }); + it('should treat null as a type error when the schema expects null itself', () => { expect(describeV3({ code: 'invalid_type', expected: 'null', path: [], received: 'null' })).toBe(INVALID); }); @@ -103,6 +184,56 @@ describe('zod v3 error map', () => { describe('zod v4 error map', () => { it.each([ + ['a missing field is the required message', z4.object({ x: z4.string() }), {}, REQUIRED], + ['a null value reads as missing rather than a type error', z4.object({ x: z4.string() }), { x: null }, REQUIRED], + ['an empty string against min(1), which untouched text fields submit', z4.string().min(1), '', REQUIRED], + [ + 'an untouched union of literals, which zod would otherwise report as "Invalid input"', + z4.object({ x: z4.union([z4.literal(1), z4.literal(2)]) }), + {}, + REQUIRED + ], + ['a decimal in an integer field', z4.number().int(), 1.5, 'Must be a whole number'], + ['a number where text is expected', z4.string(), 1, 'Must be text'], + ['text where a number is expected', z4.number(), 'x', 'Must be a number'], + ['text where a boolean is expected', z4.boolean(), 'x', 'Must be a valid selection'], + ['a number below an inclusive minimum', z4.number().gte(1), 0, 'Must be 1 or greater'], + ['a number at an exclusive minimum', z4.number().gt(1), 1, 'Must be greater than 1'], + ['a number above an inclusive maximum', z4.number().lte(10), 12, 'Must be 10 or less'], + ['a large bound, grouped for readability', z4.number().lte(1000000), 2000000, 'Must be 1,000,000 or less'], + ['a string below its minimum length', z4.string().min(3), 'ab', 'Must be at least 3 characters'], + ['a string of the wrong exact length', z4.string().length(5), 'ab', 'Must be exactly 5 characters'], + ['a set below its minimum size', z4.set(z4.string()).min(2), new Set(['a']), 'Must select at least 2 options'], + [ + 'a single-option minimum, in the singular', + z4.set(z4.string()).min(1), + new Set(), + 'Must select at least 1 option' + ], + ['an array above its maximum size', z4.array(z4.string()).max(1), ['a', 'b'], 'Must select at most 1 option'], + [ + 'a value failing a regex, without leaking the pattern', + z4.string().regex(/^[A-Z]$/), + 'zz', + 'Does not match the expected format' + ], + ['a malformed email address', z4.email(), 'x', 'Must be a valid email address'], + ['a malformed url', z4.url(), 'x', 'Must be a valid web address'], + ['a string missing its required prefix', z4.string().startsWith('ab'), 'zz', 'Must start with "ab"'], + ['a value outside an enum', z4.enum(['A', 'B']), 'C', 'Must be a valid selection'], + ['a number that is not a multiple of the divisor', z4.number().multipleOf(5), 7, 'Must be a multiple of 5'], + [ + 'a union whose branches disagree, which has no single explanation', + z4.union([z4.string(), z4.number()]), + true, + INVALID + ], + [ + 'an unexpected key, which no form field can produce', + z4.strictObject({ x: z4.string() }), + { x: 'a', y: 1 }, + INVALID + ], ['a null literal as required rather than a wrong option', z4.literal(1), null, REQUIRED], ['a type no form field produces as invalid', z4.symbol(), 1, INVALID], ['a malformed iso date as an invalid date', z4.iso.date(), 'x', 'Must be a valid date'], @@ -121,6 +252,30 @@ describe('zod v4 error map', () => { expect(v4Message(schema, value)).toBe(expected); }); + it('should format a date bound as a date rather than a timestamp', () => { + expect(v4Message(z4.date().min(JANUARY_15_2020), new Date(2019, 0, 1))).toBe( + 'Must be on or after January 15, 2020' + ); + }); + + it('should leave a message written by the schema author untouched', () => { + expect(v4Message(z4.string().min(3, { message: 'Trop court' }), 'ab')).toBe('Trop court'); + }); + + it('should follow the language the reader switched to, since messages are built at parse time', () => { + const { v4 } = createZodErrorMaps(i18n); + i18n.changeLanguage('fr'); + expect(v4Message(z4.object({ x: z4.string() }), {}, v4)).toBe('Ce champ est obligatoire'); + expect(v4Message(z4.number().int(), 1.5, v4)).toBe('Doit être un nombre entier'); + expect(v4Message(z4.string().min(3), 'ab', v4)).toBe('Doit contenir au moins 3 caractères'); + }); + + it('should translate to Spanish, which the language table covers but few inline strings do', () => { + const { v4 } = mapsFor('es'); + expect(v4Message(z4.object({ x: z4.string() }), {}, v4)).toBe('Este campo es obligatorio'); + expect(v4Message(z4.string().min(3), 'ab', v4)).toBe('Debe tener al menos 3 caracteres'); + }); + it('should treat null as a type error when the schema expects null itself', () => { expect(describeV4({ code: 'invalid_type', expected: 'null', input: null })).toBe(INVALID); }); diff --git a/packages/runtime-meta/AGENTS.md b/packages/runtime-meta/AGENTS.md index 7b3a295af..3788074f7 100644 --- a/packages/runtime-meta/AGENTS.md +++ b/packages/runtime-meta/AGENTS.md @@ -27,7 +27,7 @@ TypeScript would change what `exports` resolves to. ## Tests -`pnpm exec vitest --project runtime-meta`. There is a `vitest.config.ts` here, and tests live in -`test/` (not `src/__tests__/`) — `test/parse-packages.test.ts` is the only one. `generateManifest` and -the plugin behavior around it are covered instead by -`packages/vite-plugin-runtime/src/__tests__/plugin.test.ts`, which mocks `fs`. +`pnpm exec vitest --project runtime-meta`. There is a `vitest.config.ts` here, and +`src/__tests__/index.test.ts` covers every export of `src/index.js`, with `fs` and `module` mocked. +The plugin behavior built on top of them is covered by +`packages/vite-plugin-runtime/src/__tests__/plugin.test.ts`. diff --git a/packages/runtime-meta/test/index.test.ts b/packages/runtime-meta/src/__tests__/index.test.ts similarity index 68% rename from packages/runtime-meta/test/index.test.ts rename to packages/runtime-meta/src/__tests__/index.test.ts index c20d5422a..62da53d45 100644 --- a/packages/runtime-meta/test/index.test.ts +++ b/packages/runtime-meta/src/__tests__/index.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -const tree: Record = { +const tree: { [key: string]: string[] } = { '/base': ['index.js', 'sub'], '/base/sub': ['page.html', 'styles.css', 'types.d.ts'] }; @@ -9,8 +9,8 @@ vi.mock('fs', () => ({ existsSync: (path: string) => Object.hasOwn(tree, path), lstatSync: (path: string) => ({ isDirectory: () => Object.hasOwn(tree, path) }), promises: { - readdir: async (dir: string) => tree[dir] ?? [], - readFile: async (path: string) => `content:${path}` + readdir: (dir: string) => Promise.resolve(tree[dir] ?? []), + readFile: (path: string) => Promise.resolve(`content:${path}`) } })); @@ -20,7 +20,8 @@ vi.mock('module', () => ({ }) })); -const { generateManifest, generateMetadata, MANIFEST_FILENAME, resolveRuntimeAsset } = await import('../src/index.js'); +const { generateManifest, generateMetadata, MANIFEST_FILENAME, parsePackages, resolveRuntimeAsset } = + await import('../index.js'); describe('generateManifest', () => { it('should group files under a directory into sources, styles, declarations and html by extension', async () => { @@ -108,3 +109,44 @@ describe('resolveRuntimeAsset', () => { await expect(resolveRuntimeAsset('/v1/missing.js', metadata)).resolves.toBeNull(); }); }); + +describe('parsePackages', () => { + it('groups files under the package parsed from their path', () => { + const packages = parsePackages('v1', { + html: [], + sources: ['react@19.x/index.js', 'react@19.x/jsx-runtime.js'], + styles: ['normalize.css@8.x/normalize.css'] + }); + expect(packages).toStrictEqual([ + { + exports: { + css: [], + html: [], + js: ['/runtime/v1/react@19.x/index.js', '/runtime/v1/react@19.x/jsx-runtime.js'] + }, + name: 'react', + version: '19.x' + }, + { + exports: { css: ['/runtime/v1/normalize.css@8.x/normalize.css'], html: [], js: [] }, + name: 'normalize.css', + version: '8.x' + } + ]); + }); + + it('omits underscore-prefixed bundler output from all packages', () => { + const packages = parsePackages('v1', { + html: [], + sources: ['_chunks/ABCD1234.js', 'react@19.x/index.js'], + styles: [] + }); + expect(packages.map((pkg) => pkg.name)).toStrictEqual(['react']); + }); + + it('throws when a source path does not match the import path pattern', () => { + expect(() => parsePackages('v1', { html: [], sources: ['/leading-slash.js'], styles: [] })).toThrow( + 'Unexpected import path pattern' + ); + }); +}); diff --git a/packages/runtime-meta/test/parse-packages.test.ts b/packages/runtime-meta/test/parse-packages.test.ts deleted file mode 100644 index acba95057..000000000 --- a/packages/runtime-meta/test/parse-packages.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { parsePackages } from '../src/index.js'; - -describe('parsePackages', () => { - it('groups files under the package parsed from their path', () => { - const packages = parsePackages('v1', { - html: [], - sources: ['react@19.x/index.js', 'react@19.x/jsx-runtime.js'], - styles: ['normalize.css@8.x/normalize.css'] - }); - expect(packages).toStrictEqual([ - { - exports: { - css: [], - html: [], - js: ['/runtime/v1/react@19.x/index.js', '/runtime/v1/react@19.x/jsx-runtime.js'] - }, - name: 'react', - version: '19.x' - }, - { - exports: { css: ['/runtime/v1/normalize.css@8.x/normalize.css'], html: [], js: [] }, - name: 'normalize.css', - version: '8.x' - } - ]); - }); - - it('omits underscore-prefixed bundler output from all packages', () => { - const packages = parsePackages('v1', { - html: [], - sources: ['_chunks/ABCD1234.js', 'react@19.x/index.js'], - styles: [] - }); - expect(packages.map((pkg) => pkg.name)).toStrictEqual(['react']); - }); - - it('throws when a source path does not match the import path pattern', () => { - expect(() => parsePackages('v1', { html: [], sources: ['/leading-slash.js'], styles: [] })).toThrow( - 'Unexpected import path pattern' - ); - }); -}); diff --git a/packages/runtime-meta/tsconfig.json b/packages/runtime-meta/tsconfig.json index 3b39d71a4..feff106e7 100644 --- a/packages/runtime-meta/tsconfig.json +++ b/packages/runtime-meta/tsconfig.json @@ -5,5 +5,5 @@ "checkJs": true, "maxNodeModuleJsDepth": 0 }, - "include": ["src/**/*", "test/**/*.ts", "vitest.config.ts"] + "include": ["src/**/*", "vitest.config.ts"] } diff --git a/packages/schemas/AGENTS.md b/packages/schemas/AGENTS.md index 0675c32e3..2251b3f3a 100644 --- a/packages/schemas/AGENTS.md +++ b/packages/schemas/AGENTS.md @@ -100,11 +100,11 @@ stored before it — `.agents/docs/architecture/auth-and-permissions.md` says wh `pnpm exec vitest --project schemas`. There is a `vitest.config.ts`; no setup files, no environment beyond node. -Tests live in two places: `src/instrument/__tests__/*.test.ts`, and colocated `*.test.ts` beside the -schema for `setup` and `summary`. Fixtures come from the `@opendatacapture/instrument-stubs` +Each module's tests live in a `__tests__/` folder beside it, named after the file they test +(`src/setup/__tests__/setup.test.ts` tests `src/setup/setup.ts`). Fixtures come from the `@opendatacapture/instrument-stubs` devDependency (`@opendatacapture/instrument-stubs/forms`), not hand-written objects — see `src/instrument/__tests__/instrument.form.test.ts`. Assert with `safeParse(...).success`, and cover the reject case as well as the accept case. Where a -schema is a security boundary the rejects are the point: `src/setup/setup.test.ts` exists to pin +schema is a security boundary the rejects are the point: `src/setup/__tests__/setup.test.ts` exists to pin down that `javascript:` and `data:text/html` never pass `$BrandingConfig`. diff --git a/packages/schemas/src/assignment/assignment.test.ts b/packages/schemas/src/assignment/__tests__/assignment.test.ts similarity index 99% rename from packages/schemas/src/assignment/assignment.test.ts rename to packages/schemas/src/assignment/__tests__/assignment.test.ts index 2271b1eb4..904cba36b 100644 --- a/packages/schemas/src/assignment/assignment.test.ts +++ b/packages/schemas/src/assignment/__tests__/assignment.test.ts @@ -7,7 +7,7 @@ import { $CreateAssignmentData, $CreateBulkAssignmentsData, BULK_ASSIGNMENT_MAX_SUBJECTS -} from './assignment.js'; +} from '../assignment.js'; const futureDate = () => new Date(Date.now() + 86_400_000); diff --git a/packages/schemas/src/auth/auth.test.ts b/packages/schemas/src/auth/__tests__/auth.test.ts similarity index 92% rename from packages/schemas/src/auth/auth.test.ts rename to packages/schemas/src/auth/__tests__/auth.test.ts index 07d38c5bd..970755525 100644 --- a/packages/schemas/src/auth/auth.test.ts +++ b/packages/schemas/src/auth/__tests__/auth.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { $LoginCredentials } from './auth.js'; +import { $LoginCredentials } from '../auth.js'; describe('$LoginCredentials', () => { it('should accept a non-empty username and password', () => { diff --git a/packages/schemas/src/core/core.test.ts b/packages/schemas/src/core/__tests__/core.test.ts similarity index 99% rename from packages/schemas/src/core/core.test.ts rename to packages/schemas/src/core/__tests__/core.test.ts index df5602b6e..15760e4c0 100644 --- a/packages/schemas/src/core/core.test.ts +++ b/packages/schemas/src/core/__tests__/core.test.ts @@ -9,7 +9,7 @@ import { isGrantablePermission, resolveActiveLanguage, toInstrumentAuthoringLanguage -} from './core.js'; +} from '../core.js'; describe('$GroupScopableSubjectName', () => { it.each(['all', 'Instrument'])( diff --git a/packages/schemas/src/group/group.test.ts b/packages/schemas/src/group/__tests__/group.test.ts similarity index 94% rename from packages/schemas/src/group/group.test.ts rename to packages/schemas/src/group/__tests__/group.test.ts index c3207a84a..71521c059 100644 --- a/packages/schemas/src/group/group.test.ts +++ b/packages/schemas/src/group/__tests__/group.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { $UpdateGroupData } from './group.js'; +import { $UpdateGroupData } from '../group.js'; describe('$UpdateGroupData', () => { it('should require expectedUpdatedAt when emailTemplates is included', () => { diff --git a/packages/schemas/src/instrument-records/instrument-records.test.ts b/packages/schemas/src/instrument-records/__tests__/instrument-records.test.ts similarity index 98% rename from packages/schemas/src/instrument-records/instrument-records.test.ts rename to packages/schemas/src/instrument-records/__tests__/instrument-records.test.ts index fe690d0ee..b67982be0 100644 --- a/packages/schemas/src/instrument-records/instrument-records.test.ts +++ b/packages/schemas/src/instrument-records/__tests__/instrument-records.test.ts @@ -8,7 +8,7 @@ import { $LinearRegressionResults, $UpdateInstrumentRecordData, $UploadInstrumentRecordsData -} from './instrument-records.js'; +} from '../instrument-records.js'; describe('$CreateInstrumentRecordData', () => { it('should accept the required fields with the optional ones omitted', () => { diff --git a/packages/schemas/src/instrument-repo/instrument-repo.test.ts b/packages/schemas/src/instrument-repo/__tests__/instrument-repo.test.ts similarity index 95% rename from packages/schemas/src/instrument-repo/instrument-repo.test.ts rename to packages/schemas/src/instrument-repo/__tests__/instrument-repo.test.ts index 65efc41b7..a6ff317f4 100644 --- a/packages/schemas/src/instrument-repo/instrument-repo.test.ts +++ b/packages/schemas/src/instrument-repo/__tests__/instrument-repo.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { $CreateInstrumentRepoData, $InstrumentRepo } from './instrument-repo.js'; +import { $CreateInstrumentRepoData, $InstrumentRepo } from '../instrument-repo.js'; describe('$InstrumentRepo', () => { it('should accept a repo with lastSyncedAt omitted, since it is nullish', () => { diff --git a/packages/schemas/src/mail/mail.test.ts b/packages/schemas/src/mail/__tests__/mail.test.ts similarity index 98% rename from packages/schemas/src/mail/mail.test.ts rename to packages/schemas/src/mail/__tests__/mail.test.ts index 7229e9e66..dfb7935a4 100644 --- a/packages/schemas/src/mail/mail.test.ts +++ b/packages/schemas/src/mail/__tests__/mail.test.ts @@ -8,9 +8,9 @@ import { DEFAULT_NEW_USER_EMAIL_TEMPLATE, isMailEnabled, isSameMailServer -} from './mail.js'; +} from '../mail.js'; -import type { MailConfig } from './mail.js'; +import type { MailConfig } from '../mail.js'; const config: MailConfig = { enabled: true, diff --git a/packages/schemas/src/setup/setup.test.ts b/packages/schemas/src/setup/__tests__/setup.test.ts similarity index 98% rename from packages/schemas/src/setup/setup.test.ts rename to packages/schemas/src/setup/__tests__/setup.test.ts index 08fe461da..cf0538fd5 100644 --- a/packages/schemas/src/setup/setup.test.ts +++ b/packages/schemas/src/setup/__tests__/setup.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { $BrandingConfig, $UpdateSetupStateData } from './setup.js'; +import { $BrandingConfig, $UpdateSetupStateData } from '../setup.js'; describe('$BrandingConfig', () => { describe('customLogoSrc', () => { diff --git a/packages/schemas/src/storage/storage.test.ts b/packages/schemas/src/storage/__tests__/storage.test.ts similarity index 97% rename from packages/schemas/src/storage/storage.test.ts rename to packages/schemas/src/storage/__tests__/storage.test.ts index d4e4f24ca..bac2aeecd 100644 --- a/packages/schemas/src/storage/storage.test.ts +++ b/packages/schemas/src/storage/__tests__/storage.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { $FileLocation, $FileMetadata, $PresignedUrlInfo, $PresignedUrls, $UploadCompleteData } from './storage.js'; +import { $FileLocation, $FileMetadata, $PresignedUrlInfo, $PresignedUrls, $UploadCompleteData } from '../storage.js'; describe('$FileLocation', () => { it('should accept a basename and non-negative integer index', () => { diff --git a/packages/schemas/src/summary/summary.test.ts b/packages/schemas/src/summary/__tests__/summary.test.ts similarity index 97% rename from packages/schemas/src/summary/summary.test.ts rename to packages/schemas/src/summary/__tests__/summary.test.ts index a014454a4..f885d9140 100644 --- a/packages/schemas/src/summary/summary.test.ts +++ b/packages/schemas/src/summary/__tests__/summary.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { $Summary } from './summary.js'; +import { $Summary } from '../summary.js'; describe('$Summary', () => { it('should parse a valid summary', async () => { diff --git a/packages/schemas/src/user/user.test.ts b/packages/schemas/src/user/__tests__/user.test.ts similarity index 99% rename from packages/schemas/src/user/user.test.ts rename to packages/schemas/src/user/__tests__/user.test.ts index 9f234fe1a..0f165bf45 100644 --- a/packages/schemas/src/user/user.test.ts +++ b/packages/schemas/src/user/__tests__/user.test.ts @@ -8,7 +8,7 @@ import { $UpdateUserPermissionsData, $User, MIN_PHONE_DIGITS -} from './user.js'; +} from '../user.js'; describe('$UpdateUserData', () => { it('should strip additionalPermissions, so a profile update cannot grant anything', () => { diff --git a/packages/subject-utils/AGENTS.md b/packages/subject-utils/AGENTS.md index d7a410381..331246fb2 100644 --- a/packages/subject-utils/AGENTS.md +++ b/packages/subject-utils/AGENTS.md @@ -22,6 +22,5 @@ crashing beats deriving a wrong ID. Leave it in place. ## Tests -`pnpm exec vitest --project subject-utils`. There is a `vitest.config.ts` here. The test file is -**`src/test.ts`** — an unusual name that is picked up by the root vitest `include` glob -`**/*/test.?(c|m)[jt]s?(x)`. A new test needs either that name or a `*.test.ts` suffix. +`pnpm exec vitest --project subject-utils`. There is a `vitest.config.ts` here, and every export is +tested in `src/__tests__/index.test.ts`. diff --git a/packages/subject-utils/src/__tests__/index.test.ts b/packages/subject-utils/src/__tests__/index.test.ts index 22e5078e5..f8b147a4f 100644 --- a/packages/subject-utils/src/__tests__/index.test.ts +++ b/packages/subject-utils/src/__tests__/index.test.ts @@ -25,6 +25,11 @@ describe('generateSubjectHash', () => { await generateSubjectHash({ ...validIdentificationData, firstName: 'Jane' }) ); }); + it('should hash different identification data to different strings', async () => { + await expect(generateSubjectHash({ ...validIdentificationData, lastName: 'Smith' })).resolves.not.toEqual( + await generateSubjectHash(validIdentificationData) + ); + }); it('should reject a missing dateOfBirth', async () => { await expect(generateSubjectHash({ ...validIdentificationData, dateOfBirth: 'not-a-date' as any })).rejects.toThrow( /dateOfBirth/ diff --git a/packages/subject-utils/src/test.ts b/packages/subject-utils/src/test.ts deleted file mode 100644 index 1f7a6db27..000000000 --- a/packages/subject-utils/src/test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { ClinicalSubjectIdentificationData } from '@opendatacapture/schemas/subject'; -import { describe, expect, it } from 'vitest'; - -import { generateSubjectHash } from './index.js'; - -const inputData: ClinicalSubjectIdentificationData = Object.freeze({ - dateOfBirth: new Date(2000), - firstName: 'Jane', - lastName: 'Doe', - sex: 'FEMALE' -}); - -describe('generateSubjectHash', () => { - it('should generate the same hash for the same data', async () => { - const h1 = await generateSubjectHash(inputData); - const h2 = await generateSubjectHash(inputData); - expect(h1).toBe(h2); - }); - it('should generate the same hash for the data where the only different is an accented latin character', async () => { - const h1 = await generateSubjectHash(inputData); - const h2 = await generateSubjectHash({ ...inputData, lastName: 'Doé' }); - expect(h1).toBe(h2); - }); - it('should generate different hashes for different data', async () => { - const h1 = await generateSubjectHash(inputData); - const h2 = await generateSubjectHash({ ...inputData, lastName: 'Smith' }); - expect(h1).not.toBe(h2); - }); -}); diff --git a/vitest.config.ts b/vitest.config.ts index edf5f3d1c..c660e0d77 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -33,7 +33,7 @@ export default defineConfig({ 100: true } }, - include: ['**/*/test.?(c|m)[jt]s?(x)', '**/*.{test,spec}.?(c|m)[jt]s?(x)'], + include: ['**/*.{test,spec}.?(c|m)[jt]s?(x)'], projects: ['apps/*/vitest.config.ts', 'packages/*/vitest.config.ts', 'runtime/*/vitest.config.ts'], watch: false }