Skip to content

Commit 2f81e91

Browse files
johnml1135claude
andcommitted
Phase-1 follow-up: avalonia-rule-formula-editor (Grammar rule editors)
Stacked on the interlinear follow-up. Restore the rule-formula surface and activate it: - add back the 29 rule-formula files (the 5 plugins, RuleFormulaModel/RegionEditor + RuleCellCommands, PhEnvironment + BasicIPASymbol editors, projector/sinks/options + deriver, and all their tests incl. SupportingEditorComposeTests) - restore the 5 plugin registrations in RegionEditorPlugins - restore the 5 rule class names in the burn-down census - FLIP: register the 6 rule tools (PhonologicalRuleEdit, EnvironmentEdit, compoundRuleAdvancedEdit, naturalClassedit, phonemeEdit, AdhocCoprohibEdit) in LexicalEditFeatureCatalog under a new "Grammar rule editors" group (restoring the canonical pre-split active set + parity notes); Phase1FollowUpSurfaceTools is now empty (both edit follow-ups landed; browse table gated separately) - add the 6 rule TestCase rows to RegisteredRecordEditTools_ResolveToAvalonia Rebased onto the squashed phase1-base (post PR #964 review): tool registration is now catalog-driven, so this flip also adds a new "Grammar rule editors" group (6 rows) to the Tools->Options "Manage Individual Features" dialog under UIMode=New. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bebe347 commit 2f81e91

35 files changed

Lines changed: 3706 additions & 28 deletions

Src/Common/FwAvalonia/FwAvaloniaStrings.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,5 +497,26 @@ public static string StructuredTextParagraphName(string fieldLabel, int paragrap
497497

498498
public static string FeatureAnalysesName => Text("FwAvalonia.Feature.AnalysesName", "Words Analyses");
499499
public static string FeatureAnalysesDescription => Text("FwAvalonia.Feature.AnalysesDescription", "The interlinear morph-bundle editor.");
500+
501+
/// <summary>Group heading for the avalonia-rule-formula-editor tool surfaces in the feature-manager dialog.</summary>
502+
public static string FeatureGroupGrammarRuleEditors => Text("FwAvalonia.FeatureGroup.GrammarRuleEditors", "Grammar rule editors");
503+
504+
public static string FeaturePhonologicalRuleEditName => Text("FwAvalonia.Feature.PhonologicalRuleEditName", "Phonological Rules");
505+
public static string FeaturePhonologicalRuleEditDescription => Text("FwAvalonia.Feature.PhonologicalRuleEditDescription", "The regular and metathesis rule editors.");
506+
507+
public static string FeatureEnvironmentEditName => Text("FwAvalonia.Feature.EnvironmentEditName", "Phonological Environments");
508+
public static string FeatureEnvironmentEditDescription => Text("FwAvalonia.Feature.EnvironmentEditDescription", "The phonological-environment string editor.");
509+
510+
public static string FeatureCompoundRuleAdvancedEditName => Text("FwAvalonia.Feature.CompoundRuleAdvancedEditName", "Compound Rules");
511+
public static string FeatureCompoundRuleAdvancedEditDescription => Text("FwAvalonia.Feature.CompoundRuleAdvancedEditDescription", "The compound-rule editor.");
512+
513+
public static string FeatureNaturalClassEditName => Text("FwAvalonia.Feature.NaturalClassEditName", "Natural Classes");
514+
public static string FeatureNaturalClassEditDescription => Text("FwAvalonia.Feature.NaturalClassEditDescription", "The natural-class editor.");
515+
516+
public static string FeaturePhonemeEditName => Text("FwAvalonia.Feature.PhonemeEditName", "Phonemes");
517+
public static string FeaturePhonemeEditDescription => Text("FwAvalonia.Feature.PhonemeEditDescription", "The Basic IPA symbol editor.");
518+
519+
public static string FeatureAdhocCoprohibEditName => Text("FwAvalonia.Feature.AdhocCoprohibEditName", "Ad Hoc Co-occurrence Rules");
520+
public static string FeatureAdhocCoprohibEditDescription => Text("FwAvalonia.Feature.AdhocCoprohibEditDescription", "The ad hoc co-occurrence-rule editor.");
500521
}
501522
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright (c) 2026 SIL International
2+
// This software is licensed under the LGPL, version 2.1 or later
3+
// (http://www.gnu.org/licenses/lgpl-2.1.html)
4+
5+
using System.Linq;
6+
using Avalonia.Controls;
7+
using Avalonia.Headless.NUnit;
8+
using Avalonia.Threading;
9+
using Avalonia.VisualTree;
10+
using NUnit.Framework;
11+
using SIL.FieldWorks.Common.FwAvalonia.Region;
12+
using FwAvaloniaTests.VisualChecks;
13+
using FwAvaloniaDialogsTests;
14+
15+
namespace FwAvaloniaTests
16+
{
17+
/// <summary>
18+
/// avalonia-rule-formula-editor (task 3.1) — the Basic IPA Symbol editor control: typing a symbol commits
19+
/// through the sink; with no sink it is read-only. PNG + AssertNoCrowding.
20+
/// </summary>
21+
[TestFixture]
22+
public class BasicIPASymbolEditorTests
23+
{
24+
private sealed class FakeSink : IBasicIpaSymbolCommandSink
25+
{
26+
public string LastCommitted;
27+
public bool Commit(string symbol) { LastCommitted = symbol; return true; }
28+
}
29+
30+
private static TextBox Box(Control root) => root.GetVisualDescendants().OfType<TextBox>().First();
31+
32+
[AvaloniaTest]
33+
public void Editable_CommitsTypedSymbol()
34+
{
35+
var sink = new FakeSink();
36+
var editor = new BasicIPASymbolEditor("p", sink);
37+
var window = new Window { Content = editor, Width = 240, Height = 70 };
38+
window.Show();
39+
Dispatcher.UIThread.RunJobs();
40+
window.UpdateLayout();
41+
Dispatcher.UIThread.RunJobs();
42+
43+
DialogSnapshot.Capture(window, "BasicIPASymbolEditor-01-editable");
44+
DialogLayoutAssert.AssertNoCrowding(editor);
45+
46+
Box(editor).Text = "pʰ";
47+
Assert.That(editor.TryCommit(), Is.True);
48+
Assert.That(sink.LastCommitted, Is.EqualTo("pʰ"));
49+
Assert.That(editor.CommittedText, Is.EqualTo("pʰ"));
50+
}
51+
52+
[AvaloniaTest]
53+
public void NoSink_IsReadOnly()
54+
{
55+
var editor = new BasicIPASymbolEditor("p", null);
56+
var window = new Window { Content = editor, Width = 240, Height = 70 };
57+
window.Show();
58+
Dispatcher.UIThread.RunJobs();
59+
60+
Assert.That(editor.IsEditable, Is.False);
61+
Assert.That(Box(editor).IsReadOnly, Is.True);
62+
}
63+
}
64+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Copyright (c) 2026 SIL International
2+
// This software is licensed under the LGPL, version 2.1 or later
3+
// (http://www.gnu.org/licenses/lgpl-2.1.html)
4+
5+
using System.Linq;
6+
using Avalonia.Controls;
7+
using Avalonia.Headless.NUnit;
8+
using Avalonia.Threading;
9+
using Avalonia.VisualTree;
10+
using NUnit.Framework;
11+
using SIL.FieldWorks.Common.FwAvalonia.Region;
12+
using FwAvaloniaTests.VisualChecks;
13+
using FwAvaloniaDialogsTests;
14+
15+
namespace FwAvaloniaTests
16+
{
17+
/// <summary>
18+
/// avalonia-rule-formula-editor (task 3.2) — the environment editor control: commit a valid string,
19+
/// reject an invalid one (restore the last committed value + show the error). PNG + AssertNoCrowding.
20+
/// </summary>
21+
[TestFixture]
22+
public class PhEnvironmentEditorTests
23+
{
24+
private sealed class FakeSink : IPhEnvironmentCommandSink
25+
{
26+
public string LastCommitted;
27+
// "valid" = anything that is non-empty and balanced-ish; the real recognizer is tested in xWorks.
28+
public bool Validate(string representation) => !(representation ?? "").Contains("X");
29+
public bool Commit(string representation) { LastCommitted = representation; return true; }
30+
}
31+
32+
private static (PhEnvironmentEditor Editor, Window Window, FakeSink Sink) Show(string initial)
33+
{
34+
var sink = new FakeSink();
35+
var editor = new PhEnvironmentEditor(initial, sink);
36+
var window = new Window { Content = editor, Width = 360, Height = 120 };
37+
window.Show();
38+
Dispatcher.UIThread.RunJobs();
39+
window.UpdateLayout();
40+
Dispatcher.UIThread.RunJobs();
41+
return (editor, window, sink);
42+
}
43+
44+
private static TextBox Box(Control root) =>
45+
root.GetVisualDescendants().OfType<TextBox>().First();
46+
47+
[AvaloniaTest]
48+
public void ValidEdit_Commits()
49+
{
50+
var (editor, window, sink) = Show("/ _ #");
51+
DialogSnapshot.Capture(window, "PhEnvironmentEditor-01-initial");
52+
DialogLayoutAssert.AssertNoCrowding(editor);
53+
54+
Box(editor).Text = "/ a _ #";
55+
Assert.That(editor.TryCommit(), Is.True);
56+
Assert.That(sink.LastCommitted, Is.EqualTo("/ a _ #"));
57+
Assert.That(editor.CommittedText, Is.EqualTo("/ a _ #"));
58+
}
59+
60+
[AvaloniaTest]
61+
public void InvalidEdit_IsRejected_AndRestoresLastCommitted()
62+
{
63+
var (editor, window, sink) = Show("/ _ #");
64+
65+
Box(editor).Text = "/ X _ #"; // the fake validator rejects anything containing X
66+
Assert.That(editor.TryCommit(), Is.False);
67+
Assert.That(sink.LastCommitted, Is.Null, "an invalid string is never committed");
68+
Assert.That(Box(editor).Text, Is.EqualTo("/ _ #"), "the box restores the last committed value");
69+
70+
DialogSnapshot.Capture(window, "PhEnvironmentEditor-02-invalid");
71+
DialogLayoutAssert.AssertNoCrowding(editor);
72+
}
73+
74+
[AvaloniaTest]
75+
public void InsertToolbar_OffersTheLiteralInserts()
76+
{
77+
var (editor, _, _) = Show(string.Empty);
78+
var ids = editor.GetVisualDescendants().OfType<Button>()
79+
.Select(b => Avalonia.Automation.AutomationProperties.GetAutomationId(b))
80+
.Where(id => id != null && id.StartsWith("PhEnvInsert-"))
81+
.ToList();
82+
Assert.That(ids, Is.EquivalentTo(new[] { "PhEnvInsert-#", "PhEnvInsert-[", "PhEnvInsert-]", "PhEnvInsert-/", "PhEnvInsert-_" }),
83+
"the insert toolbar offers the boundary/optional/slash/underscore literals");
84+
}
85+
}
86+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Copyright (c) 2026 SIL International
2+
// This software is licensed under the LGPL, version 2.1 or later
3+
// (http://www.gnu.org/licenses/lgpl-2.1.html)
4+
5+
using System;
6+
using NUnit.Framework;
7+
using SIL.FieldWorks.Common.FwAvalonia.Region;
8+
9+
namespace FwAvaloniaTests
10+
{
11+
/// <summary>
12+
/// avalonia-rule-formula-editor (task 2.2) — the <see cref="RuleCellSpec"/> option-key codec that round-
13+
/// trips a chooser selection (the xWorks options projection builds the keys; the view decodes the
14+
/// committed key back to a spec the handler applies).
15+
/// </summary>
16+
[TestFixture]
17+
public class RuleCellCommandsTests
18+
{
19+
[TestCase(RuleCellKind.Phoneme, "P")]
20+
[TestCase(RuleCellKind.NaturalClass, "N")]
21+
[TestCase(RuleCellKind.Boundary, "B")]
22+
public void OptionKey_RoundTrips_ForReferenceKinds(RuleCellKind kind, string prefix)
23+
{
24+
var guid = Guid.NewGuid();
25+
var key = new RuleCellSpec(kind, guid).ToOptionKey();
26+
Assert.That(key, Is.EqualTo(prefix + ":" + guid));
27+
28+
var back = RuleCellSpec.FromOptionKey(key);
29+
Assert.That(back, Is.Not.Null);
30+
Assert.That(back.Kind, Is.EqualTo(kind));
31+
Assert.That(back.TargetGuid, Is.EqualTo(guid));
32+
}
33+
34+
[Test]
35+
public void FromOptionKey_RejectsMalformedOrTargetlessKeys()
36+
{
37+
Assert.That(RuleCellSpec.FromOptionKey(null), Is.Null);
38+
Assert.That(RuleCellSpec.FromOptionKey(""), Is.Null);
39+
Assert.That(RuleCellSpec.FromOptionKey("P"), Is.Null, "no separator");
40+
Assert.That(RuleCellSpec.FromOptionKey("Z:" + Guid.NewGuid()), Is.Null, "unknown kind prefix");
41+
Assert.That(RuleCellSpec.FromOptionKey("P:not-a-guid"), Is.Null, "a reference kind needs a parseable GUID");
42+
}
43+
}
44+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Copyright (c) 2026 SIL International
2+
// This software is licensed under the LGPL, version 2.1 or later
3+
// (http://www.gnu.org/licenses/lgpl-2.1.html)
4+
5+
using System;
6+
using System.Linq;
7+
using NUnit.Framework;
8+
using SIL.FieldWorks.Common.FwAvalonia.Region;
9+
10+
namespace FwAvaloniaTests
11+
{
12+
/// <summary>
13+
/// avalonia-rule-formula-editor (task 1.1/1.2) — the LCModel-free rule-formula projection DTO the
14+
/// Avalonia editor binds to. Sectioned (LHS/RHS/env) with immutable per-section cell-mutation helpers
15+
/// (insert/remove/move) that underpin the editor staging before the Morphology plugin commits the
16+
/// change to the rule in one UOW. <see cref="RuleFormulaModel.ToFormulaString"/> is the canonical
17+
/// rendering the parity test asserts against.
18+
/// </summary>
19+
[TestFixture]
20+
public class RuleFormulaModelTests
21+
{
22+
private static RuleFormulaSection Three(RuleSectionRole role = RuleSectionRole.Lhs) =>
23+
new RuleFormulaSection(role, new[]
24+
{
25+
new RuleCell(RuleCellKind.Phoneme, "p", Guid.NewGuid()),
26+
new RuleCell(RuleCellKind.NaturalClass, "V", Guid.NewGuid()),
27+
new RuleCell(RuleCellKind.Boundary, "#"),
28+
});
29+
30+
private static string Text(RuleFormulaSection s) => string.Concat(s.Cells.Select(c => c.DisplayText));
31+
32+
[Test]
33+
public void InsertCell_AddsAtIndex_AndIsImmutable()
34+
{
35+
var s = Three();
36+
var n = s.InsertCell(1, new RuleCell(RuleCellKind.Slot, "X"));
37+
Assert.That(Text(n), Is.EqualTo("pXV#"));
38+
Assert.That(Text(s), Is.EqualTo("pV#"), "the original section is unchanged (immutable)");
39+
}
40+
41+
[Test]
42+
public void InsertCell_OutOfRangeIndex_IsClamped()
43+
{
44+
var s = Three();
45+
Assert.That(Text(s.InsertCell(99, new RuleCell(RuleCellKind.Slot, "X"))), Is.EqualTo("pV#X"));
46+
Assert.That(Text(s.InsertCell(-5, new RuleCell(RuleCellKind.Slot, "X"))), Is.EqualTo("XpV#"));
47+
}
48+
49+
[Test]
50+
public void RemoveCell_DropsCell_OrNoOpsOutOfRange()
51+
{
52+
var s = Three();
53+
Assert.That(Text(s.RemoveCell(1)), Is.EqualTo("p#"));
54+
Assert.That(s.RemoveCell(99), Is.SameAs(s), "out-of-range remove is a no-op returning the same section");
55+
}
56+
57+
[Test]
58+
public void MoveCell_ReordersCells()
59+
{
60+
Assert.That(Text(Three().MoveCell(0, 2)), Is.EqualTo("V#p"));
61+
}
62+
63+
[Test]
64+
public void Cell_CarriesKindAndTargetGuid()
65+
{
66+
var g = Guid.NewGuid();
67+
var cell = new RuleCell(RuleCellKind.NaturalClass, "V", g);
68+
Assert.That(cell.Kind, Is.EqualTo(RuleCellKind.NaturalClass));
69+
Assert.That(cell.TargetGuid, Is.EqualTo(g));
70+
Assert.That(new RuleCell(RuleCellKind.Slot, "X").TargetGuid, Is.Null, "a pure slot has no target");
71+
}
72+
73+
[Test]
74+
public void SectionFor_FindsByRole_AndWithSectionReplaces()
75+
{
76+
var model = new RuleFormulaModel("PhRegularRule", new[]
77+
{
78+
new RuleFormulaSection(RuleSectionRole.Lhs, new[] { new RuleCell(RuleCellKind.Phoneme, "p") }),
79+
new RuleFormulaSection(RuleSectionRole.Rhs, Enumerable.Empty<RuleCell>()),
80+
});
81+
Assert.That(model.SectionFor(RuleSectionRole.Lhs).Cells, Has.Count.EqualTo(1));
82+
Assert.That(model.SectionFor(RuleSectionRole.RightContext), Is.Null);
83+
84+
var rhsWithCell = model.SectionFor(RuleSectionRole.Rhs).InsertCell(0, new RuleCell(RuleCellKind.Boundary, "#"));
85+
var updated = model.WithSection(1, rhsWithCell);
86+
Assert.That(updated.SectionFor(RuleSectionRole.Rhs).Cells, Has.Count.EqualTo(1));
87+
Assert.That(model.SectionFor(RuleSectionRole.Rhs).Cells, Is.Empty, "original model unchanged (immutable)");
88+
}
89+
90+
[Test]
91+
public void ToFormulaString_RendersBareTokens_NcBracketed_WithRoleSeparators()
92+
{
93+
// p → [V] / [C] __ # — the canonical regular-rule formula oracle shape.
94+
var model = new RuleFormulaModel("PhRegularRule", new[]
95+
{
96+
new RuleFormulaSection(RuleSectionRole.Lhs, new[] { new RuleCell(RuleCellKind.Phoneme, "p") }),
97+
new RuleFormulaSection(RuleSectionRole.Rhs, new[] { new RuleCell(RuleCellKind.NaturalClass, "V") }),
98+
new RuleFormulaSection(RuleSectionRole.LeftContext, new[] { new RuleCell(RuleCellKind.NaturalClass, "C") }),
99+
new RuleFormulaSection(RuleSectionRole.RightContext, new[] { new RuleCell(RuleCellKind.Boundary, "#") }),
100+
});
101+
Assert.That(model.ToFormulaString(), Is.EqualTo("p → [V] / [C] __ #"));
102+
}
103+
104+
[Test]
105+
public void ToFormulaString_EmptySectionsContributeNothingButTheirSeparator()
106+
{
107+
var model = new RuleFormulaModel("PhRegularRule", new[]
108+
{
109+
new RuleFormulaSection(RuleSectionRole.Lhs, new[] { new RuleCell(RuleCellKind.Phoneme, "p") }),
110+
new RuleFormulaSection(RuleSectionRole.Rhs, Enumerable.Empty<RuleCell>()),
111+
new RuleFormulaSection(RuleSectionRole.LeftContext, Enumerable.Empty<RuleCell>()),
112+
new RuleFormulaSection(RuleSectionRole.RightContext, Enumerable.Empty<RuleCell>()),
113+
});
114+
Assert.That(model.ToFormulaString(), Is.EqualTo("p → / __ "));
115+
}
116+
}
117+
}

0 commit comments

Comments
 (0)