diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml
index 427b1e401..fee965db9 100644
--- a/.gitea/workflows/docker-build.yml
+++ b/.gitea/workflows/docker-build.yml
@@ -101,7 +101,40 @@ jobs:
run: dotnet build --configuration Release --no-restore
- name: Test
- run: dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
+ run: >-
+ dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
+ --collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage
+
+ # Coverage reporting (ersatztv#15 scope item 4): coverlet.collector emits a Cobertura report
+ # per test project (via --collect above); ReportGenerator merges them into a human-readable
+ # summary printed to the log and the job step summary. No floor is enforced yet ("decide on a
+ # floor later"), so this step is purely informational — continue-on-error keeps a missing
+ # report or a transient tool-install failure from ever blocking a build.
+ - name: Coverage summary
+ continue-on-error: true
+ run: |
+ set -euo pipefail
+ shopt -s globstar nullglob
+ reports=(coverage/**/coverage.cobertura.xml)
+ if [ ${#reports[@]} -eq 0 ]; then
+ echo "No coverage reports found under ./coverage -- skipping summary."
+ exit 0
+ fi
+ echo "Found ${#reports[@]} coverage report(s)."
+ # `update` is install-or-update (idempotent, unlike `install` which errors if the tool
+ # is already present under set -e); pinned for reproducible summary output.
+ dotnet tool update --global dotnet-reportgenerator-globaltool --version 5.5.10 >/dev/null
+ export PATH="$PATH:$HOME/.dotnet/tools"
+ reportgenerator \
+ "-reports:coverage/**/coverage.cobertura.xml" \
+ "-targetdir:coverage/report" \
+ "-reporttypes:TextSummary;MarkdownSummaryGithub"
+ echo "::group::Coverage summary"
+ cat coverage/report/Summary.txt
+ echo "::endgroup::"
+ if [ -n "${GITHUB_STEP_SUMMARY:-}" ] && [ -f coverage/report/SummaryGithub.md ]; then
+ cat coverage/report/SummaryGithub.md >> "$GITHUB_STEP_SUMMARY"
+ fi
migrations:
name: EF migration integrity (SQLite + MySql)
diff --git a/.gitignore b/.gitignore
index f44e9cb5a..55bc3d56c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,8 @@
*.*~
project.lock.json
.DS_Store
+# Code-coverage output (dotnet test --results-directory ./coverage, ersatztv#15)
+/coverage/
*.pyc
.worktrees/
diff --git a/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj b/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj
index bdc224593..e20aaa355 100644
--- a/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj
+++ b/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj
@@ -8,6 +8,10 @@
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
diff --git a/ErsatzTV.Tests/ErsatzTV.Tests.csproj b/ErsatzTV.Tests/ErsatzTV.Tests.csproj
index 76c49cf5a..8db215ef5 100644
--- a/ErsatzTV.Tests/ErsatzTV.Tests.csproj
+++ b/ErsatzTV.Tests/ErsatzTV.Tests.csproj
@@ -11,6 +11,10 @@
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
diff --git a/coverlet.runsettings b/coverlet.runsettings
new file mode 100644
index 000000000..1c3d08a7c
--- /dev/null
+++ b/coverlet.runsettings
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+ **/Migrations/*.cs
+ GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute
+ [*]*.Migrations.*
+ true
+
+
+
+
+
diff --git a/docs/ci-cd.md b/docs/ci-cd.md
index 2e0e233b0..d47d87b64 100644
--- a/docs/ci-cd.md
+++ b/docs/ci-cd.md
@@ -95,6 +95,18 @@ long builds (observed 31 min) stalling every PR run.
Docker build) → `dotnet build -c Release` → `dotnet test -c Release --no-build`. Gates
the image build.
+- **Code coverage** (ersatztv#15): `dotnet test` runs with `--collect:"XPlat Code Coverage"
+ --settings coverlet.runsettings --results-directory ./coverage`, so `coverlet.collector`
+ (referenced by every `*.Tests` project) emits a Cobertura report per project. A follow-up
+ **Coverage summary** step merges them with ReportGenerator (`TextSummary` to the log,
+ `MarkdownSummaryGithub` to the job step summary). No floor is enforced yet ("decide on a
+ floor later" — #15); the step is `continue-on-error: true`, so a missing report or a
+ transient tool install never blocks a build.
+ - **`coverlet.runsettings` excludes generated EF migration code** (`**/Migrations/*.cs`,
+ ~2.59M generated lines vs ~200k authored). Instrumenting it OOM-killed the shared `test`
+ job (exit 137); excluding it cuts the instrumented surface ~126× (2.5M→20k coverable
+ lines in the whole-solution `Architecture.Tests` process) and makes the percentage reflect
+ authored code.
- **Shallow checkout**: `fetch-depth: 1` (ersatztv#190) — this job never runs `git
describe`/`git log`, only `build` needs full history/tags for version computation, so
`test` and `migrations` both check out shallow. `build`'s checkout stays `fetch-depth: 0`.