From 35310514737c21ca1359c5cb87a0270336d3047d Mon Sep 17 00:00:00 2001 From: Geonwoo Lee Date: Sat, 19 Sep 2026 20:42:21 +0900 Subject: [PATCH] fix: disable unavailable Graphic Lab color counts --- CHANGELOG.md | 11 ++ .../PaletteKitDemo/Card/CardLabView.swift | 24 +++- .../Card/GraphicColorCountPicker.swift | 48 ++++++++ .../Card/GraphicColorOptions.swift | 25 ++++ .../GraphicColorCountPickerTests.swift | 107 ++++++++++++++++++ .../GraphicColorOptionsTests.swift | 80 +++++++++++++ Examples/PaletteKitDemo/README.md | 15 +++ Examples/PaletteKitDemo/project.yml | 21 ++++ .../PaletteKit/Graphic/PaletteGraphic.swift | 17 +++ .../Graphic/PaletteGraphicRenderer.swift | 24 +++- .../PaletteGraphicResolvedStopsTests.swift | 82 ++++++++++++++ 11 files changed, 444 insertions(+), 10 deletions(-) create mode 100644 Examples/PaletteKitDemo/PaletteKitDemo/Card/GraphicColorCountPicker.swift create mode 100644 Examples/PaletteKitDemo/PaletteKitDemo/Card/GraphicColorOptions.swift create mode 100644 Examples/PaletteKitDemo/PaletteKitDemoTests/GraphicColorCountPickerTests.swift create mode 100644 Examples/PaletteKitDemo/PaletteKitDemoTests/GraphicColorOptionsTests.swift create mode 100644 Tests/PaletteKitTests/PaletteGraphicResolvedStopsTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index b2018b5..6b18e2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to PaletteKit are documented here. +## Unreleased + +### Added +- `PaletteGraphic.resolvedStopColors` exposes the actual ordered gradient stops + without rendering an image, including repeated anchors for single-color results. + +### Fixed +- Graphic Lab enables only color counts supported by the resolved gradient, + preserves valid selections, adjusts unavailable selections, and explains + single-color results with all color-count options disabled. + ## 2.1.1 ### Fixed diff --git a/Examples/PaletteKitDemo/PaletteKitDemo/Card/CardLabView.swift b/Examples/PaletteKitDemo/PaletteKitDemo/Card/CardLabView.swift index 7221f45..7712292 100644 --- a/Examples/PaletteKitDemo/PaletteKitDemo/Card/CardLabView.swift +++ b/Examples/PaletteKitDemo/PaletteKitDemo/Card/CardLabView.swift @@ -59,7 +59,7 @@ struct CardLabView: View { direction: direction, linearStart: axis.start, linearEnd: axis.end, - colorCount: colorCount, + colorCount: colorOptions.selection(preserving: colorCount), swatchStrategy: strategy, grain: grain ) @@ -118,6 +118,10 @@ struct CardLabView: View { ResolvedColors(palette: palette, swatches: swatches, strategy: strategy) } + private var colorOptions: GraphicColorOptions { + GraphicColorOptions(palette: palette, swatches: swatches, strategy: strategy) + } + var body: some View { let cp = resolvedColors VStack(spacing: 12) { @@ -155,6 +159,9 @@ struct CardLabView: View { .background(Color(cp.background).opacity(0.55).ignoresSafeArea()) .navigationTitle("Graphic Lab") .navigationBarTitleDisplayMode(.inline) + .onChange(of: colorOptions, initial: true) { _, options in + colorCount = options.selection(preserving: colorCount) + } .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { @@ -187,10 +194,19 @@ struct CardLabView: View { } private var stopsPicker: some View { - Picker("Colors", selection: $colorCount) { - ForEach(ColorCount.allCases) { c in Text("\(c.rawValue)").tag(c) } + let options = colorOptions + return VStack(spacing: 4) { + GraphicColorCountPicker(selection: $colorCount, options: options) + .fixedSize(horizontal: false, vertical: true) + + if options.isSingleColor { + Text("The current combination produces a single color.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .accessibilityIdentifier("graphic.singleColor") + } } - .pickerStyle(.segmented) } private var strategyPicker: some View { diff --git a/Examples/PaletteKitDemo/PaletteKitDemo/Card/GraphicColorCountPicker.swift b/Examples/PaletteKitDemo/PaletteKitDemo/Card/GraphicColorCountPicker.swift new file mode 100644 index 0000000..cbff6f4 --- /dev/null +++ b/Examples/PaletteKitDemo/PaletteKitDemo/Card/GraphicColorCountPicker.swift @@ -0,0 +1,48 @@ +import PaletteKit +import SwiftUI +import UIKit + +/// UIKit supplies per-segment disabled interaction and accessibility semantics. +/// SwiftUI's segmented Picker does not consistently honor child `.disabled`. +struct GraphicColorCountPicker: UIViewRepresentable { + @Binding var selection: ColorCount + let options: GraphicColorOptions + + func makeUIView(context: Context) -> UISegmentedControl { + let control = UISegmentedControl(items: ColorCount.allCases.map { String($0.rawValue) }) + control.accessibilityLabel = "Colors" + control.accessibilityIdentifier = "graphic.colors" + control.addTarget(context.coordinator, action: #selector(Coordinator.selectCount(_:)), for: .valueChanged) + return control + } + + func updateUIView(_ control: UISegmentedControl, context: Context) { + context.coordinator.parent = self + for (index, count) in ColorCount.allCases.enumerated() { + control.setEnabled(options.availableCounts.contains(count), forSegmentAt: index) + } + let resolved = options.selection(preserving: selection) + control.selectedSegmentIndex = options.isSingleColor + ? UISegmentedControl.noSegment + : ColorCount.allCases.firstIndex(of: resolved) ?? UISegmentedControl.noSegment + control.accessibilityHint = options.isSingleColor + ? "The current combination produces a single color." + : "Unavailable color counts are dimmed." + } + + func makeCoordinator() -> Coordinator { Coordinator(parent: self) } + + @MainActor + final class Coordinator: NSObject { + var parent: GraphicColorCountPicker + + init(parent: GraphicColorCountPicker) { self.parent = parent } + + @objc func selectCount(_ sender: UISegmentedControl) { + guard ColorCount.allCases.indices.contains(sender.selectedSegmentIndex) else { return } + let count = ColorCount.allCases[sender.selectedSegmentIndex] + guard parent.options.availableCounts.contains(count) else { return } + parent.selection = count + } + } +} diff --git a/Examples/PaletteKitDemo/PaletteKitDemo/Card/GraphicColorOptions.swift b/Examples/PaletteKitDemo/PaletteKitDemo/Card/GraphicColorOptions.swift new file mode 100644 index 0000000..d0e12ff --- /dev/null +++ b/Examples/PaletteKitDemo/PaletteKitDemo/Card/GraphicColorOptions.swift @@ -0,0 +1,25 @@ +import PaletteKit + +/// Demo policy built from the renderer's actual stops, not its candidate rules. +struct GraphicColorOptions: Equatable { + let availableCounts: [ColorCount] + + @MainActor + init(palette: Palette, swatches: SwatchMap?, strategy: SwatchStrategy) { + availableCounts = ColorCount.allCases.filter { count in + let graphic = PaletteGraphic( + palette: palette, swatches: swatches, + configuration: .init(colorCount: count, swatchStrategy: strategy) + ) + return Set(graphic.resolvedStopColors.map(\.rgb)).count == count.rawValue + } + } + + var isSingleColor: Bool { availableCounts.isEmpty } + + /// Preserve a valid selection; otherwise use the largest available count. + /// Two is the rendering fallback for a single color, with no segment selected. + func selection(preserving count: ColorCount) -> ColorCount { + availableCounts.contains(count) ? count : availableCounts.last ?? .two + } +} diff --git a/Examples/PaletteKitDemo/PaletteKitDemoTests/GraphicColorCountPickerTests.swift b/Examples/PaletteKitDemo/PaletteKitDemoTests/GraphicColorCountPickerTests.swift new file mode 100644 index 0000000..a105ead --- /dev/null +++ b/Examples/PaletteKitDemo/PaletteKitDemoTests/GraphicColorCountPickerTests.swift @@ -0,0 +1,107 @@ +import PaletteKit +import SwiftUI +import UIKit +import XCTest +@testable import PaletteKitDemo + +@MainActor +final class GraphicColorCountPickerTests: XCTestCase { + func testHostedLabUpdatesSegmentsAndSelectionWhenInputsChange() throws { + let host = UIHostingController(rootView: lab([.white, gray(200), gray(140), gray(70), .black])) + let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let window = UIWindow(windowScene: scene) + window.rootViewController = host + window.makeKeyAndVisible() + host.view.layoutIfNeeded() + defer { window.isHidden = true } + + waitUntil { Self.colorPicker(in: host.view) != nil } + let control = try XCTUnwrap(Self.colorPicker(in: host.view)) + XCTAssertEqual(control.accessibilityLabel, "Colors") + assertSegments(control, enabled: [0, 1, 2, 3], selected: 0) + + control.selectedSegmentIndex = 3 + control.sendActions(for: .valueChanged) + host.rootView = lab([.white, gray(140), .black]) + waitUntil { control.selectedSegmentIndex == 1 && !control.isEnabledForSegment(at: 2) } + assertSegments(control, enabled: [0, 1], selected: 1) + + host.rootView = lab([.white, gray(200), gray(140), gray(70), .black]) + waitUntil { control.isEnabledForSegment(at: 3) } + assertSegments(control, enabled: [0, 1, 2, 3], selected: 1) + + host.rootView = lab([.black]) + waitUntil { control.selectedSegmentIndex == UISegmentedControl.noSegment } + assertSegments(control, enabled: [], selected: UISegmentedControl.noSegment) + XCTAssertEqual(control.accessibilityHint, "The current combination produces a single color.") + + host.rootView = lab([.white, .black]) + waitUntil { control.isEnabledForSegment(at: 0) } + assertSegments(control, enabled: [0], selected: 0) + } + + func testHostedLabCorrectsSelectionOnStrategyChange() throws { + // The darkest color is dominant: Vibrant and Muted collapse to one + // color, while Contrast uses the lightest and darkest palette colors. + let host = UIHostingController(rootView: lab([.black, gray(140), .white])) + let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let window = UIWindow(windowScene: scene) + window.rootViewController = host + window.makeKeyAndVisible() + host.view.layoutIfNeeded() + defer { window.isHidden = true } + + waitUntil { Self.colorPicker(in: host.view) != nil } + let control = try XCTUnwrap(Self.colorPicker(in: host.view)) + let strategy = try XCTUnwrap(Self.segments(in: host.view).first { $0.titleForSegment(at: 0) == "Vibrant" }) + assertSegments(control, enabled: [], selected: UISegmentedControl.noSegment) + + strategy.selectedSegmentIndex = 1 + strategy.sendActions(for: .valueChanged) + waitUntil { control.isEnabledForSegment(at: 1) } + assertSegments(control, enabled: [0, 1], selected: 0) + control.selectedSegmentIndex = 1 + control.sendActions(for: .valueChanged) + + strategy.selectedSegmentIndex = 2 + strategy.sendActions(for: .valueChanged) + waitUntil { control.selectedSegmentIndex == UISegmentedControl.noSegment } + assertSegments(control, enabled: [], selected: UISegmentedControl.noSegment) + + strategy.selectedSegmentIndex = 1 + strategy.sendActions(for: .valueChanged) + waitUntil { control.selectedSegmentIndex == 0 } + assertSegments(control, enabled: [0, 1], selected: 0) + } + + private func assertSegments(_ control: UISegmentedControl, enabled: Set, selected: Int, + file: StaticString = #filePath, line: UInt = #line) { + XCTAssertEqual(control.numberOfSegments, 4, file: file, line: line) + XCTAssertEqual(control.selectedSegmentIndex, selected, file: file, line: line) + for index in 0..<4 { + XCTAssertEqual(control.isEnabledForSegment(at: index), enabled.contains(index), file: file, line: line) + } + } + + private func waitUntil(_ condition: @escaping @MainActor @Sendable () -> Bool, + file: StaticString = #filePath, line: UInt = #line) { + let expectation = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in + MainActor.assumeIsolated { condition() } + }, object: nil) + XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: 3), .completed, file: file, line: line) + } + + private static func colorPicker(in view: UIView) -> UISegmentedControl? { + segments(in: view).first { $0.accessibilityIdentifier == "graphic.colors" } + } + + private static func segments(in view: UIView) -> [UISegmentedControl] { + (view as? UISegmentedControl).map { [$0] } ?? view.subviews.flatMap { segments(in: $0) } + } + + private func lab(_ colors: [PaletteColor]) -> CardLabView { + CardLabView(palette: Palette(colors: colors, colorSpaceUsed: .oklch), swatches: nil) + } + + private func gray(_ value: UInt8) -> PaletteColor { PaletteColor(r: value, g: value, b: value) } +} diff --git a/Examples/PaletteKitDemo/PaletteKitDemoTests/GraphicColorOptionsTests.swift b/Examples/PaletteKitDemo/PaletteKitDemoTests/GraphicColorOptionsTests.swift new file mode 100644 index 0000000..b66ddd0 --- /dev/null +++ b/Examples/PaletteKitDemo/PaletteKitDemoTests/GraphicColorOptionsTests.swift @@ -0,0 +1,80 @@ +import Testing +@testable import PaletteKit +@testable import PaletteKitDemo + +@MainActor +@Suite("Graphic Lab color options") +struct GraphicColorOptionsTests { + @Test("Single-color and empty palettes disable every count") + func singleColor() { + for colors in [[], [.black], [.white, .white]] as [[PaletteColor]] { + let options = options(colors) + #expect(options.availableCounts.isEmpty) + #expect(options.isSingleColor) + for count in ColorCount.allCases { + #expect(options.selection(preserving: count) == .two) + } + } + } + + @Test("Only achievable counts are available", arguments: 0...3) + func candidateLimits(middleCount: Int) { + let colors = [PaletteColor.white] + [gray(200), gray(140), gray(70)].prefix(middleCount) + [.black] + let options = options(colors) + #expect(options.availableCounts == Array(ColorCount.allCases.prefix(middleCount + 1))) + #expect(!options.isSingleColor) + for count in ColorCount.allCases { + #expect(options.selection(preserving: count).rawValue == min(count.rawValue, middleCount + 2)) + } + } + + @Test("Strategy changes correct unavailable selections and preserve valid ones") + func strategyChanges() { + let palette = Palette(colors: [.white, gray(200), gray(70), .black], colorSpaceUsed: .oklch) + let swatches = SwatchMap( + vibrant: swatch(.black, .vibrant), + muted: swatch(.white, .muted), + darkVibrant: swatch(.black, .darkVibrant), + lightVibrant: swatch(gray(70), .lightVibrant) + ) + let vibrant = GraphicColorOptions(palette: palette, swatches: swatches, strategy: .vibrant) + let contrast = GraphicColorOptions(palette: palette, swatches: swatches, strategy: .contrast) + let muted = GraphicColorOptions(palette: palette, swatches: swatches, strategy: .muted) + #expect(vibrant.availableCounts == []) + #expect(contrast.availableCounts == [.two]) + #expect(muted.availableCounts == [.two, .three, .four]) + var selection = ColorCount.five + selection = muted.selection(preserving: selection) + #expect(selection == .four) + selection = contrast.selection(preserving: selection) + #expect(selection == .two) + #expect(muted.selection(preserving: selection) == .two) + #expect(vibrant.selection(preserving: .four) == .two) + #expect(muted.selection(preserving: .three) == .three) + } + + @Test("Changing the image or swatches recomputes availability") + func changedInputs() { + let full = options([.white, gray(200), gray(140), gray(70), .black]) + let fewer = options([.white, gray(140), .black]) + #expect(full.selection(preserving: .five) == .five) + #expect(fewer.selection(preserving: .five) == .three) + #expect(full.selection(preserving: .three) == .three) + + let palette = Palette(colors: [.white, gray(140), .black], colorSpaceUsed: .oklch) + let collapsed = GraphicColorOptions(palette: palette, + swatches: SwatchMap(vibrant: swatch(.black, .vibrant)), strategy: .vibrant) + #expect(collapsed.isSingleColor) + #expect(collapsed.selection(preserving: .three) == .two) + } + + private func options(_ colors: [PaletteColor]) -> GraphicColorOptions { + GraphicColorOptions(palette: Palette(colors: colors, colorSpaceUsed: .oklch), swatches: nil, strategy: .vibrant) + } + + private func gray(_ value: UInt8) -> PaletteColor { PaletteColor(r: value, g: value, b: value) } + + private func swatch(_ color: PaletteColor, _ role: SwatchRole) -> Swatch { + Swatch(color: color, role: role, titleTextColor: .white, bodyTextColor: .white) + } +} diff --git a/Examples/PaletteKitDemo/README.md b/Examples/PaletteKitDemo/README.md index 085a13e..5626529 100644 --- a/Examples/PaletteKitDemo/README.md +++ b/Examples/PaletteKitDemo/README.md @@ -43,3 +43,18 @@ if it isn't already present. Subsequent runs skip that step. The generated `PaletteKitDemo.xcodeproj` is **not committed**. It is regenerated on every `make demo-app` from `project.yml`, so the source of truth is the YAML plus the Swift/plist files in this folder. + +## Graphic Lab regression tests + +The demo tests cover available color counts, selection correction, and hosted +segmented-control updates using synthetic palettes. From the repo root: + +```sh +xcodegen generate --spec Examples/PaletteKitDemo/project.yml +xcodebuild test \ + -project Examples/PaletteKitDemo/PaletteKitDemo.xcodeproj \ + -scheme PaletteKitDemo \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' +``` + +Choose an installed simulator name or use `-destination 'id='`. diff --git a/Examples/PaletteKitDemo/project.yml b/Examples/PaletteKitDemo/project.yml index 8a1e54b..9d3873d 100644 --- a/Examples/PaletteKitDemo/project.yml +++ b/Examples/PaletteKitDemo/project.yml @@ -47,3 +47,24 @@ targets: TARGETED_DEVICE_FAMILY: "1,2" SUPPORTS_MACCATALYST: "NO" CODE_SIGN_STYLE: Automatic + + PaletteKitDemoTests: + type: bundle.unit-test + platform: iOS + sources: + - path: PaletteKitDemoTests + dependencies: + - target: PaletteKitDemo + - package: PaletteKit + settings: + base: + GENERATE_INFOPLIST_FILE: "YES" + +schemes: + PaletteKitDemo: + build: + targets: + PaletteKitDemo: all + test: + targets: + - PaletteKitDemoTests diff --git a/Sources/PaletteKit/Graphic/PaletteGraphic.swift b/Sources/PaletteKit/Graphic/PaletteGraphic.swift index 65b3541..8d2d7d6 100644 --- a/Sources/PaletteKit/Graphic/PaletteGraphic.swift +++ b/Sources/PaletteKit/Graphic/PaletteGraphic.swift @@ -63,6 +63,23 @@ public struct PaletteGraphic: View { } } + /// The actual gradient stop colors, ordered from lightest to darkest. + /// + /// Uses the same resolution as rendering, without creating an image. + /// There may be fewer stops than ``Configuration/colorCount`` requests + /// when no more eligible colors are available. Identical anchors remain + /// as two stops, so use distinct RGB values to detect a single-color + /// result or to decide whether a requested count is available: + /// + /// ```swift + /// let distinctCount = Set(graphic.resolvedStopColors.map(\.rgb)).count + /// ``` + public var resolvedStopColors: [PaletteColor] { + PaletteGraphicRenderer.resolveStopColors( + palette: palette, swatches: swatches, configuration: configuration + ) + } + /// Render this graphic to a `UIImage` at the given logical size. /// Bypasses SwiftUI's view hierarchy. Result is rectangular — apply /// your own clipping (`UIBezierPath` mask, `CALayer.mask`, …) for diff --git a/Sources/PaletteKit/Graphic/PaletteGraphicRenderer.swift b/Sources/PaletteKit/Graphic/PaletteGraphicRenderer.swift index 422b0e1..a28ce9d 100644 --- a/Sources/PaletteKit/Graphic/PaletteGraphicRenderer.swift +++ b/Sources/PaletteKit/Graphic/PaletteGraphicRenderer.swift @@ -37,14 +37,9 @@ internal enum PaletteGraphicRenderer { return cached } - let (center, edge) = resolveAnchors( - palette: palette, swatches: swatches, - strategy: configuration.swatchStrategy - ) let stopColors = resolveStopColors( palette: palette, swatches: swatches, - center: center, edge: edge, - count: configuration.colorCount.rawValue + configuration: configuration ) let extent = CGRect(origin: .zero, size: pixelSize) @@ -67,6 +62,23 @@ internal enum PaletteGraphicRenderer { return cg } + /// Shared entry point for rendering and inspecting the resolved stops. + static func resolveStopColors( + palette: Palette, + swatches: SwatchMap?, + configuration: PaletteGraphic.Configuration + ) -> [PaletteColor] { + let (center, edge) = resolveAnchors( + palette: palette, swatches: swatches, + strategy: configuration.swatchStrategy + ) + return resolveStopColors( + palette: palette, swatches: swatches, + center: center, edge: edge, + count: configuration.colorCount.rawValue + ) + } + /// Resolves the gradient `center` and `edge` anchors for a strategy via /// the same fallback chain documented for ``SwatchStrategy``. Internal /// — used by ``makeCGImage`` and exercised directly by renderer tests. diff --git a/Tests/PaletteKitTests/PaletteGraphicResolvedStopsTests.swift b/Tests/PaletteKitTests/PaletteGraphicResolvedStopsTests.swift new file mode 100644 index 0000000..a197fd1 --- /dev/null +++ b/Tests/PaletteKitTests/PaletteGraphicResolvedStopsTests.swift @@ -0,0 +1,82 @@ +#if canImport(UIKit) +import Testing +@testable import PaletteKit + +@MainActor +@Suite("PaletteGraphic resolved stops") +struct PaletteGraphicResolvedStopsTests { + @Test("Repeated RGB anchors remain two stops but represent one color", arguments: ColorCount.allCases) + func singleColor(count: ColorCount) { + let color = PaletteColor(r: 90, g: 60, b: 30, population: 8) + let duplicate = PaletteColor(rgb: color.rgb, population: 1) + let graphic = makeGraphic([color, duplicate], count: count) + #expect(graphic.resolvedStopColors.map(\.rgb) == [color.rgb, color.rgb]) + #expect(Set(graphic.resolvedStopColors.map(\.rgb)).count == 1) + } + + @Test("Empty input exposes the renderer's black fallback", arguments: ColorCount.allCases) + func emptyPalette(count: ColorCount) { + #expect(makeGraphic([], count: count).resolvedStopColors.map(\.rgb) == [.init(r: 0, g: 0, b: 0), .init(r: 0, g: 0, b: 0)]) + } + + @Test("No, some, and enough middle colors cap actual stops", arguments: 0...3) + func availableCandidates(middleCount: Int) { + let middle = [gray(200), gray(140), gray(70)].prefix(middleCount) + let colors = [PaletteColor.white] + middle + [.black] + for count in ColorCount.allCases { + let stops = makeGraphic(colors, count: count).resolvedStopColors + #expect(stops.count == min(count.rawValue, middleCount + 2)) + #expect(Set(stops.map(\.rgb)).count == stops.count) + #expect(stops.first?.rgb == PaletteColor.white.rgb) + #expect(stops.last?.rgb == PaletteColor.black.rgb) + #expect(stops.map(\.luminance) == stops.map(\.luminance).sorted(by: >)) + } + } + + @Test("Inspection matches existing resolution for every strategy and request", + arguments: SwatchStrategy.allCases, ColorCount.allCases) + func sharedResolution(strategy: SwatchStrategy, count: ColorCount) { + let palette = Palette(colors: [.white, gray(200), gray(140), gray(70), .black], colorSpaceUsed: .oklch) + let swatches = SwatchMap( + vibrant: swatch(.black, .vibrant), + muted: swatch(.white, .muted), + darkVibrant: swatch(.black, .darkVibrant), + lightVibrant: swatch(gray(70), .lightVibrant) + ) + let graphic = PaletteGraphic(palette: palette, swatches: swatches, + configuration: .init(colorCount: count, swatchStrategy: strategy)) + let anchors = PaletteGraphicRenderer.resolveAnchors(palette: palette, swatches: swatches, strategy: strategy) + let expected = PaletteGraphicRenderer.resolveStopColors( + palette: palette, swatches: swatches, center: anchors.center, edge: anchors.edge, count: count.rawValue + ) + #expect(graphic.resolvedStopColors == expected) + let distinctCount = Set(graphic.resolvedStopColors.map(\.rgb)).count + switch strategy { + case .vibrant: #expect(distinctCount == 1) + case .contrast: #expect(distinctCount == 2) + case .muted: #expect(distinctCount == count.rawValue) + } + } + + @Test("Ineligible colors and duplicate swatches do not inflate the result") + func filteredCandidates() { + let bright = PaletteColor(r: 255, g: 120, b: 0) + let dark = PaletteColor(r: 80, g: 20, b: 0) + let palette = Palette(colors: [.white, gray(130), .black], colorSpaceUsed: .oklch) + let swatches = SwatchMap(vibrant: swatch(bright, .vibrant), darkVibrant: swatch(dark, .darkVibrant)) + let graphic = PaletteGraphic(palette: palette, swatches: swatches, configuration: .init(colorCount: .five)) + #expect(graphic.resolvedStopColors.map(\.rgb) == [bright.rgb, dark.rgb]) + } + + private func makeGraphic(_ colors: [PaletteColor], count: ColorCount) -> PaletteGraphic { + PaletteGraphic(palette: Palette(colors: colors, colorSpaceUsed: .oklch), swatches: nil, + configuration: .init(colorCount: count)) + } + + private func gray(_ value: UInt8) -> PaletteColor { PaletteColor(r: value, g: value, b: value) } + + private func swatch(_ color: PaletteColor, _ role: SwatchRole) -> Swatch { + Swatch(color: color, role: role, titleTextColor: .white, bodyTextColor: .white) + } +} +#endif