Files
wristcode/watch/WristCodeTests/SnapshotTests.swift
T
gib 7ca0362745 Watch: status, environment and notifications screens
A gear on the feed opens a status overview that leads with what is wrong:
offline hosts, hosts on a fallback route, tokens expiring within 7 days,
and push problems, with healthy hosts folded into one row. Each host opens
its route ladder with Reconnect. The notifications page shows registration,
the last push, Send test and Unpair. Demo mode gains a Laptop on its VPN
fallback, and snapshot tests render the three screens.
2026-11-01 15:16:18 -05:00

121 lines
5.2 KiB
Swift

// 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")
}
@Test func statusScreens() async throws {
let status = try await DemoGateway().status()
try write(titled("Status", StatusView(status: status, scrolls: false)), "status.png")
try write(titled("Laptop", HostStatusView(hostId: "laptop", status: status, scrolls: false)), "status-environment.png")
let result = PushResult(ok: true, at: .now, status: 200, reason: nil)
try write(titled("Notifications", NotificationsView(status: status, result: result, scrolls: false)), "status-notifications.png")
}
/// The screen under a title like the navigation bar's, which ImageRenderer leaves out.
private func titled(_ title: String, _ view: some View) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white)
.padding(.horizontal, 10)
.padding(.top, 10)
view
}
.environment(AppModel.preview())
.fontDesign(.rounded)
.tint(Theme.work)
}
/// 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)
}
}
}
}