@@ -12,6 +12,37 @@ import SwiftUI
1212@MainActor
1313final class MascotRenderHarness : XCTestCase {
1414
15+ /// One-frame icon export for cli-icons assets: renders each source's
16+ /// mascot at a fixed instant on a transparent background. Opt-in like the
17+ /// contact sheets (`MASCOT_ICON_DIR` + `MASCOT_ICON_SOURCES=kiro,openclaw`).
18+ func testRenderCliIcons( ) throws {
19+ guard let outDir = ProcessInfo . processInfo. environment [ " MASCOT_ICON_DIR " ] else {
20+ throw XCTSkip ( " MASCOT_ICON_DIR not set — harness is opt-in " )
21+ }
22+ let sources = ( ProcessInfo . processInfo. environment [ " MASCOT_ICON_SOURCES " ] ?? " kiro,openclaw " )
23+ . split ( separator: " , " ) . map ( String . init)
24+ let status : MascotAgentStatus = switch ProcessInfo . processInfo. environment [ " MASCOT_ICON_STATUS " ] {
25+ case " idle " : . idle
26+ case " waitingApproval " : . waitingApproval
27+ case " waitingQuestion " : . waitingQuestion
28+ default : . processing
29+ }
30+ let time = Double ( ProcessInfo . processInfo. environment [ " MASCOT_ICON_TIME " ] ?? " " ) ?? 0.0
31+
32+ try FileManager . default. createDirectory ( atPath: outDir, withIntermediateDirectories: true )
33+ for source in sources {
34+ let icon = MascotIconFrame ( source: source, status: status, time: time)
35+ let renderer = ImageRenderer ( content: icon)
36+ renderer. scale = 2 // 64pt frame → 128px asset
37+ guard let cgImage = renderer. cgImage,
38+ let png = NSBitmapImageRep ( cgImage: cgImage) . representation ( using: . png, properties: [ : ] )
39+ else {
40+ XCTFail ( " icon render failed for \( source) " ) ; continue
41+ }
42+ try png. write ( to: URL ( fileURLWithPath: " \( outDir) / \( source) .png " ) )
43+ }
44+ }
45+
1546 func testRenderContactSheets( ) throws {
1647 guard let outDir = ProcessInfo . processInfo. environment [ " MASCOT_SHEET_DIR " ] else {
1748 throw XCTSkip ( " MASCOT_SHEET_DIR not set — harness is opt-in " )
@@ -48,6 +79,20 @@ final class MascotRenderHarness: XCTestCase {
4879 }
4980}
5081
82+ /// Single transparent-background frame sized for a cli-icons asset.
83+ private struct MascotIconFrame : View {
84+ let source : String
85+ let status : MascotAgentStatus
86+ let time : Double
87+
88+ var body : some View {
89+ MascotContactSheet . routedMascot ( source: source, status: status, size: 58 )
90+ . environment ( \. mascotAnimationsActive, false )
91+ . environment ( \. mascotStaticTime, time)
92+ . frame ( width: 64 , height: 64 )
93+ }
94+ }
95+
5196/// One row of frames for a (source, status) pair, each frame rendered at a
5297/// fixed timeline instant via the static-frame path of MascotTimeline
5398/// (mascotAnimationsActive=false renders content(mascotStaticTime) once).
@@ -76,29 +121,33 @@ private struct MascotContactSheet: View {
76121 }
77122
78123 /// Direct routing (bypasses MascotView, which re-injects the live gate).
79- @ViewBuilder
80124 private var mascot : some View {
125+ Self . routedMascot ( source: source, status: status, size: 54 )
126+ }
127+
128+ @ViewBuilder
129+ static func routedMascot( source: String , status: MascotAgentStatus , size: CGFloat ) -> some View {
81130 switch source {
82- case " codex " : DexView ( status: status, size: 54 )
83- case " gemini " : GeminiView ( status: status, size: 54 )
84- case " cursor " : CursorView ( status: status, size: 54 )
85- case " trae " : TraeView ( status: status, size: 54 )
86- case " copilot " : CopilotView ( status: status, size: 54 )
87- case " qoder " : QoderView ( status: status, size: 54 )
88- case " droid " : DroidView ( status: status, size: 54 )
89- case " codebuddy " : BuddyView ( status: status, size: 54 )
90- case " stepfun " : StepFunView ( status: status, size: 54 )
91- case " opencode " : OpenCodeView ( status: status, size: 54 )
92- case " qwen " : QwenView ( status: status, size: 54 )
93- case " antigravity " : AntiGravityView ( status: status, size: 54 )
94- case " workbuddy " : WorkBuddyView ( status: status, size: 54 )
95- case " hermes " : HermesView ( status: status, size: 54 )
96- case " openclaw " : OpenClawView ( status: status, size: 54 )
97- case " kiro " : KiroView ( status: status, size: 54 )
98- case " kimi " : KimiView ( status: status, size: 54 )
99- case " pi " : PiView ( status: status, size: 54 )
100- case " cline " : ClineView ( status: status, size: 54 )
101- default : ClawdView ( status: status, size: 54 )
131+ case " codex " : DexView ( status: status, size: size )
132+ case " gemini " : GeminiView ( status: status, size: size )
133+ case " cursor " : CursorView ( status: status, size: size )
134+ case " trae " : TraeView ( status: status, size: size )
135+ case " copilot " : CopilotView ( status: status, size: size )
136+ case " qoder " : QoderView ( status: status, size: size )
137+ case " droid " : DroidView ( status: status, size: size )
138+ case " codebuddy " : BuddyView ( status: status, size: size )
139+ case " stepfun " : StepFunView ( status: status, size: size )
140+ case " opencode " : OpenCodeView ( status: status, size: size )
141+ case " qwen " : QwenView ( status: status, size: size )
142+ case " antigravity " : AntiGravityView ( status: status, size: size )
143+ case " workbuddy " : WorkBuddyView ( status: status, size: size )
144+ case " hermes " : HermesView ( status: status, size: size )
145+ case " openclaw " : OpenClawView ( status: status, size: size )
146+ case " kiro " : KiroView ( status: status, size: size )
147+ case " kimi " : KimiView ( status: status, size: size )
148+ case " pi " : PiView ( status: status, size: size )
149+ case " cline " : ClineView ( status: status, size: size )
150+ default : ClawdView ( status: status, size: size )
102151 }
103152 }
104153}
0 commit comments