Implements pure, UI-free, I/O-free logic for single- and multi-track editing: field extraction, change detection, shared-vs-mixed across multiple tracks, and selective field application. Enum named EditableTrackField (not TrackField) to avoid collision with the existing SmartPlaylistCondition.TrackField.feat/music-streaming
parent
1970ab58c2
commit
378b71a857
@ -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<EditableTrackField> { |
||||||
|
var changed: Set<EditableTrackField> = [] |
||||||
|
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<EditableTrackField>) { |
||||||
|
let base = EditableTrackFields(from: tracks[0]) |
||||||
|
var mixed: Set<EditableTrackField> = [] |
||||||
|
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<EditableTrackField>, 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 |
||||||
|
} |
||||||
|
} |
||||||
@ -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) |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue