`docker/Dockerfile`'s web-build stage is gitless twice over — the build context is `web/` + `design-system/` so there is no `.git`, and `node:22-bookworm-slim` ships no git binary. Members of the SPA suite need one or the other, so running the suite there required naming the ones that cannot run. That list was a population nothing derived: #883 added a third member without updating the hand-written pair of `--exclude`s, and because `Build & push image (amd64)` is `if: github.event_name != 'pull_request'` the resulting red was unreachable on a PR. It landed on `main` and on the `v*` tag path instead — every image build failed, `:latest` stopped being republished, and a release cut would have failed at the image build. Adding a third `--exclude` re-arms the trap, so the list is removed rather than extended: the stage now lints, typechecks and BUILDS the SPA, and the suite runs once, unfiltered, in `docker-build.yml`'s `test` job on a real checkout. `build` carries `needs: [test, migrations, scan]`, so no image is published past a red suite. `scripts/tests/test_image_build_delegates_the_spa_suite.py` holds both halves — the negative one alone would be satisfied by deleting the `needs:` edge. Three populations, all derived: tracked Dockerfiles and workflows from the git index, and which npm scripts ARE the suite from `web/package.json` (so `test` is in and the Playwright `test:ui-e2e` is out, with no exemption list). Publishing jobs come from the `docker/build-push-action` step and the Dockerfile each builds from that step's own `file:` input, which is why `ci-image.yml` is out of scope by derivation rather than by an entry that would outlive its reason. Four mutants witnessed red, each by the intended test: a filtered suite run put back into the Dockerfile, the `needs:` edge deleted, and the gating run narrowed in both the block and the single-line `run:` step forms. Refs: #887 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
158 lines
12 KiB
Markdown
158 lines
12 KiB
Markdown
# Testing map
|
|
|
|
Purpose: authoritative map of what each test project/suite covers and how to run it. Read this
|
|
before adding tests, not just `docs/contributing.md` §8 (which now just points here).
|
|
|
|
## Test projects
|
|
|
|
> **Font dependency (ersatztv#732).** `Infrastructure/Graphics/TextElementBackgroundBoxTests` is the
|
|
> only suite that rasterises text, so it needs at least one system font to lay anything out. The CI
|
|
> image installs none explicitly — fonts arrive via `playwright install --with-deps` in
|
|
> `docker/ci/Dockerfile` (351 present in the pinned image, measured 2026-08-26). This is a real
|
|
> dependency, declared here so a future slimming of that install produces a known cause rather than a
|
|
> mystery red. `Baseline_Renders_A_Non_Empty_Bitmap` exists to make that failure loud: without it a
|
|
> fontless host would render a 0x0 bitmap and every relative geometry assertion would pass vacuously.
|
|
|
|
| Project | Covers | Notes |
|
|
|---|---|---|
|
|
| `ErsatzTV.Tests` | API controllers + MediatR handlers, plus the SkiaSharp text-overlay rasteriser (`Infrastructure/Graphics/`) | In-memory SQLite fixture: a shared `SqliteConnection("Data Source=:memory:;Foreign Keys=False")` kept open + `EnsureCreatedAsync()` (**not** full migration replay) + `PRAGMA foreign_keys=OFF`, then seed; a tiny `IDbContextFactory` wraps `new TvContext(...)`. ~1,870 tests (approximate on purpose — an exact count goes stale on every PR that adds one; the previous hardcoded 828 was off by over a thousand). |
|
|
| `ErsatzTV.Core.Tests` | Domain logic, scheduling, IPTV/XMLTV generation | References `ErsatzTV.Application` directly — there is no separate `Application.Tests` project. ~650 tests + 1 skipped under `TZ=UTC` (the Block playout golden additionally skips under a non-UTC `TZ`; see Golden-file nets). |
|
|
| `ErsatzTV.Scanner.Tests` | Library scanning: scan handlers, folder scanners, NFO readers | Handler tests substitute the folder scanners + `ILibraryRepository` and assert the resulting repository writes (e.g. `ScanLocalLibraryHandlerTests` pins which `LastScan` levels a scan records — ersatztv#264). Fakes/`Testably` back the file-system-facing scanners. ~1,485 tests (approximate on purpose — an exact count goes stale on every PR that adds one). Additionally contains `Core/FFmpeg/TranscodingTests` — `[Explicit]` + `[Combinatorial]`, so it never runs in CI or a plain `dotnet test` (it needs real ffmpeg/hardware) and contributes 0 to that count; run it by name when touching the transcoding pipeline. |
|
|
| `ErsatzTV.Architecture.Tests` | Layering rules via NetArchTest.eNhancedEdition | Core↛Infra/App/EF; FFmpeg↛all; App↛concrete providers. 5 tests. See `docs/contributing.md` §1. |
|
|
| `ErsatzTV.FFmpeg.Tests` | FFmpeg command construction | Build a pipeline, assert the exact rendered arg string (`PipelineBuilderBaseTests.cs`). |
|
|
| `web/` (vitest) | React SPA unit tests | Run alongside typecheck + build (see below). Collects `src/**`, `web/scripts/**` *and* `web/vite-plugins/**`, but deliberately **excludes** `web/e2e/**` (the Playwright specs — vitest's default `**/*.spec.*` glob would otherwise run them under jsdom). Some files have git prerequisites since ersatztv#819, and the set is not fixed — ersatztv#883 added one. `web/src/api/pageSizeCallSites.guard.test.ts` and `web/src/api/completeAnnotations.guard.test.ts` need a git **checkout** AND, through it, the **binary**: they derive their file population from `git ls-files` via `web/vite-plugins/trackedSourceFiles.ts` rather than a directory walk, and refuse rather than falling back. `web/vite-plugins/trackedSourceFiles.realgit.test.ts` needs the **binary** but no checkout — it builds its own temp repository to prove that derivation by executing it. So it is not checkout-versus-binary: supplying a `.git` alone would not let any of them run. **The suite therefore runs only where git is present, and `docker/Dockerfile` is not such a place** — its web-build stage builds the SPA and does not test it (ersatztv#887). Enumerating the git-dependent files as Docker `--exclude`s was tried and REVERSED: that list is a population nothing derives, it went stale the first time a guard was added, and the resulting red is unreachable on a PR — `Build & push image (amd64)` is `if: github.event_name != 'pull_request'` — so it landed on `main` and on the release tag instead. The image is gated on `docker-build.yml`'s `test` job running the whole suite on a real checkout, held by `scripts/tests/test_image_build_delegates_the_spa_suite.py`. |
|
|
| `web/e2e/` (Playwright) | UI-interactive E2E flows against a **live** instance | Not a unit suite and **not** part of `npm test` — needs a running server, so it runs via `scripts/e2e-ui.sh` (boots its own fresh instance) and in CI as a step of the `functional-e2e` job. Headless Chromium, `serial`, `retries: 0`. Scope rule: assert only what the curl harness structurally cannot. See `docs/e2e-local.md` → "UI-E2E harness". |
|
|
|
|
## Golden-file nets
|
|
|
|
Three golden-file suites guard the highest-value, most-subtle output:
|
|
|
|
- **M3U**: `ErsatzTV.Core.Tests/Iptv/ChannelPlaylistGoldenTests.cs` (ersatztv#11) — env var `ETV_UPDATE_GOLDENS`
|
|
- **XMLTV**: `ChannelGuideGoldenTests` (ersatztv#28) — env var `ETV_UPDATE_GOLDENS`
|
|
- **Playout build**: `ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs` (ersatztv#163)
|
|
— env var **`ETV_UPDATE_PLAYOUT_GOLDENS`** (deliberately separate from `ETV_UPDATE_GOLDENS` so
|
|
regenerating one net can't silently rewrite the other). Snapshots the `PlayoutItem`s each builder
|
|
produces over a pinned build window. Covers the **Classic** (`PlaybackOrder.Chronological`),
|
|
**Block**, and **Sequential (YAML)** builders. The **Scripted** *end-to-end pipeline* is excluded from this
|
|
net — `ScriptedPlayoutBuilder` runs a user-authored external process that drives the engine over HTTP, which
|
|
the in-memory harness can't pin; that integration harness is tracked in ersatztv#563. The scheduling
|
|
*behavior* those scripts drive, though, lives in the in-process `SchedulingEngine` (the HTTP controller is a
|
|
1:1 pass-through) and IS directly testable — `SchedulingEngineTests` news it up with substitutes, and
|
|
`ContentEnumeratorBuilderTests` (ersatztv#395) is the direct regression net over the enumerator-construction
|
|
helper the Scripted and Sequential/YAML engines now share (decision:
|
|
`testing.scripted-playout-golden-deferred`). The **Sequential** case
|
|
(`Sequential_yaml`, ersatztv#381)
|
|
builds from a committed YAML fixture (`Goldens/Fixtures/sequential-schedule.yml`) instead of a
|
|
`ProgramSchedule`; it is TZ-independent (the `count`/`all`/`duration` handlers do UTC-only arithmetic —
|
|
it passes, not skips, under a non-UTC `TZ`) so needs no `Assume` guard. A third case,
|
|
`Classic_clock_padded` (ersatztv#77), locks clock-boundary padding: a `FillerMode.Pad` +
|
|
`PadToNearestMinute=15` PostRoll preset snaps content to `:15`, and the test both goldens the output and
|
|
asserts every content item after the first starts on a quarter-hour. Its EPG counterpart is
|
|
`ErsatzTV.Core.Tests/Channels/ChannelGuideProjectorClockPadTests.cs` (guide programmes stop on the padded
|
|
boundary). The build reads
|
|
no wall clock — time enters only via the caller-supplied `start` — so a pinned `start` is fully
|
|
deterministic. Snapshots the raw `PlayoutItem.Start`/`Finish` (UTC), **not** the `*Offset` properties
|
|
(those call `.ToLocalTime()` and would make the golden machine-TZ dependent). The **Block** case is
|
|
TZ-sensitive by construction (`BlockPlayoutBuilder` maps template times via `TimeZoneInfo.Local`), so
|
|
it is guarded with `Assume.That(TimeZoneInfo.Local.BaseUtcOffset == Zero)`: it runs under `TZ=UTC`
|
|
(CI) and reports **inconclusive** (a graceful skip, not a failure) under any other TZ. A real TZ seam
|
|
for the block builder is ersatztv#380's scope.
|
|
|
|
All three locate their golden files via `[CallerFilePath]`. A missing golden is a hard fail, not a
|
|
skip. Regenerate via `ETV_UPDATE_GOLDENS=1 dotnet test ...` (M3U/XMLTV) or
|
|
`ETV_UPDATE_PLAYOUT_GOLDENS=1 dotnet test ...` (playout build).
|
|
|
|
**Never set `ETV_UPDATE_GOLDENS` / `ETV_UPDATE_PLAYOUT_GOLDENS` in CI or from an agent.** A golden
|
|
diff during normal test runs means the code changed the output — regenerating to make the diff go
|
|
away hides the change instead of surfacing it. Only a human who has confirmed the change is
|
|
intentional should regenerate.
|
|
|
|
## Timezone independence
|
|
|
|
The suite is timezone-independent (ersatztv#24). When constructing test `PlayoutItem`s, always
|
|
set a real `Start` (e.g. `startState.CurrentTime.UtcDateTime`) — never rely on the default
|
|
`DateTime.MinValue`, which underflows `DateTimeOffset.MinValue` once a non-UTC local offset is
|
|
applied (`StartOffset` calls `ToLocalTime()`). CI runs in UTC; local runs may not.
|
|
|
|
## Running tests
|
|
|
|
Full .NET gate:
|
|
|
|
```bash
|
|
dotnet build ErsatzTV.sln
|
|
TZ=UTC dotnet test
|
|
```
|
|
|
|
CI adds `--blame-hang-timeout 2m` to catch hangs.
|
|
|
|
Fast subsets:
|
|
|
|
```bash
|
|
# single project
|
|
dotnet test ErsatzTV.Tests
|
|
|
|
# filtered
|
|
dotnet test ErsatzTV.Core.Tests --filter FullyQualifiedName~ChannelPlaylistGoldenTests
|
|
```
|
|
|
|
Web (`web/`):
|
|
|
|
```bash
|
|
npm test -- --run # vitest, single pass — use this for the SPA guards (see below)
|
|
npm test # vitest WATCH mode. `pageSizeCallSites.guard.test.ts` reads the git index ONCE
|
|
# per dev-server lifetime while the glob refreshes, so it drifts BOTH ways: a
|
|
# file created mid-session reddens it misleadingly, and a file that was already
|
|
# untracked when the watcher started stays invisible to it after `git add` — a
|
|
# green that is not authoritative. Restarting the watcher swaps the first
|
|
# problem for the second; confirm with `npm test -- --run`.
|
|
npm run typecheck # tsc -b --pretty false
|
|
npm run lint # eslint .
|
|
npm run build # tsc -b && vite build
|
|
```
|
|
|
|
UI-E2E (needs a built solution + built SPA; boots and tears down its own instance):
|
|
|
|
```bash
|
|
scripts/e2e-ui.sh # from the repo root, NOT web/
|
|
```
|
|
|
|
## Provider-parity fixtures (opt-in MySQL)
|
|
|
|
Most of `ErsatzTV.Tests` runs on the in-memory SQLite harness described above. A few fixtures in
|
|
`ErsatzTV.Tests/Integration/` instead drive a **real, migrated** database, and run the SAME body against
|
|
**both** providers because the behaviour they pin is provider-specific:
|
|
|
|
| Fixture | What is provider-specific about it |
|
|
| --- | --- |
|
|
| `LibraryFolderDedupeMigrationTests` | the #491 dedupe DML — two MySQL-only collation defects (case-insensitive grouping, then `PAD SPACE`) were unreachable from SQLite |
|
|
| `SchedulingCollectionColumnNullTests` | what a NULL column materializes as through a value converter (ersatztv#823) |
|
|
| `SearchFieldValuesProviderTests` | the search-field-values query shape, which differs per provider |
|
|
|
|
The MySQL half needs a live server, supplied as `ETV_TEST_MYSQL_CONNECTION`. **Without it these
|
|
fixtures `Assert.Ignore` — a visible skip, never a silent pass**, so an ordinary local run needs no
|
|
MySQL. Setting `ETV_REQUIRE_MYSQL_TESTS=1` turns that skip into a hard failure, for a runner that is
|
|
supposed to have one.
|
|
|
|
```bash
|
|
ETV_TEST_MYSQL_CONNECTION='Server=<host>;Port=3306;Uid=root;Pwd=<pw>;DefaultCommandTimeout=300;' \
|
|
dotnet test ErsatzTV.Tests --filter FullyQualifiedName~SchedulingCollectionColumnNullTests
|
|
```
|
|
|
|
Each test uses a database name it generates per run, so isolation does not depend on a wipe
|
|
succeeding, and drops it in teardown. **CI does not currently run any of these MySQL halves** — the
|
|
`migrations` job spins a `mysql:8.4` service but only applies migrations to a fresh EMPTY database,
|
|
so it executes no data rows; re-arming these fixtures there is tracked by ersatztv#627.
|
|
|
|
## Per-PR verification gate
|
|
|
|
Before opening a PR: build the solution, run `ErsatzTV.Tests` + `ErsatzTV.Core.Tests` (plus
|
|
`ErsatzTV.Scanner.Tests`, `ErsatzTV.Architecture.Tests` and `ErsatzTV.FFmpeg.Tests` if touched),
|
|
and run the web test/lint/typecheck/build steps above. All must be green. A golden-file diff or
|
|
an architecture-test failure is a hard stop — fix the code, don't regenerate/relax the test.
|
|
|
|
## See also
|
|
|
|
- `docs/ci-cd.md` — CI pipeline (test → migrations → build), versioning, dependency management.
|
|
- `docs/contributing.md` §1 — layering rules enforced by `ErsatzTV.Architecture.Tests`.
|
|
- `docs/contributing.md` §8 — short pointer back to this doc.
|