MediaSourceRepository's Plex/Jellyfin/Emby remove-and-recreate (disable-sync) flows stamped SystemTime.MinValueUtc into library.LastScan, so normal use kept minting 0001-01-01 sentinel rows. #409 fixed the READ side (the API coerces the sentinel to null and a migration cleaned the historical residue), so the wire contract was already correct — this stops new sentinel rows being written. Safe because every remaining LastScan reader either coalesces (LastScan ?? SystemTime.MinValueUtc) for its own non-nullable scan-comparison needs, or is the API read-boundary coercion itself — audited every reference under ErsatzTV{,.Application,.Core,.Infrastructure,.Scanner,.Mcp} plus web/. There is no Where/OrderBy/GroupBy on LastScan anywhere, so the SQL null-ordering divergence between SQLite and MySQL has no surface here. Scan-comparison behavior is unchanged. Deliberately ships NO second cleanup migration: rows written between #409's NullOutNeverScannedLastScan and this change keep the sentinel at rest, and the permanent read coercion — not a migration — is what keeps the contract honest for them (as it must be anyway for a restored or hand-edited DB). LibraryPath.LastScan is likewise left untouched: the flows re-add Paths with their original values, and its only readers are the local-library scan handlers, so it has no API surface and no remote-scan effect. Regression test per provider (MediaSourceRepositoryDisableSyncTests), proven non-vacuous: restoring the MinValue writes fails all three. [decisions-edit] — the media.lastscan-null-boundary record documented this write as an ONGOING sentinel source and rested its "the coercion is permanent" argument on it, so the rationale prose is corrected in the same PR per docs.decision-lifecycle. The Rule line is unchanged, so the generated decisions/README.md catalog is byte-identical. MediaSourceRepository.cs also loses its UTF-8 BOM (fix-as-you-touch, #311). fixes #460
7.0 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
| Project | Covers | Notes |
|---|---|---|
ErsatzTV.Tests |
API controllers + MediatR handlers | 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. 543 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 | 330 tests; run alongside typecheck + build (see below). |
Golden-file nets
Three golden-file suites guard the highest-value, most-subtle output:
- M3U:
ErsatzTV.Core.Tests/Iptv/ChannelPlaylistGoldenTests.cs(ersatztv#11) — env varETV_UPDATE_GOLDENS - XMLTV:
ChannelGuideGoldenTests(ersatztv#28) — env varETV_UPDATE_GOLDENS - Playout build:
ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs(ersatztv#163) — env varETV_UPDATE_PLAYOUT_GOLDENS(deliberately separate fromETV_UPDATE_GOLDENSso regenerating one net can't silently rewrite the other). Snapshots thePlayoutItems 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 —ScriptedPlayoutBuilderruns 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-processSchedulingEngine(the HTTP controller is a 1:1 pass-through) and IS directly testable —SchedulingEngineTestsnews it up with substitutes, andContentEnumeratorBuilderTests(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 aProgramSchedule; it is TZ-independent (thecount/all/durationhandlers do UTC-only arithmetic — it passes, not skips, under a non-UTCTZ) so needs noAssumeguard. A third case,Classic_clock_padded(ersatztv#77), locks clock-boundary padding: aFillerMode.Pad+PadToNearestMinute=15PostRoll 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 isErsatzTV.Core.Tests/Channels/ChannelGuideProjectorClockPadTests.cs(guide programmes stop on the padded boundary). The build reads no wall clock — time enters only via the caller-suppliedstart— so a pinnedstartis fully deterministic. Snapshots the rawPlayoutItem.Start/Finish(UTC), not the*Offsetproperties (those call.ToLocalTime()and would make the golden machine-TZ dependent). The Block case is TZ-sensitive by construction (BlockPlayoutBuildermaps template times viaTimeZoneInfo.Local), so it is guarded withAssume.That(TimeZoneInfo.Local.BaseUtcOffset == Zero): it runs underTZ=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 # vitest
npm run typecheck # tsc -b --pretty false
npm run lint # eslint .
npm run build # tsc -b && vite build
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 byErsatzTV.Architecture.Tests.docs/contributing.md§8 — short pointer back to this doc.