Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/swift-packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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 }}
76 changes: 76 additions & 0 deletions .github/workflows/tools-python.yml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 7 additions & 2 deletions Tools/Backfill/Sources/backfill/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading