diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25fca876..7b8ccb99 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,17 @@ # its budget, so new heavies must either get the Slow trait or a real fix. # See the Test step below for details. # +# SHARDED TEST MATRIX (#716): the server suite is CPU-bound and already scales ~perfectly with cores +# (summed test time ≈ 2550 s fast-tier; at maxParallelThreads 4 on the 4-vCPU runner that IS the +# ~11 min wall the PR gate used to spend). More in-process threads would gain nothing (no more cores; +# xunit.runner.json pins 4 because of the WorldGenerator lock convoy, #536) — so the suite fans out +# over 4 runners instead, cutting the test step to ~3 min. scripts/partition-tests.py deterministically +# packs test CLASSES onto shards (weights: scripts/test-shard-weights.json, from real trx data) and +# emits each shard's --filter. Shard 1 additionally runs the small Client.Tests suite (~11 s) and a +# partition-completeness check (--list-tests cross-check) so a class no shard filter matches can never +# be SILENTLY untested. Branch protection requires the `tests-passed` fan-in job, NOT the matrix jobs +# (matrix job names carry the shard index and a skipped required matrix would not report). +# # We target the test projects directly (not the whole .sln) on purpose: the .sln also contains the # WinForms launcher (net10.0-windows), which cannot build on the Linux runner. Building the test # projects pulls in exactly their dependencies (server/shared/client.core) and nothing Windows-only. @@ -26,10 +37,12 @@ # REQUIRED-CHECK SAFE doc/screenshot-only skip: rather than skipping the whole workflow with paths-ignore # (which would leave a *required* check stuck on "Expected — Waiting for status" for a docs-only PR), the # workflow always runs and a `changes` job decides whether the heavy `build-test`/`format` jobs run. On a -# doc- or image-only PR they are skipped via their `if:`, and GitHub counts a skipped required job as a -# pass — so the `Build + test (.NET, headless)` check still reports green and never blocks the PR. Any -# code/content file in the diff makes them run for real. The doc/image skip list lives in the shared -# reusable workflow .github/workflows/detect-doc-only.yml (also used by lint.yml and codeql.yml). +# doc- or image-only PR the build-test shards are skipped via their `if:`, and the `tests-passed` fan-in +# explicitly treats "changes succeeded + build-test skipped" as a pass — so the required check reports +# green and never blocks a docs PR. Any code/content file in the diff makes them run for real. The +# doc/image skip list lives in the shared reusable workflow .github/workflows/detect-doc-only.yml (also +# used by lint.yml and codeql.yml). The `format` job stays a directly-required check (its name is stable +# and GitHub counts its skip as a pass, as before). name: CI @@ -54,12 +67,16 @@ jobs: uses: ./.github/workflows/detect-doc-only.yml build-test: - name: Build + test (.NET, headless) + name: Build + test (shard ${{ matrix.shard }}/4) needs: changes # Run for any push to main, or for a PR that touched at least one non-doc file. A docs-only PR - # skips this job; a skipped required check counts as passing, so docs PRs are never blocked. + # skips this job; the `tests-passed` fan-in treats that as passing, so docs PRs are never blocked. if: github.event_name != 'pull_request' || needs.changes.outputs.nondocs == 'true' runs-on: ubuntu-latest + strategy: + fail-fast: false # one failing shard must not cancel the others' results + matrix: + shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v5 @@ -77,10 +94,14 @@ jobs: **/*.csproj Directory.Build.props + # Shard 1 also owns the small Client.Tests suite (~11 s), so shards 2-4 skip restoring and + # building it entirely. - name: Restore run: | dotnet restore tests/BlocksBeyondTheStars.Tests/BlocksBeyondTheStars.Tests.csproj - dotnet restore tests/BlocksBeyondTheStars.Client.Tests/BlocksBeyondTheStars.Client.Tests.csproj + if [ "${{ matrix.shard }}" = "1" ]; then + dotnet restore tests/BlocksBeyondTheStars.Client.Tests/BlocksBeyondTheStars.Client.Tests.csproj + fi # -warnaserror promotes every compiler/analyzer warning to a build error for the PR gate. # Release configuration: the suite is dominated by CPU-bound worldgen/sim tests, which run far @@ -88,19 +109,37 @@ jobs: - name: Build (treat warnings as errors) run: | dotnet build tests/BlocksBeyondTheStars.Tests/BlocksBeyondTheStars.Tests.csproj --no-restore -c Release -warnaserror - dotnet build tests/BlocksBeyondTheStars.Client.Tests/BlocksBeyondTheStars.Client.Tests.csproj --no-restore -c Release -warnaserror + if [ "${{ matrix.shard }}" = "1" ]; then + dotnet build tests/BlocksBeyondTheStars.Client.Tests/BlocksBeyondTheStars.Client.Tests.csproj --no-restore -c Release -warnaserror + fi + + # Partition-completeness guard (shard 1 only — needs a built assembly, any shard's would do): + # every test that --list-tests discovers must be matched by EXACTLY one shard's filter. A test + # class the partition misses (nested class, name colliding with a namespace segment, …) fails + # the build here instead of silently never running on any shard. + - name: Verify shard partition covers every test + if: matrix.shard == 1 + run: | + dotnet test tests/BlocksBeyondTheStars.Tests/BlocksBeyondTheStars.Tests.csproj --no-build -c Release --list-tests > listed-tests.txt + python3 scripts/partition-tests.py verify --shards 4 --list-file listed-tests.txt # Two-tier test gate: PRs skip the tests marked [Trait("Category", "Slow")] — the statistical # worldgen/simulation heavies that dominate the suite's CPU time. Full coverage is still - # guaranteed twice downstream: every push to main runs the COMPLETE suite here (empty filter), - # and release.yml runs it again on the tagged commit before anything is published. + # guaranteed twice downstream: every push to main runs the COMPLETE suite here (shard filter + # only, no tier filter), and release.yml runs it again on the tagged commit before anything is + # published. The shard filter (which test classes THIS runner executes) is combined with the + # tier filter via `&`. - name: Test env: # xUnit maps [Trait("Category", ...)] to the vstest Category property. - FILTER: ${{ github.event_name == 'pull_request' && 'Category!=Slow' || '' }} + TIER: ${{ github.event_name == 'pull_request' && 'Category!=Slow' || '' }} run: | - dotnet test tests/BlocksBeyondTheStars.Tests/BlocksBeyondTheStars.Tests.csproj --no-build -c Release ${FILTER:+--filter "$FILTER"} --logger "trx;LogFileName=server.trx" --results-directory TestResults - dotnet test tests/BlocksBeyondTheStars.Client.Tests/BlocksBeyondTheStars.Client.Tests.csproj --no-build -c Release ${FILTER:+--filter "$FILTER"} --logger "trx;LogFileName=clientcore.trx" --results-directory TestResults + SHARD_FILTER="$(python3 scripts/partition-tests.py filter --shard ${{ matrix.shard }} --shards 4)" + FILTER="${TIER:+$TIER&}$SHARD_FILTER" + dotnet test tests/BlocksBeyondTheStars.Tests/BlocksBeyondTheStars.Tests.csproj --no-build -c Release --filter "$FILTER" --logger "trx;LogFileName=server.trx" --results-directory TestResults + if [ "${{ matrix.shard }}" = "1" ]; then + dotnet test tests/BlocksBeyondTheStars.Client.Tests/BlocksBeyondTheStars.Client.Tests.csproj --no-build -c Release ${TIER:+--filter "$TIER"} --logger "trx;LogFileName=clientcore.trx" --results-directory TestResults + fi # Fast-tier duration guardrail (PRs only — the full run on main legitimately contains Slow tests): # fail when any non-Slow test exceeds the per-test budget, so the fast tier cannot silently decay @@ -117,10 +156,31 @@ jobs: if: always() uses: actions/upload-artifact@v6 with: - name: test-results + name: test-results-shard-${{ matrix.shard }} path: TestResults/*.trx if-no-files-found: ignore + # Fan-in for branch protection: the ONE required check standing in for the whole test matrix. + # Required checks must not point at matrix jobs directly (names carry the shard index, and a + # docs-only PR would leave them "Expected — Waiting for status" forever). Passing states: + # * build-test == success → all four shards green + # * build-test == skipped → docs-only PR (the `changes` job said nondocs=false) — counts as pass, + # but ONLY when `changes` itself succeeded, so a cancelled/failed upstream can't sneak through. + tests-passed: + name: Tests passed + needs: [changes, build-test] + if: always() + runs-on: ubuntu-latest + steps: + - name: Check matrix result + run: | + echo "changes: ${{ needs.changes.result }} / build-test: ${{ needs.build-test.result }}" + if [ "${{ needs.changes.result }}" != "success" ]; then exit 1; fi + case "${{ needs.build-test.result }}" in + success|skipped) exit 0 ;; + *) exit 1 ;; + esac + # Verifies C# code style/formatting (whitespace, using-order, object-initializer line breaks, …) per # .editorconfig. Runs against BlocksBeyondTheStars.CI.slnf, a solution filter listing every project EXCEPT # the Windows-only WinForms launcher, so the whole set loads on the Linux runner. Line endings are LF here diff --git a/TODO.md b/TODO.md index ab24fc7b..1634ea9e 100644 --- a/TODO.md +++ b/TODO.md @@ -10,6 +10,7 @@ keep it current when controls/features change. Last consolidated 2026-06-04. **Test:** `./scripts/run-tests.sh` — currently **1421 server + 154 client passing** (2026-08-03). Locale parity (en/de) is enforced by a test. CI runs two tiers: PRs skip the tests marked `[Trait("Category", "Slow")]`; pushes to `main` and the release workflow run the full suite. CI builds/runs tests in Release, and a per-test duration guardrail (`scripts/check-test-durations.py`, PRs only) fails the gate when a non-Slow test exceeds 120 s. +The server suite is sharded across a 4-runner matrix (`scripts/partition-tests.py` + checked-in weights; `Tests passed` is the required fan-in check) — PR gate ~4½ min. **Conventions:** English docs/comments; in-game text bilingual DE+EN; commit to `main` with the Claude `Co-Authored-By` trailer; OpenAI texture + ElevenLabs sound generation is blanket-approved (no per-batch gate). @@ -102,6 +103,19 @@ Per-item detail lives in the dated work log below. **Since 2026-07 versions are --- +### ★ CI PR gate 12:40 → ~4:30: server suite sharded over a 4-runner matrix (#716, 2026-08-04, branch ci/shard-tests) +The Test step spent 11:25 of the 12:40 PR gate running the server suite on ONE 4-vCPU runner — and it +already scaled ~perfectly in-process (summed test time ≈ 2550 s ÷ 4 threads ≈ the observed wall; +`maxParallelThreads` is pinned to 4 on purpose, see #536), so the only parallelization left was more +runners. `scripts/partition-tests.py` now packs test CLASSES onto 4 shards (greedy by weight from +`scripts/test-shard-weights.json`, real trx seconds; new classes get a default) and emits per-shard +dot-anchored `FullyQualifiedName~..` filters. Classes are parsed from source declarations, not +file names, and shard 1 cross-checks `dotnet test --list-tests` so every test provably maps to exactly +one shard — a partition miss fails the build instead of silently skipping tests. Shard 1 also carries +the 11 s Client.Tests suite; the duration guardrail runs per shard. New `Tests passed` fan-in job is +the single required branch-protection check (matrix names can't be; docs-only skips still pass). +Verified locally: all 4 shard filters together run 461+376+237+324 = exactly the 1398 fast-tier tests. + ### ★ Multi-biome worlds shuffle WHICH biomes they get, not just how many (#696, 2026-08-03, branch fix/biome-subset-shuffle) `ResolveBiomes` randomised only the biome COUNT (2..pool) and then always took the first N entries of the type's pool in `data/planets.json` order — so e.g. a 2-biome `varied` world was always sand+grass, diff --git a/docs/developer/DEVELOPER.md b/docs/developer/DEVELOPER.md index 9559c1f0..9a60bb4c 100644 --- a/docs/developer/DEVELOPER.md +++ b/docs/developer/DEVELOPER.md @@ -41,10 +41,15 @@ dotnet test # all .NET xUnit suites (server/share ./scripts/run-tests.sh --suites ClientCore # just the headless client<->server integration tests ``` -CI runs these suites **two-tiered** (`.github/workflows/ci.yml`): pull requests skip the ~31 statistical / -soak tests marked `[Trait("Category", "Slow")]` for a fast gate (~3 min instead of ~13), while every push to -`main` — and `release.yml` before publishing — runs the complete suite. Reproduce the fast PR tier locally -with `dotnet test --filter "Category!=Slow"`. +CI runs these suites **two-tiered** (`.github/workflows/ci.yml`): pull requests skip the statistical / +soak tests marked `[Trait("Category", "Slow")]` for a fast gate, while every push to `main` — and +`release.yml` before publishing — runs the complete suite. Reproduce the fast PR tier locally with +`dotnet test --filter "Category!=Slow"`. On top of the tiering, CI **shards the server suite across a +4-runner matrix** (#716): the suite is CPU-bound and already scales ~perfectly with cores in-process +(`xunit.runner.json` pins `maxParallelThreads: 4`, see #536), so the only way to go faster is more +runners. `scripts/partition-tests.py` deterministically packs test classes onto shards (weights from +`scripts/test-shard-weights.json` — refresh it occasionally from a CI `.trx` artifact when balance +drifts) and shard 1 cross-checks `--list-tests` output so every test provably runs on exactly one shard. `run-tests.ps1` selects suites via `-Suites` (`Dotnet`, `ClientCore`, `UnityEdit`, `UnityPlay`, `All`); the Unity suites are opt-in so they don't slow the common loop, and need `Unity.exe` (pass `-UnityPath` if not at @@ -83,9 +88,11 @@ These are the same two suites `run-tests.ps1` / `run-tests.sh` run by default. T (The detection is an explicit `case` match, not `dorny/paths-filter` — that action's `some`-glob semantics made a `**/*` + `!doc` list evaluate true for every diff, so docs PRs never actually skipped.) -It is **safe as a required status check** — and is one: because the workflow always runs and the build-test -check always reports (green/red/skipped-as-pass), a docs-only PR is never left waiting on a missing status, the -usual failure mode of a plain `paths-ignore` skip. (Require the `Build + test (.NET, headless)` job, **not** the +It is **safe as a required status check** via the `Tests passed` fan-in job: the build-test shards are a +matrix (names carry the shard index, so they can't serve as required checks themselves), and `Tests passed` +runs `if: always()`, passing exactly when all shards succeeded or were skipped as docs-only (with the +`changes` helper itself green) — so a docs-only PR is never left waiting on a missing status, the usual +failure mode of a plain `paths-ignore` skip. (Require `Tests passed`, **not** the shard jobs or the `Detect code changes` helper.) The **Unity** suites (`UnityEdit` / `UnityPlay`) are **not** in CI — they need the Editor and stay local/opt-in @@ -93,10 +100,12 @@ The **Unity** suites (`UnityEdit` / `UnityPlay`) are **not** in CI — they need ([`release.yml`](../../.github/workflows/release.yml)) is a **separate** workflow that triggers only on tags; it does not run tests, so the PR gate is where correctness is checked before merge. -> **Required status checks on `main`** (branch protection): `Build + test (.NET, headless)`, -> `Format (dotnet format)`, `ruff (Python ai-backend)`, `actionlint (workflows)` -> and `CodeQL` — all five must pass before merge. The `Detect code changes` helper is intentionally **not** -> required (it's an always-skippable gate; only require jobs that always report). `strict` is off (no forced +> **Required status checks on `main`** (branch protection): `Tests passed`, +> `Format (dotnet format)`, `ruff (Python ai-backend)` and `actionlint (workflows)` — all four must pass +> before merge (CodeQL runs on PRs but is not a required check, contrary to what this doc used to +> claim — verified against the live branch protection 2026-08-04). The `Detect code changes` helper and the individual +> `Build + test (shard N/4)` matrix jobs are intentionally **not** required (only require jobs that always +> report under a stable name; `Tests passed` fans the matrix in). `strict` is off (no forced > rebase) and `enforce_admins` is off (an owner can still `gh pr merge --admin` in an emergency). ### Other PR checks: format, lint, CodeQL diff --git a/scripts/partition-tests.py b/scripts/partition-tests.py new file mode 100644 index 00000000..0db888d6 --- /dev/null +++ b/scripts/partition-tests.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +# Blocks Beyond the Stars — Copyright (c) 2026 Justus Dütscher & Marcel Dütscher (JuMaVe Games) +# SPDX-License-Identifier: AGPL-3.0-or-later +# This file is part of Blocks Beyond the Stars. See LICENSE for the full AGPL-3.0 text. +"""Deterministic test sharding for the CI matrix (ci.yml). + +The server suite (tests/BlocksBeyondTheStars.Tests) is CPU-bound and scales ~linearly with +cores, but a single GitHub runner only has 4. ci.yml therefore fans the suite out over N +runners; this script decides which test CLASS runs on which shard and emits the matching +vstest --filter expression. + +How it partitions: + * Test classes are discovered by parsing class declarations out of every .cs file in the + test project (NOT from file names — one file may declare several classes, and a class a + filter misses would be SILENTLY untested). Non-test classes ride along harmlessly: their + filter tokens simply match nothing. + * Each class gets a weight from scripts/test-shard-weights.json (summed trx seconds from a + real CI run; unknown/new classes get a default) and classes are greedy-packed onto the + lightest shard. Deterministic: same inputs → same assignment on every shard's runner. + * The emitted filter ORs `FullyQualifiedName~..` tokens. The dots anchor the token + between namespace and method (`~` is substring matching — a bare `~FloraTests` would also + match FloraTintTests). + +Safety net: `verify` cross-checks the partition against `dotnet test --list-tests` output — +every discovered test must be matched by EXACTLY one shard's filter, so an oddly named or +nested class (whose FQN uses `Outer+Inner`, which no token matches) fails the build loudly +instead of silently never running. ci.yml runs this on shard 1 of every build. + +Usage: + partition-tests.py filter --shard 2 --shards 4 # print shard 2's --filter expression + partition-tests.py verify --shards 4 --list-file listed.txt + partition-tests.py show --shards 4 # human-readable assignment + weights +""" + +import argparse +import json +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +TEST_DIR = REPO_ROOT / "tests" / "BlocksBeyondTheStars.Tests" +WEIGHTS_FILE = REPO_ROOT / "scripts" / "test-shard-weights.json" + +# Weight (seconds) for classes without an entry in the weights file — roughly the suite's +# median class cost, so brand-new test classes don't skew a shard until weights are refreshed. +DEFAULT_WEIGHT = 10.0 + +CLASS_RE = re.compile(r"^\s*(?:public|internal)?\s*(?:sealed\s+|static\s+|abstract\s+|partial\s+)*class\s+([A-Za-z_]\w*)", re.MULTILINE) + + +def discover_classes() -> list[str]: + """All class names declared in the test project (excluding bin/obj).""" + names: set[str] = set() + for path in sorted(TEST_DIR.glob("*.cs")): + names.update(CLASS_RE.findall(path.read_text(encoding="utf-8-sig"))) + if not names: + raise SystemExit(f"error: no classes found under {TEST_DIR} — wrong checkout?") + return sorted(names) + + +def assign(shards: int) -> list[list[str]]: + """Greedy bin-packing: heaviest class first onto the currently lightest shard.""" + weights = json.loads(WEIGHTS_FILE.read_text(encoding="utf-8")) if WEIGHTS_FILE.exists() else {} + buckets: list[list[str]] = [[] for _ in range(shards)] + loads = [0.0] * shards + # Sort by (-weight, name): deterministic even among equal weights. + for cls in sorted(discover_classes(), key=lambda c: (-weights.get(c, DEFAULT_WEIGHT), c)): + target = loads.index(min(loads)) + buckets[target].append(cls) + loads[target] += weights.get(cls, DEFAULT_WEIGHT) + return buckets + + +def shard_filter(bucket: list[str]) -> str: + return "(" + "|".join(f"FullyQualifiedName~.{cls}." for cls in sorted(bucket)) + ")" + + +FQN_RE = re.compile(r"^[A-Za-z_]\w*(?:\.\w+){2,}$") + + +def parse_listed_tests(list_file: Path) -> list[str]: + """FQNs from `dotnet test --list-tests` output. + + Deliberately does NOT key off the "The following Tests are available:" header — that line is + localized (German SDKs print "Die folgenden Tests sind verfügbar:"). Instead: any indented + line whose text (with theory arguments stripped) is a plain dotted identifier chain counts. + Build chatter never matches (those lines contain spaces, '->', ellipses, …). + """ + tests = [] + for line in list_file.read_text(encoding="utf-8-sig").splitlines(): + if not line.startswith(" "): + continue + candidate = line.strip().split("(", 1)[0] # drop theory arguments + if FQN_RE.match(candidate): + tests.append(candidate) + if not tests: + raise SystemExit(f"error: no tests parsed from {list_file} — did --list-tests run?") + return tests + + +def cmd_verify(shards: int, list_file: Path) -> int: + buckets = assign(shards) + tokens = [[f".{cls}." for cls in bucket] for bucket in buckets] + bad = [] + for fqn in parse_listed_tests(list_file): + hits = [i + 1 for i, toks in enumerate(tokens) if any(t in fqn for t in toks)] + if len(hits) != 1: + bad.append((fqn, hits)) + if bad: + print(f"{len(bad)} test(s) not matched by exactly one shard filter:") + for fqn, hits in bad[:20]: + print(f" shards {hits or '[]'}: {fqn}") + print("Nested test classes (Outer+Inner) or a class whose name occurs as a namespace " + "segment break the partition — rename or extend partition-tests.py.") + return 1 + print(f"All {len(parse_listed_tests(list_file))} listed tests map to exactly one of {shards} shards.") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=["filter", "verify", "show"]) + parser.add_argument("--shards", type=int, required=True, help="total shard count") + parser.add_argument("--shard", type=int, help="1-based shard index (filter)") + parser.add_argument("--list-file", type=Path, help="dotnet test --list-tests output (verify)") + args = parser.parse_args() + + if args.command == "filter": + if not args.shard or not 1 <= args.shard <= args.shards: + raise SystemExit("error: --shard must be in 1..--shards") + print(shard_filter(assign(args.shards)[args.shard - 1])) + return 0 + + if args.command == "verify": + if not args.list_file: + raise SystemExit("error: verify needs --list-file") + return cmd_verify(args.shards, args.list_file) + + weights = json.loads(WEIGHTS_FILE.read_text(encoding="utf-8")) if WEIGHTS_FILE.exists() else {} + for i, bucket in enumerate(assign(args.shards), start=1): + load = sum(weights.get(c, DEFAULT_WEIGHT) for c in bucket) + print(f"shard {i}: {len(bucket)} classes, ~{load:.0f}s weighted") + for cls in sorted(bucket): + print(f" {weights.get(cls, DEFAULT_WEIGHT):7.1f}s {cls}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test-shard-weights.json b/scripts/test-shard-weights.json new file mode 100644 index 00000000..ba1076d5 --- /dev/null +++ b/scripts/test-shard-weights.json @@ -0,0 +1,176 @@ +{ + "AchievementTests": 33.3, + "AdminAuthTests": 0.0, + "AdminCheatTests": 12.0, + "AdminContentTests": 0.1, + "AdminObserverTests": 44.9, + "AdminServiceTests": 0.0, + "AiMissionTests": 14.1, + "AimValidationTests": 38.1, + "AlgaeTankTests": 4.3, + "AllianceTests": 12.0, + "AtmosphereTests": 28.9, + "BanNoticeTests": 4.8, + "BanditTests": 6.7, + "BeachGenerationTests": 12.7, + "BeaconTests": 23.2, + "BeamTests": 10.2, + "BlockAttributionTests": 0.1, + "BlockShapeTests": 3.0, + "BugReportPathsTests": 0.0, + "BumpTests": 13.6, + "CargoTransferTests": 5.9, + "ChatHelpTextTests": 0.0, + "ChunkStreamingTests": 7.7, + "CommunityLocaleTests": 0.0, + "ContainerLootTests": 1.1, + "ContentTests": 0.1, + "CraftingConsistencyTests": 0.1, + "CrashPiiScrubberTests": 0.0, + "CrashReportUploaderTests": 0.1, + "CrashReportWriterTests": 0.0, + "CrateStorageTests": 2.4, + "CreativeModeTests": 6.2, + "CreatureTamingTests": 23.5, + "CreatureTests": 137.8, + "DetoxifierTests": 10.9, + "DisassemblyTests": 3.3, + "DiscardItemTests": 11.5, + "DockingTests": 16.6, + "DoorTests": 27.5, + "EconomyBalanceTests": 0.0, + "EnemyMovementTests": 11.1, + "EnergyFenceTests": 39.3, + "EquipmentTests": 38.7, + "FactoryClaimTests": 6.8, + "FactoryCraftingTests": 4.8, + "FactoryStructureTests": 23.5, + "FireTests": 7.1, + "FleetOperatorAccessTests": 1.6, + "FloraTests": 25.9, + "FloraTintTests": 0.0, + "FloraVarietyTests": 9.4, + "FluidTests": 51.5, + "GadgetTests": 5.3, + "GalaxyLayoutRegressionTests": 0.0, + "GameModeTests": 21.1, + "GameServerFinaleTests": 53.6, + "GameServerIntegrationTests": 15.8, + "GameServerNetFragmentTests": 8.3, + "GameServerStoryCombatTests": 43.9, + "GameServerStoryP7P8Tests": 34.5, + "GameServerStoryTests": 7.7, + "GlitchGatewayTests": 0.6, + "GlitchWorldRegistryTests": 0.4, + "GreenhouseTests": 17.1, + "HealTankTests": 15.5, + "HostedWorldsFoundationTests": 12.6, + "HungerTests": 30.1, + "IconCoverageTests": 0.0, + "InputHardeningTests": 0.0, + "InventoryFullSafetyTests": 25.5, + "InventoryTests": 0.0, + "LandableAsteroidTests": 28.8, + "LandingPadTests": 38.5, + "LineOfSightTests": 25.5, + "LlmStagesTests": 54.8, + "LocomotionControllerTests": 0.0, + "MaintenanceAnnounceTests": 15.4, + "MarketTests": 5.8, + "MatterConverterTests": 8.8, + "MaxPlayersTests": 2.0, + "MiningTests": 23.0, + "MissionBoardTests": 75.6, + "MissionTests": 10.6, + "MonumentTests": 21.6, + "MultiplayerVisibilityTests": 14.1, + "NameGeneratorTests": 0.0, + "NameVerificationTests": 20.1, + "NetworkingTests": 0.1, + "NpcGreetingTests": 50.8, + "NpcHintTests": 0.0, + "OxygenTests": 7.2, + "PersistenceTests": 0.1, + "PlaceableDoorTests": 1.7, + "PlacementGuaranteeTests": 68.2, + "PlanetBaseAndStationMapTests": 33.4, + "PlayerStationPlaceablesTests": 15.3, + "PlaytimeTests": 14.0, + "PortalPageTests": 0.0, + "PostgreSqlRepositoryTests": 0.0, + "PresenceTests": 3.9, + "ProtocolHardeningTests": 4.9, + "QuickbarVendorTests": 4.4, + "RadioTierTests": 12.0, + "RefineryProgressionTests": 0.0, + "ReportDuplicateGroupingTests": 0.0, + "ReportHostTests": 0.1, + "RespawnChoiceTests": 50.2, + "RespawnTests": 14.9, + "RiverFieldTests": 1.5, + "RiverNetworkSpikeTests": 4.7, + "RuinsAndChestsTests": 11.1, + "ScanningTests": 33.2, + "ServerConfigTests": 0.1, + "ServerHardeningTests": 2.4, + "ServerShutdownTests": 1.6, + "SettlementGenerationTests": 0.1, + "SettlementNpcTests": 17.1, + "SettlementOverhaulTests": 34.3, + "SettlementStampTests": 34.1, + "ShapePlacementYawTests": 12.3, + "ShipAiTests": 27.2, + "ShipFleetTests": 4.5, + "ShipInteriorTests": 22.0, + "ShipLayoutTests": 6.6, + "ShipRepairTests": 6.1, + "ShipStructureTests": 71.4, + "SingleplayerPauseTests": 13.4, + "SkylandsTests": 4.5, + "SpaceCombatTests": 59.9, + "SpaceStationBoardingTests": 9.2, + "SpaceTraderTests": 15.0, + "SpawnPointTests": 6.3, + "SpawnSafetyTests": 2.5, + "SpeederTests": 29.5, + "StartDataCubeTests": 3.7, + "StartPlanetTests": 9.2, + "StationGenerationTests": 0.7, + "StoryEngineTests": 0.0, + "StoryPackLoadTests": 0.0, + "StoryPersistenceTests": 0.0, + "StructureInteractionTests": 9.2, + "StructureTemplateTests": 0.1, + "TamingShapesStoryTests": 7.7, + "TeleportTests": 5.0, + "TemperatureHazardTests": 98.9, + "TerrainExtremesTests": 2.2, + "TerrainWondersTests": 11.3, + "ToolTierProgressionTests": 0.0, + "TorchTests": 2.2, + "TradeTests": 27.2, + "TravelTests": 82.7, + "UniverseNamingTests": 0.1, + "UniverseTests": 14.8, + "WeaponTests": 2.7, + "WebSocketTransportTests": 0.0, + "WhatsNewContentTests": 0.0, + "WoodTintTests": 0.0, + "WorkbenchTests": 14.9, + "WorldEnvironmentTests": 7.0, + "WorldGenerationTests": 71.8, + "WorldHostLegalTests": 0.4, + "WorldHostPhase3Tests": 0.6, + "WorldHostPortalPagesTests": 0.1, + "WorldHostStatsTests": 0.0, + "WorldHostTermsTests": 0.0, + "WorldHostTests": 7.1, + "WorldMathTests": 0.0, + "WorldOptionsTests": 74.4, + "WorldPasswordTests": 3.4, + "WorldStopAndKillTests": 0.3, + "WorldVisibilityTests": 2.1, + "WorldWrapTests": 0.5, + "WreckGenerationTests": 0.1, + "WreckStampTests": 3.7 +}