diff --git a/.github/workflows/swift-packages.yml b/.github/workflows/swift-packages.yml index 4dee442866..43b47c0f06 100644 --- a/.github/workflows/swift-packages.yml +++ b/.github/workflows/swift-packages.yml @@ -12,11 +12,15 @@ on: branches: [main] paths: - 'Packages/**' + - 'Tools/SleepBench/**' + - 'Tools/Backfill/**' - '.github/workflows/swift-packages.yml' push: branches: [main] paths: - 'Packages/**' + - 'Tools/SleepBench/**' + - 'Tools/Backfill/**' - '.github/workflows/swift-packages.yml' workflow_dispatch: @@ -50,3 +54,35 @@ jobs: - name: Test ${{ matrix.package }} run: swift test working-directory: Packages/${{ matrix.package }} + + # The Tools/ SwiftPM packages, which path-depend on Packages/ above and were built by nothing. + # + # SleepBench is the instrument the sleep work is argued with — #934's stage-fraction tables came out of + # it — and #935 added 13 tests that no job ran. A tool whose numbers decide whether a staging change + # lands has to keep compiling against the packages it measures, or it bitrots silently and the next + # person to reach for it finds it broken. Backfill has no tests yet, so it is build-only. + tools: + runs-on: macos-15 + strategy: + fail-fast: false + matrix: + tool: + - SleepBench + - Backfill + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Build ${{ matrix.tool }} + run: swift build + working-directory: Tools/${{ matrix.tool }} + + # Only SleepBench has a test target; `swift test` on a package without one fails, so it is guarded. + - name: Test ${{ matrix.tool }} + run: | + if grep -q 'testTarget' Package.swift; then + swift test + else + echo "no test target in Tools/${{ matrix.tool }} — build-only" + fi + working-directory: Tools/${{ matrix.tool }} diff --git a/.github/workflows/tools-python.yml b/.github/workflows/tools-python.yml new file mode 100644 index 0000000000..9b9d39cfb9 --- /dev/null +++ b/.github/workflows/tools-python.yml @@ -0,0 +1,76 @@ +# The Python test suites under tools/, which nothing ran. +# +# `tools/linux-capture` carries 212 tests across 8 files — the frame decoder, the HCI extractor, the +# activity and sync decoders, the spot-HRV path, and the SpO2 promote gate that #103 is waiting on. Every +# one of them only ever executed when a contributor remembered to, which meant a decoder could regress and +# the suite that would have caught it stayed green by never being run. +# +# Cheap to run: pure stdlib `unittest`, no dependencies, no fixtures to fetch, ubuntu rather than macOS. +# Path-filtered so it only fires when tools/ changes. +name: Tools Python CI + +on: + pull_request: + branches: [main] + paths: + - 'tools/**' + - 'Tools/*.py' + - '.github/workflows/tools-python.yml' + push: + branches: [main] + paths: + - 'tools/**' + - 'Tools/*.py' + - '.github/workflows/tools-python.yml' + workflow_dispatch: + +concurrency: + group: tools-python-${{ github.ref }} + cancel-in-progress: true + +jobs: + linux-capture: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + # `discover` rather than a hand-listed set, so a NEW test file is picked up without editing this + # workflow — the failure mode that leaves a suite unrun is exactly what this job exists to end. + # + # The count assertion is the same idea one level up: `discover` exits 0 having found NOTHING, so a + # wrong working-directory or a renamed pattern would turn this job green while running no tests at + # all. Checking a floor makes that fail loudly instead. Raise the floor when suites are added; do + # not lower it to make a red run pass. + - name: Run tools/linux-capture tests + run: | + set -o pipefail # GitHub's default shell is `bash -e` WITHOUT pipefail: piping into tee + # would return tee's status and swallow a failing suite entirely. + python3 -m unittest discover -p "test_*.py" -v 2>&1 | tee "$RUNNER_TEMP/out.txt" + ran=$(grep -oE '^Ran [0-9]+ test' "$RUNNER_TEMP/out.txt" | grep -oE '[0-9]+') + echo "collected ${ran:-0} tests" + if [ "${ran:-0}" -lt 200 ]; then + echo "::error::expected at least 200 tests, collected ${ran:-0} — discovery is broken, not the suite" + exit 1 + fi + working-directory: tools/linux-capture + + # Tools/test_i18n_audit.py covers the audit that gates every PR's translations, and nothing ran it + # either: i18n-coverage.yml invokes `i18n_audit.py --ci` but never its own test file. Run from + # inside Tools/ because the suite imports the module by bare name. + - name: Run Tools/ audit tests + run: | + set -o pipefail # see above — without this a red suite exits 0 through the pipe. + python3 -m unittest test_i18n_audit -v 2>&1 | tee "$RUNNER_TEMP/out.txt" + ran=$(grep -oE '^Ran [0-9]+ test' "$RUNNER_TEMP/out.txt" | grep -oE '[0-9]+') + echo "collected ${ran:-0} tests" + if [ "${ran:-0}" -lt 30 ]; then + echo "::error::expected at least 30 tests, collected ${ran:-0} — discovery is broken, not the suite" + exit 1 + fi + working-directory: Tools diff --git a/Tools/Backfill/Sources/backfill/main.swift b/Tools/Backfill/Sources/backfill/main.swift index 1da6c86991..48e0821325 100644 --- a/Tools/Backfill/Sources/backfill/main.swift +++ b/Tools/Backfill/Sources/backfill/main.swift @@ -54,8 +54,13 @@ if let whoopExport, FileManager.default.fileExists(atPath: whoopExport.path) { var sessions: [CachedSleepSession] = [] for sl in r.sleeps where !sl.isNap { guard let onset = sl.sleepOnset, let wake = sl.wakeOnset else { continue } - let stages = ["light": sl.lightSleepDurationMin ?? 0, "deep": sl.deepSleepDurationMin ?? 0, - "rem": sl.remDurationMin ?? 0, "awake": sl.awakeDurationMin ?? 0] + // Annotated because the four sources are `Double?` and the `?? 0` fallbacks otherwise infer a + // heterogeneous literal, which stopped this tool compiling at all. `[String: Double]` matches the + // minute-dict shape `SleepStageTotals` already decodes. + let stages: [String: Double] = [ + "light": sl.lightSleepDurationMin ?? 0, "deep": sl.deepSleepDurationMin ?? 0, + "rem": sl.remDurationMin ?? 0, "awake": sl.awakeDurationMin ?? 0, + ] let json = (try? JSONSerialization.data(withJSONObject: stages)).flatMap { String(data: $0, encoding: .utf8) } sessions.append(CachedSleepSession(startTs: Int(onset.timeIntervalSince1970), endTs: Int(wake.timeIntervalSince1970), efficiency: sl.sleepEfficiencyPct, restingHr: nil, avgHrv: nil, stagesJSON: json))