Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,9 @@
<recordList owner="MorphologicalData" property="Phonemes"/>
</clerk>
<clerk id="naturalClasses">
<recordList owner="MorphologicalData" property="NaturalClasses"/>
<recordList owner="MorphologicalData" property="NaturalClasses">
<dynamicloaderinfo assemblyPath="MorphologyEditorDll.dll" class="SIL.FieldWorks.XWorks.MorphologyEditor.NaturalClassRecordList"/>
</recordList>
</clerk>
<clerk id="environments">
<recordList owner="MorphologicalData" property="Environments"/>
Expand Down
4 changes: 3 additions & 1 deletion Src/LexText/Morphology/AffixRuleFormulaControl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -841,7 +841,9 @@ public override void SetMappingNaturalClass(SelectionHelper sel = null)
var natClasses = new HashSet<ICmObject>();
foreach (var nc in m_cache.LangProject.PhonologicalDataOA.NaturalClassesOS)
{
if (nc.ClassID == PhNCFeaturesTags.kClassId)
// Rule mappings only allow feature-based, user-defined natural classes
// to be selected.
if (nc is IPhNCFeatures featNC && IsFeatureBasedNCUserDefined(featNC))
natClasses.Add(nc);
}
var selectedNc = DisplayChooser(MEStrings.ksRuleNCOpt, MEStrings.ksRuleNCChooserLink,
Expand Down
6 changes: 5 additions & 1 deletion Src/LexText/Morphology/AffixRuleFormulaSlice.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,11 @@ public bool OnMappingSetNaturalClass(object args)
public bool OnDisplayMappingJumpToNaturalClass(object commandObject, ref XCore.UIItemDisplayProperties display)
{
CheckDisposed();
bool enable = AffixRuleFormulaControl.IsNCIndexCurrent;
// Note that a mapping's Natural Class is always feature-based.
// For feature-based NCs, we only want to allow jumping to the NC
// if it is user-defined.
var mapping = AffixRuleFormulaControl.CurrentObject as IMoModifyFromInput;
bool enable = mapping != null && RuleFormulaControl.IsFeatureBasedNCUserDefined(mapping.ModificationRA);
display.Enabled = enable;
display.Visible = enable;
return true;
Expand Down
Original file line number Diff line number Diff line change
@@ -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.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using SIL.LCModel;

namespace SIL.FieldWorks.XWorks.MorphologyEditor
{
/// <summary>
/// Tests to confirm that <see cref="NaturalClassRecordList"/> includes every
/// natural class except a feature-based one generated automatically for a rule.
/// </summary>
[TestFixture]
public class NaturalClassRecordListTests : RuleFormulaVcTestBase
{
private IEnumerable<int> NCRecordList(params ICmObject[] natClasses)
{
return NaturalClassRecordList.ExcludeAutoGenerated(
natClasses.Select(nc => nc.Hvo), Cache);
}
[Test]
public void AutoGeneratedClass_IsExcluded()
{
IPhNCFeatures natClass = AddAutoGeneratedFeatureNaturalClass("s to n",
new Dictionary<string, string> { { "vd", "+" } });

// Since the only NC is autogenerated, the NC record list should be empty
Assert.That(NCRecordList(natClass), Is.Empty);
}

[Test]
public void NamedFeatureClass_IsIncluded()
{
IPhNCFeatures natClass = AddFeatureNaturalClass("Voiced", "Vd",
new Dictionary<string, string> { { "vd", "+" } });

Assert.That(NCRecordList(natClass), Is.EqualTo(new[] { natClass.Hvo }));
}

/// <summary>
/// A feature-based class the user creates starts with no name, so an unnamed class must
/// be included for the user to be able to name it.
/// </summary>
[Test]
public void UnnamedFeatureClass_IsIncluded()
{
IPhNCFeatures natClass = AddFeatureNaturalClass(null, null,
new Dictionary<string, string> { { "vd", "+" } });

Assert.That(NCRecordList(natClass), Is.EqualTo(new[] { natClass.Hvo }));
}

[Test]
public void SegmentClass_IsIncluded()
{
IPhNCSegments natClass = AddSegmentNaturalClass("Vowels", "V");

Assert.That(NCRecordList(natClass), Is.EqualTo(new[] { natClass.Hvo }));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,41 +21,45 @@ public class RuleFormulaControlNaturalClassNameTests : RuleFormulaVcTestBase
[Test]
public void NoClass_IsNotUserDefined()
{
Assert.That(RuleFormulaControl.IsFeatureBasedNCNameUserDefined(null), Is.False);
Assert.That(RuleFormulaControl.IsFeatureBasedNCUserDefined(null), Is.False);
}

/// <summary>
/// The check answers whether a class carries a name the user gave it, and an unnamed
/// class carries none. Set Phonological Features relies on that answer to keep
/// offering feature edits on unnamed classes (LT-22576).
/// A user defined class can have no name, while an auto-generated one will always have
/// a generated name. Check that if the class has no name, it is flagged as user defined.
/// </summary>
[Test]
public void ClassWithNoName_IsNotUserDefined()
public void ClassWithNoName_IsUserDefined()
{
IPhNCFeatures natClass = AddFeatureNaturalClass(null, "Vd",
new FeatVals { { "vd", "+" } });

Assert.That(RuleFormulaControl.IsFeatureBasedNCNameUserDefined(natClass), Is.False,
"an unnamed class carries no name the user gave it");
Assert.That(RuleFormulaControl.IsFeatureBasedNCUserDefined(natClass), Is.True,
"an unnamed class must be user defined");
}

/// <summary>
/// A user defined class can have an empty name, while an auto-generated one will always
/// have a nonempty generated name. Check that if the class's name is empty,
/// it is flagged as user defined.
/// </summary>
[Test]
public void ClassWithAnEmptyName_IsNotUserDefined()
public void ClassWithAnEmptyName_IsUserDefined()
{
IPhNCFeatures natClass = AddFeatureNaturalClass(string.Empty, "Vd",
new FeatVals { { "vd", "+" } });

Assert.That(RuleFormulaControl.IsFeatureBasedNCNameUserDefined(natClass), Is.False,
"an empty name is not a name the user gave the class");
Assert.That(RuleFormulaControl.IsFeatureBasedNCUserDefined(natClass), Is.True,
"a class with an empty name must be user defined");
}

[Test]
public void ClassNamedForARule_IsNotUserDefined()
{
IPhNCFeatures natClass = AddRuleNamedFeatureNaturalClass("s to n",
IPhNCFeatures natClass = AddAutoGeneratedFeatureNaturalClass("s to n",
new FeatVals { { "vd", "+" } });

Assert.That(RuleFormulaControl.IsFeatureBasedNCNameUserDefined(natClass), Is.False);
Assert.That(RuleFormulaControl.IsFeatureBasedNCUserDefined(natClass), Is.False);
}

/// <summary>
Expand All @@ -70,7 +74,7 @@ public void ClassNamedForARuleBehindAPrefix_IsNotUserDefined()
natClass.Name.SetUserWritingSystem("Phonemes s and t - "
+ string.Format(MEStrings.ksRuleNCFeatsName, "s to n"));

Assert.That(RuleFormulaControl.IsFeatureBasedNCNameUserDefined(natClass), Is.False);
Assert.That(RuleFormulaControl.IsFeatureBasedNCUserDefined(natClass), Is.False);
}

[Test]
Expand All @@ -79,7 +83,7 @@ public void NamedClass_IsUserDefined()
IPhNCFeatures natClass = AddFeatureNaturalClass("Voiced", "Vd",
new FeatVals { { "vd", "+" } });

Assert.That(RuleFormulaControl.IsFeatureBasedNCNameUserDefined(natClass), Is.True);
Assert.That(RuleFormulaControl.IsFeatureBasedNCUserDefined(natClass), Is.True);
}

/// <summary>
Expand All @@ -101,16 +105,52 @@ public void ClassNamedInAnAnalysisWritingSystemOtherThanTheUserOne_IsUserDefined
"the name must reach the check through the analysis writing system, "
+ "not through a placeholder");

Assert.That(RuleFormulaControl.IsFeatureBasedNCNameUserDefined(natClass), Is.True);
Assert.That(RuleFormulaControl.IsFeatureBasedNCUserDefined(natClass), Is.True);
}

[Test]
public void NamedClassWithNoFeatures_IsUserDefined()
{
IPhNCFeatures natClass = AddFeatureNaturalClass("Voiced", "Vd", new FeatVals());

Assert.That(RuleFormulaControl.IsFeatureBasedNCNameUserDefined(natClass), Is.True,
Assert.That(RuleFormulaControl.IsFeatureBasedNCUserDefined(natClass), Is.True,
"a name qualifies a class whatever it carries for features");
}

/// <summary>
/// No class is neither user defined nor auto-generated.
/// </summary>
[Test]
public void NullClass_IsNotAutoGenerated()
{
Assert.That(RuleFormulaControl.IsAutoGeneratedNaturalClass(null), Is.False);
}

/// <summary>
/// A translation may put the rule name first, leaving no generated text before the
/// placeholder. Its generated names are still recognized, but ordinary names are not.
/// </summary>
[Test]
public void FormatStartingWithRuleName_RecognisesGeneratedName()
{
const string format = "{0} rule: created automatically";

Assert.That(RuleFormulaControl.IsAutoGeneratedName(string.Format(format, "s to n"), format),
Is.True, "a name generated from the format is recognized");
Assert.That(RuleFormulaControl.IsAutoGeneratedName("Voiced", format), Is.False,
"an empty stub before the rule name must not match every name");
}

/// <summary>
/// A translation lacking the rule name placeholder must not break recognition; no name
/// counts as generated from it.
/// </summary>
[Test]
public void FormatWithoutPlaceholder_RecognisesNothing()
{
const string format = "Created automatically for rule";

Assert.That(RuleFormulaControl.IsAutoGeneratedName(format, format), Is.False);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public void RegularRule_RegistersTheNameAndAbbreviationOfItsClass()
[Test]
public void RegularRule_RegistersTheNameOfARuleNamedClass()
{
IPhNCFeatures natClass = AddRuleNamedFeatureNaturalClass("s to n",
IPhNCFeatures natClass = AddAutoGeneratedFeatureNaturalClass("s to n",
new FeatVals { { "vd", "+" } });
IPhSegRuleRHS rhs = AddRegularRule("s to n");
AddStrucDescNCContext(rhs, natClass);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ public void NamedClass_WithEmptyAbbreviation_DrawsStars()
[Test]
public void RuleNamedClass_WithOneFeature_DrawsFeature()
{
IPhNCFeatures natClass = AddRuleNamedFeatureNaturalClass("s to n",
IPhNCFeatures natClass = AddAutoGeneratedFeatureNaturalClass("s to n",
new FeatVals { { "vd", "+" } });

Assert.That(DrawContext(AddStandaloneNCContext(natClass)), Is.EqualTo("[+ vd]"));
Expand All @@ -76,7 +76,7 @@ public void RuleNamedClass_WithOneFeature_DrawsFeature()
[Test]
public void RuleNamedClass_WithThreeFeatures_DrawsEveryFeatureInAPile()
{
IPhNCFeatures natClass = AddRuleNamedFeatureNaturalClass("s to n",
IPhNCFeatures natClass = AddAutoGeneratedFeatureNaturalClass("s to n",
new FeatVals { { "vd", "+" }, { "cons", "+" }, { "cont", "-" } });

string drawn = DrawContext(AddStandaloneNCContext(natClass));
Expand All @@ -92,7 +92,7 @@ public void RuleNamedClass_WithThreeFeatures_DrawsEveryFeatureInAPile()
[Test]
public void RuleNamedClass_WithNoFeatures_DrawsQuestions()
{
IPhNCFeatures natClass = AddRuleNamedFeatureNaturalClass("s to n", new FeatVals());
IPhNCFeatures natClass = AddAutoGeneratedFeatureNaturalClass("s to n", new FeatVals());

Assert.That(DrawContext(AddStandaloneNCContext(natClass)), Is.EqualTo("[???]"));
}
Expand All @@ -105,7 +105,7 @@ public void RuleNamedClass_WithNoFeatures_DrawsQuestions()
[Test]
public void RuleNamedClass_AbbreviatedC_DrawsFeature()
{
IPhNCFeatures natClass = AddRuleNamedFeatureNaturalClass("s to n",
IPhNCFeatures natClass = AddAutoGeneratedFeatureNaturalClass("s to n",
new FeatVals { { "cons", "+" } });
natClass.Abbreviation.SetAnalysisDefaultWritingSystem("C");

Expand All @@ -119,7 +119,7 @@ public void RuleNamedClass_AbbreviatedC_DrawsFeature()
[Test]
public void RuleNamedClass_OnceNamed_DrawsAbbreviation()
{
IPhNCFeatures natClass = AddRuleNamedFeatureNaturalClass("s to n",
IPhNCFeatures natClass = AddAutoGeneratedFeatureNaturalClass("s to n",
new FeatVals { { "vd", "+" } });
IPhSimpleContextNC ctxt = AddStandaloneNCContext(natClass);
Assert.That(DrawContext(ctxt), Is.EqualTo("[+ vd]"), "before the class is named");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public void NamedClass_WithNoAbbreviation_IsSizedToTheStars()
[Test]
public void RuleNamedClass_WithOneFeature_IsSizedToItsFeature()
{
IPhNCFeatures natClass = AddRuleNamedFeatureNaturalClass("s to n",
IPhNCFeatures natClass = AddAutoGeneratedFeatureNaturalClass("s to n",
new FeatVals { { "vd", "+" } });

AssertCellMatchesDrawing(AddStandaloneNCContext(natClass), "[+ vd]");
Expand All @@ -70,7 +70,7 @@ public void NamedClass_WithThreeFeatures_OccupiesOneLine()
[Test]
public void RuleNamedClass_WithThreeFeatures_OccupiesOneLinePerFeature()
{
IPhNCFeatures natClass = AddRuleNamedFeatureNaturalClass("s to n",
IPhNCFeatures natClass = AddAutoGeneratedFeatureNaturalClass("s to n",
new FeatVals { { "vd", "+" }, { "cons", "+" }, { "cont", "-" } });

Assert.That(NumLinesFor(AddStandaloneNCContext(natClass)), Is.EqualTo(3));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,10 @@ protected IPhNCFeatures AddFeatureNaturalClass(string name, string abbreviation,
}

/// <summary>
/// Adds a feature-based natural class named the way one built for a rule is named, in the
/// user writing system and with no abbreviation.
/// Adds a feature-based natural class named the way one auto-generated for a rule
/// is named, in the user writing system and with no abbreviation.
/// </summary>
protected IPhNCFeatures AddRuleNamedFeatureNaturalClass(string ruleName, FeatVals featVals)
protected IPhNCFeatures AddAutoGeneratedFeatureNaturalClass(string ruleName, FeatVals featVals)
{
IPhNCFeatures natClass = AddFeatureNaturalClass(null, null, featVals);
natClass.Name.SetUserWritingSystem(string.Format(MEStrings.ksRuleNCFeatsName, ruleName));
Expand Down
32 changes: 32 additions & 0 deletions Src/LexText/Morphology/NaturalClassRecordList.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// 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.Collections.Generic;
using System.Linq;
using SIL.LCModel;

namespace SIL.FieldWorks.XWorks.MorphologyEditor
{
/// <summary>
/// Exclude all auto-generated feature-based natural classes from the NC record list.
/// </summary>
public class NaturalClassRecordList : RecordList
{
protected override IEnumerable<int> GetObjectSet()
{
return ExcludeAutoGenerated(base.GetObjectSet(), m_cache);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once a rule stops using one of its auto-generated classes, I can't find a way for the user to delete it now. Nothing deletes the class when the context is removed from the rule, when the rule itself is deleted, or when Set Mapping Features clears an affix mapping back to a copy (AffixRuleFormulaControl.SetMappingFeatures removes the mapping and leaves its class in NaturalClassesOS). Until now the user could find these leftovers in Grammar > Natural Classes and delete them; this list now hides them. They still show in the environment natural-class chooser, so the user will see "Created automatically for rule …" entries there that they can't remove.

Should a class be deleted when its rule stops using it? Or should the list still show the leftovers?

}

/// <summary>
/// The given natural class HVOs, less the feature-based classes generated automatically
/// for a rule.
/// </summary>
internal static IEnumerable<int> ExcludeAutoGenerated(IEnumerable<int> hvos, LcmCache cache)
{
var repo = cache.ServiceLocator.GetInstance<ICmObjectRepository>();
return hvos.Where(hvo =>
!RuleFormulaControl.IsAutoGeneratedNaturalClass(repo.GetObject(hvo) as IPhNaturalClass));
}
}
}
Loading
Loading