Watch: snapshot tests render the complication and multi-select screenshots

ImageRenderer draws the real views with demo data, since the watch simulator
is not drivable here. README covers the widget target and how to add it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
gibandClaude Opus 5.5 committed 2026-11-01 11:06:38 -05:00
1 parent 0994fd7d5a
commit 67992dbf43
8 files changed
+241 -80

No files matched your search

+30 -2
View File
@@ -22,6 +22,7 @@ targets, the same pair Xcode's "Watch-only App" template makes:
| --- | --- | --- | | --- | --- | --- |
| `WristCodeContainer` | `org.gbrown.wristcode` | iOS stub that owns the App Store Connect record and embeds the watch app | | `WristCodeContainer` | `org.gbrown.wristcode` | iOS stub that owns the App Store Connect record and embeds the watch app |
| `WristCode` | `org.gbrown.wristcode.watchkitapp` | The watch app, and the APNs topic | | `WristCode` | `org.gbrown.wristcode.watchkitapp` | The watch app, and the APNs topic |
| `WristCodeWidget` | `org.gbrown.wristcode.watchkitapp.widget` | The complication, embedded in the watch app |
```sh ```sh
scripts/testflight.sh # archive, export and upload to App Store Connect scripts/testflight.sh # archive, export and upload to App Store Connect
@@ -46,7 +47,31 @@ xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -thread little
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen new # new thread xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen new # new thread
``` ```
`screenshots/` holds the screens captured this way. `screenshots/` holds the screens captured this way. The complication and the multi-select question
are rendered by `SnapshotTests` instead, which only run when given a directory:
```sh
TEST_RUNNER_WC_SCREENSHOT_DIR=$PWD/screenshots xcodebuild -scheme WristCode \
-destination 'platform=watchOS Simulator,name=Apple Watch Series 12 (46mm)' test \
-only-testing:WristCodeTests/SnapshotTests
```
In demo mode the complication shows the demo counts too.
## Complication
`WristCodeWidget` shows how many threads wait on you, from `GET /v1/summary`, in the circular,
rectangular, inline and corner families. Tapping it opens the app, at the longest-waiting thread when
there is one (`wristcode://thread/<hostId>/<threadId>`). WidgetKit refreshes it about every 15 minutes;
the app reloads it sooner when its feed counts change, when a push arrives in the foreground, and
after a notification action.
The app and the extension share the App Group `group.org.gbrown.wristcode`. The device token and
gateway URL live in that group's Keychain access group, and the demo flag and last summary in its
defaults. Builds before the complication kept the token in the app's own Keychain group;
`DeviceStore.migrateToSharedGroup()` moves it on launch, so a paired watch stays paired.
To add it: long-press the watch face, Edit, swipe to Complications, pick a slot, then WristCode.
## Layout ## Layout
@@ -56,7 +81,10 @@ xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen new
| `AppModel.swift` | Paired, demo or unpaired; navigation path; the feed; 401 sends you back to pairing | | `AppModel.swift` | Paired, demo or unpaired; navigation path; the feed; 401 sends you back to pairing |
| `API/GatewayClient.swift` | `GatewayAPI` protocol and the URLSession client | | `API/GatewayClient.swift` | `GatewayAPI` protocol and the URLSession client |
| `API/Models.swift` | Codable mirror of the contract | | `API/Models.swift` | Codable mirror of the contract |
| `Auth/DeviceStore.swift` | Device token and gateway URL in the Keychain | | `API/Route.swift` | Screens the app can open, and their `wristcode://` URLs |
| `Auth/DeviceStore.swift` | Device token and gateway URL in the shared Keychain group |
| `Shared/SharedState.swift` | Demo flag and last summary in the App Group, for the complication |
| `Demo/DemoGateway.swift` | In-memory gateway with fixtures | | `Demo/DemoGateway.swift` | In-memory gateway with fixtures |
| `Views/` | Pair, Feed, Thread, New thread, and the mock B theme | | `Views/` | Pair, Feed, Thread, New thread, and the mock B theme |
| `Notifications/NotificationHandler.swift` | Categories `approval`, `finished`, `failed`, `input`, `host` and their actions | | `Notifications/NotificationHandler.swift` | Categories `approval`, `finished`, `failed`, `input`, `host` and their actions |
| `../WristCodeWidget/` | The complication: timeline provider, and one view per family |
+44 -16
View File
@@ -18,24 +18,52 @@ struct ThreadView: View {
@State private var selections: [String: Set<String>] = [:] @State private var selections: [String: Set<String>] = [:]
@State private var scrolledToLatest = false @State private var scrolledToLatest = false
/// Snapshots render the column without its ScrollView, which ImageRenderer leaves blank.
private let scrolls: Bool
/// `detail` and `selections` start the screen with a thread loaded and options ticked, for
/// snapshots and previews.
init(
hostId: String, threadId: String,
detail: ThreadDetail? = nil, selections: [String: Set<String>] = [:], scrolls: Bool = true
) {
self.hostId = hostId
self.threadId = threadId
self.scrolls = scrolls
_detail = State(initialValue: detail)
_selections = State(initialValue: selections)
}
var body: some View { var body: some View {
if scrolls {
screen
} else {
column
}
}
private var column: some View {
VStack(alignment: .leading, spacing: 8) {
if let detail {
content(detail)
} else if error == nil {
Text("Loading")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.secondaryText)
}
if let error {
Text(error)
.font(.system(size: 12))
.foregroundStyle(Theme.secondaryText)
}
}
.padding(.horizontal, 4)
}
private var screen: some View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 8) { column
if let detail {
content(detail)
} else if error == nil {
Text("Loading")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.secondaryText)
}
if let error {
Text(error)
.font(.system(size: 12))
.foregroundStyle(Theme.secondaryText)
}
}
.padding(.horizontal, 4)
} }
.onChange(of: detail?.messages.last?.id) { .onChange(of: detail?.messages.last?.id) {
guard !scrolledToLatest, detail != nil else { return } guard !scrolledToLatest, detail != nil else { return }
@@ -360,7 +388,7 @@ private struct QuickReplies: View {
#Preview("Multi-select") { #Preview("Multi-select") {
NavigationStack { NavigationStack {
ThreadView(hostId: "mac-mini", threadId: "t-release") ThreadView(hostId: "mac-mini", threadId: "t-release", selections: ["checks": ["unit", "lint"]])
} }
.environment(AppModel.preview()) .environment(AppModel.preview())
} }
+97
View File
@@ -0,0 +1,97 @@
// Renders screens with demo data to PNGs in watch/screenshots. Skipped unless WC_SCREENSHOT_DIR is
// set, which xcodebuild passes through from TEST_RUNNER_WC_SCREENSHOT_DIR:
//
// TEST_RUNNER_WC_SCREENSHOT_DIR=$PWD/screenshots xcodebuild ... test -only-testing:WristCodeTests/SnapshotTests
import ImageIO
import SwiftUI
import Testing
import UniformTypeIdentifiers
import WidgetKit
@testable import WristCode
private let outputDirectory = ProcessInfo.processInfo.environment["WC_SCREENSHOT_DIR"]
/// The Series 12 (46mm) screen in points.
private let screen = CGSize(width: 208, height: 248)
@MainActor @Suite(.enabled(if: outputDirectory != nil))
struct SnapshotTests {
@Test func complication() async throws {
let gateway = DemoGateway()
let waiting = WaitingEntry(date: .now, content: .counts(try await gateway.summary(), stale: false))
let feed = try await gateway.feed()
for item in feed.needsYou {
try await gateway.respond(
hostId: item.hostId, threadId: item.threadId,
request: .decision(requestId: item.request!.id, decision: .decline))
}
let idle = WaitingEntry(date: .now, content: .counts(try await gateway.summary(), stale: false))
try write(ComplicationGallery(waiting: waiting, idle: idle), "complication.png")
}
@Test func multiSelectQuestion() async throws {
let detail = try await DemoGateway().thread(hostId: "mac-mini", threadId: "t-release")
let view = ThreadView(
hostId: "mac-mini", threadId: "t-release", detail: detail,
selections: ["checks": ["unit", "lint"]], scrolls: false)
.environment(AppModel.preview())
.fontDesign(.rounded)
.tint(Theme.work)
try write(view, "thread-multiselect.png")
}
/// Renders at the screen's width and as tall as the content, as if scrolled through.
private func write(_ view: some View, _ name: String) throws {
let renderer = ImageRenderer(content: view
.frame(width: screen.width)
.fixedSize(horizontal: false, vertical: true)
.padding(.bottom, 8)
.background(.black)
.environment(\.colorScheme, .dark))
renderer.scale = 2
let image = try #require(renderer.cgImage)
let url = URL(fileURLWithPath: try #require(outputDirectory)).appending(path: name)
let destination = try #require(CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil))
CGImageDestinationAddImage(destination, image, nil)
#expect(CGImageDestinationFinalize(destination))
}
}
/// Every family at its 46mm size, once with threads waiting and once with nothing waiting.
private struct ComplicationGallery: View {
var waiting: WaitingEntry
var idle: WaitingEntry
var body: some View {
VStack(alignment: .leading, spacing: 10) {
section("3 waiting", waiting)
section("Nothing waiting", idle)
}
.padding(.horizontal, 10)
.padding(.top, 8)
.foregroundStyle(.white)
}
private func section(_ title: String, _ entry: WaitingEntry) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title.uppercased())
.font(.system(size: 10, weight: .semibold, design: .rounded))
.foregroundStyle(Theme.done)
WaitingView(entry: entry, familyOverride: .accessoryInline)
.font(.system(size: 13, weight: .semibold, design: .rounded))
.frame(height: 18)
WaitingView(entry: entry, familyOverride: .accessoryRectangular)
.frame(width: 184, height: 72)
HStack(spacing: 16) {
// Outside a face AccessoryWidgetBackground fills a square; a face clips it round.
WaitingView(entry: entry, familyOverride: .accessoryCircular)
.frame(width: 50, height: 50)
.clipShape(Circle())
WaitingView(entry: entry, familyOverride: .accessoryCorner)
.frame(width: 50, height: 50)
}
}
}
}
+32 -37
View File
@@ -1,12 +1,43 @@
// One view per complication family, in mock B's colors: amber when something needs you, teal for // One view per complication family, in mock B's colors: amber when something needs you, teal for
// work in progress, and nothing that animates. Tinted faces use the accented parts only. // work in progress, and nothing that animates. Tinted faces use the accented parts only.
//
// The app target compiles this file too, so the snapshot tests can render the complication.
import SwiftUI import SwiftUI
import WidgetKit import WidgetKit
struct WaitingEntry: TimelineEntry {
enum Content: Equatable {
/// Counts from the gateway. `stale` when they are the last ones seen because the gateway
/// could not be reached.
case counts(Summary, stale: Bool)
case notPaired
/// The gateway could not be reached and there is nothing saved to show.
case unavailable
}
var date: Date
var content: Content
/// Ranks the complication higher in the Smart Stack while something waits.
var relevance: TimelineEntryRelevance? {
guard case let .counts(summary, _) = content, summary.needsYou > 0 else { return nil }
return TimelineEntryRelevance(score: Float(summary.needsYou))
}
static let sample = WaitingEntry(date: .now, content: .counts(Summary(
needsYou: 2, working: 3,
next: .init(hostId: "mac-mini", threadId: "t-ocr", title: "Receipt OCR", hostLabel: "Mac mini"),
hostsOffline: 0), stale: false))
}
struct WaitingView: View { struct WaitingView: View {
@Environment(\.widgetFamily) private var family @Environment(\.widgetFamily) private var widgetFamily
var entry: WaitingEntry var entry: WaitingEntry
/// Overrides the environment's family, for rendering outside a widget.
var familyOverride: WidgetFamily?
private var family: WidgetFamily { familyOverride ?? widgetFamily }
var body: some View { var body: some View {
content content
@@ -179,39 +210,3 @@ struct WaitingView: View {
} }
} }
} }
private func entry(_ needsYou: Int, _ working: Int, next: Bool = true) -> WaitingEntry {
let thread = Summary.Next(hostId: "mac-mini", threadId: "t-ocr", title: "Receipt OCR", hostLabel: "Mac mini")
return WaitingEntry(date: .now, content: .counts(
Summary(needsYou: needsYou, working: working, next: needsYou > 0 && next ? thread : nil, hostsOffline: 1),
stale: false))
}
#Preview("Circular", as: .accessoryCircular) {
WaitingWidget()
} timeline: {
entry(2, 3)
entry(0, 3)
WaitingEntry(date: .now, content: .notPaired)
}
#Preview("Rectangular", as: .accessoryRectangular) {
WaitingWidget()
} timeline: {
entry(2, 3)
entry(0, 3)
WaitingEntry(date: .now, content: .notPaired)
}
#Preview("Inline", as: .accessoryInline) {
WaitingWidget()
} timeline: {
entry(2, 3)
}
#Preview("Corner", as: .accessoryCorner) {
WaitingWidget()
} timeline: {
entry(2, 3)
entry(0, 3)
}
+36 -25
View File
@@ -23,31 +23,6 @@ struct WaitingWidget: Widget {
} }
} }
struct WaitingEntry: TimelineEntry {
enum Content: Equatable {
/// Counts from the gateway. `stale` when they are the last ones seen because the gateway
/// could not be reached.
case counts(Summary, stale: Bool)
case notPaired
/// The gateway could not be reached and there is nothing saved to show.
case unavailable
}
var date: Date
var content: Content
/// Ranks the complication higher in the Smart Stack while something waits.
var relevance: TimelineEntryRelevance? {
guard case let .counts(summary, _) = content, summary.needsYou > 0 else { return nil }
return TimelineEntryRelevance(score: Float(summary.needsYou))
}
static let sample = WaitingEntry(date: .now, content: .counts(Summary(
needsYou: 2, working: 3,
next: .init(hostId: "mac-mini", threadId: "t-ocr", title: "Receipt OCR", hostLabel: "Mac mini"),
hostsOffline: 0), stale: false))
}
struct WaitingProvider: TimelineProvider { struct WaitingProvider: TimelineProvider {
/// How long until WidgetKit asks again. The app reloads sooner when it learns something. /// How long until WidgetKit asks again. The app reloads sooner when it learns something.
static let refreshInterval: TimeInterval = 15 * 60 static let refreshInterval: TimeInterval = 15 * 60
@@ -92,3 +67,39 @@ struct WaitingProvider: TimelineProvider {
} }
} }
} }
private func entry(_ needsYou: Int, _ working: Int, next: Bool = true) -> WaitingEntry {
let thread = Summary.Next(hostId: "mac-mini", threadId: "t-ocr", title: "Receipt OCR", hostLabel: "Mac mini")
return WaitingEntry(date: .now, content: .counts(
Summary(needsYou: needsYou, working: working, next: needsYou > 0 && next ? thread : nil, hostsOffline: 1),
stale: false))
}
#Preview("Circular", as: .accessoryCircular) {
WaitingWidget()
} timeline: {
entry(2, 3)
entry(0, 3)
WaitingEntry(date: .now, content: .notPaired)
}
#Preview("Rectangular", as: .accessoryRectangular) {
WaitingWidget()
} timeline: {
entry(2, 3)
entry(0, 3)
WaitingEntry(date: .now, content: .notPaired)
}
#Preview("Inline", as: .accessoryInline) {
WaitingWidget()
} timeline: {
entry(2, 3)
}
#Preview("Corner", as: .accessoryCorner) {
WaitingWidget()
} timeline: {
entry(2, 3)
entry(0, 3)
}
+2
View File
@@ -19,6 +19,8 @@ targets:
platform: watchOS platform: watchOS
sources: sources:
- WristCode - WristCode
# The complication's views, so the snapshot tests can render them.
- WristCodeWidget/WaitingView.swift
dependencies: dependencies:
# Embedded in the watch app's PlugIns folder. # Embedded in the watch app's PlugIns folder.
- target: WristCodeWidget - target: WristCodeWidget
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB