Skip to content

Commit 262b79a

Browse files
committed
Add SnapshotFormat option for audio file vs MD5 checksum snapshots
For repositories with many audio snapshot tests, .caf files can bloat the repo. This adds a SnapshotFormat enum (.audio / .checksum) so users can opt into lightweight 32-byte .md5 checksum files instead of full ALAC audio snapshots. Default is .audio, preserving full backward compatibility.
1 parent 363f2be commit 262b79a

8 files changed

Lines changed: 142 additions & 12 deletions

File tree

Sources/AudioSnapshotTesting/Core/AudioSnapshotTesting.swift

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,32 @@ private struct SnapshotContext {
6969

7070
func snapshotPath(index: Int, count: Int) -> URL {
7171
let suffix = count > 1 ? ".\(index + 1)" : ""
72-
let fileName = "\(name)\(suffix).caf"
72+
let ext: String
73+
switch trait.format {
74+
case .audio:
75+
ext = "caf"
76+
case .checksum:
77+
ext = "md5"
78+
}
79+
let fileName = "\(name)\(suffix).\(ext)"
7380
return SnapshotFileManager.snapshotPath(directory: directory, fileName: fileName)
7481
}
7582

83+
func temporaryAudioPath(index: Int, count: Int) -> URL {
84+
let suffix = count > 1 ? ".\(index + 1)" : ""
85+
let fileName = "\(name)\(suffix).caf"
86+
return SnapshotFileManager.temporaryFilePath(fileName: fileName)
87+
}
88+
7689
func visualizationPath() -> URL {
7790
SnapshotFileManager.temporaryFilePath(fileName: "\(name).png")
7891
}
92+
93+
func actualAudioPath(index: Int, count: Int) -> URL {
94+
let suffix = count > 1 ? ".\(index + 1)" : ""
95+
let fileName = "\(name)\(suffix).actual.caf"
96+
return SnapshotFileManager.temporaryFilePath(fileName: fileName)
97+
}
7998
}
8099

81100
private func performSnapshot(
@@ -131,7 +150,15 @@ private func recordSnapshots(
131150

132151
for index in indicesToRecord {
133152
let path = context.snapshotPath(index: index, count: bufferCount)
134-
try AudioFileWriter.write(buffer: buffers[index], to: path, bitDepth: context.trait.bitDepth)
153+
switch context.trait.format {
154+
case .audio:
155+
try AudioFileWriter.write(buffer: buffers[index], to: path, bitDepth: context.trait.bitDepth)
156+
case .checksum:
157+
let tempPath = context.temporaryAudioPath(index: index, count: bufferCount)
158+
try AudioFileWriter.write(buffer: buffers[index], to: tempPath, bitDepth: context.trait.bitDepth)
159+
let checksum = try AudioChecksumWriter.computeChecksum(of: tempPath)
160+
try AudioChecksumWriter.writeChecksum(checksum, to: path)
161+
}
135162
}
136163

137164
var message: String
@@ -168,14 +195,25 @@ private func verifySnapshots(buffers: [AVAudioPCMBuffer], context: SnapshotConte
168195

169196
private func compareSnapshots(buffers: [AVAudioPCMBuffer], context: SnapshotContext) throws -> [(index: Int, message: String)] {
170197
var diffs: [(index: Int, message: String)] = []
171-
198+
172199
for (index, buffer) in buffers.enumerated() {
173200
let path = context.snapshotPath(index: index, count: buffers.count)
174-
if let diffMessage = try AudioDataComparator.compare(expectedURL: path, actual: buffer, bitDepth: context.trait.bitDepth) {
175-
diffs.append((index, diffMessage))
201+
switch context.trait.format {
202+
case .audio:
203+
if let diffMessage = try AudioDataComparator.compare(expectedURL: path, actual: buffer, bitDepth: context.trait.bitDepth) {
204+
diffs.append((index, diffMessage))
205+
}
206+
case .checksum:
207+
let tempPath = context.temporaryAudioPath(index: index, count: buffers.count)
208+
try AudioFileWriter.write(buffer: buffer, to: tempPath, bitDepth: context.trait.bitDepth)
209+
let actualChecksum = try AudioChecksumWriter.computeChecksum(of: tempPath)
210+
let expectedChecksum = try AudioChecksumWriter.readChecksum(from: path)
211+
if actualChecksum != expectedChecksum {
212+
diffs.append((index, "Checksum mismatch: expected \(expectedChecksum), got \(actualChecksum)"))
213+
}
176214
}
177215
}
178-
216+
179217
return diffs
180218
}
181219

@@ -186,13 +224,25 @@ private func buildFailureMessage(
186224
) async throws -> String {
187225
let bufferCount = buffers.count
188226
var message = bufferCount > 1 ? "Audio snapshots differ." : diffs[0].message
189-
227+
190228
if bufferCount > 1 {
191229
for diff in diffs {
192230
message += "\nBuffer \(diff.index + 1): \(diff.message)"
193231
}
194232
}
195233

234+
for diff in diffs {
235+
let buffer = buffers[diff.index]
236+
let path = context.actualAudioPath(index: diff.index, count: bufferCount)
237+
try AudioFileWriter.write(buffer: buffer, to: path, bitDepth: context.trait.bitDepth)
238+
Attachment.record(
239+
try Data(contentsOf: path),
240+
named: path.lastPathComponent,
241+
sourceLocation: context.sourceLocation
242+
)
243+
message += "\nActual audio: file://\(path.path)"
244+
}
245+
196246
if let strategy = context.trait.strategy {
197247
let visualizationMessage = try await generateVisualization(
198248
buffers: buffers,
@@ -201,7 +251,7 @@ private func buildFailureMessage(
201251
)
202252
message += "\nFailure visualization:" + visualizationMessage
203253
}
204-
254+
205255
return message
206256
}
207257

@@ -214,7 +264,7 @@ private func generateVisualization(
214264
let tempPath = context.visualizationPath()
215265
try SnapshotFileManager.writeFile(visualData, to: tempPath)
216266
Attachment.record(visualData, named: "\(context.name).png", sourceLocation: context.sourceLocation)
217-
267+
218268
#if os(macOS)
219269
// During developemnt, it is useful to auto open
220270
// generated file for easy inspection

Sources/AudioSnapshotTesting/Core/AudioSnapshotTrait.swift

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,22 @@ public struct AudioSnapshotTrait: TestTrait, SuiteTrait, TestScoping {
2020
/// The bit depth for ALAC encoding. Defaults to 16-bit.
2121
public let bitDepth: AudioBitDepth
2222

23+
/// The format used for storing snapshot artifacts.
24+
public let format: SnapshotFormat
25+
2326
/// Creates a new audio snapshot trait.
2427
/// - Parameters:
2528
/// - record: Whether to record new snapshots. Defaults to `false`.
2629
/// - strategy: The snapshot strategy for failure visualization. Defaults to `nil`.
2730
/// - autoOpen: Whether to automatically open visualizations. Defaults to `false`.
2831
/// - bitDepth: The bit depth for ALAC encoding. Defaults to `.bits16`.
29-
public init(record: Bool = false, strategy: VisualisationStrategy? = nil, autoOpen: Bool = false, bitDepth: AudioBitDepth = .bits16) {
32+
/// - format: The format for storing snapshot artifacts. Defaults to `.audio`.
33+
public init(record: Bool = false, strategy: VisualisationStrategy? = nil, autoOpen: Bool = false, bitDepth: AudioBitDepth = .bits16, format: SnapshotFormat = .audio) {
3034
self.record = record
3135
self.strategy = strategy
3236
self.autoOpen = autoOpen
3337
self.bitDepth = bitDepth
38+
self.format = format
3439
}
3540

3641
/// Called by Swift Testing to set up the test scope.
@@ -48,14 +53,16 @@ extension Trait where Self == AudioSnapshotTrait {
4853
/// - strategy: The snapshot strategy for failure visualization. Defaults to `nil`.
4954
/// - autoOpen: Whether to automatically open visualizations. Defaults to `false`.
5055
/// - bitDepth: The bit depth for ALAC encoding. Defaults to `.bits16`.
56+
/// - format: The format for storing snapshot artifacts. Defaults to `.audio`.
5157
/// - Returns: An `AudioSnapshotTrait` configured with the specified options.
5258
public static func audioSnapshot(
5359
record: Bool = false,
5460
strategy: VisualisationStrategy? = nil,
5561
autoOpen: Bool = false,
56-
bitDepth: AudioBitDepth = .bits16
62+
bitDepth: AudioBitDepth = .bits16,
63+
format: SnapshotFormat = .audio
5764
) -> AudioSnapshotTrait {
58-
AudioSnapshotTrait(record: record, strategy: strategy, autoOpen: autoOpen, bitDepth: bitDepth)
65+
AudioSnapshotTrait(record: record, strategy: strategy, autoOpen: autoOpen, bitDepth: bitDepth, format: format)
5966
}
6067
}
6168

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/// The format used for storing audio snapshot artifacts.
2+
public enum SnapshotFormat: Sendable {
3+
/// Full ALAC-encoded .caf audio file (current behavior).
4+
case audio
5+
/// Lightweight MD5 checksum stored in a .md5 text file.
6+
case checksum
7+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import CryptoKit
2+
import Foundation
3+
4+
/// Handles MD5 checksum computation and file I/O for checksum-based snapshots.
5+
enum AudioChecksumWriter {
6+
/// Computes the MD5 checksum of a file at the given URL.
7+
/// - Parameter url: The file URL to hash.
8+
/// - Returns: A 32-character lowercase hex string.
9+
static func computeChecksum(of url: URL) throws -> String {
10+
let data = try Data(contentsOf: url)
11+
let digest = Insecure.MD5.hash(data: data)
12+
return digest.map { String(format: "%02x", $0) }.joined()
13+
}
14+
15+
/// Writes a checksum string to a `.md5` file.
16+
/// - Parameters:
17+
/// - checksum: The 32-character hex checksum string.
18+
/// - url: The destination file URL.
19+
static func writeChecksum(_ checksum: String, to url: URL) throws {
20+
try checksum.write(to: url, atomically: true, encoding: .utf8)
21+
}
22+
23+
/// Reads a checksum string from a `.md5` file.
24+
/// - Parameter url: The file URL to read from.
25+
/// - Returns: The checksum string (trimmed of whitespace).
26+
static func readChecksum(from url: URL) throws -> String {
27+
try String(contentsOf: url, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines)
28+
}
29+
}

Tests/AudioSnapshotTestingTests/AudioSnapshotTestingTests.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,40 @@ func multiChannelComparison() async throws {
169169
await assertAudioSnapshot(of: buffer, named: "multiChannelComparison.4ch")
170170
}
171171

172+
@Test(
173+
"Checksum snapshot records and verifies a deterministic buffer",
174+
.audioSnapshot(record: false, format: .checksum)
175+
)
176+
func checksumRoundTrip() async throws {
177+
let signal = synthesizeSignal(
178+
frequencyAmplitudePairs: [(440, 0.5)],
179+
count: 4410
180+
)
181+
let buffer = createBuffer(from: signal)
182+
await assertAudioSnapshot(of: buffer, named: "checksumRoundTrip.440hz")
183+
}
184+
185+
@Test(
186+
"Checksum snapshot with multiple buffers uses indexed naming",
187+
.audioSnapshot(record: false, format: .checksum)
188+
)
189+
func checksumMultiBuffer() async throws {
190+
let signal1 = synthesizeSignal(
191+
frequencyAmplitudePairs: [(440, 0.5)],
192+
count: 4410
193+
)
194+
let signal2 = synthesizeSignal(
195+
frequencyAmplitudePairs: [(880, 0.3)],
196+
count: 4410
197+
)
198+
let buffer1 = createBuffer(from: signal1)
199+
let buffer2 = createBuffer(from: signal2)
200+
await assertAudioSnapshot(
201+
of: [buffer1, buffer2],
202+
named: "checksumMultiBuffer"
203+
)
204+
}
205+
172206
private func createBuffer(from samples: [Float], sampleRate: Double = 32768) -> AVAudioPCMBuffer {
173207
createBuffer(channels: [samples], sampleRate: sampleRate)
174208
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
60e4ed9c7ca9ae8b665aab56b5f09cd4
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
15bcba203f8d88260cd8d606496a45aa
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
60e4ed9c7ca9ae8b665aab56b5f09cd4

0 commit comments

Comments
 (0)