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