package sync import ( "fmt" "testing" "time" "tangled.org/karitham.dev/lazuli/kway" ) func TestCreateRecordKey(t *testing.T) { tests := []struct { name string record *PlayRecord expected string }{ { name: "basic TID", record: &PlayRecord{ TrackName: "Test Track", Artists: []PlayRecordArtist{{ArtistName: "Test Artist"}}, PlayedTime: Timestamp{Time: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)}, }, expected: "3kiz7zjhak222", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := CreateRecordKey(tt.record) if result != tt.expected { t.Errorf("CreateRecordKey() = %q, want %q", result, tt.expected) } }) } } func TestCreateRecordKeys(t *testing.T) { baseTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) records := []*PlayRecord{ {TrackName: "A", PlayedTime: Timestamp{Time: baseTime}}, {TrackName: "B", PlayedTime: Timestamp{Time: baseTime}}, {TrackName: "C", PlayedTime: Timestamp{Time: baseTime.Add(time.Second)}}, } keys := CreateRecordKeys(records) if len(keys) != 3 { t.Fatalf("expected 3 keys, got %d", len(keys)) } if keys[0] == keys[1] { t.Errorf("expected unique keys for same timestamp, got duplicate %q", keys[0]) } if keys[0] >= keys[1] { t.Errorf("expected keys to be sortable, got %q >= %q", keys[0], keys[1]) } if keys[1] >= keys[2] { t.Errorf("expected keys to be sortable by time, got %q >= %q", keys[1], keys[2]) } } func TestSelectBetterRecord(t *testing.T) { tests := []struct { name string r1 PlayRecord r2 PlayRecord r1IsLastFM bool r2IsLastFM bool expectedService string }{ { name: "lastfm wins over spotify", r1: PlayRecord{ TrackName: "Test", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, MusicServiceBaseDomain: MusicServiceLastFM, }, r2: PlayRecord{ TrackName: "Test", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, MusicServiceBaseDomain: MusicServiceSpotify, }, r1IsLastFM: true, r2IsLastFM: false, expectedService: MusicServiceLastFM, }, { name: "spotify loses to lastfm even with mbid", r1: PlayRecord{ TrackName: "Test", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, MusicServiceBaseDomain: MusicServiceLastFM, }, r2: PlayRecord{ TrackName: "Test", Artists: []PlayRecordArtist{{ArtistName: "Artist", ArtistMbId: "mbid-spotify"}}, MusicServiceBaseDomain: MusicServiceSpotify, }, r1IsLastFM: true, r2IsLastFM: false, expectedService: MusicServiceLastFM, }, { name: "lastfm with mbid wins over spotify without mbid", r1: PlayRecord{ TrackName: "Test", Artists: []PlayRecordArtist{{ArtistName: "Artist", ArtistMbId: "mbid-123"}}, MusicServiceBaseDomain: MusicServiceLastFM, }, r2: PlayRecord{ TrackName: "Test", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, MusicServiceBaseDomain: MusicServiceSpotify, }, r1IsLastFM: true, r2IsLastFM: false, expectedService: MusicServiceLastFM, }, { name: "spotify without mbid loses to lastfm with mbid", r1: PlayRecord{ TrackName: "Test", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, MusicServiceBaseDomain: MusicServiceLastFM, }, r2: PlayRecord{ TrackName: "Test", Artists: []PlayRecordArtist{{ArtistName: "Artist", ArtistMbId: "mbid-123"}}, MusicServiceBaseDomain: MusicServiceSpotify, }, r1IsLastFM: true, r2IsLastFM: false, expectedService: MusicServiceLastFM, }, { name: "recording mbid takes precedence", r1: PlayRecord{ TrackName: "Test", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, RecordingMbId: "mbid-recording", MusicServiceBaseDomain: MusicServiceLastFM, }, r2: PlayRecord{ TrackName: "Test", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, MusicServiceBaseDomain: MusicServiceLastFM, }, r1IsLastFM: true, r2IsLastFM: true, expectedService: MusicServiceLastFM, }, { name: "both spotify same source", r1: PlayRecord{ TrackName: "Test", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, MusicServiceBaseDomain: MusicServiceSpotify, }, r2: PlayRecord{ TrackName: "Test", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, MusicServiceBaseDomain: MusicServiceSpotify, }, r1IsLastFM: false, r2IsLastFM: false, expectedService: MusicServiceSpotify, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := tt.r1.betterThan(&tt.r2) var resultService string if result { resultService = tt.r1.MusicServiceBaseDomain } else { resultService = tt.r2.MusicServiceBaseDomain } if resultService != tt.expectedService { t.Errorf("BetterThan() result service = %q, want %q", resultService, tt.expectedService) } }) } } func TestMergeRecordsComprehensive(t *testing.T) { baseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) tests := []struct { name string lastfm []*PlayRecord spotify []*PlayRecord tolerance time.Duration expectedLen int expectedMergedTotal int expectedFirstTrack string expectedOrder []string // track names in expected order }{ { name: "both slices empty", lastfm: []*PlayRecord{}, spotify: []*PlayRecord{}, tolerance: 0, expectedLen: 0, expectedMergedTotal: 0, }, { name: "only lastfm records", lastfm: []*PlayRecord{ {TrackName: "Song A", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, {TrackName: "Song B", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(time.Hour)}, MusicServiceBaseDomain: MusicServiceLastFM}, }, spotify: []*PlayRecord{}, tolerance: 0, expectedLen: 2, expectedMergedTotal: 2, expectedOrder: []string{"Song A", "Song B"}, }, { name: "only spotify records", lastfm: []*PlayRecord{}, spotify: []*PlayRecord{ {TrackName: "Song X", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceSpotify}, {TrackName: "Song Y", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(time.Hour)}, MusicServiceBaseDomain: MusicServiceSpotify}, }, tolerance: 0, expectedLen: 2, expectedMergedTotal: 2, expectedOrder: []string{"Song X", "Song Y"}, }, { name: "zero tolerance no duplicates", lastfm: []*PlayRecord{ {TrackName: "Same Song", Artists: []PlayRecordArtist{{ArtistName: "Same Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, }, spotify: []*PlayRecord{ {TrackName: "Same Song", Artists: []PlayRecordArtist{{ArtistName: "Same Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, }, tolerance: 0, expectedLen: 2, expectedMergedTotal: 2, }, { name: "zero tolerance exact duplicate", lastfm: []*PlayRecord{ {TrackName: "Same Song", Artists: []PlayRecordArtist{{ArtistName: "Same Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, }, spotify: []*PlayRecord{ {TrackName: "Same Song", Artists: []PlayRecordArtist{{ArtistName: "Same Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceSpotify}, }, tolerance: 0, expectedLen: 1, expectedMergedTotal: 1, expectedFirstTrack: "Same Song", }, { name: "within tolerance duplicate", lastfm: []*PlayRecord{ {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, }, spotify: []*PlayRecord{ {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(10 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, }, tolerance: 30 * time.Second, expectedLen: 1, expectedMergedTotal: 1, expectedFirstTrack: "Song", }, { name: "outside tolerance no duplicate", lastfm: []*PlayRecord{ {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, }, spotify: []*PlayRecord{ {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(60 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, }, tolerance: 30 * time.Second, expectedLen: 2, expectedMergedTotal: 2, }, { name: "time bucket boundary exact", lastfm: []*PlayRecord{ {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, }, spotify: []*PlayRecord{ {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(29 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, }, tolerance: 30 * time.Second, expectedLen: 1, expectedMergedTotal: 1, }, { name: "time bucket boundary crossed", lastfm: []*PlayRecord{ {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, }, spotify: []*PlayRecord{ {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(31 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, }, tolerance: 30 * time.Second, expectedLen: 2, }, { name: "lastfm priority over spotify", lastfm: []*PlayRecord{ {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, }, spotify: []*PlayRecord{ {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(10 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, }, tolerance: 30 * time.Second, expectedLen: 1, expectedMergedTotal: 1, expectedFirstTrack: "Song", }, { name: "same source with mbid preferred", lastfm: []*PlayRecord{ {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM, RecordingMbId: "mbid-123"}, {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(10 * time.Second)}, MusicServiceBaseDomain: MusicServiceLastFM}, }, spotify: []*PlayRecord{}, tolerance: 30 * time.Second, expectedLen: 1, expectedMergedTotal: 1, expectedFirstTrack: "Song", }, { name: "case insensitive duplicate detection", lastfm: []*PlayRecord{ {TrackName: "song title", Artists: []PlayRecordArtist{{ArtistName: "artist name"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, }, spotify: []*PlayRecord{ {TrackName: "SONG TITLE", Artists: []PlayRecordArtist{{ArtistName: "ARTIST NAME"}}, PlayedTime: Timestamp{Time: baseTime.Add(10 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, }, tolerance: 30 * time.Second, expectedLen: 1, expectedMergedTotal: 1, }, { name: "multiple duplicates across time buckets", lastfm: []*PlayRecord{ {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(2 * time.Minute)}, MusicServiceBaseDomain: MusicServiceLastFM}, }, spotify: []*PlayRecord{ {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(10 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(2*time.Minute + 10*time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, }, tolerance: 30 * time.Second, expectedLen: 2, expectedMergedTotal: 2, }, { name: "sorted by time then track name", lastfm: []*PlayRecord{ {TrackName: "A Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, {TrackName: "B Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(time.Hour)}, MusicServiceBaseDomain: MusicServiceLastFM}, }, spotify: []*PlayRecord{ {TrackName: "A Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(30 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, }, tolerance: 30 * time.Second, expectedLen: 2, expectedMergedTotal: 2, expectedOrder: []string{"A Song", "B Song"}, }, { name: "many duplicates in same bucket", lastfm: []*PlayRecord{ {TrackName: "Popular Song", Artists: []PlayRecordArtist{{ArtistName: "Popular Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, {TrackName: "Popular Song", Artists: []PlayRecordArtist{{ArtistName: "Popular Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(5 * time.Second)}, MusicServiceBaseDomain: MusicServiceLastFM}, {TrackName: "Popular Song", Artists: []PlayRecordArtist{{ArtistName: "Popular Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(10 * time.Second)}, MusicServiceBaseDomain: MusicServiceLastFM}, }, spotify: []*PlayRecord{ {TrackName: "Popular Song", Artists: []PlayRecordArtist{{ArtistName: "Popular Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(15 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, {TrackName: "Popular Song", Artists: []PlayRecordArtist{{ArtistName: "Popular Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(20 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, }, tolerance: 30 * time.Second, expectedLen: 1, expectedMergedTotal: 1, }, { name: "adjacent bucket detection works", lastfm: []*PlayRecord{ {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(29 * time.Second)}, MusicServiceBaseDomain: MusicServiceLastFM}, }, spotify: []*PlayRecord{ {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(31 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, }, tolerance: 5 * time.Second, expectedLen: 1, expectedMergedTotal: 1, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := kway.Merge([][]*PlayRecord{tt.lastfm, tt.spotify}, tt.tolerance) if len(result) != tt.expectedLen { t.Errorf("MergeRecords() length = %d, want %d", len(result), tt.expectedLen) } if tt.expectedFirstTrack != "" && len(result) > 0 { if result[0].TrackName != tt.expectedFirstTrack { t.Errorf("MergeRecords() first track = %q, want %q", result[0].TrackName, tt.expectedFirstTrack) } } if len(tt.expectedOrder) > 0 { if len(result) != len(tt.expectedOrder) { t.Errorf("MergeRecords() order length mismatch, got %d, want %d", len(result), len(tt.expectedOrder)) } else { for i, expectedTrack := range tt.expectedOrder { if i < len(result) && result[i].TrackName != expectedTrack { t.Errorf("MergeRecords() order[%d] = %q, want %q", i, result[i].TrackName, expectedTrack) } } } } // Verify sorting is correct for i := 1; i < len(result); i++ { prev, curr := result[i-1], result[i] if prev.PlayedTime.After(curr.PlayedTime.Time) { t.Errorf("MergeRecords() sorting failed: %q at %v should be after %q at %v", prev.TrackName, prev.PlayedTime.Time, curr.TrackName, curr.PlayedTime.Time) } if prev.PlayedTime.Equal(curr.PlayedTime.Time) && prev.TrackName > curr.TrackName { t.Errorf("MergeRecords() same-time sorting failed: %q should be before %q", prev.TrackName, curr.TrackName) } } }) } } func BenchmarkMergeRecords(b *testing.B) { // Generate test data with multiple sources and items numSources := 10 itemsPerSource := 1000 tolerance := 10 * time.Minute baseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) sources := make([][]*PlayRecord, numSources) for i := range numSources { sources[i] = make([]*PlayRecord, itemsPerSource) for j := range itemsPerSource { sources[i][j] = &PlayRecord{ Type: "app.bsky.feed.post", TrackName: fmt.Sprintf("Song %d", (i+j)%100), Artists: []PlayRecordArtist{{ArtistName: fmt.Sprintf("Artist %d", i%20)}}, PlayedTime: Timestamp{Time: baseTime.Add(time.Duration(i*itemsPerSource+j) * time.Minute)}, SubmissionClientAgent: DefaultClientAgent, MusicServiceBaseDomain: []string{MusicServiceLastFM, MusicServiceSpotify}[i%2], OriginUrl: "https://example.com", MsPlayed: 180000, } if (i+j)%3 == 0 { sources[i][j].RecordingMbId = "mbid-123" } } } for b.Loop() { kway.Merge(sources, tolerance) } }