Introduces the core Track struct (nonisolated to work with project-wide MainActor default isolation), its FetchableRecord/MutablePersistableRecord conformances, a DEBUG fixture helper, and three Swift Testing tests covering round-trip persistence, ID assignment on insert, and deterministic hash computation.feat/music-streaming
parent
1aac6823fa
commit
a8978f7eae
@ -0,0 +1,88 @@ |
||||
import Foundation |
||||
import GRDB |
||||
|
||||
// `nonisolated` opts this struct out of the project-wide `default-isolation = MainActor` |
||||
// setting. Without it, the compiler tries to infer MainActor isolation and hits a circular |
||||
// reference when synthesising Equatable/Hashable conformances. |
||||
nonisolated struct Track: Codable, Identifiable, Equatable, Hashable, Sendable { |
||||
var id: Int64? |
||||
var fileURL: String |
||||
var title: String |
||||
var artist: String |
||||
var albumArtist: String |
||||
var album: String |
||||
var genre: String |
||||
var year: Int? |
||||
var trackNumber: Int? |
||||
var discNumber: Int? |
||||
var duration: Double |
||||
var bpm: Int? |
||||
var composer: String |
||||
var fileFormat: String |
||||
var bitrate: Int? |
||||
var sampleRate: Int? |
||||
var fileSize: Int64 |
||||
var artworkData: Data? |
||||
var playCount: Int |
||||
var lastPlayedAt: Date? |
||||
var rating: Int |
||||
var dateAdded: Date |
||||
var dateModified: Date |
||||
var fileHash: String |
||||
|
||||
static func computeHash(fileSize: Int64, modificationDate: Date) -> String { |
||||
"\(fileSize)_\(Int(modificationDate.timeIntervalSince1970))" |
||||
} |
||||
} |
||||
|
||||
// Also `nonisolated` so the GRDB protocol conformances (FetchableRecord, |
||||
// MutablePersistableRecord) are not inferred as MainActor-isolated, which |
||||
// would prevent using them in Sendable contexts. |
||||
nonisolated extension Track: FetchableRecord, MutablePersistableRecord { |
||||
static let databaseTableName = "tracks" |
||||
|
||||
mutating func didInsert(_ inserted: InsertionSuccess) { |
||||
id = inserted.rowID |
||||
} |
||||
} |
||||
|
||||
#if DEBUG |
||||
extension Track { |
||||
static func fixture( |
||||
id: Int64? = nil, |
||||
fileURL: String = "/tmp/test.mp3", |
||||
title: String = "Test Song", |
||||
artist: String = "Test Artist", |
||||
albumArtist: String = "Test Artist", |
||||
album: String = "Test Album", |
||||
genre: String = "Rock", |
||||
year: Int? = 2024, |
||||
trackNumber: Int? = 1, |
||||
discNumber: Int? = 1, |
||||
duration: Double = 210.0, |
||||
bpm: Int? = 120, |
||||
composer: String = "Test Composer", |
||||
fileFormat: String = "mp3", |
||||
bitrate: Int? = 320, |
||||
sampleRate: Int? = 44100, |
||||
fileSize: Int64 = 5_000_000, |
||||
artworkData: Data? = nil, |
||||
playCount: Int = 0, |
||||
lastPlayedAt: Date? = nil, |
||||
rating: Int = 0, |
||||
dateAdded: Date = Date(), |
||||
dateModified: Date = Date(), |
||||
fileHash: String = "5000000_1700000000" |
||||
) -> Track { |
||||
Track( |
||||
id: id, fileURL: fileURL, title: title, artist: artist, |
||||
albumArtist: albumArtist, album: album, genre: genre, year: year, |
||||
trackNumber: trackNumber, discNumber: discNumber, duration: duration, |
||||
bpm: bpm, composer: composer, fileFormat: fileFormat, bitrate: bitrate, |
||||
sampleRate: sampleRate, fileSize: fileSize, artworkData: artworkData, |
||||
playCount: playCount, lastPlayedAt: lastPlayedAt, rating: rating, |
||||
dateAdded: dateAdded, dateModified: dateModified, fileHash: fileHash |
||||
) |
||||
} |
||||
} |
||||
#endif |
||||
@ -1,17 +0,0 @@ |
||||
// |
||||
// MusicTests.swift |
||||
// MusicTests |
||||
// |
||||
// Created by Laurent Morvillier on 23/05/2026. |
||||
// |
||||
|
||||
import Testing |
||||
@testable import Music |
||||
|
||||
struct MusicTests { |
||||
|
||||
@Test func example() async throws { |
||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions. |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,81 @@ |
||||
import Foundation |
||||
import Testing |
||||
import GRDB |
||||
@testable import Music |
||||
|
||||
struct TrackTests { |
||||
// Creates a fresh in-memory database with the tracks table schema. |
||||
// Each test gets its own isolated DatabaseQueue so tests don't share state. |
||||
private static func makeDB() throws -> DatabaseQueue { |
||||
let dbQueue = try DatabaseQueue() |
||||
try dbQueue.write { db in |
||||
try db.create(table: "tracks") { t in |
||||
t.autoIncrementedPrimaryKey("id") |
||||
t.column("fileURL", .text).notNull().unique() |
||||
t.column("title", .text).notNull() |
||||
t.column("artist", .text).notNull() |
||||
t.column("albumArtist", .text).notNull() |
||||
t.column("album", .text).notNull() |
||||
t.column("genre", .text).notNull() |
||||
t.column("year", .integer) |
||||
t.column("trackNumber", .integer) |
||||
t.column("discNumber", .integer) |
||||
t.column("duration", .double).notNull() |
||||
t.column("bpm", .integer) |
||||
t.column("composer", .text).notNull() |
||||
t.column("fileFormat", .text).notNull() |
||||
t.column("bitrate", .integer) |
||||
t.column("sampleRate", .integer) |
||||
t.column("fileSize", .integer).notNull() |
||||
t.column("artworkData", .blob) |
||||
t.column("playCount", .integer).notNull().defaults(to: 0) |
||||
t.column("lastPlayedAt", .datetime) |
||||
t.column("rating", .integer).notNull().defaults(to: 0) |
||||
t.column("dateAdded", .datetime).notNull() |
||||
t.column("dateModified", .datetime).notNull() |
||||
t.column("fileHash", .text).notNull() |
||||
} |
||||
} |
||||
return dbQueue |
||||
} |
||||
|
||||
// Verifies that Track can round-trip through GRDB's encoding/decoding, |
||||
// meaning it correctly conforms to FetchableRecord and PersistableRecord. |
||||
@Test func roundTripThroughDatabase() throws { |
||||
let dbQueue = try TrackTests.makeDB() |
||||
try dbQueue.write { db in |
||||
var track = Track.fixture(title: "Bohemian Rhapsody", artist: "Queen") |
||||
try track.insert(db) |
||||
|
||||
#expect(track.id != nil) |
||||
|
||||
let fetched = try Track.fetchOne(db, key: track.id) |
||||
#expect(fetched?.title == "Bohemian Rhapsody") |
||||
#expect(fetched?.artist == "Queen") |
||||
#expect(fetched?.duration == 210.0) |
||||
} |
||||
} |
||||
|
||||
// Verifies that didInsert assigns the auto-incremented ID after insertion. |
||||
@Test func didInsertAssignsId() throws { |
||||
let dbQueue = try TrackTests.makeDB() |
||||
try dbQueue.write { db in |
||||
var track = Track.fixture() |
||||
#expect(track.id == nil) |
||||
try track.insert(db) |
||||
#expect(track.id != nil) |
||||
} |
||||
} |
||||
|
||||
// Verifies the fileHash computation produces a deterministic string from size + date. |
||||
@Test func computeHashIsDeterministic() { |
||||
let date = Date(timeIntervalSince1970: 1700000000) |
||||
let hash1 = Track.computeHash(fileSize: 5_000_000, modificationDate: date) |
||||
let hash2 = Track.computeHash(fileSize: 5_000_000, modificationDate: date) |
||||
#expect(hash1 == hash2) |
||||
#expect(hash1 == "5000000_1700000000") |
||||
|
||||
let different = Track.computeHash(fileSize: 999, modificationDate: date) |
||||
#expect(different != hash1) |
||||
} |
||||
} |
||||
Loading…
Reference in new issue