Skip to content

Commit 4692a0f

Browse files
committed
test(cache): add tests for parallel hashing and LRU eviction
GranularCacheManagerTests: - testParallelHashComputationProducesDeterministicResults - testEmptyComponentsReturnsEmptyResult - testParallelHashComputationWithManyNodes SharedDownloadQueueTests: - testConcurrentJobsFromSameConfig - testEmptyFilesJobCompletesSuccessfully - testPriorityOrderingWithMixedPriorities - testCancelNonExistentConfig VersionTrackingHelperTests (new file): - VersionTrackingConfig initialization tests - VersionTrackingCheckResult enum tests
1 parent e7af176 commit 4692a0f

4 files changed

Lines changed: 307 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
[![CI](https://github.com/alexey1312/ExFig/actions/workflows/ci.yml/badge.svg)](https://github.com/alexey1312/ExFig/actions/workflows/ci.yml)
66
[![Release](https://github.com/alexey1312/ExFig/actions/workflows/release.yml/badge.svg)](https://github.com/alexey1312/ExFig/actions/workflows/release.yml)
77
[![Docs](https://github.com/alexey1312/ExFig/actions/workflows/deploy-docc.yml/badge.svg)](https://alexey1312.github.io/ExFig/documentation/exfig)
8-
![Coverage](https://img.shields.io/badge/coverage-45.69%25-yellow)
8+
![Coverage](https://img.shields.io/badge/coverage-45.99%25-yellow)
99
[![License](https://img.shields.io/github/license/alexey1312/ExFig.svg)](LICENSE)
1010

1111
Command-line utility to export colors, typography, icons, and images from Figma to Xcode, Android Studio, Flutter, and

Tests/ExFigTests/Cache/GranularCacheManagerTests.swift

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,83 @@ final class GranularCacheManagerTests: XCTestCase {
236236
XCTAssertGreaterThanOrEqual(nodesRequests.count, 2)
237237
}
238238

239+
// MARK: - Parallel Hash Computation
240+
241+
func testParallelHashComputationProducesDeterministicResults() async throws {
242+
// Given: Multiple components with the same data
243+
let components = (0 ..< 20).map { i in
244+
Component.make(nodeId: "node\(i)", name: "icon_\(i)", frameName: "Icons")
245+
}
246+
let nodes = makeNodeResponse(for: components)
247+
mockClient.setResponse(nodes, for: NodesEndpoint.self)
248+
249+
var cache = ImageTrackingCache()
250+
cache.updateFileVersion(fileId: "file123", version: "v1")
251+
252+
let manager = GranularCacheManager(client: mockClient, cache: cache)
253+
254+
// When: Computing hashes multiple times
255+
let result1 = try await manager.filterChangedComponents(
256+
fileId: "file123",
257+
components: Dictionary(uniqueKeysWithValues: components.map { ($0.nodeId, $0) })
258+
)
259+
260+
let result2 = try await manager.filterChangedComponents(
261+
fileId: "file123",
262+
components: Dictionary(uniqueKeysWithValues: components.map { ($0.nodeId, $0) })
263+
)
264+
265+
// Then: Hashes should be identical (deterministic)
266+
XCTAssertEqual(result1.computedHashes, result2.computedHashes)
267+
}
268+
269+
func testEmptyComponentsReturnsEmptyResult() async throws {
270+
// Given: No components
271+
var cache = ImageTrackingCache()
272+
cache.updateFileVersion(fileId: "file123", version: "v1")
273+
274+
let manager = GranularCacheManager(client: mockClient, cache: cache)
275+
276+
// When: Filtering with empty components
277+
let result = try await manager.filterChangedComponents(
278+
fileId: "file123",
279+
components: [:]
280+
)
281+
282+
// Then: Empty result
283+
XCTAssertTrue(result.changedComponents.isEmpty)
284+
XCTAssertTrue(result.computedHashes.isEmpty)
285+
}
286+
287+
func testParallelHashComputationWithManyNodes() async throws {
288+
// Given: Large number of components to trigger parallel processing
289+
let components = (0 ..< 200).map { i in
290+
Component.make(nodeId: "node\(i)", name: "icon_\(i)", frameName: "Icons")
291+
}
292+
let nodes = makeNodeResponse(for: components)
293+
mockClient.setResponse(nodes, for: NodesEndpoint.self)
294+
295+
var cache = ImageTrackingCache()
296+
cache.updateFileVersion(fileId: "file123", version: "v1")
297+
298+
let manager = GranularCacheManager(client: mockClient, cache: cache)
299+
300+
// When: Computing hashes for many nodes
301+
let result = try await manager.filterChangedComponents(
302+
fileId: "file123",
303+
components: Dictionary(uniqueKeysWithValues: components.map { ($0.nodeId, $0) })
304+
)
305+
306+
// Then: All hashes computed correctly
307+
XCTAssertEqual(result.computedHashes.count, 200)
308+
XCTAssertEqual(result.changedComponents.count, 200)
309+
310+
// Verify each hash is non-empty
311+
for (_, hash) in result.computedHashes {
312+
XCTAssertFalse(hash.isEmpty)
313+
}
314+
}
315+
239316
// MARK: - Helpers
240317

241318
private func makeNodeResponse(for components: [Component]) -> [NodeId: Node] {
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
@testable import ExFig
2+
@testable import FigmaAPI
3+
import Logging
4+
import XCTest
5+
6+
/// Tests for VersionTrackingConfig initialization and properties.
7+
final class VersionTrackingConfigTests: XCTestCase {
8+
private var ui: TerminalUI!
9+
10+
override func setUp() {
11+
super.setUp()
12+
ui = TerminalUI(outputMode: .quiet)
13+
}
14+
15+
func testDefaultBatchMode() {
16+
// Given: Config without explicit batchMode
17+
let config = VersionTrackingConfig(
18+
client: MockClient(),
19+
params: Params.makeMinimal(),
20+
cacheOptions: CacheOptions(),
21+
configCacheEnabled: false,
22+
configCachePath: nil,
23+
assetType: "Icons",
24+
ui: ui,
25+
logger: Logger(label: "test")
26+
)
27+
28+
// Then: batchMode defaults to false
29+
XCTAssertFalse(config.batchMode)
30+
}
31+
32+
func testExplicitBatchMode() {
33+
// Given: Config with explicit batchMode
34+
let config = VersionTrackingConfig(
35+
client: MockClient(),
36+
params: Params.makeMinimal(),
37+
cacheOptions: CacheOptions(),
38+
configCacheEnabled: false,
39+
configCachePath: nil,
40+
assetType: "Colors",
41+
ui: ui,
42+
logger: Logger(label: "test"),
43+
batchMode: true
44+
)
45+
46+
// Then: batchMode is set
47+
XCTAssertTrue(config.batchMode)
48+
}
49+
50+
func testAssetTypeIsPreserved() {
51+
// Given: Different asset types
52+
let colorsConfig = VersionTrackingConfig(
53+
client: MockClient(),
54+
params: Params.makeMinimal(),
55+
cacheOptions: CacheOptions(),
56+
configCacheEnabled: false,
57+
configCachePath: nil,
58+
assetType: "Colors",
59+
ui: ui,
60+
logger: Logger(label: "test")
61+
)
62+
63+
let iconsConfig = VersionTrackingConfig(
64+
client: MockClient(),
65+
params: Params.makeMinimal(),
66+
cacheOptions: CacheOptions(),
67+
configCacheEnabled: false,
68+
configCachePath: nil,
69+
assetType: "Icons",
70+
ui: ui,
71+
logger: Logger(label: "test")
72+
)
73+
74+
// Then: Asset types are preserved
75+
XCTAssertEqual(colorsConfig.assetType, "Colors")
76+
XCTAssertEqual(iconsConfig.assetType, "Icons")
77+
}
78+
}
79+
80+
/// Tests for VersionTrackingCheckResult enum.
81+
final class VersionTrackingCheckResultTests: XCTestCase {
82+
func testSkipExportCase() {
83+
// Given: skipExport result
84+
let result = VersionTrackingCheckResult.skipExport
85+
86+
// Then: It matches skipExport pattern
87+
if case .skipExport = result {
88+
// Pass
89+
} else {
90+
XCTFail("Expected skipExport")
91+
}
92+
}
93+
94+
func testProceedCase() {
95+
// Given: proceed result with manager and versions
96+
let manager = ImageTrackingManager(
97+
client: MockClient(),
98+
cachePath: nil,
99+
logger: Logger(label: "test")
100+
)
101+
let versions = [
102+
FileVersionInfo(
103+
fileId: "file1",
104+
fileName: "Design",
105+
currentVersion: "v1",
106+
cachedVersion: nil,
107+
needsExport: true
108+
),
109+
]
110+
111+
let result = VersionTrackingCheckResult.proceed(manager: manager, versions: versions)
112+
113+
// Then: Values are accessible
114+
if case let .proceed(extractedManager, extractedVersions) = result {
115+
XCTAssertNotNil(extractedManager)
116+
XCTAssertEqual(extractedVersions.count, 1)
117+
XCTAssertEqual(extractedVersions[0].fileId, "file1")
118+
} else {
119+
XCTFail("Expected proceed")
120+
}
121+
}
122+
}
123+
124+
// MARK: - Test Helpers
125+
126+
private extension Params {
127+
static func makeMinimal() -> Params {
128+
let json = """
129+
{
130+
"figma": {
131+
"lightFileId": "light123"
132+
}
133+
}
134+
"""
135+
// swiftlint:disable:next force_try
136+
return try! JSONDecoder().decode(Params.self, from: Data(json.utf8))
137+
}
138+
}

Tests/ExFigTests/Pipeline/SharedDownloadQueueTests.swift

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,97 @@ final class SharedDownloadQueueTests: XCTestCase {
141141
XCTAssertTrue(description.contains("completed=10"))
142142
}
143143

144+
// MARK: - LRU Eviction Edge Cases
145+
146+
func testConcurrentJobsFromSameConfig() async throws {
147+
// Given: A queue with multiple jobs from the same config
148+
let queue = SharedDownloadQueue(maxConcurrentDownloads: 5)
149+
150+
let job1 = DownloadJob(
151+
files: [FileContents.makeLocal(name: "file1")],
152+
configId: "same-config",
153+
priority: 0
154+
)
155+
let job2 = DownloadJob(
156+
files: [FileContents.makeLocal(name: "file2")],
157+
configId: "same-config",
158+
priority: 0
159+
)
160+
161+
// When: Submitting both jobs from same config
162+
let jobId1 = await queue.submitAndProcess(job: job1)
163+
let jobId2 = await queue.submitAndProcess(job: job2)
164+
165+
// Then: Both jobs complete successfully
166+
let result1 = try await queue.waitForCompletion(jobId: jobId1)
167+
let result2 = try await queue.waitForCompletion(jobId: jobId2)
168+
169+
XCTAssertEqual(result1.configId, "same-config")
170+
XCTAssertEqual(result2.configId, "same-config")
171+
XCTAssertEqual(result1.downloadedFiles.count, 1)
172+
XCTAssertEqual(result2.downloadedFiles.count, 1)
173+
}
174+
175+
func testEmptyFilesJobCompletesSuccessfully() async throws {
176+
// Given: A job with no files
177+
let queue = SharedDownloadQueue(maxConcurrentDownloads: 5)
178+
let job = DownloadJob(
179+
files: [],
180+
configId: "empty-job",
181+
priority: 0
182+
)
183+
184+
// When: Submitting and waiting
185+
let jobId = await queue.submitAndProcess(job: job)
186+
let result = try await queue.waitForCompletion(jobId: jobId)
187+
188+
// Then: Completes with empty files
189+
XCTAssertEqual(result.downloadedFiles.count, 0)
190+
XCTAssertEqual(result.configId, "empty-job")
191+
}
192+
193+
func testPriorityOrderingWithMixedPriorities() async throws {
194+
// Given: Jobs with various priorities
195+
let queue = SharedDownloadQueue(maxConcurrentDownloads: 1)
196+
197+
let lowPriority = DownloadJob(
198+
files: [FileContents.makeLocal(name: "low")],
199+
configId: "low",
200+
priority: 100
201+
)
202+
let mediumPriority = DownloadJob(
203+
files: [FileContents.makeLocal(name: "medium")],
204+
configId: "medium",
205+
priority: 50
206+
)
207+
let highPriority = DownloadJob(
208+
files: [FileContents.makeLocal(name: "high")],
209+
configId: "high",
210+
priority: 1
211+
)
212+
213+
// When: Submitting in reverse priority order
214+
await queue.submit(job: lowPriority)
215+
await queue.submit(job: mediumPriority)
216+
let highJobId = await queue.submitAndProcess(job: highPriority)
217+
218+
// Then: High priority job can be waited on
219+
let result = try await queue.waitForCompletion(jobId: highJobId)
220+
XCTAssertEqual(result.configId, "high")
221+
}
222+
223+
func testCancelNonExistentConfig() async {
224+
// Given: An empty queue
225+
let queue = SharedDownloadQueue(maxConcurrentDownloads: 5)
226+
227+
// When: Cancelling a config that doesn't exist
228+
await queue.cancelConfig("non-existent")
229+
230+
// Then: No crash, stats remain clean
231+
let stats = await queue.stats()
232+
XCTAssertEqual(stats.pendingJobs, 0)
233+
}
234+
144235
// MARK: - DownloadJob Tests
145236

146237
func testDownloadJobInit() {

0 commit comments

Comments
 (0)