diff --git a/Music/Models/EditableTrackFields.swift b/Music/Models/EditableTrackFields.swift new file mode 100644 index 0000000..dc6042c --- /dev/null +++ b/Music/Models/EditableTrackFields.swift @@ -0,0 +1,77 @@ +import Foundation + +// The user-editable subset of Track, plus the pure logic for single- and +// multi-track editing. No UI, no I/O — fully unit-testable. +// +// Note: Named `EditableTrackField` (not `TrackField`) to avoid collision with +// the existing `TrackField` enum in SmartPlaylistCondition.swift, which covers +// all filterable columns including non-editable ones. +nonisolated enum EditableTrackField: CaseIterable, Sendable { + case title, artist, albumArtist, album, genre, composer + case year, trackNumber, discNumber, bpm, rating +} + +nonisolated struct EditableTrackFields: Equatable, Sendable { + var title: String + var artist: String + var albumArtist: String + var album: String + var genre: String + var composer: String + var year: Int? + var trackNumber: Int? + var discNumber: Int? + var bpm: Int? + var rating: Int + + init(from t: Track) { + title = t.title; artist = t.artist; albumArtist = t.albumArtist + album = t.album; genre = t.genre; composer = t.composer + year = t.year; trackNumber = t.trackNumber; discNumber = t.discNumber + bpm = t.bpm; rating = t.rating + } + + func changedFields(to other: EditableTrackFields) -> Set { + var changed: Set = [] + if title != other.title { changed.insert(.title) } + if artist != other.artist { changed.insert(.artist) } + if albumArtist != other.albumArtist { changed.insert(.albumArtist) } + if album != other.album { changed.insert(.album) } + if genre != other.genre { changed.insert(.genre) } + if composer != other.composer { changed.insert(.composer) } + if year != other.year { changed.insert(.year) } + if trackNumber != other.trackNumber { changed.insert(.trackNumber) } + if discNumber != other.discNumber { changed.insert(.discNumber) } + if bpm != other.bpm { changed.insert(.bpm) } + if rating != other.rating { changed.insert(.rating) } + return changed + } + + // Returns prefill values (from the first track) plus the set of fields whose + // values are NOT identical across all tracks (shown as "Mixed" in the UI). + static func shared(across tracks: [Track]) -> (values: EditableTrackFields, mixed: Set) { + let base = EditableTrackFields(from: tracks[0]) + var mixed: Set = [] + for t in tracks.dropFirst() { + mixed.formUnion(base.changedFields(to: EditableTrackFields(from: t))) + } + return (base, mixed) + } + + // Copies ONLY the edited fields onto the track; everything else is untouched. + func apply(editing edited: Set, to track: Track) -> Track { + var t = track + if edited.contains(.title) { t.title = title } + if edited.contains(.artist) { t.artist = artist } + if edited.contains(.albumArtist) { t.albumArtist = albumArtist } + if edited.contains(.album) { t.album = album } + if edited.contains(.genre) { t.genre = genre } + if edited.contains(.composer) { t.composer = composer } + if edited.contains(.year) { t.year = year } + if edited.contains(.trackNumber) { t.trackNumber = trackNumber } + if edited.contains(.discNumber) { t.discNumber = discNumber } + if edited.contains(.bpm) { t.bpm = bpm } + if edited.contains(.rating) { t.rating = rating } + return t + } +} diff --git a/MusicTests/EditableTrackFieldsTests.swift b/MusicTests/EditableTrackFieldsTests.swift new file mode 100644 index 0000000..a10e682 --- /dev/null +++ b/MusicTests/EditableTrackFieldsTests.swift @@ -0,0 +1,51 @@ +import Foundation +import Testing +@testable import Music + +// Verifies the pure single/multi-track edit logic: extraction, change detection, +// shared-vs-mixed across many tracks, and applying only edited fields. +struct EditableTrackFieldsTests { + @Test func initCopiesEditableValues() { + // Step 1: build fields from a fixture track. + let t = Track.fixture(title: "A", artist: "B", album: "C", year: 2001, rating: 3) + let f = EditableTrackFields(from: t) + // Step 2: editable values match. + #expect(f.title == "A"); #expect(f.artist == "B") + #expect(f.album == "C"); #expect(f.year == 2001); #expect(f.rating == 3) + } + + @Test func changedFieldsDetectsOnlyDifferences() { + // Step 1: two field sets differing only in genre + bpm. + let a = EditableTrackFields(from: .fixture(genre: "Rock", bpm: 120)) + var b = a; b.genre = "Jazz"; b.bpm = 90 + // Step 2: change set is exactly {genre, bpm}. + #expect(a.changedFields(to: b) == [.genre, .bpm]) + } + + @Test func sharedMarksDifferingFieldsMixed() { + // Step 1: two tracks share artist but differ in genre. + let t1 = Track.fixture(artist: "Same", genre: "Rock") + let t2 = Track.fixture(artist: "Same", genre: "Pop") + // Step 2: shared() returns common artist and flags genre as mixed. + let (values, mixed) = EditableTrackFields.shared(across: [t1, t2]) + #expect(values.artist == "Same") + #expect(mixed.contains(.genre)) + #expect(!mixed.contains(.artist)) + } + + @Test func applyOnlyWritesEditedFields() { + // Step 1: a track and a fields object that changes album only. + let t = Track.fixture(album: "Old", genre: "Rock") + var f = EditableTrackFields(from: t); f.album = "New"; f.genre = "IGNORED" + // Step 2: applying with editing={.album} changes album, leaves genre. + let out = f.apply(editing: [.album], to: t) + #expect(out.album == "New") + #expect(out.genre == "Rock") + } + + @Test func applyEmptyEditSetReturnsUnchanged() { + let t = Track.fixture(title: "Keep") + let f = EditableTrackFields(from: t) + #expect(f.apply(editing: [], to: t) == t) + } +}