diff --git a/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaSetFeaturesTests.cs b/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaSetFeaturesTests.cs
new file mode 100644
index 0000000000..a911d9e82e
--- /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 TestRuleFormulaControl : RuleFormulaControl
+ {
+ internal ICmObject ObjectUnderCursor { get; set; }
+
+ protected override ICmObject GetCmObject(SelectionHelper sel, SelectionHelper.SelLimitType limit)
+ {
+ return ObjectUnderCursor;
+ }
+ }
+
+ private TestRuleFormulaControl m_ruleFormulaControl;
+ private RuleFormulaSlice m_slice;
+
+ public override void TestSetup()
+ {
+ base.TestSetup();
+ m_ruleFormulaControl = new TestRuleFormulaControl();
+ 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);
+ }
+
+ }
+}
diff --git a/Src/LexText/Morphology/RuleFormulaControl.cs b/Src/LexText/Morphology/RuleFormulaControl.cs
index 88035e5afa..831c1f8ed4 100644
--- a/Src/LexText/Morphology/RuleFormulaControl.cs
+++ b/Src/LexText/Morphology/RuleFormulaControl.cs
@@ -981,16 +981,13 @@ protected int GetIndexToRemove(ICmObject[] objs, SelectionHelper sel, bool forwa
return -1;
}
///
- /// Returns a bool representing whether the currently selected feature-based natural class simple context is user defined.
- ///
- ///
+ /// Determines whether the currently selected feature-based natural class simple context is user defined.
/// A user-defined natural class has a name assigned by the user.
- /// Natural classes are auto-generated when a user assigns phonological features, and they receive auto-generated names of a specific form.
- ///
- ///
- /// Returns false if the natural class is not feature based, or if it has no name or an auto-generated name.
- /// Returns true otherwise.
- ///
+ /// When phonological features are assigned without defining/linking an existing natural class, FLEx
+ /// automatically generates an associated natural class and assigns it a name in a specific auto-generated form.
+ /// Returns false if the natural class is not feature based, has no name, or has an auto-generated name;
+ /// otherwise, returns true.
+ ///
public bool IsFeatureBasedNCNameUserDefined()
{
// If the class is not feature based, return false.
@@ -1009,6 +1006,9 @@ public bool IsFeatureBasedNCNameUserDefined()
var name = natClass.Name;
bool isNamed = name != null && name.AvailableWritingSystemIds.Any(ws => !string.IsNullOrEmpty(name.get_String(ws).Text));
string ncName = isNamed ? name.UserDefaultWritingSystem?.Text : null;
+
+ // We are not going to try to handle the edge case where a natural class was auto-generated and then the user switched UI writing systems,
+ // but note that in that case, the NC name will not contain the generated stub, even though it is an automatically generated class.
bool isAutoGeneratedOrNull = string.IsNullOrEmpty(ncName) || ncName.Contains(autoGeneratedNameStub);
return !isAutoGeneratedOrNull;
}
diff --git a/Src/LexText/Morphology/RuleFormulaSlice.cs b/Src/LexText/Morphology/RuleFormulaSlice.cs
index 0b1be0bb1b..8cbe63ddf6 100644
--- a/Src/LexText/Morphology/RuleFormulaSlice.cs
+++ b/Src/LexText/Morphology/RuleFormulaSlice.cs
@@ -154,7 +154,7 @@ public bool OnDisplayContextSetFeatures(object commandObject, ref UIItemDisplayP
if (RuleFormulaControl.IsFeatsNCContextCurrent)
{
bool isUserDefinedNC = RuleFormulaControl.IsFeatureBasedNCNameUserDefined();
- enable = !isUserDefinedNC; //enable is true iff IsFeatsNCContextCurrent is true and IsNCNameUserDefined is false
+ enable = !isUserDefinedNC;
}
display.Enabled = enable;