Files
wristcode/watch/WristCodeTests/SnapshotTests.swift
T

309 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 {
let target = item.requestThread
try await gateway.respond(
hostId: target.hostId, threadId: target.threadId,
request: .decision(requestId: item.request!.id, decision: .decline))
}
let working = WaitingEntry(date: .now, content: .counts(try await gateway.summary(), stale: false))
try write(ComplicationGallery(sections: [("\(feed.needsYou.count) waiting", waiting), ("Nothing waiting", working)]), "complication.png")
try write(ComplicationGallery(sections: [("\(feed.needsYou.count) waiting", waiting)]), "complication-waiting.png")
try write(ComplicationGallery(sections: [("Nothing waiting", working)]), "complication-working.png")
}
@Test func smartStack() async throws {
// Answering the release question leaves the homelab card's subagent approval waiting longest.
let gateway = DemoGateway()
let release = try await gateway.thread(hostId: "mac-mini", threadId: "t-release")
try await gateway.respond(
hostId: "mac-mini", threadId: "t-release",
request: .answers(requestId: release.item.request!.id, answers: ["checks": .values(["unit"])]))
let summary = try await gateway.summary()
let approval = try #require(await WaitingEntry.Approval.load(for: summary, api: gateway))
let waiting = WaitingEntry(date: .now, content: .counts(summary, stale: false), approval: approval)
// Denying everything on a fresh gateway leaves the scaffold thread working most recently.
let idle = DemoGateway()
for item in try await idle.feed().needsYou {
let target = item.requestThread
try await idle.respond(
hostId: target.hostId, threadId: target.threadId,
request: .decision(requestId: item.request!.id, decision: .decline))
}
let working = WaitingEntry(date: .now, content: .counts(try await idle.summary(), stale: false))
try write(SmartStackGallery(entries: [waiting, working]), "smart-stack.png")
}
@Test func changes() async throws {
let gateway = DemoGateway()
let feed = try await gateway.feed()
try write(feedColumn {
RecentHeader(recent: feed.recent, more: feed.moreRecent)
ForEach(feed.recent.prefix(4)) { StatusCard(item: $0) }
}, "feed-changes.png")
let detail = try await gateway.thread(hostId: "littleman", threadId: "t-next")
try write(titled("Thread", ThreadView(hostId: "littleman", threadId: "t-next", detail: detail, scrolls: false)), "thread-changes.png")
}
@Test func search() async throws {
let gateway = DemoGateway()
let hits = try await gateway.search("healthcheck")
try write(titled("\u{201C}healthcheck\u{201D}", SearchView(query: "healthcheck", response: hits, scrolls: false)), "search.png")
let none = try await gateway.search("terraform")
try write(titled("\u{201C}terraform\u{201D}", SearchView(query: "terraform", response: none, scrolls: false)), "search-empty.png")
}
@Test func questionNotification() throws {
let question = PushQuestion(
id: "base", options: [.init(label: "bun:1-alpine", value: "alpine"), .init(label: "bun:1-debian", value: "debian")],
allowCustomAnswer: true)
try write(NotificationPreview(
view: InputNotificationView(title: "Gateway Containerfile", question: "Which base image?"),
actions: NotificationHandler.inputActions(question).map(\.title)), "notification-question.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 subagentCard() async throws {
let feed = try await DemoGateway().feed()
let item = try #require(feed.needsYou.first { $0.via != nil })
try write(feedColumn { NeedsYouCard(item: item) }, "feed-subagent.png")
}
@Test func recentWithSettleAll() async throws {
let feed = try await DemoGateway().feed()
try write(feedColumn {
RecentHeader(recent: feed.recent, more: feed.moreRecent)
ForEach(feed.recent) { StatusCard(item: $0) }
MoreRecentRow(count: feed.moreRecent)
AllThreadsRow()
}, "feed-recent.png")
}
@Test func workingCardsWithActivity() async throws {
let feed = try await DemoGateway().feed()
try write(feedColumn { ForEach(feed.working) { StatusCard(item: $0) } }, "feed-activity.png")
}
@Test func browseHostsAndThreads() async throws {
// The preview model has the demo hosts and projects loaded.
try write(titled("All threads", HostsView(scrolls: false)), "browse-hosts.png")
let rows = try await DemoGateway().projectThreads(hostId: "mac-mini", projectId: "p-wc-mac").items
try write(titled("wristcode", ProjectThreadsView(hostId: "mac-mini", projectId: "p-wc-mac", rows: rows, scrolls: false)), "browse-threads.png")
}
@Test func threadWithSubagents() async throws {
let detail = try await DemoGateway().thread(hostId: "server-gib", threadId: "t-container")
try write(titled("Thread", ThreadView(hostId: "server-gib", threadId: "t-container", detail: detail, scrolls: false)), "thread-subagents.png")
}
@Test func threadWithEarlierAndMarkdown() async throws {
let detail = try await DemoGateway().thread(hostId: "server-gib", threadId: "t-hyprlock")
try write(titled("Thread", ThreadView(hostId: "server-gib", threadId: "t-hyprlock", detail: detail, scrolls: false)), "thread-earlier.png")
}
@Test func fullRecentList() async throws {
// The preview model has every demo Recent thread loaded.
try write(titled("Recent", RecentView(scrolls: false)), "recent.png")
}
@Test func subagentThread() async throws {
let detail = try await DemoGateway().thread(hostId: "mac-mini", threadId: "t-homelab")
try write(titled("Thread", ThreadView(hostId: "mac-mini", threadId: "t-homelab", detail: detail, scrolls: false)), "thread-subagent.png")
}
@Test func finishedThreadWithSettle() async throws {
let detail = try await DemoGateway().thread(hostId: "laptop", threadId: "t-status")
try write(titled("Thread", ThreadView(hostId: "laptop", threadId: "t-status", detail: detail, scrolls: false)), "thread-settle.png")
}
@Test func runningThreadWithAskFirst() async throws {
let detail = try await DemoGateway().thread(hostId: "mac-mini", threadId: "t-scaffold")
try write(titled("Thread", ThreadView(hostId: "mac-mini", threadId: "t-scaffold", detail: detail, scrolls: false)), "thread.png")
}
@Test func newThreadAndAgentList() async throws {
let projects = try await DemoGateway().projects()
// The demo opens on Mac mini › wristcode, with Ask before acting on as the mock shows it.
UserDefaults.standard.removeObject(forKey: "lastModels")
UserDefaults.standard.set(true, forKey: "askFirst")
defer { UserDefaults.standard.removeObject(forKey: "askFirst") }
try write(titled("New thread", NewThreadView(projects: projects, scrolls: false)), "new-thread.png")
let host = try #require(projects.hosts.first)
let project = try #require(host.projects.first)
let list = AgentList(
providers: host.providers, selected: project.defaultModel, defaultInstanceId: project.instanceId,
scrolls: false) { _ in }
try write(titled("Agent", list), "agent-list.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")
}
/// Feed rows stacked as the feed's List lays them out, which ImageRenderer leaves blank.
private func feedColumn(@ViewBuilder _ rows: () -> some View) -> some View {
VStack(spacing: 6) {
rows()
}
.padding(.horizontal, 4)
.padding(.top, 10)
.environment(AppModel.preview())
.fontDesign(.rounded)
.tint(Theme.work)
}
/// 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 per titled entry.
private struct ComplicationGallery: View {
var sections: [(title: String, entry: WaitingEntry)]
var body: some View {
VStack(alignment: .leading, spacing: 10) {
ForEach(sections.indices, id: \.self) { index in
section(sections[index].title, sections[index].entry)
}
}
.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, inStackOverride: false)
.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)
}
}
}
}
/// The rectangular widget as the Smart Stack shows it, on its card and with Allow and Deny when the
/// waiting thread wants an approval.
private struct SmartStackGallery: View {
var entries: [WaitingEntry]
var body: some View {
VStack(spacing: 10) {
ForEach(entries.indices, id: \.self) { index in
WaitingView(entry: entries[index], familyOverride: .accessoryRectangular, inStackOverride: true)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(width: 196, height: 96, alignment: .topLeading)
.background(Color(white: 0.11), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
}
}
.padding(.top, 8)
.foregroundStyle(.white)
.fontDesign(.rounded)
}
}
/// The question notification's long look over its actions. The system draws the actions; these
/// stand in for them with the same titles.
private struct NotificationPreview: View {
var view: InputNotificationView
var actions: [String]
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text("WRISTCODE")
.font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundStyle(Theme.secondaryText)
view
.padding(.bottom, 4)
ForEach(actions, id: \.self) { title in
Text(title)
.font(.system(size: 15, weight: .semibold, design: .rounded))
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
.background(Theme.control, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
.padding(.horizontal, 8)
.padding(.top, 10)
.foregroundStyle(.white)
}
}