|
| 1 | +import Foundation |
| 2 | +import Testing |
| 3 | + |
| 4 | +@testable import PreviewsCore |
| 5 | + |
| 6 | +@Suite("PreviewSession with BuildContext", .serialized) |
| 7 | +struct PreviewSessionBuildContextTests { |
| 8 | + |
| 9 | + // MARK: - Paths |
| 10 | + |
| 11 | + static let repoRoot: URL = URL(fileURLWithPath: #filePath) |
| 12 | + .deletingLastPathComponent() // PreviewsCoreTests/ |
| 13 | + .deletingLastPathComponent() // Tests/ |
| 14 | + .deletingLastPathComponent() // repo root |
| 15 | + |
| 16 | + static let spmExampleRoot = repoRoot.appendingPathComponent("examples/spm") |
| 17 | + static let toDoViewFile = spmExampleRoot.appendingPathComponent("Sources/ToDo/ToDoView.swift") |
| 18 | + |
| 19 | + // MARK: - Helpers |
| 20 | + |
| 21 | + static func buildSPMExample() async throws -> BuildContext { |
| 22 | + guard let spm = try await SPMBuildSystem.detect(for: toDoViewFile) else { |
| 23 | + throw TestHelperError.noBuildSystem |
| 24 | + } |
| 25 | + return try await spm.build(platform: .macOS) |
| 26 | + } |
| 27 | + |
| 28 | + static func tier1Context(from ctx: BuildContext) -> BuildContext { |
| 29 | + BuildContext( |
| 30 | + moduleName: ctx.moduleName, |
| 31 | + compilerFlags: ctx.compilerFlags, |
| 32 | + projectRoot: ctx.projectRoot, |
| 33 | + targetName: ctx.targetName, |
| 34 | + sourceFiles: nil |
| 35 | + ) |
| 36 | + } |
| 37 | + |
| 38 | + enum TestHelperError: Error, LocalizedError { |
| 39 | + case noBuildSystem |
| 40 | + |
| 41 | + var errorDescription: String? { |
| 42 | + switch self { |
| 43 | + case .noBuildSystem: |
| 44 | + return "SPMBuildSystem.detect returned nil for examples/spm" |
| 45 | + } |
| 46 | + } |
| 47 | + } |
| 48 | + |
| 49 | + // MARK: - Tests |
| 50 | + |
| 51 | + @Test("Tier 2 compile: dylib + populated literals + DesignTimeStore symbols") |
| 52 | + func tier2Compile() async throws { |
| 53 | + let ctx = try await Self.buildSPMExample() |
| 54 | + #expect(ctx.supportsTier2) |
| 55 | + #expect( |
| 56 | + ctx.sourceFiles?.contains(where: { $0.lastPathComponent == "Item.swift" }) == true) |
| 57 | + |
| 58 | + let compiler = try await Compiler() |
| 59 | + let session = PreviewSession( |
| 60 | + sourceFile: Self.toDoViewFile, |
| 61 | + previewIndex: 0, |
| 62 | + compiler: compiler, |
| 63 | + buildContext: ctx |
| 64 | + ) |
| 65 | + |
| 66 | + let result = try await session.compile() |
| 67 | + #expect(FileManager.default.fileExists(atPath: result.dylibPath.path)) |
| 68 | + #expect(!result.literals.isEmpty, "Tier 2 should produce literal mappings") |
| 69 | + |
| 70 | + let loader = try DylibLoader(path: result.dylibPath.path) |
| 71 | + typealias CreateFunc = @convention(c) () -> UnsafeMutableRawPointer |
| 72 | + let _: CreateFunc = try loader.symbol(name: "createPreviewView") |
| 73 | + typealias SetString = @convention(c) (UnsafePointer<CChar>, UnsafePointer<CChar>) -> Void |
| 74 | + let _: SetString = try loader.symbol(name: "designTimeSetString") |
| 75 | + typealias SetInt = @convention(c) (UnsafePointer<CChar>, Int) -> Void |
| 76 | + let _: SetInt = try loader.symbol(name: "designTimeSetInteger") |
| 77 | + } |
| 78 | + |
| 79 | + @Test("Tier 1 context has supportsTier2 == false") |
| 80 | + func tier1ContextProperties() { |
| 81 | + let ctx = BuildContext( |
| 82 | + moduleName: "TestModule", |
| 83 | + compilerFlags: ["-I", "/some/path"], |
| 84 | + projectRoot: URL(fileURLWithPath: "/tmp"), |
| 85 | + targetName: "TestModule", |
| 86 | + sourceFiles: nil |
| 87 | + ) |
| 88 | + #expect(!ctx.supportsTier2) |
| 89 | + #expect(ctx.sourceFiles == nil) |
| 90 | + } |
| 91 | + |
| 92 | + @Test("tryLiteralUpdate returns nil for Tier 1 session") |
| 93 | + func tryLiteralUpdateNilForTier1() async throws { |
| 94 | + let fullCtx = try await Self.buildSPMExample() |
| 95 | + let ctx = Self.tier1Context(from: fullCtx) |
| 96 | + |
| 97 | + let compiler = try await Compiler() |
| 98 | + let session = PreviewSession( |
| 99 | + sourceFile: Self.toDoViewFile, |
| 100 | + previewIndex: 0, |
| 101 | + compiler: compiler, |
| 102 | + buildContext: ctx |
| 103 | + ) |
| 104 | + |
| 105 | + // No compile needed — tryLiteralUpdate checks buildContext.supportsTier2 |
| 106 | + // before checking lastOriginalSource |
| 107 | + let original = try String(contentsOf: Self.toDoViewFile, encoding: .utf8) |
| 108 | + #expect(original.contains("\"My Items\""), "ToDoView.swift should contain \"My Items\"") |
| 109 | + let modified = original.replacingOccurrences(of: "\"My Items\"", with: "\"My Tasks\"") |
| 110 | + |
| 111 | + let changes = await session.tryLiteralUpdate(newSource: modified) |
| 112 | + #expect(changes == nil, "Tier 1 should always return nil from tryLiteralUpdate") |
| 113 | + } |
| 114 | + |
| 115 | + @Test("tryLiteralUpdate returns changes for Tier 2 session") |
| 116 | + func tryLiteralUpdateChangesForTier2() async throws { |
| 117 | + let ctx = try await Self.buildSPMExample() |
| 118 | + |
| 119 | + let compiler = try await Compiler() |
| 120 | + let session = PreviewSession( |
| 121 | + sourceFile: Self.toDoViewFile, |
| 122 | + previewIndex: 0, |
| 123 | + compiler: compiler, |
| 124 | + buildContext: ctx |
| 125 | + ) |
| 126 | + |
| 127 | + _ = try await session.compile() |
| 128 | + |
| 129 | + let original = try String(contentsOf: Self.toDoViewFile, encoding: .utf8) |
| 130 | + #expect(original.contains("\"My Items\""), "ToDoView.swift should contain \"My Items\"") |
| 131 | + let modified = original.replacingOccurrences(of: "\"My Items\"", with: "\"My Tasks\"") |
| 132 | + |
| 133 | + let changes = await session.tryLiteralUpdate(newSource: modified) |
| 134 | + #expect(changes != nil, "Tier 2 should detect literal-only change") |
| 135 | + #expect( |
| 136 | + changes?.contains(where: { $0.newValue == .string("My Tasks") }) == true, |
| 137 | + "Should contain the changed string value" |
| 138 | + ) |
| 139 | + } |
| 140 | +} |
0 commit comments