Add the marketplaces dialog to the accounts grid

The accounts grid gains a Marketplaces button per row, enabled once the account
has credentials to check with, and a dialog that asks each marketplace what it
holds and lets the user tick the ones to scan. The scan picker now lists every
marketplace an account reads, since one checkbox there can scan several.

A marketplace that could not be reached is reported as unchecked rather than
empty. Calling it empty would recreate the exact silence this feature exists to
break: titles present, and nothing anywhere in the app to suggest it.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
This commit is contained in:
Cursor Agentandrmcrackan committed 2026-08-26 16:17:47 +00:00
1 parent 72927943c2
commit 5b2c620bf5
12 files changed
+619 -18

No files matched your search

+6
View File
@@ -237,6 +237,12 @@ public partial class Mkb79Auth
return account;
}
/// <summary>
/// The exported file names one marketplace - the one this account is registered with - because that is all
/// the format holds: a single <c>locale_code</c> 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.
/// </summary>
public static Mkb79Auth FromAccount(Account account)
=> new()
{
+35 -7
View File
@@ -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);
/// <param name="ClaimedBy">
/// For <see cref="Mkb79ImportOutcome.DuplicateAccount"/>, the account already scanning that marketplace. It may
/// be one registered with it, or one carrying it as an additional marketplace.
/// </param>
public sealed record Mkb79ImportResult(
Mkb79ImportOutcome Outcome,
Account? Account = null,
string? Message = null,
Account? ClaimedBy = null);
public static class Mkb79AuthImporter
{
/// <summary>
/// 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.
/// </summary>
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}";
}
/// <summary>
/// Deserialize mkb79/audible-cli JSON, refresh tokens, and add the account if not already present.
/// </summary>
@@ -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);
@@ -87,6 +87,23 @@
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Width="Auto" Header="Marketplaces">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button
Content="{Binding MarketplacesButtonText}"
VerticalAlignment="Stretch"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
IsEnabled="{Binding CanCheckMarketplaces}"
ToolTip.Tip="{Binding MarketplacesButtonToolTip}"
Click="MarketplacesButton_Clicked" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn
Width="Auto"
Binding="{Binding AccountName, Mode=TwoWay}"
@@ -3,6 +3,7 @@ using AudibleUtilities;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using LibationUiBase;
using LibationUiBase.Forms;
using ReactiveUI;
using System;
@@ -45,6 +46,12 @@ public partial class AccountsDialog : DialogWindow
public string? AccountName { get; set; }
public bool IsDefault => string.IsNullOrEmpty(AccountId);
/// <summary>
/// Marketplaces beyond <see cref="SelectedLocale"/> that this account should also scan. Edited by the
/// marketplaces dialog and written on save, like every other field here.
/// </summary>
public List<string> 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.";
/// <summary>
/// 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.
/// </summary>
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<string> 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<DialogResult>(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<bool> inputIsValid()
{
@@ -0,0 +1,81 @@
<Window
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignWidth="560" d:DesignHeight="520"
MinWidth="400" MinHeight="360"
Width="560" Height="520"
x:Class="LibationAvalonia.Dialogs.MarketplacesDialog"
xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs"
x:DataType="dialogs:MarketplacesDialog"
x:CompileBindings="True"
Title="Marketplaces"
WindowStartupLocation="CenterOwner">
<Grid
ColumnDefinitions="Auto,*,Auto"
RowDefinitions="Auto,Auto,*,Auto,Auto"
Margin="10">
<TextBlock
Grid.Row="0"
Grid.Column="0"
Grid.ColumnSpan="3"
TextWrapping="Wrap"
Text="{Binding Intro}" />
<TextBlock
Grid.Row="1"
Grid.Column="0"
Grid.ColumnSpan="3"
Margin="0,10,0,0"
FontWeight="Bold"
Text="{Binding AccountLabel}" />
<ListBox
Grid.Row="2"
Grid.Column="0"
Grid.ColumnSpan="3"
Margin="0,10"
Name="lbMarketplaces"
ItemsSource="{Binding Marketplaces}">
<ListBox.ItemTemplate>
<DataTemplate>
<CheckBox
IsChecked="{Binding IsChecked, Mode=TwoWay}"
IsEnabled="{Binding CanCheck}"
ToolTip.Tip="{Binding ToolTip}">
<TextBlock Text="{Binding Text}" />
</CheckBox>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock
Grid.Row="3"
Grid.Column="0"
Grid.ColumnSpan="3"
TextWrapping="Wrap"
Margin="0,0,0,10"
Name="StatusTextBlock" />
<Button
Grid.Row="4"
Grid.Column="0"
Padding="20,6"
Name="CheckButton"
Content="{Binding CheckButtonText}"
Click="CheckButton_Clicked" />
<Button
Grid.Row="4"
Grid.Column="2"
Classes="SaveButton"
HorizontalAlignment="Right"
Content="Save"
Name="SaveButton"
Command="{Binding SaveAndClose}" />
</Grid>
</Window>
@@ -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;
/// <summary>
/// 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.
/// </summary>
public partial class MarketplacesDialog : DialogWindow
{
public string Intro => MarketplacesUi.Intro;
public string CheckButtonText => MarketplacesUi.CheckButton;
public string AccountLabel { get; } = "";
public AvaloniaList<ListItem> Marketplaces { get; } = new();
/// <summary>The additional marketplaces the user checked. The registered one is never among them.</summary>
public IReadOnlyList<string> 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;
}
/// <param name="selectedAdditionalLocaleNames">
/// What the accounts grid currently shows for this account, which may not yet be saved.
/// </param>
public MarketplacesDialog(Account account, AccountsSettings accountsSettings, IEnumerable<string> 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<MarketplaceProbeResult>();
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();
}
@@ -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; }
@@ -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:
+107
View File
@@ -0,0 +1,107 @@
using AudibleUtilities;
using System.Collections.Generic;
using System.Linq;
namespace LibationUiBase;
/// <summary>
/// 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.
/// </summary>
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.";
/// <summary>Label for the accounts grid button: the account's marketplaces, at a glance.</summary>
public static string ButtonText(int marketplaceCount)
=> marketplaceCount > 1 ? $"{DialogTitle} ({marketplaceCount})" : DialogTitle;
/// <summary>One probed marketplace, as a line in the list.</summary>
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})"
};
/// <summary>
/// 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.
/// </summary>
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)));
}
/// <summary>The row text used by both frontends' scan pickers.</summary>
public static string ScanPickerText(Account account)
=> $"{account.AccountName} ({account.AccountId} - {MarketplacesSuffix(account)})";
/// <summary>
/// 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.
/// </summary>
public static string Summary(IEnumerable<MarketplaceProbeResult> 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_;
}
}
@@ -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;
}
@@ -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));
}
/// <summary>
/// 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.
/// </summary>
[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<string>().Should().Be("ca");
jo["with_username"]!.Value<bool>().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();
}
}
@@ -0,0 +1,123 @@
using AudibleApi;
using AudibleApi.Authorization;
using AudibleUtilities;
using LibationUiBase;
namespace LibationUiBase.Tests;
/// <summary>
/// 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.
/// </summary>
[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));
}
}