| C1.source/Resources/Aligner-Info.plist | ●●●●● patch | view | raw | blame | history | |
| C1.source/Sources/Aligner/QuickSwitchRootView.swift | ●●●●● patch | view | raw | blame | history | |
| C1.source/Sources/AlignerCore/Thumbnails/NativeSkeletonThumbnailProvider.swift | ●●●●● patch | view | raw | blame | history | |
| C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift | ●●●●● patch | view | raw | blame | history | |
| C3.tools/round1-app-shelf-fixture-qa.sh | ●●●●● patch | view | raw | blame | history | |
| C3.tools/round1-view-mode-entry-qa.sh | ●●●●● patch | view | raw | blame | history |
C1.source/Resources/Aligner-Info.plist
@@ -17,9 +17,9 @@ <key>CFBundlePackageType</key> <string>APPL</string> <key>CFBundleShortVersionString</key> <string>0.0.68</string> <string>0.0.69</string> <key>CFBundleVersion</key> <string>20260614.1827</string> <string>20260614.2031</string> <key>LSMinimumSystemVersion</key> <string>26.0</string> <key>NSHighResolutionCapable</key> C1.source/Sources/Aligner/QuickSwitchRootView.swift
@@ -52,6 +52,7 @@ private var closeFeedback: CloseFeedback? private var closeFeedbackHideTask: Task<Void, Never>? private var isViewModeToggleButtonHovered = false private var suppressViewModeToggleHoverPreview = false private var hoverTarget: HoverTarget = .none private var hoverTargetSource: HoverTargetSource = .event private var hoveredSpaceLaneID: UInt64? @@ -308,6 +309,24 @@ static let bottomMargin: CGFloat = 34 } private enum ViewModeToggleMetrics { static let hitSize: CGFloat = 34 static let visualSize: CGFloat = 30 static let cornerRadius: CGFloat = 9 static let iconInset: CGFloat = 7 static let glyphLineWidth: CGFloat = 1.65 static let glyphCornerRadius: CGFloat = 2.1 static let selectedShadowRadius: CGFloat = 10 static let idleShadowRadius: CGFloat = 5 } private enum ViewModeToggleVisualState: String { case horizontalAvailable = "H1" case horizontalSelected = "H2" case verticalAvailable = "Z1" case verticalSelected = "Z2" } private enum WaterfallMetrics { static let gap: CGFloat = 16 static let columnWidth: CGFloat = 252 @@ -485,8 +504,12 @@ guard debugHoveredAppGroupIndex == nil else { return } let toggleHovered = viewModeToggleButtonContains(windowLocation: event.locationInWindow) if isViewModeToggleButtonHovered != toggleHovered { isViewModeToggleButtonHovered = toggleHovered if !toggleHovered { suppressViewModeToggleHoverPreview = false } let shouldShowToggleHoverPreview = toggleHovered && !suppressViewModeToggleHoverPreview if isViewModeToggleButtonHovered != shouldShowToggleHoverPreview { isViewModeToggleButtonHovered = shouldShowToggleHoverPreview needsLayout = true } if toggleHovered { @@ -506,6 +529,7 @@ override func mouseExited(with event: NSEvent) { guard debugHoveredAppGroupIndex == nil else { return } isViewModeToggleButtonHovered = false suppressViewModeToggleHoverPreview = false hoveredCloseTarget = nil setHoverTarget(.none) } @@ -1082,10 +1106,16 @@ "spaceFilterActive": currentViewModel?.lockedSpaceID != nil, "viewModeToggleButtonVisible": !viewModeToggleButtonLayer.isHidden, "viewModeToggleButtonHovered": isViewModeToggleButtonHovered, "viewModeToggleHoverPreviewSuppressed": suppressViewModeToggleHoverPreview, "viewModeToggleButtonFrame": dictionary(from: viewModeToggleButtonLayer.frame), "viewModeToggleHitSize": Double(ViewModeToggleMetrics.hitSize), "viewModeToggleVisualSize": Double(ViewModeToggleMetrics.visualSize), "viewModeToggleCurrentMode": waterfallViewMode.rawValue, "viewModeToggleTargetMode": nextWaterfallViewMode.rawValue, "viewModeToggleIconKind": viewModeToggleIconKind, "viewModeToggleVisualState": viewModeToggleVisualState.rawValue, "viewModeToggleStandardBlueColor": colorDictionary(from: viewModeToggleBlueColor.cgColor), "viewModeToggleGlyphLineWidth": Double(ViewModeToggleMetrics.glyphLineWidth), "viewModeToggleAccessibilityLabel": viewModeToggleAccessibilityLabel, "settingsButtonVisible": false, "spaceFocusAppGroupIndexes": spaceFocusedAppGroupIndexes(), @@ -1330,6 +1360,12 @@ case "click-background": recordMouseCommand("click-background") _ = onBackgroundClick?() case "hover-view-toggle": recordMouseCommand("hover-view-toggle") suppressViewModeToggleHoverPreview = false isViewModeToggleButtonHovered = true hoveredCloseTarget = nil setHoverTarget(.none, source: .debug) case "click-view-toggle", "click-settings": recordMouseCommand("click-view-toggle") clickViewModeToggleButton(source: "debug") @@ -2650,7 +2686,7 @@ } private func layoutViewModeToggleButton() { let size: CGFloat = 34 let size = ViewModeToggleMetrics.visualSize let topMargin = topSafeAreaInset + RootLayoutMetrics.topContentMargin let x = max( RootLayoutMetrics.horizontalMargin, @@ -2661,16 +2697,23 @@ glassLayer.bounds.height - topMargin - size ) viewModeToggleButtonLayer.frame = CGRect(x: x, y: y, width: size, height: size) viewModeToggleButtonLayer.backgroundColor = NSColor.windowBackgroundColor .withAlphaComponent(isViewModeToggleButtonHovered ? 0.78 : 0.54) .cgColor viewModeToggleButtonLayer.borderColor = NSColor.separatorColor .withAlphaComponent(isViewModeToggleButtonHovered ? 0.42 : 0.26) .cgColor viewModeToggleButtonLayer.shadowOpacity = isViewModeToggleButtonHovered ? 0.14 : 0.08 viewModeToggleButtonLayer.shadowRadius = isViewModeToggleButtonHovered ? 9 : 6 viewModeToggleButtonLayer.cornerRadius = ViewModeToggleMetrics.cornerRadius let visualState = viewModeToggleVisualState let isSelectedState = viewModeToggleVisualStateIsSelected(visualState) viewModeToggleButtonLayer.backgroundColor = (isSelectedState ? viewModeToggleBlueColor : NSColor.windowBackgroundColor.withAlphaComponent(isViewModeToggleButtonHovered ? 0.76 : 0.58) ).cgColor viewModeToggleButtonLayer.borderColor = (isSelectedState ? viewModeToggleBlueColor.withAlphaComponent(0.96) : viewModeToggleBlueColor.withAlphaComponent(isViewModeToggleButtonHovered ? 0.72 : 0.34) ).cgColor viewModeToggleButtonLayer.shadowOpacity = isSelectedState ? 0.16 : (isViewModeToggleButtonHovered ? 0.11 : 0.06) viewModeToggleButtonLayer.shadowRadius = isSelectedState ? ViewModeToggleMetrics.selectedShadowRadius : ViewModeToggleMetrics.idleShadowRadius viewModeToggleIconLayer.contentsScale = backingScaleFactor let iconInset: CGFloat = 7 let iconInset = ViewModeToggleMetrics.iconInset viewModeToggleIconLayer.frame = viewModeToggleButtonLayer.bounds.insetBy(dx: iconInset, dy: iconInset) layoutViewModeToggleIcon() } @@ -2686,39 +2729,28 @@ return } let activeStroke = (isViewModeToggleButtonHovered ? NSColor.controlAccentColor : NSColor.secondaryLabelColor ).withAlphaComponent(isViewModeToggleButtonHovered ? 0.88 : 0.82) let quietStroke = (isViewModeToggleButtonHovered ? NSColor.controlAccentColor : NSColor.secondaryLabelColor ).withAlphaComponent(isViewModeToggleButtonHovered ? 0.34 : 0.24) let subtleFill = (isViewModeToggleButtonHovered ? NSColor.controlAccentColor : NSColor.labelColor ).withAlphaComponent(isViewModeToggleButtonHovered ? 0.06 : 0.025) let visualState = viewModeToggleVisualState let isSelectedState = viewModeToggleVisualStateIsSelected(visualState) let strokeColor = isSelectedState ? NSColor.white.withAlphaComponent(0.98) : viewModeToggleBlueColor.withAlphaComponent(0.96) let fillColor = isSelectedState ? NSColor.white.withAlphaComponent(0.22) : viewModeToggleBlueColor.withAlphaComponent(0.06) let glyphRect = centeredSquare(in: bounds) addViewModeToggleRoundedRect( bounds.insetBy(dx: 0.65, dy: 0.65), cornerRadius: 4.8, strokeColor: quietStroke, fillColor: NSColor.clear, lineWidth: 1.05 ) switch nextWaterfallViewMode { case .verticalColumns: switch visualState { case .verticalAvailable, .verticalSelected: addVerticalColumnsToggleGlyph( in: bounds.insetBy(dx: 4.15, dy: 3.25), strokeColor: activeStroke, fillColor: subtleFill in: glyphRect, strokeColor: strokeColor, fillColor: fillColor ) case .horizontalMasonry: case .horizontalAvailable, .horizontalSelected: addHorizontalMasonryToggleGlyph( in: bounds.insetBy(dx: 3.95, dy: 3.95), strokeColor: activeStroke, fillColor: subtleFill in: glyphRect, strokeColor: strokeColor, fillColor: fillColor ) } @@ -2730,17 +2762,17 @@ strokeColor: NSColor, fillColor: NSColor ) { let columnWidth: CGFloat = 3.35 let gap = max(1.7, (rect.width - columnWidth * 3) / 2) let columnWidth = rect.width * 0.22 let gap = (rect.width - columnWidth * 3) / 2 for index in 0..<3 { let x = rect.minX + CGFloat(index) * (columnWidth + gap) let columnRect = CGRect(x: x, y: rect.minY, width: columnWidth, height: rect.height) addViewModeToggleRoundedRect( columnRect, cornerRadius: 1.6, cornerRadius: ViewModeToggleMetrics.glyphCornerRadius, strokeColor: strokeColor, fillColor: fillColor, lineWidth: 1.22 lineWidth: ViewModeToggleMetrics.glyphLineWidth ) } } @@ -2750,23 +2782,22 @@ strokeColor: NSColor, fillColor: NSColor ) { let cellSize: CGFloat = 3.65 let gapX = max(1.65, (rect.width - cellSize * 3) / 2) let gapY = max(1.65, (rect.height - cellSize * 3) / 2) for row in 0..<3 { for column in 0..<3 { let cellSize = rect.width * 0.38 let gap = rect.width - cellSize * 2 for row in 0..<2 { for column in 0..<2 { let cellRect = CGRect( x: rect.minX + CGFloat(column) * (cellSize + gapX), y: rect.minY + CGFloat(row) * (cellSize + gapY), x: rect.minX + CGFloat(column) * (cellSize + gap), y: rect.minY + CGFloat(row) * (cellSize + gap), width: cellSize, height: cellSize ) addViewModeToggleRoundedRect( cellRect, cornerRadius: 1.15, cornerRadius: ViewModeToggleMetrics.glyphCornerRadius, strokeColor: strokeColor, fillColor: fillColor, lineWidth: 1.18 lineWidth: ViewModeToggleMetrics.glyphLineWidth ) } } @@ -2793,6 +2824,16 @@ layer.lineWidth = lineWidth layer.lineJoin = .round viewModeToggleIconLayer.addSublayer(layer) } private func centeredSquare(in rect: CGRect) -> CGRect { let side = min(rect.width, rect.height) return CGRect( x: rect.midX - side / 2, y: rect.midY - side / 2, width: side, height: side ) } private var topSafeAreaInset: CGFloat { @@ -3150,7 +3191,10 @@ x: locationInView.x - glassLayer.frame.minX, y: locationInView.y - glassLayer.frame.minY ) return viewModeToggleButtonLayer.frame.contains(locationInGlass) let hitOutset = (ViewModeToggleMetrics.hitSize - ViewModeToggleMetrics.visualSize) / 2 return viewModeToggleButtonLayer.frame .insetBy(dx: -hitOutset, dy: -hitOutset) .contains(locationInGlass) } private func clickViewModeToggleButton(source: String) { @@ -3163,6 +3207,8 @@ if !mouseCommandsApplied.contains("click-view-toggle") { mouseCommandsApplied.append("click-view-toggle") } isViewModeToggleButtonHovered = false suppressViewModeToggleHoverPreview = true onToggleWaterfallViewMode?() } @@ -3833,9 +3879,11 @@ width: bounds.width, height: WaterfallMetrics.headerHeight ) column.headerLayer.backgroundColor = (isSpaceFocused ? NSColor.systemFill.withAlphaComponent(0.10) : NSColor.controlBackgroundColor.withAlphaComponent(0.52) column.headerLayer.backgroundColor = (isHovered ? viewModeToggleBlueColor : (isSpaceFocused ? NSColor.systemFill.withAlphaComponent(0.10) : NSColor.controlBackgroundColor.withAlphaComponent(0.52)) ).cgColor column.appNameLayer.frame = CGRect( x: WaterfallMetrics.columnHeaderTextInset, @@ -3843,16 +3891,14 @@ width: max(1, bounds.width - WaterfallMetrics.columnHeaderTextInset * 2), height: 16 ) column.appNameLayer.foregroundColor = (isHovered ? spaceLaneOccupiedColor : NSColor.labelColor ).cgColor column.appNameLayer.foregroundColor = (isHovered ? NSColor.white : NSColor.labelColor).cgColor column.countLayer.frame = CGRect( x: bounds.width - 48, y: bounds.height - 24, width: 36, height: 16 ) column.countLayer.foregroundColor = (isHovered ? NSColor.white : NSColor.secondaryLabelColor).cgColor let appGroupIndex = column.column.appGroupIndex let verticalOffset = clampedWaterfallColumnOffset( @@ -4563,13 +4609,28 @@ isHorizontalMasonryMode ? .verticalColumns : .horizontalMasonry } private var viewModeToggleIconKind: String { switch nextWaterfallViewMode { case .verticalColumns: return "verticalColumnsTarget" case .horizontalMasonry: return "horizontalMasonryTarget" private var viewModeToggleBlueColor: NSColor { NSColor(deviceRed: 135.0 / 255.0, green: 200.0 / 255.0, blue: 1, alpha: 1) } private var viewModeToggleVisualState: ViewModeToggleVisualState { if isHorizontalMasonryMode { return isViewModeToggleButtonHovered ? .verticalAvailable : .horizontalSelected } return isViewModeToggleButtonHovered ? .horizontalAvailable : .verticalSelected } private func viewModeToggleVisualStateIsSelected(_ state: ViewModeToggleVisualState) -> Bool { switch state { case .horizontalSelected, .verticalSelected: return true case .horizontalAvailable, .verticalAvailable: return false } } private var viewModeToggleIconKind: String { viewModeToggleVisualState.rawValue } private var viewModeToggleAccessibilityLabel: String { @@ -5052,12 +5113,14 @@ "frame": dictionary(from: column.containerLayer.frame), "visibleFrame": dictionary(from: visibleFrame), "headerFrame": dictionary(from: column.headerLayer.frame), "headerBackgroundColor": colorDictionary(from: column.headerLayer.backgroundColor), "headerToFirstCardGap": headerToFirstCardGap.map(Double.init) ?? NSNull(), "topToFirstCardGap": topToFirstCardGap.map(Double.init) ?? NSNull(), "appNameFrame": dictionary(from: column.appNameLayer.frame), "appNameAlignment": column.appNameLayer.alignmentMode.rawValue, "appNameColor": colorDictionary(from: column.appNameLayer.foregroundColor), "countFrame": dictionary(from: column.countLayer.frame), "countColor": colorDictionary(from: column.countLayer.foregroundColor), "verticalScrollOffset": Double(waterfallColumnScrollOffsets[appGroupIndex] ?? 0), "maxVerticalScrollOffset": Double(waterfallMaxVerticalScrollOffset(for: appGroupIndex)), "cards": waterfallCardReports(for: column) C1.source/Sources/AlignerCore/Thumbnails/NativeSkeletonThumbnailProvider.swift
@@ -34,6 +34,13 @@ } } public enum SkeletonThumbnailPalette { public static let headerAlpha: CGFloat = 0.10 public static let contentRowAlpha: CGFloat = 0.075 public static let accentStripeAlpha: CGFloat = 0.28 public static let titleBandAlpha: CGFloat = 0.86 } @MainActor public final class NativeSkeletonThumbnailProvider: SkeletonThumbnailProviderProtocol { public static let defaultSize = NSSize(width: 120, height: 80) @@ -79,7 +86,7 @@ width: bounds.width, height: template.headerHeight ) color(for: template.category).withAlphaComponent(0.22).setFill() color(for: template.category).withAlphaComponent(SkeletonThumbnailPalette.headerAlpha).setFill() NSBezierPath(roundedRect: header, xRadius: 6, yRadius: 6).fill() } @@ -104,7 +111,7 @@ width: maxWidth * widthRatio, height: rowHeight ) NSColor.secondaryLabelColor.withAlphaComponent(0.18).setFill() NSColor.secondaryLabelColor.withAlphaComponent(SkeletonThumbnailPalette.contentRowAlpha).setFill() NSBezierPath(roundedRect: rect, xRadius: 3, yRadius: 3).fill() } } @@ -120,14 +127,14 @@ width: stripeWidth, height: max(4, template.headerHeight - 10) ) color(for: template.category).withAlphaComponent(0.55).setFill() color(for: template.category).withAlphaComponent(SkeletonThumbnailPalette.accentStripeAlpha).setFill() NSBezierPath(roundedRect: rect, xRadius: 3, yRadius: 3).fill() } } private func drawTitle(_ title: String, in bounds: NSRect) { let titleBand = NSRect(x: 0, y: 0, width: bounds.width, height: 24) NSColor.windowBackgroundColor.withAlphaComponent(0.86).setFill() NSColor.windowBackgroundColor.withAlphaComponent(SkeletonThumbnailPalette.titleBandAlpha).setFill() NSBezierPath(roundedRect: titleBand, xRadius: 6, yRadius: 6).fill() let paragraph = NSMutableParagraphStyle() C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
@@ -707,6 +707,13 @@ XCTAssertTrue(templates.allSatisfy { $0.contentRows > 0 }) } func testSkeletonThumbnailPaletteStaysQuietForFallbackState() { XCTAssertLessThanOrEqual(SkeletonThumbnailPalette.headerAlpha, 0.10) XCTAssertLessThanOrEqual(SkeletonThumbnailPalette.contentRowAlpha, 0.08) XCTAssertLessThanOrEqual(SkeletonThumbnailPalette.accentStripeAlpha, 0.30) XCTAssertGreaterThanOrEqual(SkeletonThumbnailPalette.titleBandAlpha, 0.80) } @MainActor func testNativeSkeletonThumbnailProviderProducesValidImages() { let provider = NativeSkeletonThumbnailProvider() C3.tools/round1-app-shelf-fixture-qa.sh
@@ -140,28 +140,29 @@ require(items[0].get("iconFrame", {}).get("width") == expected_icon_size, "non-hovered selected app icon must match normal fixture icon size") require(items[hover_index].get("iconFrame", {}).get("width") == 68, "hovered app icon must be 68pt") blue_reference = next( ( color for segment in segments for color in segment.get("windowBlockColors", []) if color.get("alpha", 0) > 0 ), None ) require(blue_reference is not None, "fixture must expose a Space Lane blue reference color") blue_reference = {"red": 135 / 255, "green": 200 / 255, "blue": 1.0, "alpha": 1.0} hovered_title_color = columns[hover_index].get("appNameColor", {}) hovered_header_color = columns[hover_index].get("headerBackgroundColor", {}) hovered_count_color = columns[hover_index].get("countColor", {}) for channel in ("red", "green", "blue"): require( abs(hovered_title_color.get(channel, -1) - blue_reference.get(channel, -2)) <= 0.002, f"hovered column title must use the same blue reference for {channel}" abs(hovered_header_color.get(channel, -1) - blue_reference.get(channel, -2)) <= 0.002, f"hovered column header must use #87C8FF for {channel}" ) require(hovered_title_color.get("alpha", 0) >= 0.98, "hovered column title color must be opaque") require(hovered_header_color.get("alpha", 0) >= 0.98, "hovered column header blue fill must be opaque") for color_name, color in (("title", hovered_title_color), ("count", hovered_count_color)): for channel in ("red", "green", "blue", "alpha"): require(abs(color.get(channel, -1) - 1.0) <= 0.002, f"hovered column {color_name} text must be pure white for {channel}") for index, column in enumerate(columns): if index == hover_index: continue require(column.get("isHovered") is False, "non-hovered columns must not be marked hovered") require("hover" not in column.get("visualStates", []), "non-hovered columns must not expose hover visual state") normal_header = column.get("headerBackgroundColor", {}) require( any(abs(normal_header.get(channel, -1) - blue_reference[channel]) > 0.05 for channel in ("red", "green", "blue")), "non-hovered column headers must not use the hover blue fill" ) normal_items = [ item for item in items @@ -187,7 +188,9 @@ "selectedIcon": items[0].get("iconFrame"), "hoveredIndex": root.get("appShelfHoveredIndex"), "hoveredIcon": items[hover_index].get("iconFrame"), "hoveredColumnHeaderColor": hovered_header_color, "hoveredColumnTitleColor": hovered_title_color, "hoveredColumnCountColor": hovered_count_color, "blueReference": blue_reference, "selectedZ": selected_z, "hoverZ": hover_z, C3.tools/round1-view-mode-entry-qa.sh
@@ -12,6 +12,8 @@ APP="$BUILD_CURRENT_APP" REPORT_VERTICAL="$BUILD_REPORT_ROOT/round01-view-mode-entry-vertical-report.json" REPORT_HORIZONTAL="$BUILD_REPORT_ROOT/round01-view-mode-entry-horizontal-report.json" REPORT_VERTICAL_HOVER="$BUILD_REPORT_ROOT/round01-view-mode-entry-vertical-hover-report.json" REPORT_HORIZONTAL_HOVER="$BUILD_REPORT_ROOT/round01-view-mode-entry-horizontal-hover-report.json" REPORT_TOGGLE_TO_HORIZONTAL="$BUILD_REPORT_ROOT/round01-view-mode-entry-toggle-to-horizontal-report.json" REPORT_TOGGLE_TO_VERTICAL="$BUILD_REPORT_ROOT/round01-view-mode-entry-toggle-to-vertical-report.json" DOMAIN="com.ar.Aligner" @@ -137,8 +139,9 @@ settings_window = report.get("settingsWindow", {}) root = report.get("rootView", {}) expected_target_mode = "verticalColumns" if expected_mode == "horizontalMasonry" else "horizontalMasonry" expected_icon_kind = "verticalColumnsTarget" if expected_target_mode == "verticalColumns" else "horizontalMasonryTarget" expected_icon_kind = "H2" if expected_mode == "horizontalMasonry" else "Z2" expected_accessibility_label = "切换到纵栏瀑布" if expected_target_mode == "verticalColumns" else "切换到横栏瀑布" standard_blue = {"red": 135 / 255, "green": 200 / 255, "blue": 1.0, "alpha": 1.0} require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true") require(report.get("quickSwitchVisible") is True, "Quick Switch must be visible during entry QA") @@ -161,12 +164,18 @@ require(root.get("settingsButtonVisible") is False, "Quick Switch must not expose the old top-right Settings button") require(root.get("viewModeToggleButtonVisible") is True, "Quick Switch must expose a top-right view-mode toggle button") toggle_frame = root.get("viewModeToggleButtonFrame", {}) require(toggle_frame.get("width", 0) >= 30 and toggle_frame.get("height", 0) >= 30, "view-mode toggle button must have tappable size") require(toggle_frame.get("width", 0) == 30 and toggle_frame.get("height", 0) == 30, "view-mode toggle visual button must be 30pt") require(root.get("viewModeToggleHitSize") == 34, "view-mode toggle must keep a 34pt hit target") require(toggle_frame.get("x", 0) + toggle_frame.get("width", 0) > root.get("bounds", {}).get("width", 0) * 0.90, "view-mode toggle button must sit near the top-right edge") require(root.get("viewModeToggleCurrentMode") == expected_mode, "view-mode toggle must report the current mode") require(root.get("viewModeToggleTargetMode") == expected_target_mode, "view-mode toggle must report the next target mode") require(root.get("viewModeToggleIconKind") == expected_icon_kind, "view-mode toggle icon kind must represent the target mode") require(root.get("viewModeToggleIconKind") == expected_icon_kind, "view-mode toggle icon kind must represent the selected current mode") require(root.get("viewModeToggleVisualState") == expected_icon_kind, "view-mode toggle visual state must match the selected current mode") require(root.get("viewModeToggleAccessibilityLabel") == expected_accessibility_label, "view-mode toggle label must name the target mode") blue = root.get("viewModeToggleStandardBlueColor", {}) for channel, expected in standard_blue.items(): require(abs(blue.get(channel, -1) - expected) <= 0.002, f"view-mode toggle standard blue must be #87C8FF for {channel}") require(abs(root.get("viewModeToggleGlyphLineWidth", 0) - 1.65) <= 0.001, "view-mode toggle glyphs must share the standard line width") require(settings_window.get("visible") is False, "Settings window should not be open in passive view-mode entry cases") require(report.get("aboutVersion") == app_version, "About version must match app Info.plist") require(report.get("aboutBuild") == app_build, "About build must match app Info.plist") @@ -181,6 +190,88 @@ "mainMenu": main_menu, "viewModeToggleButtonFrame": toggle_frame, "viewModeToggleTargetMode": root.get("viewModeToggleTargetMode"), "viewModeToggleIconKind": root.get("viewModeToggleIconKind"), "viewModeToggleVisualState": root.get("viewModeToggleVisualState") }, indent=2, ensure_ascii=False)) PY } wait_for_hover_report() { local report="$1" local expected_mode="$2" /usr/bin/python3 - "$report" "$expected_mode" "$REPORT_WAIT" <<'PY' import json import sys import time path = sys.argv[1] expected_mode = sys.argv[2] timeout = float(sys.argv[3]) deadline = time.monotonic() + timeout last_report = None while time.monotonic() < deadline: try: with open(path, "r", encoding="utf-8") as file: report = json.load(file) last_report = report root = report.get("rootView", {}) if ( report.get("snapshotLoaded") is True and report.get("quickSwitchVisible") is True and report.get("resolvedWaterfallViewMode") == expected_mode and root.get("waterfallViewMode") == expected_mode and root.get("lastMouseCommand") == "hover-view-toggle" ): sys.exit(0) except FileNotFoundError: pass except json.JSONDecodeError: pass time.sleep(0.2) if last_report is not None: print(json.dumps(last_report, indent=2, ensure_ascii=False), file=sys.stderr) print(f"view-mode hover report did not reach {expected_mode} within {timeout:.1f}s", file=sys.stderr) sys.exit(1) PY } assert_hover_report() { local report="$1" local expected_mode="$2" local expected_visual_state="$3" /usr/bin/python3 - "$report" "$expected_mode" "$expected_visual_state" <<'PY' import json import sys path = sys.argv[1] expected_mode = sys.argv[2] expected_visual_state = sys.argv[3] with open(path, "r", encoding="utf-8") as file: report = json.load(file) def require(condition, message): if not condition: print(message, file=sys.stderr) print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr) sys.exit(1) root = report.get("rootView", {}) require(root.get("waterfallViewMode") == expected_mode, "hover case must keep current view mode") require(root.get("lastMouseCommand") == "hover-view-toggle", "hover case must use the view toggle hover command") require(root.get("viewModeToggleButtonHovered") is True, "hover case must mark the toggle hovered") require(root.get("viewModeToggleHoverPreviewSuppressed") is False, "hover preview must not be suppressed in hover case") require(root.get("viewModeToggleVisualState") == expected_visual_state, "hover case must expose the target unselected visual state") require(root.get("viewModeToggleIconKind") == expected_visual_state, "hover icon kind must match visual state") print(json.dumps({ "case": "view-mode-hover", "mode": expected_mode, "viewModeToggleVisualState": root.get("viewModeToggleVisualState"), "viewModeToggleIconKind": root.get("viewModeToggleIconKind") }, indent=2, ensure_ascii=False)) PY @@ -262,7 +353,7 @@ status_menu = report.get("statusMenu", {}) main_menu = report.get("mainMenu", {}) settings_window = report.get("settingsWindow", {}) expected_icon_kind = "verticalColumnsTarget" if initial_mode == "verticalColumns" else "horizontalMasonryTarget" expected_icon_kind = "H2" if expected_mode == "horizontalMasonry" else "Z2" expected_label = "切换到纵栏瀑布" if initial_mode == "verticalColumns" else "切换到横栏瀑布" require(report.get("snapshotLoaded") is True, "toggle case must load the snapshot before clicking the view-mode toggle") @@ -275,7 +366,9 @@ require(root.get("viewModeToggleButtonVisible") is True, "Quick Switch root must report the view-mode toggle") require(root.get("viewModeToggleCurrentMode") == expected_mode, "toggle current mode must update after click") require(root.get("viewModeToggleTargetMode") == initial_mode, "toggle target mode must flip back to the initial mode after click") require(root.get("viewModeToggleIconKind") == expected_icon_kind, "toggle icon must represent the next target mode after click") require(root.get("viewModeToggleIconKind") == expected_icon_kind, "toggle icon must represent the selected mode after click") require(root.get("viewModeToggleVisualState") == expected_icon_kind, "toggle visual state must represent the selected mode after click") require(root.get("viewModeToggleHoverPreviewSuppressed") is True, "click should suppress hover preview until pointer exits") require(root.get("viewModeToggleAccessibilityLabel") == expected_label, "toggle label must represent the next target mode after click") require(status_menu.get("verticalColumnsState") == expected_vertical_state, "Vertical Columns menu state must update after toggle") require(status_menu.get("horizontalWaterfallState") == expected_horizontal_state, "Horizontal Waterfall menu state must update after toggle") @@ -292,6 +385,7 @@ "quickSwitchVisible": report.get("quickSwitchVisible"), "lastMouseCommand": root.get("lastMouseCommand"), "viewModeToggleIconKind": root.get("viewModeToggleIconKind"), "viewModeToggleVisualState": root.get("viewModeToggleVisualState"), "statusMenu": status_menu, "mainMenu": main_menu }, indent=2, ensure_ascii=False)) @@ -322,6 +416,31 @@ wait_for_loaded_report "$report" "$mode" assert_report "$report" "$mode" "$vertical_state" "$horizontal_state" "$app_version" "$app_build" wait "$!" || true stop_current_aligner } run_hover_case() { local mode="$1" local expected_visual_state="$2" local report="$3" stop_current_aligner rm -f "$report" /usr/bin/defaults write "$DOMAIN" "$WATERFALL_MODE_KEY" -string "$mode" "$APP/Contents/MacOS/Aligner" \ --round0-skip-permissions \ --round01-open-quick-switch \ --round01-fixture-app-count=4 \ --round01-fixture-windows-per-app=2 \ --round01-disable-screenshot-refresh \ --round01-debug-mouse-sequence="hover-view-toggle" \ --round01-quick-switch-report="$report" \ --round01-quick-switch-quit-after=1.2 & wait_for_hover_report "$report" "$mode" assert_hover_report "$report" "$mode" "$expected_visual_state" wait "$!" || true stop_current_aligner } @@ -367,6 +486,8 @@ run_case "verticalColumns" "$REPORT_VERTICAL" "on" "off" "$APP_VERSION" "$APP_BUILD" run_case "horizontalMasonry" "$REPORT_HORIZONTAL" "off" "on" "$APP_VERSION" "$APP_BUILD" run_hover_case "verticalColumns" "H1" "$REPORT_VERTICAL_HOVER" run_hover_case "horizontalMasonry" "Z1" "$REPORT_HORIZONTAL_HOVER" run_toggle_case "verticalColumns" "horizontalMasonry" "$REPORT_TOGGLE_TO_HORIZONTAL" "off" "on" "$APP_VERSION" "$APP_BUILD" run_toggle_case "horizontalMasonry" "verticalColumns" "$REPORT_TOGGLE_TO_VERTICAL" "on" "off" "$APP_VERSION" "$APP_BUILD"