Files
Leonid PershinandClaude Opus 5 f8744c930a Remove the demo text-parsing domain
The scaffolding domain existed to prove the shell end to end before there was
anything real to put in it. There is now, so it goes - as CLAUDE.md promised it
would.

Gone: the two sample parsers, ITextParser, ParsedRecord, the parser catalog,
ParseViewModel and ParseView, their tests, and the settings key that remembered
which parser was last used. ParseError.LineNumber becomes Index, since for a
listing "line 42" was simply untrue, and the error keys move from Parse.Error.*
to Collect.Error.* now that parsing is not a concept here.

Kept: IParser<,>, ParseOutcome, ParseProgress and ParseError. The streaming
contract was always the general part - it was only ever the text-shaped closure
of it that was scaffolding.

Rendering the dashboard caught two keys that were referenced but never added
during the rename: the XAML was repointed and the resources were not. The parity
test could not see it, because it compares the two files against each other and
a key absent from both is consistent. That gap now has its own test, which reads
every {l:Loc} in the XAML and checks it resolves - a screenshot is too late and
too manual a way to find a missing string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 22:23:52 +03:00

222 lines
7.8 KiB
C#

using System.Globalization;
using System.Resources;
using System.Text.RegularExpressions;
using AvParser.Core.Settings;
using AvParser.UI.Localization;
namespace AvParser.UI.Tests;
public sealed class LocalizationTests : IDisposable
{
private static readonly CultureInfo English = CultureInfo.GetCultureInfo("en");
private static readonly CultureInfo Russian = CultureInfo.GetCultureInfo("ru");
private static readonly ResourceManager Resources = new(
"AvParser.UI.Localization.Strings",
typeof(Localizer).Assembly
);
/// <summary>Leaves the localizer where the rest of the suite expects it.</summary>
public void Dispose() => Localizer.Instance.SetLanguage(AppLanguage.English);
/// <summary>
/// Reads one language's resources without letting it fall back to another.
/// </summary>
/// <remarks>
/// English lives in the neutral <c>Strings.resx</c>, which resolves under the invariant
/// culture rather than under "en". <c>tryParents: false</c> matters: with fallback on, a
/// missing Russian satellite would quietly serve English and every comparison below would
/// pass while the app shipped untranslated.
/// </remarks>
private static IReadOnlyDictionary<string, string> Read(CultureInfo culture)
{
var lookup = Equals(culture, English) ? CultureInfo.InvariantCulture : culture;
// Deliberately not disposed: ResourceManager hands back a cached instance, and disposing
// it breaks every later read in the assembly.
var set = Resources.GetResourceSet(lookup, createIfNotExists: true, tryParents: false);
set.ShouldNotBeNull();
return set.Cast<System.Collections.DictionaryEntry>()
.ToDictionary(
entry => (string)entry.Key,
entry => (string?)entry.Value ?? string.Empty,
StringComparer.Ordinal
);
}
[Fact]
public void Russian_translates_every_english_key()
{
var english = Read(English);
var russian = Read(Russian);
english.ShouldNotBeEmpty();
russian.Keys.Except(english.Keys, StringComparer.Ordinal).ShouldBeEmpty("Russian has keys English does not");
english.Keys.Except(russian.Keys, StringComparer.Ordinal).ShouldBeEmpty("Russian is missing keys");
}
[Fact]
public void No_translation_is_blank() =>
Read(Russian).Where(entry => string.IsNullOrWhiteSpace(entry.Value)).Select(entry => entry.Key).ShouldBeEmpty();
[Fact]
public void Format_placeholders_match_between_languages()
{
var english = Read(English);
var russian = Read(Russian);
// A translation that drops a {0} throws at runtime rather than merely reading oddly, so
// the placeholder sets have to be identical.
var mismatched = english
.Where(entry => Placeholders(entry.Value) != Placeholders(russian[entry.Key]))
.Select(entry => entry.Key)
.ToArray();
mismatched.ShouldBeEmpty();
}
[Fact]
public void Every_plural_key_has_all_three_forms()
{
var english = Read(English);
// Russian needs One/Few/Many; a family missing one of them would silently render "!key!".
var families = english
.Keys.Where(key => key.EndsWith(".One", StringComparison.Ordinal))
.Select(key => key[..^4])
.ToArray();
families.ShouldNotBeEmpty();
foreach (var family in families)
{
foreach (var suffix in new[] { "One", "Few", "Many" })
{
english.ShouldContainKey($"{family}.{suffix}");
}
}
}
[Theory]
[InlineData(1, "One")]
[InlineData(2, "Few")]
[InlineData(4, "Few")]
[InlineData(5, "Many")]
[InlineData(11, "Many")]
[InlineData(12, "Many")]
[InlineData(14, "Many")]
[InlineData(21, "One")]
[InlineData(22, "Few")]
[InlineData(25, "Many")]
[InlineData(101, "One")]
[InlineData(111, "Many")]
[InlineData(0, "Many")]
public void Russian_plural_rules(int count, string expected) =>
Localizer.SuffixFor(count, Russian).ShouldBe(expected);
[Theory]
[InlineData(0, "Many")]
[InlineData(1, "One")]
[InlineData(2, "Many")]
[InlineData(21, "Many")]
public void English_plural_rules(int count, string expected) =>
Localizer.SuffixFor(count, English).ShouldBe(expected);
[Fact]
public void Plural_lookup_picks_the_right_form()
{
Localizer.Instance.SetLanguage(AppLanguage.Russian);
Localizer.Instance.Plural("Collect.Count.Images", 1).ShouldBe("1 изображение");
Localizer.Instance.Plural("Collect.Count.Images", 3).ShouldBe("3 изображения");
Localizer.Instance.Plural("Collect.Count.Images", 7).ShouldBe("7 изображений");
}
[Fact]
public void Switching_language_changes_what_lookups_return()
{
Localizer.Instance.SetLanguage(AppLanguage.English);
var english = Localizer.Instance["Page.Settings"];
Localizer.Instance.SetLanguage(AppLanguage.Russian);
Localizer.Instance["Page.Settings"].ShouldNotBe(english);
Localizer.Instance["Page.Settings"].ShouldBe("Настройки");
}
[Fact]
public void Switching_language_invalidates_bindings()
{
Localizer.Instance.SetLanguage(AppLanguage.English);
var notifications = new List<string?>();
void Handler(object? sender, System.ComponentModel.PropertyChangedEventArgs e) =>
notifications.Add(e.PropertyName);
Localizer.Instance.PropertyChanged += Handler;
try
{
Localizer.Instance.SetLanguage(AppLanguage.Russian);
}
finally
{
Localizer.Instance.PropertyChanged -= Handler;
}
// "Item[]" is what makes every {l:Loc} binding re-read; without it the UI would keep the
// previous language until it was rebuilt.
notifications.ShouldContain("Item[]");
}
[Fact]
public void Setting_the_same_language_twice_does_not_churn_bindings()
{
Localizer.Instance.SetLanguage(AppLanguage.Russian);
var raised = 0;
void Handler(object? sender, System.ComponentModel.PropertyChangedEventArgs e) => raised++;
Localizer.Instance.PropertyChanged += Handler;
try
{
Localizer.Instance.SetLanguage(AppLanguage.Russian);
}
finally
{
Localizer.Instance.PropertyChanged -= Handler;
}
raised.ShouldBe(0);
}
[Fact]
public void An_unknown_key_is_visibly_marked() => Localizer.Instance["No.Such.Key"].ShouldBe("!No.Such.Key!");
[Fact]
public void A_fallback_is_used_when_a_key_is_absent() =>
Localizer.Instance.GetOrDefault("Source.brand-new.Name", "Brand new").ShouldBe("Brand new");
[Theory]
[InlineData(AppLanguage.English, "en")]
[InlineData(AppLanguage.Russian, "ru")]
public void Explicit_languages_map_to_their_culture(AppLanguage language, string expected) =>
AppLanguages.ToCulture(language).TwoLetterISOLanguageName.ShouldBe(expected);
[Theory]
[InlineData("ru-RU", "ru")]
[InlineData("ru", "ru")]
[InlineData("en-GB", "en")]
[InlineData("de-DE", "en")]
[InlineData("ja-JP", "en")]
public void System_follows_the_os_only_where_a_translation_exists(string system, string expected) =>
// A half-translated German would be worse than plain English, so anything unsupported
// lands on English rather than on the OS language.
AppLanguages
.ToCulture(AppLanguage.System, CultureInfo.GetCultureInfo(system))
.TwoLetterISOLanguageName.ShouldBe(expected);
private static string Placeholders(string value) =>
string.Concat(Regex.Matches(value, @"\{\d+\}").Select(match => match.Value).Order());
}