From e551dc0b6c387429ade9cca07869e8dd3157ca2f Mon Sep 17 00:00:00 2001 From: BarredEwe Date: Thu, 9 Jul 2026 11:26:31 +0300 Subject: [PATCH] Support native preview arguments --- .../PrefireCore/PrefireCacheManager.swift | 10 +- .../PrefireCore/PrefireGenerator.swift | 16 +- .../PrefireCore/Previews/PreviewLoader.swift | 44 ++++- .../PrefireCore/Previews/PreviewParser.swift | 92 +++++++++ .../Previews/RawPreviewModel.swift | 186 +----------------- .../Templates/PreviewModelsTemplates.swift | 38 +++- .../Templates/PreviewTestsTemplate.swift | 19 ++ .../PrefireCacheManagerTests.swift | 12 +- .../PrefireTests/PrefireGeneratorTests.swift | 65 ++++++ .../PrefireTests/RawPreviewModelTests.swift | 92 +++++++-- README.md | 11 ++ 11 files changed, 365 insertions(+), 220 deletions(-) diff --git a/PrefireExecutable/Sources/PrefireCore/PrefireCacheManager.swift b/PrefireExecutable/Sources/PrefireCore/PrefireCacheManager.swift index c36659b..32ac222 100644 --- a/PrefireExecutable/Sources/PrefireCore/PrefireCacheManager.swift +++ b/PrefireExecutable/Sources/PrefireCore/PrefireCacheManager.swift @@ -16,15 +16,15 @@ struct PrefireCacheManager { sources: [Path], template: String, parseTypes: () throws -> Types, - parsePreviews: () async throws -> [String: String] - ) async throws -> (types: Types, previews: [String: String]) { - let key = fingerprint(for: sources, extra: template) + parsePreviews: () async throws -> [String: RawPreviewModel] + ) async throws -> (types: Types, previews: [String: RawPreviewModel]) { + let key = fingerprint(for: sources, extra: template + "\npreview-model-cache-v2") let dir = Path.cachesDir(sourcePath: sources.first ?? .current, basePath: cacheBasePath) let typesFile = dir + "\(version)-\(key).types" let previewsFile = dir + "\(version)-\(key).previews.json" var types: Types? - var previews: [String: String]? + var previews: [String: RawPreviewModel]? if typesFile.exists { do { @@ -38,7 +38,7 @@ struct PrefireCacheManager { if previewsFile.exists { do { let data = try Data(contentsOf: previewsFile.url) - previews = try JSONDecoder().decode([String: String].self, from: data) + previews = try JSONDecoder().decode([String: RawPreviewModel].self, from: data) } catch { Logger.warning("⚠️ Failed to read Previews cache: \(error)") } diff --git a/PrefireExecutable/Sources/PrefireCore/PrefireGenerator.swift b/PrefireExecutable/Sources/PrefireCore/PrefireGenerator.swift index 660ee2e..d858cba 100644 --- a/PrefireExecutable/Sources/PrefireCore/PrefireGenerator.swift +++ b/PrefireExecutable/Sources/PrefireCore/PrefireGenerator.swift @@ -59,14 +59,15 @@ public enum PrefireGenerator { }, parsePreviews: { Logger.info("🔍 Extracting #Preview bodies...") - var result: [String: String] = [:] + var result: [String: RawPreviewModel] = [:] for (path, content) in fileContents { guard content.contains("#Preview") else { continue } - if let bodies = PreviewLoader.previewBodies(from: content, defaultEnabled: defaultEnabled) { - for (i, body) in bodies.enumerated() { - let key = "\(path.lastComponentWithoutExtension)_\(i)" - result[key] = body - } + if let models = PreviewLoader.previewModels( + from: content, + filename: path.lastComponentWithoutExtension, + defaultEnabled: defaultEnabled + ) { + result.merge(models) { current, _ in current } } } return result @@ -76,8 +77,7 @@ public enum PrefireGenerator { let previewModels = previews .sorted { $0.key > $1.key } .compactMap { entry -> [String: Any?]? in - guard let model = RawPreviewModel(from: entry.value, filename: entry.key) else { return nil } - var dict = model.makeStencilDict() + var dict = entry.value.makeStencilDict() // Add the source filename for ungrouped generation dict["sourceFileName"] = extractFileNameFromKey(entry.key) return dict diff --git a/PrefireExecutable/Sources/PrefireCore/Previews/PreviewLoader.swift b/PrefireExecutable/Sources/PrefireCore/Previews/PreviewLoader.swift index 5f1624b..36233ae 100644 --- a/PrefireExecutable/Sources/PrefireCore/Previews/PreviewLoader.swift +++ b/PrefireExecutable/Sources/PrefireCore/Previews/PreviewLoader.swift @@ -6,6 +6,7 @@ import SwiftSyntax enum PreviewLoader { enum Constants { static let previewMacro = "Preview" + static let defaultTrait = ".device" static let prefireDisableMarker = ".prefireIgnored()" static let prefireEnabledMarker = ".prefireEnabled()" } @@ -18,6 +19,37 @@ enum PreviewLoader { /// - Returns: An array representing the results of the macro preview, each starting with `#Preview` /// and ending with the closing `}` of the preview closure followed by a newline. static func previewBodies(from content: String, defaultEnabled: Bool) -> [String]? { + let previews = rawPreviews(from: content, defaultEnabled: defaultEnabled) + return previews?.map(\.body) + } + + static func previewModels(from content: String, filename: String, defaultEnabled: Bool) -> [String: RawPreviewModel]? { + guard let previews = rawPreviews(from: content, defaultEnabled: defaultEnabled) else { return nil } + + var models: [String: RawPreviewModel] = [:] + for (index, preview) in previews.enumerated() { + let key = "\(filename)_\(index)" + models[key] = RawPreviewModel( + displayName: preview.parser.displayName ?? key, + traits: preview.parser.traits ?? [Constants.defaultTrait], + body: preview.parser.body ?? "", + properties: preview.parser.propertiesSource, + arguments: preview.parser.arguments, + argumentPattern: preview.parser.argumentPattern + ) + } + + return models.isEmpty ? nil : models + } +} + +private extension PreviewLoader { + struct RawPreview { + let body: String + let parser: PreviewParser + } + + static func rawPreviews(from content: String, defaultEnabled: Bool) -> [RawPreview]? { // Locate `#Preview` macros with SwiftSyntax rather than a line-based brace scanner. // The scanner used to miscount braces that appear inside string literals, comments or // raw strings (e.g. `#Preview("{braced}")`), truncating the collected body. SwiftSyntax @@ -27,7 +59,7 @@ enum PreviewLoader { collector.walk(sourceFile) let sourceBytes = Array(content.utf8) - var previewBodies: [String] = [] + var previews: [RawPreview] = [] for preview in collector.previews { guard preview.startOffset <= preview.endOffset, preview.endOffset <= sourceBytes.count else { continue } @@ -41,11 +73,11 @@ enum PreviewLoader { } if viewMustBeLoaded { - previewBodies.append(body) + previews.append(RawPreview(body: body, parser: preview.parser)) } } - return previewBodies.isEmpty ? nil : previewBodies + return previews.isEmpty ? nil : previews } } @@ -54,6 +86,7 @@ private final class PreviewMacroCollector: SyntaxVisitor { struct PreviewRange { let startOffset: Int let endOffset: Int + let parser: PreviewParser } private(set) var previews: [PreviewRange] = [] @@ -76,10 +109,13 @@ private final class PreviewMacroCollector: SyntaxVisitor { /// (e.g. `@available(...)`) are excluded, matching the shape the downstream parser expects. private func record(macroName: String, pound: TokenSyntax, node: some SyntaxProtocol) { guard macroName == PreviewLoader.Constants.previewMacro else { return } + let parser = PreviewParser() + parser.walk(node) previews.append( PreviewRange( startOffset: pound.positionAfterSkippingLeadingTrivia.utf8Offset, - endOffset: node.endPositionBeforeTrailingTrivia.utf8Offset + endOffset: node.endPositionBeforeTrailingTrivia.utf8Offset, + parser: parser ) ) } diff --git a/PrefireExecutable/Sources/PrefireCore/Previews/PreviewParser.swift b/PrefireExecutable/Sources/PrefireCore/Previews/PreviewParser.swift index 475e617..6fa5490 100644 --- a/PrefireExecutable/Sources/PrefireCore/Previews/PreviewParser.swift +++ b/PrefireExecutable/Sources/PrefireCore/Previews/PreviewParser.swift @@ -3,15 +3,44 @@ import SwiftSyntax final class PreviewParser: SyntaxVisitor { private(set) var body: String? private(set) var properties: [String] = [] + private(set) var displayName: String? + private(set) var traits: [String]? + private(set) var arguments: String? + private(set) var argumentPattern: String? enum Constants { static let previewable = "Previewable" + static let previewMacro = "Preview" } init() { super.init(viewMode: .sourceAccurate) } + var propertiesSource: String? { + guard let firstProperty = properties.first else { return nil } + + return properties.dropFirst().reduce(firstProperty) { + "\($0)\n\($1)" + } + } + + override func visit(_ node: MacroExpansionExprSyntax) -> SyntaxVisitorContinueKind { + guard node.macroName.text == Constants.previewMacro else { return .visitChildren } + + collectMacroData(arguments: node.arguments, trailingClosure: node.trailingClosure) + + return .visitChildren + } + + override func visit(_ node: MacroExpansionDeclSyntax) -> SyntaxVisitorContinueKind { + guard node.macroName.text == Constants.previewMacro else { return .visitChildren } + + collectMacroData(arguments: node.arguments, trailingClosure: node.trailingClosure) + + return .visitChildren + } + override func visit(_ node: VariableDeclSyntax) -> SyntaxVisitorContinueKind { guard node.attributes.contain(matchingName: Constants.previewable) else { appendItemToBody(node) @@ -42,6 +71,69 @@ private extension PreviewParser { body = "\(node.trimmed)" } } + + func collectMacroData(arguments macroArgumentList: LabeledExprListSyntax, trailingClosure: ClosureExprSyntax?) { + let macroArguments = Array(macroArgumentList) + + if let firstArgument = macroArguments.first, + firstArgument.label == nil { + displayName = Self.stringLiteralValue(from: firstArgument.expression) + } + + traits = Self.traits(from: macroArguments) + + arguments = macroArguments.first(where: { $0.label?.text == "arguments" }).map { + "\($0.expression.trimmed)" + } + + argumentPattern = trailingClosure.flatMap(Self.argumentPattern) + } + + static func stringLiteralValue(from expression: ExprSyntax) -> String? { + guard let literal = expression.as(StringLiteralExprSyntax.self) else { return nil } + + let segments = literal.segments.compactMap { $0.as(StringSegmentSyntax.self)?.content.text } + guard segments.count == literal.segments.count else { return nil } + + return segments.joined() + } + + static func traits(from arguments: [LabeledExprSyntax]) -> [String]? { + guard let traitsIndex = arguments.firstIndex(where: { $0.label?.text == "traits" }) else { + return nil + } + + var traits = ["\(arguments[traitsIndex].expression.trimmed)"] + var index = arguments.index(after: traitsIndex) + + while index < arguments.endIndex, arguments[index].label == nil { + traits.append("\(arguments[index].expression.trimmed)") + index = arguments.index(after: index) + } + + return traits + } + + static func argumentPattern(from closure: ClosureExprSyntax) -> String? { + guard let parameterClause = closure.signature?.parameterClause else { return nil } + + let names: [String] + switch parameterClause { + case .simpleInput(let parameters): + names = parameters.map { $0.name.text } + case .parameterClause(let parameterClause): + names = parameterClause.parameters.map { ($0.secondName ?? $0.firstName).text } + } + + let filteredNames = names.filter { !$0.isEmpty && $0 != "_" } + guard !filteredNames.isEmpty else { return nil } + + if filteredNames.count == 1 { + return filteredNames[0] + } else { + return "(\(filteredNames.joined(separator: ", ")))" + } + } } private extension AttributeListSyntax { diff --git a/PrefireExecutable/Sources/PrefireCore/Previews/RawPreviewModel.swift b/PrefireExecutable/Sources/PrefireCore/Previews/RawPreviewModel.swift index 6bde060..26e1c6c 100644 --- a/PrefireExecutable/Sources/PrefireCore/Previews/RawPreviewModel.swift +++ b/PrefireExecutable/Sources/PrefireCore/Previews/RawPreviewModel.swift @@ -1,195 +1,26 @@ import Foundation -import SwiftParser -struct RawPreviewModel { +struct RawPreviewModel: Codable, Equatable { var displayName: String var traits: [String] var body: String var properties: String? + var arguments: String? + var argumentPattern: String? var isScreen: Bool { traits.contains(Constants.defaultTrait) } -} -extension RawPreviewModel { - private enum Markers { - static let previewMacro = "#Preview" - static let traits = "traits: " + var hasArguments: Bool { + arguments != nil && argumentPattern != nil } +} +extension RawPreviewModel { private enum Constants { static let defaultTrait = ".device" } - - /// Initialization from the macro Preview body - /// - Parameters: - /// - macroBody: Preview View body - /// - filename: File name in which the macro was found - init?(from macroBody: String, filename: String) { - guard !macroBody.isEmpty else { return nil } - - // Only the first line is needed here (for displayName and traits); the body and - // properties are parsed from the full `macroBody` via SwiftSyntax below. Taking the - // first line directly avoids crashing on single-line previews like - // `#Preview("x") { previewFoo() }`, where `dropLast(2)` leaves an empty collection - // and `removeFirst()` would trap. - guard let firstLine = macroBody.split(separator: "\n", omittingEmptySubsequences: false).first else { - return nil - } - - // Define displayName by splitting the first line by " - let parts = firstLine.split(separator: "\"") - if let namePart = parts.first(where: { !$0.contains(Markers.previewMacro) }) { - self.displayName = String(namePart) - } else { - self.displayName = filename - } - - // Retrieve traits using a range finder - var previewTrait: String? - if let range = firstLine.range(of: Markers.traits) { - let substring = firstLine[range.upperBound...] - let endIndex = Self.findTraitsEndIndex(in: substring) - if let endIndex = endIndex { - previewTrait = String(substring[..= 2 { - self.properties = previewParser.properties.dropFirst().reduce(firstProperty) { - var properties = $0 - properties = properties + "\n" + $1 - return properties - } - } - - body = previewParser.body ?? "" - } - - /// Parse traits from the raw trait string - /// Handles single traits, comma-separated traits, and function-style traits - private static func parseTraits(from rawTraits: String?) -> [String] { - guard let rawTraits = rawTraits?.trimmingCharacters(in: .whitespacesAndNewlines), - !rawTraits.isEmpty else { - return [Constants.defaultTrait] - } - - // Pre-allocate array with estimated capacity for better performance - var traits: [String] = [] - traits.reserveCapacity(4) // Most common case: 1-3 traits - - let startIndex = rawTraits.startIndex - let endIndex = rawTraits.endIndex - var currentStart = startIndex - var currentIndex = startIndex - var parenthesesDepth = 0 - var insideQuotes = false - var quoteChar: Character? - - while currentIndex < endIndex { - let char = rawTraits[currentIndex] - - switch char { - case "\"", "'": - if !insideQuotes { - insideQuotes = true - quoteChar = char - } else if char == quoteChar { - insideQuotes = false - quoteChar = nil - } - case "(": - if !insideQuotes { - parenthesesDepth += 1 - } - case ")": - if !insideQuotes { - parenthesesDepth -= 1 - } - case ",": - if !insideQuotes && parenthesesDepth == 0 { - // This comma is a trait separator - let traitSubstring = rawTraits[currentStart.. String.Index? { - var parenthesesDepth = 0 - var insideQuotes = false - var quoteChar: Character? - var currentIndex = substring.startIndex - let endIndex = substring.endIndex - - while currentIndex < endIndex { - let char = substring[currentIndex] - - switch char { - case "\"", "'": - if !insideQuotes { - insideQuotes = true - quoteChar = char - } else if char == quoteChar { - insideQuotes = false - quoteChar = nil - } - case "(": - if !insideQuotes { - parenthesesDepth += 1 - } - case ")": - if !insideQuotes { - if parenthesesDepth == 0 { - // This is the closing parenthesis of the #Preview call - return currentIndex - } - parenthesesDepth -= 1 - } - default: - break - } - - currentIndex = substring.index(after: currentIndex) - } - - // If we didn't find a balanced closing parenthesis, return nil - return nil - } -} - -extension RawPreviewModel { private static let funcCharacterSet = CharacterSet(arrayLiteral: "_").inverted.intersection(.alphanumerics.inverted) var componentTestName: String { @@ -203,6 +34,9 @@ extension RawPreviewModel { "isScreen": isScreen, "body": body, "properties": properties, + "arguments": arguments, + "argumentPattern": argumentPattern, + "hasArguments": hasArguments, "traits": traits ].filter({ $0.value != nil }) } diff --git a/PrefireExecutable/Sources/PrefireCore/Templates/PreviewModelsTemplates.swift b/PrefireExecutable/Sources/PrefireCore/Templates/PreviewModelsTemplates.swift index 22cf9d2..d4b41de 100644 --- a/PrefireExecutable/Sources/PrefireCore/Templates/PreviewModelsTemplates.swift +++ b/PrefireExecutable/Sources/PrefireCore/Templates/PreviewModelsTemplates.swift @@ -58,6 +58,7 @@ public enum PreviewModels { private struct MacroPreviews { {% for macroModel in argument.previewsMacrosDict %} {% if macroModel.properties %} + {% if not macroModel.hasArguments %} struct PreviewWrapper{{ macroModel.componentTestName }}: SwiftUI.View { {{ macroModel.properties }} var body: some View { @@ -65,19 +66,44 @@ private struct MacroPreviews { } } {% endif %} + {% endif %} {% endfor %} - static var previews: [PreviewModel] = [ + static var previews: [PreviewModel] = { + var previews: [PreviewModel] = [] {% for macroModel in argument.previewsMacrosDict %} + {% if macroModel.hasArguments %} + for (previewArgumentIndex, previewArgument) in ({{ macroModel.arguments }}).enumerated() { + let {{ macroModel.argumentPattern }} = previewArgument + previews.append( + PreviewModel( + id: "{{ macroModel.componentTestName }}-\(previewArgumentIndex)", + content: { + {{ macroModel.body|indent:24 }} + }, + name: "{{ macroModel.displayName }}-\(previewArgumentIndex + 1)-\(String(describing: previewArgument))", + type: {% if macroModel.isScreen == 1 %}.screen{% else %}.component{% endif %} + ) + ) + } + {% else %} {% if macroModel.properties %} - PreviewModel(content: { PreviewWrapper{{ macroModel.componentTestName }}() }, name: "{{ macroModel.displayName }}"), + previews.append(PreviewModel(content: { PreviewWrapper{{ macroModel.componentTestName }}() }, name: "{{ macroModel.displayName }}")) {% else %} - PreviewModel(content: { - {{ macroModel.body|indent:12 }} - }, name: "{{ macroModel.displayName }}"), + previews.append( + PreviewModel( + content: { + {{ macroModel.body|indent:20 }} + }, + name: "{{ macroModel.displayName }}", + type: {% if macroModel.isScreen == 1 %}.screen{% else %}.component{% endif %} + ) + ) + {% endif %} {% endif %} {% endfor %} - ] + return previews + }() } {% endif %} diff --git a/PrefireExecutable/Sources/PrefireCore/Templates/PreviewTestsTemplate.swift b/PrefireExecutable/Sources/PrefireCore/Templates/PreviewTestsTemplate.swift index 98dc21d..6b5275e 100644 --- a/PrefireExecutable/Sources/PrefireCore/Templates/PreviewTestsTemplate.swift +++ b/PrefireExecutable/Sources/PrefireCore/Templates/PreviewTestsTemplate.swift @@ -62,6 +62,24 @@ import SnapshotTesting {% for macroModel in argument.previewsMacrosDict %} func test_{{ macroModel.componentTestName }}_Preview() { + {% if macroModel.hasArguments %} + for (previewArgumentIndex, previewArgument) in ({{ macroModel.arguments }}).enumerated() { + let {{ macroModel.argumentPattern }} = previewArgument + + let prefireSnapshot = PrefireSnapshot( + { + {{ macroModel.body|indent:20 }} + }, + name: "{{ macroModel.displayName }}-\(previewArgumentIndex + 1)-\(String(describing: previewArgument))", + isScreen: {% if macroModel.isScreen == 1 %}true{% else %}false{% endif %}, + device: deviceConfig + ) + + if let failure = assertSnapshots(for: prefireSnapshot) { + XCTFail(failure) + } + } + {% else %} {% if macroModel.properties %} struct PreviewWrapper{{ macroModel.componentTestName }}: SwiftUI.View { {{ macroModel.properties }} @@ -86,6 +104,7 @@ import SnapshotTesting if let failure = assertSnapshots(for: prefireSnapshot) { XCTFail(failure) } + {% endif %} } {%- if not forloop.last %} diff --git a/PrefireExecutable/Tests/PrefireTests/PrefireCacheManagerTests.swift b/PrefireExecutable/Tests/PrefireTests/PrefireCacheManagerTests.swift index 89d8d3d..7bd789f 100644 --- a/PrefireExecutable/Tests/PrefireTests/PrefireCacheManagerTests.swift +++ b/PrefireExecutable/Tests/PrefireTests/PrefireCacheManagerTests.swift @@ -34,13 +34,13 @@ final class PrefireCacheManagerTests: XCTestCase { }, parsePreviews: { previewsParsed = true - return ["a": "Text(\"Hello\")"] + return PreviewLoader.previewModels(from: try file.read(.utf8), filename: "Example", defaultEnabled: true) ?? [:] } ) XCTAssertTrue(parsed) XCTAssertTrue(previewsParsed) - XCTAssertEqual(firstPreviews["a"], "Text(\"Hello\")") + XCTAssertEqual(firstPreviews["Example_0"]?.body, "Text(\"Hello\")") parsed = false previewsParsed = false @@ -71,10 +71,14 @@ final class PrefireCacheManagerTests: XCTestCase { Types(types: []) }, parsePreviews: { - ["b": "Text(\"Updated\")"] + PreviewLoader.previewModels(from: """ + #Preview { + Text("Updated") + } + """, filename: "Example", defaultEnabled: true) ?? [:] } ) - XCTAssertEqual(updatedPreviews["b"], "Text(\"Updated\")") + XCTAssertEqual(updatedPreviews["Example_0"]?.body, "Text(\"Updated\")") } } diff --git a/PrefireExecutable/Tests/PrefireTests/PrefireGeneratorTests.swift b/PrefireExecutable/Tests/PrefireTests/PrefireGeneratorTests.swift index a85e29c..aa39c42 100644 --- a/PrefireExecutable/Tests/PrefireTests/PrefireGeneratorTests.swift +++ b/PrefireExecutable/Tests/PrefireTests/PrefireGeneratorTests.swift @@ -47,6 +47,71 @@ final class PrefireGeneratorTests: XCTestCase { XCTAssertTrue(result.contains("TestPreview"), "Should include basic preview") XCTAssertFalse(result.contains("TestPreview_Ignored"), "Should skip explicitly ignored preview") } + + func testParameterizedPreviewRendersSnapshotTemplate() async throws { + let file = Path("/tmp/ParameterizedPreview.swift") + let output = Path("/tmp/ParameterizedPreviewTests.generated.swift") + let cache = Path("/tmp/cache_parameterized_preview/") + try file.write(""" + import SwiftUI + + #Preview("TextView", traits: .sizeThatFitsLayout, arguments: ["- A", "- B"]) { suffix in + Text("1 \\(suffix)") + } + + """) + + try await PrefireGenerator.generate( + version: "1.0.0", + sources: [file], + output: output, + arguments: [:], + inlineTemplate: EmbeddedTemplates.previewTests, + defaultEnabled: true, + cacheDir: cache, + useGroupedSnapshots: true + ) + + let result = try output.read(.utf8) + + XCTAssertTrue(result.contains("for (previewArgumentIndex, previewArgument) in ([\"- A\", \"- B\"]).enumerated()")) + XCTAssertTrue(result.contains("let suffix = previewArgument")) + XCTAssertTrue(result.contains("name: \"TextView-\\(previewArgumentIndex + 1)-\\(String(describing: previewArgument))\"")) + XCTAssertTrue(result.contains("Text(\"1 \\(suffix)\")")) + } + + func testParameterizedPreviewRendersPlaybookTemplate() async throws { + let file = Path("/tmp/ParameterizedPlaybookPreview.swift") + let output = Path("/tmp/ParameterizedPreviewModels.generated.swift") + let cache = Path("/tmp/cache_parameterized_playbook_preview/") + try file.write(""" + import SwiftUI + + #Preview("TextView", traits: .sizeThatFitsLayout, arguments: ["- A", "- B"]) { suffix in + Text("1 \\(suffix)") + } + + """) + + try await PrefireGenerator.generate( + version: "1.0.0", + sources: [file], + output: output, + arguments: [:], + inlineTemplate: EmbeddedTemplates.previewModels, + defaultEnabled: true, + cacheDir: cache, + useGroupedSnapshots: true + ) + + let result = try output.read(.utf8) + + XCTAssertTrue(result.contains("for (previewArgumentIndex, previewArgument) in ([\"- A\", \"- B\"]).enumerated()")) + XCTAssertTrue(result.contains("let suffix = previewArgument")) + XCTAssertTrue(result.contains("id: \"TextView-\\(previewArgumentIndex)\"")) + XCTAssertTrue(result.contains("name: \"TextView-\\(previewArgumentIndex + 1)-\\(String(describing: previewArgument))\"")) + XCTAssertTrue(result.contains("Text(\"1 \\(suffix)\")")) + } func testUngroupedFileGeneration() async throws { let file = Path(fixtureTestPreviewSource) diff --git a/PrefireExecutable/Tests/PrefireTests/RawPreviewModelTests.swift b/PrefireExecutable/Tests/PrefireTests/RawPreviewModelTests.swift index c5e077b..1a7156d 100644 --- a/PrefireExecutable/Tests/PrefireTests/RawPreviewModelTests.swift +++ b/PrefireExecutable/Tests/PrefireTests/RawPreviewModelTests.swift @@ -10,7 +10,7 @@ class RawPreviewModelTests: XCTestCase { } """ - let rawPreviewModel = RawPreviewModel(from: previewBodyWithName, filename: "Test") + let rawPreviewModel = makePreviewModel(from: previewBodyWithName) XCTAssertEqual(rawPreviewModel?.body, "Text(\"TestView\")") XCTAssertEqual(rawPreviewModel?.properties, nil) @@ -30,13 +30,67 @@ class RawPreviewModelTests: XCTestCase { } """ - let rawPreviewModel = RawPreviewModel(from: previewBodyWithoutName, filename: "TestView") + let rawPreviewModel = makePreviewModel(from: previewBodyWithoutName, filename: "TestView") XCTAssertEqual(rawPreviewModel?.body, "VStack {\n Text(name)\n }\n .snapshot(delay: 8)") XCTAssertEqual(rawPreviewModel?.properties, "@State var name: String = \"TestView\"") - XCTAssertEqual(rawPreviewModel?.displayName, "TestView") + XCTAssertEqual(rawPreviewModel?.displayName, "TestView_0") XCTAssertEqual(rawPreviewModel?.traits, [".sizeThatFitsLayout"]) } + + func test_initWithArguments() { + let previewBodyWithArguments = """ + #Preview("TextView", traits: .sizeThatFitsLayout, arguments: ["- A", "- B", "- C"]) { suffix in + Text("1 \\(suffix)") + } + + """ + let rawPreviewModel = makePreviewModel(from: previewBodyWithArguments) + + XCTAssertEqual(rawPreviewModel?.body, "Text(\"1 \\(suffix)\")") + XCTAssertEqual(rawPreviewModel?.properties, nil) + XCTAssertEqual(rawPreviewModel?.displayName, "TextView") + XCTAssertEqual(rawPreviewModel?.traits, [".sizeThatFitsLayout"]) + XCTAssertEqual(rawPreviewModel?.arguments, "[\"- A\", \"- B\", \"- C\"]") + XCTAssertEqual(rawPreviewModel?.argumentPattern, "suffix") + XCTAssertEqual(rawPreviewModel?.hasArguments, true) + } + + func test_initWithTupleArguments() { + let previewBodyWithArguments = """ + #Preview("TextView", traits: .sizeThatFitsLayout, arguments: [("1", "- A"), ("2", "- B")]) { prefix, suffix in + Text("\\(prefix) \\(suffix)") + } + + """ + let rawPreviewModel = makePreviewModel(from: previewBodyWithArguments) + + XCTAssertEqual(rawPreviewModel?.body, "Text(\"\\(prefix) \\(suffix)\")") + XCTAssertEqual(rawPreviewModel?.arguments, "[(\"1\", \"- A\"), (\"2\", \"- B\")]") + XCTAssertEqual(rawPreviewModel?.argumentPattern, "(prefix, suffix)") + XCTAssertEqual(rawPreviewModel?.hasArguments, true) + } + + func test_initWithMultilineArgumentsSignature() { + let previewBodyWithArguments = """ + #Preview( + "TextView", + traits: .sizeThatFitsLayout, + arguments: ["- A", "- B"] + ) { suffix in + Text("1 \\(suffix)") + } + + """ + let rawPreviewModel = makePreviewModel(from: previewBodyWithArguments) + + XCTAssertEqual(rawPreviewModel?.body, "Text(\"1 \\(suffix)\")") + XCTAssertEqual(rawPreviewModel?.displayName, "TextView") + XCTAssertEqual(rawPreviewModel?.traits, [".sizeThatFitsLayout"]) + XCTAssertEqual(rawPreviewModel?.arguments, "[\"- A\", \"- B\"]") + XCTAssertEqual(rawPreviewModel?.argumentPattern, "suffix") + XCTAssertEqual(rawPreviewModel?.hasArguments, true) + } func test_initWithMultipleTraits() { let previewBodyWithMultipleTraits = """ @@ -45,7 +99,7 @@ class RawPreviewModelTests: XCTestCase { } """ - let rawPreviewModel = RawPreviewModel(from: previewBodyWithMultipleTraits, filename: "Test") + let rawPreviewModel = makePreviewModel(from: previewBodyWithMultipleTraits) XCTAssertEqual(rawPreviewModel?.body, "Text(\"TestView\")") XCTAssertEqual(rawPreviewModel?.properties, nil) @@ -60,7 +114,7 @@ class RawPreviewModelTests: XCTestCase { } """ - let rawPreviewModel = RawPreviewModel(from: previewBodyWithFunctionTrait, filename: "Test") + let rawPreviewModel = makePreviewModel(from: previewBodyWithFunctionTrait) XCTAssertEqual(rawPreviewModel?.body, "Text(\"TestView\")") XCTAssertEqual(rawPreviewModel?.properties, nil) @@ -75,7 +129,7 @@ class RawPreviewModelTests: XCTestCase { } """ - let rawPreviewModel = RawPreviewModel(from: previewBodyWithMixedTraits, filename: "Test") + let rawPreviewModel = makePreviewModel(from: previewBodyWithMixedTraits) XCTAssertEqual(rawPreviewModel?.body, "Text(\"TestView\")") XCTAssertEqual(rawPreviewModel?.properties, nil) @@ -90,7 +144,7 @@ class RawPreviewModelTests: XCTestCase { } """ - let rawPreviewModel = RawPreviewModel(from: previewBodyWithoutTraits, filename: "Test") + let rawPreviewModel = makePreviewModel(from: previewBodyWithoutTraits) XCTAssertEqual(rawPreviewModel?.body, "Text(\"TestView\")") XCTAssertEqual(rawPreviewModel?.properties, nil) @@ -105,7 +159,7 @@ class RawPreviewModelTests: XCTestCase { } """ - let rawPreviewModel = RawPreviewModel(from: previewBodyWithMultipleFunctionTraits, filename: "Test") + let rawPreviewModel = makePreviewModel(from: previewBodyWithMultipleFunctionTraits) XCTAssertEqual(rawPreviewModel?.body, "Text(\"TestView\")") XCTAssertEqual(rawPreviewModel?.properties, nil) @@ -120,7 +174,7 @@ class RawPreviewModelTests: XCTestCase { } """ - let rawPreviewModel = RawPreviewModel(from: previewBodyWithComplexTraits, filename: "Test") + let rawPreviewModel = makePreviewModel(from: previewBodyWithComplexTraits) XCTAssertEqual(rawPreviewModel?.body, "Text(\"TestView\")") XCTAssertEqual(rawPreviewModel?.properties, nil) @@ -135,7 +189,7 @@ class RawPreviewModelTests: XCTestCase { } """ - let rawPreviewModel = RawPreviewModel(from: previewBodyWithNestedParentheses, filename: "Test") + let rawPreviewModel = makePreviewModel(from: previewBodyWithNestedParentheses) XCTAssertEqual(rawPreviewModel?.body, "Text(\"TestView\")") XCTAssertEqual(rawPreviewModel?.properties, nil) @@ -155,11 +209,11 @@ class RawPreviewModelTests: XCTestCase { } """ - let rawPreviewModel = RawPreviewModel(from: previewBodyWithNestedParentheses, filename: "Test") + let rawPreviewModel = makePreviewModel(from: previewBodyWithNestedParentheses) XCTAssertEqual(rawPreviewModel?.body, "ForEach(foo, id: \\.self) {\n Text($0.formatted())\n }") XCTAssertEqual(rawPreviewModel?.properties, "@State var foo = [\n 1, 2, 3\n ]") - XCTAssertEqual(rawPreviewModel?.displayName, "Test") + XCTAssertEqual(rawPreviewModel?.displayName, "Test_0") XCTAssertEqual(rawPreviewModel?.traits, [".device"]) } @@ -177,11 +231,11 @@ class RawPreviewModelTests: XCTestCase { } """ - let rawPreviewModel = RawPreviewModel(from: previewBodyWithNestedParentheses, filename: "Test") + let rawPreviewModel = makePreviewModel(from: previewBodyWithNestedParentheses) XCTAssertEqual(rawPreviewModel?.body, "var hoge = \"Test\"\nForEach(foo, id: \\.self) {\n Text($0.formatted())\n }") XCTAssertEqual(rawPreviewModel?.properties, "@State var foo = [\n 1, 2, 3\n ]\n@State var name: String = \"TestView\"") - XCTAssertEqual(rawPreviewModel?.displayName, "Test") + XCTAssertEqual(rawPreviewModel?.displayName, "Test_0") XCTAssertEqual(rawPreviewModel?.traits, [".device"]) } @@ -194,17 +248,17 @@ class RawPreviewModelTests: XCTestCase { } """ - let rawPreviewModel = RawPreviewModel(from: previewBodyWithNestedParentheses, filename: "Test") + let rawPreviewModel = makePreviewModel(from: previewBodyWithNestedParentheses) XCTAssertEqual(rawPreviewModel?.body, "let viewController = UIViewController()\nviewController.view.backgroundColor = .green\nreturn viewController") XCTAssertEqual(rawPreviewModel?.properties, nil) - XCTAssertEqual(rawPreviewModel?.displayName, "Test") + XCTAssertEqual(rawPreviewModel?.displayName, "Test_0") XCTAssertEqual(rawPreviewModel?.traits, [".device"]) } func test_initWithSingleLineBody() { let previewBody = "#Preview(\"SingleLine\", traits: .sizeThatFitsLayout) { Text(\"TestView\") }\n" - let rawPreviewModel = RawPreviewModel(from: previewBody, filename: "Test") + let rawPreviewModel = makePreviewModel(from: previewBody) XCTAssertEqual(rawPreviewModel?.body, "Text(\"TestView\")") XCTAssertEqual(rawPreviewModel?.properties, nil) @@ -212,3 +266,7 @@ class RawPreviewModelTests: XCTestCase { XCTAssertEqual(rawPreviewModel?.traits, [".sizeThatFitsLayout"]) } } + +private func makePreviewModel(from source: String, filename: String = "Test") -> RawPreviewModel? { + PreviewLoader.previewModels(from: source, filename: filename, defaultEnabled: true)?["\(filename)_0"] +} diff --git a/README.md b/README.md index f13cf48..155a486 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,16 @@ For detailed instruction, check out [swift-snapshot-testing](https://github.com/
+- Prefire supports native SwiftUI parameterized previews: + + ```swift + #Preview("TextView", traits: .sizeThatFitsLayout, arguments: ["- A", "- B", "- C"]) { suffix in + Text("1 \(suffix)") + } + ``` + + Prefire expands each argument into its own snapshot and Playbook preview. + --- ## 🧰 API Summary @@ -251,6 +261,7 @@ For detailed instruction, check out [swift-snapshot-testing](https://github.com/ | Group in a flow | `.previewUserStory(.auth)` | | Mark a UI state | `.previewState(.error)` | | Customize snapshot | `.snapshot(delay: 0.3, precision: 0.95)` | +| Parameterized preview | `#Preview(..., arguments: values)` | ---