Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 54 additions & 9 deletions Sources/AudioSnapshotTesting/Core/AudioSnapshotTesting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand All @@ -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,
Expand All @@ -201,7 +246,7 @@ private func buildFailureMessage(
)
message += "\nFailure visualization:" + visualizationMessage
}

return message
}

Expand All @@ -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
Expand Down
13 changes: 10 additions & 3 deletions Sources/AudioSnapshotTesting/Core/AudioSnapshotTrait.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
}
}

Expand Down
7 changes: 7 additions & 0 deletions Sources/AudioSnapshotTesting/Core/SnapshotFormat.swift
Original file line number Diff line number Diff line change
@@ -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
}
29 changes: 29 additions & 0 deletions Sources/AudioSnapshotTesting/Internal/AudioChecksumWriter.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
34 changes: 34 additions & 0 deletions Tests/AudioSnapshotTestingTests/AudioSnapshotTestingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
60e4ed9c7ca9ae8b665aab56b5f09cd4
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
15bcba203f8d88260cd8d606496a45aa
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
60e4ed9c7ca9ae8b665aab56b5f09cd4