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 |
|
| `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 |
|
||||||
@@ -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())
|
||||||
}
|
}
|
||||||
@@ -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
|
// 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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 |
Reference in new issue
Block a user