diff --git a/Source/AudibleUtilities/Mkb79Auth.cs b/Source/AudibleUtilities/Mkb79Auth.cs
index 4731a806..e58e6041 100644
--- a/Source/AudibleUtilities/Mkb79Auth.cs
+++ b/Source/AudibleUtilities/Mkb79Auth.cs
@@ -237,6 +237,12 @@ public partial class Mkb79Auth
return account;
}
+ ///
+ /// The exported file names one marketplace - the one this account is registered with - because that is all
+ /// the format holds: a single locale_code alongside a single device registration. audible-cli switches
+ /// marketplaces on its own from those same tokens, so nothing is lost to it. Any additional marketplaces
+ /// Libation reads for this account are its own bookkeeping and have no slot here.
+ ///
public static Mkb79Auth FromAccount(Account account)
=> new()
{
diff --git a/Source/AudibleUtilities/Mkb79AuthImporter.cs b/Source/AudibleUtilities/Mkb79AuthImporter.cs
index 82b21ccb..a4227dba 100644
--- a/Source/AudibleUtilities/Mkb79AuthImporter.cs
+++ b/Source/AudibleUtilities/Mkb79AuthImporter.cs
@@ -1,4 +1,4 @@
-using System.Linq;
+using System;
using System.Threading.Tasks;
namespace AudibleUtilities;
@@ -10,10 +10,37 @@ public enum Mkb79ImportOutcome
InvalidFile,
}
-public sealed record Mkb79ImportResult(Mkb79ImportOutcome Outcome, Account? Account = null, string? Message = null);
+///
+/// For , the account already scanning that marketplace. It may
+/// be one registered with it, or one carrying it as an additional marketplace.
+///
+public sealed record Mkb79ImportResult(
+ Mkb79ImportOutcome Outcome,
+ Account? Account = null,
+ string? Message = null,
+ Account? ClaimedBy = null);
public static class Mkb79AuthImporter
{
+ ///
+ /// Why a duplicate import was refused, in the same words everywhere it is refused. Naming the account that
+ /// already reads the marketplace matters now that it need not be a row registered with it - it may be one
+ /// reading it as an additional marketplace, which is not obvious from the accounts grid.
+ ///
+ public static string DuplicateMessage(Mkb79ImportResult result)
+ {
+ var locale = result.Account?.Locale?.Name ?? "[unknown]";
+
+ if (result.ClaimedBy is { } claimedBy && claimedBy.Locale?.Name != locale)
+ return $"The '{locale}' marketplace is already scanned by the account "
+ + $"{AccountCredentialStatus.FormatAccountLabel(claimedBy)}, as an additional marketplace. "
+ + "Nothing was imported.";
+
+ return "An account with that account id and country already exists."
+ + $"{Environment.NewLine}Account ID: {result.Account?.AccountId}"
+ + $"{Environment.NewLine}Country: {locale}";
+ }
+
///
/// Deserialize mkb79/audible-cli JSON, refresh tokens, and add the account if not already present.
///
@@ -32,11 +59,12 @@ public static class Mkb79AuthImporter
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
- if (persister.AccountsSettings.Accounts.Any(a =>
- a.AccountId == account.AccountId && a.IdentityTokens?.Locale.Name == account.Locale?.Name))
- {
- return new Mkb79ImportResult(Mkb79ImportOutcome.DuplicateAccount, account);
- }
+ // An mkb79 file names one marketplace, and a marketplace can only be scanned by one account. Ask about
+ // every claim on it, not just registrations: an existing account may already be reading this marketplace
+ // as an additional one, in which case importing would scan it twice.
+ var claimedBy = persister.AccountsSettings.GetAccountClaimingMarketplace(account.AccountId, account.Locale?.Name);
+ if (claimedBy is not null)
+ return new Mkb79ImportResult(Mkb79ImportOutcome.DuplicateAccount, account, ClaimedBy: claimedBy);
persister.AccountsSettings.Add(account);
return new Mkb79ImportResult(Mkb79ImportOutcome.Success, account);
diff --git a/Source/LibationAvalonia/Dialogs/AccountsDialog.axaml b/Source/LibationAvalonia/Dialogs/AccountsDialog.axaml
index c0cf27b4..1dc81128 100644
--- a/Source/LibationAvalonia/Dialogs/AccountsDialog.axaml
+++ b/Source/LibationAvalonia/Dialogs/AccountsDialog.axaml
@@ -87,6 +87,23 @@
+
+
+
+
+
+
+
+
+
+
string.IsNullOrEmpty(AccountId);
+ ///
+ /// Marketplaces beyond that this account should also scan. Edited by the
+ /// marketplaces dialog and written on save, like every other field here.
+ ///
+ public List AdditionalLocaleNames { get; } = new();
+
public bool CanExport
{
get => field;
@@ -56,6 +63,17 @@ public partial class AccountsDialog : DialogWindow
? "Export account authorization to audible-cli"
: "Authenticate this account (e.g. library scan) before exporting to audible-cli.";
+ ///
+ /// Checking other marketplaces uses this account's stored credentials, so it needs the same thing an
+ /// export does: an account that has logged in at least once.
+ ///
+ public bool CanCheckMarketplaces => CanExport;
+
+ public string MarketplacesButtonText => MarketplacesUi.ButtonText(AdditionalLocaleNames.Count + 1);
+
+ public string MarketplacesButtonToolTip
+ => CanCheckMarketplaces ? MarketplacesUi.ButtonToolTip : MarketplacesUi.NotAuthenticatedToolTip;
+
public AccountDto() => RefreshCanExport();
public AccountDto(Account account)
@@ -64,13 +82,28 @@ public partial class AccountsDialog : DialogWindow
AccountId = account.AccountId;
SelectedLocale = Locales.Single(l => l.Name == account.Locale?.Name);
AccountName = account.AccountName;
+ AdditionalLocaleNames.AddRange(account.AdditionalLocales.Select(l => l.Name));
RefreshCanExportFromAccount(account);
}
+ public void SetAdditionalLocaleNames(IEnumerable localeNames)
+ {
+ AdditionalLocaleNames.Clear();
+ AdditionalLocaleNames.AddRange(localeNames);
+ this.RaisePropertyChanged(nameof(MarketplacesButtonText));
+ }
+
private void RefreshCanExportFromAccount(Account account)
{
CanExport = account.IdentityTokens?.IsValid == true;
+ RaiseDerivedFromCanExport();
+ }
+
+ private void RaiseDerivedFromCanExport()
+ {
this.RaisePropertyChanged(nameof(ExportButtonToolTip));
+ this.RaisePropertyChanged(nameof(CanCheckMarketplaces));
+ this.RaisePropertyChanged(nameof(MarketplacesButtonToolTip));
}
private void RefreshCanExport()
@@ -78,7 +111,7 @@ public partial class AccountsDialog : DialogWindow
if (string.IsNullOrEmpty(AccountId) || SelectedLocale is null)
{
CanExport = false;
- this.RaisePropertyChanged(nameof(ExportButtonToolTip));
+ RaiseDerivedFromCanExport();
return;
}
@@ -86,7 +119,7 @@ public partial class AccountsDialog : DialogWindow
var account = persister.AccountsSettings.Accounts.FirstOrDefault(a =>
a.AccountId == AccountId && a.Locale?.Name == SelectedLocale.Name);
CanExport = account?.IdentityTokens?.IsValid == true;
- this.RaisePropertyChanged(nameof(ExportButtonToolTip));
+ RaiseDerivedFromCanExport();
}
}
@@ -174,9 +207,9 @@ public partial class AccountsDialog : DialogWindow
return;
}
- if (importResult.Outcome is Mkb79ImportOutcome.DuplicateAccount && importResult.Account is { } dup)
+ if (importResult.Outcome is Mkb79ImportOutcome.DuplicateAccount && importResult.Account is not null)
{
- await MessageBox.Show(this, $"An account with that account id and country already exists.\r\n\r\nAccount ID: {dup.AccountId}\r\nCountry: {dup.Locale?.Name}", "Cannot Add Duplicate Account");
+ await MessageBox.Show(this, Mkb79AuthImporter.DuplicateMessage(importResult), "Cannot Add Duplicate Account");
return;
}
@@ -199,6 +232,29 @@ public partial class AccountsDialog : DialogWindow
Export(acc);
}
+ public async void MarketplacesButton_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (e.Source is not Button btn || btn.DataContext is not AccountDto acc)
+ return;
+
+ // the probe speaks to Audible with this account's stored credentials, so it needs the saved account,
+ // not the grid's copy of it
+ using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
+ var account = persister.AccountsSettings.Accounts.FirstOrDefault(a =>
+ a.AccountId == acc.AccountId && a.Locale?.Name == acc.SelectedLocale?.Name);
+
+ if (account is null || account.IdentityTokens?.IsValid != true)
+ {
+ await MessageBox.Show(this, MarketplacesUi.NotAuthenticatedToolTip, "Account Not Authenticated");
+ return;
+ }
+
+ var dialog = new MarketplacesDialog(account, persister.AccountsSettings, acc.AdditionalLocaleNames);
+
+ if (await dialog.ShowDialog(this) == DialogResult.OK)
+ acc.SetAdditionalLocaleNames(dialog.SelectedAdditionalLocaleNames);
+ }
+
protected override async Task SaveAndCloseAsync()
{
try
@@ -246,6 +302,7 @@ public partial class AccountsDialog : DialogWindow
}
// upsert each. validation occurs through Account and AccountsSettings
+ var upserted = new List<(AccountDto Dto, Account Account)>();
foreach (var dto in Accounts.Where(a => a.AccountId is not null))
{
var acct = accountsSettings.Upsert(dto.AccountId!, dto.SelectedLocale?.Name);
@@ -254,7 +311,15 @@ public partial class AccountsDialog : DialogWindow
= string.IsNullOrWhiteSpace(dto.AccountName)
? $"{dto.AccountId} - {dto.SelectedLocale?.Name}"
: dto.AccountName.Trim();
+
+ // drop every marketplace before assigning any, so that moving one from one account to another in a
+ // single sitting cannot trip the "no two accounts scan one marketplace" rule halfway through
+ acct.SetAdditionalMarketplaces([]);
+ upserted.Add((dto, acct));
}
+
+ foreach (var (dto, acct) in upserted)
+ acct.SetAdditionalMarketplaces(dto.AdditionalLocaleNames);
}
private async Task inputIsValid()
{
diff --git a/Source/LibationAvalonia/Dialogs/MarketplacesDialog.axaml b/Source/LibationAvalonia/Dialogs/MarketplacesDialog.axaml
new file mode 100644
index 00000000..17023ff5
--- /dev/null
+++ b/Source/LibationAvalonia/Dialogs/MarketplacesDialog.axaml
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Source/LibationAvalonia/Dialogs/MarketplacesDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/MarketplacesDialog.axaml.cs
new file mode 100644
index 00000000..599a215d
--- /dev/null
+++ b/Source/LibationAvalonia/Dialogs/MarketplacesDialog.axaml.cs
@@ -0,0 +1,148 @@
+using AudibleApi;
+using AudibleUtilities;
+using Avalonia.Collections;
+using LibationUiBase;
+using ReactiveUI;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace LibationAvalonia.Dialogs;
+
+///
+/// Which Audible marketplaces one account should read. Opened from the accounts grid, and only for an account
+/// that has already logged in - the check is made with that account's own credentials.
+///
+public partial class MarketplacesDialog : DialogWindow
+{
+ public string Intro => MarketplacesUi.Intro;
+ public string CheckButtonText => MarketplacesUi.CheckButton;
+ public string AccountLabel { get; } = "";
+
+ public AvaloniaList Marketplaces { get; } = new();
+
+ /// The additional marketplaces the user checked. The registered one is never among them.
+ public IReadOnlyList SelectedAdditionalLocaleNames
+ => Marketplaces
+ .Where(m => m.IsChecked && m.CanCheck)
+ .Select(m => m.Locale.Name)
+ .ToList();
+
+ public class ListItem : ViewModels.ViewModelBase
+ {
+ public ListItem(Locale locale, string text, bool isChecked, bool canCheck, string? toolTip = null)
+ {
+ Locale = locale;
+ Text = text;
+ IsChecked = isChecked;
+ CanCheck = canCheck;
+ ToolTip = toolTip;
+ }
+
+ public Locale Locale { get; }
+ public string Text
+ {
+ get => field;
+ set => this.RaiseAndSetIfChanged(ref field, value);
+ }
+ public bool IsChecked
+ {
+ get => field;
+ set => this.RaiseAndSetIfChanged(ref field, value);
+ }
+ public bool CanCheck
+ {
+ get => field;
+ set => this.RaiseAndSetIfChanged(ref field, value);
+ }
+ public string? ToolTip { get; }
+ public override string ToString() => Text;
+ }
+
+ private readonly Account? account;
+ private readonly AccountsSettings? accountsSettings;
+
+ // parameterless ctor for the axaml designer
+ public MarketplacesDialog()
+ {
+ InitializeComponent();
+ DataContext = this;
+ }
+
+ ///
+ /// What the accounts grid currently shows for this account, which may not yet be saved.
+ ///
+ public MarketplacesDialog(Account account, AccountsSettings accountsSettings, IEnumerable selectedAdditionalLocaleNames)
+ {
+ InitializeComponent();
+
+ this.account = account;
+ this.accountsSettings = accountsSettings;
+
+ AccountLabel = AccountCredentialStatus.FormatAccountLabel(account);
+
+ var selected = selectedAdditionalLocaleNames.ToHashSet();
+
+ // list every candidate up front, so the dialog is a full picture before anything is asked of Audible
+ foreach (var locale in MarketplaceProbe.CandidateLocales(account))
+ {
+ var isRegistered = locale.Name == account.Locale?.Name;
+
+ Marketplaces.Add(new ListItem(
+ locale,
+ isRegistered
+ ? $"{locale.Name} - this account's own marketplace"
+ : locale.Name,
+ isChecked: isRegistered || selected.Contains(locale.Name),
+ canCheck: !isRegistered,
+ toolTip: isRegistered ? "Always scanned. This is where the account is registered." : null));
+ }
+
+ StatusTextBlock.Text = MarketplacesUi.ButtonToolTip;
+ ControlToFocusOnShow = CheckButton;
+ DataContext = this;
+ }
+
+ public async void CheckButton_Clicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ => await ProbeAsync();
+
+ public async Task ProbeAsync()
+ {
+ if (account is null || accountsSettings is null)
+ return;
+
+ CheckButton.IsEnabled = false;
+ StatusTextBlock.Text = MarketplacesUi.Checking;
+
+ var results = new List();
+
+ try
+ {
+ await foreach (var result in MarketplaceProbe.ProbeAsync(account, accountsSettings))
+ {
+ results.Add(result);
+
+ if (Marketplaces.FirstOrDefault(m => m.Locale.Name == result.Locale.Name) is not { } item)
+ continue;
+
+ item.Text = MarketplacesUi.ResultText(result);
+
+ // a marketplace another account already scans must not be checkable here: two rows scanning one
+ // marketplace would import it twice
+ if (result.Outcome is MarketplaceProbeOutcome.ScannedByAnotherAccount or MarketplaceProbeOutcome.Failed)
+ item.CanCheck = false;
+
+ if (result.Outcome is MarketplaceProbeOutcome.TitlesFound)
+ item.IsChecked = true;
+ }
+
+ StatusTextBlock.Text = MarketplacesUi.Summary(results);
+ }
+ finally
+ {
+ CheckButton.IsEnabled = true;
+ }
+ }
+
+ public new void SaveAndClose() => base.SaveAndClose();
+}
diff --git a/Source/LibationAvalonia/Dialogs/ScanAccountsDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/ScanAccountsDialog.axaml.cs
index 34f34901..332d3fef 100644
--- a/Source/LibationAvalonia/Dialogs/ScanAccountsDialog.axaml.cs
+++ b/Source/LibationAvalonia/Dialogs/ScanAccountsDialog.axaml.cs
@@ -1,5 +1,6 @@
using AudibleUtilities;
using Avalonia.Collections;
+using LibationUiBase;
using LibationUiBase.Forms;
using System.Collections.Generic;
using System.Linq;
@@ -17,7 +18,8 @@ public partial class ScanAccountsDialog : DialogWindow
{
Account = account;
IsChecked = account.LibraryScan;
- Text = $"{account.AccountName} ({account.AccountId} - {account.Locale?.Name})";
+ // lists every marketplace: one checkbox here can scan more than one
+ Text = MarketplacesUi.ScanPickerText(account);
}
public Account Account { get; }
public string Text { get; }
diff --git a/Source/LibationCli/Options/ImportAccountOptions.cs b/Source/LibationCli/Options/ImportAccountOptions.cs
index 75f2ff9d..7d4b5d9d 100644
--- a/Source/LibationCli/Options/ImportAccountOptions.cs
+++ b/Source/LibationCli/Options/ImportAccountOptions.cs
@@ -59,10 +59,8 @@ internal class ImportAccountOptions : OptionsBase
Console.Error.WriteLine(result.Message ?? "Invalid import file.");
Environment.ExitCode = (int)ExitCode.RunTimeError;
return;
- case Mkb79ImportOutcome.DuplicateAccount when result.Account is { } dup:
- Console.Error.WriteLine(
- $"An account with that account id and country already exists.{Environment.NewLine}"
- + $"Account ID: {dup.AccountId}{Environment.NewLine}Country: {dup.Locale?.Name}");
+ case Mkb79ImportOutcome.DuplicateAccount when result.Account is not null:
+ Console.Error.WriteLine(Mkb79AuthImporter.DuplicateMessage(result));
Environment.ExitCode = (int)ExitCode.RunTimeError;
return;
case Mkb79ImportOutcome.Success when result.Account is { } account:
diff --git a/Source/LibationUiBase/MarketplacesUi.cs b/Source/LibationUiBase/MarketplacesUi.cs
new file mode 100644
index 00000000..320ec096
--- /dev/null
+++ b/Source/LibationUiBase/MarketplacesUi.cs
@@ -0,0 +1,107 @@
+using AudibleUtilities;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace LibationUiBase;
+
+///
+/// Shared copy for the marketplaces feature, so Classic and Chardonnay say the same thing about the same
+/// state. The wording assumes the reader does not know a marketplace other than their own can hold titles -
+/// that is the whole reason they are here.
+///
+public static class MarketplacesUi
+{
+ public const string DialogTitle = "Marketplaces";
+
+ public const string Intro
+ = "Audible keeps a separate library for each marketplace. A title bought while your Amazon address was "
+ + "set to another country stays in that country's library, and Libation only sees the marketplaces "
+ + "listed here.\r\n\r\nChecking one adds it to this account's library scans. No second login is needed - "
+ + "your existing credentials work in every marketplace.";
+
+ public const string CheckButton = "Check other marketplaces";
+
+ public const string Checking = "Checking marketplaces...";
+
+ public const string NotAuthenticatedToolTip
+ = "Scan this account's library first, so Libation has credentials to check the other marketplaces with.";
+
+ public const string ButtonToolTip
+ = "See whether this account holds titles in other Audible marketplaces.";
+
+ public const string NoneFound
+ = "No other marketplace holds titles for this account.";
+
+ public const string NoneChecked
+ = "No marketplace could be checked. Scan this account's library to refresh its credentials, then try again.";
+
+ /// Label for the accounts grid button: the account's marketplaces, at a glance.
+ public static string ButtonText(int marketplaceCount)
+ => marketplaceCount > 1 ? $"{DialogTitle} ({marketplaceCount})" : DialogTitle;
+
+ /// One probed marketplace, as a line in the list.
+ public static string ResultText(MarketplaceProbeResult result)
+ => result.Outcome switch
+ {
+ MarketplaceProbeOutcome.AlreadyScanned
+ => $"{result.Locale.Name} - already scanned by this account",
+ MarketplaceProbeOutcome.ScannedByAnotherAccount
+ => $"{result.Locale.Name} - already scanned by {result.ClaimedBy}",
+ MarketplaceProbeOutcome.TitlesFound when result.TitleCount == 1
+ => $"{result.Locale.Name} - 1 title",
+ MarketplaceProbeOutcome.TitlesFound
+ => $"{result.Locale.Name} - {result.TitleCount} titles",
+ MarketplaceProbeOutcome.Empty when result.TitleCount == 0
+ => $"{result.Locale.Name} - no titles",
+ MarketplaceProbeOutcome.Empty
+ => $"{result.Locale.Name} - reachable, title count unknown",
+ _ => $"{result.Locale.Name} - could not be checked ({result.Error})"
+ };
+
+ ///
+ /// How an account's marketplaces read in a list of accounts. The scan picker in particular has to show
+ /// these: one checkbox there can scan several marketplaces, which would otherwise be invisible.
+ ///
+ public static string MarketplacesSuffix(Account account)
+ {
+ var extras = account.AdditionalLocales;
+ if (extras.Count == 0)
+ return account.Locale?.Name ?? "";
+
+ return string.Join(", ", new[] { account.Locale?.Name ?? "" }.Concat(extras.Select(l => l.Name)));
+ }
+
+ /// The row text used by both frontends' scan pickers.
+ public static string ScanPickerText(Account account)
+ => $"{account.AccountName} ({account.AccountId} - {MarketplacesSuffix(account)})";
+
+ ///
+ /// What a probe turned up. A marketplace that could not be reached says nothing about what is in it, so
+ /// failures are never folded into "nothing found" - reporting an unasked marketplace as empty is the exact
+ /// silence this feature exists to break.
+ ///
+ public static string Summary(IEnumerable results)
+ {
+ var all = results.ToList();
+
+ var found = all.Where(r => r.Outcome is MarketplaceProbeOutcome.TitlesFound).ToList();
+ var failed = all.Count(r => r.Outcome is MarketplaceProbeOutcome.Failed);
+ var answered = all.Count(r => r.Outcome is MarketplaceProbeOutcome.TitlesFound or MarketplaceProbeOutcome.Empty);
+
+ var unchecked_ = failed == 0
+ ? ""
+ : $"\r\n\r\n{failed} marketplace{(failed == 1 ? "" : "s")} could not be checked, so what they hold is unknown.";
+
+ if (found.Count == 0)
+ return answered == 0
+ ? NoneChecked
+ : NoneFound + unchecked_;
+
+ var list = string.Join(", ", found.Select(r => $"{r.Locale.Name} ({r.TitleCount})"));
+ var lead = found.Count == 1
+ ? $"Found titles in another marketplace: {list}.\r\n\r\nCheck it to include it in library scans."
+ : $"Found titles in {found.Count} other marketplaces: {list}.\r\n\r\nCheck the ones to include in library scans.";
+
+ return lead + unchecked_;
+ }
+}
diff --git a/Source/LibationWinForms/Dialogs/AccountsDialog.cs b/Source/LibationWinForms/Dialogs/AccountsDialog.cs
index e1ad0748..727a5f75 100644
--- a/Source/LibationWinForms/Dialogs/AccountsDialog.cs
+++ b/Source/LibationWinForms/Dialogs/AccountsDialog.cs
@@ -337,9 +337,9 @@ public partial class AccountsDialog : Form
return;
}
- if (importResult.Outcome is Mkb79ImportOutcome.DuplicateAccount && importResult.Account is { } dup)
+ if (importResult.Outcome is Mkb79ImportOutcome.DuplicateAccount && importResult.Account is not null)
{
- MessageBox.Show(this, $"An account with that account id and country already exists.\r\n\r\nAccount ID: {dup.AccountId}\r\nCountry: {dup.Locale?.Name}", "Cannot Add Duplicate Account");
+ MessageBox.Show(this, Mkb79AuthImporter.DuplicateMessage(importResult), "Cannot Add Duplicate Account");
return;
}
diff --git a/Source/_Tests/AudibleUtilities.Tests/Mkb79AuthExportTests.cs b/Source/_Tests/AudibleUtilities.Tests/Mkb79AuthExportTests.cs
index 1ad5491b..b89b9891 100644
--- a/Source/_Tests/AudibleUtilities.Tests/Mkb79AuthExportTests.cs
+++ b/Source/_Tests/AudibleUtilities.Tests/Mkb79AuthExportTests.cs
@@ -1,4 +1,6 @@
using AssertionHelper;
+using AudibleApi;
+using AudibleApi.Authorization;
using AudibleApi.Cryptography;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Newtonsoft.Json.Linq;
@@ -65,4 +67,28 @@ public class Mkb79AuthExportTests
auth.BeNotNull();
auth.ToJson().Should().Be(Serialize.ToJson(auth));
}
+
+ ///
+ /// The file carries one marketplace because that is all the format has room for: one locale_code beside one
+ /// device registration. Exporting an account that reads several must still name the one it is registered
+ /// with, so the file stays valid for audible-cli - which switches marketplaces from those same tokens anyway.
+ ///
+ [TestMethod]
+ public void export_names_the_registered_marketplace_and_leaves_the_additional_ones_out()
+ {
+ var account = new Account("user@example.com")
+ {
+ IdentityTokens = new Identity(Localization.Get("ca"))
+ };
+ account.AddMarketplace("us");
+
+ var jo = JObject.Parse(Mkb79Auth.FromAccount(account).ToJson());
+
+ jo["locale_code"]!.Value().Should().Be("ca");
+ jo["with_username"]!.Value().Should().BeFalse();
+
+ // nothing in the format records the extra marketplace, so a re-import will not restore it
+ jo.ContainsKey("AdditionalLocaleNames").Should().BeFalse();
+ jo.Properties().Select(p => p.Name).Contains("additional_locale_codes").Should().BeFalse();
+ }
}
diff --git a/Source/_Tests/LibationUiBase.Tests/MarketplacesUiTests.cs b/Source/_Tests/LibationUiBase.Tests/MarketplacesUiTests.cs
new file mode 100644
index 00000000..ff7af45e
--- /dev/null
+++ b/Source/_Tests/LibationUiBase.Tests/MarketplacesUiTests.cs
@@ -0,0 +1,123 @@
+using AudibleApi;
+using AudibleApi.Authorization;
+using AudibleUtilities;
+using LibationUiBase;
+
+namespace LibationUiBase.Tests;
+
+///
+/// The marketplaces summary reports on marketplaces that answered. A marketplace that could not be reached
+/// says nothing about what is in it, and calling it empty would recreate the very silence the feature exists
+/// to break - titles present, and no sign of them anywhere in the app.
+///
+[TestClass]
+public class MarketplacesUiTests
+{
+ private static Locale locale(string name) => Localization.Get(name);
+
+ private static MarketplaceProbeResult found(string name, int count)
+ => new(locale(name), MarketplaceProbeOutcome.TitlesFound, count);
+
+ private static MarketplaceProbeResult empty(string name)
+ => new(locale(name), MarketplaceProbeOutcome.Empty, 0);
+
+ private static MarketplaceProbeResult failed(string name)
+ => new(locale(name), MarketplaceProbeOutcome.Failed, Error: "Request could not be authenticated");
+
+ [TestMethod]
+ public void nothing_checked_does_not_claim_there_is_nothing_to_find()
+ {
+ var summary = MarketplacesUi.Summary([failed("canada"), failed("uk")]);
+
+ Assert.AreEqual(MarketplacesUi.NoneChecked, summary);
+ Assert.IsFalse(summary.Contains("No other marketplace holds titles"));
+ }
+
+ [TestMethod]
+ public void every_marketplace_answering_empty_is_a_real_answer()
+ => Assert.AreEqual(MarketplacesUi.NoneFound, MarketplacesUi.Summary([empty("canada"), empty("uk")]));
+
+ [TestMethod]
+ public void a_marketplace_that_could_not_be_checked_is_called_out_alongside_the_ones_that_could()
+ {
+ var summary = MarketplacesUi.Summary([empty("canada"), failed("uk")]);
+
+ StringAssert.Contains(summary, MarketplacesUi.NoneFound);
+ StringAssert.Contains(summary, "1 marketplace could not be checked");
+ }
+
+ [TestMethod]
+ public void found_titles_are_named_with_their_counts()
+ {
+ var summary = MarketplacesUi.Summary([found("us", 50), empty("uk")]);
+
+ StringAssert.Contains(summary, "us (50)");
+ StringAssert.Contains(summary, "another marketplace");
+ }
+
+ [TestMethod]
+ public void several_finds_are_counted()
+ {
+ var summary = MarketplacesUi.Summary([found("us", 50), found("uk", 3)]);
+
+ StringAssert.Contains(summary, "2 other marketplaces");
+ StringAssert.Contains(summary, "us (50)");
+ StringAssert.Contains(summary, "uk (3)");
+ }
+
+ [TestMethod]
+ public void a_find_still_reports_what_could_not_be_checked()
+ {
+ var summary = MarketplacesUi.Summary([found("us", 50), failed("uk"), failed("japan")]);
+
+ StringAssert.Contains(summary, "us (50)");
+ StringAssert.Contains(summary, "2 marketplaces could not be checked");
+ }
+
+ [TestMethod]
+ public void one_title_is_not_reported_as_titles()
+ => StringAssert.Contains(
+ MarketplacesUi.ResultText(found("us", 1)),
+ "1 title");
+
+ [TestMethod]
+ public void a_reachable_marketplace_with_no_count_is_not_called_empty()
+ => StringAssert.Contains(
+ MarketplacesUi.ResultText(new MarketplaceProbeResult(locale("us"), MarketplaceProbeOutcome.Empty)),
+ "title count unknown");
+
+ [TestMethod]
+ public void the_scan_picker_names_every_marketplace_an_account_reads()
+ {
+ var account = new Account("user@example.com")
+ {
+ AccountName = "Mine",
+ IdentityTokens = new Identity(Localization.Get("ca"))
+ };
+ account.AddMarketplace("us");
+
+ var text = MarketplacesUi.ScanPickerText(account);
+
+ StringAssert.Contains(text, "canada");
+ StringAssert.Contains(text, "us");
+ }
+
+ [TestMethod]
+ public void the_scan_picker_reads_as_it_always_did_for_a_single_marketplace_account()
+ {
+ var account = new Account("user@example.com")
+ {
+ AccountName = "Mine",
+ IdentityTokens = new Identity(Localization.Get("ca"))
+ };
+
+ Assert.AreEqual("Mine (user@example.com - canada)", MarketplacesUi.ScanPickerText(account));
+ }
+
+ [TestMethod]
+ public void the_accounts_grid_button_counts_marketplaces_only_when_there_is_more_than_one()
+ {
+ Assert.AreEqual("Marketplaces", MarketplacesUi.ButtonText(1));
+ Assert.AreEqual("Marketplaces (3)", MarketplacesUi.ButtonText(3));
+ }
+}