Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions Tests/CLI/CLIAnnotationStateTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import Foundation
import Testing
import VitrineDomain
import VitrineRendering

@testable import VitrineCLICore

@MainActor
@Suite("CLI annotation state compatibility")
struct CLIAnnotationStateTests {
@Test func interleavedMarksUseFinalPerKindStyleAndKeepWithinKindOrder() throws {
let options = try CLIArguments.parse([
"render", "input.swift", "-o", "output.png",
"--arrow-color", "#FF0000", "--arrow", "0.1,0.2,0.3,0.4",
"--line", "0.2,0.6,0.8,0.6", "--arrow-color", "#0000FF",
"--arrow", "0.5,0.6,0.7,0.8", "--arrow-size", "3", "--arrow-size", "9",
"--line-color", "#00FF00", "--line-size", "5",
"--highlighter-color", "#FFFFFF", "--highlighter", "0.1,0.1,0.2,0.2",
"--blur-box", "0.2,0.2,0.3,0.3", "--rectangle", "0.3,0.3,0.4,0.4",
])
#expect(options.arrows.map(\.start) == [CGPoint(x: 0.1, y: 0.2), CGPoint(x: 0.5, y: 0.6)])
#expect(options.arrows.allSatisfy { $0.color == RGBAColor(hex: "#0000FF") && $0.size == 9 })
#expect(options.lines.first?.color == RGBAColor(hex: "#00FF00"))
#expect(options.lines.first?.size == 5)
#expect(options.highlighters.first?.size == nil)
#expect(options.blurBoxes.first?.color == nil)
#expect(options.blurBoxes.first?.size == nil)
#expect(
options.makeConfig(code: "x", language: .swift).annotations.map(\.kind)
== [.arrow, .arrow, .line, .rectangle, .highlighter, .blur])
}

@Test func repeatedPointValuesKeepLastAssignmentAndCanonicalModelOrder() throws {
let options = try CLIArguments.parse([
"render", "input.swift", "-o", "output.png", "--counter", "2",
"--callout-y", "0.4", "--callout", "Earlier", "--counter-y", "0.8",
"--callout-x", "0.3", "--counter-x", "0.7", "--counter", "8",
"--callout", "Final", "--callout-x", "0.2",
])
#expect(options.calloutText == "Final")
#expect(options.calloutPosition == CGPoint(x: 0.2, y: 0.4))
#expect(options.counterNumber == 8)
#expect(options.counterPosition == CGPoint(x: 0.7, y: 0.8))
#expect(
options.makeConfig(code: "x", language: .swift).annotations.map(\.kind)
== [.text, .counter])
}

@Test func semanticErrorsKeepKindPrecedenceRegardlessOfFlagOrder() {
for modifiers in [
["--line-size", "5", "--arrow-size", "7"],
["--arrow-size", "7", "--line-size", "5"],
] {
#expect(
throws: CLIError.incompatibleOptions(
"--arrow-color and --arrow-size require --arrow.")
) {
try CLIArguments.parse(["render", "input.swift", "-o", "output.png"] + modifiers)
}
}
#expect(
throws: CLIError.incompatibleOptions(
"--callout-x and --callout-y must be provided together.")
) {
try CLIArguments.parse([
"render", "input.swift", "-o", "output.png", "--counter-size", "5",
"--callout", "Note", "--callout-x", "0.2",
])
}
}

@Test func syntaxAndCrossFeatureErrorsKeepTheirExistingPrecedence() {
#expect(throws: CLIError.invalidValue(flag: "--line", value: "invalid")) {
try CLIArguments.parse([
"render", "input.swift", "-o", "output.png", "--arrow-size", "7",
"--line", "invalid",
])
}
#expect(
throws: CLIError.incompatibleOptions("--arrow-color and --arrow-size require --arrow.")
) {
try CLIArguments.parse([
"render", "input.swift", "-o", "output.png", "--frame", "browser",
"--arrow-size", "7",
])
}
}

@Test func annotationStateDoesNotSurviveAnotherInvocation() throws {
let styled = try CLIArguments.parse([
"render", "input.swift", "-o", "output.png", "--arrow", "0.1,0.1,0.9,0.9",
"--arrow-color", "#0000FF", "--arrow-size", "9",
])
let fresh = try CLIArguments.parse([
"render", "input.swift", "-o", "output.png", "--arrow", "0.1,0.1,0.9,0.9",
])
#expect(styled.arrows.first?.size == 9)
#expect(fresh.arrows.first?.color == nil)
#expect(fresh.arrows.first?.size == nil)
#expect(fresh.calloutText == nil)
#expect(fresh.counterNumber == nil)
}
}
85 changes: 85 additions & 0 deletions Vitrine/CLI/CLIAnnotationArguments.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import Foundation
import VitrineDomain

/// Invocation-local annotation values. Token parsing remains in `CLIArgumentParser`;
/// this value owns modifier compatibility and projection into the stable CLI options.
struct CLIAnnotationArguments {
struct PositionedStyle {
var x: Double?
var y: Double?
var color: RGBAColor?
var size: Double?

var isRequested: Bool { x != nil || y != nil || color != nil || size != nil }
var hasPartialPosition: Bool { (x == nil) != (y == nil) }
var position: CGPoint? {
guard let x, let y else { return nil }
return CGPoint(x: x, y: y)
}
}

struct GeometryGroup {
var points: [(start: CGPoint, end: CGPoint)] = []
var color: RGBAColor?
var size: Double?

var hasStyle: Bool { color != nil || size != nil }
var materialized: [CLIOptions.SegmentAnnotation] {
points.map {
CLIOptions.SegmentAnnotation(start: $0.start, end: $0.end, color: color, size: size)
}
}
}

var calloutText: String?
var callout = PositionedStyle()
var counterNumber: Int?
var counter = PositionedStyle()
var arrows = GeometryGroup()
var lines = GeometryGroup()
var rectangles = GeometryGroup()
var highlighters = GeometryGroup()
var blurBoxes = GeometryGroup()

/// The compatibility pass has already rejected orphan modifiers before checking
/// `--edit`, so only actual annotation content participates in its style decision.
var hasContent: Bool {
calloutText != nil || counterNumber != nil || !arrows.points.isEmpty
|| !lines.points.isEmpty || !rectangles.points.isEmpty
|| !highlighters.points.isEmpty || !blurBoxes.points.isEmpty
}

/// Keep the established kind order and exact diagnostics, even when argv contains
/// several incompatible annotation groups in a different order.
func validate() throws {
if calloutText == nil, callout.isRequested {
throw CLIError.incompatibleOptions(
"--callout-x, --callout-y, --callout-color, and --callout-size require --callout.")
}
if callout.hasPartialPosition {
throw CLIError.incompatibleOptions(
"--callout-x and --callout-y must be provided together.")
}
if counterNumber == nil, counter.isRequested {
throw CLIError.incompatibleOptions(
"--counter-x, --counter-y, --counter-color, and --counter-size require --counter.")
}
if counter.hasPartialPosition {
throw CLIError.incompatibleOptions(
"--counter-x and --counter-y must be provided together.")
}
if arrows.points.isEmpty, arrows.hasStyle {
throw CLIError.incompatibleOptions("--arrow-color and --arrow-size require --arrow.")
}
if lines.points.isEmpty, lines.hasStyle {
throw CLIError.incompatibleOptions("--line-color and --line-size require --line.")
}
if rectangles.points.isEmpty, rectangles.hasStyle {
throw CLIError.incompatibleOptions(
"--rectangle-color and --rectangle-size require --rectangle.")
}
if highlighters.points.isEmpty, highlighters.color != nil {
throw CLIError.incompatibleOptions("--highlighter-color requires --highlighter.")
}
}
}
67 changes: 23 additions & 44 deletions Vitrine/CLI/CLIArgumentParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,28 +47,7 @@ struct CLIArgumentParser {
var watermarkPosition: CLIOptions.WatermarkPosition?
var watermarkX: Double?
var watermarkY: Double?
var calloutText: String?
var calloutX: Double?
var calloutY: Double?
var calloutColor: RGBAColor?
var calloutSize: Double?
var counterNumber: Int?
var counterX: Double?
var counterY: Double?
var counterColor: RGBAColor?
var counterSize: Double?
var arrowSegments: [(start: CGPoint, end: CGPoint)] = []
var arrowColor: RGBAColor?
var arrowSize: Double?
var lineSegments: [(start: CGPoint, end: CGPoint)] = []
var lineColor: RGBAColor?
var lineSize: Double?
var rectangleRegions: [(start: CGPoint, end: CGPoint)] = []
var rectangleColor: RGBAColor?
var rectangleSize: Double?
var highlighterRegions: [(start: CGPoint, end: CGPoint)] = []
var highlighterColor: RGBAColor?
var blurBoxRegions: [(start: CGPoint, end: CGPoint)] = []
var annotations = CLIAnnotationArguments()
var imageFrame: CLIOptions.ImageFrameOption?
var frameAppearance: CLIOptions.ImageFrameAppearance?
var noOverwrite = false
Expand Down Expand Up @@ -258,49 +237,49 @@ struct CLIArgumentParser {
case .watermarkY:
watermarkY = try resolveNormalizedCoordinate(value, flag: token)
case .callout:
calloutText = try resolveCalloutText(value)
annotations.calloutText = try resolveCalloutText(value)
case .calloutX:
calloutX = try resolveNormalizedCoordinate(value, flag: token)
annotations.callout.x = try resolveNormalizedCoordinate(value, flag: token)
case .calloutY:
calloutY = try resolveNormalizedCoordinate(value, flag: token)
annotations.callout.y = try resolveNormalizedCoordinate(value, flag: token)
case .calloutColor:
calloutColor = try resolveCalloutColor(value)
annotations.callout.color = try resolveCalloutColor(value)
case .calloutSize:
calloutSize = try resolveCalloutSize(value)
annotations.callout.size = try resolveCalloutSize(value)
case .counter:
counterNumber = try resolveCounterNumber(value)
annotations.counterNumber = try resolveCounterNumber(value)
case .counterX:
counterX = try resolveNormalizedCoordinate(value, flag: token)
annotations.counter.x = try resolveNormalizedCoordinate(value, flag: token)
case .counterY:
counterY = try resolveNormalizedCoordinate(value, flag: token)
annotations.counter.y = try resolveNormalizedCoordinate(value, flag: token)
case .counterColor:
counterColor = try resolveHexColor(value, flag: token)
annotations.counter.color = try resolveHexColor(value, flag: token)
case .counterSize:
counterSize = try resolveAnnotationSize(value, flag: token)
annotations.counter.size = try resolveAnnotationSize(value, flag: token)
case .arrow:
arrowSegments.append(try resolveNormalizedSegment(value, flag: token))
annotations.arrows.points.append(try resolveNormalizedSegment(value, flag: token))
case .arrowColor:
arrowColor = try resolveHexColor(value, flag: token)
annotations.arrows.color = try resolveHexColor(value, flag: token)
case .arrowSize:
arrowSize = try resolveAnnotationSize(value, flag: token)
annotations.arrows.size = try resolveAnnotationSize(value, flag: token)
case .line:
lineSegments.append(try resolveNormalizedSegment(value, flag: token))
annotations.lines.points.append(try resolveNormalizedSegment(value, flag: token))
case .lineColor:
lineColor = try resolveHexColor(value, flag: token)
annotations.lines.color = try resolveHexColor(value, flag: token)
case .lineSize:
lineSize = try resolveAnnotationSize(value, flag: token)
annotations.lines.size = try resolveAnnotationSize(value, flag: token)
case .rectangle:
rectangleRegions.append(try resolveNormalizedRegion(value, flag: token))
annotations.rectangles.points.append(try resolveNormalizedRegion(value, flag: token))
case .rectangleColor:
rectangleColor = try resolveHexColor(value, flag: token)
annotations.rectangles.color = try resolveHexColor(value, flag: token)
case .rectangleSize:
rectangleSize = try resolveAnnotationSize(value, flag: token)
annotations.rectangles.size = try resolveAnnotationSize(value, flag: token)
case .highlighter:
highlighterRegions.append(try resolveNormalizedRegion(value, flag: token))
annotations.highlighters.points.append(try resolveNormalizedRegion(value, flag: token))
case .highlighterColor:
highlighterColor = try resolveHexColor(value, flag: token)
annotations.highlighters.color = try resolveHexColor(value, flag: token)
case .blurBox:
blurBoxRegions.append(try resolveNormalizedRegion(value, flag: token))
annotations.blurBoxes.points.append(try resolveNormalizedRegion(value, flag: token))
case .frame:
imageFrame = try resolveImageFrame(value)
case .frameAppearance:
Expand Down
Loading
Loading