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 +