310 lines
15 KiB
Swift
310 lines
15 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 {
|
||
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")
|
||
try write(titled("Thread", ThreadView(hostId: "server-gib", threadId: "t-container", detail: detail, subagentsOpen: true, scrolls: false)), "thread-subagents-open.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)
|
||
}
|
||
}
|