oh-my-agent-commandcode 0.0.0-stage → 20.38.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -2
- package/filter-test-output.sh +52 -0
- package/mods/oma.ts +347 -0
- package/package.json +30 -4
- package/skills/_shared/conditional/experiment-ledger.md +28 -0
- package/skills/_shared/conditional/exploration-loop.md +22 -0
- package/skills/_shared/conditional/quality-score.md +33 -0
- package/skills/_shared/core/anti-ai-prose.md +191 -0
- package/skills/_shared/core/api-contracts/README.md +28 -0
- package/skills/_shared/core/api-contracts/template.md +90 -0
- package/skills/_shared/core/clarification-protocol.md +23 -0
- package/skills/_shared/core/code-intelligence.md +46 -0
- package/skills/_shared/core/collaboration-policy.md +28 -0
- package/skills/_shared/core/common-checklist.md +28 -0
- package/skills/_shared/core/context-budget.md +22 -0
- package/skills/_shared/core/context-loading.md +34 -0
- package/skills/_shared/core/difficulty-guide.md +26 -0
- package/skills/_shared/core/execution-policy.md +31 -0
- package/skills/_shared/core/lessons-learned.md +33 -0
- package/skills/_shared/core/prompt-structure.md +18 -0
- package/skills/_shared/core/quality-principles.md +11 -0
- package/skills/_shared/core/session-metrics.md +35 -0
- package/skills/_shared/core/skill-routing.md +39 -0
- package/skills/_shared/core/test-approach.md +54 -0
- package/skills/_shared/core/vendor-detection.md +52 -0
- package/skills/_shared/runtime/memory-protocol.md +118 -0
- package/skills/_shared/runtime/result-contract.md +64 -0
- package/skills/_shared/ui-interaction-quality.md +133 -0
- package/skills/oma-academic-writing/SKILL.md +181 -0
- package/skills/oma-academic-writing/resources/abstract-guide.md +51 -0
- package/skills/oma-academic-writing/resources/academic-verb-tiers.md +42 -0
- package/skills/oma-academic-writing/resources/anti-ai-checklist.md +52 -0
- package/skills/oma-academic-writing/resources/hedging-guide.md +132 -0
- package/skills/oma-academic-writing/resources/sentence-structure-reference.md +151 -0
- package/skills/oma-architecture/SKILL.md +203 -0
- package/skills/oma-architecture/resources/api-evolution.md +41 -0
- package/skills/oma-architecture/resources/checklist.md +42 -0
- package/skills/oma-architecture/resources/execution-protocol.md +115 -0
- package/skills/oma-architecture/resources/methodology-selection.md +92 -0
- package/skills/oma-architecture/resources/migration-patterns.md +28 -0
- package/skills/oma-architecture/resources/output-templates.md +111 -0
- package/skills/oma-architecture/resources/stakeholder-synthesis.md +80 -0
- package/skills/oma-backend/SKILL.md +202 -0
- package/skills/oma-backend/resources/checklist.md +45 -0
- package/skills/oma-backend/resources/error-playbook.md +97 -0
- package/skills/oma-backend/resources/execution-protocol.md +39 -0
- package/skills/oma-backend/resources/orm-reference.md +61 -0
- package/skills/oma-backend/variants/node/api-template.ts +315 -0
- package/skills/oma-backend/variants/node/snippets.md +475 -0
- package/skills/oma-backend/variants/node/stack.yaml +21 -0
- package/skills/oma-backend/variants/node/tech-stack.md +33 -0
- package/skills/oma-backend/variants/python/api-template.py +378 -0
- package/skills/oma-backend/variants/python/snippets.md +199 -0
- package/skills/oma-backend/variants/python/stack.yaml +18 -0
- package/skills/oma-backend/variants/python/tech-stack.md +45 -0
- package/skills/oma-backend/variants/rust/api-template.rs +635 -0
- package/skills/oma-backend/variants/rust/snippets.md +471 -0
- package/skills/oma-backend/variants/rust/stack.yaml +20 -0
- package/skills/oma-backend/variants/rust/tech-stack.md +36 -0
- package/skills/oma-backend/variants/stack.schema.json +73 -0
- package/skills/oma-brainstorm/SKILL.md +166 -0
- package/skills/oma-brainstorm/resources/triz-lite.md +117 -0
- package/skills/oma-coordination/SKILL.md +171 -0
- package/skills/oma-db/SKILL.md +201 -0
- package/skills/oma-db/resources/anti-patterns.md +177 -0
- package/skills/oma-db/resources/checklist.md +67 -0
- package/skills/oma-db/resources/document-templates.md +107 -0
- package/skills/oma-db/resources/error-playbook.md +50 -0
- package/skills/oma-db/resources/examples.md +170 -0
- package/skills/oma-db/resources/execution-protocol.md +127 -0
- package/skills/oma-db/resources/iso-controls.md +98 -0
- package/skills/oma-db/resources/migration-playbook.md +93 -0
- package/skills/oma-db/resources/query-tuning.md +57 -0
- package/skills/oma-db/resources/vector-db.md +159 -0
- package/skills/oma-debug/SKILL.md +146 -0
- package/skills/oma-debug/resources/bug-report-template.md +327 -0
- package/skills/oma-debug/resources/checklist.md +31 -0
- package/skills/oma-debug/resources/common-patterns.md +734 -0
- package/skills/oma-debug/resources/debugging-checklist.md +364 -0
- package/skills/oma-debug/resources/error-playbook.md +94 -0
- package/skills/oma-debug/resources/execution-protocol.md +40 -0
- package/skills/oma-design/SKILL.md +135 -0
- package/skills/oma-design/resources/asset-strategy.md +34 -0
- package/skills/oma-design/resources/checklist.md +63 -0
- package/skills/oma-design/resources/data-grid.md +48 -0
- package/skills/oma-design/resources/design-md-spec.md +107 -0
- package/skills/oma-design/resources/design-tokens.md +53 -0
- package/skills/oma-design/resources/error-playbook.md +18 -0
- package/skills/oma-design/resources/execution-protocol.md +95 -0
- package/skills/oma-design/resources/external-design-skills.md +130 -0
- package/skills/oma-design/resources/getdesign-fetcher.md +104 -0
- package/skills/oma-design/resources/product-interface-review.md +59 -0
- package/skills/oma-design/resources/redesign-protocol.md +52 -0
- package/skills/oma-design/resources/stitch-integration.md +244 -0
- package/skills/oma-dev-workflow/SKILL.md +375 -0
- package/skills/oma-dev-workflow/resources/affected-checks.py +58 -0
- package/skills/oma-dev-workflow/resources/api-workflows.md +58 -0
- package/skills/oma-dev-workflow/resources/database-patterns.md +107 -0
- package/skills/oma-dev-workflow/resources/i18n-patterns.md +63 -0
- package/skills/oma-dev-workflow/resources/release-coordination.md +240 -0
- package/skills/oma-dev-workflow/resources/tooling-profiles.md +217 -0
- package/skills/oma-dev-workflow/resources/troubleshooting.md +78 -0
- package/skills/oma-dev-workflow/resources/validation-pipeline.md +246 -0
- package/skills/oma-docs/SKILL.md +77 -0
- package/skills/oma-docs/resources/commands.md +76 -0
- package/skills/oma-explanation/SKILL.md +212 -0
- package/skills/oma-explanation/resources/document-structure.md +115 -0
- package/skills/oma-explanation/resources/draft-format.md +199 -0
- package/skills/oma-explanation/resources/html-contract.md +102 -0
- package/skills/oma-frontend/SKILL.md +227 -0
- package/skills/oma-frontend/resources/angular-rules.md +110 -0
- package/skills/oma-frontend/resources/checklist.md +76 -0
- package/skills/oma-frontend/resources/error-playbook.md +125 -0
- package/skills/oma-frontend/resources/execution-protocol.md +46 -0
- package/skills/oma-frontend/resources/snippets.md +354 -0
- package/skills/oma-frontend/resources/specialized-visual-implementation.md +193 -0
- package/skills/oma-frontend/resources/tailwind-rules.md +83 -0
- package/skills/oma-frontend/resources/tech-stack.md +156 -0
- package/skills/oma-hwp/SKILL.md +166 -0
- package/skills/oma-hwp/config/hwp-config.yaml +35 -0
- package/skills/oma-hwp/resources/balanced-tables.ts +39 -0
- package/skills/oma-hwp/resources/execution-protocol.md +145 -0
- package/skills/oma-hwp/resources/flatten-tables.ts +101 -0
- package/skills/oma-hwp/resources/package.json +13 -0
- package/skills/oma-hwp/resources/troubleshooting.md +70 -0
- package/skills/oma-hwp/resources/tsconfig.json +10 -0
- package/skills/oma-hwp/resources/turndown-plugin-gfm.d.ts +9 -0
- package/skills/oma-image/SKILL.md +142 -0
- package/skills/oma-image/config/image-config.yaml +70 -0
- package/skills/oma-image/resources/checklist.md +18 -0
- package/skills/oma-image/resources/execution-protocol.md +117 -0
- package/skills/oma-image/resources/invocation.md +105 -0
- package/skills/oma-image/resources/prompt-tips.md +71 -0
- package/skills/oma-image/resources/vendor-matrix.md +90 -0
- package/skills/oma-market/SKILL.md +142 -0
- package/skills/oma-market/resources/checklist.md +12 -0
- package/skills/oma-market/resources/error-playbook.md +12 -0
- package/skills/oma-market/resources/execution-protocol.md +60 -0
- package/skills/oma-market/resources/frameworks/pestel.md +101 -0
- package/skills/oma-market/resources/frameworks/porters-5f.md +90 -0
- package/skills/oma-market/resources/frameworks/swot.md +61 -0
- package/skills/oma-market/resources/intent-rules.md +35 -0
- package/skills/oma-market/resources/output-laws.md +53 -0
- package/skills/oma-mobile/SKILL.md +168 -0
- package/skills/oma-mobile/resources/checklist.md +77 -0
- package/skills/oma-mobile/resources/error-playbook.md +198 -0
- package/skills/oma-mobile/resources/execution-protocol.md +47 -0
- package/skills/oma-mobile/resources/screen-template.dart +331 -0
- package/skills/oma-mobile/resources/screen-template.swift +239 -0
- package/skills/oma-mobile/resources/screen-template.tsx +179 -0
- package/skills/oma-mobile/resources/tech-stack.md +69 -0
- package/skills/oma-mobile/variants/README.md +45 -0
- package/skills/oma-mobile/variants/flutter/api-template.dart +461 -0
- package/skills/oma-mobile/variants/flutter/snippets.md +1353 -0
- package/skills/oma-mobile/variants/flutter/stack.yaml +21 -0
- package/skills/oma-mobile/variants/flutter/tech-stack.md +201 -0
- package/skills/oma-mobile/variants/kotlin-android/api-template.kt +182 -0
- package/skills/oma-mobile/variants/kotlin-android/snippets.md +871 -0
- package/skills/oma-mobile/variants/kotlin-android/stack.yaml +32 -0
- package/skills/oma-mobile/variants/kotlin-android/tech-stack.md +159 -0
- package/skills/oma-mobile/variants/react-native/api-template.ts +531 -0
- package/skills/oma-mobile/variants/react-native/snippets.md +1014 -0
- package/skills/oma-mobile/variants/react-native/stack.yaml +22 -0
- package/skills/oma-mobile/variants/react-native/tech-stack.md +192 -0
- package/skills/oma-mobile/variants/stack.schema.json +68 -0
- package/skills/oma-mobile/variants/swift-ios/api-template.swift +165 -0
- package/skills/oma-mobile/variants/swift-ios/snippets.md +1028 -0
- package/skills/oma-mobile/variants/swift-ios/stack.yaml +25 -0
- package/skills/oma-mobile/variants/swift-ios/tech-stack.md +174 -0
- package/skills/oma-observability/SKILL.md +225 -0
- package/skills/oma-observability/resources/anti-patterns.md +555 -0
- package/skills/oma-observability/resources/boundaries/cross-application.md +348 -0
- package/skills/oma-observability/resources/boundaries/multi-tenant.md +290 -0
- package/skills/oma-observability/resources/boundaries/release.md +372 -0
- package/skills/oma-observability/resources/boundaries/slo.md +225 -0
- package/skills/oma-observability/resources/checklist.md +123 -0
- package/skills/oma-observability/resources/examples.md +448 -0
- package/skills/oma-observability/resources/execution-protocol.md +166 -0
- package/skills/oma-observability/resources/incident-forensics.md +348 -0
- package/skills/oma-observability/resources/intent-rules.md +307 -0
- package/skills/oma-observability/resources/layers/L3-network.md +281 -0
- package/skills/oma-observability/resources/layers/L4-transport.md +299 -0
- package/skills/oma-observability/resources/layers/L7-application/crash-analytics.md +359 -0
- package/skills/oma-observability/resources/layers/L7-application/mobile-rum.md +278 -0
- package/skills/oma-observability/resources/layers/L7-application/waf.md +246 -0
- package/skills/oma-observability/resources/layers/L7-application/web-rum.md +350 -0
- package/skills/oma-observability/resources/layers/mesh.md +368 -0
- package/skills/oma-observability/resources/matrix.md +177 -0
- package/skills/oma-observability/resources/meta-observability.md +605 -0
- package/skills/oma-observability/resources/observability-as-code.md +666 -0
- package/skills/oma-observability/resources/signals/audit.md +310 -0
- package/skills/oma-observability/resources/signals/cost.md +303 -0
- package/skills/oma-observability/resources/signals/logs.md +263 -0
- package/skills/oma-observability/resources/signals/metrics.md +478 -0
- package/skills/oma-observability/resources/signals/privacy.md +351 -0
- package/skills/oma-observability/resources/signals/profiles.md +245 -0
- package/skills/oma-observability/resources/signals/traces.md +360 -0
- package/skills/oma-observability/resources/standards.md +214 -0
- package/skills/oma-observability/resources/transport/collector-topology.md +308 -0
- package/skills/oma-observability/resources/transport/otlp-grpc-vs-http.md +256 -0
- package/skills/oma-observability/resources/transport/sampling-recipes.md +297 -0
- package/skills/oma-observability/resources/transport/udp-statsd-mtu.md +167 -0
- package/skills/oma-observability/resources/vendor-categories.md +352 -0
- package/skills/oma-orchestration/SKILL.md +302 -0
- package/skills/oma-orchestration/config/cli-config.yaml +92 -0
- package/skills/oma-orchestration/resources/memory-schema.md +232 -0
- package/skills/oma-orchestration/resources/subagent-prompt-template.md +30 -0
- package/skills/oma-orchestration/scripts/parallel-run.sh +6 -0
- package/skills/oma-orchestration/scripts/spawn-agent.sh +5 -0
- package/skills/oma-orchestration/scripts/verify.sh +20 -0
- package/skills/oma-orchestration/templates/backend-task.md +19 -0
- package/skills/oma-orchestration/templates/debug-task.md +16 -0
- package/skills/oma-orchestration/templates/frontend-task.md +18 -0
- package/skills/oma-orchestration/templates/mobile-task.md +18 -0
- package/skills/oma-orchestration/templates/qa-task.md +16 -0
- package/skills/oma-orchestration/templates/tasks-example.yaml +15 -0
- package/skills/oma-pdf/SKILL.md +162 -0
- package/skills/oma-pdf/config/pdf-config.yaml +27 -0
- package/skills/oma-pdf/resources/execution-protocol.md +127 -0
- package/skills/oma-pm/SKILL.md +157 -0
- package/skills/oma-pm/resources/error-playbook.md +75 -0
- package/skills/oma-pm/resources/examples.md +439 -0
- package/skills/oma-pm/resources/execution-protocol.md +62 -0
- package/skills/oma-pm/resources/iso-planning.md +110 -0
- package/skills/oma-pm/resources/plan-phase-protocol.md +66 -0
- package/skills/oma-pm/resources/task-template.json +86 -0
- package/skills/oma-qa/SKILL.md +141 -0
- package/skills/oma-qa/resources/checklist.md +339 -0
- package/skills/oma-qa/resources/error-playbook.md +98 -0
- package/skills/oma-qa/resources/execution-protocol.md +169 -0
- package/skills/oma-qa/resources/iso-quality.md +82 -0
- package/skills/oma-qa/resources/verify-ship-protocol.md +116 -0
- package/skills/oma-recap/SKILL.md +245 -0
- package/skills/oma-recap/resources/output-formats.md +83 -0
- package/skills/oma-refactor/SKILL.md +184 -0
- package/skills/oma-refactor/resources/definition.md +108 -0
- package/skills/oma-refactor/resources/governance.md +33 -0
- package/skills/oma-refactor/resources/hotspots.py +44 -0
- package/skills/oma-refactor/resources/measurement.md +93 -0
- package/skills/oma-scholar/SKILL.md +276 -0
- package/skills/oma-scholar/config/scholar-config.yaml +79 -0
- package/skills/oma-scholar/resources/api-endpoints.md +174 -0
- package/skills/oma-scholar/resources/checklist.md +92 -0
- package/skills/oma-scholar/resources/execution-protocol.md +224 -0
- package/skills/oma-scholar/resources/fallback-providers.md +143 -0
- package/skills/oma-scholar/resources/setup-openalex.md +85 -0
- package/skills/oma-scholar/resources/sidecar-spec.md +341 -0
- package/skills/oma-scholar/resources/upstream-spec-cache.md +972 -0
- package/skills/oma-scm/SKILL.md +103 -0
- package/skills/oma-scm/resources/cm-operations.md +56 -0
- package/skills/oma-scm/resources/codeowners-playbook.md +45 -0
- package/skills/oma-scm/resources/conventional-commits.md +108 -0
- package/skills/oma-scm/resources/merge-risk.md +76 -0
- package/skills/oma-scm/resources/onboarding-risk-signals.md +71 -0
- package/skills/oma-search/SKILL.md +125 -0
- package/skills/oma-search/resources/checklist.md +36 -0
- package/skills/oma-search/resources/error-playbook.md +66 -0
- package/skills/oma-search/resources/examples.md +80 -0
- package/skills/oma-search/resources/execution-protocol.md +86 -0
- package/skills/oma-search/resources/intent-rules.md +47 -0
- package/skills/oma-search/resources/trust-registry.md +53 -0
- package/skills/oma-security/SKILL.md +172 -0
- package/skills/oma-security/resources/artex.md +108 -0
- package/skills/oma-security/resources/ci.md +134 -0
- package/skills/oma-security/resources/cisco-source.md +108 -0
- package/skills/oma-security/resources/deepsec-config.md +45 -0
- package/skills/oma-security/resources/deepsec-matchers.md +35 -0
- package/skills/oma-security/resources/deepsec-scanning.md +45 -0
- package/skills/oma-security/resources/deepsec-setup.md +41 -0
- package/skills/oma-security/resources/findings-contract.md +408 -0
- package/skills/oma-security/resources/mcp-scanning.md +122 -0
- package/skills/oma-security/resources/skill-scanning.md +106 -0
- package/skills/oma-security/resources/validation.md +71 -0
- package/skills/oma-skill-creation/SKILL.md +84 -0
- package/skills/oma-skill-creation/resources/prompt-evaluation.md +36 -0
- package/skills/oma-skill-creation/resources/ssl-lite-template.md +108 -0
- package/skills/oma-skill-creation/resources/validation-checklist.md +101 -0
- package/skills/oma-slide/SKILL.md +277 -0
- package/skills/oma-slide/resources/animation-patterns.md +245 -0
- package/skills/oma-slide/resources/assets/deck-stage.js +514 -0
- package/skills/oma-slide/resources/assets/viewport-base.css +257 -0
- package/skills/oma-slide/resources/canva-integration.md +203 -0
- package/skills/oma-slide/resources/checklist.md +46 -0
- package/skills/oma-slide/resources/design-doctrine.md +260 -0
- package/skills/oma-slide/resources/fixed-stage.md +232 -0
- package/skills/oma-slide/resources/generation-protocol.md +393 -0
- package/skills/oma-slide/resources/native-pptx.md +84 -0
- package/skills/oma-slide/resources/selection-index.json +461 -0
- package/skills/oma-slide/resources/style-presets.LICENSE +10 -0
- package/skills/oma-slide/resources/style-presets.md +520 -0
- package/skills/oma-tf-infra/SKILL.md +201 -0
- package/skills/oma-tf-infra/resources/checklist.md +53 -0
- package/skills/oma-tf-infra/resources/cost-optimization.md +574 -0
- package/skills/oma-tf-infra/resources/error-playbook.md +68 -0
- package/skills/oma-tf-infra/resources/examples.md +38 -0
- package/skills/oma-tf-infra/resources/execution-protocol.md +129 -0
- package/skills/oma-tf-infra/resources/iso-42001-infra.md +229 -0
- package/skills/oma-tf-infra/resources/multi-cloud-examples.md +241 -0
- package/skills/oma-tf-infra/resources/policy-testing-examples.md +332 -0
- package/skills/oma-translation/SKILL.md +229 -0
- package/skills/oma-translation/resources/anti-ai-patterns.md +104 -0
- package/skills/oma-translation/resources/lang/_template.md +73 -0
- package/skills/oma-translation/resources/lang/en.md +123 -0
- package/skills/oma-translation/resources/lang/ja.md +274 -0
- package/skills/oma-translation/resources/lang/ko.md +228 -0
- package/skills/oma-translation/resources/lang/zh.md +285 -0
- package/skills/oma-translation/resources/style-skills.md +104 -0
- package/skills/oma-translation/resources/translation-rubric.md +126 -0
- package/skills/oma-video/SKILL.md +229 -0
- package/skills/oma-video/config/video-config.yaml +60 -0
- package/skills/oma-video/resources/checklist.md +26 -0
- package/skills/oma-video/resources/execution-protocol.md +122 -0
- package/skills/oma-video/resources/existing-media.md +185 -0
- package/skills/oma-video/resources/hyperframes-authoring/README.md +44 -0
- package/skills/oma-video/resources/hyperframes-authoring/demo.md +31 -0
- package/skills/oma-video/resources/hyperframes-authoring/explainer.md +10 -0
- package/skills/oma-video/resources/hyperframes-authoring/shorts.md +10 -0
- package/skills/oma-video/resources/motion-principles.md +81 -0
- package/skills/oma-video/resources/mpt/driver.py +301 -0
- package/skills/oma-video/resources/prompt-tips.md +75 -0
- package/skills/oma-video/resources/script-schema.md +81 -0
- package/skills/oma-video/resources/sound-design.md +127 -0
- package/skills/oma-video/resources/strudel/package.json +11 -0
- package/skills/oma-video/resources/strudel/page.html +87 -0
- package/skills/oma-video/resources/strudel/render.mjs +295 -0
- package/skills/oma-video/resources/vendor-matrix.md +124 -0
- package/skills/oma-voice/SKILL.md +249 -0
- package/skills/oma-voice/config/voice-config.yaml +30 -0
- package/skills/oma-voice/resources/audio-processing.md +119 -0
- package/skills/oma-voice/resources/checklist.md +47 -0
- package/skills/oma-voice/resources/execution-protocol.md +215 -0
- package/skills/oma-voice/resources/prompt-tips.md +43 -0
- package/skills/oma-voice/resources/voice-matrix.md +51 -0
- package/skills/oma-voice/resources/voicestudio.md +87 -0
|
@@ -0,0 +1,1028 @@
|
|
|
1
|
+
# Mobile Agent - Code Snippets (Swift iOS Native)
|
|
2
|
+
|
|
3
|
+
Copy-paste ready patterns. Use these as starting points; adapt to the specific task.
|
|
4
|
+
Always use the generated `Client` — never hand-roll `URLRequest`/`JSONDecoder` for API calls.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## 1. Package.swift with OpenAPI Build Plugin
|
|
9
|
+
|
|
10
|
+
```swift
|
|
11
|
+
// swift-tools-version: 5.9
|
|
12
|
+
import PackageDescription
|
|
13
|
+
|
|
14
|
+
let package = Package(
|
|
15
|
+
name: "MyApp",
|
|
16
|
+
platforms: [.iOS(.v17)],
|
|
17
|
+
dependencies: [
|
|
18
|
+
// Code-generation build plugin (dev / build-time only)
|
|
19
|
+
.package(
|
|
20
|
+
url: "https://github.com/apple/swift-openapi-generator",
|
|
21
|
+
from: "1.3.0"
|
|
22
|
+
),
|
|
23
|
+
// Runtime types used by the generated Client
|
|
24
|
+
.package(
|
|
25
|
+
url: "https://github.com/apple/swift-openapi-runtime",
|
|
26
|
+
from: "1.5.0"
|
|
27
|
+
),
|
|
28
|
+
// URLSession transport
|
|
29
|
+
.package(
|
|
30
|
+
url: "https://github.com/apple/swift-openapi-urlsession",
|
|
31
|
+
from: "1.0.2"
|
|
32
|
+
),
|
|
33
|
+
// Repository-layer response cache (hybrid memory + disk)
|
|
34
|
+
.package(
|
|
35
|
+
url: "https://github.com/hyperoslo/Cache",
|
|
36
|
+
from: "7.4.0"
|
|
37
|
+
),
|
|
38
|
+
],
|
|
39
|
+
targets: [
|
|
40
|
+
.target(
|
|
41
|
+
name: "MyApp",
|
|
42
|
+
dependencies: [
|
|
43
|
+
.product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"),
|
|
44
|
+
.product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"),
|
|
45
|
+
.product(name: "Cache", package: "Cache"),
|
|
46
|
+
],
|
|
47
|
+
// The generator discovers openapi.yaml + openapi-generator-config.yaml
|
|
48
|
+
// inside this target's source directory and runs at every `swift build`.
|
|
49
|
+
plugins: [
|
|
50
|
+
.plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator"),
|
|
51
|
+
]
|
|
52
|
+
),
|
|
53
|
+
.testTarget(
|
|
54
|
+
name: "MyAppTests",
|
|
55
|
+
dependencies: ["MyApp"]
|
|
56
|
+
),
|
|
57
|
+
]
|
|
58
|
+
)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## 2. openapi-generator-config.yaml
|
|
64
|
+
|
|
65
|
+
```yaml
|
|
66
|
+
# Core/Networking/openapi-generator-config.yaml
|
|
67
|
+
# Placed alongside openapi.yaml inside the target source directory.
|
|
68
|
+
generate:
|
|
69
|
+
- types
|
|
70
|
+
- client
|
|
71
|
+
accessModifier: public
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## 3. @Observable View Model
|
|
77
|
+
|
|
78
|
+
```swift
|
|
79
|
+
// Features/Todos/TodosViewModel.swift
|
|
80
|
+
import Foundation
|
|
81
|
+
import Observation
|
|
82
|
+
|
|
83
|
+
/// Possible states for the Todos screen.
|
|
84
|
+
enum TodosViewState {
|
|
85
|
+
case idle
|
|
86
|
+
case loading
|
|
87
|
+
case loaded([Components.Schemas.Todo])
|
|
88
|
+
case empty
|
|
89
|
+
case error(String)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@MainActor
|
|
93
|
+
@Observable
|
|
94
|
+
final class TodosViewModel {
|
|
95
|
+
// MARK: - Published state (observed by the View automatically)
|
|
96
|
+
var viewState: TodosViewState = .idle
|
|
97
|
+
|
|
98
|
+
// MARK: - Private
|
|
99
|
+
// Depend on the protocol seam, not the concrete service — enables protocol-based
|
|
100
|
+
// mocking in tests without a third-party mock lib (see §8).
|
|
101
|
+
private let service: any TodoProviding
|
|
102
|
+
// Retained for cancellation; tests await load() directly (see §8).
|
|
103
|
+
private(set) var loadTask: Task<Void, Never>?
|
|
104
|
+
|
|
105
|
+
init(service: any TodoProviding) {
|
|
106
|
+
self.service = service
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// MARK: - Intent
|
|
110
|
+
|
|
111
|
+
func load() async {
|
|
112
|
+
// Cancel any in-flight task before starting a new one.
|
|
113
|
+
loadTask?.cancel()
|
|
114
|
+
viewState = .loading
|
|
115
|
+
|
|
116
|
+
let task = Task { [weak self] in
|
|
117
|
+
guard let self else { return }
|
|
118
|
+
do {
|
|
119
|
+
// Stale-while-revalidate: the stream yields the cached list first
|
|
120
|
+
// (instant render), then the revalidated list. State updates per yield.
|
|
121
|
+
let stream = await self.service.todosStream()
|
|
122
|
+
for try await todos in stream {
|
|
123
|
+
guard !Task.isCancelled else { return }
|
|
124
|
+
self.viewState = todos.isEmpty ? .empty : .loaded(todos)
|
|
125
|
+
}
|
|
126
|
+
} catch is CancellationError {
|
|
127
|
+
// Silently ignore — another load will follow.
|
|
128
|
+
} catch {
|
|
129
|
+
guard !Task.isCancelled else { return }
|
|
130
|
+
self.viewState = .error(error.localizedDescription)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
loadTask = task
|
|
134
|
+
await withTaskCancellationHandler {
|
|
135
|
+
await task.value
|
|
136
|
+
} onCancel: {
|
|
137
|
+
task.cancel()
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
func retry() async { await load() }
|
|
142
|
+
|
|
143
|
+
/// Cancels button/retry loads too; awaited .task forwards caller cancellation.
|
|
144
|
+
func cancelLoad() { loadTask?.cancel() }
|
|
145
|
+
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The async load waits for its task and forwards caller cancellation to that task.
|
|
150
|
+
The view also cancels button/retry loads in `.onDisappear`. After `guard let self`,
|
|
151
|
+
the task temporarily retains the view model; a weak capture or `deinit` does not
|
|
152
|
+
stop an active request.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## 4. SwiftUI Feature View
|
|
157
|
+
|
|
158
|
+
```swift
|
|
159
|
+
// Features/Todos/TodosView.swift
|
|
160
|
+
import SwiftUI
|
|
161
|
+
|
|
162
|
+
struct TodosView: View {
|
|
163
|
+
// @State owns the view model; the View is the allocation site.
|
|
164
|
+
@State private var viewModel: TodosViewModel
|
|
165
|
+
|
|
166
|
+
init(service: any TodoProviding) {
|
|
167
|
+
_viewModel = State(wrappedValue: TodosViewModel(service: service))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
var body: some View {
|
|
171
|
+
// The router owns the enclosing NavigationStack(path:) (see §9); a feature
|
|
172
|
+
// view assumes it is already inside one and never wraps itself in a stack.
|
|
173
|
+
content
|
|
174
|
+
.navigationTitle("Todos")
|
|
175
|
+
.task { await viewModel.load() }
|
|
176
|
+
.onDisappear { viewModel.cancelLoad() }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// MARK: - Content switch
|
|
180
|
+
|
|
181
|
+
@ViewBuilder
|
|
182
|
+
private var content: some View {
|
|
183
|
+
switch viewModel.viewState {
|
|
184
|
+
case .idle, .loading:
|
|
185
|
+
loadingView
|
|
186
|
+
|
|
187
|
+
case .loaded(let todos):
|
|
188
|
+
todoList(todos)
|
|
189
|
+
|
|
190
|
+
case .empty:
|
|
191
|
+
emptyView
|
|
192
|
+
|
|
193
|
+
case .error(let message):
|
|
194
|
+
errorView(message)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// MARK: - State views
|
|
199
|
+
|
|
200
|
+
private var loadingView: some View {
|
|
201
|
+
ProgressView("Loading…")
|
|
202
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private func todoList(_ todos: [Components.Schemas.Todo]) -> some View {
|
|
206
|
+
List(todos, id: \.id) { todo in
|
|
207
|
+
Label(todo.title, systemImage: todo.completed ? "checkmark.circle.fill" : "circle")
|
|
208
|
+
}
|
|
209
|
+
.refreshable { await viewModel.load() }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private var emptyView: some View {
|
|
213
|
+
ContentUnavailableView(
|
|
214
|
+
"No Todos",
|
|
215
|
+
systemImage: "tray",
|
|
216
|
+
description: Text("Add your first todo to get started.")
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private func errorView(_ message: String) -> some View {
|
|
221
|
+
VStack(spacing: 16) {
|
|
222
|
+
Image(systemName: "exclamationmark.triangle")
|
|
223
|
+
.font(.largeTitle)
|
|
224
|
+
.foregroundStyle(.red)
|
|
225
|
+
Text(message)
|
|
226
|
+
.multilineTextAlignment(.center)
|
|
227
|
+
Button("Retry") { Task { await viewModel.retry() } }
|
|
228
|
+
.buttonStyle(.borderedProminent)
|
|
229
|
+
}
|
|
230
|
+
.padding()
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## 5. Core/Networking API Service (wrapping the generated Client)
|
|
238
|
+
|
|
239
|
+
```swift
|
|
240
|
+
// Core/Networking/APIClient.swift
|
|
241
|
+
import Foundation
|
|
242
|
+
import OpenAPIRuntime
|
|
243
|
+
import OpenAPIURLSession
|
|
244
|
+
|
|
245
|
+
/// Configures the generated Client with the server URL and auth middleware.
|
|
246
|
+
/// Inject this as a singleton from App/AppDependencies.swift.
|
|
247
|
+
public final class APIClient {
|
|
248
|
+
public let client: Client
|
|
249
|
+
|
|
250
|
+
public init(serverURL: URL, tokenProvider: @escaping () -> String?) {
|
|
251
|
+
let transport = URLSessionTransport()
|
|
252
|
+
let authMiddleware = BearerAuthMiddleware(tokenProvider: tokenProvider)
|
|
253
|
+
// The generated Client initialiser is non-throwing — no `try` needed.
|
|
254
|
+
self.client = Client(
|
|
255
|
+
serverURL: serverURL,
|
|
256
|
+
transport: transport,
|
|
257
|
+
middlewares: [authMiddleware]
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// Core/Networking/BearerAuthMiddleware.swift
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
import OpenAPIRuntime
|
|
267
|
+
import HTTPTypes
|
|
268
|
+
|
|
269
|
+
/// Injects a bearer token into every outgoing request.
|
|
270
|
+
public struct BearerAuthMiddleware: ClientMiddleware {
|
|
271
|
+
private let tokenProvider: () -> String?
|
|
272
|
+
|
|
273
|
+
public init(tokenProvider: @escaping () -> String?) {
|
|
274
|
+
self.tokenProvider = tokenProvider
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
public func intercept(
|
|
278
|
+
_ request: HTTPRequest,
|
|
279
|
+
body: HTTPBody?,
|
|
280
|
+
baseURL: URL,
|
|
281
|
+
operationID: String,
|
|
282
|
+
next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)
|
|
283
|
+
) async throws -> (HTTPResponse, HTTPBody?) {
|
|
284
|
+
var request = request
|
|
285
|
+
if let token = tokenProvider() {
|
|
286
|
+
request.headerFields[.authorization] = "Bearer \(token)"
|
|
287
|
+
}
|
|
288
|
+
return try await next(request, body, baseURL)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## 6. Generated-Client Call Pattern
|
|
296
|
+
|
|
297
|
+
> Isolates the raw generated-`Client` call + response mapping — this is the **inner
|
|
298
|
+
> call shape** only. The **production** service is the cached `TodoService` in §10
|
|
299
|
+
> that wraps this shape with a `ResponseCache` (read-through + invalidation). Use the
|
|
300
|
+
> cached form for real features; this type is named `TodoServiceUncached` so it never
|
|
301
|
+
> collides with §10's `TodoService`.
|
|
302
|
+
|
|
303
|
+
```swift
|
|
304
|
+
// Core/Networking/TodoServiceUncached.swift (excerpt showing call + response handling)
|
|
305
|
+
import OpenAPIRuntime
|
|
306
|
+
|
|
307
|
+
public final class TodoServiceUncached {
|
|
308
|
+
private let client: Client
|
|
309
|
+
|
|
310
|
+
public init(client: Client) {
|
|
311
|
+
self.client = client
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/// List all todos for the authenticated user.
|
|
315
|
+
public func listTodos() async throws -> [Components.Schemas.Todo] {
|
|
316
|
+
// Use the generated operation initialiser — never construct URLRequest by hand.
|
|
317
|
+
let response = try await client.listTodos(.init())
|
|
318
|
+
|
|
319
|
+
switch response {
|
|
320
|
+
case .ok(let ok):
|
|
321
|
+
// Decode the typed body; the generator guarantees the shape.
|
|
322
|
+
return try ok.body.json
|
|
323
|
+
case .undocumented(let statusCode, _):
|
|
324
|
+
throw APIError.undocumented(statusCode: statusCode)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
public enum APIError: Error {
|
|
329
|
+
case undocumented(statusCode: Int)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
---
|
|
335
|
+
|
|
336
|
+
## 7. App Entry Point and Dependency Injection
|
|
337
|
+
|
|
338
|
+
```swift
|
|
339
|
+
// App/MyApp.swift
|
|
340
|
+
import SwiftUI
|
|
341
|
+
|
|
342
|
+
@main
|
|
343
|
+
struct MyApp: App {
|
|
344
|
+
// Composition root: build the dependency graph once at launch.
|
|
345
|
+
private let dependencies = AppDependencies()
|
|
346
|
+
|
|
347
|
+
var body: some Scene {
|
|
348
|
+
WindowGroup {
|
|
349
|
+
// Pass the concrete service down; Views never import Core directly.
|
|
350
|
+
TodosView(service: dependencies.todoService)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
// Core/Security/KeychainTokenStore.swift
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
import Foundation
|
|
359
|
+
import Security
|
|
360
|
+
|
|
361
|
+
/// Minimal Keychain-backed token store — no third-party dependency.
|
|
362
|
+
/// Secrets (access tokens, refresh tokens) belong in the Keychain, never in
|
|
363
|
+
/// `UserDefaults`, which is an unencrypted plist readable from a device backup.
|
|
364
|
+
struct KeychainTokenStore {
|
|
365
|
+
let service: String // e.g. Bundle.main.bundleIdentifier ?? "MyApp"
|
|
366
|
+
|
|
367
|
+
/// Read a stored token, or nil if absent.
|
|
368
|
+
func token(account: String = "accessToken") -> String? {
|
|
369
|
+
let query: [String: Any] = [
|
|
370
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
371
|
+
kSecAttrService as String: service,
|
|
372
|
+
kSecAttrAccount as String: account,
|
|
373
|
+
kSecReturnData as String: true,
|
|
374
|
+
kSecMatchLimit as String: kSecMatchLimitOne,
|
|
375
|
+
]
|
|
376
|
+
var result: AnyObject?
|
|
377
|
+
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
|
378
|
+
let data = result as? Data else { return nil }
|
|
379
|
+
return String(data: data, encoding: .utf8)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/// Insert or overwrite a token (delete-then-add keeps it idempotent).
|
|
383
|
+
func setToken(_ value: String, account: String = "accessToken") {
|
|
384
|
+
let base: [String: Any] = [
|
|
385
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
386
|
+
kSecAttrService as String: service,
|
|
387
|
+
kSecAttrAccount as String: account,
|
|
388
|
+
]
|
|
389
|
+
SecItemDelete(base as CFDictionary)
|
|
390
|
+
var add = base
|
|
391
|
+
add[kSecValueData as String] = Data(value.utf8)
|
|
392
|
+
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
|
393
|
+
SecItemAdd(add as CFDictionary, nil)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/// Remove a stored token (e.g. on sign-out).
|
|
397
|
+
func deleteToken(account: String = "accessToken") {
|
|
398
|
+
let query: [String: Any] = [
|
|
399
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
400
|
+
kSecAttrService as String: service,
|
|
401
|
+
kSecAttrAccount as String: account,
|
|
402
|
+
]
|
|
403
|
+
SecItemDelete(query as CFDictionary)
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// App/AppDependencies.swift
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
import Foundation
|
|
411
|
+
import OSLog
|
|
412
|
+
|
|
413
|
+
/// Builds and owns shared singletons. Constructed once in @main.
|
|
414
|
+
final class AppDependencies {
|
|
415
|
+
let todoService: TodoService
|
|
416
|
+
init() {
|
|
417
|
+
let logger = Logger(
|
|
418
|
+
subsystem: Bundle.main.bundleIdentifier ?? "MyApp",
|
|
419
|
+
category: "ResponseCache"
|
|
420
|
+
)
|
|
421
|
+
let serverURL = URL(string: ProcessInfo.processInfo.environment["API_BASE_URL"]
|
|
422
|
+
?? "https://api.example.com")!
|
|
423
|
+
// Secrets come from the Keychain — never UserDefaults (see tech-stack.md).
|
|
424
|
+
let tokens = KeychainTokenStore(service: Bundle.main.bundleIdentifier ?? "MyApp")
|
|
425
|
+
let apiClient = APIClient(serverURL: serverURL, tokenProvider: {
|
|
426
|
+
tokens.token()
|
|
427
|
+
})
|
|
428
|
+
// `accountID` is a stable, non-secret server identifier, never a token or email.
|
|
429
|
+
let accountID = "current-account-id" // Obtain from validated auth/session claims.
|
|
430
|
+
let todoCache: ResponseCache<[Components.Schemas.Todo]>?
|
|
431
|
+
do {
|
|
432
|
+
todoCache = try ResponseCache(name: "todos-\(accountID)")
|
|
433
|
+
} catch {
|
|
434
|
+
// Caching is optional. Do not log the account ID, token, or storage path.
|
|
435
|
+
logger.notice("Response cache unavailable; continuing network-only")
|
|
436
|
+
todoCache = nil
|
|
437
|
+
}
|
|
438
|
+
self.todoService = TodoService(client: apiClient.client, cache: todoCache, accountID: accountID)
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
```swift
|
|
444
|
+
// App/AccountServiceController.swift
|
|
445
|
+
// Publish a newly built service only after the old account has been closed.
|
|
446
|
+
@MainActor
|
|
447
|
+
final class AccountServiceController {
|
|
448
|
+
private(set) var todoService: TodoService?
|
|
449
|
+
private var transitionGeneration = 0
|
|
450
|
+
|
|
451
|
+
func switchAccount(to replacement: TodoService) async {
|
|
452
|
+
transitionGeneration += 1
|
|
453
|
+
let generation = transitionGeneration
|
|
454
|
+
let current = todoService
|
|
455
|
+
// Remove the old service from the UI before awaiting; another transition may begin.
|
|
456
|
+
todoService = nil
|
|
457
|
+
if let current {
|
|
458
|
+
await current.close() // cancels in-flight reads and purges this account's cache
|
|
459
|
+
}
|
|
460
|
+
guard generation == transitionGeneration else {
|
|
461
|
+
await replacement.close()
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
todoService = replacement // replacement has a new client and account-scoped cache
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
func signOut() async {
|
|
468
|
+
transitionGeneration += 1
|
|
469
|
+
let current = todoService
|
|
470
|
+
todoService = nil
|
|
471
|
+
if let current { await current.close() }
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
Build `replacement` with the new account's credentials and non-secret account identifier. Do not
|
|
477
|
+
reuse a service, token provider, or disk namespace across accounts.
|
|
478
|
+
|
|
479
|
+
---
|
|
480
|
+
|
|
481
|
+
## 8. XCTest Unit Test for the View Model
|
|
482
|
+
|
|
483
|
+
```swift
|
|
484
|
+
// Tests/TodosViewModelTests.swift
|
|
485
|
+
import XCTest
|
|
486
|
+
@testable import MyApp
|
|
487
|
+
|
|
488
|
+
// MARK: - Mock (protocol-based — no subclassing, no third-party mock lib)
|
|
489
|
+
|
|
490
|
+
final class MockTodoService: TodoProviding, @unchecked Sendable {
|
|
491
|
+
var stubbedTodos: [Components.Schemas.Todo] = []
|
|
492
|
+
var shouldThrow: Error?
|
|
493
|
+
|
|
494
|
+
func todosStream() async -> AsyncThrowingStream<[Components.Schemas.Todo], Error> {
|
|
495
|
+
AsyncThrowingStream { continuation in
|
|
496
|
+
if let error = shouldThrow {
|
|
497
|
+
continuation.finish(throwing: error)
|
|
498
|
+
} else {
|
|
499
|
+
continuation.yield(stubbedTodos)
|
|
500
|
+
continuation.finish()
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
func createTodo(title: String) async throws -> Components.Schemas.Todo {
|
|
506
|
+
if let shouldThrow { throw shouldThrow }
|
|
507
|
+
return .init(id: "new", title: title, completed: false)
|
|
508
|
+
}
|
|
509
|
+
func toggleTodo(id: String) async throws -> Components.Schemas.Todo {
|
|
510
|
+
if let shouldThrow { throw shouldThrow }
|
|
511
|
+
return .init(id: id, title: "", completed: true)
|
|
512
|
+
}
|
|
513
|
+
func deleteTodo(id: String) async throws {
|
|
514
|
+
if let shouldThrow { throw shouldThrow }
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// MARK: - Tests
|
|
519
|
+
|
|
520
|
+
// The view model is `@MainActor`, so the test methods are too. Instead of polling
|
|
521
|
+
// with `Task.sleep`, await `load()`; assertions run after the load has finished.
|
|
522
|
+
@MainActor
|
|
523
|
+
final class TodosViewModelTests: XCTestCase {
|
|
524
|
+
// Test that a successful response transitions to .loaded.
|
|
525
|
+
func testLoad_success_transitionsToLoaded() async {
|
|
526
|
+
let mock = MockTodoService()
|
|
527
|
+
mock.stubbedTodos = [
|
|
528
|
+
.init(id: "1", title: "Buy milk", completed: false),
|
|
529
|
+
]
|
|
530
|
+
let sut = TodosViewModel(service: mock)
|
|
531
|
+
|
|
532
|
+
await sut.load()
|
|
533
|
+
|
|
534
|
+
guard case .loaded(let todos) = sut.viewState else {
|
|
535
|
+
return XCTFail("Expected .loaded, got \(sut.viewState)")
|
|
536
|
+
}
|
|
537
|
+
XCTAssertEqual(todos.count, 1)
|
|
538
|
+
XCTAssertEqual(todos[0].title, "Buy milk")
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Test that an empty response transitions to .empty.
|
|
542
|
+
func testLoad_emptyResponse_transitionsToEmpty() async {
|
|
543
|
+
let mock = MockTodoService()
|
|
544
|
+
mock.stubbedTodos = []
|
|
545
|
+
let sut = TodosViewModel(service: mock)
|
|
546
|
+
|
|
547
|
+
await sut.load()
|
|
548
|
+
|
|
549
|
+
guard case .empty = sut.viewState else {
|
|
550
|
+
return XCTFail("Expected .empty, got \(sut.viewState)")
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Test that a thrown error transitions to .error.
|
|
555
|
+
func testLoad_networkError_transitionsToError() async {
|
|
556
|
+
let mock = MockTodoService()
|
|
557
|
+
mock.shouldThrow = URLError(.notConnectedToInternet)
|
|
558
|
+
let sut = TodosViewModel(service: mock)
|
|
559
|
+
|
|
560
|
+
await sut.load()
|
|
561
|
+
|
|
562
|
+
guard case .error = sut.viewState else {
|
|
563
|
+
return XCTFail("Expected .error, got \(sut.viewState)")
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
---
|
|
570
|
+
|
|
571
|
+
## 9. Navigation: interactive swipe-back on nav-bar-hidden routes
|
|
572
|
+
|
|
573
|
+
**Root cause.** `NavigationStack` is backed by UIKit's `UINavigationController`, and
|
|
574
|
+
the edge swipe-back is its `interactivePopGestureRecognizer`. UIKit ties that
|
|
575
|
+
recognizer to the system back button, so any screen that hides the nav bar
|
|
576
|
+
(`.toolbar(.hidden, for: .navigationBar)` — common with custom headers) loses the
|
|
577
|
+
swipe gesture, and UIKit re-disables it every time such a screen becomes top.
|
|
578
|
+
|
|
579
|
+
**Standard.** Swipe-back restoration is a navigation-layer concern, so restore it
|
|
580
|
+
once at the **route-registration layer** (`navigationDestination`), not per screen.
|
|
581
|
+
Pushed destinations always have something to pop to; tab roots aren't
|
|
582
|
+
`navigationDestination`s, so they're correctly excluded for free.
|
|
583
|
+
|
|
584
|
+
```swift
|
|
585
|
+
// Shared/InteractiveSwipeBack.swift
|
|
586
|
+
import SwiftUI
|
|
587
|
+
import UIKit
|
|
588
|
+
|
|
589
|
+
/// Reaches the enclosing UINavigationController, takes over the pop recognizer's
|
|
590
|
+
/// delegate, and re-enables the edge swipe — scoped to the screen that asks for
|
|
591
|
+
/// it (re-runs on every appear because UIKit re-disables it on nav-bar-hidden
|
|
592
|
+
/// transitions). `canPop`/`onBlocked` guard the pop (e.g. confirm unsaved edits).
|
|
593
|
+
private struct InteractiveSwipeBack: UIViewControllerRepresentable {
|
|
594
|
+
var canPop: () -> Bool
|
|
595
|
+
var onBlocked: () -> Void
|
|
596
|
+
|
|
597
|
+
func makeUIViewController(context: Context) -> UIViewController {
|
|
598
|
+
Controller(canPop: canPop, onBlocked: onBlocked)
|
|
599
|
+
}
|
|
600
|
+
func updateUIViewController(_ vc: UIViewController, context: Context) {
|
|
601
|
+
guard let c = vc as? Controller else { return }
|
|
602
|
+
c.canPop = canPop; c.onBlocked = onBlocked
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
final class Controller: UIViewController, UIGestureRecognizerDelegate {
|
|
606
|
+
var canPop: () -> Bool
|
|
607
|
+
var onBlocked: () -> Void
|
|
608
|
+
init(canPop: @escaping () -> Bool, onBlocked: @escaping () -> Void) {
|
|
609
|
+
self.canPop = canPop; self.onBlocked = onBlocked
|
|
610
|
+
super.init(nibName: nil, bundle: nil)
|
|
611
|
+
}
|
|
612
|
+
@available(*, unavailable) required init?(coder: NSCoder) { fatalError() }
|
|
613
|
+
|
|
614
|
+
override func didMove(toParent parent: UIViewController?) {
|
|
615
|
+
super.didMove(toParent: parent)
|
|
616
|
+
reassertSwipeBack()
|
|
617
|
+
}
|
|
618
|
+
// UIKit re-disables the recognizer on every nav-bar-hidden transition, so
|
|
619
|
+
// re-assert on each appear — not just once at insertion (`didMove`).
|
|
620
|
+
override func viewWillAppear(_ animated: Bool) {
|
|
621
|
+
super.viewWillAppear(animated)
|
|
622
|
+
reassertSwipeBack()
|
|
623
|
+
}
|
|
624
|
+
override func viewDidAppear(_ animated: Bool) {
|
|
625
|
+
super.viewDidAppear(animated)
|
|
626
|
+
reassertSwipeBack()
|
|
627
|
+
}
|
|
628
|
+
private func reassertSwipeBack() {
|
|
629
|
+
guard let r = navigationController?.interactivePopGestureRecognizer else { return }
|
|
630
|
+
r.delegate = self
|
|
631
|
+
r.isEnabled = true
|
|
632
|
+
}
|
|
633
|
+
func gestureRecognizerShouldBegin(_ g: UIGestureRecognizer) -> Bool {
|
|
634
|
+
guard (navigationController?.viewControllers.count ?? 0) > 1 else { return false }
|
|
635
|
+
if canPop() { return true }
|
|
636
|
+
onBlocked(); return false
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
extension View {
|
|
642
|
+
/// Restore edge swipe-back on a nav-bar-hidden screen.
|
|
643
|
+
func enableInteractiveSwipeBack() -> some View {
|
|
644
|
+
background(InteractiveSwipeBack(canPop: { true }, onBlocked: {}).frame(width: 0, height: 0))
|
|
645
|
+
}
|
|
646
|
+
/// Guarded variant: swallow the swipe and run `onBlocked` when `canPop` is false.
|
|
647
|
+
func interactiveSwipeBack(
|
|
648
|
+
canPop: @escaping () -> Bool,
|
|
649
|
+
onBlocked: @escaping () -> Void
|
|
650
|
+
) -> some View {
|
|
651
|
+
background(InteractiveSwipeBack(canPop: canPop, onBlocked: onBlocked).frame(width: 0, height: 0))
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/// PREFERRED registration: every pushed route gets swipe-back automatically.
|
|
655
|
+
/// Use this instead of bare `navigationDestination(for:)` for push routes.
|
|
656
|
+
func swipeBackDestination<D: Hashable, C: View>(
|
|
657
|
+
for type: D.Type,
|
|
658
|
+
@ViewBuilder destination: @escaping (D) -> C
|
|
659
|
+
) -> some View {
|
|
660
|
+
navigationDestination(for: type) { value in
|
|
661
|
+
destination(value).enableInteractiveSwipeBack()
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
```swift
|
|
668
|
+
// Usage — register routes through the wrapper, not bare navigationDestination:
|
|
669
|
+
NavigationStack(path: $router.path) {
|
|
670
|
+
RootView()
|
|
671
|
+
.swipeBackDestination(for: ProgramID.self) { ProgramDetailView(id: $0) }
|
|
672
|
+
.swipeBackDestination(for: SettingsRoute.self) { _ in SettingsView() }
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Only screens with a custom pop policy override explicitly:
|
|
676
|
+
NewPostView()
|
|
677
|
+
.interactiveSwipeBack(canPop: { viewModel.isSaved }, onBlocked: { showDiscardConfirm = true })
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
> Anti-pattern: sprinkling `.enableInteractiveSwipeBack()` on each `View` body. It
|
|
681
|
+
> compiles even when forgotten, so nav-bar-hidden routes silently lose the gesture.
|
|
682
|
+
> Enforce at the route layer; optionally add a SwiftLint rule that flags
|
|
683
|
+
> `.toolbar(.hidden, for: .navigationBar)` without a swipe-back modifier.
|
|
684
|
+
|
|
685
|
+
---
|
|
686
|
+
|
|
687
|
+
## 10. Repository-layer response cache (hyperoslo/Cache)
|
|
688
|
+
|
|
689
|
+
When response caching is required, implement it at the Repository (Service) layer. Cache the
|
|
690
|
+
**decoded** `Components.Schemas.*` models returned by the generated `Client` — never
|
|
691
|
+
intercept `HTTPBody` in a middleware (it is a single-consumption stream). `hyperoslo/Cache`'s
|
|
692
|
+
`Storage` is not `Sendable`, so it is always owned by an `actor`.
|
|
693
|
+
|
|
694
|
+
```swift
|
|
695
|
+
// Core/Cache/ResponseCache.swift
|
|
696
|
+
import Foundation
|
|
697
|
+
import Cache
|
|
698
|
+
|
|
699
|
+
/// Actor wrapper over hyperoslo/Cache. One instance per cached value type and account namespace.
|
|
700
|
+
/// Owns a non-Sendable `Storage`, so all access is actor-isolated → Swift 6 clean.
|
|
701
|
+
actor ResponseCache<Value: Codable & Sendable> {
|
|
702
|
+
private let storage: Storage<String, Value>
|
|
703
|
+
|
|
704
|
+
/// - Parameters:
|
|
705
|
+
/// - name: disk namespace. Include a stable, non-secret account/tenant identifier.
|
|
706
|
+
/// - memoryExpiry: in-memory TTL — fast path, lost on app relaunch.
|
|
707
|
+
/// - diskExpiry: on-disk TTL — survives relaunch. Never use `.never`.
|
|
708
|
+
init(
|
|
709
|
+
name: String,
|
|
710
|
+
memoryExpiry: Expiry = .seconds(120),
|
|
711
|
+
diskExpiry: Expiry = .seconds(60 * 60)
|
|
712
|
+
) throws {
|
|
713
|
+
// `fileManager:` is REQUIRED on the 7.4.0 release tag (last release, 2024-08).
|
|
714
|
+
// The README shows the master signature where it defaults to `.default`, but
|
|
715
|
+
// that default is unreleased — pass it explicitly and pin the exact version.
|
|
716
|
+
self.storage = try Storage<String, Value>(
|
|
717
|
+
diskConfig: DiskConfig(name: name, expiry: diskExpiry),
|
|
718
|
+
memoryConfig: MemoryConfig(expiry: memoryExpiry, countLimit: 200, totalCostLimit: 0),
|
|
719
|
+
fileManager: .default,
|
|
720
|
+
transformer: TransformerFactory.forCodable(ofType: Value.self)
|
|
721
|
+
)
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/// Non-expired cached value, or nil on miss/expiry. Read errors degrade to nil.
|
|
725
|
+
func value(forKey key: String) -> Value? {
|
|
726
|
+
try? storage.object(forKey: key)
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
func store(_ value: Value, forKey key: String) {
|
|
730
|
+
try? storage.setObject(value, forKey: key)
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/// Drop affected keys after a write so the next read repopulates.
|
|
734
|
+
func invalidate(_ key: String) { try? storage.removeObject(forKey: key) }
|
|
735
|
+
func invalidateAll() { try? storage.removeAll() }
|
|
736
|
+
}
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
```swift
|
|
740
|
+
// Core/Networking/TodoProviding.swift
|
|
741
|
+
import Foundation
|
|
742
|
+
|
|
743
|
+
/// Protocol seam the view models depend on. Keeps the cached `TodoService`
|
|
744
|
+
/// swappable for a protocol-based mock in tests (no third-party mock lib).
|
|
745
|
+
public protocol TodoProviding: Sendable {
|
|
746
|
+
func todosStream() async -> AsyncThrowingStream<[Components.Schemas.Todo], Error>
|
|
747
|
+
func createTodo(title: String) async throws -> Components.Schemas.Todo
|
|
748
|
+
func toggleTodo(id: String) async throws -> Components.Schemas.Todo
|
|
749
|
+
func deleteTodo(id: String) async throws
|
|
750
|
+
}
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
```swift
|
|
754
|
+
// Core/Networking/TodoService.swift (cached repository)
|
|
755
|
+
import Foundation
|
|
756
|
+
|
|
757
|
+
public actor TodoService: TodoProviding {
|
|
758
|
+
private let client: Client
|
|
759
|
+
private let cache: ResponseCache<[Components.Schemas.Todo]>?
|
|
760
|
+
private let accountID: String
|
|
761
|
+
private var isActive = true
|
|
762
|
+
private var requests: [UUID: Task<Void, Never>] = [:]
|
|
763
|
+
|
|
764
|
+
public init(
|
|
765
|
+
client: Client,
|
|
766
|
+
cache: ResponseCache<[Components.Schemas.Todo]>?,
|
|
767
|
+
accountID: String
|
|
768
|
+
) {
|
|
769
|
+
self.client = client
|
|
770
|
+
self.cache = cache
|
|
771
|
+
self.accountID = accountID
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
private var listKey: String { "\(accountID):listTodos" }
|
|
775
|
+
|
|
776
|
+
/// Call before replacing an account or signing out. Cancels reads, clears
|
|
777
|
+
/// account-scoped cache data, and prevents a late response from reaching the old UI.
|
|
778
|
+
public func close() async {
|
|
779
|
+
isActive = false
|
|
780
|
+
let pendingRequests = Array(requests.values)
|
|
781
|
+
let pendingContinuations = Array(continuations.values)
|
|
782
|
+
requests.removeAll()
|
|
783
|
+
continuations.removeAll()
|
|
784
|
+
pendingContinuations.forEach { $0.finish() }
|
|
785
|
+
pendingRequests.forEach { $0.cancel() }
|
|
786
|
+
// Wait for cancelled work before purging. A request that was suspended in a cache
|
|
787
|
+
// write cannot repopulate this account's namespace after the purge.
|
|
788
|
+
for task in pendingRequests { await task.value }
|
|
789
|
+
await cache?.invalidateAll()
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// MARK: - Read (stale-while-revalidate)
|
|
793
|
+
|
|
794
|
+
/// Yields the cached list first (if any), then the freshly-fetched list.
|
|
795
|
+
/// The View model iterates with `for try await` and updates state on each yield.
|
|
796
|
+
/// If the network fails but a cached value exists, the stale value stands and
|
|
797
|
+
/// the error is swallowed; with no cache, the error surfaces.
|
|
798
|
+
public func todosStream() async -> AsyncThrowingStream<[Components.Schemas.Todo], Error> {
|
|
799
|
+
let requestID = UUID()
|
|
800
|
+
var continuation: AsyncThrowingStream<[Components.Schemas.Todo], Error>.Continuation!
|
|
801
|
+
let stream = AsyncThrowingStream<[Components.Schemas.Todo], Error> { continuation = $0 }
|
|
802
|
+
|
|
803
|
+
guard isActive else {
|
|
804
|
+
continuation.finish()
|
|
805
|
+
return stream
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Registration happens while this actor is isolated. `close()` therefore sees both
|
|
809
|
+
// the request and its continuation before it can cancel or purge the account cache.
|
|
810
|
+
continuations[requestID] = continuation
|
|
811
|
+
let task = Task { [weak self] in
|
|
812
|
+
guard let self else { return }
|
|
813
|
+
await self.runRequest(requestID)
|
|
814
|
+
}
|
|
815
|
+
requests[requestID] = task
|
|
816
|
+
continuation.onTermination = { [weak self] _ in
|
|
817
|
+
Task { await self?.cancelRequest(requestID) }
|
|
818
|
+
}
|
|
819
|
+
return stream
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
private var continuations: [UUID: AsyncThrowingStream<[Components.Schemas.Todo], Error>.Continuation] = [:]
|
|
823
|
+
|
|
824
|
+
private func runRequest(_ requestID: UUID) async {
|
|
825
|
+
guard let continuation = continuations[requestID] else { return }
|
|
826
|
+
defer { finishRequest(requestID) }
|
|
827
|
+
|
|
828
|
+
let cached = await cache?.value(forKey: listKey)
|
|
829
|
+
guard isActive, !Task.isCancelled else { return }
|
|
830
|
+
if let cached { continuation.yield(cached) }
|
|
831
|
+
|
|
832
|
+
do {
|
|
833
|
+
let fresh = try await fetchTodos()
|
|
834
|
+
guard isActive, !Task.isCancelled else { return }
|
|
835
|
+
if let cache { await cache.store(fresh, forKey: listKey) }
|
|
836
|
+
// `close()` may have run while the cache actor was awaited.
|
|
837
|
+
guard isActive, !Task.isCancelled else { return }
|
|
838
|
+
continuation.yield(fresh)
|
|
839
|
+
} catch {
|
|
840
|
+
guard isActive, !Task.isCancelled else { return }
|
|
841
|
+
if cached == nil { continuation.finish(throwing: error) }
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
private func finishRequest(_ id: UUID) {
|
|
846
|
+
continuations[id]?.finish()
|
|
847
|
+
continuations[id] = nil
|
|
848
|
+
requests[id] = nil
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
private func cancelRequest(_ id: UUID) {
|
|
852
|
+
continuations[id]?.finish()
|
|
853
|
+
continuations[id] = nil
|
|
854
|
+
requests[id]?.cancel()
|
|
855
|
+
// Keep the task registered until `finishRequest`. A later account close must await
|
|
856
|
+
// an already-cancelled task before it purges the disk namespace.
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
private func fetchTodos() async throws -> [Components.Schemas.Todo] {
|
|
860
|
+
let response = try await client.listTodos(.init())
|
|
861
|
+
switch response {
|
|
862
|
+
case .ok(let ok):
|
|
863
|
+
return try ok.body.json
|
|
864
|
+
case .undocumented(let statusCode, _):
|
|
865
|
+
throw TodoServiceError.undocumented(statusCode: statusCode)
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// MARK: - Write (invalidate after success)
|
|
870
|
+
|
|
871
|
+
public func createTodo(title: String) async throws -> Components.Schemas.Todo {
|
|
872
|
+
let body = Components.Schemas.CreateTodoRequest(title: title)
|
|
873
|
+
let response = try await client.createTodo(.init(body: .json(body)))
|
|
874
|
+
switch response {
|
|
875
|
+
case .created(let created):
|
|
876
|
+
await cache?.invalidate(listKey) // next read repopulates
|
|
877
|
+
return try created.body.json
|
|
878
|
+
case .conflict:
|
|
879
|
+
throw TodoServiceError.conflict
|
|
880
|
+
case .undocumented(let statusCode, _):
|
|
881
|
+
throw TodoServiceError.undocumented(statusCode: statusCode)
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
```
|
|
886
|
+
|
|
887
|
+
The view model consumes the stream with `for try await` — see §3 `TodosViewModel.load()`
|
|
888
|
+
for the cancellation-safe consumer.
|
|
889
|
+
|
|
890
|
+
Wire the cache into the service at the composition root — see §7 `AppDependencies`.
|
|
891
|
+
Use an optional cache: if `Storage` initialization fails, continue network-only and record a
|
|
892
|
+
non-sensitive diagnostic. Namespace and key by a stable non-secret account/tenant identifier.
|
|
893
|
+
On account switch or logout, cancel the old service's consumers and call `await oldService.close()`
|
|
894
|
+
before publishing the next account's service. Rebuild the API client with the new credentials.
|
|
895
|
+
|
|
896
|
+
> Rules recap: cache **decoded models** at the Repository layer (not `HTTPBody`);
|
|
897
|
+
> key on account/tenant + `operationID` + params; explicit memory/disk TTLs (never `.never`);
|
|
898
|
+
> invalidate affected keys after every write and purge/cancel on account switch or logout. Durable user-owned data → SwiftData;
|
|
899
|
+
> secrets → Keychain. `hyperoslo/Cache` is never a system of record.
|
|
900
|
+
|
|
901
|
+
---
|
|
902
|
+
|
|
903
|
+
## 11. Durable storage: SwiftData (system of record)
|
|
904
|
+
|
|
905
|
+
`hyperoslo/Cache` (§10) memoizes transient server-owned responses. **Durable,
|
|
906
|
+
user-owned** data (drafts, offline records) lives in **SwiftData** — the Swift-native
|
|
907
|
+
ORM on Core Data. Model with `@Model`, own one `ModelContainer`, and read/write
|
|
908
|
+
through a repository so features never touch a `ModelContext` directly.
|
|
909
|
+
|
|
910
|
+
```swift
|
|
911
|
+
// Core/Models/Draft.swift
|
|
912
|
+
import SwiftData
|
|
913
|
+
|
|
914
|
+
/// A user-owned draft persisted across launches. `@Model` makes the class a
|
|
915
|
+
/// SwiftData entity (schema inferred from stored properties).
|
|
916
|
+
@Model
|
|
917
|
+
final class Draft {
|
|
918
|
+
@Attribute(.unique) var id: UUID
|
|
919
|
+
var title: String
|
|
920
|
+
var body: String
|
|
921
|
+
var updatedAt: Date
|
|
922
|
+
|
|
923
|
+
init(id: UUID = UUID(), title: String, body: String, updatedAt: Date = .now) {
|
|
924
|
+
self.id = id
|
|
925
|
+
self.title = title
|
|
926
|
+
self.body = body
|
|
927
|
+
self.updatedAt = updatedAt
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
```
|
|
931
|
+
|
|
932
|
+
```swift
|
|
933
|
+
// Core/Services/DraftRepository.swift
|
|
934
|
+
import Foundation
|
|
935
|
+
import SwiftData
|
|
936
|
+
|
|
937
|
+
/// Repository over SwiftData. Owns a `ModelContext` bound to the app's container;
|
|
938
|
+
/// `@MainActor` because the default context is main-actor bound.
|
|
939
|
+
@MainActor
|
|
940
|
+
final class DraftRepository {
|
|
941
|
+
private let context: ModelContext
|
|
942
|
+
|
|
943
|
+
init(container: ModelContainer) {
|
|
944
|
+
self.context = container.mainContext
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/// Fetch newest-first, optionally filtered by a title substring.
|
|
948
|
+
func drafts(matching query: String = "") throws -> [Draft] {
|
|
949
|
+
let predicate = query.isEmpty ? nil : #Predicate<Draft> {
|
|
950
|
+
$0.title.localizedStandardContains(query)
|
|
951
|
+
}
|
|
952
|
+
let descriptor = FetchDescriptor<Draft>(
|
|
953
|
+
predicate: predicate,
|
|
954
|
+
sortBy: [SortDescriptor(\.updatedAt, order: .reverse)]
|
|
955
|
+
)
|
|
956
|
+
return try context.fetch(descriptor)
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
func upsert(_ draft: Draft) throws {
|
|
960
|
+
context.insert(draft) // insert is idempotent on the unique id
|
|
961
|
+
try context.save()
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
func delete(_ draft: Draft) throws {
|
|
965
|
+
context.delete(draft)
|
|
966
|
+
try context.save()
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
```
|
|
970
|
+
|
|
971
|
+
```swift
|
|
972
|
+
// App/MyApp.swift — build one ModelContainer for the whole app.
|
|
973
|
+
import SwiftUI
|
|
974
|
+
import SwiftData
|
|
975
|
+
|
|
976
|
+
@main
|
|
977
|
+
struct MyApp: App {
|
|
978
|
+
// One container per app; SwiftUI injects it into the environment.
|
|
979
|
+
let container: ModelContainer = {
|
|
980
|
+
do { return try ModelContainer(for: Draft.self) }
|
|
981
|
+
catch { fatalError("Failed to create ModelContainer: \(error)") }
|
|
982
|
+
}()
|
|
983
|
+
|
|
984
|
+
var body: some Scene {
|
|
985
|
+
WindowGroup { RootView() }
|
|
986
|
+
.modelContainer(container)
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
```
|
|
990
|
+
|
|
991
|
+
---
|
|
992
|
+
|
|
993
|
+
## 12. UI testing: XCUITest
|
|
994
|
+
|
|
995
|
+
Unit tests (§8) cover view models against protocol mocks. **XCUITest** drives the
|
|
996
|
+
real app through the accessibility layer for critical user flows — launch, interact,
|
|
997
|
+
assert on-screen. Give interactive views stable `.accessibilityIdentifier`s so the
|
|
998
|
+
queries don't depend on visible copy.
|
|
999
|
+
|
|
1000
|
+
```swift
|
|
1001
|
+
// UITests/TodosFlowUITests.swift
|
|
1002
|
+
import XCTest
|
|
1003
|
+
|
|
1004
|
+
final class TodosFlowUITests: XCTestCase {
|
|
1005
|
+
override func setUp() {
|
|
1006
|
+
super.setUp()
|
|
1007
|
+
continueAfterFailure = false // stop at the first failed assertion
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
func testTodosScreen_loadsAndShowsList() {
|
|
1011
|
+
let app = XCUIApplication()
|
|
1012
|
+
// Pass a launch argument the app reads to serve deterministic fixtures.
|
|
1013
|
+
app.launchArguments += ["-uiTestMode"]
|
|
1014
|
+
app.launch()
|
|
1015
|
+
|
|
1016
|
+
// Wait for the navigation title to appear (async load).
|
|
1017
|
+
let title = app.navigationBars["Todos"]
|
|
1018
|
+
XCTAssertTrue(title.waitForExistence(timeout: 5))
|
|
1019
|
+
|
|
1020
|
+
// Tap the first row and assert navigation happened.
|
|
1021
|
+
let firstCell = app.cells.element(boundBy: 0)
|
|
1022
|
+
XCTAssertTrue(firstCell.waitForExistence(timeout: 5))
|
|
1023
|
+
firstCell.tap()
|
|
1024
|
+
|
|
1025
|
+
XCTAssertTrue(app.staticTexts["todoDetailTitle"].waitForExistence(timeout: 5))
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
```
|