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