feat: add Track model with GRDB record conformance and tests

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
Laurent 1 month ago
parent 1aac6823fa
commit a8978f7eae
  1. 8
      Music.xcodeproj/project.pbxproj
  2. 88
      Music/Models/Track.swift
  3. 17
      MusicTests/MusicTests.swift
  4. 81
      MusicTests/TrackTests.swift

@ -38,10 +38,10 @@
C46B2CC32FC2449900F95A24 /* Exceptions for "Music" folder in "Music" target */ = { C46B2CC32FC2449900F95A24 /* Exceptions for "Music" folder in "Music" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
"Models/.gitkeep", Models/.gitkeep,
"Services/.gitkeep", Services/.gitkeep,
"ViewModels/.gitkeep", ViewModels/.gitkeep,
"Views/.gitkeep", Views/.gitkeep,
); );
target = C46B2C8C2FC2448700F95A24 /* Music */; target = C46B2C8C2FC2448700F95A24 /* Music */;
}; };

@ -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…
Cancel
Save