diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..300eb4d2 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,10 @@ +# Code owners for PulseLoop. +# +# When branch protection is set to "require review from Code Owners", every PR +# will require an approving review from the owner(s) listed here before it can +# be merged. Right now that's the maintainer. +# +# Syntax: <@owner ...> + +# Default owner for everything in the repo. +* @saksham2001 diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..55e9969c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,105 @@ +name: "๐Ÿ› Bug report" +description: Something isn't working the way it should. +title: "[Bug]: " +labels: ["bug", "triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file a bug! Please search [existing issues](https://github.com/saksham2001/PulseLoopIOS/issues) first to avoid duplicates. + + - type: textarea + id: what-happened + attributes: + label: What happened? + description: A clear description of the bug, and what you expected instead. + placeholder: When I sync sleep from my Colmi R02, REM minutes show as 0โ€ฆ + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: Exact steps so we can see it too. + placeholder: | + 1. Connect ring + 2. Open Sleep tab + 3. Pull to sync + 4. See โ€ฆ + validations: + required: true + + - type: dropdown + id: ring + attributes: + label: Which ring are you using? + options: + - "jring (generic SMART_RING / 56ff)" + - "Colmi R02" + - "Colmi R03 / R06 / R07 / R09 / R10 / R12" + - "Colmi R11" + - "Yawell R05 / R10 / R11 / H59" + - "Other / not sure" + - "No ring โ€” using demo data" + validations: + required: true + + - type: input + id: ios-version + attributes: + label: iOS version + placeholder: "e.g. iOS 26.4" + validations: + required: true + + - type: input + id: device + attributes: + label: iPhone model + placeholder: "e.g. iPhone 16 Pro" + validations: + required: false + + - type: input + id: app-version + attributes: + label: PulseLoop version / commit + description: Settings โ†’ About, or the git commit you built from. + placeholder: "e.g. main @ a1b2c3d" + validations: + required: false + + - type: dropdown + id: area + attributes: + label: Which area is affected? + options: + - "BLE connection / pairing" + - "Data sync / decoding" + - "Today / Vitals / Sleep / Activity UI" + - "Workout recording / Live Activity" + - "AI Coach" + - "Notifications" + - "Other" + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Logs / screenshots + description: Console logs, crash text, or screenshots. Please redact anything personal. + render: shell + validations: + required: false + + - type: checkboxes + id: checks + attributes: + label: Before submitting + options: + - label: I searched existing issues and this isn't a duplicate. + required: true + - label: I removed any personal health data / secrets from logs and screenshots. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..a451772f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: ๐Ÿ’ฌ Questions & discussion + url: https://github.com/saksham2001/PulseLoopIOS/discussions + about: Ask questions, share setups, or discuss ideas before filing an issue. + - name: ๐Ÿ”’ Report a security or privacy vulnerability + url: https://github.com/saksham2001/PulseLoopIOS/security/advisories/new + about: Please report vulnerabilities privately โ€” do NOT open a public issue. + - name: ๐Ÿ“– Project writeup + url: https://sakshambhutani.xyz/projects/20_project/ + about: How PulseLoop works, in depth. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..ecaf9128 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,61 @@ +name: "โœจ Feature request" +description: Suggest an idea or improvement for PulseLoop. +title: "[Feature]: " +labels: ["enhancement", "triage"] +body: + - type: markdown + attributes: + value: | + Thanks for the idea! Please check the [Roadmap in the README](https://github.com/saksham2001/PulseLoopIOS#goals--roadmap) and [existing issues](https://github.com/saksham2001/PulseLoopIOS/issues) first. + + - type: textarea + id: problem + attributes: + label: What problem does this solve? + description: What are you trying to do that's hard or impossible today? + placeholder: "I want to โ€ฆ but currently โ€ฆ" + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed solution + description: What would you like to see happen? + validations: + required: true + + - type: dropdown + id: area + attributes: + label: Area + options: + - "New wearable support (use the wearable template instead if you have a specific ring)" + - "AI Coach (tools, prompts, on-device LLM)" + - "Data / decoding / sync" + - "UI / DesignSystem" + - "Workouts / Live Activity" + - "Apple Health integration" + - "Privacy / transparency" + - "Other" + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Other approaches you thought about, and why you prefer this one. + validations: + required: false + + - type: dropdown + id: contribute + attributes: + label: Would you be willing to work on this? + options: + - "Yes, I'd like to implement it" + - "Maybe, with some guidance" + - "No, just suggesting" + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/new_wearable_support.yml b/.github/ISSUE_TEMPLATE/new_wearable_support.yml new file mode 100644 index 00000000..e1c91f8a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/new_wearable_support.yml @@ -0,0 +1,109 @@ +name: "๐Ÿ“Ÿ New wearable support" +description: Request or report support for a new ring / wearable. +title: "[Wearable]: " +labels: ["wearable", "enhancement", "triage"] +body: + - type: markdown + attributes: + value: | + PulseLoop is built around a **device-agnostic driver layer** (`RingProtocol/`), so adding a + wearable means writing a driver that declares its capabilities and decodes its packets. + The more protocol detail you can give here, the more likely this gets implemented โ€” and the + easier it is to do it without owning the hardware. See [CONTRIBUTING.md](../CONTRIBUTING.md) + for how the driver layer works. + + - type: input + id: model + attributes: + label: Ring / wearable model + placeholder: "e.g. Colmi R08, Oura-clone XYZ, โ€ฆ" + validations: + required: true + + - type: input + id: advertised-name + attributes: + label: Advertised BLE name + description: The name it broadcasts (visible in a BLE scanner app like LightBlue / nRF Connect). + placeholder: "e.g. R08_A1B2, SMART_RING, COLMI R1xโ€ฆ" + validations: + required: false + + - type: input + id: ble-service + attributes: + label: Primary BLE service UUID(s) + description: The custom service the ring exposes (from a BLE scanner). + placeholder: "e.g. 6e40fff0-โ€ฆ, 56ff, de5bf728-โ€ฆ" + validations: + required: false + + - type: textarea + id: characteristics + attributes: + label: Characteristics (write / notify UUIDs) + description: Which characteristic do commands go to, and which one sends notifications back? + placeholder: | + Write: 6e40fff6-โ€ฆ + Notify: 6e40fff7-โ€ฆ + validations: + required: false + + - type: checkboxes + id: capabilities + attributes: + label: Which capabilities does this device have? (best guess is fine) + options: + - label: Heart rate (spot) + - label: Heart rate (history) + - label: Heart rate (live / workout) + - label: SpOโ‚‚ + - label: Steps / distance / calories + - label: Sleep stages + - label: REM sleep + - label: HRV + - label: Stress + - label: Body temperature + - label: Battery level + - label: Find device + + - type: textarea + id: packets + attributes: + label: Known packet formats / captures + description: > + If you've sniffed any packets (e.g. via the vendor app + a BLE logger), paste the raw + 20-byte command/notification bytes here. This is the single most useful thing for adding a driver. + render: text + validations: + required: false + + - type: input + id: vendor-app + attributes: + label: Vendor app it ships with + placeholder: "e.g. QRing, Colmi, GloryFit, Da Fit" + validations: + required: false + + - type: dropdown + id: have-hardware + attributes: + label: Do you have this device and can you test a driver? + options: + - "Yes โ€” I own it and can test builds" + - "Yes โ€” and I can capture packets" + - "No โ€” just requesting support" + validations: + required: true + + - type: dropdown + id: contribute + attributes: + label: Would you like to write the driver yourself? + options: + - "Yes, I'd like to implement it" + - "Maybe, with guidance" + - "No, just requesting" + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..67346c7d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,60 @@ + + +## Summary + + + +## Related issues + + + +## Type of change + +- [ ] ๐Ÿ› Bug fix (non-breaking change that fixes an issue) +- [ ] โœจ New feature (non-breaking change that adds functionality) +- [ ] ๐Ÿ“Ÿ New / improved wearable support (BLE driver layer) +- [ ] ๐Ÿค– Coach / LLM change (tools, prompts, orchestration) +- [ ] ๐ŸŽจ UI / DesignSystem change +- [ ] ๐Ÿงน Refactor / chore (no behavior change) +- [ ] ๐Ÿ“ Docs only +- [ ] โš ๏ธ Breaking change (existing data, settings, or APIs change) + +## How was this tested? + + + +- [ ] Added / updated unit tests (`PulseLoopTests`) +- [ ] Ran the test suite locally (`โŒ˜U` in Xcode) +- [ ] Tested on a physical device with a real ring โ€” model: +- [ ] Tested with demo data (`-seedDemo YES`, no hardware) +- [ ] N/A (docs / non-code change) + +## Privacy & data + + + +- [ ] This change does **not** send health data off-device without explicit user action. +- [ ] No secrets, API keys, or personal data are committed. +- [ ] N/A + +## Screenshots / recordings + + + +## Checklist + +- [ ] My code follows the project's style (SwiftLint passes). +- [ ] I ran the tests and they pass. +- [ ] I updated docs / README where relevant. +- [ ] I read the [Contributing guide](../CONTRIBUTING.md). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..f6e3bcd8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,144 @@ +name: CI + +# Build the app + Live Activity extension and run the unit-test suite on a +# simulator. Everything here is hermetic: the tests use in-memory SwiftData and +# a mocked OpenAI client (api key "sk-test"), so no secrets are required. + +on: + push: + branches: [main] + pull_request: + branches: [main] + +# Cancel superseded runs on the same PR/branch to save runner minutes. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build-and-test: + name: Build & Test + runs-on: macos-15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select latest stable Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Show toolchain + run: | + xcodebuild -version + swift --version + + - name: Install xcbeautify + run: brew install xcbeautify + + # Resolve an iPhone simulator from whatever the runner image ships. + # + # IMPORTANT: prefer an **iOS 18.x** runtime. The runner's iOS 26.0โ€“26.2 + # simulators have a Swift concurrency runtime bug that double-frees when a + # main-actor-isolated class (this project sets SWIFT_DEFAULT_ACTOR_ISOLATION + # = MainActor, so ~all classes) is deallocated without an explicit deinit โ€” + # it crashes the test runner with SIGABRT (fixed in iOS 26.5, which the + # runners don't have yet). iOS 18.x predates that machinery and runs the + # same tests cleanly. The app targets iOS 18+ and uses no iOS 26-only APIs. + - name: Resolve simulator destination + id: sim + run: | + resolve_dest() { + # arg1: "prefer18" to pick the newest iOS 18.x; otherwise newest overall. + xcrun simctl list devices available --json | PREFER="$1" python3 -c ' + import json, os, sys + prefer18 = os.environ.get("PREFER") == "prefer18" + data = json.load(sys.stdin)["devices"] + best = None + for runtime, devices in data.items(): + if "iOS" not in runtime: + continue + # runtime id looks like com.apple.CoreSimulator.SimRuntime.iOS-18-6 + ver = runtime.rsplit(".", 1)[-1].replace("iOS-", "").replace("-", ".") + parts = tuple(int(p) for p in ver.split(".") if p.isdigit()) + if prefer18 and (not parts or parts[0] != 18): + continue + for dev in devices: + if dev.get("isAvailable") and dev["name"].startswith("iPhone"): + key = (parts, dev["name"]) + if best is None or key > best[0]: + best = (key, ver, dev["name"]) + if best is not None: + _, ver, name = best + print(f"platform=iOS Simulator,name={name},OS={ver}") + ' + } + + echo "Installed iOS runtimes:" + xcrun simctl list runtimes | grep -i ios || true + + # Prefer iOS 18.x; fall back to newest available if the image has no 18.x. + DEST="$(resolve_dest prefer18)" + if [ -z "$DEST" ]; then + echo "No iOS 18.x simulator found; falling back to the newest available iOS runtime." + DEST="$(resolve_dest any)" + fi + if [ -z "$DEST" ]; then + echo "No iOS simulator runtime found โ€” downloading one (this can be slow/flaky)โ€ฆ" + xcodebuild -downloadPlatform iOS || true + DEST="$(resolve_dest any)" + fi + if [ -z "$DEST" ]; then + echo "::error::No iOS simulator runtime available on this runner." + exit 1 + fi + echo "Using destination: $DEST" + echo "destination=$DEST" >> "$GITHUB_OUTPUT" + + # Pre-boot the resolved simulator so the test run doesn't race a cold device, + # and so a broken runtime fails here with a clear message rather than inside xcodebuild. + - name: Boot simulator + run: | + NAME="$(printf '%s' "${{ steps.sim.outputs.destination }}" | sed -E 's/.*name=([^,]+).*/\1/')" + echo "Booting: $NAME" + xcrun simctl boot "$NAME" || true + xcrun simctl bootstatus "$NAME" -b || true + + - name: Build & run tests + run: | + set -o pipefail + xcodebuild test \ + -project PulseLoop.xcodeproj \ + -scheme PulseLoop \ + -destination "${{ steps.sim.outputs.destination }}" \ + -resultBundlePath TestResults.xcresult \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + | xcbeautify --renderer github-actions + env: + # Belt-and-suspenders: keep tests offline even if a code path tries the network. + OPENAI_API_KEY: sk-test + + # On failure, print the failure summary inline. xcbeautify's annotations hide + # crash reasons (e.g. a SIGABRT in a test runner), so surface the result bundle's + # own summary โ€” including crash backtraces โ€” directly in the job log. + - name: Test failure summary + if: failure() + run: | + echo "::group::Test failures (from TestResults.xcresult)" + xcrun xcresulttool get test-results summary --path TestResults.xcresult 2>/dev/null \ + | python3 -c 'import json,sys; d=json.load(sys.stdin); [print("โ€ข", f.get("testIdentifierString") or f.get("testName"), "โ€”", f.get("failureText","")) for f in d.get("testFailures",[])]' \ + || xcrun xcresulttool get test-results summary --path TestResults.xcresult + echo "::endgroup::" + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: TestResults + path: TestResults.xcresult + if-no-files-found: ignore diff --git a/.github/workflows/swiftlint.yml b/.github/workflows/swiftlint.yml new file mode 100644 index 00000000..7479e449 --- /dev/null +++ b/.github/workflows/swiftlint.yml @@ -0,0 +1,34 @@ +name: SwiftLint + +# Style/lint check on Swift sources. Kept separate from the build so a style nit +# doesn't block you from seeing whether the code actually compiles and tests pass. + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: swiftlint-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + swiftlint: + name: SwiftLint + runs-on: macos-15 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install SwiftLint + run: brew install swiftlint + + # No --strict: warnings (style nits like long lines) annotate the PR but + # don't fail CI on day one. Only error-level violations block the merge. + # Tighten by adding --strict once the codebase is clean. + - name: Run SwiftLint + run: swiftlint lint --reporter github-actions-logging diff --git a/.gitignore b/.gitignore index 3e5b0573..743bca42 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,12 @@ fastlane/Preview.html fastlane/screenshots/**/*.png fastlane/test_output +# Test results +*.xcresult + +# Local scratch / planning notes +tasks/ + # Misc *.log *~ diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 00000000..c706f095 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,77 @@ +# SwiftLint configuration for PulseLoop. +# +# Tuned to be helpful, not hostile, for an open-source project taking outside +# PRs: catch real smells and keep the codebase consistent, but don't reject +# contributions over cosmetic line-length or whitespace nits. Tighten over time. + +included: + - PulseLoop + - PulseLoopLiveActivity + - PulseLoopTests + +excluded: + - .build + - DerivedData + - "**/*.generated.swift" + +# Rules that are off by default but worth opting into. +opt_in_rules: + - empty_count + - empty_string + - closure_spacing + - contains_over_first_not_nil + - last_where + - sorted_first_last + - redundant_nil_coalescing + - unneeded_parentheses_in_closure_argument + - toggle_bool + - yoda_condition + +# Rules we deliberately relax for this project. +disabled_rules: + - todo # TODO/FIXME markers are fine in an evolving codebase. + - trailing_comma # Multiline trailing commas are a matter of taste here. + - force_try # `try!` is fine in tests and in known-safe decode paths. + - first_where # Established style uses `.filter { }.first` in places. + +line_length: + warning: 140 + error: 200 + ignores_urls: true + ignores_comments: true + +type_body_length: + warning: 400 + error: 600 + +file_length: + warning: 600 + error: 1000 + ignore_comment_only_lines: true + +function_body_length: + warning: 80 + error: 150 + +cyclomatic_complexity: + warning: 15 + error: 25 + +# Short names are common and intentional in this codebase (BLE byte math, +# SwiftData contexts `c`, loop indices, etc.), so don't warn on length at all. +# Only flag genuinely bad names via the type checks below. +identifier_name: + min_length: + warning: 1 + error: 0 + max_length: + warning: 60 + error: 80 + +type_name: + min_length: 3 + max_length: + warning: 50 + error: 60 + +reporter: "xcode" diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..4d35c86f --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,29 @@ +# Code of Conduct + +PulseLoop is a friendly, open project. To keep it that way, everyone taking part +โ€” in issues, pull requests, discussions, or anywhere else โ€” is asked to follow a +few simple rules. + +## Be respectful + +- Be kind, patient, and welcoming, especially to newcomers. +- Assume good intent and give constructive feedback. +- Critique ideas and code, not people. + +## Not okay + +- Harassment, insults, or personal attacks. +- Discrimination or demeaning comments of any kind. +- Sharing other people's private information without permission. +- Posting someone else's personal health data. + +## If something goes wrong + +If you see or experience behavior that breaks these rules, please reach out +privately to the maintainer at **sakshambhutani2001@gmail.com**. Reports will be +handled discreetly. + +The maintainer may remove comments, reject contributions, or block anyone who +doesn't follow this Code of Conduct. + +Thanks for helping keep PulseLoop a good place to build. ๐Ÿซ€ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..1e578ad2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,149 @@ +# Contributing to PulseLoop + +First off โ€” thank you! PulseLoop is an open, privacy-first health app that turns +a cheap Bluetooth ring into a real, local-first health tracker. Contributions of +all kinds are welcome: bug reports, new ring drivers, coach improvements, UI +polish, and docs. + +This guide explains how to get set up, the standards we hold PRs to, and how the +review/merge process works. + +--- + +## Code of conduct + +This project follows a [Code of Conduct](CODE_OF_CONDUCT.md). By participating, +you're expected to uphold it. Please be kind. + +--- + +## Ways to contribute + +- **Report a bug** โ€” open a [bug report](https://github.com/saksham2001/PulseLoopIOS/issues/new?template=bug_report.yml). +- **Request a feature** โ€” open a [feature request](https://github.com/saksham2001/PulseLoopIOS/issues/new?template=feature_request.yml). +- **Add a wearable** โ€” open a [new wearable support](https://github.com/saksham2001/PulseLoopIOS/issues/new?template=new_wearable_support.yml) + issue, ideally with BLE service/characteristic UUIDs and packet captures. +- **Send a pull request** โ€” see below. + +For anything large, please open an issue to discuss it **before** writing a lot +of code, so we can agree on the approach. + +--- + +## Development setup + +**Requirements** + +- macOS with **Xcode 16+**. +- An **iOS 18+ physical device** for anything touching Bluetooth or Live + Activities โ€” the simulator cannot reach the ring. +- A compatible BLE ring (see the [supported wearables](README.md#supported-wearables)), + or use demo data (below) if you don't have hardware. +- An OpenAI API key only if you're working on the Coach. + +**Build & run** + +1. Open `PulseLoop.xcodeproj` in Xcode. +2. Set your own **Team** and a unique **Bundle Identifier** under + *Signing & Capabilities* (the Live Activity extension target needs this too). +3. Select the `PulseLoop` scheme + your device, then Build & Run (`โŒ˜R`). + +**No hardware? Use demo data** + +You can do most UI/coach work without a ring: + +- **Settings โ†’ "Reseed demo data"**, or +- Launch with the `-seedDemo YES` argument. + +--- + +## Running the tests + +The unit tests live in `PulseLoopTests/` and are **hermetic**: they use +in-memory SwiftData and a mocked OpenAI client (`sk-test`), so they need no +network and no secrets. + +- In Xcode: `โŒ˜U`. +- From the CLI: + + ```sh + xcodebuild test \ + -project PulseLoop.xcodeproj \ + -scheme PulseLoop \ + -destination 'platform=iOS Simulator,name=iPhone 16' + ``` + +CI runs this exact suite on every PR. **Please add or update tests** for any +behavior you change โ€” especially decoders, capability gating, sleep/activity +math, and coach tools, which are all covered today and easy to extend. + +--- + +## Code style + +- We use **SwiftLint** (config in [.swiftlint.yml](.swiftlint.yml)); CI runs it + on every PR. Install locally with `brew install swiftlint` and run `swiftlint` + from the repo root. +- Match the surrounding code: SwiftUI views in `Views/`, models in `Models/`, + etc. Keep changes small and focused. +- Prefer clarity over cleverness. Comments should explain *why*, not *what*. + +--- + +## Adding support for a new wearable + +PulseLoop has a **device-agnostic driver layer** in `RingProtocol/`. Each device +declares the capabilities it supports, and the UI shows only those features +(see `CapabilityGatingTests` for how this is enforced). To add a ring: + +1. **Identify it** โ€” advertised BLE name, primary service UUID, and the + write/notify characteristic UUIDs (use a scanner like LightBlue or nRF + Connect). +2. **Decode the protocol** โ€” capture the 20-byte command/notification packets + from the vendor app and map them to metrics. Existing decoders + (`RingDecoderTests`, `ColmiDecoderTests`) are good references and good places + to add coverage. +3. **Declare capabilities** โ€” give the driver the right `WearableCapability` set + so the app gates features correctly. +4. **Add tests** โ€” decoder round-trips and capability gating, following the + existing test files. +5. **Update the README capability matrix** with the new model and its support status. + +You don't necessarily need the hardware to *start* โ€” a good packet capture in an +issue lets others help. Mark anything you couldn't verify on real hardware as +๐Ÿงช ("implemented, needs testing"). + +--- + +## Pull request process + +1. **Fork** the repo and create a branch from `main`: + `git checkout -b feature/short-description`. +2. Make your change, **add tests**, and make sure `swiftlint` and the test suite + pass locally. +3. **Open a PR** against `main` and fill out the PR template. Link any related + issue (`Closes #123`). +4. **CI must be green** โ€” build, tests, and SwiftLint all pass. +5. **Review** โ€” the maintainer ([@saksham2001](https://github.com/saksham2001)) + reviews and may request changes. One approving review is required to merge. +6. Once approved and green, your PR gets merged. ๐ŸŽ‰ + +Keep PRs focused โ€” one logical change per PR is much easier to review than a +large mixed one. + +--- + +## Privacy expectations + +PulseLoop's promise is that **your health data stays on your device**. The only +thing that should ever leave the phone is a coach question the user explicitly +chooses to send. Any PR that changes what data leaves the device, or how it's +stored, must call that out clearly in the PR description and will get extra +scrutiny. Never commit secrets, API keys, or real personal health data. + +--- + +## License + +By contributing, you agree that your contributions will be licensed under the +project's [CC BY 4.0](LICENSE) license. diff --git a/PulseLoop.xcodeproj/project.pbxproj b/PulseLoop.xcodeproj/project.pbxproj index 3ac7b9c6..b860f281 100644 --- a/PulseLoop.xcodeproj/project.pbxproj +++ b/PulseLoop.xcodeproj/project.pbxproj @@ -357,7 +357,7 @@ INFOPLIST_FILE = PulseLoopLiveActivity/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = PulseLoopLiveActivity; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 26.4; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -389,7 +389,7 @@ INFOPLIST_FILE = PulseLoopLiveActivity/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = PulseLoopLiveActivity; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 26.4; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -461,7 +461,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.4; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -519,7 +519,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.4; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; @@ -615,7 +615,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = CQUQ9RZMDK; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.4; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = xyz.sakshambhutani.pulseloop2Tests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -636,7 +636,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = CQUQ9RZMDK; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.4; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = xyz.sakshambhutani.pulseloop2Tests; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/PulseLoop.xcodeproj/xcshareddata/xcschemes/PulseLoop.xcscheme b/PulseLoop.xcodeproj/xcshareddata/xcschemes/PulseLoop.xcscheme new file mode 100644 index 00000000..7e8abbc3 --- /dev/null +++ b/PulseLoop.xcodeproj/xcshareddata/xcschemes/PulseLoop.xcscheme @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PulseLoop/Coach/Context/CoachPromptBuilder.swift b/PulseLoop/Coach/Context/CoachPromptBuilder.swift index 4affbc38..9326dc5b 100644 --- a/PulseLoop/Coach/Context/CoachPromptBuilder.swift +++ b/PulseLoop/Coach/Context/CoachPromptBuilder.swift @@ -4,6 +4,8 @@ import Foundation /// `backend/app/coach/prompts.py` and adapted to the iOS tool set /// (deterministic analysis tools instead of a code sandbox). enum CoachPromptBuilder { + // Prompt prose is kept verbatim (ported from the backend); don't hard-wrap it. + // swiftlint:disable line_length static let systemPrompt = """ You are PulseLoop Coach, a transparent, evidence-grounded health and fitness coach for a smart-ring app. @@ -53,6 +55,7 @@ enum CoachPromptBuilder { Use the provided tools to retrieve, analyze, chart, search, or act. Prefer compact retrieval first, then deeper analysis only if needed. Today's date and the user's timezone are in the context packet. """ } + // swiftlint:enable line_length private static func encodePacket(_ packet: CoachContextPacket) -> String { let encoder = JSONEncoder() diff --git a/PulseLoop/Coach/Orchestration/CoachFallbacks.swift b/PulseLoop/Coach/Orchestration/CoachFallbacks.swift index 12a182b9..a5f63174 100644 --- a/PulseLoop/Coach/Orchestration/CoachFallbacks.swift +++ b/PulseLoop/Coach/Orchestration/CoachFallbacks.swift @@ -24,6 +24,7 @@ enum CoachFallbacks { return CoachResponse( responseType: .dataMissing, title: "No activity synced yet", + // swiftlint:disable:next line_length summary: "I don't have today's activity from the ring yet. Sync the ring or take a measurement and I'll summarize what comes in. (The AI coach is off โ€” add an OpenAI key in Settings to enable full coaching.)", dataQualityNote: packet.dataQualityWarnings.first, followUpChips: ["Is my ring connected?", "What data is missing?"], diff --git a/PulseLoop/Coach/Summaries/CoachSummaryContextBuilder.swift b/PulseLoop/Coach/Summaries/CoachSummaryContextBuilder.swift index 364fe4b8..7497e847 100644 --- a/PulseLoop/Coach/Summaries/CoachSummaryContextBuilder.swift +++ b/PulseLoop/Coach/Summaries/CoachSummaryContextBuilder.swift @@ -49,7 +49,7 @@ enum CoachSummaryContextBuilder { // MARK: Sleep โ€” nightly static func sleepDay(context: ModelContext, now: Date = Date()) -> Built? { - let range = SleepService.sleepRange(.day, context: context) + let range = SleepService.sleepRange(.day, context: context, now: now) guard let night = SleepInsights.validSessions(range.sessions).last else { return nil } let score = SleepScore.calculate(night) let activitySteps = MetricsRepository.latestActivity(context: context)?.steps @@ -83,7 +83,7 @@ enum CoachSummaryContextBuilder { // MARK: Sleep โ€” aggregate static func sleepRange(_ range: SleepRangeKey, context: ModelContext, now: Date = Date()) -> Built { - let summary = SleepService.sleepRange(range, context: context) + let summary = SleepService.sleepRange(range, context: context, now: now) let valid = SleepInsights.validSessions(summary.sessions) let avgMin = SleepInsights.averageDuration(valid) let avgScore = SleepInsights.averageScore(valid) diff --git a/PulseLoop/Coach/Tools/ChartTools.swift b/PulseLoop/Coach/Tools/ChartTools.swift index 3403d94b..9d08eef4 100644 --- a/PulseLoop/Coach/Tools/ChartTools.swift +++ b/PulseLoop/Coach/Tools/ChartTools.swift @@ -27,6 +27,7 @@ enum ChartTools { .make( name: "prepare_chart", label: "Preparing a chart", + // swiftlint:disable:next line_length description: "Build a ready-to-render chart (with embedded data) from ring data for a metric + date range. Returns a chart object to copy verbatim into the final response's `chart` field. granularity controls hr/spo2 resolution: use 'raw' for a within-a-single-day trend (individual readings), 'hour' for a busy day, 'day' for multi-day trends (daily averages). Activity/sleep metrics are always daily. For chart_type 'sleep_stage', uses the most recent sleep session in range.", parameters: JSONSchema.object([ "chart_type": JSONSchema.enumString(["line", "bar", "dot", "sleep_stage", "sparkline"]), diff --git a/PulseLoop/Coach/Tools/MemoryTools.swift b/PulseLoop/Coach/Tools/MemoryTools.swift index c1e564e8..574ce34f 100644 --- a/PulseLoop/Coach/Tools/MemoryTools.swift +++ b/PulseLoop/Coach/Tools/MemoryTools.swift @@ -29,6 +29,7 @@ enum MemoryTools { .make( name: "save_memory", label: "Saving this for future coaching", + // swiftlint:disable:next line_length description: "Save an important durable user-stated fact, preference, routine, injury, goal, or constraint for future coaching. Use null expires_in_days for stable facts, 7โ€“60 for temporary conditions (soreness, illness).", parameters: JSONSchema.object([ "memory_type": JSONSchema.enumString(memoryTypes), diff --git a/PulseLoop/Coach/Tools/RetrievalTools.swift b/PulseLoop/Coach/Tools/RetrievalTools.swift index dd90466a..5aa97f0b 100644 --- a/PulseLoop/Coach/Tools/RetrievalTools.swift +++ b/PulseLoop/Coach/Tools/RetrievalTools.swift @@ -147,6 +147,7 @@ enum RetrievalTools { .make( name: "get_metric_series", label: "Pulling the numbers", + // swiftlint:disable:next line_length description: "Fetch a time-series for one metric over a date range. For hr/spo2 use granularity 'raw' for a within-a-single-day trend (individual readings), 'hour' to bucket by hour, or 'day' for daily averages across multiple days. Activity/sleep are always daily.", parameters: JSONSchema.object([ "metric": JSONSchema.enumString(metricEnum), diff --git a/PulseLoop/Events/PulseEventBus.swift b/PulseLoop/Events/PulseEventBus.swift index 485df283..84de7d80 100644 --- a/PulseLoop/Events/PulseEventBus.swift +++ b/PulseLoop/Events/PulseEventBus.swift @@ -31,7 +31,18 @@ enum PulseEvent: Sendable { case workoutPaused(UUID) case workoutResumed(UUID) case workoutFinished(UUID) - case gpsPoint(sessionId: UUID, latitude: Double, longitude: Double, altitude: Double?, horizontalAccuracy: Double?, speed: Double?, course: Double?, accepted: Bool, rejectionReason: String?, timestamp: Date) + case gpsPoint( + sessionId: UUID, + latitude: Double, + longitude: Double, + altitude: Double?, + horizontalAccuracy: Double?, + speed: Double?, + course: Double?, + accepted: Bool, + rejectionReason: String?, + timestamp: Date + ) case coachTrace(String) } diff --git a/PulseLoop/Persistence/SeedData.swift b/PulseLoop/Persistence/SeedData.swift index aa641897..b627b472 100644 --- a/PulseLoop/Persistence/SeedData.swift +++ b/PulseLoop/Persistence/SeedData.swift @@ -2,6 +2,17 @@ import Foundation import SwiftData enum SeedData { + /// One synthetic finished workout for the demo seed. `origin` seeds a GPS loop + /// so the route map renders in the Simulator (which has no real GPS). + private struct SeedWorkout { + let offset: Int + let type: String + let minutes: Int + let distance: Double + let calories: Double + let origin: (Double, Double)? + } + @MainActor static func seedIfNeeded(_ context: ModelContext) { let descriptor = FetchDescriptor() @@ -81,21 +92,29 @@ enum SeedData { } } - // Several finished workouts across recent days (one today). `origin` seeds a synthetic - // GPS loop so the route map renders in the Simulator (which has no real GPS). - let workouts: [(offset: Int, type: String, minutes: Int, distance: Double, calories: Double, origin: (Double, Double)?)] = [ - (0, "run", 38, 6100, 330, (40.4443, -79.9436)), // CMU / Pittsburgh - (-1, "walk", 52, 4200, 210, (40.4406, -79.9959)), - (-3, "cycle", 64, 18400, 460, (40.4612, -79.9249)), - (-6, "gym", 45, 0, 280, nil), - (-10, "run", 41, 7300, 372, (40.4280, -79.9420)) + // Several finished workouts across recent days (one today). + let workouts: [SeedWorkout] = [ + SeedWorkout(offset: 0, type: "run", minutes: 38, distance: 6100, calories: 330, origin: (40.4443, -79.9436)), // CMU / Pittsburgh + SeedWorkout(offset: -1, type: "walk", minutes: 52, distance: 4200, calories: 210, origin: (40.4406, -79.9959)), + SeedWorkout(offset: -3, type: "cycle", minutes: 64, distance: 18400, calories: 460, origin: (40.4612, -79.9249)), + SeedWorkout(offset: -6, type: "gym", minutes: 45, distance: 0, calories: 280, origin: nil), + SeedWorkout(offset: -10, type: "run", minutes: 41, distance: 7300, calories: 372, origin: (40.4280, -79.9420)) ] for workout in workouts { let dayStart = calendar.date(byAdding: .day, value: workout.offset, to: now) ?? now let start = calendar.date(bySettingHour: 18, minute: 5, second: 0, of: dayStart) ?? dayStart let end = calendar.date(byAdding: .minute, value: workout.minutes, to: start) let useGps = workout.origin != nil - let session = ActivitySession(type: workout.type, status: .finished, startedAt: start, endedAt: end, calories: workout.calories, distanceMeters: workout.distance > 0 ? workout.distance : nil, notes: nil, useGps: useGps) + let session = ActivitySession( + type: workout.type, + status: .finished, + startedAt: start, + endedAt: end, + calories: workout.calories, + distanceMeters: workout.distance > 0 ? workout.distance : nil, + notes: nil, + useGps: useGps + ) session.avgHeartRate = 132 + Double(abs(workout.offset) % 12) session.minHeartRate = 108 session.maxHeartRate = 158 + Double(abs(workout.offset) % 8) @@ -111,9 +130,20 @@ enum SeedData { let conversation = CoachConversation(title: "Recovery check") context.insert(conversation) - context.insert(CoachMessage(conversationId: conversation.id, role: "assistant", body: "Your sleep is synced and activity is trending above baseline. Keep today's effort steady unless your HR stays elevated.")) + context.insert(CoachMessage( + conversationId: conversation.id, + role: "assistant", + body: "Your sleep is synced and activity is trending above baseline. Keep today's effort steady unless your HR stays elevated." + )) - context.insert(RawPacketRow(direction: .incoming, commandId: 0x03, hexPayload: "03112233447e240000a51a000064010000000000", decodedKind: "activity", decodedJSON: #"{"steps":9342}"#, confidence: .known)) + context.insert(RawPacketRow( + direction: .incoming, + commandId: 0x03, + hexPayload: "03112233447e240000a51a000064010000000000", + decodedKind: "activity", + decodedJSON: #"{"steps":9342}"#, + confidence: .known + )) context.insert(RawPacketRow(direction: .outgoing, commandId: 0x0c, hexPayload: "0c00000000000000000000000000000000000000", decodedKind: "status_command", confidence: .known)) context.insert(DerivedUpdateRow(kind: "seed", entityType: "database", entityId: "demo", payloadJSON: #"{"source":"SeedData"}"#)) diff --git a/PulseLoop/PulseLoopApp.swift b/PulseLoop/PulseLoopApp.swift index 17a2f853..22c19891 100644 --- a/PulseLoop/PulseLoopApp.swift +++ b/PulseLoop/PulseLoopApp.swift @@ -26,16 +26,29 @@ struct PulseLoopApp: App { /// Retained so the UNUserNotificationCenter delegate stays alive. private let notificationDelegate = CoachNotificationDelegate() + /// True when the app host is launched by the XCTest runner. Unit tests build their own + /// in-memory stores and never touch the live BLE/notification stack, so the app host must + /// not spin those up: on the headless CI simulator CoreBluetooth's XPC service and the + /// on-disk SwiftData store are unavailable, and starting them hangs/crashes the test host. + private static var isRunningUnitTests: Bool { + NSClassFromString("XCTestCase") != nil + } + init() { + let runningTests = Self.isRunningUnitTests + let container: ModelContainer do { - container = try ModelContainerFactory.make() + // Under tests, use an isolated in-memory store instead of the on-disk `default.store` + // (which fails to create in the sandboxed CI simulator). + container = try ModelContainerFactory.make(inMemory: runningTests) } catch { fatalError("Failed to create SwiftData container: \(error)") } self.container = container - let client = RingBLEClient() + // Don't bring up CoreBluetooth under tests (see `isRunningUnitTests`). + let client = RingBLEClient(startManager: !runningTests) let coordinator = RingSyncCoordinator(client: client, context: container.mainContext) client.onConnected = { [weak coordinator] in coordinator?.runStartupSequence() } let gps = GpsRouteRecorder() @@ -50,6 +63,10 @@ struct PulseLoopApp: App { let diagnostics = DiagnosticsSubscriber(context: container.mainContext) self.diagnostics = diagnostics + // Skip the live subsystems entirely under XCTest โ€” the test target exercises these + // components directly with their own fixtures; the app host just needs to launch cleanly. + guard !runningTests else { return } + // Start persistence + coordinator draining the bus; auto-reconnect happens when // CoreBluetooth reports poweredOn (see RingBLEClient.centralManagerDidUpdateState). subscriber.start() diff --git a/PulseLoop/RingProtocol/ColmiCoordinator.swift b/PulseLoop/RingProtocol/ColmiCoordinator.swift index a9583a58..4c411807 100644 --- a/PulseLoop/RingProtocol/ColmiCoordinator.swift +++ b/PulseLoop/RingProtocol/ColmiCoordinator.swift @@ -5,6 +5,8 @@ import Foundation /// Declares the rich capability set and recognizes the ring by its advertised name / service UUIDs. @MainActor final class ColmiCoordinator: WearableCoordinator { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + static let deviceType: RingDeviceType = .colmiR02 /// The whole Colmi/Yawell ring family advertises under many names but shares one protocol, so diff --git a/PulseLoop/RingProtocol/ColmiDriver.swift b/PulseLoop/RingProtocol/ColmiDriver.swift index 90e4852d..a0b010df 100644 --- a/PulseLoop/RingProtocol/ColmiDriver.swift +++ b/PulseLoop/RingProtocol/ColmiDriver.swift @@ -8,6 +8,8 @@ import Foundation /// is decoded directly. @MainActor final class ColmiDriver: WearableDriver { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + private weak var writer: RingCommandWriter? private let decoder = ColmiDecoder() private let engine: ColmiSyncEngine diff --git a/PulseLoop/RingProtocol/ColmiSyncEngine.swift b/PulseLoop/RingProtocol/ColmiSyncEngine.swift index 63591ea9..1b111da8 100644 --- a/PulseLoop/RingProtocol/ColmiSyncEngine.swift +++ b/PulseLoop/RingProtocol/ColmiSyncEngine.swift @@ -12,6 +12,8 @@ import Foundation /// and paged terminal conditions. These are the spots to re-check against a real ring. @MainActor final class ColmiSyncEngine: RingSyncEngine { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + private weak var writer: RingCommandWriter? private let decoder: ColmiDecoder private let encoder = ColmiEncoder() diff --git a/PulseLoop/RingProtocol/JringCoordinator.swift b/PulseLoop/RingProtocol/JringCoordinator.swift index 13ca5473..fd00cd31 100644 --- a/PulseLoop/RingProtocol/JringCoordinator.swift +++ b/PulseLoop/RingProtocol/JringCoordinator.swift @@ -6,6 +6,8 @@ import Foundation /// from the old `RingBLEClient.matchesRing`. @MainActor final class JringCoordinator: WearableCoordinator { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + static let deviceType: RingDeviceType = .jring private static let advertisedName = "SMART_RING" diff --git a/PulseLoop/RingProtocol/JringDriver.swift b/PulseLoop/RingProtocol/JringDriver.swift index 891b7120..b3f2897d 100644 --- a/PulseLoop/RingProtocol/JringDriver.swift +++ b/PulseLoop/RingProtocol/JringDriver.swift @@ -9,6 +9,8 @@ import Foundation /// multi-packet reassembly, so `ingest` is a straight `RingDecoder.decode`. @MainActor final class JringDriver: WearableDriver { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + private weak var writer: RingCommandWriter? private let decoder = RingDecoder() diff --git a/PulseLoop/RingProtocol/JringSyncEngine.swift b/PulseLoop/RingProtocol/JringSyncEngine.swift index 19916d2a..63c0f6bb 100644 --- a/PulseLoop/RingProtocol/JringSyncEngine.swift +++ b/PulseLoop/RingProtocol/JringSyncEngine.swift @@ -7,6 +7,8 @@ import Foundation /// and the measurement helpers, via the existing `RingEncoder` โ€” behavior is unchanged. @MainActor final class JringSyncEngine: RingSyncEngine { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + private weak var writer: RingCommandWriter? private let encoder = RingEncoder() diff --git a/PulseLoop/RingProtocol/RingBLEClient.swift b/PulseLoop/RingProtocol/RingBLEClient.swift index 7f249754..d66f233f 100644 --- a/PulseLoop/RingProtocol/RingBLEClient.swift +++ b/PulseLoop/RingProtocol/RingBLEClient.swift @@ -23,6 +23,8 @@ import Foundation @MainActor @Observable final class RingBLEClient: NSObject { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + /// Registry of supported wearables. First coordinator whose `matches` claims a peripheral wins. /// **Adding a wearable = append one entry here.** static let coordinators: [WearableCoordinator.Type] = [ @@ -89,9 +91,18 @@ final class RingBLEClient: NSObject { /// The 0x180F battery service, used only when the active driver exposes GATT battery. private let batteryServiceCBUUID = CBUUID(string: "180F") - override init() { + override convenience init() { + self.init(startManager: true) + } + + /// `startManager: false` skips creating the `CBCentralManager`, used by the app host under + /// XCTest where CoreBluetooth's XPC service is unavailable (headless CI simulator) and would + /// otherwise log "XPC connection invalid" and hang. No BLE method is called in that mode. + init(startManager: Bool) { super.init() - central = CBCentralManager(delegate: self, queue: nil) + if startManager { + central = CBCentralManager(delegate: self, queue: nil) + } } // MARK: - Public API diff --git a/PulseLoop/Services/LiveWorkoutManager.swift b/PulseLoop/Services/LiveWorkoutManager.swift index 1f5396c6..b6dbc876 100644 --- a/PulseLoop/Services/LiveWorkoutManager.swift +++ b/PulseLoop/Services/LiveWorkoutManager.swift @@ -111,16 +111,19 @@ final class LiveWorkoutManager { let pace = paceSecondsPerKm(distanceMeters: distance, durationSeconds: elapsed) liveActivity.update( sessionID: session.id.uuidString, - status: status, - elapsedSeconds: elapsed, - startDate: timerStart(session), - pausedAt: status == "paused" ? now : nil, - usesGps: session.useGps, - distanceMeters: session.useGps ? distance : 0, - paceSecondsPerKm: session.useGps ? pace : nil, - lastHeartRate: coordinator.latestHRValue, - lastSpO2: coordinator.latestSpO2Value, - activityType: session.type + state: WorkoutActivityAttributes.ContentState( + status: status, + elapsedSeconds: elapsed, + startDate: timerStart(session), + pausedAt: status == "paused" ? now : nil, + usesGps: session.useGps, + distanceMeters: session.useGps ? distance : 0, + paceSecondsPerKm: session.useGps ? pace : nil, + lastHeartRate: coordinator.latestHRValue, + lastSpO2: coordinator.latestSpO2Value, + activityType: session.type, + lastUpdated: Date() + ) ) } diff --git a/PulseLoop/Services/PulseServices.swift b/PulseLoop/Services/PulseServices.swift index 87484f43..3a2b7c97 100644 --- a/PulseLoop/Services/PulseServices.swift +++ b/PulseLoop/Services/PulseServices.swift @@ -33,7 +33,7 @@ enum MetricsService { hrSamples24h: hrSamples, spo2Samples24h: spo2Samples ) - let metricStates = buildMetricStates( + let metricStates = buildMetricStates(MetricStateInputs( today: today, sleep: sleep, latestHR: latestHR, @@ -43,7 +43,7 @@ enum MetricsService { activityRows: activityRows, calibration: calibration, isDemo: isDemo - ) + )) // The ring's calorie field is unverified, so ring-history days don't carry calories โ€” show // "โ€”" rather than a misleading 0. Steps/distance from the ring are trustworthy. @@ -281,17 +281,30 @@ enum MetricsService { return resting.min() } - private static func buildMetricStates( - today: ActivityDaily?, - sleep: SleepSummary?, - latestHR: Measurement?, - latestSpO2: Measurement?, - hrFreshness: DataFreshness, - spo2Freshness: DataFreshness, - activityRows: [ActivityDaily], - calibration: CalibrationState, - isDemo: Bool - ) -> [MetricKey: MetricState] { + /// Inputs for `buildMetricStates`, bundled to keep the call site readable. + private struct MetricStateInputs { + let today: ActivityDaily? + let sleep: SleepSummary? + let latestHR: Measurement? + let latestSpO2: Measurement? + let hrFreshness: DataFreshness + let spo2Freshness: DataFreshness + let activityRows: [ActivityDaily] + let calibration: CalibrationState + let isDemo: Bool + } + + private static func buildMetricStates(_ inputs: MetricStateInputs) -> [MetricKey: MetricState] { + let today = inputs.today + let sleep = inputs.sleep + let latestHR = inputs.latestHR + let latestSpO2 = inputs.latestSpO2 + let hrFreshness = inputs.hrFreshness + let spo2Freshness = inputs.spo2Freshness + let activityRows = inputs.activityRows + let calibration = inputs.calibration + let isDemo = inputs.isDemo + let activityFreshness = freshness(lastUpdatedAt: today?.syncedAt, isDemo: isDemo) let activitySampleCount = activityRows.count return [ @@ -477,14 +490,14 @@ enum SleepService { return summary(for: session, includeStages: true, context: context) } - static func sleepRange(_ range: SleepRangeKey, context: ModelContext) -> SleepRangeSummary { + static func sleepRange(_ range: SleepRangeKey, context: ModelContext, now: Date = Date()) -> SleepRangeSummary { let expected = expectedNights(for: range) // Day view is "last night" โ€” anchored on the current reference night, not // the latest recorded one. If nothing was captured we want to show the // empty state, not a stale night from days ago. Week/Month/Year keep the // last-recorded anchor so historical data still surfaces. let anchor = range == .day - ? dayReferenceNight(now: Date()) + ? dayReferenceNight(now: now) : sleepAnchor(context: context) let start = Calendar.current.date(byAdding: .day, value: -(expected - 1), to: anchor) ?? anchor // End-of-day cap on the anchor so sessions stored mid-day are included. diff --git a/PulseLoop/Services/SleepInsights.swift b/PulseLoop/Services/SleepInsights.swift index f42acb35..facdb79b 100644 --- a/PulseLoop/Services/SleepInsights.swift +++ b/PulseLoop/Services/SleepInsights.swift @@ -273,6 +273,7 @@ enum SleepInsights { default: return SleepCoach( headline: "Your long-term sleep trend", + // swiftlint:disable:next line_length body: "Across the year your tracked average is \(avgText) over \(valid.count) nights. The long-term trend is still forming โ€” as more months fill in, I'll be able to compare seasonal changes and consistency.", chips: chips ) diff --git a/PulseLoop/Services/WorkoutLiveActivityService.swift b/PulseLoop/Services/WorkoutLiveActivityService.swift index 5d0cdbe6..0850264c 100644 --- a/PulseLoop/Services/WorkoutLiveActivityService.swift +++ b/PulseLoop/Services/WorkoutLiveActivityService.swift @@ -64,33 +64,9 @@ final class WorkoutLiveActivityService: ObservableObject { /// Pushes a new ContentState to the Live Activity for the session. Non-async: /// the actual `await` is dispatched onto a Task. Throttling is the caller's job. - func update(sessionID: String, - status: String, - elapsedSeconds: Int, - startDate: Date, - pausedAt: Date?, - usesGps: Bool, - distanceMeters: Double, - paceSecondsPerKm: Double?, - lastHeartRate: Int?, - lastSpO2: Int?, - activityType: String) { + func update(sessionID: String, state: WorkoutActivityAttributes.ContentState) { guard let activity = activities[sessionID] else { return } - let state = WorkoutActivityAttributes.ContentState( - status: status, - elapsedSeconds: elapsedSeconds, - startDate: startDate, - pausedAt: pausedAt, - usesGps: usesGps, - distanceMeters: distanceMeters, - paceSecondsPerKm: paceSecondsPerKm, - lastHeartRate: lastHeartRate, - lastSpO2: lastSpO2, - activityType: activityType, - lastUpdated: Date() - ) - Task { await activity.update(ActivityContent(state: state, staleDate: nil)) } diff --git a/PulseLoop/Views/ActivityView.swift b/PulseLoop/Views/ActivityView.swift index ab7b72c2..ff62bf2e 100644 --- a/PulseLoop/Views/ActivityView.swift +++ b/PulseLoop/Views/ActivityView.swift @@ -109,10 +109,33 @@ struct ActivityView: View { .buttonStyle(.plain) LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { - MetricCardButton(metric: "steps", label: "Steps", value: summary.steps.map { $0.formatted() } ?? "โ€”", color: PulseColors.steps) - MetricCardButton(metric: "calories", label: "Calories", value: summary.calories.map { Int($0).formatted() } ?? "โ€”", unit: summary.calories == nil ? nil : "kcal", color: PulseColors.calories) - MetricCardButton(metric: "distance", label: "Distance", value: summary.distanceMeters.map { String(format: "%.2f", $0 / 1000) } ?? "โ€”", unit: summary.distanceMeters == nil ? nil : "km", color: PulseColors.distance) - MetricCardButton(metric: "readiness", label: "Active min", value: summary.activeMinutes.map { "\($0)" } ?? "โ€”", unit: summary.activeMinutes == nil ? nil : "min", color: PulseColors.readiness) + MetricCardButton( + metric: "steps", + label: "Steps", + value: summary.steps.map { $0.formatted() } ?? "โ€”", + color: PulseColors.steps + ) + MetricCardButton( + metric: "calories", + label: "Calories", + value: summary.calories.map { Int($0).formatted() } ?? "โ€”", + unit: summary.calories == nil ? nil : "kcal", + color: PulseColors.calories + ) + MetricCardButton( + metric: "distance", + label: "Distance", + value: summary.distanceMeters.map { String(format: "%.2f", $0 / 1000) } ?? "โ€”", + unit: summary.distanceMeters == nil ? nil : "km", + color: PulseColors.distance + ) + MetricCardButton( + metric: "readiness", + label: "Active min", + value: summary.activeMinutes.map { "\($0)" } ?? "โ€”", + unit: summary.activeMinutes == nil ? nil : "min", + color: PulseColors.readiness + ) } // Trend graphs with range toggles diff --git a/PulseLoop/Views/DebugView.swift b/PulseLoop/Views/DebugView.swift index db3234e0..931d118f 100644 --- a/PulseLoop/Views/DebugView.swift +++ b/PulseLoop/Views/DebugView.swift @@ -45,7 +45,15 @@ struct DebugView: View { SecondaryButton(title: "Log mock packet", systemImage: "plus") { let data = RingEncoder().makeStatusCommand() let decoded = RingDecoder().decode(data) - DebugRepository.insertRawPacket(direction: .outgoing, commandId: Int(data.first ?? 0), hexPayload: data.hexString, decodedKind: decoded.kind, decodedJSON: decoded.debugJSON, confidence: decoded.confidence, context: modelContext) + DebugRepository.insertRawPacket( + direction: .outgoing, + commandId: Int(data.first ?? 0), + hexPayload: data.hexString, + decodedKind: decoded.kind, + decodedJSON: decoded.debugJSON, + confidence: decoded.confidence, + context: modelContext + ) try? modelContext.save() refreshToken = UUID() } diff --git a/PulseLoop/Views/RootViews.swift b/PulseLoop/Views/RootViews.swift index ed2d2c7c..614da47c 100644 --- a/PulseLoop/Views/RootViews.swift +++ b/PulseLoop/Views/RootViews.swift @@ -343,21 +343,39 @@ struct OnboardingFlowView: View { struct OnboardingWelcomeView: View { let next: () -> Void var body: some View { - OnboardingPage(title: "PulseLoop", subtitle: "Your ring data, activity, sleep, and coach in one native app.", systemImage: "circle.hexagongrid.circle.fill", actionTitle: "Get started", action: next) + OnboardingPage( + title: "PulseLoop", + subtitle: "Your ring data, activity, sleep, and coach in one native app.", + systemImage: "circle.hexagongrid.circle.fill", + actionTitle: "Get started", + action: next + ) } } struct OnboardingProfileView: View { let next: () -> Void var body: some View { - OnboardingPage(title: "Set profile", subtitle: "Age, body metrics, and preferences help PulseLoop tune goals and summaries.", systemImage: "person.crop.circle", actionTitle: "Save profile", action: next) + OnboardingPage( + title: "Set profile", + subtitle: "Age, body metrics, and preferences help PulseLoop tune goals and summaries.", + systemImage: "person.crop.circle", + actionTitle: "Save profile", + action: next + ) } } struct OnboardingBaselineView: View { let next: () -> Void var body: some View { - OnboardingPage(title: "Learning your baseline", subtitle: "Wear the ring through the day and sync after sleep so trends become personal.", systemImage: "chart.line.uptrend.xyaxis", actionTitle: "Continue", action: next) + OnboardingPage( + title: "Learning your baseline", + subtitle: "Wear the ring through the day and sync after sleep so trends become personal.", + systemImage: "chart.line.uptrend.xyaxis", + actionTitle: "Continue", + action: next + ) } } diff --git a/PulseLoop/Views/VitalsView.swift b/PulseLoop/Views/VitalsView.swift index fc7b3010..3ed6d182 100644 --- a/PulseLoop/Views/VitalsView.swift +++ b/PulseLoop/Views/VitalsView.swift @@ -45,6 +45,7 @@ struct VitalsView: View { } .padding(.top, 12) + // swiftlint:disable:next line_length Text("Resting estimate: \(summary.restingHeartRateEstimate.map { "\(Int($0))" } ?? "Calibrating") ยท Peak today: \(summary.peakHeartRateToday.map { "\(Int($0))" } ?? "Not enough data")") .font(.system(size: 12)).foregroundStyle(PulseColors.textMuted) .padding(.top, 8) diff --git a/PulseLoopLiveActivity/PulseLoopLiveActivityLiveActivity.swift b/PulseLoopLiveActivity/PulseLoopLiveActivityLiveActivity.swift index 52eed99d..b3208a82 100644 --- a/PulseLoopLiveActivity/PulseLoopLiveActivityLiveActivity.swift +++ b/PulseLoopLiveActivity/PulseLoopLiveActivityLiveActivity.swift @@ -139,13 +139,49 @@ extension WorkoutActivityAttributes { extension WorkoutActivityAttributes.ContentState { fileprivate static var recording: WorkoutActivityAttributes.ContentState { - WorkoutActivityAttributes.ContentState(status: "recording", elapsedSeconds: 1325, startDate: Date().addingTimeInterval(-1325), pausedAt: nil, usesGps: true, distanceMeters: 3450, paceSecondsPerKm: 312, lastHeartRate: 152, lastSpO2: 98, activityType: "run", lastUpdated: Date()) + WorkoutActivityAttributes.ContentState( + status: "recording", + elapsedSeconds: 1325, + startDate: Date().addingTimeInterval(-1325), + pausedAt: nil, + usesGps: true, + distanceMeters: 3450, + paceSecondsPerKm: 312, + lastHeartRate: 152, + lastSpO2: 98, + activityType: "run", + lastUpdated: Date() + ) } fileprivate static var paused: WorkoutActivityAttributes.ContentState { - WorkoutActivityAttributes.ContentState(status: "paused", elapsedSeconds: 1325, startDate: Date().addingTimeInterval(-1325), pausedAt: Date(), usesGps: true, distanceMeters: 3450, paceSecondsPerKm: 312, lastHeartRate: 138, lastSpO2: 97, activityType: "run", lastUpdated: Date()) + WorkoutActivityAttributes.ContentState( + status: "paused", + elapsedSeconds: 1325, + startDate: Date().addingTimeInterval(-1325), + pausedAt: Date(), + usesGps: true, + distanceMeters: 3450, + paceSecondsPerKm: 312, + lastHeartRate: 138, + lastSpO2: 97, + activityType: "run", + lastUpdated: Date() + ) } fileprivate static var indoor: WorkoutActivityAttributes.ContentState { - WorkoutActivityAttributes.ContentState(status: "recording", elapsedSeconds: 640, startDate: Date().addingTimeInterval(-640), pausedAt: nil, usesGps: false, distanceMeters: 0, paceSecondsPerKm: nil, lastHeartRate: 121, lastSpO2: 97, activityType: "gym", lastUpdated: Date()) + WorkoutActivityAttributes.ContentState( + status: "recording", + elapsedSeconds: 640, + startDate: Date().addingTimeInterval(-640), + pausedAt: nil, + usesGps: false, + distanceMeters: 0, + paceSecondsPerKm: nil, + lastHeartRate: 121, + lastSpO2: 97, + activityType: "gym", + lastUpdated: Date() + ) } } diff --git a/PulseLoopTests/CoachActionTests.swift b/PulseLoopTests/CoachActionTests.swift index 6164a5b5..7a655c37 100644 --- a/PulseLoopTests/CoachActionTests.swift +++ b/PulseLoopTests/CoachActionTests.swift @@ -89,7 +89,8 @@ final class CoachActionTests: XCTestCase { c.insert(session) try c.save() let context = ctx(c) - let result = try await tool("update_activity_session", c).run(Data(#"{"activity_id":"\#(session.id.uuidString)","type":"cycle","notes":null,"distance_km":null,"duration_min":null,"perceived_effort":null,"start_time":null,"reason":"misclassified"}"#.utf8), context) + let json = #"{"activity_id":"\#(session.id.uuidString)","type":"cycle","notes":null,"distance_km":null,"duration_min":null,"perceived_effort":null,"start_time":null,"reason":"misclassified"}"# + let result = try await tool("update_activity_session", c).run(Data(json.utf8), context) XCTAssertEqual((try parse(result))["needs_confirmation"] as? Bool, true) XCTAssertEqual(context.pendingActions.first?.kind, .updateActivitySession) XCTAssertEqual(session.type, "run") // unchanged until confirmed diff --git a/PulseLoopTests/CoachNotificationTests.swift b/PulseLoopTests/CoachNotificationTests.swift index 14a31b2a..fa49258d 100644 --- a/PulseLoopTests/CoachNotificationTests.swift +++ b/PulseLoopTests/CoachNotificationTests.swift @@ -71,7 +71,10 @@ final class CoachNotificationServiceTests: XCTestCase { func testForceGeneratesRecordsAndWritesThread() async throws { let c = try TestSupport.makeContext() - let outcome = await service(c).runDueSlot(force: true) + // Pin `now` to a morning hour: a forced run picks its slot from the wall clock + // (< 14:00 โ†’ morning), and this test asserts the "Good morning" copy. + let morning = Calendar.current.date(bySettingHour: 8, minute: 0, second: 0, of: Date()) ?? Date() + let outcome = await service(c).runDueSlot(force: true, now: morning) if case .sent = outcome {} else { XCTFail("expected sent, got \(outcome)") } // A record exists + a fresh per-notification conversation was created. diff --git a/PulseLoopTests/CoachSummaryTests.swift b/PulseLoopTests/CoachSummaryTests.swift index 61518850..d61e7357 100644 --- a/PulseLoopTests/CoachSummaryTests.swift +++ b/PulseLoopTests/CoachSummaryTests.swift @@ -33,6 +33,8 @@ final class CoachSummaryContentTests: XCTestCase { final class CoachSummaryServiceTests: XCTestCase { private func service(_ c: ModelContext, key: String? = "sk-test", json: String = summaryJSON()) -> CoachSummaryService { let store = CoachSettingsStore(defaults: UserDefaults(suiteName: UUID().uuidString)!) + // The coach is opt-out by default; turn the master switch on so summaries actually generate. + store.settings.coachMasterEnabled = true return CoachSummaryService( modelContext: c, keyStore: SummaryStubKeyStore(key: key), @@ -76,15 +78,18 @@ final class CoachSummaryServiceTests: XCTestCase { TestSupport.insertSleep(nightStart: Calendar.current.startOfDay(for: Date()), stages: Array(repeating: .light, count: 60) + Array(repeating: .deep, count: 30), into: c) let svc = service(c, json: summaryJSON(title: "Solid night", body: "1h30m tracked.", chips: ["Deep sleep?"])) + // Pin `now` past the 4 AM day-reference flip so today's night is in the Day window + // regardless of when the suite runs. + let now = Calendar.current.date(bySettingHour: 12, minute: 0, second: 0, of: TestSupport.day(0)) ?? Date() - await svc.refreshSleepDayIfNeeded() + await svc.refreshSleepDayIfNeeded(now: now) var sleeps = (try c.fetch(FetchDescriptor())).filter { $0.kind == "sleep_day" } XCTAssertEqual(sleeps.count, 1) XCTAssertEqual(sleeps.first?.title, "Solid night") let updated = sleeps.first!.updatedAt // Second call same night โ†’ no new summary, no update (once per night). - await svc.refreshSleepDayIfNeeded() + await svc.refreshSleepDayIfNeeded(now: now) sleeps = (try c.fetch(FetchDescriptor())).filter { $0.kind == "sleep_day" } XCTAssertEqual(sleeps.count, 1) XCTAssertEqual(sleeps.first!.updatedAt, updated) diff --git a/PulseLoopTests/CoachTests.swift b/PulseLoopTests/CoachTests.swift index 866c7a29..b16080e3 100644 --- a/PulseLoopTests/CoachTests.swift +++ b/PulseLoopTests/CoachTests.swift @@ -222,7 +222,7 @@ final class CoachOrchestratorTests: XCTestCase { func testMessageOnlyResponseIsParsed() async throws { let c = try TestSupport.makeContext() - let flags = CoachFeatureFlags(settings: .default, hasAPIKey: true) + let flags = CoachFeatureFlags(settings: TestSupport.enabledCoachSettings(), hasAPIKey: true) let stub = StubResponsesClient([OpenAIResponse(id: "r1", outputItems: [.message(text: validResponseJSON(title: "Hi"))])]) let o = orchestrator(client: stub, flags: flags, context: c) let result = await o.runTurn(userText: "hi", packet: packet(c), recentMessages: []) @@ -234,7 +234,7 @@ final class CoachOrchestratorTests: XCTestCase { let c = try TestSupport.makeContext() TestSupport.insertActivity(date: Date(), steps: 8200, into: c) let today = CoachDataAccess.localDateString(Date()) - let flags = CoachFeatureFlags(settings: .default, hasAPIKey: true) + let flags = CoachFeatureFlags(settings: TestSupport.enabledCoachSettings(), hasAPIKey: true) let stub = StubResponsesClient([ OpenAIResponse(id: "r1", outputItems: [.functionCall(.init(name: "get_daily_summary", callID: "c1", arguments: #"{"date":"\#(today)"}"#))]), OpenAIResponse(id: "r2", outputItems: [.message(text: validResponseJSON())]), @@ -250,7 +250,7 @@ final class CoachOrchestratorTests: XCTestCase { func testUnparseableFinalFallsBack() async throws { let c = try TestSupport.makeContext() - let flags = CoachFeatureFlags(settings: .default, hasAPIKey: true) + let flags = CoachFeatureFlags(settings: TestSupport.enabledCoachSettings(), hasAPIKey: true) // Every response is junk โ†’ repair loop exhausts โ†’ fallback. let junk = OpenAIResponse(id: "x", outputItems: [.message(text: "not json")]) let o = orchestrator(client: StubResponsesClient([junk, junk, junk, junk]), flags: flags, context: c) diff --git a/PulseLoopTests/ColmiDecoderTests.swift b/PulseLoopTests/ColmiDecoderTests.swift index 7385ae46..711d50a1 100644 --- a/PulseLoopTests/ColmiDecoderTests.swift +++ b/PulseLoopTests/ColmiDecoderTests.swift @@ -376,5 +376,6 @@ final class ColmiDecoderTests: XCTestCase { /// A no-op command writer for driver tests. @MainActor private final class NullWriter: RingCommandWriter { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) func enqueue(_ command: Data) {} } diff --git a/PulseLoopTests/DebugRepositoryTests.swift b/PulseLoopTests/DebugRepositoryTests.swift index 6b1ceff3..4771ea42 100644 --- a/PulseLoopTests/DebugRepositoryTests.swift +++ b/PulseLoopTests/DebugRepositoryTests.swift @@ -5,9 +5,18 @@ import SwiftData @MainActor final class DebugRepositoryTests: XCTestCase { private func seed(_ context: ModelContext) { - DebugRepository.insertRawPacket(timestamp: Date().addingTimeInterval(-3), direction: .outgoing, commandId: 0x0c, hexPayload: "0c00", decodedKind: "status", confidence: .known, context: context) - DebugRepository.insertRawPacket(timestamp: Date().addingTimeInterval(-2), direction: .incoming, commandId: 0x03, hexPayload: "03aa", decodedKind: "activity", confidence: .known, context: context) - DebugRepository.insertRawPacket(timestamp: Date().addingTimeInterval(-1), direction: .incoming, commandId: 0x52, hexPayload: "5200", decodedKind: "unknown", confidence: .unknown, context: context) + DebugRepository.insertRawPacket( + timestamp: Date().addingTimeInterval(-3), direction: .outgoing, commandId: 0x0c, + hexPayload: "0c00", decodedKind: "status", confidence: .known, context: context + ) + DebugRepository.insertRawPacket( + timestamp: Date().addingTimeInterval(-2), direction: .incoming, commandId: 0x03, + hexPayload: "03aa", decodedKind: "activity", confidence: .known, context: context + ) + DebugRepository.insertRawPacket( + timestamp: Date().addingTimeInterval(-1), direction: .incoming, commandId: 0x52, + hexPayload: "5200", decodedKind: "unknown", confidence: .unknown, context: context + ) try? context.save() } diff --git a/PulseLoopTests/EventBridgeTests.swift b/PulseLoopTests/EventBridgeTests.swift index f530d32f..fe245835 100644 --- a/PulseLoopTests/EventBridgeTests.swift +++ b/PulseLoopTests/EventBridgeTests.swift @@ -45,7 +45,9 @@ final class EventBridgeTests: XCTestCase { } func testStaleSleepTimelineRejected() { - let oldStart = Date().addingTimeInterval(-5 * 24 * 3600) + // The plausibility window is ~8 days (matches the Colmi multi-night history horizon), + // so a timestamp well outside it indicates a misdecoded frame and must be dropped. + let oldStart = Date().addingTimeInterval(-10 * 24 * 3600) XCTAssertTrue(RingEventBridge.events(for: .sleepTimeline(timestamp: oldStart, stages: [.light])).isEmpty) } diff --git a/PulseLoopTests/PulseLoopTestSupport.swift b/PulseLoopTests/PulseLoopTestSupport.swift index 3bf44dad..d502b1a5 100644 --- a/PulseLoopTests/PulseLoopTestSupport.swift +++ b/PulseLoopTests/PulseLoopTestSupport.swift @@ -12,6 +12,15 @@ enum TestSupport { return ModelContext(container) } + /// Coach settings with the master switch on, so `CoachFeatureFlags(... hasAPIKey: true)` + /// resolves to an enabled coach. The product default is opt-out (`coachMasterEnabled == false`), + /// which would otherwise route every turn to the scripted fallback. + static func enabledCoachSettings() -> CoachSettings { + var settings = CoachSettings.default + settings.coachMasterEnabled = true + return settings + } + static func day(_ offset: Int, from base: Date = Date()) -> Date { Calendar.current.date(byAdding: .day, value: offset, to: Calendar.current.startOfDay(for: base)) ?? base } diff --git a/PulseLoopTests/SleepServiceTests.swift b/PulseLoopTests/SleepServiceTests.swift index 45819518..ee98535d 100644 --- a/PulseLoopTests/SleepServiceTests.swift +++ b/PulseLoopTests/SleepServiceTests.swift @@ -9,6 +9,12 @@ final class SleepServiceTests: XCTestCase { return Calendar.current.date(bySettingHour: 23, minute: 0, second: 0, of: base) ?? base } + /// A fixed "now" at noon today โ€” past the 4 AM day-reference flip โ€” so `.day`-range + /// assertions don't depend on the wall-clock time the suite happens to run at. + private func noonToday() -> Date { + Calendar.current.date(bySettingHour: 12, minute: 0, second: 0, of: TestSupport.day(0)) ?? Date() + } + func testStaleSleepHiddenFromLatest() throws { let context = try TestSupport.makeContext() TestSupport.insertSleep(nightStart: night(-3), stages: Array(repeating: .light, count: 60), into: context) @@ -60,7 +66,9 @@ final class SleepServiceTests: XCTestCase { func testBlocksOnlyForDayRange() throws { let context = try TestSupport.makeContext() TestSupport.insertSleep(nightStart: night(0), stages: Array(repeating: .light, count: 60), into: context) - XCTAssertFalse(SleepService.sleepRange(.day, context: context).sessions.first?.blocks.isEmpty ?? true) + // Pin `now` to noon today so the Day window resolves to today regardless of when the suite runs + // (before 4 AM the reference night is yesterday, which would exclude tonight's session). + XCTAssertFalse(SleepService.sleepRange(.day, context: context, now: noonToday()).sessions.first?.blocks.isEmpty ?? true) XCTAssertTrue(SleepService.sleepRange(.week, context: context).sessions.first?.blocks.isEmpty ?? false) }