Files
timothyandClaude Opus 5 febaad77d7 fix(887): the image build builds the SPA and does not test it
`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
2026-08-30 13:37:58 +02:00

12 KiB

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 --excludes 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 PlayoutItems 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 PlayoutItems, 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:

dotnet build ErsatzTV.sln
TZ=UTC dotnet test

CI adds --blame-hang-timeout 2m to catch hangs.

Fast subsets:

# single project
dotnet test ErsatzTV.Tests

# filtered
dotnet test ErsatzTV.Core.Tests --filter FullyQualifiedName~ChannelPlaylistGoldenTests

Web (web/):

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):

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.

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.