From 6c8bcc3958029442c91ac50d97b49c455ca0c483 Mon Sep 17 00:00:00 2001 From: David Liebeherr Date: Tue, 13 Oct 2026 02:56:10 +0200 Subject: [PATCH] feat!: route every lookup through a replaceable message formatter Every lookup, with or without arguments, now passes its resolved value through ILocalizer.MessageFormatter (default: StringFormatMessageFormatter). A failed lookup raises ILocalizer.LookupFailed and returns the failure text from ILocalizer.LookupFailureTextBuilder. RefreshTriggeringRevision changes on every change that can change a lookup's result. Refs #30 BREAKING CHANGE: a formatting failure no longer throws FormatException. Get returns the failure text (by default !key!) and raises ILocalizer.LookupFailed, and XAML renders the failure text. Get(key, args) with an empty array returns the resource value unformatted. ILocalizer has new members (MessageFormatter, LookupFailed, RefreshTriggeringRevision, a hidden Get overload) that custom implementations must add. TranslationNotFound and TranslationNotFoundEventArgs are replaced by LookupFailed and LookupFailedEventArgs, which also report formatting failures. MissingTranslationFormat is replaced by LookupFailureTextBuilder, a Func that also serves formatting failures. See docs/guides/migration-2.0.md. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 33 ++ docs/guides/enums.md | 2 +- docs/guides/format-arguments.md | 36 +- docs/guides/lookup-and-fallback.md | 73 ++++- docs/guides/migration-2.0.md | 162 +++++++++ docs/index.md | 1 + docs/toc.yml | 2 + src/ResXLocalization.Avalonia/GlobalUsings.cs | 1 - .../LocalizedFormattedTextObservable.cs | 19 +- .../CompatibilitySuppressions.xml | 200 +++++++++++ src/ResXLocalization.Core/ILocalizer.cs | 254 +++++++++----- .../IMessageFormatter.cs | 38 +++ src/ResXLocalization.Core/Localizer.cs | 260 ++++++++++----- .../LookupFailedEventArgs.cs | 9 + src/ResXLocalization.Core/LookupFailure.cs | 42 +++ .../LookupFailureReason.cs | 13 + src/ResXLocalization.Core/MessageArgument.cs | 24 ++ .../PublicAPI.Unshipped.txt | 77 +++++ .../StringFormatMessageFormatter.cs | 31 ++ .../TranslationNotFoundEventArgs.cs | 42 --- .../LocalizedFormattedStringConverter.cs | 17 +- .../LocalizeArgsTests.cs | 23 ++ .../LocalizeExtensionTests.cs | 31 ++ .../MainWindowViewModelTests.cs | 16 +- ...XLocalization.Avalonia.Sample.Tests.csproj | 6 + .../Resources/Catalog.resx | 4 + .../CapturingFormatter.cs | 23 ++ .../IsolatedInstanceTests.cs | 5 +- .../LocalizerContractTests.cs | 32 +- .../LocalizerFormatTests.cs | 310 +++++++++++++++++- .../LocalizerMissTests.cs | 209 +++++++++--- .../LocalizerMonitorExtensions.cs | 42 +++ .../RefreshTriggeringRevisionTests.cs | 122 +++++++ .../Resources/Catalog.de.resx | 6 + .../Resources/Catalog.resx | 9 +- .../LocalizeArgsTests.cs | 24 ++ .../LocalizeExtensionTests.cs | 32 ++ .../MainWindowViewModelTests.cs | 16 +- .../ResXLocalization.WPF.Sample.Tests.csproj | 6 + .../Resources/Catalog.resx | 4 + 40 files changed, 1911 insertions(+), 345 deletions(-) create mode 100644 docs/guides/migration-2.0.md create mode 100644 src/ResXLocalization.Core/CompatibilitySuppressions.xml create mode 100644 src/ResXLocalization.Core/IMessageFormatter.cs create mode 100644 src/ResXLocalization.Core/LookupFailedEventArgs.cs create mode 100644 src/ResXLocalization.Core/LookupFailure.cs create mode 100644 src/ResXLocalization.Core/LookupFailureReason.cs create mode 100644 src/ResXLocalization.Core/MessageArgument.cs create mode 100644 src/ResXLocalization.Core/StringFormatMessageFormatter.cs delete mode 100644 src/ResXLocalization.Core/TranslationNotFoundEventArgs.cs create mode 100644 tests/ResXLocalization.Core.Tests/CapturingFormatter.cs create mode 100644 tests/ResXLocalization.Core.Tests/LocalizerMonitorExtensions.cs create mode 100644 tests/ResXLocalization.Core.Tests/RefreshTriggeringRevisionTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8168561..0a9e201 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,39 @@ this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- `IMessageFormatter` and `ILocalizer.MessageFormatter`: one replaceable formatter that every resolved + value passes through, with or without arguments. The default, `StringFormatMessageFormatter`, uses + `string.Format` and does not format a value without arguments. +- `MessageArgument`, one argument of a lookup: its position, an optional name and its value. +- `ILocalizer.LookupFailed` with `LookupFailedEventArgs`, raised for every failed lookup: a key that no + resource file contains, or a value the formatter cannot format. +- `LookupFailure` and `LookupFailureReason`, which describe a failed lookup to `LookupFailed` and to + `ILocalizer.LookupFailureTextBuilder`. +- `ILocalizer.RefreshTriggeringRevision`, which changes on every change that can change a lookup's + result (culture, formatter, lookup-failure text builder, registered resource managers). + +### Changed + +- **BREAKING:** A formatting failure in `Get(key, args)` and its scoped and typed siblings returns the + failure text and raises `ILocalizer.LookupFailed`; it no longer throws `FormatException`. See + the [migration guide](docs/guides/migration-2.0.md). +- **BREAKING:** In XAML, a formatting failure in `{l:Localize}` renders the failure text instead of an + Avalonia binding error or an empty WPF text. See the [migration guide](docs/guides/migration-2.0.md). +- **BREAKING:** `Get(key, args)` with an empty array returns the resource value unformatted: `{{x}}` + stays `{{x}}`. See the [migration guide](docs/guides/migration-2.0.md). +- **BREAKING:** A custom `ILocalizer` implementation must implement `LookupFailed`, + `MessageFormatter`, `RefreshTriggeringRevision`, `LookupFailureTextBuilder` and the hidden + `Get(string key, MessageArgument[] arguments, ResourceManager? resourceManager)`. See the + [migration guide](docs/guides/migration-2.0.md). +- **BREAKING:** `ILocalizer.MissingTranslationFormat` is replaced by `ILocalizer.LookupFailureTextBuilder`, + a `Func` that also produces the failure text for a formatting failure. See the + [migration guide](docs/guides/migration-2.0.md). +- **BREAKING:** `ILocalizer.TranslationNotFound` and `TranslationNotFoundEventArgs` are replaced by + `ILocalizer.LookupFailed` and `LookupFailedEventArgs`, which report formatting failures too. See the + [migration guide](docs/guides/migration-2.0.md). + ## [1.1.0] - 2026-07-20 ### Added diff --git a/docs/guides/enums.md b/docs/guides/enums.md index 7037676..59f204c 100644 --- a/docs/guides/enums.md +++ b/docs/guides/enums.md @@ -81,5 +81,5 @@ own instance in resources: ## When a member has no entry An enum member with no matching resource entry resolves like any other missing key: the -`!Enum_FileSortOrder_Ascending!` sentinel, and a `TranslationNotFound` event. See +`!Enum_FileSortOrder_Ascending!` failure text, and a `LookupFailed` event. See [Lookup modes and culture fallback](lookup-and-fallback.md). diff --git a/docs/guides/format-arguments.md b/docs/guides/format-arguments.md index 025a334..d1b38c9 100644 --- a/docs/guides/format-arguments.md +++ b/docs/guides/format-arguments.md @@ -13,8 +13,8 @@ view model. Add the entry as usual: Localizer.Current.Get(AppStringsKeys.PeopleInvited, peopleCount); ``` -The resolved text and the arguments go to `string.Format` in the localizer's current culture, so a -number or a date formats the way that culture writes it. +The resolved text and the arguments go to the localizer's formatter - by default `string.Format` in +the localizer's current culture, so a number or a date formats the way that culture writes it. ## In XAML @@ -39,9 +39,35 @@ place as `5 Personen eingeladen`. when they need different arguments. - **Per-argument format specifiers belong in the resource string** — `{0:N0} people invited` — where a translator can adjust them per language. -- **No arguments set, no formatting.** An element without any `ArgN` resolves without composite - formatting at all, so a resource value containing a literal `{` or `}` keeps working without `{{` - escaping. The missing-key sentinel is likewise never formatted. +- **No arguments, no formatting.** Under the default formatter, a lookup without arguments returns the + resource value unchanged - in code `Localizer.Current.Get(key, args)` with an empty array, like + `Get(key)`, and in XAML an element without any `ArgN`. A resource value containing a literal `{` or + `}` keeps working without `{{` escaping. The failure text of a missing key is never formatted. +- **Formatter exceptions are caught.** When a value cannot be formatted - `Broken {0`, or + `File {0} of {1}` with one argument - the localizer raises `LookupFailed` and returns the + failure text (by default `!key!`), in code and in XAML alike, provided the event handlers and + `LookupFailureTextBuilder` complete successfully. An exception from an event handler or from + `LookupFailureTextBuilder`, and a `null` result of the builder, propagate. See + [Lookup modes and culture fallback](lookup-and-fallback.md#failure-text). +- **The formatter is replaceable.** Assign `Localizer.Current.MessageFormatter = ...`, usually at + startup; changing it later re-resolves bound text like a language switch + (`RefreshTriggeringRevision`). The default is `string.Format` in the localizer's current culture. + The formatter sees **every** resolved value, in code and in XAML, also lookups without arguments + (with an empty array), so it is the one place to post-process all translated texts; a missing key's + failure text does not pass through it. A custom formatter should return the value unchanged when + `arguments` is empty, unless it means to handle plain texts: + + ```csharp + Localizer.Current.MessageFormatter = new MyFormatter(); + + sealed class MyFormatter : IMessageFormatter + { + public string Format(string pattern, MessageArgument[] arguments, CultureInfo culture) => + arguments.Length == 0 + ? pattern // no arguments: do not format, so a literal { keeps working + : string.Format(culture, pattern, arguments.Select(argument => argument.Value).ToArray()); + } + ``` - **Keep it simple.** For a very complex string, compose the text in the view model with `Get(key, args…)` rather than wiring many argument slots. - **Composite formatting does not pluralize.** `1 people invited` is on the resource author; use diff --git a/docs/guides/lookup-and-fallback.md b/docs/guides/lookup-and-fallback.md index 532f887..ff0c3ec 100644 --- a/docs/guides/lookup-and-fallback.md +++ b/docs/guides/lookup-and-fallback.md @@ -46,13 +46,13 @@ that loads resources dynamically. > [!TIP] > In Avalonia, put the registration and the initial `CurrentCulture` inside `BuildAvaloniaApp()` > rather than `Main`. `BuildAvaloniaApp` runs at run time *and* under the XAML previewer, so -> search-all lookups resolve at design time too instead of showing the `!Greeting!` sentinel. +> search-all lookups resolve at design time too instead of showing the `!Greeting!` failure text. ## Culture fallback Fallback is .NET's own `ResourceManager` behaviour, unchanged. The localizer sits on top of it. -| Situation | Result | Raises `TranslationNotFound` | +| Situation | Result | Raises `LookupFailed` | | --- | --- | --- | | `de-DE` entry exists | the `de-DE` value | No | | Missing in `de-DE`, exists in `de` | the parent `de` value | No | @@ -60,21 +60,74 @@ Fallback is .NET's own `ResourceManager` behaviour, unchanged. The localizer sit | Missing across the complete fallback chain | `!key!` by default | Yes | Because fallback runs first, a key that resolves from a parent or from the neutral file does **not** -count as missing. The sentinel and the event report keys that cannot be resolved at all — they are -not a report of incomplete per-language coverage. +count as missing. The failure text and the event report keys that cannot be resolved at all — they +are not a report of incomplete per-language coverage. ## Missing-translation diagnostics ```csharp -// Change the sentinel, or make it invisible in production. -Localizer.MissingTranslationFormat = "«{0}»"; +// Log every key that cannot be resolved, and every value that cannot be formatted. +Localizer.Current.LookupFailed += (_, args) => +{ + var failure = args.Failure; + + if (failure.Reason == LookupFailureReason.TranslationNotFound) + { + logger.LogWarning("No translation for {Key} in {Culture}", failure.Key, failure.Culture); + } + else + { + logger.LogWarning(failure.Exception, "Cannot format {Key} in {Culture}: {Pattern}", failure.Key, failure.Culture, failure.Pattern); + } +}; +``` + +`LookupFailed` reports both kinds of failure, and `LookupFailure.Reason` tells them apart: +`TranslationNotFound` for a key that no resource file contains, `MessageFormatFailed` for a key that +was found but whose value could not be formatted - for example `Broken {0`, or `File {0} of {1}` with +one argument. Both lookups return the failure text. + +## Failure text + +The failure text is what a lookup returns instead of a translation when it cannot produce one: the key +is in no resource file, or its value could not be formatted. It is produced by the function +`LookupFailureTextBuilder`, which receives the same `LookupFailure` that `LookupFailed` reports - the +reason, the key, the culture, the resource manager of a scoped or typed lookup, the arguments, and for a +formatting failure the pattern and the exception. + +```csharp +// Wrap the key differently, or make it invisible in production. +Localizer.Current.LookupFailureTextBuilder = failure => $"«{failure.Key}»"; + +// Show only the last segment of a dotted key. +Localizer.Current.LookupFailureTextBuilder = failure => failure.Key[(failure.Key.LastIndexOf('.') + 1)..]; -// Log every unresolvable key. -Localizer.Current.TranslationNotFound += (_, args) => logger.LogWarning("No translation for {Key}", args.Key); +// Tell the two reasons apart. +Localizer.Current.LookupFailureTextBuilder = failure => + failure.Reason == LookupFailureReason.TranslationNotFound + ? $"[missing: {failure.Key}]" + : $"[broken: {failure.Key}]"; ``` -The default sentinel is `!key!`, which is deliberately ugly: a missing translation should be -noticeable in a screenshot. +The default produces `!key!`, which is deliberately ugly: a missing translation should be noticeable +in a screenshot. A formatting failure uses the same function. + +The function is called on every failed lookup, so keep it cheap. It must not throw or return `null`: +an exception it throws propagates out of the lookup, and a `null` result throws +`InvalidOperationException`. An empty string is fine and shows nothing. Changing the function +re-resolves bound text. + +## Refreshing localized text + +`ILocalizer.RefreshTriggeringRevision` changes whenever something changes that can change the result +of a lookup: the culture, the formatter, the lookup-failure text builder, or the registered resource +managers (a manager registered or unregistered, or the registrations cleared). Only whether the value +differs is meaningful, never the number itself. + +Bound text re-resolves on each of these changes. Strings fetched in code with `Get` are not +updated; re-fetch them when the localizer raises +`PropertyChanged(nameof(ILocalizer.RefreshTriggeringRevision))`. Make these changes on the UI thread +once views exist. ## Available cultures diff --git a/docs/guides/migration-2.0.md b/docs/guides/migration-2.0.md new file mode 100644 index 0000000..f3930aa --- /dev/null +++ b/docs/guides/migration-2.0.md @@ -0,0 +1,162 @@ +# Migrating to 2.0 + +2.0 is a major version; each section below is one breaking change, with what to do. + +## A formatting failure returns the failure text instead of throwing + +A resource value that cannot be formatted with the supplied arguments - `Broken {0`, or +`File {0} of {1}` with one argument - no longer throws `FormatException` from +`Localizer.Current.Get(key, args)` and its scoped and typed siblings. The lookup returns the failure +text (by default `!key!`) and raises `ILocalizer.LookupFailed` with +`LookupFailureReason.MessageFormatFailed`. Code that caught `FormatException` subscribes to the event +instead. + +Before: + +```csharp +try +{ + text = Localizer.Current.Get(AppStringsKeys.FilesCopied, copied); +} +catch (FormatException exception) +{ + logger.LogWarning(exception, "Broken translation"); +} +``` + +After: + +```csharp +Localizer.Current.LookupFailed += (_, args) => + logger.LogWarning(args.Failure.Exception, "Broken translation {Key} in {Culture}", args.Failure.Key, args.Failure.Culture); + +text = Localizer.Current.Get(AppStringsKeys.FilesCopied, copied); +``` + +## A formatting failure in XAML renders the failure text + +`{l:Localize}` with `LocalizeArgs` arguments whose resource value cannot be formatted renders the +failure text, in both frameworks, and raises `LookupFailed`. In 1.x, Avalonia reported a binding +error and kept the previous text, and WPF showed an empty text. + +Before (1.x): an Avalonia binding error, or an empty WPF `TextBlock`. + +After: + +```xml + + + +``` + +## An empty arguments array no longer formats + +`Localizer.Current.Get(key, args)` with an empty array returns the resource value unformatted, exactly +like `Get(key)`: `{{x}}` stays `{{x}}`, and a value containing `{0}` no longer throws. + +Before: + +```csharp +// Resource "Template" = "Literal {{braces}} for {0}" +Localizer.Current.Get("Template", Array.Empty()); // 1.x: FormatException +``` + +After: + +```csharp +Localizer.Current.Get("Template", Array.Empty()); // "Literal {{braces}} for {0}" +``` + +## Custom `ILocalizer` implementations have new members + +A custom `ILocalizer` implementation must implement: + +- `ILocalizer.LookupFailed`, replacing `TranslationNotFound` (see below); +- `ILocalizer.MessageFormatter`; +- `ILocalizer.RefreshTriggeringRevision` - advance it and raise `PropertyChanged` on every change that + can change a lookup's result; from 2.0 the XAML bindings re-resolve on it, not on `CurrentCulture`; +- the hidden `ILocalizer.Get(string key, MessageArgument[] arguments, ResourceManager? resourceManager)`, + the one entry point every lookup goes through. + +It also replaces `MissingTranslationFormat` with `LookupFailureTextBuilder` (see below), raises +`LookupFailed` and builds the failure text for both `LookupFailureReason`s. + +Before: + +```csharp +public sealed class MyLocalizer : ILocalizer +{ + public event EventHandler? TranslationNotFound; + public string MissingTranslationFormat { get; set; } = "!{0}!"; + // ... +} +``` + +After: + +```csharp +public sealed class MyLocalizer : ILocalizer +{ + public event EventHandler? LookupFailed; + public IMessageFormatter MessageFormatter { get; set; } = StringFormatMessageFormatter.Instance; + public int RefreshTriggeringRevision { get; private set; } + public Func LookupFailureTextBuilder { get; set; } = failure => $"!{failure.Key}!"; + + public string Get(string key, MessageArgument[] arguments, ResourceManager? resourceManager) => + /* resolve, then MessageFormatter.Format(value, arguments, CurrentCulture); + on a failure raise LookupFailed, then return LookupFailureTextBuilder(failure) */; + // ... +} +``` + +## `MissingTranslationFormat` is replaced by `LookupFailureTextBuilder` + +`ILocalizer.MissingTranslationFormat`, a composite format string, is replaced by +`ILocalizer.LookupFailureTextBuilder`, a `Func`. It produces the failure text for +a missing translation and, new in 2.0, for a formatting failure; `LookupFailure.Reason` tells them +apart. The compiler reports every use of the old property; code that read the format string calls the +function instead. The `ArgumentException` for a malformed format no longer exists. The documentation +calls the text the "failure text" instead of the "sentinel". + +Before: + +```csharp +Localizer.Current.MissingTranslationFormat = "[{0}]"; +``` + +After: + +```csharp +Localizer.Current.LookupFailureTextBuilder = failure => $"[{failure.Key}]"; +``` + +See [Lookup modes and culture fallback](lookup-and-fallback.md#failure-text) for more examples. + +## `TranslationNotFound` is replaced by `LookupFailed` + +`ILocalizer.TranslationNotFound` and `TranslationNotFoundEventArgs` are replaced by +`ILocalizer.LookupFailed` and `LookupFailedEventArgs`. The new event reports every failed lookup - a key +that no resource file contains, and, new in 2.0, a value that cannot be formatted. Its +`LookupFailedEventArgs.Failure` is a `LookupFailure`, the same value `LookupFailureTextBuilder` +receives; `Failure.Reason` tells the two cases apart. To keep logging only missing translations, check +the reason. + +Before: + +```csharp +Localizer.Current.TranslationNotFound += (_, args) => + logger.LogWarning("No translation for {Key} in {Culture}", args.Key, args.Culture); +``` + +After: + +```csharp +Localizer.Current.LookupFailed += (_, args) => +{ + if (args.Failure.Reason == LookupFailureReason.TranslationNotFound) + { + logger.LogWarning("No translation for {Key} in {Culture}", args.Failure.Key, args.Failure.Culture); + } +}; +``` diff --git a/docs/index.md b/docs/index.md index b74e32c..e927e23 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,6 +15,7 @@ installation and a quick start. These pages are the full documentation. | [Localizing enum values](guides/enums.md) | The naming convention, item templates, the converter, custom prefixes and scoping | | [Dynamic format arguments](guides/format-arguments.md) | `Get(key, args…)` and the `LocalizeArgs.Arg0`…`Arg8` attached properties | | [Native AOT and trimming](guides/native-aot.md) | What to declare, how to publish, and why the repository's own gate runs the native binary | +| [Migrating to 2.0](guides/migration-2.0.md) | Each breaking change of 2.0, with what to do | ## Reference diff --git a/docs/toc.yml b/docs/toc.yml index 26e4dda..fee04b3 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -10,6 +10,8 @@ href: guides/format-arguments.md - name: Native AOT and trimming href: guides/native-aot.md + - name: Migrating to 2.0 + href: guides/migration-2.0.md - name: Reference items: - name: Generated keys diff --git a/src/ResXLocalization.Avalonia/GlobalUsings.cs b/src/ResXLocalization.Avalonia/GlobalUsings.cs index 8b434b5..8c89c29 100644 --- a/src/ResXLocalization.Avalonia/GlobalUsings.cs +++ b/src/ResXLocalization.Avalonia/GlobalUsings.cs @@ -1,7 +1,6 @@ global using System.Globalization; global using System.Resources; global using Avalonia; -global using Avalonia.Data; global using Avalonia.Data.Converters; global using Avalonia.Markup.Xaml; global using Avalonia.Reactive; diff --git a/src/ResXLocalization.Avalonia/LocalizedFormattedTextObservable.cs b/src/ResXLocalization.Avalonia/LocalizedFormattedTextObservable.cs index 514210d..14203d8 100644 --- a/src/ResXLocalization.Avalonia/LocalizedFormattedTextObservable.cs +++ b/src/ResXLocalization.Avalonia/LocalizedFormattedTextObservable.cs @@ -94,24 +94,9 @@ public void Dispose() /// private void Emit() { - object? value; + var arguments = LocalizeArgs.GetArguments(this.element); - try - { - var arguments = LocalizeArgs.GetArguments(this.element); - value = arguments is null ? this.resolve() : this.resolveFormatted(arguments); - } - catch (FormatException exception) - { - // Mirrors WPF, whose binding engine catches converter exceptions and reports a binding - // error: a format string that does not (yet) match the supplied arguments - routine - // while multi-argument bindings still deliver their values one by one - must not throw - // into the property system mid-delivery. It surfaces as a binding error instead and - // resolves cleanly on the next argument or culture change. - value = new BindingNotification(exception, BindingErrorType.Error); - } - - this.observer.OnNext(value); + this.observer.OnNext(arguments is null ? this.resolve() : this.resolveFormatted(arguments)); } } } diff --git a/src/ResXLocalization.Core/CompatibilitySuppressions.xml b/src/ResXLocalization.Core/CompatibilitySuppressions.xml new file mode 100644 index 0000000..d01d718 --- /dev/null +++ b/src/ResXLocalization.Core/CompatibilitySuppressions.xml @@ -0,0 +1,200 @@ + + + + + CP0001 + T:RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0001 + T:RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.ILocalizer.add_TranslationNotFound(System.EventHandler{RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs}) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.ILocalizer.get_MissingTranslationFormat + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.ILocalizer.remove_TranslationNotFound(System.EventHandler{RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs}) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.ILocalizer.set_MissingTranslationFormat(System.String) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Localizer.add_TranslationNotFound(System.EventHandler{RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs}) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Localizer.get_MissingTranslationFormat + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Localizer.remove_TranslationNotFound(System.EventHandler{RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs}) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Localizer.set_MissingTranslationFormat(System.String) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.ILocalizer.add_TranslationNotFound(System.EventHandler{RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs}) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.ILocalizer.get_MissingTranslationFormat + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.ILocalizer.remove_TranslationNotFound(System.EventHandler{RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs}) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.ILocalizer.set_MissingTranslationFormat(System.String) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Localizer.add_TranslationNotFound(System.EventHandler{RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs}) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Localizer.get_MissingTranslationFormat + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Localizer.remove_TranslationNotFound(System.EventHandler{RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs}) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Localizer.set_MissingTranslationFormat(System.String) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0006 + E:RentADeveloper.ResXLocalization.ILocalizer.LookupFailed + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0006 + M:RentADeveloper.ResXLocalization.ILocalizer.Get(System.String,RentADeveloper.ResXLocalization.MessageArgument[],System.Resources.ResourceManager) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0006 + P:RentADeveloper.ResXLocalization.ILocalizer.LookupFailureTextBuilder + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0006 + P:RentADeveloper.ResXLocalization.ILocalizer.MessageFormatter + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0006 + P:RentADeveloper.ResXLocalization.ILocalizer.RefreshTriggeringRevision + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0006 + E:RentADeveloper.ResXLocalization.ILocalizer.LookupFailed + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0006 + M:RentADeveloper.ResXLocalization.ILocalizer.Get(System.String,RentADeveloper.ResXLocalization.MessageArgument[],System.Resources.ResourceManager) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0006 + P:RentADeveloper.ResXLocalization.ILocalizer.LookupFailureTextBuilder + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0006 + P:RentADeveloper.ResXLocalization.ILocalizer.MessageFormatter + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0006 + P:RentADeveloper.ResXLocalization.ILocalizer.RefreshTriggeringRevision + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + \ No newline at end of file diff --git a/src/ResXLocalization.Core/ILocalizer.cs b/src/ResXLocalization.Core/ILocalizer.cs index ad76309..ce87435 100644 --- a/src/ResXLocalization.Core/ILocalizer.cs +++ b/src/ResXLocalization.Core/ILocalizer.cs @@ -6,9 +6,31 @@ namespace RentADeveloper.ResXLocalization; /// culture changes - without reloading views. /// /// -/// Implementations are intended for UI-thread use: register resource managers at startup and switch -/// from the UI thread, so change notifications reach bindings on the -/// thread WPF and Avalonia expect. +/// +/// Lookups. Every Get overload and the indexer resolve the key in +/// , with .NET's own parent and neutral fallback, and pass the resolved value +/// to together with the lookup's arguments - an empty array when the +/// lookup has none. The default formatter () returns a value without +/// arguments unchanged and formats one with arguments with +/// . A or empty key returns +/// without calling the formatter and without raising an event. +/// +/// +/// The failure text. When no resource file contains the key, or the formatter throws, a lookup +/// raises and then returns the failure text produced by +/// , by default !key!. The failure text never passes +/// through the formatter. +/// +/// +/// Exceptions. A lookup catches every exception the formatter throws. Exceptions from the +/// resource lookup itself, from event handlers and from propagate +/// unchanged; an event handler that throws prevents the failure text from being built. +/// +/// +/// Threading. Implementations are intended for UI-thread use: register resource managers at +/// startup and switch from the UI thread, so change notifications reach +/// bindings on the thread WPF and Avalonia expect. +/// /// public interface ILocalizer : INotifyPropertyChanged { @@ -19,17 +41,47 @@ public interface ILocalizer : INotifyPropertyChanged event EventHandler CultureChanged; /// - /// Occurs whenever a lookup misses - no resource file could resolve the key for the current - /// culture and the sentinel (see ) is about to be - /// returned. Normal .NET resource fallback runs first; subscribe to log keys that remain - /// unresolved across the complete parent and neutral fallback chain. + /// Occurs when a lookup fails, before it returns the failure text: no resource file contains the key, + /// or threw while formatting its value. + /// tells the two apart. Subscribe to log missing or broken + /// translations. /// - event EventHandler TranslationNotFound; + /// + /// + /// Normal .NET resource fallback runs first, so a key that resolves from a parent or the neutral + /// resources is not reported. A formatting failure is not specific to ICU MessageFormat: the + /// default reports one for Broken {0, or for + /// File {0} of {1} with one argument. + /// + /// Handlers must not throw (see ). + /// + event EventHandler LookupFailed; + + /// + /// Gets a value that changes whenever something changes that can change the result of a lookup - + /// the culture, the formatter, the lookup-failure text builder or the registered resource managers. + /// Bind to it to re-resolve localized text; only whether the value differs is meaningful. + /// + /// + /// + /// The value advances after each real change of , + /// or , and after + /// , and + /// when they change the registered set. Each advance raises + /// for this property and then for the indexer + /// (Item[]). Strings previously returned by Get are not updated. + /// + /// + /// Make these changes on the UI thread once views exist. In XAML, the trigger is + /// <Binding Path="RefreshTriggeringRevision" Source="{x:Static core:Localizer.Current}" />. + /// + /// + int RefreshTriggeringRevision { get; } /// /// Gets or sets the culture used to resolve resources. Setting a different value raises - /// and , causing - /// bound localized values to re-resolve. + /// , advances + /// and raises . /// /// /// Setting this property changes only how this localizer resolves translations; it deliberately @@ -44,22 +96,54 @@ public interface ILocalizer : INotifyPropertyChanged CultureInfo CurrentCulture { get; set; } /// - /// Gets or sets the composite format string that produces the sentinel returned for a key no - /// lookup could resolve, where {0} is the key. Defaults to !{0}!, which renders a - /// missing Greeting as !Greeting!. + /// Gets or sets the function that produces the failure text: the text a lookup returns instead of a + /// translation when no resource file contains the key or its value cannot be formatted. /// /// - /// Configure this once at startup, before views load: changing it later does not re-resolve - /// values that are already bound (only a culture change does that). + /// + /// The function receives a describing the failed lookup; + /// tells the two cases apart. It returns the text shown instead + /// of the translation; an empty string shows nothing. + /// + /// Examples: + /// + /// failure => $"[{failure.Key}]" + /// failure => failure.Key[(failure.Key.LastIndexOf('.') + 1)..] + /// failure => failure.Culture.TwoLetterISOLanguageName == "de" ? $"[fehlt: {failure.Key}]" : $"[missing: {failure.Key}]" + /// failure => failure.Reason == LookupFailureReason.TranslationNotFound ? $"[missing: {failure.Key}]" : $"[broken: {failure.Key}]" + /// + /// + /// The function is called on every failed lookup, so it should be cheap. It must not throw or return + /// : an exception it throws propagates out of the lookup, and a + /// result makes the lookup throw . + /// + /// + /// Assigning a different delegate instance advances ; + /// assigning the current instance again does nothing. + /// /// - /// The sentinel format. Defaults to !{0}!. + /// The failure-text function. Defaults to failure => $"!{failure.Key}!". /// The supplied value is . - /// - /// The supplied value is not a valid composite format string for a single argument (the key) - for - /// example {1}. It is validated on assignment so the defect surfaces here rather than on the - /// first miss. - /// - string MissingTranslationFormat { get; set; } + Func LookupFailureTextBuilder { get; set; } + + /// + /// Gets or sets the formatter that turns each resolved resource value and the arguments of its lookup + /// into the final string. + /// + /// + /// + /// Every lookup that resolves a value calls the formatter, also a lookup without arguments, so it is + /// the one hook for all translated texts (see ). + /// + /// + /// Assigning a different instance advances ; assigning the + /// current instance again does nothing. Exceptions thrown by + /// handlers propagate from the assignment. + /// + /// + /// The formatter. Defaults to . + /// The supplied value is . + IMessageFormatter MessageFormatter { get; set; } /// /// Gets the localized string for the specified key by searching every registered @@ -67,16 +151,15 @@ public interface ILocalizer : INotifyPropertyChanged /// /// The resource key to resolve. /// - /// The localized string for in ; - /// if is or empty; - /// or the configured missing-translation sentinel (by default !key!) if no registered - /// resource manager contains the key. + /// The localized string, for a or empty key, or the + /// failure text (see ). /// string this[string key] { get; } /// /// Removes every registered from the search-all set. Typed and - /// scoped lookups are unaffected - they never consult the registration set. + /// scoped lookups are unaffected - they never consult the registration set. When at least one + /// manager was registered, advances . /// void ClearResourceManagers(); @@ -86,35 +169,57 @@ public interface ILocalizer : INotifyPropertyChanged /// /// The resource key to resolve. /// - /// The localized string for in ; - /// if is or empty; - /// or the configured missing-translation sentinel (by default !key!) if no registered - /// resource manager contains the key. + /// The localized string, for a or empty key, or the + /// failure text (see ). /// string Get(string key); /// /// Resolves the localized string for the specified key by searching every registered - /// in registration order, then formats it as a composite format - /// string with the supplied arguments in - for example a resource - /// value of Downloading {0} of {1}…. + /// in registration order, and formats it with the supplied arguments - + /// for example a resource value of Downloading {0} of {1}…. /// /// The resource key to resolve. - /// The values to format into the resolved string. + /// The values to format into the resolved string; empty for none. /// - /// The formatted, localized string; if is - /// or empty; or the configured missing-translation sentinel (by default - /// !key!), without applying , if no registered resource manager - /// contains the key. + /// The formatted string, for a or empty key, or the + /// failure text (see ). /// /// is . - /// - /// The resolved resource value is not a valid composite format string for - /// . That is a defect in the resource file you want to surface - /// during development, not a missing translation, so it fails loudly. - /// string Get(string key, params object?[] arguments); + /// + /// Resolves the localized string for the specified key and formats it with the supplied arguments - + /// the one entry point every lookup goes through. + /// + /// + /// This member is infrastructure and hidden from IntelliSense: code callers use the ordinary + /// Get overloads, which all delegate to it. It is an interface member because + /// may hold a custom implementation and the UI packages call it + /// from other assemblies. + /// + /// The resource key to resolve. + /// + /// The arguments of the lookup; empty for none. Argument i must have + /// i, and a must not + /// be an empty string. + /// + /// + /// The single resource manager to read the key from, or to search every + /// registered resource manager in registration order. + /// + /// + /// The formatted string, for a or empty key, or the + /// failure text (see ). + /// + /// is . + /// + /// An argument's is not its index, or its + /// is an empty string. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + string Get(string key, MessageArgument[] arguments, ResourceManager? resourceManager); + /// /// Resolves the localized string for an enumeration value by mapping it to a resource key using /// the convention {keyPrefix}{EnumTypeName}_{Value}, then searching every registered @@ -122,11 +227,7 @@ public interface ILocalizer : INotifyPropertyChanged /// /// The enumeration value to resolve. /// The prefix prepended to the generated key. Defaults to Enum_. - /// - /// The localized string for the generated key in , or the configured - /// missing-translation sentinel (by default !key!) if no registered resource manager - /// contains it. - /// + /// The localized string, or the failure text (see ). /// /// or is . /// @@ -139,10 +240,8 @@ public interface ILocalizer : INotifyPropertyChanged /// The resource key to resolve. /// The resource manager to read the key from. /// - /// The localized string for in ; - /// if is or empty; - /// or the configured missing-translation sentinel (by default !key!) if - /// does not contain the key. + /// The localized string, for a or empty key, or the + /// failure text (see ). /// /// /// is . @@ -151,25 +250,18 @@ public interface ILocalizer : INotifyPropertyChanged /// /// Resolves the localized string for the specified key from a single, explicit - /// , then formats it as a composite format string with the supplied - /// arguments in . + /// , and formats it with the supplied arguments. /// /// The resource key to resolve. /// The resource manager to read the key from. - /// The values to format into the resolved string. + /// The values to format into the resolved string; empty for none. /// - /// The formatted, localized string; if is - /// or empty; or the configured missing-translation sentinel (by default - /// !key!), without applying , if - /// does not contain the key. + /// The formatted string, for a or empty key, or the + /// failure text (see ). /// /// /// or is . /// - /// - /// The resolved resource value is not a valid composite format string for - /// . - /// string Get(string key, ResourceManager resourceManager, params object?[] arguments); /// @@ -180,11 +272,7 @@ public interface ILocalizer : INotifyPropertyChanged /// The enumeration value to resolve. /// The resource manager to read the generated key from. /// The prefix prepended to the generated key. Defaults to Enum_. - /// - /// The localized string for the generated key in , or the configured - /// missing-translation sentinel (by default !key!) if - /// does not contain it. - /// + /// The localized string, or the failure text (see ). /// /// , , or /// is . @@ -196,11 +284,7 @@ public interface ILocalizer : INotifyPropertyChanged /// key name and the owning . /// /// The typed key identifying the resource and its resource manager. - /// - /// The localized string for in , or the - /// configured missing-translation sentinel (by default !key!) if the resource manager does - /// not contain it. - /// + /// The localized string, or the failure text (see ). /// /// is a default or otherwise uninitialized , /// whose is . @@ -208,25 +292,17 @@ public interface ILocalizer : INotifyPropertyChanged string Get(ResourceKey key); /// - /// Resolves the localized string for a typed , then formats it as a - /// composite format string with the supplied arguments in . + /// Resolves the localized string for a typed , and formats it with the + /// supplied arguments. /// /// The typed key identifying the resource and its resource manager. - /// The values to format into the resolved string. - /// - /// The formatted, localized string, or the configured missing-translation sentinel (by default - /// !key!), without applying , if the resource manager does not - /// contain the key. - /// + /// The values to format into the resolved string; empty for none. + /// The formatted string, or the failure text (see ). /// /// is , or is a /// default or otherwise uninitialized whose /// is . /// - /// - /// The resolved resource value is not a valid composite format string for - /// . - /// string Get(ResourceKey key, params object?[] arguments); /// @@ -270,7 +346,8 @@ public interface ILocalizer : INotifyPropertyChanged /// /// Registers a to be searched by the key-only and enum lookups. /// Managers are searched in registration order on a first-match-wins basis; registering the same - /// manager more than once has no effect. + /// manager more than once has no effect. Adding a manager advances + /// . /// /// The resource manager to add to the search set. /// @@ -281,7 +358,8 @@ public interface ILocalizer : INotifyPropertyChanged /// /// Removes a previously registered from the search-all set, for /// example when the plugin or module that owns its strings is unloaded. Typed and scoped lookups - /// are unaffected - they never consult the registration set. + /// are unaffected - they never consult the registration set. Removing a manager advances + /// . /// /// The resource manager to remove from the search set. /// diff --git a/src/ResXLocalization.Core/IMessageFormatter.cs b/src/ResXLocalization.Core/IMessageFormatter.cs new file mode 100644 index 0000000..84d1387 --- /dev/null +++ b/src/ResXLocalization.Core/IMessageFormatter.cs @@ -0,0 +1,38 @@ +namespace RentADeveloper.ResXLocalization; + +/// +/// Turns a resolved resource value and the arguments of its lookup - possibly none - into the final +/// string. Assign an implementation to to replace the default +/// . +/// +/// +/// +/// The localizer calls for every lookup that resolves a resource value, +/// whether or not the lookup has arguments, so a formatter is the one hook every translated text passes +/// through. It is never called for a key no resource file contains, and never for a +/// or empty key. +/// +/// +/// An implementation signals a malformed pattern or unusable arguments by throwing. The +/// localizer catches the exception, raises and returns the +/// failure text. Because every lookup calls the formatter, a formatter that throws for values without +/// arguments turns plain texts into the failure text too. +/// +/// +public interface IMessageFormatter +{ + /// Formats a resolved resource value with the arguments of its lookup. + /// The resolved resource value. + /// + /// The arguments of the lookup; never . Argument i always has + /// i. The array is empty when the lookup had no + /// arguments - Get(key), the indexer, {l:Localize} without arguments, or an empty array. + /// The default formatter then returns unchanged, and an implementation + /// should do the same unless it deliberately handles values without arguments: formatting them would + /// turn {{x}} into {x} and make every plain text containing {0} or a literal + /// { fail. + /// + /// The localizer's at the time of the lookup. + /// The final string. + string Format(string pattern, MessageArgument[] arguments, CultureInfo culture); +} diff --git a/src/ResXLocalization.Core/Localizer.cs b/src/ResXLocalization.Core/Localizer.cs index 7b49c06..0a1bdcf 100644 --- a/src/ResXLocalization.Core/Localizer.cs +++ b/src/ResXLocalization.Core/Localizer.cs @@ -7,19 +7,18 @@ namespace RentADeveloper.ResXLocalization; /// /// /// Like the UI frameworks it serves, this class is designed for UI-thread use and is not -/// synchronized: register resource managers once at startup and set -/// from the UI thread, so change notifications reach bindings on the thread WPF and Avalonia expect. +/// synchronized: register resource managers once at startup and set , +/// and from the UI thread, so +/// change notifications reach bindings on the thread WPF and Avalonia expect. /// public sealed class Localizer : ILocalizer { - /// Cached event args announcing a change. - private static readonly PropertyChangedEventArgs CurrentCultureChangedArgs = new(nameof(CurrentCulture)); - /// - /// Cached event args announcing an indexer change (Item[]), which makes bindings through - /// the indexer re-resolve after a culture change. + /// The default of : produces !key!. One shared instance, + /// so every new localizer starts with the same delegate. /// - private static readonly PropertyChangedEventArgs IndexerChangedArgs = new("Item[]"); + private static readonly Func DefaultLookupFailureTextBuilder = static failure => + $"!{failure.Key}!"; /// Backing field of . private CultureInfo currentCulture = CultureInfo.CurrentUICulture; @@ -41,15 +40,16 @@ public Localizer() { } /// public event EventHandler? CultureChanged; + /// + public event EventHandler? LookupFailed; + /// - /// Occurs when a property value changes, including and the indexer, - /// so that localized bindings re-resolve after a culture change. + /// Occurs when a property value changes, including , + /// and the indexer, so that localized bindings re-resolve + /// after a change that can change the result of a lookup. /// public event PropertyChangedEventHandler? PropertyChanged; - /// - public event EventHandler? TranslationNotFound; - /// /// Gets or sets the shared, ambient localizer instance used throughout the application. Register resource /// managers on it at startup and set to switch languages. Applications @@ -83,71 +83,115 @@ public CultureInfo CurrentCulture var oldCulture = this.currentCulture; this.currentCulture = value; - this.PropertyChanged?.Invoke(this, CurrentCultureChangedArgs); - this.PropertyChanged?.Invoke(this, IndexerChangedArgs); + this.PropertyChanged?.Invoke(this, new(nameof(this.CurrentCulture))); + this.AdvanceRefreshTriggeringRevision(); this.CultureChanged?.Invoke(this, new(oldCulture, value)); } } /// - public string MissingTranslationFormat + public Func LookupFailureTextBuilder { get; set { ArgumentNullException.ThrowIfNull(value); - // Validate eagerly with a probe key so a bad format (e.g. "{1}", which references a second - // argument that never exists) fails right here, at the faulty assignment - not later, deep - // inside the first cache miss where the FormatException would be far from its cause. - try + if (ReferenceEquals(field, value)) { - _ = string.Format(CultureInfo.InvariantCulture, value, "probe"); + return; } - catch (FormatException exception) + + field = value; + this.AdvanceRefreshTriggeringRevision(); + } + } = DefaultLookupFailureTextBuilder; + + /// + public IMessageFormatter MessageFormatter + { + get; + set + { + ArgumentNullException.ThrowIfNull(value); + + if (ReferenceEquals(field, value)) { - throw new ArgumentException( - "The missing-translation format must be a composite format string with at most one " - + "placeholder ({0}, the key).", - nameof(value), - exception - ); + return; } field = value; + this.AdvanceRefreshTriggeringRevision(); } - } = "!{0}!"; + } = StringFormatMessageFormatter.Instance; /// - public string this[string key] => this.Get(key); + public int RefreshTriggeringRevision { get; private set; } /// - public void ClearResourceManagers() => this.resourceManagers = []; + public string this[string key] => this.Get(key); /// - public string Get(string key) + public void ClearResourceManagers() { - if (string.IsNullOrEmpty(key)) + if (this.resourceManagers.IsEmpty) { - return string.Empty; + return; } - return this.FindInRegisteredManagers(key) ?? this.Miss(key, null); + this.resourceManagers = []; + this.AdvanceRefreshTriggeringRevision(); } + /// + public string Get(string key) => this.Get(key, Array.Empty(), null); + /// public string Get(string key, params object?[] arguments) { ArgumentNullException.ThrowIfNull(arguments); + return this.Get(key, ToMessageArguments(arguments), null); + } + + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public string Get(string key, MessageArgument[] arguments, ResourceManager? resourceManager) + { + ValidateArguments(arguments); + + // One lookup uses one culture throughout: for the lookup, the format and the failure. + var culture = this.currentCulture; + if (string.IsNullOrEmpty(key)) { return string.Empty; } - var value = this.FindInRegisteredManagers(key); + var value = resourceManager is null + ? this.FindInRegisteredResourceManagers(key, culture) + : resourceManager.GetString(key, culture); + + if (value is null) + { + return this.FailLookup( + new(LookupFailureReason.TranslationNotFound, key, culture, resourceManager, arguments, null, null) + ); + } - return value is null ? this.Miss(key, null) : this.FormatValue(value, arguments); + try + { + // Also without arguments: the formatter is the one hook for every resolved value. + return this.MessageFormatter.Format(value, arguments, culture); + } + catch (Exception exception) + { + // Every exception type, deliberately: one bad translation in one language must not take + // down a screen, and formatters from other packages throw their own exception types. + return this.FailLookup( + new(LookupFailureReason.MessageFormatFailed, key, culture, resourceManager, arguments, value, exception) + ); + } } /// @@ -156,20 +200,16 @@ public string Get(Enum value, string keyPrefix = EnumKeyConvention.DefaultEnumKe ArgumentNullException.ThrowIfNull(value); ArgumentNullException.ThrowIfNull(keyPrefix); - return this.Get(EnumKeyConvention.BuildEnumKey(value, keyPrefix)); + return this.Get(EnumKeyConvention.BuildEnumKey(value, keyPrefix), Array.Empty(), null); } /// public string Get(string key, ResourceManager resourceManager) { + // Explicit: the primitive treats a null manager as search-all. ArgumentNullException.ThrowIfNull(resourceManager); - if (string.IsNullOrEmpty(key)) - { - return string.Empty; - } - - return resourceManager.GetString(key, this.currentCulture) ?? this.Miss(key, resourceManager); + return this.Get(key, Array.Empty(), resourceManager); } /// @@ -178,14 +218,7 @@ public string Get(string key, ResourceManager resourceManager, params object?[] ArgumentNullException.ThrowIfNull(resourceManager); ArgumentNullException.ThrowIfNull(arguments); - if (string.IsNullOrEmpty(key)) - { - return string.Empty; - } - - var value = resourceManager.GetString(key, this.currentCulture); - - return value is null ? this.Miss(key, resourceManager) : this.FormatValue(value, arguments); + return this.Get(key, ToMessageArguments(arguments), resourceManager); } /// @@ -199,14 +232,30 @@ public string Get( ArgumentNullException.ThrowIfNull(resourceManager); ArgumentNullException.ThrowIfNull(keyPrefix); - return this.Get(EnumKeyConvention.BuildEnumKey(value, keyPrefix), resourceManager); + return this.Get( + EnumKeyConvention.BuildEnumKey(value, keyPrefix), + Array.Empty(), + resourceManager + ); } /// - public string Get(ResourceKey key) => this.Get(key.Name, key.Manager); + public string Get(ResourceKey key) + { + // Explicit: a default ResourceKey would otherwise fall through to search-all. + ArgumentNullException.ThrowIfNull(key.Manager); + + return this.Get(key.Name, Array.Empty(), key.Manager); + } /// - public string Get(ResourceKey key, params object?[] arguments) => this.Get(key.Name, key.Manager, arguments); + public string Get(ResourceKey key, params object?[] arguments) + { + ArgumentNullException.ThrowIfNull(key.Manager); + ArgumentNullException.ThrowIfNull(arguments); + + return this.Get(key.Name, ToMessageArguments(arguments), key.Manager); + } /// public IReadOnlyList GetAvailableCultures() @@ -247,6 +296,7 @@ public void RegisterResourceManager(ResourceManager resourceManager) // collection being mutated - even from a stray background thread - while keeping the // documented UI-thread contract for culture changes. this.resourceManagers = this.resourceManagers.Add(resourceManager); + this.AdvanceRefreshTriggeringRevision(); } /// @@ -262,6 +312,7 @@ public bool UnregisterResourceManager(ResourceManager resourceManager) } this.resourceManagers = updated; + this.AdvanceRefreshTriggeringRevision(); return true; } @@ -313,17 +364,89 @@ private static void CollectAvailableCultures(ResourceManager resourceManager, Ha private static IReadOnlyList SortCultures(HashSet cultures) => [.. cultures.OrderBy(static culture => culture.Name, StringComparer.OrdinalIgnoreCase)]; + /// Wraps positional format arguments as s without names. + /// The format arguments. + /// One per argument, with its index as position. + private static MessageArgument[] ToMessageArguments(object?[] arguments) => + [.. arguments.Select((value, index) => new MessageArgument(index, null, value))]; + + /// + /// Validates the arguments of a lookup: the array must not be , argument + /// i must have position i, and a name must not be an empty string. Duplicate names are + /// not checked here; that is the concern of whoever builds the array and of the formatter. + /// + /// The arguments to validate. + /// is . + /// An argument has a wrong position or an empty name. + private static void ValidateArguments(MessageArgument[] arguments) + { + ArgumentNullException.ThrowIfNull(arguments); + + for (var index = 0; index < arguments.Length; index++) + { + if (arguments[index].Position != index) + { + throw new ArgumentException( + $"The argument at index {index} has the position {arguments[index].Position}; " + + "an argument's position must be its index in the array.", + nameof(arguments) + ); + } + + if (arguments[index].Name is { Length: 0 }) + { + throw new ArgumentException( + $"The argument at index {index} has an empty name; an empty string is not an argument " + + "name. Use null for a positional-only argument.", + nameof(arguments) + ); + } + } + } + + /// + /// Advances (wrapping around harmlessly) and raises + /// for it and then for the indexer, so bound text re-resolves. + /// + private void AdvanceRefreshTriggeringRevision() + { + this.RefreshTriggeringRevision = unchecked(this.RefreshTriggeringRevision + 1); + + this.PropertyChanged?.Invoke(this, new(nameof(this.RefreshTriggeringRevision))); + this.PropertyChanged?.Invoke(this, new("Item[]")); + } + + /// + /// Reports a failed lookup through , then produces its failure text by + /// calling . Exceptions from event handlers and from the + /// function propagate unchanged. + /// + /// The failed lookup. + /// The failure text. + /// The function returned . + private string FailLookup(LookupFailure failure) + { + this.LookupFailed?.Invoke(this, new(failure)); + + return this.LookupFailureTextBuilder(failure) + ?? throw new InvalidOperationException( + $"{nameof(this.LookupFailureTextBuilder)} returned null for the lookup failure " + + $"{failure.Reason} of the key '{failure.Key}'. Return a string; an empty string shows nothing." + ); + } + /// /// Searches every registered resource manager in registration order and returns the first match, /// or when no registered manager contains the key. /// /// The resource key to resolve. + /// The culture of the lookup. /// The first matching value, or . - private string? FindInRegisteredManagers(string key) + private string? FindInRegisteredResourceManagers(string key, CultureInfo culture) { foreach (var resourceManager in this.resourceManagers) { - var value = resourceManager.GetString(key, this.currentCulture); + var value = resourceManager.GetString(key, culture); if (value is not null) { return value; @@ -332,27 +455,4 @@ private static IReadOnlyList SortCultures(HashSet cult return null; } - - /// Formats a resolved value with in the current culture. - /// The resolved resource value, used as the composite format string. - /// The format arguments. - /// The formatted string. - private string FormatValue(string value, object?[] arguments) => - string.Format(this.currentCulture, value, arguments); - - /// - /// Handles a lookup no resource file could satisfy: raises - /// and produces the sentinel from . - /// - /// The unresolved key. - /// - /// The single manager of a scoped or typed lookup, or for search-all. - /// - /// The miss sentinel, by default !key!. - private string Miss(string key, ResourceManager? resourceManager) - { - this.TranslationNotFound?.Invoke(this, new(key, this.currentCulture, resourceManager)); - - return string.Format(CultureInfo.InvariantCulture, this.MissingTranslationFormat, key); - } } diff --git a/src/ResXLocalization.Core/LookupFailedEventArgs.cs b/src/ResXLocalization.Core/LookupFailedEventArgs.cs new file mode 100644 index 0000000..708796a --- /dev/null +++ b/src/ResXLocalization.Core/LookupFailedEventArgs.cs @@ -0,0 +1,9 @@ +namespace RentADeveloper.ResXLocalization; + +/// Provides the failed lookup reported through . +/// The failed lookup. +public sealed class LookupFailedEventArgs(LookupFailure failure) : EventArgs +{ + /// Gets the failed lookup. + public LookupFailure Failure { get; } = failure; +} diff --git a/src/ResXLocalization.Core/LookupFailure.cs b/src/ResXLocalization.Core/LookupFailure.cs new file mode 100644 index 0000000..fb72ba2 --- /dev/null +++ b/src/ResXLocalization.Core/LookupFailure.cs @@ -0,0 +1,42 @@ +namespace RentADeveloper.ResXLocalization; + +/// +/// Describes a failed lookup. reports it, and +/// receives it to produce the failure text. +/// +/// +/// Why the lookup failed: or +/// . +/// +/// +/// The key as looked up, including an enum lookup's full convention key (for example +/// Enum_FileSortOrder_Ascending); never or empty. +/// +/// +/// The culture of the lookup - the read once at its start; +/// never . +/// +/// +/// The resource manager of a scoped or typed lookup (for a typed lookup, +/// ); for a search-all lookup. +/// +/// +/// The arguments of the lookup; never , empty when the lookup had none. +/// +/// +/// For , the resolved resource value that failed to +/// format; for , . +/// +/// +/// For , what the formatter threw; for +/// , . +/// +public readonly record struct LookupFailure( + LookupFailureReason Reason, + string Key, + CultureInfo Culture, + ResourceManager? ResourceManager, + MessageArgument[] Arguments, + string? Pattern, + Exception? Exception +); diff --git a/src/ResXLocalization.Core/LookupFailureReason.cs b/src/ResXLocalization.Core/LookupFailureReason.cs new file mode 100644 index 0000000..fb03c71 --- /dev/null +++ b/src/ResXLocalization.Core/LookupFailureReason.cs @@ -0,0 +1,13 @@ +namespace RentADeveloper.ResXLocalization; + +/// Why a lookup failed; see . +public enum LookupFailureReason +{ + /// + /// No resource file contains the key, across the complete parent and neutral fallback chain. + /// + TranslationNotFound = 0, + + /// The value was found, but the threw. + MessageFormatFailed = 1, +} diff --git a/src/ResXLocalization.Core/MessageArgument.cs b/src/ResXLocalization.Core/MessageArgument.cs new file mode 100644 index 0000000..eaac74d --- /dev/null +++ b/src/ResXLocalization.Core/MessageArgument.cs @@ -0,0 +1,24 @@ +namespace RentADeveloper.ResXLocalization; + +/// +/// One argument of a lookup: its position, an optional name and its value. The arguments of one lookup +/// are a array, in which argument i has +/// i; no arguments is an empty array. +/// +/// +/// is what an receives - a resolved +/// value. In XAML an argument is declared as <l:Argument>, which becomes a +/// at each conversion, with its index among the children as position +/// and its Name. +/// +/// +/// The argument's index in the arguments array, also when it has a name. +/// +/// +/// for a positional-only argument; otherwise the name under which a formatter +/// that understands names (ICU MessageFormat) also finds the argument. The default +/// () formatter uses positions only and +/// ignores names. +/// +/// The argument's value; may be . +public readonly record struct MessageArgument(int Position, string? Name, object? Value); diff --git a/src/ResXLocalization.Core/PublicAPI.Unshipped.txt b/src/ResXLocalization.Core/PublicAPI.Unshipped.txt index 7dc5c58..f9c94ef 100644 --- a/src/ResXLocalization.Core/PublicAPI.Unshipped.txt +++ b/src/ResXLocalization.Core/PublicAPI.Unshipped.txt @@ -1 +1,78 @@ #nullable enable +*REMOVED*RentADeveloper.ResXLocalization.ILocalizer.MissingTranslationFormat.get -> string! +*REMOVED*RentADeveloper.ResXLocalization.ILocalizer.MissingTranslationFormat.set -> void +*REMOVED*RentADeveloper.ResXLocalization.Localizer.MissingTranslationFormat.get -> string! +*REMOVED*RentADeveloper.ResXLocalization.Localizer.MissingTranslationFormat.set -> void +*REMOVED*RentADeveloper.ResXLocalization.ILocalizer.TranslationNotFound -> System.EventHandler! +*REMOVED*RentADeveloper.ResXLocalization.Localizer.TranslationNotFound -> System.EventHandler? +*REMOVED*RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs +*REMOVED*RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs.Culture.get -> System.Globalization.CultureInfo! +*REMOVED*RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs.Key.get -> string! +*REMOVED*RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs.ResourceManager.get -> System.Resources.ResourceManager? +*REMOVED*RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs.TranslationNotFoundEventArgs(string! key, System.Globalization.CultureInfo! culture, System.Resources.ResourceManager? resourceManager) -> void +override RentADeveloper.ResXLocalization.LookupFailure.GetHashCode() -> int +override RentADeveloper.ResXLocalization.MessageArgument.GetHashCode() -> int +RentADeveloper.ResXLocalization.ILocalizer.Get(string! key, RentADeveloper.ResXLocalization.MessageArgument[]! arguments, System.Resources.ResourceManager? resourceManager) -> string! +RentADeveloper.ResXLocalization.ILocalizer.LookupFailed -> System.EventHandler! +RentADeveloper.ResXLocalization.ILocalizer.LookupFailureTextBuilder.get -> System.Func! +RentADeveloper.ResXLocalization.ILocalizer.LookupFailureTextBuilder.set -> void +RentADeveloper.ResXLocalization.ILocalizer.MessageFormatter.get -> RentADeveloper.ResXLocalization.IMessageFormatter! +RentADeveloper.ResXLocalization.ILocalizer.MessageFormatter.set -> void +RentADeveloper.ResXLocalization.ILocalizer.RefreshTriggeringRevision.get -> int +RentADeveloper.ResXLocalization.IMessageFormatter +RentADeveloper.ResXLocalization.IMessageFormatter.Format(string! pattern, RentADeveloper.ResXLocalization.MessageArgument[]! arguments, System.Globalization.CultureInfo! culture) -> string! +RentADeveloper.ResXLocalization.Localizer.Get(string! key, RentADeveloper.ResXLocalization.MessageArgument[]! arguments, System.Resources.ResourceManager? resourceManager) -> string! +RentADeveloper.ResXLocalization.Localizer.LookupFailed -> System.EventHandler? +RentADeveloper.ResXLocalization.Localizer.LookupFailureTextBuilder.get -> System.Func! +RentADeveloper.ResXLocalization.Localizer.LookupFailureTextBuilder.set -> void +RentADeveloper.ResXLocalization.Localizer.MessageFormatter.get -> RentADeveloper.ResXLocalization.IMessageFormatter! +RentADeveloper.ResXLocalization.Localizer.MessageFormatter.set -> void +RentADeveloper.ResXLocalization.Localizer.RefreshTriggeringRevision.get -> int +RentADeveloper.ResXLocalization.LookupFailedEventArgs +RentADeveloper.ResXLocalization.LookupFailedEventArgs.Failure.get -> RentADeveloper.ResXLocalization.LookupFailure +RentADeveloper.ResXLocalization.LookupFailedEventArgs.LookupFailedEventArgs(RentADeveloper.ResXLocalization.LookupFailure failure) -> void +RentADeveloper.ResXLocalization.LookupFailure +RentADeveloper.ResXLocalization.LookupFailure.Arguments.get -> RentADeveloper.ResXLocalization.MessageArgument[]! +RentADeveloper.ResXLocalization.LookupFailure.Arguments.init -> void +RentADeveloper.ResXLocalization.LookupFailure.Culture.get -> System.Globalization.CultureInfo! +RentADeveloper.ResXLocalization.LookupFailure.Culture.init -> void +RentADeveloper.ResXLocalization.LookupFailure.Deconstruct(out RentADeveloper.ResXLocalization.LookupFailureReason Reason, out string! Key, out System.Globalization.CultureInfo! Culture, out System.Resources.ResourceManager? ResourceManager, out RentADeveloper.ResXLocalization.MessageArgument[]! Arguments, out string? Pattern, out System.Exception? Exception) -> void +RentADeveloper.ResXLocalization.LookupFailure.Equals(RentADeveloper.ResXLocalization.LookupFailure other) -> bool +RentADeveloper.ResXLocalization.LookupFailure.Exception.get -> System.Exception? +RentADeveloper.ResXLocalization.LookupFailure.Exception.init -> void +RentADeveloper.ResXLocalization.LookupFailure.Key.get -> string! +RentADeveloper.ResXLocalization.LookupFailure.Key.init -> void +RentADeveloper.ResXLocalization.LookupFailure.LookupFailure() -> void +RentADeveloper.ResXLocalization.LookupFailure.LookupFailure(RentADeveloper.ResXLocalization.LookupFailureReason Reason, string! Key, System.Globalization.CultureInfo! Culture, System.Resources.ResourceManager? ResourceManager, RentADeveloper.ResXLocalization.MessageArgument[]! Arguments, string? Pattern, System.Exception? Exception) -> void +RentADeveloper.ResXLocalization.LookupFailure.Pattern.get -> string? +RentADeveloper.ResXLocalization.LookupFailure.Pattern.init -> void +RentADeveloper.ResXLocalization.LookupFailure.Reason.get -> RentADeveloper.ResXLocalization.LookupFailureReason +RentADeveloper.ResXLocalization.LookupFailure.Reason.init -> void +RentADeveloper.ResXLocalization.LookupFailure.ResourceManager.get -> System.Resources.ResourceManager? +RentADeveloper.ResXLocalization.LookupFailure.ResourceManager.init -> void +RentADeveloper.ResXLocalization.LookupFailureReason +RentADeveloper.ResXLocalization.LookupFailureReason.MessageFormatFailed = 1 -> RentADeveloper.ResXLocalization.LookupFailureReason +RentADeveloper.ResXLocalization.LookupFailureReason.TranslationNotFound = 0 -> RentADeveloper.ResXLocalization.LookupFailureReason +RentADeveloper.ResXLocalization.MessageArgument +RentADeveloper.ResXLocalization.MessageArgument.Deconstruct(out int Position, out string? Name, out object? Value) -> void +RentADeveloper.ResXLocalization.MessageArgument.Equals(RentADeveloper.ResXLocalization.MessageArgument other) -> bool +RentADeveloper.ResXLocalization.MessageArgument.MessageArgument() -> void +RentADeveloper.ResXLocalization.MessageArgument.MessageArgument(int Position, string? Name, object? Value) -> void +RentADeveloper.ResXLocalization.MessageArgument.Name.get -> string? +RentADeveloper.ResXLocalization.MessageArgument.Name.init -> void +RentADeveloper.ResXLocalization.MessageArgument.Position.get -> int +RentADeveloper.ResXLocalization.MessageArgument.Position.init -> void +RentADeveloper.ResXLocalization.MessageArgument.Value.get -> object? +RentADeveloper.ResXLocalization.MessageArgument.Value.init -> void +RentADeveloper.ResXLocalization.StringFormatMessageFormatter +RentADeveloper.ResXLocalization.StringFormatMessageFormatter.Format(string! pattern, RentADeveloper.ResXLocalization.MessageArgument[]! arguments, System.Globalization.CultureInfo! culture) -> string! +RentADeveloper.ResXLocalization.StringFormatMessageFormatter.StringFormatMessageFormatter() -> void +static readonly RentADeveloper.ResXLocalization.StringFormatMessageFormatter.Instance -> RentADeveloper.ResXLocalization.StringFormatMessageFormatter! +static RentADeveloper.ResXLocalization.LookupFailure.operator !=(RentADeveloper.ResXLocalization.LookupFailure left, RentADeveloper.ResXLocalization.LookupFailure right) -> bool +static RentADeveloper.ResXLocalization.LookupFailure.operator ==(RentADeveloper.ResXLocalization.LookupFailure left, RentADeveloper.ResXLocalization.LookupFailure right) -> bool +static RentADeveloper.ResXLocalization.MessageArgument.operator !=(RentADeveloper.ResXLocalization.MessageArgument left, RentADeveloper.ResXLocalization.MessageArgument right) -> bool +static RentADeveloper.ResXLocalization.MessageArgument.operator ==(RentADeveloper.ResXLocalization.MessageArgument left, RentADeveloper.ResXLocalization.MessageArgument right) -> bool +~override RentADeveloper.ResXLocalization.LookupFailure.Equals(object obj) -> bool +~override RentADeveloper.ResXLocalization.LookupFailure.ToString() -> string +~override RentADeveloper.ResXLocalization.MessageArgument.Equals(object obj) -> bool +~override RentADeveloper.ResXLocalization.MessageArgument.ToString() -> string diff --git a/src/ResXLocalization.Core/StringFormatMessageFormatter.cs b/src/ResXLocalization.Core/StringFormatMessageFormatter.cs new file mode 100644 index 0000000..8977725 --- /dev/null +++ b/src/ResXLocalization.Core/StringFormatMessageFormatter.cs @@ -0,0 +1,31 @@ +namespace RentADeveloper.ResXLocalization; + +/// +/// The default : returns a value without arguments unchanged, and +/// formats a value with arguments with +/// in the lookup's culture. Argument +/// names are ignored; only positions count. +/// +public sealed class StringFormatMessageFormatter : IMessageFormatter +{ + /// The shared instance, and the default of . + public static readonly StringFormatMessageFormatter Instance = new(); + + /// + /// Returns unchanged when is empty; + /// otherwise formats it as a composite format string with the argument values in + /// . + /// + /// The resolved resource value. + /// The arguments of the lookup; never . + /// The culture to format in. + /// The formatted string, or when there are no arguments. + /// + /// is not a valid composite format string for + /// . The localizer catches it and returns the failure text. + /// + public string Format(string pattern, MessageArgument[] arguments, CultureInfo culture) => + arguments.Length == 0 + ? pattern + : string.Format(culture, pattern, arguments.Select(argument => argument.Value).ToArray()); +} diff --git a/src/ResXLocalization.Core/TranslationNotFoundEventArgs.cs b/src/ResXLocalization.Core/TranslationNotFoundEventArgs.cs deleted file mode 100644 index 28d2bc1..0000000 --- a/src/ResXLocalization.Core/TranslationNotFoundEventArgs.cs +++ /dev/null @@ -1,42 +0,0 @@ -namespace RentADeveloper.ResXLocalization; - -/// -/// Provides the details of a lookup that no resource file could satisfy, raised through -/// . Subscribe to log missing translations during -/// development. Normal .NET parent/neutral resource fallback runs first, so this event means the -/// key was unresolved across the complete fallback chain; the lookup itself still returns the -/// missing-translation sentinel (see ). -/// -public sealed class TranslationNotFoundEventArgs : EventArgs -{ - /// - /// Initializes a new instance of the class. - /// - /// The resource key that could not be resolved. - /// The culture the lookup ran against. - /// - /// The single resource manager of a scoped or typed lookup, or for a - /// search-all lookup. - /// - public TranslationNotFoundEventArgs(string key, CultureInfo culture, ResourceManager? resourceManager) - { - ArgumentNullException.ThrowIfNull(key); - ArgumentNullException.ThrowIfNull(culture); - - this.Key = key; - this.Culture = culture; - this.ResourceManager = resourceManager; - } - - /// Gets the culture the lookup ran against. - public CultureInfo Culture { get; } - - /// Gets the resource key that could not be resolved. - public string Key { get; } - - /// - /// Gets the single resource manager of a scoped or typed lookup, or when - /// the miss came from a search-all lookup across every registered resource manager. - /// - public ResourceManager? ResourceManager { get; } -} diff --git a/src/ResXLocalization.WPF/LocalizedFormattedStringConverter.cs b/src/ResXLocalization.WPF/LocalizedFormattedStringConverter.cs index 64e06e1..4fee4cb 100644 --- a/src/ResXLocalization.WPF/LocalizedFormattedStringConverter.cs +++ b/src/ResXLocalization.WPF/LocalizedFormattedStringConverter.cs @@ -31,11 +31,7 @@ internal sealed class LocalizedFormattedStringConverter(Func resolve, Fu /// ). /// /// - /// The localized, possibly formatted string, or when - /// the format string does not (yet) match the supplied arguments - routine while multi-argument - /// bindings still deliver their values one by one - so the mismatch never throws into the - /// property system mid-delivery and the text resolves cleanly on the next argument or culture - /// change. + /// The localized, possibly formatted string, or the failure text (see ). /// public object Convert(object?[] values, Type targetType, object? parameter, CultureInfo culture) { @@ -44,16 +40,9 @@ public object Convert(object?[] values, Type targetType, object? parameter, Cult return resolve(); } - try - { - var arguments = LocalizeArgs.GetArguments(element); + var arguments = LocalizeArgs.GetArguments(element); - return arguments is null ? resolve() : resolveFormatted(arguments); - } - catch (FormatException) - { - return DependencyProperty.UnsetValue; - } + return arguments is null ? resolve() : resolveFormatted(arguments); } /// Not supported; this converter is one-way. diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeArgsTests.cs b/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeArgsTests.cs index efdedb5..ade1be8 100644 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeArgsTests.cs +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeArgsTests.cs @@ -1,4 +1,5 @@ using Avalonia.Controls.Templates; +using RentADeveloper.ResXLocalization.Core.Tests; namespace RentADeveloper.ResXLocalization.Avalonia.Sample.Tests; @@ -53,6 +54,28 @@ public void CultureChange_ReFormats_Live() textBlock.Text.Should().Be("3 Personen eingeladen"); } + [AvaloniaFact] + public void FormattingFailure_RendersTheFailureText_AndRaisesLookupFailed() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + using var monitor = Localizer.Current.Monitor(); + + var textBlock = TestSupport.BindLocalizedTextWithTarget(new("FormatBroken")); + LocalizeArgs.SetArg0(textBlock, 1); + TestSupport.PumpUi(); + + textBlock.Text.Should().Be("!FormatBroken!"); + monitor + .LookupFailures() + .Should() + .NotBeEmpty() + .And.OnlyContain(failure => + failure.Reason == LookupFailureReason.MessageFormatFailed + && failure.Key == "FormatBroken" + && failure.Exception is FormatException + ); + } + [AvaloniaFact] public void HigherSlotAlone_FillsInteriorGapsWithNull() { diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeExtensionTests.cs b/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeExtensionTests.cs index 7e7a8f3..fb9fe27 100644 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeExtensionTests.cs +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeExtensionTests.cs @@ -1,3 +1,5 @@ +using RentADeveloper.ResXLocalization.Core.Tests; + namespace RentADeveloper.ResXLocalization.Avalonia.Sample.Tests; /// @@ -40,6 +42,35 @@ public void KeyProperty_WithResourceManager_ResolvesScoped() textBlock.Text.Should().Be("Hello and welcome!"); } + [AvaloniaFact] + public void NoArguments_PassesTheValueThroughTheFormatter() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var previous = Localizer.Current.MessageFormatter; + var formatter = new CapturingFormatter(); + Localizer.Current.MessageFormatter = formatter; + + try + { + var textBlock = TestSupport.BindLocalizedText(new("CurlyNoArgs")); + TestSupport.PumpUi(); + + textBlock.Text.Should().Be("Literal {braces} stay"); + formatter.Calls.Should().NotBeEmpty(); + formatter + .Calls.Should() + .AllSatisfy(call => + { + call.Pattern.Should().Be("Literal {braces} stay"); + call.Arguments.Should().BeEmpty(); + }); + } + finally + { + Localizer.Current.MessageFormatter = previous; + } + } + [AvaloniaFact] public void ResourceKeyConstructor_ResolvesViaTypedKey() { diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/MainWindowViewModelTests.cs b/tests/ResXLocalization.Avalonia.Sample.Tests/MainWindowViewModelTests.cs index e227959..70f7364 100644 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/MainWindowViewModelTests.cs +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/MainWindowViewModelTests.cs @@ -33,8 +33,7 @@ public void SelectingLanguage_RecomputesAndNotifies_ReadOutProperties() TestSupport.ResetToEnglishWithTestCatalogs(); using var viewModel = new MainWindowViewModel(Localizer.Current); - var changed = new List(); - viewModel.PropertyChanged += (_, args) => changed.Add(args.PropertyName ?? string.Empty); + using var monitor = viewModel.Monitor(); viewModel.SelectedLanguage = TestSupport.German; @@ -42,9 +41,9 @@ public void SelectingLanguage_RecomputesAndNotifies_ReadOutProperties() viewModel.SortOrderViaSearchAllDefaultPrefix.Should().Be("Aufsteigend"); viewModel.PoweredByViaTypedKey.Should().Be("Bereitgestellt von rent-a-developer"); - changed.Should().Contain(nameof(MainWindowViewModel.GreetingViaSearchAll)); - changed.Should().Contain(nameof(MainWindowViewModel.SortOrderViaSearchAllDefaultPrefix)); - changed.Should().Contain(nameof(MainWindowViewModel.PoweredByViaTypedKey)); + monitor.Should().RaisePropertyChangeFor(model => model.GreetingViaSearchAll); + monitor.Should().RaisePropertyChangeFor(model => model.SortOrderViaSearchAllDefaultPrefix); + monitor.Should().RaisePropertyChangeFor(model => model.PoweredByViaTypedKey); } [AvaloniaFact] @@ -64,15 +63,14 @@ public void SelectingSortOrder_RecomputesAndNotifies_EnumReadOuts() TestSupport.ResetToEnglishWithTestCatalogs(); using var viewModel = new MainWindowViewModel(Localizer.Current); - var changed = new List(); - viewModel.PropertyChanged += (_, args) => changed.Add(args.PropertyName ?? string.Empty); + using var monitor = viewModel.Monitor(); viewModel.SelectedFileSortOrder = FileSortOrder.Descending; viewModel.SortOrderViaSearchAllDefaultPrefix.Should().Be("Descending"); viewModel.SortOrderViaSearchAllCustomPrefix.Should().Be("Z to A"); - changed.Should().Contain(nameof(MainWindowViewModel.SortOrderViaSearchAllDefaultPrefix)); - changed.Should().Contain(nameof(MainWindowViewModel.SortOrderViaScopedCustomPrefix)); + monitor.Should().RaisePropertyChangeFor(model => model.SortOrderViaSearchAllDefaultPrefix); + monitor.Should().RaisePropertyChangeFor(model => model.SortOrderViaScopedCustomPrefix); } [AvaloniaFact] diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/ResXLocalization.Avalonia.Sample.Tests.csproj b/tests/ResXLocalization.Avalonia.Sample.Tests/ResXLocalization.Avalonia.Sample.Tests.csproj index 305b90c..6d82861 100644 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/ResXLocalization.Avalonia.Sample.Tests.csproj +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/ResXLocalization.Avalonia.Sample.Tests.csproj @@ -14,4 +14,10 @@ + + + + + + diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/Resources/Catalog.resx b/tests/ResXLocalization.Avalonia.Sample.Tests/Resources/Catalog.resx index 7d9ac5c..4aaa74d 100644 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/Resources/Catalog.resx +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/Resources/Catalog.resx @@ -9,6 +9,7 @@ * FormatPair - a two-argument composite format string, for the LocalizeArgs tests. * FormatGap - references {0} and {2}, to test a higher argument slot used alone. * CurlyNoArgs - literal braces, to prove the no-argument path never composite-formats. + * FormatBroken - a malformed composite format string, to test the formatting-failure path. --> text/microsoft-resx @@ -40,4 +41,7 @@ Literal {braces} stay + + Broken {0 + diff --git a/tests/ResXLocalization.Core.Tests/CapturingFormatter.cs b/tests/ResXLocalization.Core.Tests/CapturingFormatter.cs new file mode 100644 index 0000000..63d7b9a --- /dev/null +++ b/tests/ResXLocalization.Core.Tests/CapturingFormatter.cs @@ -0,0 +1,23 @@ +namespace RentADeveloper.ResXLocalization.Core.Tests; + +/// +/// An that records every call in order and otherwise behaves like the +/// default formatter: it returns the pattern unchanged without arguments and formats it with +/// otherwise, so installing it changes no +/// output. Compiled into the UI test suites too. +/// +internal sealed class CapturingFormatter : IMessageFormatter +{ + /// Gets the recorded calls, in order. + public List<(string Pattern, MessageArgument[] Arguments, CultureInfo Culture)> Calls { get; } = []; + + /// + public string Format(string pattern, MessageArgument[] arguments, CultureInfo culture) + { + this.Calls.Add((pattern, arguments, culture)); + + return arguments.Length == 0 + ? pattern + : string.Format(culture, pattern, arguments.Select(argument => argument.Value).ToArray()); + } +} diff --git a/tests/ResXLocalization.Core.Tests/IsolatedInstanceTests.cs b/tests/ResXLocalization.Core.Tests/IsolatedInstanceTests.cs index aa101b2..abb2fa4 100644 --- a/tests/ResXLocalization.Core.Tests/IsolatedInstanceTests.cs +++ b/tests/ResXLocalization.Core.Tests/IsolatedInstanceTests.cs @@ -26,15 +26,14 @@ public void CultureChanges_DoNotLeakIntoOtherInstances() { var first = this.resources.CreateLocalizer(); var second = this.resources.CreateLocalizer(); - var secondNotified = false; - second.CultureChanged += (_, _) => secondNotified = true; + using var secondMonitor = second.Monitor(); first.CurrentCulture = TestResources.German; first.Get("Greeting").Should().Be("Hallo und willkommen!"); second.Get("Greeting").Should().Be("Hello and welcome!"); second.CurrentCulture.Should().Be(TestResources.English); - secondNotified.Should().BeFalse(); + secondMonitor.Should().NotRaise(nameof(ILocalizer.CultureChanged)); } [Fact] diff --git a/tests/ResXLocalization.Core.Tests/LocalizerContractTests.cs b/tests/ResXLocalization.Core.Tests/LocalizerContractTests.cs index 340905b..0710a18 100644 --- a/tests/ResXLocalization.Core.Tests/LocalizerContractTests.cs +++ b/tests/ResXLocalization.Core.Tests/LocalizerContractTests.cs @@ -29,16 +29,14 @@ public void AssigningNullCulture_Throws() public void AssigningTheSameCulture_RaisesNoNotifications() { var localizer = this.resources.CreateLocalizer(); - var eventCount = 0; - localizer.PropertyChanged += (_, _) => eventCount++; - localizer.CultureChanged += (_, _) => eventCount++; + using var monitor = localizer.Monitor(); // Both the very same instance and a distinct-but-equal culture must be treated as "no change". var sameInstance = localizer.CurrentCulture; localizer.CurrentCulture = sameInstance; localizer.CurrentCulture = new("en"); - eventCount.Should().Be(0); + monitor.EventLog().Should().BeEmpty(); localizer.CurrentCulture.Should().BeSameAs(sameInstance); } @@ -46,27 +44,35 @@ public void AssigningTheSameCulture_RaisesNoNotifications() public void CultureChange_RaisesCurrentCulture_ThenIndexer_ThenCultureChanged() { var localizer = this.resources.CreateLocalizer(); - var events = new List(); - localizer.PropertyChanged += (_, args) => events.Add($"PropertyChanged({args.PropertyName})"); - localizer.CultureChanged += (_, _) => events.Add("CultureChanged"); + using var monitor = localizer.Monitor(); localizer.CurrentCulture = TestResources.German; - events.Should().Equal("PropertyChanged(CurrentCulture)", "PropertyChanged(Item[])", "CultureChanged"); + monitor + .EventLog() + .Should() + .Equal( + "PropertyChanged(CurrentCulture)", + "PropertyChanged(RefreshTriggeringRevision)", + "PropertyChanged(Item[])", + "CultureChanged" + ); } [Fact] public void CultureChanged_CarriesOldAndNewCulture() { var localizer = this.resources.CreateLocalizer(); - CultureChangedEventArgs? captured = null; - localizer.CultureChanged += (_, args) => captured = args; + using var monitor = localizer.Monitor(); localizer.CurrentCulture = TestResources.German; - captured.Should().NotBeNull(); - captured.OldCulture.Should().Be(TestResources.English); - captured.NewCulture.Should().Be(TestResources.German); + monitor + .Should() + .Raise(nameof(ILocalizer.CultureChanged)) + .WithArgs(args => + args.OldCulture.Equals(TestResources.English) && args.NewCulture.Equals(TestResources.German) + ); } [Fact] diff --git a/tests/ResXLocalization.Core.Tests/LocalizerFormatTests.cs b/tests/ResXLocalization.Core.Tests/LocalizerFormatTests.cs index 65515ba..ccde062 100644 --- a/tests/ResXLocalization.Core.Tests/LocalizerFormatTests.cs +++ b/tests/ResXLocalization.Core.Tests/LocalizerFormatTests.cs @@ -1,14 +1,30 @@ +using System.ComponentModel; +using System.Reflection; + namespace RentADeveloper.ResXLocalization.Core.Tests; /// -/// Exercises the format-argument overloads: the resolved resource value is treated as a composite -/// format string and formatted with the localizer's current culture, so numbers and dates inside a -/// translation follow the selected language. +/// Exercises the format-argument overloads and the formatting pipeline: every resolved value passes +/// through , which by default formats it as a composite format +/// string in the localizer's current culture when the lookup has arguments, and a formatting failure +/// returns the failure text and raises . /// public class LocalizerFormatTests { private readonly TestResources resources = new(); + [Fact] + public void EmptyArgumentsArray_ReturnsTheValueUnformatted() + { + var localizer = this.resources.CreateLocalizer(); + using var monitor = localizer.Monitor(); + + localizer.Get("EscapedBraces", Array.Empty()).Should().Be("Literal {{braces}} for {0}"); + monitor.Should().NotRaise(nameof(ILocalizer.LookupFailed)); + + localizer.Get("EscapedBraces", "x").Should().Be("Literal {braces} for x"); + } + [Fact] public void EmptyKey_ReturnsEmptyString() { @@ -18,15 +34,135 @@ public void EmptyKey_ReturnsEmptyString() } [Fact] - public void InvalidCompositeFormat_FailsLoudly() + public void FormatterException_OfAnyType_ReturnsTheFailureText_WithThatException() + { + var localizer = this.resources.CreateLocalizer(); + var exception = new InvalidOperationException("boom"); + localizer.MessageFormatter = new ThrowingFormatter(exception); + using var monitor = localizer.Monitor(); + + localizer.Get("Greeting", 1).Should().Be("!Greeting!"); + + monitor.LookupFailures().Should().ContainSingle().Which.Exception.Should().BeSameAs(exception); + } + + [Fact] + public void FormatterException_WithoutArguments_ReturnsTheFailureText_AndRaisesLookupFailed() + { + var localizer = this.resources.CreateLocalizer(); + var exception = new InvalidOperationException("boom"); + localizer.MessageFormatter = new ThrowingFormatter(exception); + using var monitor = localizer.Monitor(); + + localizer.Get("Greeting").Should().Be("!Greeting!"); + localizer.Get("Greeting", this.resources.Catalog).Should().Be("!Greeting!"); + + var raised = monitor.LookupFailures(); + raised.Should().HaveCount(2); + raised + .Should() + .AllSatisfy(failure => + { + failure.Reason.Should().Be(LookupFailureReason.MessageFormatFailed); + failure.Arguments.Should().BeEmpty(); + failure.Exception.Should().BeSameAs(exception); + }); + raised[0].ResourceManager.Should().BeNull(); + raised[1].ResourceManager.Should().BeSameAs(this.resources.Catalog); + } + + [Fact] + public void Formatter_ReceivesThePatternTheArgumentsAndTheCulture() + { + var localizer = this.resources.CreateLocalizer(); + var formatter = new CapturingFormatter(); + localizer.MessageFormatter = formatter; + + localizer.Get("ItemsFound", 1, "x"); + + var call = formatter.Calls.Should().ContainSingle().Subject; + call.Pattern.Should().Be("Found {0:N1} items in {1}."); + call.Arguments.Should().Equal(new MessageArgument(0, null, 1), new MessageArgument(1, null, "x")); + call.Culture.Should().Be(TestResources.English); + } + + [Fact] + public void FormattingFailure_InTheCurrentCulture_ReportsThatCultureAndPattern() + { + var localizer = this.resources.CreateLocalizer(); + localizer.CurrentCulture = TestResources.German; + using var monitor = localizer.Monitor(); + + localizer.Get("Malformed", 1).Should().Be("!Malformed!"); + + var failure = monitor.LookupFailures().Should().ContainSingle().Subject; + failure.Culture.Should().Be(TestResources.German); + failure.Pattern.Should().Be("Kaputt {0"); + } + + [Fact] + public void FormattingFailure_ReturnsTheFailureText_AndRaisesLookupFailed() + { + var localizer = this.resources.CreateLocalizer(); + using var monitor = localizer.Monitor(); + + // The template needs two arguments; one is a resource defect. It is reported, not thrown. + localizer.Get("ItemsFound", "only-one").Should().Be("!ItemsFound!"); + + var failure = monitor.LookupFailures().Should().ContainSingle().Subject; + failure.Reason.Should().Be(LookupFailureReason.MessageFormatFailed); + failure.Key.Should().Be("ItemsFound"); + failure.Culture.Should().Be(TestResources.English); + failure.ResourceManager.Should().BeNull(); + failure.Pattern.Should().Be("Found {0:N1} items in {1}."); + failure.Arguments.Should().Equal(new MessageArgument(0, null, "only-one")); + failure.Exception.Should().BeOfType(); + } + + [Fact] + public void FormattingFailure_ScopedAndTyped_ReportsTheManager() + { + var localizer = this.resources.CreateLocalizer(); + using var monitor = localizer.Monitor(); + + localizer.Get("Malformed", this.resources.Catalog, 1).Should().Be("!Malformed!"); + localizer.Get(new ResourceKey("Malformed", this.resources.Catalog), 1).Should().Be("!Malformed!"); + + monitor + .LookupFailures() + .Should() + .HaveCount(2) + .And.AllSatisfy(failure => failure.ResourceManager.Should().BeSameAs(this.resources.Catalog)); + } + + [Fact] + public void MessageFormatter_DefaultsToAFormatter_AndRejectsNull() + { + var localizer = new Localizer(); + var formatter = localizer.MessageFormatter; + + formatter.Should().NotBeNull(); + + var act = () => localizer.MessageFormatter = null!; + + act.Should().Throw(); + localizer.MessageFormatter.Should().BeSameAs(formatter); + } + + [Fact] + public void MissingKey_ReportsTranslationNotFound_NotMessageFormatFailed() { var localizer = this.resources.CreateLocalizer(); + using var monitor = localizer.Monitor(); - // The template needs two arguments; supplying none is a resource defect, not a missing - // translation, so it must throw rather than degrade silently. - var act = () => localizer.Get("ItemsFound", arguments: []); + localizer.Get("ThisKeyDoesNotExist", 1, 2).Should().Be("!ThisKeyDoesNotExist!"); + localizer.Get("ThisKeyDoesNotExist", this.resources.Catalog, 1, 2).Should().Be("!ThisKeyDoesNotExist!"); - act.Should().Throw(); + monitor + .LookupFailures() + .Should() + .HaveCount(2) + .And.AllSatisfy(failure => failure.Reason.Should().Be(LookupFailureReason.TranslationNotFound)); } [Fact] @@ -38,6 +174,67 @@ public void MissingKey_ReturnsTheSentinelUnformatted() localizer.Get("ThisKeyDoesNotExist", this.resources.Catalog, 1, 2).Should().Be("!ThisKeyDoesNotExist!"); } + [Fact] + public void MissingKey_TheFailureText_NeverReachesTheFormatter() + { + var localizer = this.resources.CreateLocalizer(); + localizer.LookupFailureTextBuilder = failure => $"{{{failure.Key}}}"; + var formatter = new CapturingFormatter(); + localizer.MessageFormatter = formatter; + using var monitor = localizer.Monitor(); + + localizer.Get("ThisKeyDoesNotExist", 1).Should().Be("{ThisKeyDoesNotExist}"); + + formatter.Calls.Should().BeEmpty(); + monitor + .LookupFailures() + .Should() + .ContainSingle() + .Which.Reason.Should() + .Be(LookupFailureReason.TranslationNotFound); + } + + [Fact] + public void NoArgumentMembers_PassTheValueThroughTheFormatter_WithAnEmptyArray() + { + var localizer = this.resources.CreateLocalizer(); + var formatter = new CapturingFormatter(); + localizer.MessageFormatter = formatter; + var catalog = this.resources.Catalog; + + string[] results = + [ + localizer.Get("Greeting"), + localizer["Greeting"], + localizer.Get("Greeting", catalog), + localizer.Get(new ResourceKey("Greeting", catalog)), + localizer.Get(TestSortOrder.Ascending), + localizer.Get(TestSortOrder.Ascending, catalog), + ]; + localizer.Get("ThisKeyDoesNotExist"); + localizer.Get(string.Empty); + + formatter + .Calls.Select(call => call.Pattern) + .Should() + .Equal( + "Hello and welcome!", + "Hello and welcome!", + "Hello and welcome!", + "Hello and welcome!", + "Ascending", + "Ascending" + ); + formatter + .Calls.Should() + .AllSatisfy(call => + { + call.Arguments.Should().BeEmpty(); + call.Culture.Should().Be(TestResources.English); + }); + results.Should().Equal(formatter.Calls.Select(call => call.Pattern)); + } + [Fact] public void NullArgumentsArray_Throws() { @@ -48,6 +245,94 @@ public void NullArgumentsArray_Throws() act.Should().Throw(); } + [Fact] + public void NullArgumentsArray_Throws_ForEveryOverloadWithArguments() + { + var localizer = this.resources.CreateLocalizer(); + var catalog = this.resources.Catalog; + + var searchAll = () => localizer.Get("Greeting", (object?[])null!); + var scoped = () => localizer.Get("Greeting", catalog, (object?[])null!); + var typed = () => localizer.Get(new ResourceKey("Greeting", catalog), (object?[])null!); + + searchAll.Should().Throw(); + scoped.Should().Throw(); + typed.Should().Throw(); + } + + [Fact] + public void NullManagerOrDefaultResourceKey_Throws_InsteadOfSearchingAll() + { + var localizer = this.resources.CreateLocalizer(); + + var scoped = () => localizer.Get("Greeting", (ResourceManager)null!); + var typed = () => localizer.Get(default(ResourceKey)); + var typedWithArguments = () => localizer.Get(default(ResourceKey), 1); + var enumScoped = () => localizer.Get(TestSortOrder.Ascending, (ResourceManager)null!, "Enum_"); + + scoped.Should().Throw(); + typed.Should().Throw(); + typedWithArguments.Should().Throw(); + enumScoped.Should().Throw(); + } + + [Fact] + public void Primitive_DefaultFormatter_UsesPositions_AndIgnoresNames() + { + var localizer = this.resources.CreateLocalizer(); + + localizer + .Get("ItemsFound", [new(0, "Count", 1.5), new(1, "Where", "x")], null) + .Should() + .Be("Found 1.5 items in x."); + } + + [Fact] + public void Primitive_IsHiddenFromIntelliSense() + { + Type[] parameters = [typeof(string), typeof(MessageArgument[]), typeof(ResourceManager)]; + + foreach (var type in new[] { typeof(ILocalizer), typeof(Localizer) }) + { + type.GetMethod(nameof(ILocalizer.Get), parameters)! + .GetCustomAttribute()! + .State.Should() + .Be(EditorBrowsableState.Never); + } + } + + [Fact] + public void Primitive_RejectsNullArguments_ReturnsEmptyForAnEmptyKey_AndResolvesWithoutArguments() + { + var localizer = this.resources.CreateLocalizer(); + + // The cast is required: Get("Greeting", null, null) is ambiguous with the scoped params overload. + var act = () => localizer.Get("Greeting", (MessageArgument[])null!, null); + + act.Should().Throw(); + localizer.Get(string.Empty, [new(0, null, 1)], null).Should().BeEmpty(); + localizer.Get("Greeting", Array.Empty(), null).Should().Be("Hello and welcome!"); + } + + [Fact] + public void Primitive_ValidatesPositionsAndEmptyNames_Only() + { + var localizer = this.resources.CreateLocalizer(); + + var wrongPosition = () => localizer.Get("Greeting", [new(1, null, 1)], null); + var repeatedPosition = () => localizer.Get("Greeting", [new(0, null, 1), new(0, null, 2)], null); + var emptyName = () => localizer.Get("Greeting", [new(0, "", 1)], null); + + wrongPosition.Should().Throw().WithParameterName("arguments"); + repeatedPosition.Should().Throw().WithParameterName("arguments"); + emptyName.Should().Throw().WithParameterName("arguments"); + + // Duplicate names and a name that looks like a position are not the primitive's concern, and a + // null value is fine. + localizer.Get("Greeting", [new(0, "A", 1), new(1, "A", 2)], null).Should().Be("Hello and welcome!"); + localizer.Get("Greeting", [new(0, "0", null)], null).Should().Be("Hello and welcome!"); + } + [Fact] public void Scoped_FormatsTheResolvedValue() { @@ -77,4 +362,13 @@ public void TypedKey_FormatsTheResolvedValue() localizer.Get(key, 2.0, "Music").Should().Be("Found 2.0 items in Music."); } + + /// A formatter that throws the given exception for every call. + /// The exception to throw. + private sealed class ThrowingFormatter(Exception exception) : IMessageFormatter + { + private readonly Exception exception = exception; + + public string Format(string pattern, MessageArgument[] arguments, CultureInfo culture) => throw this.exception; + } } diff --git a/tests/ResXLocalization.Core.Tests/LocalizerMissTests.cs b/tests/ResXLocalization.Core.Tests/LocalizerMissTests.cs index 82b0b9e..4ad0458 100644 --- a/tests/ResXLocalization.Core.Tests/LocalizerMissTests.cs +++ b/tests/ResXLocalization.Core.Tests/LocalizerMissTests.cs @@ -1,107 +1,230 @@ namespace RentADeveloper.ResXLocalization.Core.Tests; /// -/// Exercises the miss diagnostics: the event and the -/// configurable sentinel. +/// Exercises the lookup-failure diagnostics: the event and +/// the failure text produced by the configurable , for a +/// missing translation and for a formatting failure. /// public class LocalizerMissTests { private readonly TestResources resources = new(); [Fact] - public void EnumMiss_RaisesTranslationNotFound_WithTheConventionKey() + public void EnumMiss_RaisesLookupFailed_WithTheConventionKey() { var localizer = this.resources.CreateLocalizer(); - var raised = new List(); - localizer.TranslationNotFound += (_, args) => raised.Add(args); + using var monitor = localizer.Monitor(); localizer.Get(TestSortOrder.Descending); - raised.Should().ContainSingle().Which.Key.Should().Be("Enum_TestSortOrder_Descending"); + monitor.LookupFailures().Should().ContainSingle().Which.Key.Should().Be("Enum_TestSortOrder_Descending"); } [Fact] - public void MissingSatelliteEntry_FallsBackToNeutral_WithoutMissEvent() + public void LookupFailureTextBuilder_AcceptsAFunctionWithoutTheKey() + { + var localizer = this.resources.CreateLocalizer(); + localizer.LookupFailureTextBuilder = _ => "missing"; + + localizer.Get("ThisKeyDoesNotExist").Should().Be("missing"); + } + + [Fact] + public void LookupFailureTextBuilder_CustomizesTheFailureText() + { + var localizer = this.resources.CreateLocalizer(); + localizer.LookupFailureTextBuilder = failure => $"[missing: {failure.Key}]"; + + localizer.Get("ThisKeyDoesNotExist").Should().Be("[missing: ThisKeyDoesNotExist]"); + localizer.Get("ThisKeyDoesNotExist", this.resources.Catalog).Should().Be("[missing: ThisKeyDoesNotExist]"); + localizer.Get(TestSortOrder.Descending).Should().Be("[missing: Enum_TestSortOrder_Descending]"); + localizer.Get(string.Empty).Should().BeEmpty(); + } + + [Fact] + public void LookupFailureTextBuilder_DefaultsToBangKeyBang() + { + var first = new Localizer().LookupFailureTextBuilder; + + first.Should().BeSameAs(new Localizer().LookupFailureTextBuilder); + first(new(LookupFailureReason.TranslationNotFound, "Key", CultureInfo.InvariantCulture, null, [], null, null)) + .Should() + .Be("!Key!"); + } + + [Fact] + public void LookupFailureTextBuilder_FormattingFailure_ReceivesTheFailure_AfterLookupFailed() { var localizer = this.resources.CreateLocalizer(); localizer.CurrentCulture = TestResources.German; - var raised = 0; - localizer.TranslationNotFound += (_, _) => raised++; + using var monitor = localizer.Monitor(); + var calls = new List(); + var eventRaisedBeforeCall = false; + localizer.LookupFailureTextBuilder = failure => + { + calls.Add(failure); + eventRaisedBeforeCall = monitor.LookupFailures().Length == 1; + return "[broken]"; + }; - localizer.Get("NeutralOnly", this.resources.Catalog).Should().Be("Neutral fallback value"); - raised.Should().Be(0); + localizer.Get("Malformed", this.resources.Catalog, 1).Should().Be("[broken]"); + + eventRaisedBeforeCall.Should().BeTrue(); + var call = calls.Should().ContainSingle().Subject; + call.Should().Be(monitor.LookupFailures().Single()); + call.Reason.Should().Be(LookupFailureReason.MessageFormatFailed); + call.Key.Should().Be("Malformed"); + call.Culture.Should().Be(TestResources.German); + call.ResourceManager.Should().BeSameAs(this.resources.Catalog); + call.Arguments.Should().Equal(new MessageArgument(0, null, 1)); + call.Pattern.Should().Be("Kaputt {0"); + call.Exception.Should().BeOfType(); } [Fact] - public void MissingTranslationFormat_AcceptsAFormatWithoutAPlaceholder() + public void LookupFailureTextBuilder_FormattingFailure_UsesTheDefault() { var localizer = this.resources.CreateLocalizer(); - localizer.MissingTranslationFormat = "missing"; - localizer.Get("ThisKeyDoesNotExist").Should().Be("missing"); + localizer.Get("Malformed", 1).Should().Be("!Malformed!"); } [Fact] - public void MissingTranslationFormat_CustomizesTheSentinel() + public void LookupFailureTextBuilder_IsNotCalled_ForAResolvedValue() { var localizer = this.resources.CreateLocalizer(); - localizer.MissingTranslationFormat = "[missing: {0}]"; + var calls = 0; + localizer.LookupFailureTextBuilder = failure => + { + calls++; + return failure.Key; + }; - localizer.Get("ThisKeyDoesNotExist").Should().Be("[missing: ThisKeyDoesNotExist]"); - localizer.Get("ThisKeyDoesNotExist", this.resources.Catalog).Should().Be("[missing: ThisKeyDoesNotExist]"); + localizer.Get("Greeting").Should().Be("Hello and welcome!"); + calls.Should().Be(0); } [Fact] - public void MissingTranslationFormat_DefaultsToBangKeyBang() => - new Localizer().MissingTranslationFormat.Should().Be("!{0}!"); + public void LookupFailureTextBuilder_NullResult_Throws() + { + var localizer = this.resources.CreateLocalizer(); + localizer.LookupFailureTextBuilder = _ => null!; + + var miss = () => localizer.Get("ThisKeyDoesNotExist"); + var formatFailure = () => localizer.Get("Malformed", 1); + + miss.Should() + .Throw() + .Which.Message.Should() + .ContainAll("LookupFailureTextBuilder", "TranslationNotFound", "ThisKeyDoesNotExist"); + formatFailure + .Should() + .Throw() + .Which.Message.Should() + .ContainAll("LookupFailureTextBuilder", "MessageFormatFailed", "Malformed"); + } [Fact] - public void MissingTranslationFormat_RejectsAnInvalidFormat_Eagerly() + public void LookupFailureTextBuilder_ReceivesTheDetailsOfTheMiss() { var localizer = this.resources.CreateLocalizer(); + localizer.CurrentCulture = TestResources.German; + using var monitor = localizer.Monitor(); + var calls = new List(); + localizer.LookupFailureTextBuilder = failure => + { + calls.Add(failure); + return string.Empty; + }; + var catalog = this.resources.Catalog; - // "{1}" references a second argument that the sentinel never supplies: reject it at assignment, - // not later at the first miss. - var act = () => localizer.MissingTranslationFormat = "{1}"; + localizer.Get("ThisKeyDoesNotExist"); + localizer.Get("ThisKeyDoesNotExist", catalog); + localizer.Get(new ResourceKey("ThisKeyDoesNotExist", catalog)); + localizer.Get(TestSortOrder.Descending, catalog); - act.Should().Throw().WithInnerException(); - localizer.MissingTranslationFormat.Should().Be("!{0}!"); + calls + .Select(call => (call.Key, call.Culture, call.ResourceManager)) + .Should() + .Equal( + ("ThisKeyDoesNotExist", TestResources.German, (ResourceManager?)null), + ("ThisKeyDoesNotExist", TestResources.German, catalog), + ("ThisKeyDoesNotExist", TestResources.German, catalog), + ("Enum_TestSortOrder_Descending", TestResources.German, catalog) + ); + calls + .Should() + .AllSatisfy(call => + { + call.Reason.Should().Be(LookupFailureReason.TranslationNotFound); + call.Arguments.Should().BeEmpty(); + call.Pattern.Should().BeNull(); + call.Exception.Should().BeNull(); + }); + calls.Should().Equal(monitor.LookupFailures()); } [Fact] - public void MissingTranslationFormat_RejectsNull() + public void LookupFailureTextBuilder_RejectsNull() { var localizer = this.resources.CreateLocalizer(); + var builder = localizer.LookupFailureTextBuilder; - var act = () => localizer.MissingTranslationFormat = null!; + var act = () => localizer.LookupFailureTextBuilder = null!; act.Should().Throw(); - localizer.MissingTranslationFormat.Should().Be("!{0}!"); + localizer.LookupFailureTextBuilder.Should().BeSameAs(builder); + } + + [Fact] + public void LookupFailureTextBuilder_ThrowingFunction_Propagates_AfterTheEvent() + { + var localizer = this.resources.CreateLocalizer(); + var exception = new InvalidOperationException("boom"); + localizer.LookupFailureTextBuilder = _ => throw exception; + using var monitor = localizer.Monitor(); + + var miss = () => localizer.Get("ThisKeyDoesNotExist"); + var formatFailure = () => localizer.Get("Malformed", 1); + + miss.Should().Throw().Which.Should().BeSameAs(exception); + monitor.LookupFailures().Should().ContainSingle(); + formatFailure.Should().Throw().Which.Should().BeSameAs(exception); + monitor.LookupFailures().Should().HaveCount(2); + } + + [Fact] + public void MissingSatelliteEntry_FallsBackToNeutral_WithoutMissEvent() + { + var localizer = this.resources.CreateLocalizer(); + localizer.CurrentCulture = TestResources.German; + using var monitor = localizer.Monitor(); + + localizer.Get("NeutralOnly", this.resources.Catalog).Should().Be("Neutral fallback value"); + monitor.Should().NotRaise(nameof(ILocalizer.LookupFailed)); } [Fact] - public void ScopedMiss_RaisesTranslationNotFound_WithTheManager() + public void ScopedMiss_RaisesLookupFailed_WithTheManager() { var localizer = this.resources.CreateLocalizer(); - var raised = new List(); - localizer.TranslationNotFound += (_, args) => raised.Add(args); + using var monitor = localizer.Monitor(); localizer.Get("ThisKeyDoesNotExist", this.resources.Catalog); - var miss = raised.Should().ContainSingle().Subject; + var miss = monitor.LookupFailures().Should().ContainSingle().Subject; miss.ResourceManager.Should().BeSameAs(this.resources.Catalog); } [Fact] - public void SearchAllMiss_RaisesTranslationNotFound_WithoutAManager() + public void SearchAllMiss_RaisesLookupFailed_WithoutAManager() { var localizer = this.resources.CreateLocalizer(); - var raised = new List(); - localizer.TranslationNotFound += (_, args) => raised.Add(args); + using var monitor = localizer.Monitor(); localizer.Get("ThisKeyDoesNotExist"); - var miss = raised.Should().ContainSingle().Subject; + var miss = monitor.LookupFailures().Should().ContainSingle().Subject; miss.Key.Should().Be("ThisKeyDoesNotExist"); miss.Culture.Should().Be(TestResources.English); miss.ResourceManager.Should().BeNull(); @@ -112,25 +235,23 @@ public void SpecificCulture_FallsBackToParentCulture_WithoutMissEvent() { var localizer = this.resources.CreateLocalizer(); localizer.CurrentCulture = new("de-DE"); - var raised = 0; - localizer.TranslationNotFound += (_, _) => raised++; + using var monitor = localizer.Monitor(); localizer.Get("Greeting", this.resources.Catalog).Should().Be("Hallo und willkommen!"); - raised.Should().Be(0); + monitor.Should().NotRaise(nameof(ILocalizer.LookupFailed)); } [Fact] - public void SuccessfulAndEmptyLookups_DoNotRaiseTranslationNotFound() + public void SuccessfulAndEmptyLookups_DoNotRaiseLookupFailed() { var localizer = this.resources.CreateLocalizer(); - var raised = 0; - localizer.TranslationNotFound += (_, _) => raised++; + using var monitor = localizer.Monitor(); localizer.Get("Greeting"); localizer.Get("Greeting", this.resources.Catalog); localizer.Get(string.Empty); _ = localizer["Greeting"]; - raised.Should().Be(0); + monitor.Should().NotRaise(nameof(ILocalizer.LookupFailed)); } } diff --git a/tests/ResXLocalization.Core.Tests/LocalizerMonitorExtensions.cs b/tests/ResXLocalization.Core.Tests/LocalizerMonitorExtensions.cs new file mode 100644 index 0000000..b777d8f --- /dev/null +++ b/tests/ResXLocalization.Core.Tests/LocalizerMonitorExtensions.cs @@ -0,0 +1,42 @@ +using System.ComponentModel; +using AwesomeAssertions.Events; + +namespace RentADeveloper.ResXLocalization.Core.Tests; + +/// +/// Reads the failed lookups an AwesomeAssertions event monitor recorded on a localizer. Compiled into the +/// UI test suites too. +/// +internal static class LocalizerMonitorExtensions +{ + /// + /// Gets every event recorded so far, in the order it was raised: PropertyChanged(Name) for a + /// property change, otherwise the event name. + /// + /// The monitored localizer type. + /// The monitor of the localizer under test. + /// The event log. + public static string[] EventLog(this IMonitor monitor) + where T : ILocalizer => + [ + .. monitor + .OccurredEvents.OrderBy(occurred => occurred.Sequence) + .Select(occurred => + occurred.Parameters[1] is PropertyChangedEventArgs args + ? $"PropertyChanged({args.PropertyName})" + : occurred.EventName + ), + ]; + + /// Gets the failure of every recorded so far. + /// The monitored localizer type. + /// The monitor of the localizer under test. + /// The failed lookups, in order. + public static LookupFailure[] LookupFailures(this IMonitor monitor) + where T : ILocalizer => + [ + .. monitor + .OccurredEvents.Where(occurred => occurred.EventName == nameof(ILocalizer.LookupFailed)) + .Select(occurred => ((LookupFailedEventArgs)occurred.Parameters[1]).Failure), + ]; +} diff --git a/tests/ResXLocalization.Core.Tests/RefreshTriggeringRevisionTests.cs b/tests/ResXLocalization.Core.Tests/RefreshTriggeringRevisionTests.cs new file mode 100644 index 0000000..ed49824 --- /dev/null +++ b/tests/ResXLocalization.Core.Tests/RefreshTriggeringRevisionTests.cs @@ -0,0 +1,122 @@ +namespace RentADeveloper.ResXLocalization.Core.Tests; + +/// +/// Exercises : it advances, with one +/// PropertyChanged for itself and one for Item[], after every real change that can change a +/// lookup's result, and after nothing else. +/// +public class RefreshTriggeringRevisionTests +{ + private readonly TestResources resources = new(); + + [Fact] + public void EveryNoOp_DoesNotAdvance() + { + var localizer = this.resources.CreateLocalizer(); + var culture = localizer.CurrentCulture; + var formatter = localizer.MessageFormatter; + var builder = localizer.LookupFailureTextBuilder; + + AssertDoesNotAdvance(localizer, () => localizer.CurrentCulture = culture); + AssertDoesNotAdvance(localizer, () => localizer.MessageFormatter = formatter); + AssertDoesNotAdvance(localizer, () => localizer.LookupFailureTextBuilder = builder); + AssertDoesNotAdvance(localizer, () => localizer.RegisterResourceManager(this.resources.Catalog)); + AssertDoesNotAdvance(localizer, () => localizer.UnregisterResourceManager(CreateUnregisteredManager())); + + localizer.ClearResourceManagers(); + AssertDoesNotAdvance(localizer, localizer.ClearResourceManagers); + + AssertDoesNotAdvance( + localizer, + () => + FluentActions.Invoking(() => localizer.MessageFormatter = null!).Should().Throw() + ); + AssertDoesNotAdvance( + localizer, + () => + FluentActions + .Invoking(() => localizer.LookupFailureTextBuilder = null!) + .Should() + .Throw() + ); + } + + [Fact] + public void EveryRealChange_Advances() + { + var localizer = this.resources.CreateLocalizer(); + + AssertAdvances( + localizer, + () => localizer.CurrentCulture = TestResources.German, + "PropertyChanged(CurrentCulture)", + "PropertyChanged(RefreshTriggeringRevision)", + "PropertyChanged(Item[])", + "CultureChanged" + ); + AssertAdvances(localizer, () => localizer.MessageFormatter = new CapturingFormatter()); + AssertAdvances(localizer, () => localizer.LookupFailureTextBuilder = failure => $"[{failure.Key}]"); + AssertAdvances(localizer, () => localizer.RegisterResourceManager(CreateUnregisteredManager())); + AssertAdvances(localizer, () => localizer.UnregisterResourceManager(this.resources.Catalog)); + AssertAdvances(localizer, localizer.ClearResourceManagers); + } + + [Fact] + public void StartsAtZero_AndWrapsAround() + { + var localizer = new Localizer(); + + localizer.RefreshTriggeringRevision.Should().Be(0); + + // Start from English, so the switch to German below is a real change whatever the host culture. + localizer.CurrentCulture = TestResources.English; + typeof(Localizer).GetProperty(nameof(Localizer.RefreshTriggeringRevision))!.SetValue(localizer, int.MaxValue); + localizer.CurrentCulture = TestResources.German; + + localizer.RefreshTriggeringRevision.Should().Be(int.MinValue); + } + + /// + /// Asserts that changes the revision and raises exactly + /// (by default one revision and one indexer notification, in that order). + /// + /// The localizer under test. + /// The change to apply. + /// The expected event log; empty for the default. + private static void AssertAdvances(Localizer localizer, Action action, params string[] expectedLog) + { + var before = localizer.RefreshTriggeringRevision; + using var monitor = localizer.Monitor(); + + action(); + + localizer.RefreshTriggeringRevision.Should().NotBe(before); + monitor + .EventLog() + .Should() + .Equal( + expectedLog.Length == 0 + ? ["PropertyChanged(RefreshTriggeringRevision)", "PropertyChanged(Item[])"] + : expectedLog + ); + } + + /// Asserts that leaves the revision unchanged and raises nothing. + /// The localizer under test. + /// The no-op to apply. + private static void AssertDoesNotAdvance(Localizer localizer, Action action) + { + var before = localizer.RefreshTriggeringRevision; + using var monitor = localizer.Monitor(); + + action(); + + localizer.RefreshTriggeringRevision.Should().Be(before); + monitor.EventLog().Should().BeEmpty(); + } + + /// Creates a manager over the Catalog resources that is a different, unregistered instance. + /// The new manager. + private static ResourceManager CreateUnregisteredManager() => + new("RentADeveloper.ResXLocalization.Core.Tests.Resources.Catalog", typeof(TestResources).Assembly); +} diff --git a/tests/ResXLocalization.Core.Tests/Resources/Catalog.de.resx b/tests/ResXLocalization.Core.Tests/Resources/Catalog.de.resx index 725c378..774caf8 100644 --- a/tests/ResXLocalization.Core.Tests/Resources/Catalog.de.resx +++ b/tests/ResXLocalization.Core.Tests/Resources/Catalog.de.resx @@ -31,4 +31,10 @@ {0:N1} Elemente in {1} gefunden. + + Kaputt {0 + + + Wörtliche {{Klammern}} für {0} + diff --git a/tests/ResXLocalization.Core.Tests/Resources/Catalog.resx b/tests/ResXLocalization.Core.Tests/Resources/Catalog.resx index 4530513..fa88d97 100644 --- a/tests/ResXLocalization.Core.Tests/Resources/Catalog.resx +++ b/tests/ResXLocalization.Core.Tests/Resources/Catalog.resx @@ -3,7 +3,8 @@ text/microsoft-resx @@ -38,4 +39,10 @@ Neutral fallback value + + Broken {0 + + + Literal {{braces}} for {0} + diff --git a/tests/ResXLocalization.WPF.Sample.Tests/LocalizeArgsTests.cs b/tests/ResXLocalization.WPF.Sample.Tests/LocalizeArgsTests.cs index be9c281..19c2274 100644 --- a/tests/ResXLocalization.WPF.Sample.Tests/LocalizeArgsTests.cs +++ b/tests/ResXLocalization.WPF.Sample.Tests/LocalizeArgsTests.cs @@ -1,5 +1,6 @@ using System.Windows.Markup; using System.Windows.Media; +using RentADeveloper.ResXLocalization.Core.Tests; namespace RentADeveloper.ResXLocalization.WPF.Sample.Tests; @@ -60,6 +61,29 @@ public void CultureChange_ReFormats_Live() => textBlock.Text.Should().Be("3 Personen eingeladen"); }); + [Fact] + public void FormattingFailure_RendersTheFailureText_AndRaisesLookupFailed() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + using var monitor = Localizer.Current.Monitor(); + + var textBlock = TestSupport.BindLocalizedText(new("FormatBroken")); + LocalizeArgs.SetArg0(textBlock, 1); + TestSupport.Flush(); + + textBlock.Text.Should().Be("!FormatBroken!"); + monitor + .LookupFailures() + .Should() + .NotBeEmpty() + .And.OnlyContain(failure => + failure.Reason == LookupFailureReason.MessageFormatFailed + && failure.Key == "FormatBroken" + && failure.Exception is FormatException + ); + }); + [Fact] public void HigherSlotAlone_FillsInteriorGapsWithNull() => WpfThread.Invoke(() => diff --git a/tests/ResXLocalization.WPF.Sample.Tests/LocalizeExtensionTests.cs b/tests/ResXLocalization.WPF.Sample.Tests/LocalizeExtensionTests.cs index 9300dff..5404464 100644 --- a/tests/ResXLocalization.WPF.Sample.Tests/LocalizeExtensionTests.cs +++ b/tests/ResXLocalization.WPF.Sample.Tests/LocalizeExtensionTests.cs @@ -1,3 +1,5 @@ +using RentADeveloper.ResXLocalization.Core.Tests; + namespace RentADeveloper.ResXLocalization.WPF.Sample.Tests; /// @@ -44,6 +46,36 @@ public void KeyProperty_WithResourceManager_ResolvesScoped() => textBlock.Text.Should().Be("Hello and welcome!"); }); + [Fact] + public void NoArguments_PassesTheValueThroughTheFormatter() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var previous = Localizer.Current.MessageFormatter; + var formatter = new CapturingFormatter(); + Localizer.Current.MessageFormatter = formatter; + + try + { + var textBlock = TestSupport.BindLocalizedText(new("CurlyNoArgs")); + TestSupport.Flush(); + + textBlock.Text.Should().Be("Literal {braces} stay"); + formatter.Calls.Should().NotBeEmpty(); + formatter + .Calls.Should() + .AllSatisfy(call => + { + call.Pattern.Should().Be("Literal {braces} stay"); + call.Arguments.Should().BeEmpty(); + }); + } + finally + { + Localizer.Current.MessageFormatter = previous; + } + }); + [Fact] public void ResourceKeyConstructor_ResolvesViaTypedKey() => WpfThread.Invoke(() => diff --git a/tests/ResXLocalization.WPF.Sample.Tests/MainWindowViewModelTests.cs b/tests/ResXLocalization.WPF.Sample.Tests/MainWindowViewModelTests.cs index b42bb30..e30e31e 100644 --- a/tests/ResXLocalization.WPF.Sample.Tests/MainWindowViewModelTests.cs +++ b/tests/ResXLocalization.WPF.Sample.Tests/MainWindowViewModelTests.cs @@ -35,8 +35,7 @@ public void SelectingLanguage_RecomputesAndNotifies_ReadOutProperties() => TestSupport.ResetToEnglishWithTestCatalogs(); using var viewModel = new MainWindowViewModel(Localizer.Current); - var changed = new List(); - viewModel.PropertyChanged += (_, args) => changed.Add(args.PropertyName ?? string.Empty); + using var monitor = viewModel.Monitor(); viewModel.SelectedLanguage = TestSupport.German; @@ -44,9 +43,9 @@ public void SelectingLanguage_RecomputesAndNotifies_ReadOutProperties() => viewModel.SortOrderViaSearchAllDefaultPrefix.Should().Be("Aufsteigend"); viewModel.PoweredByViaTypedKey.Should().Be("Bereitgestellt von rent-a-developer"); - changed.Should().Contain(nameof(MainWindowViewModel.GreetingViaSearchAll)); - changed.Should().Contain(nameof(MainWindowViewModel.SortOrderViaSearchAllDefaultPrefix)); - changed.Should().Contain(nameof(MainWindowViewModel.PoweredByViaTypedKey)); + monitor.Should().RaisePropertyChangeFor(model => model.GreetingViaSearchAll); + monitor.Should().RaisePropertyChangeFor(model => model.SortOrderViaSearchAllDefaultPrefix); + monitor.Should().RaisePropertyChangeFor(model => model.PoweredByViaTypedKey); }); [Fact] @@ -68,15 +67,14 @@ public void SelectingSortOrder_RecomputesAndNotifies_EnumReadOuts() => TestSupport.ResetToEnglishWithTestCatalogs(); using var viewModel = new MainWindowViewModel(Localizer.Current); - var changed = new List(); - viewModel.PropertyChanged += (_, args) => changed.Add(args.PropertyName ?? string.Empty); + using var monitor = viewModel.Monitor(); viewModel.SelectedFileSortOrder = FileSortOrder.Descending; viewModel.SortOrderViaSearchAllDefaultPrefix.Should().Be("Descending"); viewModel.SortOrderViaSearchAllCustomPrefix.Should().Be("Z to A"); - changed.Should().Contain(nameof(MainWindowViewModel.SortOrderViaSearchAllDefaultPrefix)); - changed.Should().Contain(nameof(MainWindowViewModel.SortOrderViaScopedCustomPrefix)); + monitor.Should().RaisePropertyChangeFor(model => model.SortOrderViaSearchAllDefaultPrefix); + monitor.Should().RaisePropertyChangeFor(model => model.SortOrderViaScopedCustomPrefix); }); [Fact] diff --git a/tests/ResXLocalization.WPF.Sample.Tests/ResXLocalization.WPF.Sample.Tests.csproj b/tests/ResXLocalization.WPF.Sample.Tests/ResXLocalization.WPF.Sample.Tests.csproj index 8cb4809..d8b7a8b 100644 --- a/tests/ResXLocalization.WPF.Sample.Tests/ResXLocalization.WPF.Sample.Tests.csproj +++ b/tests/ResXLocalization.WPF.Sample.Tests/ResXLocalization.WPF.Sample.Tests.csproj @@ -9,4 +9,10 @@ + + + + + + diff --git a/tests/ResXLocalization.WPF.Sample.Tests/Resources/Catalog.resx b/tests/ResXLocalization.WPF.Sample.Tests/Resources/Catalog.resx index 7d9ac5c..4aaa74d 100644 --- a/tests/ResXLocalization.WPF.Sample.Tests/Resources/Catalog.resx +++ b/tests/ResXLocalization.WPF.Sample.Tests/Resources/Catalog.resx @@ -9,6 +9,7 @@ * FormatPair - a two-argument composite format string, for the LocalizeArgs tests. * FormatGap - references {0} and {2}, to test a higher argument slot used alone. * CurlyNoArgs - literal braces, to prove the no-argument path never composite-formats. + * FormatBroken - a malformed composite format string, to test the formatting-failure path. --> text/microsoft-resx @@ -40,4 +41,7 @@ Literal {braces} stay + + Broken {0 +