diff --git a/Src/Common/FwAvalonia/FwAvaloniaStrings.cs b/Src/Common/FwAvalonia/FwAvaloniaStrings.cs index 5a1b5b6a89..e42bc59478 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaStrings.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaStrings.cs @@ -497,5 +497,26 @@ public static string StructuredTextParagraphName(string fieldLabel, int paragrap public static string FeatureAnalysesName => Text("FwAvalonia.Feature.AnalysesName", "Words Analyses"); public static string FeatureAnalysesDescription => Text("FwAvalonia.Feature.AnalysesDescription", "The interlinear morph-bundle editor."); + + /// Group heading for the avalonia-rule-formula-editor tool surfaces in the feature-manager dialog. + public static string FeatureGroupGrammarRuleEditors => Text("FwAvalonia.FeatureGroup.GrammarRuleEditors", "Grammar rule editors"); + + public static string FeaturePhonologicalRuleEditName => Text("FwAvalonia.Feature.PhonologicalRuleEditName", "Phonological Rules"); + public static string FeaturePhonologicalRuleEditDescription => Text("FwAvalonia.Feature.PhonologicalRuleEditDescription", "The regular and metathesis rule editors."); + + public static string FeatureEnvironmentEditName => Text("FwAvalonia.Feature.EnvironmentEditName", "Phonological Environments"); + public static string FeatureEnvironmentEditDescription => Text("FwAvalonia.Feature.EnvironmentEditDescription", "The phonological-environment string editor."); + + public static string FeatureCompoundRuleAdvancedEditName => Text("FwAvalonia.Feature.CompoundRuleAdvancedEditName", "Compound Rules"); + public static string FeatureCompoundRuleAdvancedEditDescription => Text("FwAvalonia.Feature.CompoundRuleAdvancedEditDescription", "The compound-rule editor."); + + public static string FeatureNaturalClassEditName => Text("FwAvalonia.Feature.NaturalClassEditName", "Natural Classes"); + public static string FeatureNaturalClassEditDescription => Text("FwAvalonia.Feature.NaturalClassEditDescription", "The natural-class editor."); + + public static string FeaturePhonemeEditName => Text("FwAvalonia.Feature.PhonemeEditName", "Phonemes"); + public static string FeaturePhonemeEditDescription => Text("FwAvalonia.Feature.PhonemeEditDescription", "The Basic IPA symbol editor."); + + public static string FeatureAdhocCoprohibEditName => Text("FwAvalonia.Feature.AdhocCoprohibEditName", "Ad Hoc Co-occurrence Rules"); + public static string FeatureAdhocCoprohibEditDescription => Text("FwAvalonia.Feature.AdhocCoprohibEditDescription", "The ad hoc co-occurrence-rule editor."); } } diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/BasicIPASymbolEditorTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/BasicIPASymbolEditorTests.cs new file mode 100644 index 0000000000..2f7eca59ec --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/BasicIPASymbolEditorTests.cs @@ -0,0 +1,64 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Linq; +using Avalonia.Controls; +using Avalonia.Headless.NUnit; +using Avalonia.Threading; +using Avalonia.VisualTree; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.Region; +using FwAvaloniaTests.VisualChecks; +using FwAvaloniaDialogsTests; + +namespace FwAvaloniaTests +{ + /// + /// avalonia-rule-formula-editor (task 3.1) — the Basic IPA Symbol editor control: typing a symbol commits + /// through the sink; with no sink it is read-only. PNG + AssertNoCrowding. + /// + [TestFixture] + public class BasicIPASymbolEditorTests + { + private sealed class FakeSink : IBasicIpaSymbolCommandSink + { + public string LastCommitted; + public bool Commit(string symbol) { LastCommitted = symbol; return true; } + } + + private static TextBox Box(Control root) => root.GetVisualDescendants().OfType().First(); + + [AvaloniaTest] + public void Editable_CommitsTypedSymbol() + { + var sink = new FakeSink(); + var editor = new BasicIPASymbolEditor("p", sink); + var window = new Window { Content = editor, Width = 240, Height = 70 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + + DialogSnapshot.Capture(window, "BasicIPASymbolEditor-01-editable"); + DialogLayoutAssert.AssertNoCrowding(editor); + + Box(editor).Text = "pʰ"; + Assert.That(editor.TryCommit(), Is.True); + Assert.That(sink.LastCommitted, Is.EqualTo("pʰ")); + Assert.That(editor.CommittedText, Is.EqualTo("pʰ")); + } + + [AvaloniaTest] + public void NoSink_IsReadOnly() + { + var editor = new BasicIPASymbolEditor("p", null); + var window = new Window { Content = editor, Width = 240, Height = 70 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + + Assert.That(editor.IsEditable, Is.False); + Assert.That(Box(editor).IsReadOnly, Is.True); + } + } +} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/PhEnvironmentEditorTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/PhEnvironmentEditorTests.cs new file mode 100644 index 0000000000..37e984ed98 --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/PhEnvironmentEditorTests.cs @@ -0,0 +1,86 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Linq; +using Avalonia.Controls; +using Avalonia.Headless.NUnit; +using Avalonia.Threading; +using Avalonia.VisualTree; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.Region; +using FwAvaloniaTests.VisualChecks; +using FwAvaloniaDialogsTests; + +namespace FwAvaloniaTests +{ + /// + /// avalonia-rule-formula-editor (task 3.2) — the environment editor control: commit a valid string, + /// reject an invalid one (restore the last committed value + show the error). PNG + AssertNoCrowding. + /// + [TestFixture] + public class PhEnvironmentEditorTests + { + private sealed class FakeSink : IPhEnvironmentCommandSink + { + public string LastCommitted; + // "valid" = anything that is non-empty and balanced-ish; the real recognizer is tested in xWorks. + public bool Validate(string representation) => !(representation ?? "").Contains("X"); + public bool Commit(string representation) { LastCommitted = representation; return true; } + } + + private static (PhEnvironmentEditor Editor, Window Window, FakeSink Sink) Show(string initial) + { + var sink = new FakeSink(); + var editor = new PhEnvironmentEditor(initial, sink); + var window = new Window { Content = editor, Width = 360, Height = 120 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + return (editor, window, sink); + } + + private static TextBox Box(Control root) => + root.GetVisualDescendants().OfType().First(); + + [AvaloniaTest] + public void ValidEdit_Commits() + { + var (editor, window, sink) = Show("/ _ #"); + DialogSnapshot.Capture(window, "PhEnvironmentEditor-01-initial"); + DialogLayoutAssert.AssertNoCrowding(editor); + + Box(editor).Text = "/ a _ #"; + Assert.That(editor.TryCommit(), Is.True); + Assert.That(sink.LastCommitted, Is.EqualTo("/ a _ #")); + Assert.That(editor.CommittedText, Is.EqualTo("/ a _ #")); + } + + [AvaloniaTest] + public void InvalidEdit_IsRejected_AndRestoresLastCommitted() + { + var (editor, window, sink) = Show("/ _ #"); + + Box(editor).Text = "/ X _ #"; // the fake validator rejects anything containing X + Assert.That(editor.TryCommit(), Is.False); + Assert.That(sink.LastCommitted, Is.Null, "an invalid string is never committed"); + Assert.That(Box(editor).Text, Is.EqualTo("/ _ #"), "the box restores the last committed value"); + + DialogSnapshot.Capture(window, "PhEnvironmentEditor-02-invalid"); + DialogLayoutAssert.AssertNoCrowding(editor); + } + + [AvaloniaTest] + public void InsertToolbar_OffersTheLiteralInserts() + { + var (editor, _, _) = Show(string.Empty); + var ids = editor.GetVisualDescendants().OfType