diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9672b55 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,96 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## What this is + +Sortify is a Windows desktop (WPF, .NET 8) app that reads a user's exported Spotify streaming +history (JSON) and turns it into browsable statistics, charts and insights. It ships as a single +portable self-contained `.exe`; there is no server component and no network calls — everything +runs and is stored locally (`%LOCALAPPDATA%\Sortify` holds the settings file, the parsed-history +cache and an error log). + +## Commands + +```bash +# Run the app +dotnet run --project Sortify/Sortify.csproj + +# Build +dotnet build + +# Run the full test suite +dotnet test + +# Run a single test class or method (filter by fully-qualified name substring) +dotnet test --filter "FullyQualifiedName~AnalysisEngineTests" +dotnet test --filter "FullyQualifiedName~AnalysisEngineTests.SomeMethod" + +# Publish a self-contained single-file build (64-bit or 32-bit) +dotnet publish Sortify/Sortify.csproj -c Release -r win-x64 +dotnet publish Sortify/Sortify.csproj -c Release -r win-x86 +``` + +The published exe lands in `Sortify/bin/Release/net8.0-windows//publish/`. CI (`.github/workflows/ci.yml`) +runs `dotnet restore`, `dotnet format --verify-no-changes`, `dotnet build -c Debug`, +`dotnet test -c Debug` and a win-x64 Release publish on `windows-latest` for pushes to +`main`/`update-*` and all PRs. + +Formatting/style rules (naming, brace placement, `var` usage, file-scoped namespaces) live in +`.editorconfig`. CI runs `dotnet format --verify-no-changes`; run `dotnet format` locally to fix +whatever it flags. + +## Architecture + +Two projects: `Sortify` (the WPF app) and `Sortify.Tests` (xUnit; `InternalsVisibleTo` is set so +tests can reach internal members). MVVM via CommunityToolkit.Mvvm (`[ObservableProperty]`, +`[RelayCommand]`); charts via LiveChartsCore.SkiaSharpView.WPF. + +**Pipeline, in order** (driven by `ViewModels/MainViewModel.cs`, the single hub the views bind to): + +1. **`Services/HistoryParser.cs`** — finds and parses the Spotify export JSON files (both the + modern extended-history format and the older legacy `StreamingHistory*.json` format) into + normalized `Models/PlayRecord.cs` objects. +2. **`Services/RecordCache.cs`** — a binary cache of parsed records, keyed by the exact set of + source file paths + sizes + write times (`%LOCALAPPDATA%\Sortify\records.cache`). Reopening an + unchanged export skips JSON parsing entirely; any change to the file set invalidates the key. +3. **`Services/FilterEngine.cs`** — applies the current `Models/FilterOptions` (date range, min + play duration, search text, excluded artists/tracks, time-of-day/day-of-week windows, whether + podcasts count) as a streaming filter over raw records. +4. **`Services/AnalysisEngine.cs`** — the aggregation core. Consumes filtered records and produces + one `AnalysisResult` (`Models/Stats.cs`): per-track/artist/album/year stats, skip/completion + rates, sessions, streaks, hour/day-of-week breakdowns, podcast stats, playback-context + breakdowns (device/country/shuffle/offline). Runs on a background thread via `AnalyzeAsync`. + Note it re-applies filtering itself with `ignoreMinDuration: true` so skip/reason-end stats can + still see the short plays a duration cutoff would otherwise drop. +5. **`Services/ChartBuilder.cs`** — turns an `AnalysisResult` into `Services/ChartData.cs` / + LiveCharts `ISeries[]` for each chart on screen. Charts bake colors into Skia paints at build + time, so a theme change rebuilds charts rather than repainting them (see + `ThemeService.Changed` in `MainViewModel`). +6. **`Services/DetailEngine.cs`** — builds the focused drill-down (`DetailResult`, + `Models/DetailStats.cs`) shown in `Views/DetailWindow.xaml` when a track/artist/album/year row + is double-clicked; reruns aggregation scoped to one entity under the current filters. +7. **`Services/ExportService.cs`** — writes the current `AnalysisResult` out as txt/Markdown/JSON/CSV. + +Supporting services: `Services/AppSettings.cs` (JSON-persisted preferences — recent folders, theme, +session-gap minutes, chart animation toggle — failures degrade to defaults rather than surfacing +errors), `Services/ThemeService.cs` (swaps the palette `ResourceDictionary` at a fixed slot in +`App.xaml`'s merged dictionaries so every DynamicResource-bound style repaints; raises `Changed`), +`Services/ImageExporter.cs` (copy/save a chart as PNG, wired to chart right-click), `Views/Converters.cs`. + +**UI structure**: `Views/MainWindow.xaml(.cs)` is the main shell (tabs: Overview, Tracks, Artists, +Albums, Years, Podcasts, Trends, Insights) bound to `MainViewModel`. `Views/FilterPanel.xaml(.cs)` ++ `ViewModels/FilterViewModel.cs` is the filter sidebar, which raises `FiltersChanged` on any +change; `MainViewModel` debounces that (300ms `DispatcherTimer`) before re-running analysis. +`Views/DetailWindow.xaml(.cs)` is the per-row drill-down. `Views/SettingsWindow.xaml(.cs)` covers +theme/animation/session-gap/cache-clearing preferences. Grid item sources (`Tracks`, `Artists`, +etc.) are swapped wholesale after each analysis pass rather than mutated via +`ObservableCollection`, since incremental updates to tens of thousands of rows would raise a +`CollectionChanged` per row and freeze the UI. Large horizontal bar charts (tracks/artists/albums) +page in via `LoadMore*` methods as the user scrolls rather than rendering every bar up front. + +Tests (`Sortify.Tests/`) that touch WPF types (windows, converters, dependency properties) run +through `WpfTestHost.cs`, which spins up a single STA thread with one live `Application` and merges +the theme/converter resources by hand (bypassing `App.xaml`'s `StartupUri` so no real window +opens). WPF only allows one `Application` per process, so this host is shared across all UI tests +rather than created per-test. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0d4a0e7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,107 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +Sortify is a Windows desktop (WPF, .NET 8) app that reads a user's exported Spotify streaming +history (JSON) and turns it into browsable statistics, charts and insights. It ships as a single +portable self-contained `.exe`; there is no server component and no network calls — everything +runs and is stored locally (`%LOCALAPPDATA%\Sortify` holds the settings file, the parsed-history +cache and an error log). + +## Commands + +```bash +# Run the app +dotnet run --project Sortify/Sortify.csproj + +# Build +dotnet build + +# Run the full test suite +dotnet test + +# Run a single test class or method (filter by fully-qualified name substring) +dotnet test --filter "FullyQualifiedName~AnalysisEngineTests" +dotnet test --filter "FullyQualifiedName~AnalysisEngineTests.SomeMethod" + +# Publish a self-contained single-file build (64-bit or 32-bit) +dotnet publish Sortify/Sortify.csproj -c Release -r win-x64 +dotnet publish Sortify/Sortify.csproj -c Release -r win-x86 +``` + +The published exe lands in `Sortify/bin/Release/net8.0-windows//publish/`. CI (`.github/workflows/ci.yml`) +runs `dotnet restore`, `dotnet format --verify-no-changes`, `dotnet build -c Debug`, +`dotnet test -c Debug` and a win-x64 Release publish on `windows-latest` for pushes to +`main`/`update-*` and all PRs. + +Formatting/style rules (naming, brace placement, `var` usage, file-scoped namespaces) live in +`.editorconfig`. CI runs `dotnet format --verify-no-changes`; run `dotnet format` locally to fix +whatever it flags. + +## Architecture + +Two projects: `Sortify` (the WPF app) and `Sortify.Tests` (xUnit; `InternalsVisibleTo` is set so +tests can reach internal members). MVVM via CommunityToolkit.Mvvm (`[ObservableProperty]`, +`[RelayCommand]`); charts via LiveChartsCore.SkiaSharpView.WPF. + +**Pipeline, in order** (driven by `ViewModels/MainViewModel.cs`, the single hub the views bind to): + +1. **`Services/HistoryParser.cs`** — finds and parses the Spotify export JSON files (both the + modern extended-history format and the older legacy `StreamingHistory*.json` format) into + normalized `Models/PlayRecord.cs` objects. +2. **`Services/RecordCache.cs`** — a binary cache of parsed records, keyed by the exact set of + source file paths + sizes + write times (`%LOCALAPPDATA%\Sortify\records.cache`). Reopening an + unchanged export skips JSON parsing entirely; any change to the file set invalidates the key. +3. **`Services/FilterEngine.cs`** — applies the current `Models/FilterOptions` (date range, min + play duration, search text, excluded artists/tracks, time-of-day/day-of-week windows, whether + podcasts count) as a streaming filter over raw records. +4. **`Services/AnalysisEngine.cs`** — the aggregation core. Consumes filtered records and produces + one `AnalysisResult` (`Models/Stats.cs`): per-track/artist/album/year stats, skip/completion + rates, sessions, streaks, hour/day-of-week breakdowns, podcast stats, playback-context + breakdowns (device/country/shuffle/offline). Runs on a background thread via `AnalyzeAsync`. + Note it re-applies filtering itself with `ignoreMinDuration: true` so skip/reason-end stats can + still see the short plays a duration cutoff would otherwise drop. +5. **`Services/ChartBuilder.cs`** — turns an `AnalysisResult` into `Services/ChartData.cs` / + LiveCharts `ISeries[]` for each chart on screen. Charts bake colors into Skia paints at build + time, so a theme change rebuilds charts rather than repainting them (see + `ThemeService.Changed` in `MainViewModel`). +6. **`Services/DetailEngine.cs`** — builds the focused drill-down (`DetailResult`, + `Models/DetailStats.cs`) shown in `Views/DetailWindow.xaml` when a track/artist/album/year row + is double-clicked; reruns aggregation scoped to one entity under the current filters. +7. **`Services/ExportService.cs`** — writes the current `AnalysisResult` out as txt/Markdown/JSON/CSV. + +Supporting services: `Services/AppSettings.cs` (JSON-persisted preferences — recent folders, theme, +session-gap minutes, chart animation toggle — failures degrade to defaults rather than surfacing +errors), `Services/ThemeService.cs` (swaps the palette `ResourceDictionary` at a fixed slot in +`App.xaml`'s merged dictionaries so every DynamicResource-bound style repaints; raises `Changed`), +`Services/ImageExporter.cs` (copy/save a chart as PNG, wired to chart right-click), `Views/Converters.cs`. + +**UI structure**: `Views/MainWindow.xaml(.cs)` is the main shell (tabs: Overview, Tracks, Artists, +Albums, Years, Podcasts, Trends, Insights) bound to `MainViewModel`. `Views/FilterPanel.xaml(.cs)` ++ `ViewModels/FilterViewModel.cs` is the filter sidebar, which raises `FiltersChanged` on any +change; `MainViewModel` debounces that (300ms `DispatcherTimer`) before re-running analysis. +`Views/DetailWindow.xaml(.cs)` is the per-row drill-down. `Views/SettingsWindow.xaml(.cs)` covers +theme/animation/session-gap/cache-clearing preferences. Every window derives from +`Views/ThemedWindow.cs`, which swaps the native title bar for a palette-coloured one via `WindowChrome` +(template and caption-button styles at the end of `Themes/Controls.xaml`); a new window should derive +from it too, or it gets the white Windows title bar. MainWindow's toolbar lives in that bar, via +`ThemedWindow.TitleBarContent`: its buttons take clicks, while the gaps and anything marked +`IsHitTestVisible="False"` (the logo, the name, the progress bar) still drag the window. Grid item +sources (`Tracks`, `Artists`, etc.) are swapped wholesale after each analysis pass rather than +mutated via `ObservableCollection`, since incremental updates to tens of thousands of rows would +raise a `CollectionChanged` per row and freeze the UI. Large horizontal bar charts +(tracks/artists/albums) page in via `LoadMore*` methods as the user scrolls rather than rendering +every bar up front. + +The logo's source is `Sortify/Assets/sortify-logo.svg`. `Themes/Logo.xaml` is the same drawing as a +WPF `DrawingImage` (key `SortifyLogo`) that the title bar, toolbar, Settings and the year-in-review +card draw, and `Assets/Sortify.ico` is rendered from it at 16-256 px for the exe, taskbar and Alt+Tab. +A change to the logo means updating all three. + +Tests (`Sortify.Tests/`) that touch WPF types (windows, converters, dependency properties) run +through `WpfTestHost.cs`, which spins up a single STA thread with one live `Application` and merges +the theme/converter resources by hand (bypassing `App.xaml`'s `StartupUri` so no real window +opens). WPF only allows one `Application` per process, so this host is shared across all UI tests +rather than created per-test. diff --git a/README.md b/README.md index 3bbeda9..b0c5566 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +Sortify logo + # Sortify [![CI](https://img.shields.io/github/actions/workflow/status/IDGBAN/Sortify/ci.yml?branch=main&label=CI&logo=github)](https://github.com/IDGBAN/Sortify/actions/workflows/ci.yml) @@ -13,33 +15,44 @@ Sortify reads your Spotify streaming history and turns it into statistics you ca - Top tracks, artists and albums, ranked by listening time, play count, or the first time you played them. - Charts throughout the app: bar charts for top tracks, artists and albums, a donut showing each artist's share of your listening, a line chart of listening over time (daily, weekly or monthly), a day-of-week vs. hour-of-day heatmap, and breakdowns by hour and by day of the week. - An **Overview** tab that opens with your headline totals and your top five tracks, artists and albums. +- A **Compare** tab that puts two periods side by side (it starts with your last 12 months against the 12 before): listening time, plays, artists and the rest with how much each changed, your artists and tracks ranked in both with how far they climbed or fell, and the artists that are new in the second period or gone from it. One click swaps the periods or makes the first one the stretch just before the second. Every sidebar filter except the date range applies to both. - A **Years** tab with a per-year rollup: listening time, plays, unique artists and tracks, and your top artist and track for every year. -- A **Podcasts** tab covering podcasts and audiobooks: total time and plays, how many shows and episodes, top shows and top episodes, and sortable tables for both. +- **Year in review image**: right-click a year on the Years tab (or use the button in a year's detail view) for a 1080 × 1350 card with your minutes, plays, artists, new artists, longest streak, top five artists and tracks, and biggest month and day. Save it as a PNG or copy it. It follows the light or dark theme, and says so on the card when other filters are on. +- **Compare artists**, on the Trends tab: up to five artists' listening month by month (or week by week) on one chart. It starts with your top three; type a name to add another. +- **Your #1 each month**, on the Trends tab: every month's top artist and top track, and which artist topped the most months. +- A **Podcasts** tab covering podcasts and audiobooks: total time and plays, how many shows and episodes, top shows and top episodes, and sortable tables for both. Double-click a show or episode (or an audiobook or chapter) for its own detail view, whether or not podcasts count toward your music stats. - Insights: longest and current listening streaks, your longest break, your biggest day, listening sessions (count, average and longest), weekday vs. weekend split, favorite time of day, skip rate, completion rate, repeat rate, discovery rate, how much of your time goes to a single artist, most skipped tracks, a chart of new artists discovered per month, and a donut of why plays ended (finished, skipped, and so on). -- Playback context, also in Insights: your shuffle rate, how much you listened offline, and donuts breaking listening down by device (desktop, mobile, web player, speaker, car) and by country. +- **Milestones**, also in Insights: your first play, the plays that reached 1,000, 10,000, 50,000 (and so on) plays and 100, 500, 1,000 (and so on) hours, and the first time you heard your top artist. +- **Forgotten favorites**, also in Insights: tracks you played at least 20 times and artists you played at least 50 times that have gone quiet for the last six months of your history. Copy the tracks as links to hear them again. +- Playback context, also in Insights: your shuffle rate, how much you listened offline or in private sessions, and donuts breaking listening down by device (desktop, mobile, web player, speaker, car) and by country. - **Light and dark themes**, switchable from the toolbar (Ctrl+T) or set to follow Windows (including when you switch Windows' mode while Sortify is open). -- Sortable tables. Click any column header to reorder by that field; right-click a row to copy it or exclude that track/artist. Each table has its own filter box that narrows the rows instantly, without re-running the analysis. -- **Double-click any track, artist, album or year row** to open a detail view: listening time, plays, active days, first and last listen, a monthly chart, an hour-of-day profile, its tracks and albums, and a button that opens it in Spotify. +- Sortable tables. Click any column header to reorder by that field; right-click a row to copy it or exclude that track/artist. Ctrl- or Shift-click to pick several rows and copy or exclude them all at once. Each table has its own filter box that narrows the rows instantly, without re-running the analysis. +- **Make a playlist from your stats**: on the Tracks tab, **Copy as links** puts the top 25, 50 or 100 rows (in whatever order the table is sorted) or the rows you picked on the clipboard as Spotify links. Paste them into a playlist in the Spotify desktop app. Set the date filter to a year first to get that year's most played songs. Only the extended streaming history records track links. +- **Double-click any track, artist, album or year row** to open a detail view: listening time, plays, active days, first and last listen, a monthly chart, an hour-of-day profile, its tracks and albums, and a button that opens it in Spotify. Double-click a track, album or artist inside a detail view, or click the artist's name under a track or album, to open that one in turn. - **Right-click any chart** to copy it to the clipboard or save it as a PNG. -- Filters that update every chart and table as you change them, with a chip row showing which ones are active: +- **Click a bar** in the track, artist, album, show, episode or most-skipped charts, or a year on the Years chart, to open its detail view. Click a square on the heatmap to narrow everything to that hour of that day. +- Filters that update every chart and table as you change them, with a chip row showing which ones are active. The x on a chip clears just that filter: - Minimum play duration. The default is five seconds, which drops skips. - Whether podcasts and audiobooks count toward your track, artist and album statistics. Off by default, since a few long shows will otherwise outrank your music. The Podcasts tab shows them either way. - - Date range. + - Date range, typed in or picked with one click: all time, the last 30 days or 12 months of your history, or any single year. Right-click a year on the Years tab to filter everything to it. - Search by track, artist or album name. - An exclude list for specific artists or tracks. - Time of day and day of week. Time ranges may cross midnight, for example 22:00 to 02:00. -- Load data your way: pick individual JSON files (Ctrl+O), point at the extracted export folder (**Open Folder**, Ctrl+Shift+O), reopen one of the last eight folders from **Recent**, or just drag & drop the files or the whole folder onto the window. -- Reads both the extended streaming history (`Streaming_History_Audio_*.json`) and the older account-data export (`StreamingHistory*.json`). + - Playback: count only shuffled, offline or private-session plays, or leave them out, and untick any device or country you want left out. Older exports don't record any of this, so their plays always count unless one of the lists is set to "Only". +- **Saved filters**: name the current filters at the top of the sidebar and pick them from the list later to put them all back. Exclusions (unticked devices and countries included), the minimum play duration, and the podcast and private-session settings are kept between launches on their own; date ranges, searches, time windows and the shuffle and offline settings reset each time you open Sortify. +- Load data your way: open the ZIP Spotify sends without unzipping it, pick individual JSON files (both with Ctrl+O), point at the extracted export folder (**Open Folder**, Ctrl+Shift+O), reopen one of the last eight exports from **Recent**, or just drag & drop the ZIP, the files or the whole folder onto the window. +- Reads both the extended streaming history (`Streaming_History_Audio_*.json`) and the older account-data export (`StreamingHistory*.json`). Load both together and each play is still counted once: account-data plays the extended history already has are left out, and the newer ones are kept, so a recent account-data export can top up an older extended one. Loading the same export twice doesn't double anything either. - Reopens the folder you used last time when it starts, remembers your window size and layout, and caches the parsed history so an unchanged export loads instantly instead of being re-read. -- Export a text summary, a Markdown report, everything as JSON, or tracks, artists, albums, years and shows as CSV. +- Export a text summary, a Markdown report, everything as JSON, or tracks, artists, albums, years and shows as CSV. The text, Markdown and JSON exports say which filters they were made under. - Settings for the theme, chart animations, how long a break has to be before it starts a new listening session, and clearing the cache. ## Keyboard Shortcuts | Shortcut | Action | | --- | --- | -| `Ctrl+O` / `Ctrl+Shift+O` | Open files / open a folder | +| `Ctrl+O` / `Ctrl+Shift+O` | Open the export ZIP or files / open a folder | | `F5` | Re-read the files from disk, ignoring the cache | -| `Ctrl+1` … `Ctrl+8` | Jump to a tab | +| `Ctrl+1` … `Ctrl+9` | Jump to a tab | +| `Ctrl+K` | Jump to any artist, track, album or podcast by name and open its detail view | | `Ctrl+F` | Focus the current table's filter box (`Esc` clears it) | | `Enter` | Open the detail view for the selected track, artist, album or year row | | `Ctrl+E` / `Ctrl+M` / `Ctrl+J` | Export text / Markdown / JSON | @@ -48,10 +61,10 @@ Sortify reads your Spotify streaming history and turns it into statistics you ca | `Ctrl+R` | Reset every filter | ## Get Started -1. Request your [extended streaming history](https://www.spotify.com/ca-en/account/privacy/) from Spotify. When it arrives, download and extract the ZIP. +1. Request your [extended streaming history](https://www.spotify.com/ca-en/account/privacy/) from Spotify. When it arrives, download the ZIP. There's no need to extract it. 2. Download the latest `Sortify.exe` from the [releases page](https://github.com/IDGBAN/Sortify/releases/) and run it. -3. Click **Open Folder** and pick the extracted folder (or click **Open Files** to choose individual JSON files, or drag & drop them onto the window). -4. Browse the Overview, Tracks, Artists, Albums, Years, Podcasts, Trends and Insights tabs, adjust the filters on the left, and export your results if you want a copy. +3. Drag the ZIP onto the window, or click **Open Files** and pick it. An extracted folder works too: use **Open Folder**, or drop the folder or its JSON files onto the window. +4. Browse the Overview, Tracks, Artists, Albums, Years, Podcasts, Trends, Insights and Compare tabs, adjust the filters on the left, and export your results if you want a copy. Analysis is quick unless your history is unusually large. @@ -79,7 +92,7 @@ dotnet format --verify-no-changes ## Disclaimer - Sortify is not affiliated with Spotify. -- Everything runs locally on your machine. The only files it writes outside your exports are a settings file, a cache of your parsed history and an error log, all in `%LOCALAPPDATA%\Sortify`; deleting that folder resets everything. **Settings → Open data folder** takes you there. +- Everything runs locally on your machine. The only files it writes outside your exports are a settings file, a cache of your parsed history and an error log (plus the previous log once it passes 1 MB), all in `%LOCALAPPDATA%\Sortify`; deleting that folder resets everything. **Settings → Open data folder** takes you there. - Please **review the code** before you download and run it. ## License diff --git a/Sortify.Tests/AppSettingsTests.cs b/Sortify.Tests/AppSettingsTests.cs index a7e3595..f05e36d 100644 --- a/Sortify.Tests/AppSettingsTests.cs +++ b/Sortify.Tests/AppSettingsTests.cs @@ -68,6 +68,28 @@ public void PruneMissingFolders_DropsPathsThatAreGone() Assert.Equal(Path.GetTempPath(), settings.RecentFolders[0]); } + [Fact] + public void PruneMissingFolders_KeepsAZipThatStillExists() + { + var zip = Path.Combine(Path.GetTempPath(), $"sortify-{Guid.NewGuid():N}.zip"); + File.WriteAllText(zip, "zip"); + try + { + var settings = new AppSettings(); + settings.RememberFolder(Path.Combine(Path.GetTempPath(), "sortify-gone.zip")); + settings.RememberFolder(zip); + + settings.PruneMissingFolders(); + + Assert.Equal(new[] { zip }, settings.RecentFolders); + Assert.Equal(zip, settings.LastFolder); + } + finally + { + File.Delete(zip); + } + } + [Fact] public void Normalize_CleansAHandEditedRecentList() { @@ -100,6 +122,20 @@ public void SessionGap_IsNotWrittenToTheSettingsFile() Assert.Contains("\"SessionGapMinutes\"", json); } + [Fact] + public void Save_RoundTripsWithoutLeavingTheTempFileBehind() + { + var settings = new AppSettings { SessionGapMinutes = 45 }; + settings.FilterPresets.Add(new Sortify.Models.FilterPreset { Name = "Gym" }); + + settings.Save(); + var loaded = AppSettings.Load(); + + Assert.Equal(45, loaded.SessionGapMinutes); + Assert.Equal("Gym", Assert.Single(loaded.FilterPresets).Name); + Assert.False(File.Exists(Path.Combine(AppPaths.DataDirectory, "settings.json.tmp"))); + } + [Fact] public void SessionGap_DefaultsToThirtyMinutes() { diff --git a/Sortify.Tests/ArtistComparisonTests.cs b/Sortify.Tests/ArtistComparisonTests.cs new file mode 100644 index 0000000..3406dfa --- /dev/null +++ b/Sortify.Tests/ArtistComparisonTests.cs @@ -0,0 +1,107 @@ +using LiveChartsCore.SkiaSharpView; +using Sortify.Models; +using Sortify.Services; +using Xunit; +using LcDateTimePoint = LiveChartsCore.Defaults.DateTimePoint; + +namespace Sortify.Tests; + +/// Several artists' listening over time, side by side. +public class ArtistComparisonTests +{ + private static PlayRecord Play(string artist, DateTime when, int minutes = 60) => new() + { + TrackName = "Song", + ArtistName = artist, + AlbumName = "Album", + MsPlayed = minutes * 60_000, + Timestamp = when, + }; + + private static readonly PlayRecord[] Records = + { + Play("A", new DateTime(2024, 1, 10)), + Play("A", new DateTime(2024, 1, 20), 30), + Play("B", new DateTime(2024, 3, 5)), + Play("A", new DateTime(2024, 4, 1)), + Play("C", new DateTime(2024, 6, 1)), + new() { Kind = ContentKind.Podcast, ShowName = "A", EpisodeName = "Ep", MsPlayed = 3_600_000, Timestamp = new DateTime(2024, 5, 1) }, + }; + + private static ArtistTimeline Build(FilterOptions filter, ChartBuilder.TimeGranularity granularity, params string[] artists) => + ArtistComparison.Build(Records, filter, artists, granularity); + + [Fact] + public void Months_RunFromFirstToLastWithQuietMonthsAtZero() + { + var timeline = Build(new FilterOptions(), ChartBuilder.TimeGranularity.Monthly, "A", "B"); + + Assert.Equal(new[] { 1, 2, 3, 4 }, timeline.Buckets.Select(b => b.Month)); + Assert.Equal(new[] { "A", "B" }, timeline.Series.Select(s => s.Artist)); + Assert.Equal(new[] { 1.5, 0, 0, 1 }, timeline.Series[0].Hours); + Assert.Equal(new[] { 0d, 0, 1, 0 }, timeline.Series[1].Hours); + } + + [Fact] + public void Podcasts_OnlyCountWhenTheFilterSaysSo() + { + var without = Build(new FilterOptions(), ChartBuilder.TimeGranularity.Monthly, "A"); + var with = Build(new FilterOptions { IncludePodcasts = true }, ChartBuilder.TimeGranularity.Monthly, "A"); + + Assert.Equal(2.5, without.Series[0].Hours.Sum()); + Assert.Equal(3.5, with.Series[0].Hours.Sum()); + } + + [Fact] + public void Filters_StillApply() + { + var timeline = Build(new FilterOptions { StartDate = new DateTime(2024, 3, 1) }, ChartBuilder.TimeGranularity.Monthly, "A"); + + Assert.Equal(new DateTime(2024, 4, 1), Assert.Single(timeline.Buckets)); + } + + [Fact] + public void Weeks_StartOnSunday() + { + var timeline = Build(new FilterOptions(), ChartBuilder.TimeGranularity.Weekly, "B"); + + // 2024-03-05 was a Tuesday. + Assert.Equal(new DateTime(2024, 3, 3), Assert.Single(timeline.Buckets)); + } + + [Fact] + public void AnArtistWithNoPlays_GetsAnEmptyLine() + { + var timeline = Build(new FilterOptions(), ChartBuilder.TimeGranularity.Monthly, "Nobody"); + + Assert.Empty(timeline.Buckets); + Assert.Equal("Nobody", Assert.Single(timeline.Series).Artist); + } + + [Fact] + public void NoArtists_NoTimeline() + { + Assert.Same(ArtistTimeline.Empty, Build(new FilterOptions(), ChartBuilder.TimeGranularity.Monthly)); + } +} + +[Collection(WpfCollection.Name)] +public class ArtistComparisonChartTests +{ + [Fact] + public void EachArtistIsALine() + { + ChartData chart = ChartData.Empty; + var timeline = new ArtistTimeline + { + Buckets = new[] { new DateTime(2024, 1, 1), new DateTime(2024, 2, 1) }, + Series = new[] { ("A", new[] { 1.0, 2.0 }), ("B", new[] { 0.0, 3.0 }) }, + }; + + WpfTestHost.Run(() => chart = ChartBuilder.ArtistTimelines(timeline, ChartBuilder.TimeGranularity.Monthly)); + + Assert.Equal(new[] { "A", "B" }, chart.Series.Select(s => s.Name)); + var b = (LineSeries)chart.Series[1]; + Assert.Equal(new double?[] { 0.0, 3.0 }, b.Values!.Select(p => p.Value)); + } +} diff --git a/Sortify.Tests/ClickableChartTests.cs b/Sortify.Tests/ClickableChartTests.cs new file mode 100644 index 0000000..5905ca4 --- /dev/null +++ b/Sortify.Tests/ClickableChartTests.cs @@ -0,0 +1,85 @@ +using LiveChartsCore.SkiaSharpView; +using Sortify.Models; +using Sortify.Services; +using Sortify.ViewModels; +using Sortify.Views; +using Xunit; + +namespace Sortify.Tests; + +/// Clicking a bar, a year or a heatmap square. +[Collection(WpfCollection.Name)] +public class ClickableChartTests +{ + private static AnalysisResult Result() + { + PlayRecord Play(string track, int minutes, int year) => new() + { + TrackName = track, + ArtistName = track + " Band", + AlbumName = track + " LP", + MsPlayed = minutes * 60_000, + Timestamp = new DateTime(year, 3, 1, 12, 0, 0), + }; + + return AnalysisEngine.Analyze(new[] { Play("Big", 90, 2023), Play("Middle", 60, 2024), Play("Small", 30, 2024) }, + new FilterOptions()); + } + + // The chart builder reaches the palette, whose brushes come from the WPF resources. + private static T OnUiThread(Func build) + { + T value = default!; + WpfTestHost.Run(() => value = build()); + return value; + } + + [Fact] + public void EachBarKnowsItsRow() + { + var chart = OnUiThread(() => ChartBuilder.TopTracksByTime(Result(), 3)); + + // Bars are drawn bottom-up, so the biggest is last; its row has to be last too. + var values = ((RowSeries)chart.Series[0]).Values!.ToList(); + var rows = chart.Items.Cast().ToList(); + Assert.Equal(new[] { "Small", "Middle", "Big" }, rows.Select(t => t.Track)); + Assert.Equal(rows.Select(t => Math.Round(t.TotalHours, 2)), values); + } + + [Fact] + public void EachYearColumnKnowsItsYear() + { + var chart = OnUiThread(() => ChartBuilder.HoursPerYear(Result())); + + Assert.Equal(new[] { 2023, 2024 }, chart.Items.Cast().Select(y => y.Year)); + } + + [Fact] + public void ChartsWithoutRowsHaveNoItems() + { + Assert.Empty(OnUiThread(() => ChartBuilder.ByHour(Result())).Items); + } + + [Fact] + public void ASkippedTrackOpensAsATrack() + { + Assert.Equal((DetailScope.Track, "Song", "Band"), + DetailWindow.TargetFor(new SkippedTrackStat { Track = "Song", Artist = "Band" })); + } + + [Fact] + public void AHeatmapSquare_NarrowsToOneHourOfOneDay() + { + var vm = new FilterViewModel(); + int changes = 0; + vm.FiltersChanged += (_, _) => changes++; + + vm.SetSlot(2, 14); + + Assert.Equal(1, changes); + Assert.Equal(new[] { "14:00-14:59", "Tue" }, vm.Describe()); + var options = vm.ToOptions(); + Assert.True(options.HourMatches(14)); + Assert.False(options.HourMatches(15)); + } +} diff --git a/Sortify.Tests/CultureScope.cs b/Sortify.Tests/CultureScope.cs index 87dfd7a..2bfd082 100644 --- a/Sortify.Tests/CultureScope.cs +++ b/Sortify.Tests/CultureScope.cs @@ -23,5 +23,11 @@ public static CultureScope Unusual() return new CultureScope(culture); } + /// + /// A real culture by name. th-TH and ar-SA are the useful ones: their default calendars + /// are Buddhist and Hijri, so a date formatted under them gets a different year. + /// + public static CultureScope Named(string name) => new(new CultureInfo(name)); + public void Dispose() => CultureInfo.CurrentCulture = _previous; } diff --git a/Sortify.Tests/DetailDrillTests.cs b/Sortify.Tests/DetailDrillTests.cs new file mode 100644 index 0000000..4f383a2 --- /dev/null +++ b/Sortify.Tests/DetailDrillTests.cs @@ -0,0 +1,57 @@ +using System.Windows.Documents; +using Sortify.Models; +using Sortify.Views; +using Xunit; + +namespace Sortify.Tests; + +/// Opening a track, album or artist from inside another breakdown. +[Collection(WpfCollection.Name)] +public class DetailDrillTests +{ + [Fact] + public void EachRowOpensItsOwnKindOfBreakdown() + { + Assert.Equal((DetailScope.Track, "Song", "Band"), DetailWindow.TargetFor(new TrackStat { Track = "Song", Artist = "Band" })); + Assert.Equal((DetailScope.Album, "Record", "Band"), DetailWindow.TargetFor(new AlbumStat { Album = "Record", Artist = "Band" })); + Assert.Equal((DetailScope.Artist, "Band", ""), DetailWindow.TargetFor(new ArtistStat { Artist = "Band" })); + Assert.Equal((DetailScope.Year, "2024", ""), DetailWindow.TargetFor(new YearStat { Year = 2024 })); + Assert.Null(DetailWindow.TargetFor("something else")); + } + + private static DetailResult TrackDetail() => new() + { + Scope = DetailScope.Track, + Title = "Song", + Subtitle = "Band", + TotalMsPlayed = 120_000, + PlayCount = 2, + }; + + private static IEnumerable SubtitleInlines(DetailWindow window) => + ((System.Windows.Controls.TextBlock)window.FindName("SubtitleText")).Inlines; + + [Fact] + public void TheArtistUnderATrackIsALink_WhenItCanBeOpened() + { + WpfTestHost.Run(() => + { + var window = new DetailWindow(TrackDetail(), (_, _, _) => Task.FromResult(null)); + + var link = Assert.Single(SubtitleInlines(window).OfType()); + Assert.Equal("Band", Assert.Single(link.Inlines.OfType()).Text); + }); + } + + [Fact] + public void TheArtistIsPlainText_WhenNothingCanBeOpened() + { + WpfTestHost.Run(() => + { + var window = new DetailWindow(TrackDetail()); + + Assert.Empty(SubtitleInlines(window).OfType()); + Assert.Equal("Track - Band", string.Concat(SubtitleInlines(window).OfType().Select(r => r.Text))); + }); + } +} diff --git a/Sortify.Tests/ErrorLogTests.cs b/Sortify.Tests/ErrorLogTests.cs new file mode 100644 index 0000000..278192c --- /dev/null +++ b/Sortify.Tests/ErrorLogTests.cs @@ -0,0 +1,40 @@ +using System.IO; +using Sortify.Services; +using Xunit; + +namespace Sortify.Tests; + +public class ErrorLogTests : IDisposable +{ + public ErrorLogTests() => Dispose(); + + public void Dispose() + { + File.Delete(AppPaths.ErrorLog); + File.Delete(ErrorLog.OldLog); + } + + [Fact] + public void AppendsTheException() + { + ErrorLog.TryWrite(new InvalidOperationException("first")); + ErrorLog.TryWrite(new InvalidOperationException("second")); + + string log = File.ReadAllText(AppPaths.ErrorLog); + Assert.Contains("first", log); + Assert.Contains("second", log); + } + + [Fact] + public void StartsOverOnceTheLogIsFull() + { + Directory.CreateDirectory(AppPaths.DataDirectory); + File.WriteAllText(AppPaths.ErrorLog, new string('x', (int)ErrorLog.MaxBytes)); + + ErrorLog.TryWrite(new InvalidOperationException("fresh")); + + Assert.Contains("fresh", File.ReadAllText(AppPaths.ErrorLog)); + Assert.True(new FileInfo(AppPaths.ErrorLog).Length < ErrorLog.MaxBytes); + Assert.Equal(ErrorLog.MaxBytes, new FileInfo(ErrorLog.OldLog).Length); + } +} diff --git a/Sortify.Tests/ExportArchiveTests.cs b/Sortify.Tests/ExportArchiveTests.cs new file mode 100644 index 0000000..bd71278 --- /dev/null +++ b/Sortify.Tests/ExportArchiveTests.cs @@ -0,0 +1,202 @@ +using System.IO; +using System.IO.Compression; +using Sortify.Services; +using Xunit; + +namespace Sortify.Tests; + +/// Opening the ZIP Spotify sends without extracting it first. +public class ExportArchiveTests : IDisposable +{ + private const string OnePlay = """ + [ + { + "ts": "2023-05-01T10:00:00Z", + "ms_played": 215000, + "master_metadata_track_name": "Song", + "master_metadata_album_artist_name": "Artist", + "master_metadata_album_album_name": "Album" + } + ] + """; + + private readonly string _dir; + + public ExportArchiveTests() + { + _dir = Path.Combine(Path.GetTempPath(), "SortifyZip_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_dir); + } + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch { } + } + + private string WriteZip(string name, params (string Entry, string Content)[] entries) + { + var path = Path.Combine(_dir, name); + using var stream = File.Create(path); + using var zip = new ZipArchive(stream, ZipArchiveMode.Create); + foreach (var (entry, content) in entries) + { + using var writer = new StreamWriter(zip.CreateEntry(entry).Open()); + writer.Write(content); + } + return path; + } + + [Fact] + public void FindHistoryFiles_LooksInsideAZip() + { + var zip = WriteZip("my_spotify_data.zip", + ("Spotify Extended Streaming History/Streaming_History_Audio_2023.json", OnePlay), + ("Spotify Extended Streaming History/Streaming_History_Audio_2022.json", OnePlay), + ("Spotify Extended Streaming History/ReadMeFirst_ExtendedStreamingHistory.pdf", "pdf"), + ("Userdata.json", "{}")); + + var files = HistoryParser.FindHistoryFiles(zip); + + Assert.Equal(new[] + { + ArchivePath.Combine(zip, "Spotify Extended Streaming History/Streaming_History_Audio_2022.json"), + ArchivePath.Combine(zip, "Spotify Extended Streaming History/Streaming_History_Audio_2023.json"), + }, files); + } + + [Fact] + public void FindHistoryFiles_ZipFallsBackToTopLevelJson() + { + var zip = WriteZip("renamed.zip", + ("my plays.json", OnePlay), + ("nested/other.json", OnePlay)); + + var files = HistoryParser.FindHistoryFiles(zip); + + Assert.Equal(new[] { ArchivePath.Combine(zip, "my plays.json") }, files); + } + + [Fact] + public void FindHistoryFiles_DamagedZipFindsNothing() + { + var zip = Path.Combine(_dir, "broken.zip"); + File.WriteAllText(zip, "this is not a zip file"); + + Assert.Empty(HistoryParser.FindHistoryFiles(zip)); + } + + [Fact] + public void FindHistoryFiles_MissingZipFindsNothing() + { + Assert.Empty(HistoryParser.FindHistoryFiles(Path.Combine(_dir, "gone.zip"))); + } + + [Fact] + public async Task ParseAsync_ReadsRecordsStraightFromTheZip() + { + var zip = WriteZip("export.zip", + ("Spotify Extended Streaming History/Streaming_History_Audio_2023.json", OnePlay)); + + var result = await new HistoryParser().ParseAsync(HistoryParser.FindHistoryFiles(zip)); + + var record = Assert.Single(result.Records); + Assert.Equal("Song", record.TrackName); + Assert.Equal(215000, record.MsPlayed); + Assert.Empty(result.Warnings); + } + + [Fact] + public async Task ParseAsync_ReadsManyEntriesOfOneZipAtOnce() + { + var entries = Enumerable.Range(0, 12) + .Select(i => ($"Streaming_History_Audio_{2010 + i}.json", OnePlay.Replace("2023", $"{2010 + i}"))) + .ToArray(); + var zip = WriteZip("export.zip", entries); + + var result = await new HistoryParser().ParseAsync(HistoryParser.FindHistoryFiles(zip)); + + Assert.Equal(12, result.Records.Count); + } + + [Fact] + public async Task ParseAsync_SkipsAnEntryThatIsNotThere() + { + var zip = WriteZip("export.zip", ("Streaming_History_Audio_2023.json", OnePlay)); + var missing = ArchivePath.Combine(zip, "Streaming_History_Audio_1999.json"); + + var result = await new HistoryParser().ParseAsync(new[] { missing }); + + Assert.Empty(result.Records); + Assert.Equal(new[] { missing }, result.SkippedFiles); + } + + [Fact] + public async Task ParseAsync_WarnsWhenTheZipIsDamagedAfterItWasScanned() + { + var zip = WriteZip("export.zip", ("Streaming_History_Audio_2023.json", OnePlay)); + var files = HistoryParser.FindHistoryFiles(zip); + + // Cutting the file short loses the archive's directory, the way an interrupted + // download would. + using (var stream = new FileStream(zip, FileMode.Open)) + stream.SetLength(stream.Length / 2); + + var result = await new HistoryParser().ParseAsync(files); + + Assert.Empty(result.Records); + Assert.Contains("ZIP", Assert.Single(result.Warnings)); + } + + [Fact] + public void CacheKey_ChangesWhenTheZipChanges() + { + var zip = WriteZip("export.zip", ("Streaming_History_Audio_2023.json", OnePlay)); + var files = HistoryParser.FindHistoryFiles(zip); + string before = RecordCache.BuildKey(files); + + Assert.Equal(before, RecordCache.BuildKey(files)); + + File.SetLastWriteTimeUtc(zip, DateTime.UtcNow.AddMinutes(5)); + + Assert.NotEqual(before, RecordCache.BuildKey(files)); + } + + [Fact] + public void CacheKey_TellsEntriesOfOneZipApart() + { + var zip = WriteZip("export.zip", + ("Streaming_History_Audio_2022.json", OnePlay), + ("Streaming_History_Audio_2023.json", OnePlay)); + var files = HistoryParser.FindHistoryFiles(zip); + + Assert.NotEqual(RecordCache.BuildKey(files.Take(1)), RecordCache.BuildKey(files.Skip(1))); + } + + [Fact] + public void ArchivePath_RoundTrips() + { + var path = ArchivePath.Combine(@"C:\Downloads\my_spotify_data.zip", "folder/Streaming_History_Audio_2023.json"); + + Assert.True(ArchivePath.TrySplit(path, out var archive, out var entry)); + Assert.Equal(@"C:\Downloads\my_spotify_data.zip", archive); + Assert.Equal("folder/Streaming_History_Audio_2023.json", entry); + Assert.Equal("Streaming_History_Audio_2023.json", Path.GetFileName(path)); + } + + [Theory] + [InlineData(@"C:\Downloads\my_spotify_data.zip", true)] + [InlineData(@"C:\Downloads\MY_SPOTIFY_DATA.ZIP", true)] + [InlineData(@"C:\Downloads\Streaming_History_Audio_2023.json", false)] + [InlineData(@"C:\Downloads\export.zip|Streaming_History_Audio_2023.json", false)] + [InlineData(@"C:\Downloads", false)] + public void ArchivePath_RecognisesOnlyAZipItself(string path, bool expected) + { + Assert.Equal(expected, ArchivePath.IsArchive(path)); + } + + [Fact] + public void ArchivePath_DoesNotSplitAPlainPath() + { + Assert.False(ArchivePath.TrySplit(@"C:\Downloads\Streaming_History_Audio_2023.json", out _, out _)); + } +} diff --git a/Sortify.Tests/ExportFiltersTests.cs b/Sortify.Tests/ExportFiltersTests.cs new file mode 100644 index 0000000..ff87612 --- /dev/null +++ b/Sortify.Tests/ExportFiltersTests.cs @@ -0,0 +1,89 @@ +using System.IO; +using System.Text.Json; +using Sortify.Models; +using Sortify.Services; +using Xunit; + +namespace Sortify.Tests; + +/// Reports say which filters they were made under. +public class ExportFiltersTests : IDisposable +{ + private static readonly string[] Filters = { "2023-01-01 to 2023-12-31", "Sun, Sat", "2 artists excluded" }; + + private readonly string _dir; + + public ExportFiltersTests() + { + _dir = Path.Combine(Path.GetTempPath(), "SortifyTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_dir); + } + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch { } + } + + private static AnalysisResult Result() => AnalysisEngine.Analyze(new[] + { + new PlayRecord + { + TrackName = "Song", ArtistName = "Artist", AlbumName = "Album", + MsPlayed = 180_000, Timestamp = new DateTime(2023, 1, 5, 12, 0, 0), + }, + }, new FilterOptions()); + + [Fact] + public async Task Text_ListsTheFiltersUnderTheHeading() + { + var path = Path.Combine(_dir, "results.txt"); + + await ExportService.SaveTxtAsync(path, Result(), Filters); + var lines = await File.ReadAllLinesAsync(path); + + Assert.Equal("Filters: 2023-01-01 to 2023-12-31; Sun, Sat; 2 artists excluded", lines[2]); + } + + [Fact] + public async Task Text_SaysWhenNothingWasFiltered() + { + var path = Path.Combine(_dir, "results.txt"); + + await ExportService.SaveTxtAsync(path, Result()); + + Assert.Contains("Filters: none (every play in the export)", await File.ReadAllTextAsync(path)); + } + + [Fact] + public async Task Markdown_ListsTheFiltersWithFormattingEscaped() + { + var path = Path.Combine(_dir, "report.md"); + + await ExportService.SaveMarkdownAsync(path, Result(), new[] { "Search “*hits*”" }); + var text = await File.ReadAllTextAsync(path); + + Assert.Contains("Filters: Search “\\*hits\\*”", text); + } + + [Fact] + public async Task Json_CarriesTheFiltersAsAnArray() + { + var path = Path.Combine(_dir, "results.json"); + + await ExportService.SaveJsonAsync(path, Result(), Filters); + using var doc = JsonDocument.Parse(await File.ReadAllTextAsync(path)); + + Assert.Equal(Filters, doc.RootElement.GetProperty("filters").EnumerateArray().Select(e => e.GetString())); + } + + [Fact] + public async Task Json_HasAnEmptyArrayWhenNothingWasFiltered() + { + var path = Path.Combine(_dir, "results.json"); + + await ExportService.SaveJsonAsync(path, Result()); + using var doc = JsonDocument.Parse(await File.ReadAllTextAsync(path)); + + Assert.Equal(0, doc.RootElement.GetProperty("filters").GetArrayLength()); + } +} diff --git a/Sortify.Tests/FilterPresetTests.cs b/Sortify.Tests/FilterPresetTests.cs new file mode 100644 index 0000000..e9bfdb8 --- /dev/null +++ b/Sortify.Tests/FilterPresetTests.cs @@ -0,0 +1,285 @@ +using System.Text.Json; +using Sortify.Models; +using Sortify.Services; +using Sortify.ViewModels; +using Xunit; + +namespace Sortify.Tests; + +/// Named filter sets, and the filters remembered between launches. +public class FilterPresetTests +{ + private static FilterViewModel Busy() + { + var vm = new FilterViewModel + { + MinSeconds = 30, + IncludePodcasts = true, + StartDate = new DateTime(2023, 1, 1), + EndDate = new DateTime(2023, 12, 31), + SearchTerm = "night", + StartHour = 22, + EndHour = 2, + }; + vm.Days[0].IsSelected = false; + vm.ExcludeArtist("Rain Sounds"); + vm.ExcludeTrack("Lullaby"); + return vm; + } + + [Fact] + public void Snapshot_ThenApply_PutsEveryFilterBack() + { + var saved = Busy().Snapshot("Nights"); + var vm = new FilterViewModel(); + + vm.Apply(saved); + + Assert.True(vm.Snapshot().SameFiltersAs(saved)); + Assert.Equal(new[] { "Rain Sounds" }, vm.ExcludedArtists); + Assert.False(vm.Days[0].IsSelected); + Assert.Equal(22, vm.StartHour); + } + + [Fact] + public void Apply_RaisesASingleChange() + { + var vm = new FilterViewModel(); + int changes = 0; + vm.FiltersChanged += (_, _) => changes++; + + vm.Apply(Busy().Snapshot()); + + Assert.Equal(1, changes); + } + + [Fact] + public void PickingAPreset_AppliesIt() + { + var vm = new FilterViewModel(); + var preset = Busy().Snapshot("Nights"); + vm.LoadPresets(new[] { preset }); + + vm.SelectedPreset = preset; + + Assert.Equal(30, vm.MinSeconds); + Assert.Same(preset, vm.SelectedPreset); + } + + [Fact] + public void ChangingAFilterAfterwards_ClearsThePickedPreset() + { + var vm = new FilterViewModel(); + var preset = Busy().Snapshot("Nights"); + vm.LoadPresets(new[] { preset }); + vm.SelectedPreset = preset; + + vm.SearchTerm = "day"; + + Assert.Null(vm.SelectedPreset); + Assert.Single(vm.Presets); + } + + [Fact] + public void Save_AddsThePresetAndSelectsIt() + { + var vm = Busy(); + int persisted = 0; + vm.PresetsChanged += (_, _) => persisted++; + vm.NewPresetName = " Nights "; + + vm.SavePresetCommand.Execute(null); + + var preset = Assert.Single(vm.Presets); + Assert.Equal("Nights", preset.Name); + Assert.Same(preset, vm.SelectedPreset); + Assert.Equal(string.Empty, vm.NewPresetName); + Assert.Equal(1, persisted); + } + + [Fact] + public void Save_UnderATakenName_ReplacesIt() + { + var vm = Busy(); + vm.NewPresetName = "Nights"; + vm.SavePresetCommand.Execute(null); + + vm.MinSeconds = 90; + vm.NewPresetName = "NIGHTS"; + vm.SavePresetCommand.Execute(null); + + var preset = Assert.Single(vm.Presets); + Assert.Equal(90, preset.MinSeconds); + } + + [Fact] + public void Save_NeedsAName() + { + var vm = new FilterViewModel { NewPresetName = " " }; + + Assert.False(vm.SavePresetCommand.CanExecute(null)); + } + + [Fact] + public void Save_StopsAtTheLimitAndSaysWhy() + { + var vm = new FilterViewModel(); + vm.LoadPresets(Enumerable.Range(0, AppSettings.MaxFilterPresets) + .Select(i => new FilterPreset { Name = $"Set {i}" })); + string? notice = null; + vm.Notice += (_, message) => notice = message; + + vm.NewPresetName = "One more"; + vm.SavePresetCommand.Execute(null); + + Assert.Equal(AppSettings.MaxFilterPresets, vm.Presets.Count); + Assert.Contains("Delete one", notice); + } + + [Fact] + public void Delete_ForgetsThePresetButKeepsTheFilters() + { + var vm = new FilterViewModel(); + var preset = Busy().Snapshot("Nights"); + vm.LoadPresets(new[] { preset }); + vm.SelectedPreset = preset; + + vm.DeletePresetCommand.Execute(null); + + Assert.Empty(vm.Presets); + Assert.Null(vm.SelectedPreset); + Assert.Equal(30, vm.MinSeconds); + Assert.False(vm.DeletePresetCommand.CanExecute(null)); + } + + [Fact] + public void Prompt_SaysWhetherThereIsAnythingToPick() + { + var vm = new FilterViewModel(); + Assert.Equal("Nothing saved yet", vm.PresetPrompt); + + vm.LoadPresets(new[] { new FilterPreset { Name = "Nights" } }); + + Assert.Equal("Choose a saved set", vm.PresetPrompt); + Assert.False(vm.HasSelectedPreset); + } + + [Fact] + public void RememberedSnapshot_KeepsOnlyWhatShouldOutliveTheSession() + { + var remembered = Busy().RememberedSnapshot(); + + Assert.Equal(30, remembered.MinSeconds); + Assert.True(remembered.IncludePodcasts); + Assert.Equal(new[] { "Rain Sounds" }, remembered.ExcludedArtists); + Assert.Equal(new[] { "Lullaby" }, remembered.ExcludedTracks); + Assert.Null(remembered.StartDate); + Assert.Equal(string.Empty, remembered.SearchTerm); + Assert.Equal(0, remembered.StartHour); + Assert.All(remembered.IncludedDays, Assert.True); + } + + [Fact] + public void Presets_SurviveTheSettingsFile() + { + var settings = new AppSettings + { + Filters = Busy().RememberedSnapshot(), + FilterPresets = { Busy().Snapshot("Nights") }, + }; + + var restored = JsonSerializer.Deserialize(JsonSerializer.Serialize(settings))!; + restored.Normalize(); + + Assert.True(restored.Filters!.SameFiltersAs(settings.Filters)); + Assert.True(restored.FilterPresets.Single().SameFiltersAs(settings.FilterPresets[0])); + Assert.Equal("Nights", restored.FilterPresets[0].Name); + } + + [Fact] + public void Normalize_RepairsAHandEditedPresetList() + { + var settings = JsonSerializer.Deserialize(""" + { "FilterPresets": [ + null, + { "Name": " " }, + { "Name": "Nights", "StartHour": 40, "IncludedDays": [ true ], "ExcludedArtists": [ "A", null, " a " ] }, + { "Name": "nights" } + ] } + """)!; + + settings.Normalize(); + + var preset = Assert.Single(settings.FilterPresets); + Assert.Equal(23, preset.StartHour); + Assert.Equal(7, preset.IncludedDays.Length); + Assert.Equal(new[] { "A" }, preset.ExcludedArtists); + } + +} + +/// +/// The main view model reaches ThemeService, whose palettes are pack URIs that only resolve +/// once a WPF Application exists, so these run on the shared WPF host. +/// +[Collection(WpfCollection.Name)] +public class RememberedFilterTests +{ + [Fact] + public void RestoresTheRememberedFiltersOnLaunch() + { + WpfTestHost.Run(() => + { + var remembered = new FilterPreset { MinSeconds = 30, ExcludedArtists = { "Rain Sounds" } }; + + var vm = new MainViewModel(new AppSettings { Filters = remembered }); + + Assert.Equal(new[] { "Rain Sounds" }, vm.Filters.ExcludedArtists); + Assert.Equal(30, vm.Filters.MinSeconds); + Assert.Contains("1 artist excluded", vm.ActiveFilters.Select(c => c.Text)); + }); + } + + [Fact] + public void RemembersAnExclusionAsSoonAsItIsMade() + { + WpfTestHost.Run(() => + { + var settings = new AppSettings(); + var vm = new MainViewModel(settings); + + vm.ExcludeArtistsFromGrid(new[] { "White Noise" }); + + Assert.Equal(new[] { "White Noise" }, settings.Filters!.ExcludedArtists); + }); + } + + [Fact] + public void DoesNotRememberADateRange() + { + WpfTestHost.Run(() => + { + var settings = new AppSettings(); + var vm = new MainViewModel(settings); + + vm.Filters.StartDate = new DateTime(2023, 1, 1); + + Assert.Null(settings.Filters?.StartDate); + }); + } + + [Fact] + public void LoadsAndPersistsPresets() + { + WpfTestHost.Run(() => + { + var settings = new AppSettings { FilterPresets = { new FilterPreset { Name = "Old" } } }; + var vm = new MainViewModel(settings); + + vm.Filters.NewPresetName = "New"; + vm.Filters.SavePresetCommand.Execute(null); + + Assert.Equal(new[] { "Old", "New" }, settings.FilterPresets.Select(p => p.Name)); + }); + } +} diff --git a/Sortify.Tests/FilterViewModelTests.cs b/Sortify.Tests/FilterViewModelTests.cs index 47de116..880ed80 100644 --- a/Sortify.Tests/FilterViewModelTests.cs +++ b/Sortify.Tests/FilterViewModelTests.cs @@ -82,6 +82,14 @@ public void Describe_CountsExclusions() Assert.Contains("1 track excluded", vm.Describe()); } + [Fact] + public void Describe_QuotesTheSearchTerm() + { + var vm = new FilterViewModel { SearchTerm = " night " }; + + Assert.Contains("Search “night”", vm.Describe()); + } + [Fact] public void Describe_MentionsANarrowedHourRange() { @@ -118,6 +126,66 @@ public void HasInvalidDateRange_RaisesChangeNotifications() Assert.Contains(nameof(FilterViewModel.HasInvalidDateRange), changed); } + private static FilterViewModel EveryFilterSet() + { + var vm = new FilterViewModel + { + MinSeconds = 30, + IncludePodcasts = true, + StartDate = new DateTime(2023, 1, 1), + EndDate = new DateTime(2023, 12, 31), + SearchTerm = "night", + StartHour = 22, + EndHour = 2, + }; + vm.Days[3].IsSelected = false; + vm.ExcludeArtist("Rain Sounds"); + vm.ExcludeArtist("Ocean Waves"); + vm.ExcludeTrack("Lullaby"); + return vm; + } + + [Theory] + [InlineData("Min 30s")] + [InlineData("Podcasts counted")] + [InlineData("2023-01-01 to 2023-12-31")] + [InlineData("Search “night”")] + [InlineData("22:00-02:59")] + [InlineData("Sun, Mon, Tue, Thu, Fri, Sat")] + [InlineData("2 artists excluded")] + [InlineData("1 track excluded")] + public void Chip_ClearsOnlyItsOwnFilter(string text) + { + var vm = EveryFilterSet(); + var others = vm.Describe().Where(d => d != text).ToList(); + + vm.Chips().Single(c => c.Text == text).ClearCommand.Execute(null); + + Assert.Equal(others, vm.Describe()); + } + + [Fact] + public void Chip_ClearsInASingleChange() + { + var vm = EveryFilterSet(); + int changes = 0; + vm.FiltersChanged += (_, _) => changes++; + + foreach (var chip in vm.Chips().ToList()) + chip.ClearCommand.Execute(null); + + Assert.Equal(8, changes); + Assert.Empty(vm.Describe()); + } + + [Fact] + public void ExclusionChip_NamesWhatItWouldStopExcluding() + { + var chip = EveryFilterSet().Chips().Single(c => c.Text == "2 artists excluded"); + + Assert.Equal("Stop excluding Rain Sounds, Ocean Waves", chip.ClearHint); + } + [Fact] public void Reset_ClearsEveryChip() { @@ -129,4 +197,16 @@ public void Reset_ClearsEveryChip() Assert.Empty(vm.Describe()); Assert.Equal(FilterOptions.DefaultMinMs / 1000, vm.MinSeconds); } + + [Fact] + public void Reset_IsOneChange() + { + var vm = EveryFilterSet(); + int changes = 0; + vm.FiltersChanged += (_, _) => changes++; + + vm.ResetCommand.Execute(null); + + Assert.Equal(1, changes); + } } diff --git a/Sortify.Tests/ForgottenFavoritesTests.cs b/Sortify.Tests/ForgottenFavoritesTests.cs new file mode 100644 index 0000000..c3a8c7c --- /dev/null +++ b/Sortify.Tests/ForgottenFavoritesTests.cs @@ -0,0 +1,88 @@ +using Sortify.Models; +using Sortify.Services; +using Xunit; + +namespace Sortify.Tests; + +/// Tracks and artists played a lot that then went quiet. +public class ForgottenFavoritesTests +{ + private static readonly DateTime End = new(2025, 6, 30, 20, 0, 0); + + private static IEnumerable Plays(string track, string artist, int count, DateTime last) => + Enumerable.Range(0, count).Select(i => new PlayRecord + { + TrackName = track, + ArtistName = artist, + AlbumName = "Album", + MsPlayed = 60_000, + Timestamp = last.AddDays(-i), + }); + + private static AnalysisResult Analyze(params IEnumerable[] groups) => + AnalysisEngine.Analyze(groups.SelectMany(g => g).ToList(), new FilterOptions()); + + [Fact] + public void AFavoriteThatWentQuiet_IsListed() + { + var result = Analyze( + Plays("Old Love", "Band", 25, End.AddDays(-200)), + Plays("Still Playing", "Band", 25, End)); + + Assert.Equal(new[] { "Old Love" }, result.ForgottenTracks.Select(t => t.Track)); + } + + [Fact] + public void QuietIsMeasuredFromTheEndOfTheHistoryNotFromToday() + { + // Everything here is years old, but the history ends right after it. + var then = new DateTime(2019, 1, 1); + var result = Analyze(Plays("Then", "Band", 25, then), Plays("Last", "Other", 1, then.AddDays(10))); + + Assert.Empty(result.ForgottenTracks); + } + + [Fact] + public void ARarelyPlayedTrack_IsNotAFavorite() + { + var result = Analyze( + Plays("Passing", "Band", AnalysisEngine.ForgottenMinTrackPlays - 1, End.AddDays(-300)), + Plays("Now", "Other", 1, End)); + + Assert.Empty(result.ForgottenTracks); + } + + [Fact] + public void JustInsideTheQuietStretch_StillCounts() + { + var result = Analyze( + Plays("Edge", "Band", 30, End.AddDays(-AnalysisEngine.ForgottenAfterDays).AddMinutes(-1)), + Plays("Now", "Other", 1, End)); + + Assert.Single(result.ForgottenTracks); + } + + [Fact] + public void ArtistsNeedMorePlays_AndComeMostPlayedFirst() + { + var result = Analyze( + Plays("A", "Big Band", 80, End.AddDays(-400)), + Plays("B", "Bigger Band", 120, End.AddDays(-400)), + Plays("C", "Small Band", 30, End.AddDays(-400)), + Plays("Now", "Current", 1, End)); + + Assert.Equal(new[] { "Bigger Band", "Big Band" }, result.ForgottenArtists.Select(a => a.Artist)); + Assert.Equal(new[] { "B", "A", "C" }, result.ForgottenTracks.Select(t => t.Track)); + } + + [Fact] + public void TheListIsCapped() + { + var groups = Enumerable.Range(0, AnalysisEngine.ForgottenMaxRows + 10) + .Select(i => Plays($"Song {i}", "Band", 21, End.AddDays(-400))) + .Append(Plays("Now", "Other", 1, End)) + .ToArray(); + + Assert.Equal(AnalysisEngine.ForgottenMaxRows, Analyze(groups).ForgottenTracks.Count); + } +} diff --git a/Sortify.Tests/JumpToTests.cs b/Sortify.Tests/JumpToTests.cs new file mode 100644 index 0000000..bc21ff4 --- /dev/null +++ b/Sortify.Tests/JumpToTests.cs @@ -0,0 +1,100 @@ +using Sortify.Models; +using Sortify.Services; +using Sortify.ViewModels; +using Xunit; + +namespace Sortify.Tests; + +/// The Ctrl+K jump box. +public class JumpToTests +{ + [Theory] + [InlineData("Love", "love", 0)] + [InlineData("Lovely Day", "love", 1)] + [InlineData("Crazy Love", "love", 2)] + [InlineData("Glove (Remix)", "love", 3)] + [InlineData("Glove Love", "love", 2)] + [InlineData("Hate", "love", -1)] + public void MatchRank_PrefersWholeNamesThenStartsThenWords(string name, string query, int expected) + { + Assert.Equal(expected, MainViewModel.MatchRank(name, query)); + } + + private static AnalysisResult Result() + { + PlayRecord Play(string track, string artist, int minutes) => new() + { + TrackName = track, + ArtistName = artist, + AlbumName = track + " (Album)", + MsPlayed = minutes * 60_000, + Timestamp = new DateTime(2024, 1, 1, 12, 0, 0), + }; + + return AnalysisEngine.Analyze(new[] + { + Play("Glove", "Band A", 50), + Play("Love", "Band B", 5), + Play("Lovely", "Band C", 30), + Play("Other", "Love Club", 10), + new PlayRecord + { + Kind = ContentKind.Podcast, ShowName = "Love Talk", EpisodeName = "Episode 1", + MsPlayed = 60 * 60_000, Timestamp = new DateTime(2024, 1, 1, 12, 0, 0), + }, + }, new FilterOptions()); + } + + [Fact] + public void Search_RanksByMatchThenListeningTime() + { + var tracks = MainViewModel.SearchForJump(Result(), "love").Where(r => r.Kind == "Track").Select(r => r.Name); + + Assert.Equal(new[] { "Love", "Lovely", "Glove" }, tracks); + } + + [Fact] + public void Search_CoversEveryKindInOrder() + { + var kinds = MainViewModel.SearchForJump(Result(), "love").Select(r => r.Kind).Distinct(); + + Assert.Equal(new[] { "Artist", "Track", "Album", "Podcast" }, kinds); + } + + [Fact] + public void Search_CapsEachKind() + { + Assert.Equal(1, MainViewModel.SearchForJump(Result(), "love", perKind: 1).Count(r => r.Kind == "Track")); + } + + [Fact] + public void Search_KeepsTheRowForTheDetailView() + { + var match = MainViewModel.SearchForJump(Result(), "love club").Single(); + + Assert.Equal("Love Club", Assert.IsType(match.Item).Artist); + } + + [Fact] + public void Search_FindsNothingForABlankQuery() + { + Assert.Empty(MainViewModel.SearchForJump(Result(), " ")); + } +} + +[Collection(WpfCollection.Name)] +public class JumpBoxTests +{ + [Fact] + public void WithNothingLoaded_ItSaysWhyAndStaysShut() + { + WpfTestHost.Run(() => + { + var vm = new MainViewModel(new AppSettings()); + + Assert.False(vm.OpenJump()); + Assert.False(vm.IsJumpOpen); + Assert.Contains("Load your history first", vm.StatusText); + }); + } +} diff --git a/Sortify.Tests/LoadingTests.cs b/Sortify.Tests/LoadingTests.cs new file mode 100644 index 0000000..de0f5da --- /dev/null +++ b/Sortify.Tests/LoadingTests.cs @@ -0,0 +1,160 @@ +using System.IO; +using Sortify.Services; +using Sortify.ViewModels; +using Xunit; + +namespace Sortify.Tests; + +/// Opening, dropping and reopening exports through the main view model. +[Collection(WpfCollection.Name)] +public class LoadingTests : IDisposable +{ + private const string TwoPlays = """ + [ + { "ts": "2023-05-01T10:00:00Z", "ms_played": 200000, "master_metadata_track_name": "One", + "master_metadata_album_artist_name": "Artist", "master_metadata_album_album_name": "Album" }, + { "ts": "2023-05-02T10:00:00Z", "ms_played": 200000, "master_metadata_track_name": "Two", + "master_metadata_album_artist_name": "Artist", "master_metadata_album_album_name": "Album" } + ] + """; + + private readonly string _dir; + + public LoadingTests() + { + _dir = Path.Combine(Path.GetTempPath(), "SortifyLoading_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_dir); + RecordCache.Clear(); + } + + public void Dispose() + { + RecordCache.Clear(); + try { Directory.Delete(_dir, recursive: true); } catch { } + } + + /// An export folder holding one extended-history file with the given content. + private string Export(string name, string json) + { + var folder = Path.Combine(_dir, name); + Directory.CreateDirectory(folder); + File.WriteAllText(Path.Combine(folder, "Streaming_History_Audio_2023.json"), json); + return folder; + } + + private string WriteFile(string name, string content) + { + var path = Path.Combine(_dir, name); + File.WriteAllText(path, content); + return path; + } + + [Fact] + public async Task OpeningAFolder_LoadsAndRemembersIt() + { + var folder = Export("export", TwoPlays); + var settings = new AppSettings(); + + await WpfTestHost.RunAsync(async () => + { + var vm = new MainViewModel(settings); + await vm.OpenFolderPathAsync(folder); + + Assert.True(vm.HasData); + Assert.False(vm.IsBusy); + Assert.Equal("2", vm.TotalPlaysText); + Assert.Equal(folder, settings.LastFolder); + Assert.Equal(new[] { folder }, settings.RecentFolders); + }); + } + + [Fact] + public async Task AFileWithNoPlays_LeavesTheOpenHistoryAlone() + { + var folder = Export("export", TwoPlays); + var stray = WriteFile("notes.json", "[]"); + + await WpfTestHost.RunAsync(async () => + { + var vm = new MainViewModel(new AppSettings()); + await vm.OpenFolderPathAsync(folder); + await vm.LoadDroppedAsync(new[] { stray }); + + Assert.True(vm.HasData); + Assert.Equal("2", vm.TotalPlaysText); + Assert.True(vm.StatusIsError); + Assert.Contains("still open", vm.StatusText); + }); + } + + [Fact] + public async Task AFolderWithNoPlays_IsNotRemembered() + { + var empty = Export("empty", "[]"); + var settings = new AppSettings(); + + await WpfTestHost.RunAsync(async () => + { + var vm = new MainViewModel(settings); + await vm.OpenFolderPathAsync(empty); + + Assert.False(vm.HasData); + Assert.True(vm.StatusIsError); + Assert.Null(settings.LastFolder); + Assert.Empty(settings.RecentFolders); + }); + } + + [Fact] + public async Task DroppingAFolder_LoadsTheHistoryInsideIt() + { + var folder = Export("export", TwoPlays); + var settings = new AppSettings(); + + await WpfTestHost.RunAsync(async () => + { + var vm = new MainViewModel(settings); + await vm.LoadDroppedAsync(new[] { folder }); + + Assert.True(vm.HasData); + Assert.Equal("2", vm.TotalPlaysText); + Assert.Equal(folder, settings.LastFolder); + }); + } + + [Fact] + public async Task DroppingSomethingElse_SaysWhatToDrop() + { + var text = WriteFile("readme.txt", "hello"); + + await WpfTestHost.RunAsync(async () => + { + var vm = new MainViewModel(new AppSettings()); + await vm.LoadDroppedAsync(new[] { text }); + + Assert.False(vm.HasData); + Assert.False(vm.IsBusy); + Assert.True(vm.StatusIsError); + Assert.Contains("Drop the ZIP", vm.StatusText); + }); + } + + [Fact] + public async Task RestoringAFolderWithNoHistory_SaysNothing() + { + var folder = Path.Combine(_dir, "nothing here"); + Directory.CreateDirectory(folder); + var settings = new AppSettings { LastFolder = folder }; + + await WpfTestHost.RunAsync(async () => + { + var vm = new MainViewModel(settings); + string before = vm.StatusText; + await vm.RestoreLastFolderAsync(); + + Assert.False(vm.HasData); + Assert.False(vm.StatusIsError); + Assert.Equal(before, vm.StatusText); + }); + } +} diff --git a/Sortify.Tests/MilestoneTests.cs b/Sortify.Tests/MilestoneTests.cs new file mode 100644 index 0000000..f2c4baa --- /dev/null +++ b/Sortify.Tests/MilestoneTests.cs @@ -0,0 +1,124 @@ +using Sortify.Models; +using Sortify.Services; +using Sortify.Views; +using Xunit; + +namespace Sortify.Tests; + +/// The plays that mark a history. +public class MilestoneTests +{ + private static readonly DateTime Start = new(2023, 1, 1, 8, 0, 0); + + // One play an hour, each named after its position, so a milestone names the play it landed on. + private static List Plays(int count, int minutes = 3, string artist = "Band") => + Enumerable.Range(1, count).Select(i => new PlayRecord + { + TrackName = $"Play {i}", + ArtistName = artist, + AlbumName = "Album", + MsPlayed = minutes * 60_000, + Timestamp = Start.AddHours(i), + }).ToList(); + + private static IReadOnlyList MilestonesOf(IEnumerable records) => + AnalysisEngine.Analyze(records.ToList(), new FilterOptions()).Milestones; + + [Fact] + public void TheFirstPlayIsAlwaysOne() + { + var first = MilestonesOf(Plays(3)).First(); + + Assert.Equal("First play", first.Label); + Assert.Equal("Play 1", first.Track.Track); + Assert.Equal(Start.AddHours(1), first.Date); + } + + [Fact] + public void TheThousandthPlayLandsOnTheThousandthPlay() + { + var milestone = MilestonesOf(Plays(1_200)).Single(m => m.Label == "1,000th play"); + + Assert.Equal("Play 1000", milestone.Track.Track); + } + + [Fact] + public void AnHourMarkLandsOnThePlayThatCrossedIt() + { + // Ten-minute plays: the 600th takes the total to exactly 100 hours. + var milestone = MilestonesOf(Plays(700, minutes: 10)).Single(m => m.Label == "100 hours listened"); + + Assert.Equal("Play 600", milestone.Track.Track); + } + + [Fact] + public void OnePlayCanPassSeveralMarks() + { + var records = new List + { + new() { TrackName = "Marathon", ArtistName = "Band", MsPlayed = int.MaxValue, Timestamp = Start }, + }; + + var labels = MilestonesOf(records).Select(m => m.Label).ToList(); + + Assert.Contains("100 hours listened", labels); + Assert.Contains("500 hours listened", labels); + Assert.DoesNotContain("1,000 hours listened", labels); + } + + [Fact] + public void MarksNotReachedAreLeftOut() + { + Assert.DoesNotContain(MilestonesOf(Plays(500)), m => m.Label == "1,000th play"); + } + + [Fact] + public void TheTopArtistsFirstPlayIsMarked_AndEverythingIsInDateOrder() + { + var records = Plays(5, artist: "Early Band"); + records.AddRange(Plays(30, minutes: 30, artist: "Top Band").Select(r => new PlayRecord + { + TrackName = "Big Song", + ArtistName = r.ArtistName, + AlbumName = r.AlbumName, + MsPlayed = r.MsPlayed, + Timestamp = r.Timestamp.AddDays(10), + })); + + var milestones = MilestonesOf(records); + + var top = milestones.Single(m => m.Label.StartsWith("First time hearing")); + Assert.Equal("First time hearing Top Band, your top artist", top.Label); + Assert.Equal("Big Song", top.Track.Track); + Assert.Equal(milestones.OrderBy(m => m.Date).Select(m => m.Label), milestones.Select(m => m.Label)); + } + + [Fact] + public void NoDatedPlaysMeansNoMilestones() + { + var records = new[] { new PlayRecord { TrackName = "Song", ArtistName = "Band", MsPlayed = 60_000 } }; + + Assert.Empty(MilestonesOf(records)); + } + + [Fact] + public void SessionsAreUnchangedByTheSharedSort() + { + // Out of order on purpose: sessions read the plays in time order whatever the input order. + var records = Plays(4, minutes: 50); + records.Reverse(); + + var result = AnalysisEngine.Analyze(records, new FilterOptions()); + + Assert.Equal(1, result.SessionCount); + Assert.Equal(200 * 60_000, result.LongestSessionMs); + } + + [Fact] + public void AMilestoneOpensItsTrack() + { + var milestone = new Milestone("First play", Start, new TrackStat { Track = "Song", Artist = "Band" }); + + Assert.Equal((DetailScope.Track, "Song", "Band"), DetailWindow.TargetFor(milestone)); + } +} diff --git a/Sortify.Tests/MonthlyTopTests.cs b/Sortify.Tests/MonthlyTopTests.cs new file mode 100644 index 0000000..a7e2a86 --- /dev/null +++ b/Sortify.Tests/MonthlyTopTests.cs @@ -0,0 +1,102 @@ +using Sortify.Models; +using Sortify.Services; +using Sortify.ViewModels; +using Sortify.Views; +using Xunit; + +namespace Sortify.Tests; + +/// The top artist and track of every month. +public class MonthlyTopTests +{ + private static PlayRecord Play(string track, string artist, int minutes, int year, int month, int day = 1) => new() + { + TrackName = track, + ArtistName = artist, + AlbumName = "Album", + MsPlayed = minutes * 60_000, + Timestamp = new DateTime(year, month, day, 12, 0, 0), + }; + + [Fact] + public void EachMonthGetsItsOwnLeaders() + { + var result = AnalysisEngine.Analyze(new[] + { + Play("Song A", "Band A", 30, 2024, 1), + Play("Song B", "Band B", 20, 2024, 1), + Play("Song B2", "Band B", 20, 2024, 1, 2), + Play("Song C", "Band C", 10, 2024, 2), + }, new FilterOptions()); + + Assert.Equal(new[] { new DateTime(2024, 1, 1), new DateTime(2024, 2, 1) }, result.Months.Select(m => m.Month)); + var january = result.Months[0]; + // Band B leads the month on two tracks, though Song A is its single biggest track. + Assert.Equal("Band B", january.TopArtist); + Assert.Equal(40 * 60_000, january.TopArtistMs); + Assert.Equal("Song A", january.TopTrack); + Assert.Equal("Band A", january.TopTrackArtist); + Assert.Equal(3, january.PlayCount); + Assert.Equal("Band C", result.Months[1].TopArtist); + } + + [Fact] + public void YearsStillAgreeWithTheMonths() + { + var result = AnalysisEngine.Analyze(new[] + { + Play("Song A", "Band A", 30, 2024, 1), + Play("Song B", "Band B", 50, 2024, 2), + }, new FilterOptions()); + + var year = Assert.Single(result.Years); + Assert.Equal("Band B", year.TopArtist); + Assert.Equal("Song B", year.TopTrack); + Assert.Equal(result.Months.Sum(m => m.TotalMsPlayed), year.TotalMsPlayed); + } + + private static MonthStat Month(int year, int month, string artist, long ms = 1000) => + new() { Month = new DateTime(year, month, 1), TopArtist = artist, TopArtistMs = ms }; + + [Fact] + public void Leader_CountsMonthsAndTheLongestRun() + { + var months = new[] + { + Month(2024, 1, "A"), Month(2024, 2, "A"), Month(2024, 3, "B"), + Month(2024, 4, "A"), Month(2024, 5, "A"), Month(2024, 6, "A"), + }; + + Assert.Equal("A was your #1 artist in 5 of 6 months, 3 of them in a row (2024-04 to 2024-06).", + MainViewModel.DescribeMonthlyLeader(months)); + } + + [Fact] + public void Leader_RunsBreakOnAGapInTheCalendar() + { + // Two months apart are not "in a row", even with nothing listened to in between. + var months = new[] { Month(2024, 1, "A"), Month(2024, 3, "A") }; + + Assert.Equal("A was your #1 artist in 2 of 2 months.", MainViewModel.DescribeMonthlyLeader(months)); + } + + [Fact] + public void Leader_TiesGoToMoreListening() + { + var months = new[] { Month(2024, 1, "A", 100), Month(2024, 2, "B", 900) }; + + Assert.StartsWith("B was your #1 artist in 1 of 2 months", MainViewModel.DescribeMonthlyLeader(months)); + } + + [Fact] + public void Leader_IsBlankWithNoMonths() + { + Assert.Equal(string.Empty, MainViewModel.DescribeMonthlyLeader(Array.Empty())); + } + + [Fact] + public void AMonthRowOpensItsTopArtist() + { + Assert.Equal((DetailScope.Artist, "A", ""), DetailWindow.TargetFor(Month(2024, 1, "A"))); + } +} diff --git a/Sortify.Tests/OverlappingExportsTests.cs b/Sortify.Tests/OverlappingExportsTests.cs new file mode 100644 index 0000000..02f2563 --- /dev/null +++ b/Sortify.Tests/OverlappingExportsTests.cs @@ -0,0 +1,186 @@ +using System.IO; +using Sortify.Models; +using Sortify.Services; +using Xunit; + +namespace Sortify.Tests; + +/// Loading files that repeat each other's plays counts every play once. +public class OverlappingExportsTests : IDisposable +{ + private readonly string _dir; + + public OverlappingExportsTests() + { + _dir = Path.Combine(Path.GetTempPath(), "SortifyOverlap_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_dir); + } + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch { } + } + + private string Write(string name, string json) + { + var path = Path.Combine(_dir, name); + File.WriteAllText(path, json); + return path; + } + + private static string Extended(string ts, string track = "Song", string artist = "Artist", int ms = 200_000) => $$""" + { "ts": "{{ts}}", "ms_played": {{ms}}, "master_metadata_track_name": "{{track}}", + "master_metadata_album_artist_name": "{{artist}}", "master_metadata_album_album_name": "Album", + "spotify_track_uri": "spotify:track:abc", "platform": "windows" } + """; + + private static string Legacy(string endTime, string track = "Song", string artist = "Artist", int ms = 200_000) => $$""" + { "endTime": "{{endTime}}", "artistName": "{{artist}}", "trackName": "{{track}}", "msPlayed": {{ms}} } + """; + + private static string Array(params string[] rows) => "[" + string.Join(",", rows) + "]"; + + private static Task Parse(params string[] paths) => new HistoryParser().ParseAsync(paths); + + [Fact] + public async Task TwoCopiesOfOneExport_CountEachPlayOnce() + { + var rows = Array(Extended("2023-05-01T10:00:00Z"), Extended("2023-05-01T10:04:00Z"), Extended("2023-05-02T09:00:00Z")); + var a = Write("Streaming_History_Audio_2023.json", rows); + Directory.CreateDirectory(Path.Combine(_dir, "copy")); + var b = Write(Path.Combine("copy", "Streaming_History_Audio_2023.json"), rows); + + var result = await Parse(a, b); + + Assert.Equal(3, result.Records.Count); + Assert.Equal(3, result.DuplicatesRemoved); + } + + [Fact] + public async Task ARowRepeatedInsideOneFile_IsLeftAlone() + { + var a = Write("Streaming_History_Audio_2023.json", + Array(Extended("2023-05-01T10:00:00Z"), Extended("2023-05-01T10:00:00Z"))); + var b = Write("Streaming_History_Audio_2024.json", Array(Extended("2024-01-01T10:00:00Z"))); + + var result = await Parse(a, b); + + Assert.Equal(3, result.Records.Count); + Assert.Equal(0, result.DuplicatesRemoved); + } + + [Fact] + public async Task ARepeatedRowKeepsTheMostCopiesOneFileHad() + { + var row = Extended("2023-05-01T10:00:00Z"); + var a = Write("a.json", Array(row, row)); + var b = Write("b.json", Array(row)); + + var result = await Parse(a, b); + + Assert.Equal(2, result.Records.Count); + Assert.Equal(1, result.DuplicatesRemoved); + } + + [Fact] + public async Task AccountDataPlaysTheExtendedHistoryHas_AreDropped() + { + var extended = Write("Streaming_History_Audio_2023.json", Array(Extended("2023-05-01T10:00:42Z"))); + var legacy = Write("StreamingHistory_music_0.json", Array(Legacy("2023-05-01 10:00"))); + + var result = await Parse(extended, legacy); + + var record = Assert.Single(result.Records); + Assert.Equal("spotify:track:abc", record.Uri); + Assert.Equal(1, result.DuplicatesRemoved); + } + + [Fact] + public async Task AnAccountDataTimeRoundedUpToTheNextMinute_StillMatches() + { + var extended = Write("Streaming_History_Audio_2023.json", Array(Extended("2023-05-01T10:00:42Z"))); + var legacy = Write("StreamingHistory_music_0.json", Array(Legacy("2023-05-01 10:01"))); + + var result = await Parse(extended, legacy); + + Assert.Single(result.Records); + } + + [Fact] + public async Task AccountDataNamesTheTrackArtist_WhereExtendedNamesTheAlbumArtist() + { + var extended = Write("Streaming_History_Audio_2023.json", + Array(Extended("2023-05-01T10:00:42Z", artist: "Various Artists"))); + var legacy = Write("StreamingHistory_music_0.json", + Array(Legacy("2023-05-01 10:00", artist: "The Real Artist"))); + + var result = await Parse(extended, legacy); + + Assert.Equal("Various Artists", Assert.Single(result.Records).ArtistName); + } + + [Fact] + public async Task AccountDataPlaysNewerThanTheExtendedHistory_AreKept() + { + var extended = Write("Streaming_History_Audio_2023.json", Array(Extended("2023-05-01T10:00:42Z"))); + var legacy = Write("StreamingHistory_music_0.json", + Array(Legacy("2023-05-01 10:00"), Legacy("2023-09-14 18:20", track: "Newer Song"))); + + var result = await Parse(extended, legacy); + + Assert.Equal(2, result.Records.Count); + Assert.Contains(result.Records, r => r.TrackName == "Newer Song"); + Assert.Equal(1, result.DuplicatesRemoved); + } + + [Fact] + public async Task EachExtendedPlayCoversOnlyOneAccountDataPlay() + { + var extended = Write("Streaming_History_Audio_2023.json", Array(Extended("2023-05-01T10:00:30Z", ms: 20_000))); + var legacy = Write("StreamingHistory_music_0.json", + Array(Legacy("2023-05-01 10:00", ms: 20_000), Legacy("2023-05-01 10:00", ms: 10_000))); + + var result = await Parse(extended, legacy); + + Assert.Equal(2, result.Records.Count); + } + + [Fact] + public async Task AccountDataPodcastsMatchTheirEpisode() + { + var extended = Write("Streaming_History_Audio_2023.json", """ + [ { "ts": "2023-05-01T10:30:10Z", "ms_played": 1800000, + "episode_name": "Episode 12", "episode_show_name": "The Show" } ] + """); + var legacy = Write("StreamingHistory_podcast_0.json", + Array(Legacy("2023-05-01 10:30", track: "Episode 12", artist: "The Show", ms: 1_800_000))); + + var result = await Parse(extended, legacy); + + Assert.Equal(ContentKind.Podcast, Assert.Single(result.Records).Kind); + } + + [Fact] + public async Task OnlyAccountData_IsLeftAlone() + { + var a = Write("StreamingHistory_music_0.json", Array(Legacy("2023-05-01 10:00"), Legacy("2023-05-01 10:04"))); + var b = Write("StreamingHistory_music_1.json", Array(Legacy("2023-06-01 10:00"))); + + var result = await Parse(a, b); + + Assert.Equal(3, result.Records.Count); + Assert.Equal(0, result.DuplicatesRemoved); + } + + [Fact] + public async Task UndatedRowsAreNeverTreatedAsDuplicates() + { + var row = """{ "ms_played": 1000, "master_metadata_track_name": "Song", "master_metadata_album_artist_name": "Artist" }"""; + var a = Write("a.json", Array(row)); + var b = Write("b.json", Array(row)); + + var result = await Parse(a, b); + + Assert.Equal(2, result.Records.Count); + } +} diff --git a/Sortify.Tests/PaletteContrastTests.cs b/Sortify.Tests/PaletteContrastTests.cs new file mode 100644 index 0000000..2e7bdc3 --- /dev/null +++ b/Sortify.Tests/PaletteContrastTests.cs @@ -0,0 +1,79 @@ +using System.Windows; +using System.Windows.Media; +using Sortify.Services; +using Xunit; + +namespace Sortify.Tests; + +/// +/// The accents do two jobs in each palette - fills (checkboxes, toggles, chart bars) and small +/// text (links, ranks, trend labels) - and the light theme needs a different colour for each. +/// These pin the WCAG thresholds that split exists for, so neither half can drift. +/// +[Collection(WpfCollection.Name)] +public class PaletteContrastTests +{ + private static Color Colour(string key) => ((SolidColorBrush)Application.Current.Resources[key]).Color; + + private static double Luminance(Color c) + { + static double Channel(byte v) + { + double s = v / 255.0; + return s <= 0.04045 ? s / 12.92 : Math.Pow((s + 0.055) / 1.055, 2.4); + } + + return 0.2126 * Channel(c.R) + 0.7152 * Channel(c.G) + 0.0722 * Channel(c.B); + } + + private static double Contrast(string a, string b) + { + double la = Luminance(Colour(a)), lb = Luminance(Colour(b)); + return (Math.Max(la, lb) + 0.05) / (Math.Min(la, lb) + 0.05); + } + + private static void AtLeast(double ratio, string foreground, string background) => + Assert.True(Contrast(foreground, background) >= ratio, + $"{foreground} on {background} is {Contrast(foreground, background):0.00}:1, needs {ratio}:1 ({ThemeService.Current})"); + + [Theory] + [InlineData(AppTheme.Dark)] + [InlineData(AppTheme.Light)] + public void AccentFillsStandOutAndCarryReadableText(AppTheme theme) + { + WpfTestHost.Run(() => + { + ThemeService.Apply(theme); + + // Controls and chart marks: 3:1 against the card they sit on. + AtLeast(3, "AccentBrush", "PanelBrush"); + AtLeast(3, "Accent2Brush", "PanelBrush"); + + // Button labels, the checked segment and the calendar's selected day sit on the fill. + AtLeast(4.5, "OnAccentBrush", "AccentBrush"); + AtLeast(4.5, "OnAccentBrush", "AccentHoverBrush"); + AtLeast(4.5, "OnAccentBrush", "Accent2Brush"); + + ThemeService.Apply(AppTheme.Dark); + }); + } + + [Theory] + [InlineData(AppTheme.Dark)] + [InlineData(AppTheme.Light)] + public void AccentTextIsReadableOnEverySurface(AppTheme theme) + { + WpfTestHost.Run(() => + { + ThemeService.Apply(theme); + + foreach (var surface in new[] { "PanelBrush", "BgBrush" }) + { + AtLeast(4.5, "AccentTextBrush", surface); + AtLeast(4.5, "Accent2TextBrush", surface); + } + + ThemeService.Apply(AppTheme.Dark); + }); + } +} diff --git a/Sortify.Tests/PeriodComparisonTests.cs b/Sortify.Tests/PeriodComparisonTests.cs new file mode 100644 index 0000000..5d86a9f --- /dev/null +++ b/Sortify.Tests/PeriodComparisonTests.cs @@ -0,0 +1,285 @@ +using System.Collections; +using Sortify.Models; +using Sortify.Services; +using Sortify.ViewModels; +using Xunit; + +namespace Sortify.Tests; + +/// Two date ranges of one history, side by side. +public class PeriodComparisonTests +{ + private static PlayRecord Play(string track, string artist, int minutes, DateTime when) => new() + { + TrackName = track, + ArtistName = artist, + AlbumName = "Album", + MsPlayed = minutes * 60_000, + Timestamp = when, + }; + + private static readonly DateTime A = new(2023, 6, 1, 12, 0, 0); + private static readonly DateTime B = new(2024, 6, 1, 12, 0, 0); + + private static readonly List Records = new() + { + Play("Rising", "Riser", 30, A), + Play("Big", "Steady", 300, A), + Play("Old Song", "Faded", 120, A), + Play("Rising", "Riser", 400, B), + Play("Big", "Steady", 200, B), + Play("Fresh", "Newcomer", 90, B), + Play("Blip", "Brief", 10, B), + }; + + private static Task Compare(FilterOptions? filter = null) => + PeriodComparison.CompareAsync(Records, filter ?? new FilterOptions(), + new DateTime(2023, 1, 1), new DateTime(2023, 12, 31), + new DateTime(2024, 1, 1), new DateTime(2024, 12, 31)); + + [Fact] + public async Task Artists_ShowTheirRankInEachPeriodAndHowTheyMoved() + { + var result = await Compare(); + + var riser = result.Artists.Single(r => r.Name == "Riser"); + Assert.Equal((3, 1), (riser.RankA, riser.RankB)); + Assert.Equal(Trend.Up, riser.Trend); + Assert.Equal("▲ 2", riser.Movement); + + var steady = result.Artists.Single(r => r.Name == "Steady"); + Assert.Equal("▼ 1", steady.Movement); + + Assert.Equal("new", result.Artists.Single(r => r.Name == "Newcomer").Movement); + Assert.Equal("out", result.Artists.Single(r => r.Name == "Faded").Movement); + } + + [Fact] + public async Task Artists_AreOrderedByPeriodBWithDropoutsLast() + { + var result = await Compare(); + + Assert.Equal(new[] { "Riser", "Steady", "Newcomer", "Brief", "Faded" }, result.Artists.Select(r => r.Name)); + } + + [Fact] + public async Task Tracks_KeepTheirArtist() + { + var result = await Compare(); + + var rising = result.Tracks.First(); + Assert.Equal(("Rising", "Riser"), (rising.Name, rising.Secondary)); + Assert.Equal(400, rising.HoursB * 60, 3); + } + + [Fact] + public async Task NewAndGone_NeedAnHourOfListening() + { + var result = await Compare(); + + Assert.Equal(new[] { "Newcomer" }, result.NewInB.Select(r => r.Name)); + Assert.Equal(new[] { "Faded" }, result.GoneSinceA.Select(r => r.Name)); + } + + [Fact] + public async Task TheLastDayOfAPeriodCountsInFull() + { + var late = new List(Records) { Play("Late", "Night Owl", 60, new DateTime(2024, 12, 31, 23, 30, 0)) }; + + var result = await PeriodComparison.CompareAsync(late, new FilterOptions(), + null, new DateTime(2023, 12, 31), new DateTime(2024, 1, 1), new DateTime(2024, 12, 31)); + + Assert.Contains(result.NewInB, r => r.Name == "Night Owl"); + } + + [Fact] + public async Task SidebarFiltersApplyToBothPeriods() + { + var filter = new FilterOptions(); + filter.ExcludedArtists.Add("Riser"); + + var result = await Compare(filter); + + Assert.DoesNotContain(result.Artists, r => r.Name == "Riser"); + Assert.Equal(3, result.B.UniqueArtists); + } + + [Fact] + public async Task Metrics_SayHowTheTotalsChanged() + { + var result = await Compare(); + var metrics = result.Metrics.ToDictionary(m => m.Metric); + + // A: 450 minutes, B: 700 minutes. + Assert.Equal("+56%", metrics["Listening time"].Change); + Assert.Equal(Trend.Up, metrics["Listening time"].Trend); + Assert.Equal("+33%", metrics["Plays"].Change); + Assert.Equal(("Steady", "Riser", "changed"), (metrics["Top artist"].A, metrics["Top artist"].B, metrics["Top artist"].Change)); + } + + [Fact] + public void Metrics_CallSomethingFromNothingNew() + { + var result = PeriodComparison.Build(AnalysisResult.Empty, AnalysisEngine.Analyze(Records, new FilterOptions())); + + Assert.Equal("new", result.Metrics.Single(m => m.Metric == "Plays").Change); + } + + [Fact] + public void WithDateRange_CopiesEveryOtherFilter() + { + var original = new FilterOptions + { + MinMsPlayed = 1234, + IncludePodcasts = true, + StartDate = new DateTime(2020, 1, 1), + EndDate = new DateTime(2020, 2, 1), + SearchTerm = "x", + StartHour = 3, + EndHour = 4, + Shuffle = PlaybackMode.Only, + Offline = PlaybackMode.Exclude, + Private = PlaybackMode.Exclude, + }; + original.ExcludedArtists.Add("a"); + original.ExcludedTracks.Add("t"); + original.ExcludedDevices.Add("Car"); + original.ExcludedCountries.Add("US"); + original.IncludedDaysOfWeek[2] = false; + + var copy = original.WithDateRange(new DateTime(2024, 1, 1), null); + + Assert.Equal(new DateTime(2024, 1, 1), copy.StartDate); + Assert.Null(copy.EndDate); + // Every other property, including any added later, has to come across. + foreach (var property in typeof(FilterOptions).GetProperties()) + { + if (property.Name is nameof(FilterOptions.StartDate) or nameof(FilterOptions.EndDate)) + continue; + var a = property.GetValue(original); + var b = property.GetValue(copy); + if (a is IEnumerable sequence and not string) + Assert.Equal(sequence.Cast(), ((IEnumerable)b!).Cast()); + else + Assert.Equal(a, b); + } + } +} + +/// The Compare tab's own behaviour. +public class CompareViewModelTests +{ + private static readonly DateTime LastListen = new(2026, 6, 29, 19, 0, 0); + + private static CompareViewModel Model(Func? source = null) + { + var vm = new CompareViewModel(source ?? (() => null)); + vm.SetHistory(LastListen, new[] { 2024, 2025, 2026 }, resetPeriods: true); + return vm; + } + + [Fact] + public void StartsWithTheLastTwelveMonthsAgainstTheTwelveBefore() + { + var vm = Model(); + + Assert.Equal((new DateTime(2025, 6, 30), new DateTime(2026, 6, 29)), (vm.StartB, vm.EndB)); + Assert.Equal((new DateTime(2024, 6, 30), new DateTime(2025, 6, 29)), (vm.StartA, vm.EndA)); + Assert.Equal("Period B: 2025-06-30 to 2026-06-29", vm.PeriodBLabel); + Assert.True(vm.RangesB.Single(r => r.Label == "Last 12 months").IsActive); + } + + [Fact] + public void ReloadingKeepsThePeriodsTheUserChose() + { + var vm = Model(); + vm.ApplyRangeBCommand.Execute(vm.RangesB.Single(r => r.Label == "2025")); + + vm.SetHistory(LastListen, new[] { 2024, 2025, 2026 }, resetPeriods: false); + + Assert.Equal(new DateTime(2025, 1, 1), vm.StartB); + } + + [Fact] + public void Swap_TradesThePeriods() + { + var vm = Model(); + var (a, b) = ((vm.StartA, vm.EndA), (vm.StartB, vm.EndB)); + + vm.SwapCommand.Execute(null); + + Assert.Equal(b, (vm.StartA, vm.EndA)); + Assert.Equal(a, (vm.StartB, vm.EndB)); + } + + [Fact] + public void MatchAToB_TakesTheSameNumberOfDaysJustBefore() + { + var vm = Model(); + vm.ApplyRangeBCommand.Execute(vm.RangesB.Single(r => r.Label == "Last 30 days")); + + vm.MatchAToBCommand.Execute(null); + + Assert.Equal((new DateTime(2026, 5, 1), new DateTime(2026, 5, 30)), (vm.StartA, vm.EndA)); + Assert.Equal((vm.EndB - vm.StartB), (vm.EndA - vm.StartA)); + } + + [Theory] + [InlineData("2025-01-01", "2025-12-31", "2024-01-01", "2024-12-31")] + [InlineData("2024-03-01", "2024-03-31", "2024-02-01", "2024-02-29")] + [InlineData("2025-04-01", "2025-06-30", "2025-01-01", "2025-03-31")] + public void MatchAToB_KeepsWholeMonthsWhole(string startB, string endB, string startA, string endA) + { + var vm = Model(); + vm.StartB = DateTime.Parse(startB, System.Globalization.CultureInfo.InvariantCulture); + vm.EndB = DateTime.Parse(endB, System.Globalization.CultureInfo.InvariantCulture); + + vm.MatchAToBCommand.Execute(null); + + Assert.Equal(DateTime.Parse(startA, System.Globalization.CultureInfo.InvariantCulture), vm.StartA); + Assert.Equal(DateTime.Parse(endA, System.Globalization.CultureInfo.InvariantCulture), vm.EndA); + } + + [Fact] + public void MatchAToB_NeedsBothEndsOfB() + { + var vm = Model(); + vm.ApplyRangeBCommand.Execute(vm.RangesB.Single(r => r.Label == "All time")); + + vm.MatchAToBCommand.Execute(null); + + Assert.Contains("Give period B a start and an end", vm.Message); + } + + [Fact] + public async Task ABackwardsPeriod_IsExplainedInsteadOfCompared() + { + var vm = Model(() => new CompareViewModel.Source(Array.Empty(), new FilterOptions(), TimeSpan.FromMinutes(30))); + vm.EndA = new DateTime(2020, 1, 1); + + await vm.RefreshAsync(); + + Assert.Contains("starts after it ends", vm.Message); + Assert.Empty(vm.Metrics); + } + + [Fact] + public async Task NothingIsWorkedOutWhileTheTabIsHidden() + { + int asked = 0; + var vm = Model(() => + { + asked++; + return new CompareViewModel.Source(Array.Empty(), new FilterOptions(), TimeSpan.FromMinutes(30)); + }); + + vm.Invalidate(); + Assert.Equal(0, asked); + + vm.IsVisible = true; + await Task.Delay(200); + + Assert.Equal(1, asked); + Assert.Equal("Neither period has any plays under the current filters.", vm.Message); + } +} diff --git a/Sortify.Tests/PlaybackFilterTests.cs b/Sortify.Tests/PlaybackFilterTests.cs new file mode 100644 index 0000000..9db2cee --- /dev/null +++ b/Sortify.Tests/PlaybackFilterTests.cs @@ -0,0 +1,204 @@ +using Sortify.Models; +using Sortify.Services; +using Sortify.ViewModels; +using Xunit; + +namespace Sortify.Tests; + +/// Filtering by shuffle, offline, private sessions, device and country. +public class PlaybackFilterTests +{ + private static PlayRecord Play( + string track, bool shuffle = false, bool offline = false, bool incognito = false, + string platform = "windows 10", string country = "CA", bool flagged = true) => new() + { + TrackName = track, + ArtistName = "Artist", + AlbumName = "Album", + MsPlayed = 60_000, + Timestamp = new DateTime(2023, 5, 10, 14, 0, 0), + Shuffle = shuffle, + Offline = offline, + Incognito = incognito, + Platform = platform, + Country = country, + HasPlaybackFlags = flagged, + }; + + // An account-data export row: no flags, no device, no country. + private static PlayRecord Legacy(string track) => Play(track, platform: "", country: "", flagged: false); + + private static List Kept(FilterOptions filter, params PlayRecord[] records) => + FilterEngine.Apply(records, filter).Select(r => r.TrackName).ToList(); + + [Fact] + public void ShuffleOnly_KeepsOnlyPlaysKnownToBeShuffled() + { + var kept = Kept(new FilterOptions { Shuffle = PlaybackMode.Only }, + Play("shuffled", shuffle: true), Play("in order"), Legacy("old")); + + Assert.Equal(new[] { "shuffled" }, kept); + } + + [Fact] + public void ShuffleLeftOut_KeepsPlaysThatNeverRecordedIt() + { + var kept = Kept(new FilterOptions { Shuffle = PlaybackMode.Exclude }, + Play("shuffled", shuffle: true), Play("in order"), Legacy("old")); + + Assert.Equal(new[] { "in order", "old" }, kept); + } + + [Fact] + public void OfflineAndPrivateFollowTheSameRules() + { + Assert.Equal(new[] { "offline" }, Kept(new FilterOptions { Offline = PlaybackMode.Only }, + Play("offline", offline: true), Play("online"))); + + Assert.Equal(new[] { "public" }, Kept(new FilterOptions { Private = PlaybackMode.Exclude }, + Play("private", incognito: true), Play("public"))); + } + + [Fact] + public void ADeviceLeftOut_DropsEveryPlatformInItsFamily() + { + var filter = new FilterOptions(); + filter.ExcludedDevices.Add("Mobile"); + + var kept = Kept(filter, + Play("phone", platform: "Android OS 13 API 33 (Google, Pixel 7)"), + Play("tablet", platform: "iOS 17.1 (iPad13,4)"), + Play("desktop", platform: "windows 10 (10.0.19045; x64)"), + Legacy("old")); + + Assert.Equal(new[] { "desktop", "old" }, kept); + } + + [Fact] + public void ACountryLeftOut_DropsItsPlaysButNotUnknownOnes() + { + var filter = new FilterOptions(); + filter.ExcludedCountries.Add("us"); + + var kept = Kept(filter, Play("home"), Play("trip", country: "US"), Legacy("old")); + + Assert.Equal(new[] { "home", "old" }, kept); + } + + private static FilterViewModel WithChoices() + { + var vm = new FilterViewModel(); + vm.SetAvailablePlayback( + new[] { ("Desktop", "Desktop", "Desktop"), ("Mobile", "Mobile", "Mobile") }, + new[] { ("CA", "CA", "Canada (CA)"), ("US", "US", "United States (US)") }); + return vm; + } + + [Fact] + public void UntickingADevice_LeavesItOut() + { + var vm = WithChoices(); + int changes = 0; + vm.FiltersChanged += (_, _) => changes++; + + vm.Devices.Single(d => d.Key == "Mobile").IsSelected = false; + + Assert.Equal(1, changes); + Assert.Equal(new[] { "Mobile" }, vm.ToOptions().ExcludedDevices); + Assert.Contains("1 device left out", vm.Describe()); + } + + [Fact] + public void TheDeviceChip_TicksEverythingBackOn() + { + var vm = WithChoices(); + vm.Devices[0].IsSelected = false; + vm.Devices[1].IsSelected = false; + + vm.Chips().Single(c => c.Text == "2 devices left out").ClearCommand.Execute(null); + + Assert.All(vm.Devices, d => Assert.True(d.IsSelected)); + Assert.Empty(vm.ToOptions().ExcludedDevices); + } + + [Fact] + public void ModeChips_DescribeAndClearTheirMode() + { + var vm = new FilterViewModel { ShuffleMode = PlaybackMode.Only, PrivateMode = PlaybackMode.Exclude }; + + Assert.Equal(new[] { "Shuffled plays only", "No private sessions" }, vm.Describe()); + + vm.Chips().Single(c => c.Text == "No private sessions").ClearCommand.Execute(null); + + Assert.Equal(PlaybackMode.Any, vm.PrivateMode); + Assert.Equal(PlaybackMode.Only, vm.ShuffleMode); + } + + [Fact] + public void ACountryLeftOutStaysLeftOut_WhenAnotherHistoryIsLoaded() + { + var vm = WithChoices(); + vm.Countries.Single(c => c.Key == "US").IsSelected = false; + + vm.SetAvailablePlayback( + new[] { ("Desktop", "Desktop", "Desktop") }, + new[] { ("US", "US", "United States (US)"), ("GB", "GB", "United Kingdom (GB)") }); + + Assert.False(vm.Countries.Single(c => c.Key == "US").IsSelected); + Assert.True(vm.Countries.Single(c => c.Key == "GB").IsSelected); + } + + [Fact] + public void Presets_CarryThePlaybackFilters() + { + var vm = WithChoices(); + vm.ShuffleMode = PlaybackMode.Exclude; + vm.Countries.Single(c => c.Key == "US").IsSelected = false; + var preset = vm.Snapshot("Home only"); + + var other = WithChoices(); + other.Apply(preset); + + Assert.Equal(PlaybackMode.Exclude, other.ShuffleMode); + Assert.False(other.Countries.Single(c => c.Key == "US").IsSelected); + Assert.True(other.Snapshot().SameFiltersAs(preset)); + } + + [Fact] + public void Remembered_KeepsWhatToLeaveOutButNotShuffleOrOffline() + { + var vm = WithChoices(); + vm.ShuffleMode = PlaybackMode.Only; + vm.OfflineMode = PlaybackMode.Exclude; + vm.PrivateMode = PlaybackMode.Exclude; + vm.Devices.Single(d => d.Key == "Mobile").IsSelected = false; + + var remembered = vm.RememberedSnapshot(); + + Assert.Equal(PlaybackMode.Any, remembered.Shuffle); + Assert.Equal(PlaybackMode.Any, remembered.Offline); + Assert.Equal(PlaybackMode.Exclude, remembered.Private); + Assert.Equal(new[] { "Mobile" }, remembered.ExcludedDevices); + } + + [Fact] + public void Reset_CountsEveryPlayAgain() + { + var vm = WithChoices(); + vm.OfflineMode = PlaybackMode.Only; + vm.Countries[0].IsSelected = false; + + vm.ResetCommand.Execute(null); + + Assert.Empty(vm.Describe()); + Assert.All(vm.Countries, c => Assert.True(c.IsSelected)); + } + + [Theory] + [InlineData("CA", "Canada (CA)")] + [InlineData("zz", "ZZ")] + public void CountryName_FallsBackToTheCode(string code, string expected) + { + Assert.Equal(expected, MainViewModel.CountryName(code)); + } +} diff --git a/Sortify.Tests/PodcastDetailTests.cs b/Sortify.Tests/PodcastDetailTests.cs new file mode 100644 index 0000000..10c2845 --- /dev/null +++ b/Sortify.Tests/PodcastDetailTests.cs @@ -0,0 +1,91 @@ +using Sortify.Models; +using Sortify.Services; +using Sortify.Views; +using Xunit; + +namespace Sortify.Tests; + +/// Breakdowns opened from the Podcasts tab, whatever the podcasts setting says. +public class PodcastDetailTests +{ + // The default: podcasts kept out of the music statistics. + private static readonly FilterOptions Default = new() { MinMsPlayed = 0 }; + + private static PlayRecord Episode(string show, string episode, ContentKind kind = ContentKind.Podcast, + int ms = 600_000, string uri = "") => new() + { + Kind = kind, + ShowName = show, + EpisodeName = episode, + MsPlayed = ms, + Uri = uri, + Timestamp = new DateTime(2024, 3, 4, 9, 0, 0), + }; + + private static readonly PlayRecord[] Records = + { + Episode("Radiolab", "Bit Flip", uri: "spotify:episode:bitflip"), + Episode("Radiolab", "Bit Flip"), + Episode("Radiolab", "Stochasticity"), + Episode("Other Show", "Bit Flip"), + new() { TrackName = "Song", ArtistName = "Radiolab", MsPlayed = 180_000, Timestamp = new DateTime(2024, 3, 4, 9, 0, 0) }, + }; + + [Fact] + public void AShow_CollectsItsEpisodesEvenWhenPodcastsAreNotCounted() + { + var detail = DetailEngine.Build(Records, Default, DetailScope.Show, "Radiolab", string.Empty); + + Assert.Equal(3, detail.PlayCount); + Assert.Equal(new[] { "Bit Flip", "Stochasticity" }, detail.Tracks.Select(t => t.Track)); + Assert.Equal(ContentKind.Podcast, detail.Kind); + } + + [Fact] + public void AnEpisode_MatchesOnItsShowToo() + { + var detail = DetailEngine.Build(Records, Default, DetailScope.Episode, "Bit Flip", "Radiolab"); + + Assert.Equal(2, detail.PlayCount); + Assert.Equal("https://open.spotify.com/episode/bitflip", detail.WebUrl); + } + + [Fact] + public void AShowOpensThroughSearch() + { + var detail = DetailEngine.Build(Records, Default, DetailScope.Show, "Radiolab", string.Empty); + + Assert.Equal("https://open.spotify.com/search/Radiolab", detail.WebUrl); + } + + [Fact] + public void AnAudiobookIsCalledOne() + { + var records = new[] { Episode("The Hobbit", "Chapter 1", ContentKind.Audiobook) }; + + var book = DetailEngine.Build(records, Default, DetailScope.Show, "The Hobbit", string.Empty); + var chapter = DetailEngine.Build(records, Default, DetailScope.Episode, "Chapter 1", "The Hobbit"); + + Assert.Equal("Audiobook", DetailWindow.ScopeLabel(book)); + Assert.Equal("Chapter", DetailWindow.ScopeLabel(chapter)); + Assert.Equal("Podcast", DetailWindow.ScopeLabel(new DetailResult { Scope = DetailScope.Show, Title = "x", Kind = ContentKind.Podcast })); + } + + [Fact] + public void ShowAndEpisodeRowsOpenTheirOwnBreakdowns() + { + Assert.Equal((DetailScope.Show, "Radiolab", ""), DetailWindow.TargetFor(new ShowStat { Show = "Radiolab" })); + Assert.Equal((DetailScope.Episode, "Bit Flip", "Radiolab"), + DetailWindow.TargetFor(new EpisodeStat { Episode = "Bit Flip", Show = "Radiolab" })); + } + + [Fact] + public void FiltersStillApply() + { + var filter = new FilterOptions { MinMsPlayed = 0, EndDate = new DateTime(2024, 1, 1) }; + + var detail = DetailEngine.Build(Records, filter, DetailScope.Show, "Radiolab", string.Empty); + + Assert.Equal(0, detail.PlayCount); + } +} diff --git a/Sortify.Tests/QuickRangeTests.cs b/Sortify.Tests/QuickRangeTests.cs new file mode 100644 index 0000000..158a419 --- /dev/null +++ b/Sortify.Tests/QuickRangeTests.cs @@ -0,0 +1,87 @@ +using Sortify.ViewModels; +using Xunit; + +namespace Sortify.Tests; + +/// The one-click date ranges under the date pickers. +public class QuickRangeTests +{ + private static readonly DateTime LastListen = new(2025, 6, 29, 21, 15, 0); + + [Fact] + public void For_CountsBackFromTheLastListenNotFromToday() + { + var options = DateRangeOption.For(LastListen, new[] { 2024, 2025 }); + + var month = options.Single(o => o.Label == "Last 30 days"); + Assert.Equal(new DateTime(2025, 5, 31), month.Start); + Assert.Equal(new DateTime(2025, 6, 29), month.End); + + var year = options.Single(o => o.Label == "Last 12 months"); + Assert.Equal(new DateTime(2024, 6, 30), year.Start); + Assert.Equal(new DateTime(2025, 6, 29), year.End); + } + + [Fact] + public void For_OffersEveryYearOnceInOrder() + { + var options = DateRangeOption.For(LastListen, new[] { 2025, 2023, 2024, 2023 }); + + Assert.Equal(new[] { "All time", "Last 30 days", "Last 12 months", "2023", "2024", "2025" }, + options.Select(o => o.Label)); + var y2024 = options.Single(o => o.Label == "2024"); + Assert.Equal(new DateTime(2024, 1, 1), y2024.Start); + Assert.Equal(new DateTime(2024, 12, 31), y2024.End); + } + + [Fact] + public void For_OffersNothingWithoutDatedPlays() + { + Assert.Empty(DateRangeOption.For(null, Array.Empty())); + } + + [Fact] + public void Applying_SetsBothDatesInOneChange() + { + var vm = new FilterViewModel(); + vm.SetAvailableDates(LastListen, new[] { 2024, 2025 }); + int changes = 0; + vm.FiltersChanged += (_, _) => changes++; + + vm.ApplyQuickRangeCommand.Execute(vm.QuickRanges.Single(o => o.Label == "2024")); + + Assert.Equal(new DateTime(2024, 1, 1), vm.StartDate); + Assert.Equal(new DateTime(2024, 12, 31), vm.EndDate); + Assert.Equal(1, changes); + Assert.Equal(new[] { "2024-01-01 to 2024-12-31" }, vm.Describe()); + } + + [Fact] + public void TheRangeInEffectIsMarked_HoweverItWasSet() + { + var vm = new FilterViewModel(); + vm.SetAvailableDates(LastListen, new[] { 2024, 2025 }); + Assert.True(vm.QuickRanges.Single(o => o.Label == "All time").IsActive); + + vm.StartDate = new DateTime(2025, 1, 1); + vm.EndDate = new DateTime(2025, 12, 31); + + Assert.Equal(new[] { "2025" }, vm.QuickRanges.Where(o => o.IsActive).Select(o => o.Label)); + + vm.EndDate = new DateTime(2025, 12, 30); + + Assert.DoesNotContain(vm.QuickRanges, o => o.IsActive); + } + + [Fact] + public void AllTime_ClearsTheRange() + { + var vm = new FilterViewModel { StartDate = new DateTime(2024, 1, 1) }; + vm.SetAvailableDates(LastListen, new[] { 2024 }); + + vm.ApplyQuickRangeCommand.Execute(vm.QuickRanges[0]); + + Assert.Null(vm.StartDate); + Assert.Null(vm.EndDate); + } +} diff --git a/Sortify.Tests/SpotifyLinkTests.cs b/Sortify.Tests/SpotifyLinkTests.cs new file mode 100644 index 0000000..c4c087d --- /dev/null +++ b/Sortify.Tests/SpotifyLinkTests.cs @@ -0,0 +1,123 @@ +using Sortify.Models; +using Sortify.Services; +using Sortify.ViewModels; +using Xunit; + +namespace Sortify.Tests; + +/// Copying tracks as links that paste into a Spotify playlist. +public class SpotifyLinkTests +{ + private static TrackStat Track(string name, string uri) => new() { Track = name, Artist = "Artist", Uri = uri }; + + [Theory] + [InlineData("spotify:track:4uLU6hMCjMI75M1A2tKUQC", "https://open.spotify.com/track/4uLU6hMCjMI75M1A2tKUQC")] + [InlineData("spotify:episode:abc", "https://open.spotify.com/episode/abc")] + [InlineData("SPOTIFY:track:abc", "https://open.spotify.com/track/abc")] + [InlineData("spotify:track:a b&c", "https://open.spotify.com/track/a%20b%26c")] + [InlineData("spotify:track:", "")] + [InlineData("spotify:local:Artist:Album:Title:215", "")] + [InlineData("https://example.com", "")] + [InlineData("", "")] + public void FromUri_BuildsAnOpenSpotifyLink(string uri, string expected) + { + Assert.Equal(expected, SpotifyLink.FromUri(uri)); + } + + [Fact] + public void ForTracks_PutsOneLinkPerLineInOrderWithoutRepeats() + { + var links = SpotifyLink.ForTracks(new[] + { + Track("One", "spotify:track:1"), + Track("Old", ""), + Track("Two", "spotify:track:2"), + Track("One again", "spotify:track:1"), + }); + + Assert.Equal("https://open.spotify.com/track/1\nhttps://open.spotify.com/track/2", links.Text); + Assert.Equal(2, links.Copied); + Assert.Equal(1, links.Missing); + } + + [Fact] + public void Analysis_KeepsAUriForEachTrack() + { + var records = new[] + { + new PlayRecord { TrackName = "Song", ArtistName = "A", MsPlayed = 60_000, Timestamp = new DateTime(2023, 1, 1, 10, 0, 0) }, + new PlayRecord { TrackName = "Song", ArtistName = "A", MsPlayed = 60_000, Timestamp = new DateTime(2023, 1, 2, 10, 0, 0), Uri = "spotify:track:xyz" }, + }; + + var result = AnalysisEngine.Analyze(records, new FilterOptions()); + + Assert.Equal("spotify:track:xyz", Assert.Single(result.Tracks).Uri); + } + + [Fact] + public void ExcludingSeveralRows_IsOneChange() + { + var vm = new FilterViewModel(); + int changes = 0; + vm.FiltersChanged += (_, _) => changes++; + + vm.ExcludeArtists(new[] { "A", "B", "a" }); + + Assert.Equal(1, changes); + Assert.Equal(new[] { "A", "B" }, vm.ExcludedArtists); + } +} + +[Collection(WpfCollection.Name)] +public class CopyLinksStatusTests +{ + private static string Copy(IEnumerable tracks, bool clipboardWorks, out string? copied) + { + string? text = null; + string status = string.Empty; + WpfTestHost.Run(() => + { + var vm = new MainViewModel(new AppSettings()); + vm.CopySpotifyLinks(tracks, t => + { + text = t; + return clipboardWorks; + }); + status = vm.StatusText; + }); + copied = text; + return status; + } + + [Fact] + public void SaysHowManyWereCopiedAndWhy() + { + var status = Copy(new[] + { + new TrackStat { Track = "One", Artist = "A", Uri = "spotify:track:1" }, + new TrackStat { Track = "Old", Artist = "A" }, + }, clipboardWorks: true, out var copied); + + Assert.Equal("https://open.spotify.com/track/1", copied); + Assert.Equal("Copied 1 Spotify link. Paste it into a playlist in the Spotify desktop app. " + + "1 track has no link in the export and was left out.", status); + } + + [Fact] + public void ExplainsWhenNoTrackHasALink() + { + var status = Copy(new[] { new TrackStat { Track = "Old", Artist = "A" } }, clipboardWorks: true, out var copied); + + Assert.Null(copied); + Assert.Contains("Only the extended streaming history records them", status); + } + + [Fact] + public void SaysSoWhenTheClipboardIsBusy() + { + var status = Copy(new[] { new TrackStat { Track = "One", Artist = "A", Uri = "spotify:track:1" } }, + clipboardWorks: false, out _); + + Assert.Contains("another program is holding the clipboard", status); + } +} diff --git a/Sortify.Tests/ThemedWindowTests.cs b/Sortify.Tests/ThemedWindowTests.cs new file mode 100644 index 0000000..d1d943a --- /dev/null +++ b/Sortify.Tests/ThemedWindowTests.cs @@ -0,0 +1,144 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Shell; +using Sortify.Models; +using Sortify.Services; +using Sortify.Views; +using Xunit; + +namespace Sortify.Tests; + +/// The title bar every window draws in place of the white one Windows paints. +[Collection(WpfCollection.Name)] +public class ThemedWindowTests +{ + private static DetailResult SampleDetail() => new() + { + Scope = DetailScope.Artist, + Title = "Band", + TotalMsPlayed = 120_000, + PlayCount = 2, + }; + + private static T Part(Window window, string name) where T : FrameworkElement => + Assert.IsAssignableFrom(window.Template.FindName(name, window)); + + [Fact] + public void EveryWindowDrawsItsOwnTitleBar() + { + WpfTestHost.Run(() => + { + foreach (var window in new ThemedWindow[] { new MainWindow(), new SettingsWindow(new AppSettings()), new DetailWindow(SampleDetail()) }) + { + window.ApplyTemplate(); + + Assert.NotNull(WindowChrome.GetWindowChrome(window)); + Assert.NotNull(window.Icon); + Assert.Equal(Visibility.Visible, Part