From f46422ed124bb5f45f5a7f7ef32e227db30c2b5e Mon Sep 17 00:00:00 2001 From: Josip Cavar Date: Wed, 25 Feb 2026 10:55:18 +0100 Subject: [PATCH] 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. --- .../Core/AudioSnapshotTesting.swift | 63 ++++++++++++++++--- .../Core/AudioSnapshotTrait.swift | 13 +++- .../Core/SnapshotFormat.swift | 7 +++ .../Internal/AudioChecksumWriter.swift | 29 +++++++++ .../AudioSnapshotTestingTests.swift | 34 ++++++++++ .../checksumMultiBuffer.1.md5 | 1 + .../checksumMultiBuffer.2.md5 | 1 + .../checksumRoundTrip.440hz.md5 | 1 + 8 files changed, 137 insertions(+), 12 deletions(-) create mode 100644 Sources/AudioSnapshotTesting/Core/SnapshotFormat.swift create mode 100644 Sources/AudioSnapshotTesting/Internal/AudioChecksumWriter.swift create mode 100644 Tests/AudioSnapshotTestingTests/__AudioSnapshots__/AudioSnapshotTestingTests/checksumMultiBuffer.1.md5 create mode 100644 Tests/AudioSnapshotTestingTests/__AudioSnapshots__/AudioSnapshotTestingTests/checksumMultiBuffer.2.md5 create mode 100644 Tests/AudioSnapshotTestingTests/__AudioSnapshots__/AudioSnapshotTestingTests/checksumRoundTrip.440hz.md5 diff --git a/Sources/AudioSnapshotTesting/Core/AudioSnapshotTesting.swift b/Sources/AudioSnapshotTesting/Core/AudioSnapshotTesting.swift index b8dda5a..01d48dc 100644 --- a/Sources/AudioSnapshotTesting/Core/AudioSnapshotTesting.swift +++ b/Sources/AudioSnapshotTesting/Core/AudioSnapshotTesting.swift @@ -69,10 +69,24 @@ private struct SnapshotContext { func snapshotPath(index: Int, count: Int) -> URL { let suffix = count > 1 ? ".\(index + 1)" : "" - let fileName = "\(name)\(suffix).caf" + let ext: String + switch trait.format { + case .audio: + ext = "caf" + case .checksum: + ext = "md5" + } + let fileName = "\(name)\(suffix).\(ext)" return SnapshotFileManager.snapshotPath(directory: directory, fileName: fileName) } + func temporaryAudioPath(index: Int, count: Int, label: String? = nil) -> URL { + let suffix = count > 1 ? ".\(index + 1)" : "" + let labelSuffix = label.map { ".\($0)" } ?? "" + let fileName = "\(name)\(suffix)\(labelSuffix).caf" + return SnapshotFileManager.temporaryFilePath(fileName: fileName) + } + func visualizationPath() -> URL { SnapshotFileManager.temporaryFilePath(fileName: "\(name).png") } @@ -131,7 +145,15 @@ private func recordSnapshots( for index in indicesToRecord { let path = context.snapshotPath(index: index, count: bufferCount) - try AudioFileWriter.write(buffer: buffers[index], to: path, bitDepth: context.trait.bitDepth) + switch context.trait.format { + case .audio: + try AudioFileWriter.write(buffer: buffers[index], to: path, bitDepth: context.trait.bitDepth) + case .checksum: + let tempPath = context.temporaryAudioPath(index: index, count: bufferCount) + try AudioFileWriter.write(buffer: buffers[index], to: tempPath, bitDepth: context.trait.bitDepth) + let checksum = try AudioChecksumWriter.computeChecksum(of: tempPath) + try AudioChecksumWriter.writeChecksum(checksum, to: path) + } } var message: String @@ -168,14 +190,25 @@ private func verifySnapshots(buffers: [AVAudioPCMBuffer], context: SnapshotConte private func compareSnapshots(buffers: [AVAudioPCMBuffer], context: SnapshotContext) throws -> [(index: Int, message: String)] { var diffs: [(index: Int, message: String)] = [] - + for (index, buffer) in buffers.enumerated() { let path = context.snapshotPath(index: index, count: buffers.count) - if let diffMessage = try AudioDataComparator.compare(expectedURL: path, actual: buffer, bitDepth: context.trait.bitDepth) { - diffs.append((index, diffMessage)) + switch context.trait.format { + case .audio: + if let diffMessage = try AudioDataComparator.compare(expectedURL: path, actual: buffer, bitDepth: context.trait.bitDepth) { + diffs.append((index, diffMessage)) + } + case .checksum: + let tempPath = context.temporaryAudioPath(index: index, count: buffers.count) + try AudioFileWriter.write(buffer: buffer, to: tempPath, bitDepth: context.trait.bitDepth) + let actualChecksum = try AudioChecksumWriter.computeChecksum(of: tempPath) + let expectedChecksum = try AudioChecksumWriter.readChecksum(from: path) + if actualChecksum != expectedChecksum { + diffs.append((index, "Checksum mismatch: expected \(expectedChecksum), got \(actualChecksum)")) + } } } - + return diffs } @@ -186,13 +219,25 @@ private func buildFailureMessage( ) async throws -> String { let bufferCount = buffers.count var message = bufferCount > 1 ? "Audio snapshots differ." : diffs[0].message - + if bufferCount > 1 { for diff in diffs { message += "\nBuffer \(diff.index + 1): \(diff.message)" } } + for diff in diffs { + let buffer = buffers[diff.index] + let path = context.temporaryAudioPath(index: diff.index, count: bufferCount, label: "actual") + try AudioFileWriter.write(buffer: buffer, to: path, bitDepth: context.trait.bitDepth) + Attachment.record( + try Data(contentsOf: path), + named: path.lastPathComponent, + sourceLocation: context.sourceLocation + ) + message += "\nActual audio: file://\(path.path)" + } + if let strategy = context.trait.strategy { let visualizationMessage = try await generateVisualization( buffers: buffers, @@ -201,7 +246,7 @@ private func buildFailureMessage( ) message += "\nFailure visualization:" + visualizationMessage } - + return message } @@ -214,7 +259,7 @@ private func generateVisualization( let tempPath = context.visualizationPath() try SnapshotFileManager.writeFile(visualData, to: tempPath) Attachment.record(visualData, named: "\(context.name).png", sourceLocation: context.sourceLocation) - + #if os(macOS) // During developemnt, it is useful to auto open // generated file for easy inspection diff --git a/Sources/AudioSnapshotTesting/Core/AudioSnapshotTrait.swift b/Sources/AudioSnapshotTesting/Core/AudioSnapshotTrait.swift index c00eaa7..4bcd009 100644 --- a/Sources/AudioSnapshotTesting/Core/AudioSnapshotTrait.swift +++ b/Sources/AudioSnapshotTesting/Core/AudioSnapshotTrait.swift @@ -20,17 +20,22 @@ public struct AudioSnapshotTrait: TestTrait, SuiteTrait, TestScoping { /// The bit depth for ALAC encoding. Defaults to 16-bit. public let bitDepth: AudioBitDepth + /// The format used for storing snapshot artifacts. + public let format: SnapshotFormat + /// Creates a new audio snapshot trait. /// - Parameters: /// - record: Whether to record new snapshots. Defaults to `false`. /// - strategy: The snapshot strategy for failure visualization. Defaults to `nil`. /// - autoOpen: Whether to automatically open visualizations. Defaults to `false`. /// - bitDepth: The bit depth for ALAC encoding. Defaults to `.bits16`. - public init(record: Bool = false, strategy: VisualisationStrategy? = nil, autoOpen: Bool = false, bitDepth: AudioBitDepth = .bits16) { + /// - format: The format for storing snapshot artifacts. Defaults to `.audio`. + public init(record: Bool = false, strategy: VisualisationStrategy? = nil, autoOpen: Bool = false, bitDepth: AudioBitDepth = .bits16, format: SnapshotFormat = .audio) { self.record = record self.strategy = strategy self.autoOpen = autoOpen self.bitDepth = bitDepth + self.format = format } /// Called by Swift Testing to set up the test scope. @@ -48,14 +53,16 @@ extension Trait where Self == AudioSnapshotTrait { /// - strategy: The snapshot strategy for failure visualization. Defaults to `nil`. /// - autoOpen: Whether to automatically open visualizations. Defaults to `false`. /// - bitDepth: The bit depth for ALAC encoding. Defaults to `.bits16`. + /// - format: The format for storing snapshot artifacts. Defaults to `.audio`. /// - Returns: An `AudioSnapshotTrait` configured with the specified options. public static func audioSnapshot( record: Bool = false, strategy: VisualisationStrategy? = nil, autoOpen: Bool = false, - bitDepth: AudioBitDepth = .bits16 + bitDepth: AudioBitDepth = .bits16, + format: SnapshotFormat = .audio ) -> AudioSnapshotTrait { - AudioSnapshotTrait(record: record, strategy: strategy, autoOpen: autoOpen, bitDepth: bitDepth) + AudioSnapshotTrait(record: record, strategy: strategy, autoOpen: autoOpen, bitDepth: bitDepth, format: format) } } diff --git a/Sources/AudioSnapshotTesting/Core/SnapshotFormat.swift b/Sources/AudioSnapshotTesting/Core/SnapshotFormat.swift new file mode 100644 index 0000000..48e8c2f --- /dev/null +++ b/Sources/AudioSnapshotTesting/Core/SnapshotFormat.swift @@ -0,0 +1,7 @@ +/// The format used for storing audio snapshot artifacts. +public enum SnapshotFormat: Sendable { + /// Full ALAC-encoded .caf audio file (current behavior). + case audio + /// Lightweight MD5 checksum stored in a .md5 text file. + case checksum +} diff --git a/Sources/AudioSnapshotTesting/Internal/AudioChecksumWriter.swift b/Sources/AudioSnapshotTesting/Internal/AudioChecksumWriter.swift new file mode 100644 index 0000000..280eff8 --- /dev/null +++ b/Sources/AudioSnapshotTesting/Internal/AudioChecksumWriter.swift @@ -0,0 +1,29 @@ +import CryptoKit +import Foundation + +/// Handles MD5 checksum computation and file I/O for checksum-based snapshots. +enum AudioChecksumWriter { + /// Computes the MD5 checksum of a file at the given URL. + /// - Parameter url: The file URL to hash. + /// - Returns: A 32-character lowercase hex string. + static func computeChecksum(of url: URL) throws -> String { + let data = try Data(contentsOf: url) + let digest = Insecure.MD5.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } + + /// Writes a checksum string to a `.md5` file. + /// - Parameters: + /// - checksum: The 32-character hex checksum string. + /// - url: The destination file URL. + static func writeChecksum(_ checksum: String, to url: URL) throws { + try checksum.write(to: url, atomically: true, encoding: .utf8) + } + + /// Reads a checksum string from a `.md5` file. + /// - Parameter url: The file URL to read from. + /// - Returns: The checksum string (trimmed of whitespace). + static func readChecksum(from url: URL) throws -> String { + try String(contentsOf: url, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Tests/AudioSnapshotTestingTests/AudioSnapshotTestingTests.swift b/Tests/AudioSnapshotTestingTests/AudioSnapshotTestingTests.swift index 23f3326..5b5bbf1 100644 --- a/Tests/AudioSnapshotTestingTests/AudioSnapshotTestingTests.swift +++ b/Tests/AudioSnapshotTestingTests/AudioSnapshotTestingTests.swift @@ -169,6 +169,40 @@ func multiChannelComparison() async throws { await assertAudioSnapshot(of: buffer, named: "multiChannelComparison.4ch") } +@Test( + "Checksum snapshot records and verifies a deterministic buffer", + .audioSnapshot(record: false, format: .checksum) +) +func checksumRoundTrip() async throws { + let signal = synthesizeSignal( + frequencyAmplitudePairs: [(440, 0.5)], + count: 4410 + ) + let buffer = createBuffer(from: signal) + await assertAudioSnapshot(of: buffer, named: "checksumRoundTrip.440hz") +} + +@Test( + "Checksum snapshot with multiple buffers uses indexed naming", + .audioSnapshot(record: false, format: .checksum) +) +func checksumMultiBuffer() async throws { + let signal1 = synthesizeSignal( + frequencyAmplitudePairs: [(440, 0.5)], + count: 4410 + ) + let signal2 = synthesizeSignal( + frequencyAmplitudePairs: [(880, 0.3)], + count: 4410 + ) + let buffer1 = createBuffer(from: signal1) + let buffer2 = createBuffer(from: signal2) + await assertAudioSnapshot( + of: [buffer1, buffer2], + named: "checksumMultiBuffer" + ) +} + private func createBuffer(from samples: [Float], sampleRate: Double = 32768) -> AVAudioPCMBuffer { createBuffer(channels: [samples], sampleRate: sampleRate) } diff --git a/Tests/AudioSnapshotTestingTests/__AudioSnapshots__/AudioSnapshotTestingTests/checksumMultiBuffer.1.md5 b/Tests/AudioSnapshotTestingTests/__AudioSnapshots__/AudioSnapshotTestingTests/checksumMultiBuffer.1.md5 new file mode 100644 index 0000000..ea1615f --- /dev/null +++ b/Tests/AudioSnapshotTestingTests/__AudioSnapshots__/AudioSnapshotTestingTests/checksumMultiBuffer.1.md5 @@ -0,0 +1 @@ +60e4ed9c7ca9ae8b665aab56b5f09cd4 \ No newline at end of file diff --git a/Tests/AudioSnapshotTestingTests/__AudioSnapshots__/AudioSnapshotTestingTests/checksumMultiBuffer.2.md5 b/Tests/AudioSnapshotTestingTests/__AudioSnapshots__/AudioSnapshotTestingTests/checksumMultiBuffer.2.md5 new file mode 100644 index 0000000..451a436 --- /dev/null +++ b/Tests/AudioSnapshotTestingTests/__AudioSnapshots__/AudioSnapshotTestingTests/checksumMultiBuffer.2.md5 @@ -0,0 +1 @@ +15bcba203f8d88260cd8d606496a45aa \ No newline at end of file diff --git a/Tests/AudioSnapshotTestingTests/__AudioSnapshots__/AudioSnapshotTestingTests/checksumRoundTrip.440hz.md5 b/Tests/AudioSnapshotTestingTests/__AudioSnapshots__/AudioSnapshotTestingTests/checksumRoundTrip.440hz.md5 new file mode 100644 index 0000000..ea1615f --- /dev/null +++ b/Tests/AudioSnapshotTestingTests/__AudioSnapshots__/AudioSnapshotTestingTests/checksumRoundTrip.440hz.md5 @@ -0,0 +1 @@ +60e4ed9c7ca9ae8b665aab56b5f09cd4 \ No newline at end of file