import Foundation import Testing @testable import Music struct SmartPlaylistTests { // Creates a SmartPlaylist in memory and verifies its properties. @Test func smartPlaylistProperties() throws { let sp = SmartPlaylist( id: nil, name: "Miles Davis", searchQuery: "miles davis", createdAt: Date(), conditions: nil ) #expect(sp.name == "Miles Davis") #expect(sp.searchQuery == "miles davis") #expect(sp.isSmartPlaylist == true) } // Creates a smart playlist in the database and fetches it back. @Test func createAndFetchSmartPlaylist() throws { let db = try DatabaseService(inMemory: true) let sp = try db.createSmartPlaylist(name: "Jazz Vibes", searchQuery: "jazz") #expect(sp.id != nil) #expect(sp.name == "Jazz Vibes") #expect(sp.searchQuery == "jazz") let all = try db.fetchSmartPlaylists() #expect(all.count == 1) #expect(all[0].name == "Jazz Vibes") } // Renames a smart playlist and verifies the new name persists. @Test func renameSmartPlaylist() throws { let db = try DatabaseService(inMemory: true) let sp = try db.createSmartPlaylist(name: "Old Name", searchQuery: "old") try db.renameSmartPlaylist(id: sp.id!, name: "New Name") let all = try db.fetchSmartPlaylists() #expect(all[0].name == "New Name") } // Updates the search query of a smart playlist. @Test func updateSmartPlaylistQuery() throws { let db = try DatabaseService(inMemory: true) let sp = try db.createSmartPlaylist(name: "Jazz", searchQuery: "jazz") try db.updateSmartPlaylistQuery(id: sp.id!, searchQuery: "jazz fusion") let all = try db.fetchSmartPlaylists() #expect(all[0].searchQuery == "jazz fusion") } // Deletes a smart playlist and verifies it's gone. @Test func deleteSmartPlaylist() throws { let db = try DatabaseService(inMemory: true) let sp = try db.createSmartPlaylist(name: "To Delete", searchQuery: "delete") try db.deleteSmartPlaylist(id: sp.id!) let all = try db.fetchSmartPlaylists() #expect(all.isEmpty) } // Verifies smart playlist tracks are computed via FTS5 search (not stored). @Test func smartPlaylistReturnsDynamicResults() throws { let db = try DatabaseService(inMemory: true) var t1 = Track.fixture(fileURL: "/a.mp3", title: "Kind of Blue", artist: "Miles Davis") var t2 = Track.fixture(fileURL: "/b.mp3", title: "Hotel California", artist: "Eagles") var t3 = Track.fixture(fileURL: "/c.mp3", title: "Bitches Brew", artist: "Miles Davis") try db.insert(&t1) try db.insert(&t2) try db.insert(&t3) // Smart playlist uses the existing fetchTracks with its searchQuery let results = try db.fetchTracks(search: "miles davis", sortColumn: "title", ascending: true) #expect(results.count == 2) #expect(results[0].title == "Bitches Brew") #expect(results[1].title == "Kind of Blue") } // Creates a SmartPlaylist fixture with conditions and verifies the conditions // field is preserved and the isSmartPlaylist flag is true. @Test func smartPlaylistWithConditions() throws { let conditions = [SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Miles Davis"))] let sp = SmartPlaylist.fixture(conditions: conditions) #expect(sp.conditions?.count == 1) #expect(sp.conditions?[0].field == .artist) #expect(sp.isSmartPlaylist == true) } // Encodes and decodes a SmartPlaylistCondition to/from JSON, // verifying that all fields survive the round-trip. @Test func conditionCodableRoundTrip() throws { let condition = SmartPlaylistCondition( field: .artist, op: .equals, value: .string("Miles Davis") ) let data = try JSONEncoder().encode(condition) let decoded = try JSONDecoder().decode(SmartPlaylistCondition.self, from: data) #expect(decoded.field == .artist) #expect(decoded.op == .equals) if case .string(let s) = decoded.value { #expect(s == "Miles Davis") } else { Issue.record("Expected string value") } } // Encodes and decodes an array of conditions with mixed value types. @Test func conditionsArrayCodableRoundTrip() throws { let conditions: [SmartPlaylistCondition] = [ SmartPlaylistCondition(field: .artist, op: .startsWith, value: .string("Miles")), SmartPlaylistCondition(field: .year, op: .greaterThan, value: .int(1960)), SmartPlaylistCondition(field: .dateAdded, op: .lessThan, value: .date(Date(timeIntervalSince1970: 0))) ] let data = try JSONEncoder().encode(conditions) let decoded = try JSONDecoder().decode([SmartPlaylistCondition].self, from: data) #expect(decoded.count == 3) #expect(decoded[0].field == .artist) #expect(decoded[1].op == .greaterThan) if case .int(let y) = decoded[1].value { #expect(y == 1960) } else { Issue.record("Expected int") } if case .date(let d) = decoded[2].value { #expect(d == Date(timeIntervalSince1970: 0)) } else { Issue.record("Expected date") } } // Creates an in-memory DB (which runs all migrations including v5) and verifies // that existing FTS smart playlists (conditions = nil) still load correctly. @Test func existingFTSPlaylistSurvivesMigration() throws { // Step 1: Create DB — migration v5 runs automatically, adding the conditions column // Step 2: Create a FTS smart playlist using the old searchQuery path // Step 3: Fetch it back and verify conditions is nil, searchQuery is intact let db = try DatabaseService(inMemory: true) _ = try db.createSmartPlaylist(name: "Jazz", searchQuery: "jazz") let all = try db.fetchSmartPlaylists() #expect(all.count == 1) #expect(all[0].searchQuery == "jazz") #expect(all[0].conditions == nil) } // Inserts two tracks with different artists, fetches with an equals condition // on artist, and verifies only the matching track is returned. @Test func fetchTracksWithEqualsCondition() throws { // Step 1: Insert two tracks with different artists // Step 2: Build a condition: artist equals "Miles Davis" // Step 3: Fetch tracks with that condition and verify only one returned let db = try DatabaseService(inMemory: true) var t1 = Track.fixture(fileURL: "/a.mp3", title: "Kind of Blue", artist: "Miles Davis") var t2 = Track.fixture(fileURL: "/b.mp3", title: "Hotel California", artist: "Eagles") try db.insert(&t1) try db.insert(&t2) let conditions = [SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Miles Davis"))] let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true) #expect(results.count == 1) #expect(results[0].artist == "Miles Davis") } // Verifies that string equals matching is case-insensitive. @Test func fetchTracksEqualsIsCaseInsensitive() throws { // Step 1: Insert a track with mixed-case artist "Miles Davis" // Step 2: Fetch using lowercase "miles davis" // Step 3: Verify the track is returned let db = try DatabaseService(inMemory: true) var t = Track.fixture(fileURL: "/a.mp3", artist: "Miles Davis") try db.insert(&t) let conditions = [SmartPlaylistCondition(field: .artist, op: .equals, value: .string("miles davis"))] let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true) #expect(results.count == 1) } // Verifies that startsWith matches case-insensitively on the leading prefix. @Test func fetchTracksWithStartsWithCondition() throws { // Step 1: Insert a Miles Davis track and an Eagles track // Step 2: Fetch with artist startsWith "miles" (lowercase) // Step 3: Verify only Miles Davis is returned let db = try DatabaseService(inMemory: true) var t1 = Track.fixture(fileURL: "/a.mp3", artist: "Miles Davis") var t2 = Track.fixture(fileURL: "/b.mp3", artist: "Eagles") try db.insert(&t1) try db.insert(&t2) let conditions = [SmartPlaylistCondition(field: .artist, op: .startsWith, value: .string("miles"))] let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true) #expect(results.count == 1) #expect(results[0].artist == "Miles Davis") } // Verifies that greaterThan on an integer field returns only tracks strictly // above the threshold value. @Test func fetchTracksWithGreaterThanCondition() throws { // Step 1: Insert tracks with years 1990, 2010, 2020 // Step 2: Fetch with year > 2000 // Step 3: Verify 2010 and 2020 are returned; 1990 is not let db = try DatabaseService(inMemory: true) var t1 = Track.fixture(fileURL: "/a.mp3", year: 1990) var t2 = Track.fixture(fileURL: "/b.mp3", year: 2010) var t3 = Track.fixture(fileURL: "/c.mp3", year: 2020) try db.insert(&t1) try db.insert(&t2) try db.insert(&t3) let conditions = [SmartPlaylistCondition(field: .year, op: .greaterThan, value: .int(2000))] let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true) #expect(results.count == 2) #expect(results.allSatisfy { ($0.year ?? 0) > 2000 }) } // Verifies that multiple conditions are AND-ed: only tracks matching all // conditions are returned. @Test func fetchTracksWithMultipleAndConditions() throws { // Step 1: Insert three tracks — two Miles Davis (1959, 1970) and one Eagles (1975) // Step 2: Fetch with artist = "Miles Davis" AND year > 1960 // Step 3: Verify only the 1970 Miles Davis track is returned let db = try DatabaseService(inMemory: true) var t1 = Track.fixture(fileURL: "/a.mp3", title: "Kind of Blue", artist: "Miles Davis", year: 1959) var t2 = Track.fixture(fileURL: "/b.mp3", title: "Bitches Brew", artist: "Miles Davis", year: 1970) var t3 = Track.fixture(fileURL: "/c.mp3", title: "Hotel California", artist: "Eagles", year: 1975) try db.insert(&t1) try db.insert(&t2) try db.insert(&t3) let conditions = [ SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Miles Davis")), SmartPlaylistCondition(field: .year, op: .greaterThan, value: .int(1960)) ] let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true) #expect(results.count == 1) #expect(results[0].title == "Bitches Brew") } // Creates a conditions-based smart playlist, fetches it back, and verifies the // conditions survive the JSON round-trip through GRDB's Codable synthesis. @Test func createSmartPlaylistWithConditionsPersists() throws { // Step 1: Create a conditions-based playlist with artist equals and year > conditions // Step 2: Fetch all smart playlists // Step 3: Verify both conditions survived the DB round-trip intact let db = try DatabaseService(inMemory: true) let conditions = [ SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Miles Davis")), SmartPlaylistCondition(field: .year, op: .greaterThan, value: .int(1960)) ] _ = try db.createSmartPlaylist(name: "Late Miles", conditions: conditions) let all = try db.fetchSmartPlaylists() #expect(all.count == 1) #expect(all[0].conditions?.count == 2) #expect(all[0].conditions?[0].field == .artist) #expect(all[0].conditions?[1].op == .greaterThan) if case .int(let y) = all[0].conditions?[1].value { #expect(y == 1960) } else { Issue.record("Expected int value") } } // Verifies that lessThan on an integer field returns only tracks strictly // below the threshold value. @Test func fetchTracksWithLessThanCondition() throws { // Step 1: Insert tracks with years 1990, 2010, 2020 // Step 2: Fetch with year < 2000 // Step 3: Verify only the 1990 track is returned let db = try DatabaseService(inMemory: true) var t1 = Track.fixture(fileURL: "/a.mp3", year: 1990) var t2 = Track.fixture(fileURL: "/b.mp3", year: 2010) var t3 = Track.fixture(fileURL: "/c.mp3", year: 2020) try db.insert(&t1) try db.insert(&t2) try db.insert(&t3) let conditions = [SmartPlaylistCondition(field: .year, op: .lessThan, value: .int(2000))] let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true) #expect(results.count == 1) #expect((results[0].year ?? 0) < 2000) } // Verifies that the startsWith operator treats % and _ as literal characters, // not LIKE wildcards — confirming the ESCAPE clause in buildWhereClause works. @Test func startsWithEscapesLIKEMetachars() throws { // Step 1: Insert a track whose artist literally contains "%" and one whose // artist matches the % wildcard pattern but not the literal prefix // Step 2: Search with startsWith "A%B" — only the literal match should return // Step 3: Verify only the literal "A%B Band" track is returned, not "AXB Band" let db = try DatabaseService(inMemory: true) var t1 = Track.fixture(fileURL: "/a.mp3", artist: "A%B Band") var t2 = Track.fixture(fileURL: "/b.mp3", artist: "AXB Band") try db.insert(&t1) try db.insert(&t2) let conditions = [SmartPlaylistCondition(field: .artist, op: .startsWith, value: .string("A%B"))] let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true) #expect(results.count == 1) #expect(results[0].artist == "A%B Band") } // Updates the conditions of a structured smart playlist and verifies the updated // conditions are persisted and fetch back correctly. @Test func updateSmartPlaylistConditionsPersists() throws { // Step 1: Create a playlist with artist = "Eagles" // Step 2: Update its conditions to genre startsWith "Jazz" // Step 3: Fetch and verify the updated conditions are stored let db = try DatabaseService(inMemory: true) let sp = try db.createSmartPlaylist( name: "Test", conditions: [SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Eagles"))] ) let newConditions = [SmartPlaylistCondition(field: .genre, op: .startsWith, value: .string("Jazz"))] try db.updateSmartPlaylistConditions(id: sp.id!, conditions: newConditions) let all = try db.fetchSmartPlaylists() #expect(all[0].conditions?.count == 1) #expect(all[0].conditions?[0].field == .genre) } }