diff --git a/Music.xcodeproj/project.pbxproj b/Music.xcodeproj/project.pbxproj index bf338bb..6733ce9 100644 --- a/Music.xcodeproj/project.pbxproj +++ b/Music.xcodeproj/project.pbxproj @@ -38,10 +38,10 @@ C46B2CC32FC2449900F95A24 /* Exceptions for "Music" folder in "Music" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - "Models/.gitkeep", - "Services/.gitkeep", - "ViewModels/.gitkeep", - "Views/.gitkeep", + Models/.gitkeep, + Services/.gitkeep, + ViewModels/.gitkeep, + Views/.gitkeep, ); target = C46B2C8C2FC2448700F95A24 /* Music */; }; diff --git a/Music/Models/Track.swift b/Music/Models/Track.swift new file mode 100644 index 0000000..78c15da --- /dev/null +++ b/Music/Models/Track.swift @@ -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 diff --git a/MusicTests/MusicTests.swift b/MusicTests/MusicTests.swift deleted file mode 100644 index 16afd29..0000000 --- a/MusicTests/MusicTests.swift +++ /dev/null @@ -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. - } - -} diff --git a/MusicTests/TrackTests.swift b/MusicTests/TrackTests.swift new file mode 100644 index 0000000..170852b --- /dev/null +++ b/MusicTests/TrackTests.swift @@ -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) + } +}