diff --git a/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaSetFeaturesTests.cs b/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaSetFeaturesTests.cs
new file mode 100644
index 0000000000..0c6c3a7f40
--- /dev/null
+++ b/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaSetFeaturesTests.cs
@@ -0,0 +1,302 @@
+// 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 System.Threading;
+using NUnit.Framework;
+using SIL.FieldWorks.Common.RootSites;
+using SIL.LCModel;
+using XCore;
+
+namespace SIL.FieldWorks.XWorks.MorphologyEditor
+{
+ ///
+ /// Tests the classification of the natural class under the cursor that decides whether the
+ /// "Set Phonological Features" rule-formula command is offered.
+ ///
+ [TestFixture]
+ [Apartment(ApartmentState.STA)]
+ public class RuleFormulaSetFeaturesTests : MemoryOnlyBackendProviderRestoredForEachTestTestBase
+ {
+ ///
+ /// Resolves the selection to a fixed object, the extension point concrete rule controls
+ /// implement, so the classification runs without a laid-out Views root box.
+ ///
+ private sealed class StubRuleFormulaControl : RuleFormulaControl
+ {
+ internal ICmObject ObjectUnderCursor { get; set; }
+
+ protected override ICmObject GetCmObject(SelectionHelper sel, SelectionHelper.SelLimitType limit)
+ {
+ return ObjectUnderCursor;
+ }
+ }
+
+ private StubRuleFormulaControl m_ruleFormulaControl;
+ private RuleFormulaSlice m_slice;
+
+ public override void TestSetup()
+ {
+ base.TestSetup();
+ m_ruleFormulaControl = new StubRuleFormulaControl();
+ m_slice = new RuleFormulaSlice { Control = m_ruleFormulaControl };
+ }
+
+ public override void TestTearDown()
+ {
+ if (m_slice != null)
+ {
+ m_slice.Dispose();
+ m_slice = null;
+ }
+ m_ruleFormulaControl = null;
+ base.TestTearDown();
+ }
+
+ private IPhSimpleContextNC MakeFeatureClassContext(IPhNCFeatures natClass)
+ {
+ var ctxt = Cache.ServiceLocator.GetInstance().Create();
+ Cache.LanguageProject.PhonologicalDataOA.ContextsOS.Add(ctxt);
+ ctxt.FeatureStructureRA = natClass;
+ return ctxt;
+ }
+
+ private IPhNCFeatures MakeFeatureClass(string name)
+ {
+ var natClass = Cache.ServiceLocator.GetInstance().Create();
+ Cache.LanguageProject.PhonologicalDataOA.NaturalClassesOS.Add(natClass);
+ if (name != null)
+ natClass.Name.SetUserWritingSystem(name);
+ natClass.FeaturesOA = Cache.ServiceLocator.GetInstance().Create();
+ return natClass;
+ }
+
+ ///
+ /// Produces the natural class name that the rule formula controls write when the user sets
+ /// phonological features, so the tests stay tied to the shipped resource string.
+ ///
+ private static string AutoGeneratedName(string ruleName)
+ {
+ return string.Format(MEStrings.ksRuleNCFeatsName, ruleName);
+ }
+
+ private IPhSimpleContextNC MakeSegmentClassContext()
+ {
+ var natClass = Cache.ServiceLocator.GetInstance().Create();
+ Cache.LanguageProject.PhonologicalDataOA.NaturalClassesOS.Add(natClass);
+ natClass.Name.SetUserWritingSystem("Vowels");
+
+ var ctxt = Cache.ServiceLocator.GetInstance().Create();
+ Cache.LanguageProject.PhonologicalDataOA.ContextsOS.Add(ctxt);
+ ctxt.FeatureStructureRA = natClass;
+ return ctxt;
+ }
+
+ private IPhSimpleContextSeg MakePhonemeContext()
+ {
+ var phonData = Cache.LanguageProject.PhonologicalDataOA;
+ if (phonData.PhonemeSetsOS.Count == 0)
+ phonData.PhonemeSetsOS.Add(Cache.ServiceLocator.GetInstance().Create());
+ var phoneme = Cache.ServiceLocator.GetInstance().Create();
+ phonData.PhonemeSetsOS[0].PhonemesOC.Add(phoneme);
+
+ var ctxt = Cache.ServiceLocator.GetInstance().Create();
+ phonData.ContextsOS.Add(ctxt);
+ ctxt.FeatureStructureRA = phoneme;
+ return ctxt;
+ }
+
+ private static UIItemDisplayProperties NewDisplayProperties()
+ {
+ return new UIItemDisplayProperties(null, "Set Phonological Features", true, null, true);
+ }
+
+ private UIItemDisplayProperties AskSliceToDisplaySetFeatures()
+ {
+ UIItemDisplayProperties display = NewDisplayProperties();
+ Assert.That(m_slice.OnDisplayContextSetFeatures(null, ref display), Is.True);
+ return display;
+ }
+
+ [Test]
+ public void UserNamedFeatureClassIsUserDefined()
+ {
+ m_ruleFormulaControl.ObjectUnderCursor = MakeFeatureClassContext(MakeFeatureClass("Voiced obstruents"));
+
+ Assert.That(m_ruleFormulaControl.IsFeatureBasedNCNameUserDefined(), Is.True);
+ }
+
+ [Test]
+ public void FeatureClassGeneratedForARegularRuleIsNotUserDefined()
+ {
+ m_ruleFormulaControl.ObjectUnderCursor =
+ MakeFeatureClassContext(MakeFeatureClass(AutoGeneratedName("Intervocalic voicing")));
+
+ Assert.That(m_ruleFormulaControl.IsFeatureBasedNCNameUserDefined(), Is.False);
+ }
+
+ [Test]
+ public void FeatureClassGeneratedForAnAffixRuleIsNotUserDefined()
+ {
+ // Affix rules substitute the affix form rather than a rule name, so only the fixed stub
+ // of the resource string is common to both kinds of generated name.
+ m_ruleFormulaControl.ObjectUnderCursor = MakeFeatureClassContext(MakeFeatureClass(AutoGeneratedName("-ed")));
+
+ Assert.That(m_ruleFormulaControl.IsFeatureBasedNCNameUserDefined(), Is.False);
+ }
+
+ [Test]
+ public void UnnamedFeatureClassIsNotUserDefined()
+ {
+ m_ruleFormulaControl.ObjectUnderCursor = MakeFeatureClassContext(MakeFeatureClass(null));
+
+ Assert.That(m_ruleFormulaControl.IsFeatureBasedNCNameUserDefined(), Is.False);
+ }
+
+ [Test]
+ public void FeatureClassWithAnEmptyNameIsNotUserDefined()
+ {
+ m_ruleFormulaControl.ObjectUnderCursor = MakeFeatureClassContext(MakeFeatureClass(string.Empty));
+
+ Assert.That(m_ruleFormulaControl.IsFeatureBasedNCNameUserDefined(), Is.False);
+ }
+
+ [Test]
+ public void FeatureClassNamedOnlyInANonUserWritingSystemIsUserDefined()
+ {
+ // Generated names are always written in the user writing system, so a name that exists only
+ // elsewhere came from a person and the class must keep its features.
+ int otherWs = Cache.ServiceLocator.WritingSystems.AllWritingSystems
+ .Select(ws => ws.Handle).First(handle => handle != Cache.DefaultUserWs);
+ var natClass = MakeFeatureClass(null);
+ natClass.Name.set_String(otherWs, "Voiced obstruents");
+ m_ruleFormulaControl.ObjectUnderCursor = MakeFeatureClassContext(natClass);
+
+ Assert.That(m_ruleFormulaControl.IsFeatureBasedNCNameUserDefined(), Is.True);
+ }
+
+ [Test]
+ public void FeatureClassNameMerelyResemblingTheGeneratedFormIsUserDefined()
+ {
+ m_ruleFormulaControl.ObjectUnderCursor =
+ MakeFeatureClassContext(MakeFeatureClass("Automatically created for the voicing rule"));
+
+ Assert.That(m_ruleFormulaControl.IsFeatureBasedNCNameUserDefined(), Is.True);
+ }
+
+ [Test]
+ public void FeatureClassInsideAnIterationContextIsClassifiedByItsMember()
+ {
+ var ncCtxt = MakeFeatureClassContext(MakeFeatureClass("Voiced obstruents"));
+ var iterCtxt = Cache.ServiceLocator.GetInstance().Create();
+ Cache.LanguageProject.PhonologicalDataOA.ContextsOS.Add(iterCtxt);
+ iterCtxt.MemberRA = ncCtxt;
+ m_ruleFormulaControl.ObjectUnderCursor = iterCtxt;
+
+ Assert.That(m_ruleFormulaControl.IsFeatureBasedNCNameUserDefined(), Is.True);
+ }
+
+ [Test]
+ public void SegmentBasedNaturalClassIsNotReportedAsUserDefined()
+ {
+ m_ruleFormulaControl.ObjectUnderCursor = MakeSegmentClassContext();
+
+ Assert.That(m_ruleFormulaControl.IsFeatureBasedNCNameUserDefined(), Is.False);
+ }
+
+ [Test]
+ public void PhonemeContextIsNotReportedAsUserDefined()
+ {
+ m_ruleFormulaControl.ObjectUnderCursor = MakePhonemeContext();
+
+ Assert.That(m_ruleFormulaControl.IsFeatureBasedNCNameUserDefined(), Is.False);
+ }
+
+ [Test]
+ public void NaturalClassContextWithNoNaturalClassIsNotReportedAsUserDefined()
+ {
+ var ctxt = Cache.ServiceLocator.GetInstance().Create();
+ Cache.LanguageProject.PhonologicalDataOA.ContextsOS.Add(ctxt);
+ m_ruleFormulaControl.ObjectUnderCursor = ctxt;
+
+ Assert.That(m_ruleFormulaControl.IsFeatureBasedNCNameUserDefined(), Is.False);
+ }
+
+ [Test]
+ public void EmptySelectionIsNotReportedAsUserDefined()
+ {
+ m_ruleFormulaControl.ObjectUnderCursor = null;
+
+ Assert.That(m_ruleFormulaControl.IsFeatureBasedNCNameUserDefined(), Is.False);
+ }
+
+ [Test]
+ public void SetFeaturesIsOfferedForAFeatureClassGeneratedForARule()
+ {
+ m_ruleFormulaControl.ObjectUnderCursor =
+ MakeFeatureClassContext(MakeFeatureClass(AutoGeneratedName("Intervocalic voicing")));
+
+ UIItemDisplayProperties display = AskSliceToDisplaySetFeatures();
+
+ Assert.That(display.Enabled, Is.True);
+ Assert.That(display.Visible, Is.True);
+ }
+
+ [Test]
+ public void SetFeaturesIsOfferedForAnUnnamedFeatureClass()
+ {
+ m_ruleFormulaControl.ObjectUnderCursor = MakeFeatureClassContext(MakeFeatureClass(null));
+
+ UIItemDisplayProperties display = AskSliceToDisplaySetFeatures();
+
+ Assert.That(display.Enabled, Is.True);
+ Assert.That(display.Visible, Is.True);
+ }
+
+ [Test]
+ public void SetFeaturesIsHiddenForAUserDefinedFeatureClass()
+ {
+ m_ruleFormulaControl.ObjectUnderCursor = MakeFeatureClassContext(MakeFeatureClass("Voiced obstruents"));
+
+ UIItemDisplayProperties display = AskSliceToDisplaySetFeatures();
+
+ Assert.That(display.Enabled, Is.False);
+ Assert.That(display.Visible, Is.False);
+ }
+
+ [Test]
+ public void SetFeaturesIsHiddenForASegmentBasedNaturalClass()
+ {
+ m_ruleFormulaControl.ObjectUnderCursor = MakeSegmentClassContext();
+
+ UIItemDisplayProperties display = AskSliceToDisplaySetFeatures();
+
+ Assert.That(display.Enabled, Is.False);
+ Assert.That(display.Visible, Is.False);
+ }
+
+ [Test]
+ public void SetFeaturesIsHiddenForAPhonemeContext()
+ {
+ m_ruleFormulaControl.ObjectUnderCursor = MakePhonemeContext();
+
+ UIItemDisplayProperties display = AskSliceToDisplaySetFeatures();
+
+ Assert.That(display.Enabled, Is.False);
+ Assert.That(display.Visible, Is.False);
+ }
+
+ [Test]
+ public void SetFeaturesIsHiddenForAnEmptySelection()
+ {
+ m_ruleFormulaControl.ObjectUnderCursor = null;
+
+ UIItemDisplayProperties display = AskSliceToDisplaySetFeatures();
+
+ Assert.That(display.Enabled, Is.False);
+ Assert.That(display.Visible, Is.False);
+ }
+
+ }
+}