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:
1 parent
0994fd7d5a
commit
67992dbf43
8 files changed
+241
-80
No files matched your search
+30
-2
@@ -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 |
|
||||
| `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
|
||||
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
|
||||
```
|
||||
|
||||
`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
|
||||
|
||||
@@ -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 |
|
||||
| `API/GatewayClient.swift` | `GatewayAPI` protocol and the URLSession client |
|
||||
| `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 |
|
||||
| `Views/` | Pair, Feed, Thread, New thread, and the mock B theme |
|
||||
| `Notifications/NotificationHandler.swift` | Categories `approval`, `finished`, `failed`, `input`, `host` and their actions |
|
||||
| `../WristCodeWidget/` | The complication: timeline provider, and one view per family |
|
||||
@@ -18,24 +18,52 @@ struct ThreadView: View {
|
||||
@State private var selections: [String: Set<String>] = [:]
|
||||
@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 {
|
||||
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
|
||||
ScrollView {
|
||||
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)
|
||||
column
|
||||
}
|
||||
.onChange(of: detail?.messages.last?.id) {
|
||||
guard !scrolledToLatest, detail != nil else { return }
|
||||
@@ -360,7 +388,7 @@ private struct QuickReplies: View {
|
||||
|
||||
#Preview("Multi-select") {
|
||||
NavigationStack {
|
||||
ThreadView(hostId: "mac-mini", threadId: "t-release")
|
||||
ThreadView(hostId: "mac-mini", threadId: "t-release", selections: ["checks": ["unit", "lint"]])
|
||||
}
|
||||
.environment(AppModel.preview())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,43 @@
|
||||
// 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.
|
||||
//
|
||||
// The app target compiles this file too, so the snapshot tests can render the complication.
|
||||
|
||||
import SwiftUI
|
||||
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 {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
@Environment(\.widgetFamily) private var widgetFamily
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
@@ -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 {
|
||||
/// How long until WidgetKit asks again. The app reloads sooner when it learns something.
|
||||
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)
|
||||
}
|
||||
@@ -19,6 +19,8 @@ targets:
|
||||
platform: watchOS
|
||||
sources:
|
||||
- WristCode
|
||||
# The complication's views, so the snapshot tests can render them.
|
||||
- WristCodeWidget/WaitingView.swift
|
||||
dependencies:
|
||||
# Embedded in the watch app's PlugIns folder.
|
||||
- target: WristCodeWidget
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
Reference in new issue
Block a user