Skip to content

Commit aeaac10

Browse files
thejambiZachary Burnhamclaude
authored
LT-22690: Test ghost add rows, and create nothing when one is cleared (#1194)
* LT-22690: Test ghost add rows, and create nothing when one is cleared Typing into a ghost row creates its object on the first keystroke, so typing and then clearing the row before leaving it saved an empty object. The edit context now records which row opened the edit session. Clearing a ghost row whose edits are the whole session cancels that session: nothing is created and no undo step remains. An edit another row staged into the same session is never cancelled. New tests cover the ticket's acceptance steps. Typing into the Example and SubSenses ghost rows creates the object and the view re-composes into its rows. Creating and typing are one undo step, and undoing it brings the ghost row back. Focusing and leaving an untouched ghost row stages and saves nothing. * LT-22690: Trim the comments on the ghost row change Shorten the session-ownership doc comments to their contracts, and drop test comments that restate the helper or test name. Comment-only. * Comment cleanup * LT-22690: Cancel a cleared ghost row only when it can roll back Two gaps in clearing a ghost row before leaving it: - When the edit session joined an undo task opened elsewhere, cancelling it rolled nothing back, so the created Example kept the text the user had just cleared. The cancel path now also requires a session that can roll back; otherwise the empty text is written. - A rejected write from another row dropped the ghost row's ownership of the session. Ownership now changes only on a successful write, and is cleared when a setter throws in an already-open session. A test for each reproduces the gap and passes with its fix. Co-Authored-By: Zachary Burnham <zachary_burnham@sil.org> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 5ccdba7 commit aeaac10

5 files changed

Lines changed: 254 additions & 4 deletions

File tree

‎Src/Common/FwAvalonia/FwAvaloniaTests/DetailEditingTests.cs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1127,7 +1127,8 @@ public void GhostRow_WatermarkClearsOnFocus_AndRestoresWhenLeftEmpty()
11271127
isEditable: true, indent: 0, ghostPrompt: "Click here to add Lexeme Form");
11281128
var model = new DetailModel("LexEntry", "Normal",
11291129
new List<DetailField> { ghost }, new List<ViewDiagnostic>());
1130-
var view = new DataTree(model, new FakeDetailEditContext());
1130+
var context = new FakeDetailEditContext();
1131+
var view = new DataTree(model, context);
11311132
var window = new Window { Content = view, Width = 480, Height = 200 };
11321133
window.Show();
11331134
Dispatcher.UIThread.RunJobs();
@@ -1144,6 +1145,9 @@ public void GhostRow_WatermarkClearsOnFocus_AndRestoresWhenLeftEmpty()
11441145
Dispatcher.UIThread.RunJobs();
11451146
Assert.That(box.Watermark, Is.EqualTo("Click here to add Lexeme Form"),
11461147
"leaving without typing restores the prompt");
1148+
Assert.That(context.TextEdits, Is.Empty,
1149+
"focusing and leaving the ghost row stages nothing, so nothing is created");
1150+
Assert.That(context.CommitCount, Is.Zero, "with nothing staged there is nothing to save");
11471151
}
11481152

11491153
// A search-backed reference vector (SearchOptions

‎Src/xWorks/Avalonia/Composer/ComposedDetailEditContext.cs‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
using System.Linq;
88
using SIL.FieldWorks.Common.FwAvalonia.Detail;
99
using SIL.LCModel;
10-
using SIL.LCModel.Core.KernelInterfaces;
1110

1211
namespace SIL.FieldWorks.XWorks
1312
{
@@ -82,8 +81,15 @@ public override bool TrySetText(DetailField field, string ws, string value)
8281
var setter = Handler(field)?.Text;
8382
if (setter == null)
8483
return false;
84+
85+
if (string.IsNullOrEmpty(value) && field.GhostPrompt != null
86+
&& IsSessionOwnedBy(field.StableId) && CanRollBackSession)
87+
{
88+
Cancel();
89+
return true;
90+
}
8591
// A single field's edit names the undo label (e.g. "Undo change to Gloss").
86-
return Stage(() => setter(ws, value), FieldLabelFor(field));
92+
return Stage(() => setter(ws, value), FieldLabelFor(field), field.StableId);
8793
}
8894

8995
public override bool TrySetRichText(DetailField field, string ws, DetailRichTextValue value)

‎Src/xWorks/Avalonia/Composer/LcmDetailEditSession.cs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ public LcmDetailEditSession(LcmCache cache, string undoLabel, string redoLabel)
4343
/// <inheritdoc />
4444
public bool IsOpen { get; private set; }
4545

46+
/// <summary>
47+
/// Whether <see cref="Cancel"/> would roll back this session's writes: it is open and
48+
/// owns its undo task, which is still the open one.
49+
/// </summary>
50+
public bool CanRollBack => IsOpen && _ownsTask && TaskStillOpen;
51+
4652
/// <inheritdoc />
4753
public void Commit()
4854
{

‎Src/xWorks/Avalonia/DetailEditContextBase.cs‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ namespace SIL.FieldWorks.XWorks
2121
public abstract class DetailEditContextBase : IDetailEditContext
2222
{
2323
private LcmDetailEditSession _session;
24+
private string _sessionOwnerKey;
2425
private readonly Dictionary<string, Func<bool>> _pendingEditFlushes =
2526
new Dictionary<string, Func<bool>>(StringComparer.Ordinal);
2627

@@ -154,15 +155,31 @@ public bool FlushPendingEdits()
154155
/// <inheritdoc />
155156
public void Commit()
156157
{
158+
_sessionOwnerKey = null;
157159
_session?.Commit();
158160
}
159161

160162
/// <inheritdoc />
161163
public void Cancel()
162164
{
165+
_sessionOwnerKey = null;
163166
_session?.Cancel();
164167
}
165168

169+
/// <summary>
170+
/// Whether every write in the open session was staged under <paramref name="ownerKey"/>.
171+
/// False when no session is open or the key is null.
172+
/// </summary>
173+
protected bool IsSessionOwnedBy(string ownerKey)
174+
=> IsOpen && ownerKey != null
175+
&& string.Equals(_sessionOwnerKey, ownerKey, StringComparison.Ordinal);
176+
177+
/// <summary>
178+
/// Whether <see cref="Cancel"/> would roll back the open session's writes. False when the
179+
/// session joined an undo task opened elsewhere, which only that task's opener can end.
180+
/// </summary>
181+
protected bool CanRollBackSession => _session != null && _session.CanRollBack;
182+
166183
/// <summary>
167184
/// Opens the fenced session on the first staged edit; later calls are no-ops. The generic
168185
/// "Undo/Redo Edit Entry" label is used -- the batch/bulk path that stages several fields
@@ -230,6 +247,15 @@ public bool Stage(Func<bool> setter)
230247
/// already-open session leaves that session's label untouched.
231248
/// </summary>
232249
public bool Stage(Func<bool> setter, string fieldLabel)
250+
{
251+
return Stage(setter, fieldLabel, null);
252+
}
253+
254+
/// <summary>
255+
/// As <see cref="Stage(Func{bool}, string)"/>, recording a write that stages under
256+
/// <paramref name="ownerKey"/> for <see cref="IsSessionOwnedBy"/>.
257+
/// </summary>
258+
protected bool Stage(Func<bool> setter, string fieldLabel, string ownerKey)
233259
{
234260
if (setter == null)
235261
return false;
@@ -238,14 +264,25 @@ public bool Stage(Func<bool> setter, string fieldLabel)
238264
try
239265
{
240266
var staged = setter();
241-
if (!staged && !wasOpen)
267+
if (staged)
268+
{
269+
if (!wasOpen)
270+
_sessionOwnerKey = ownerKey;
271+
else if (!string.Equals(_sessionOwnerKey, ownerKey, StringComparison.Ordinal))
272+
_sessionOwnerKey = null;
273+
}
274+
else if (!wasOpen)
275+
{
242276
Cancel();
277+
}
243278
return staged;
244279
}
245280
catch
246281
{
247282
if (!wasOpen)
248283
Cancel();
284+
else
285+
_sessionOwnerKey = null; // a throwing setter may have written before it threw
249286
throw;
250287
}
251288
}

‎Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs‎

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1555,6 +1555,203 @@ public void Compose_GhostWithoutGhostField_IsNotEditable_SoTypingCannotCreateAnd
15551555
"no bare object was created behind the user's back");
15561556
}
15571557

1558+
private static DetailField ExampleGhost(ComposedDetail composed, ILexSense sense)
1559+
=> composed.Model.Fields.FirstOrDefault(f => f.Field == "Examples"
1560+
&& f.StableId.EndsWith(DetailField.GhostStableIdSuffix) && f.ObjectHvo == sense.Hvo);
1561+
1562+
// The layout gives SubSenses no ghost= attribute; LT-22690 approves its ghost row.
1563+
private static DetailField SubsenseGhost(ComposedDetail composed, ILexSense sense)
1564+
=> composed.Model.Fields.FirstOrDefault(f => f.Field == "Senses"
1565+
&& f.StableId.EndsWith(DetailField.GhostStableIdSuffix) && f.ObjectHvo == sense.Hvo);
1566+
1567+
// One stage per keystroke, as the view does.
1568+
private static void TypeInto(ComposedDetail composed, DetailField field, string text)
1569+
{
1570+
for (var length = 1; length <= text.Length; length++)
1571+
{
1572+
Assert.That(composed.EditContext.TrySetText(field, field.Values[0].WsAbbrev,
1573+
text.Substring(0, length)), Is.True, "each keystroke stages");
1574+
}
1575+
}
1576+
1577+
[Test]
1578+
public void Compose_GhostExample_TypingCreatesTheExample_AndRecomposesIntoItsRows()
1579+
{
1580+
var sense = m_entry.SensesOS[0];
1581+
var composed = DetailComposer.Compose(m_entry, Cache);
1582+
var ghost = ExampleGhost(composed, sense);
1583+
Assert.That(ghost, Is.Not.Null, "a sense with no examples shows the Example ghost row");
1584+
Assert.That(ghost.IsEditable, Is.True);
1585+
Assert.That(ghost.GhostPrompt, Does.Contain("Example"));
1586+
1587+
TypeInto(composed, ghost, "una casa");
1588+
composed.EditContext.Commit();
1589+
1590+
Assert.That(sense.ExamplesOS.Count, Is.EqualTo(1), "typing created the example");
1591+
var example = sense.ExamplesOS[0];
1592+
Assert.That(example.Example.get_String(Cache.DefaultVernWs).Text, Is.EqualTo("una casa"),
1593+
"the typed text landed in the Example field, in the vernacular writing system");
1594+
1595+
var recomposed = DetailComposer.Compose(m_entry, Cache);
1596+
Assert.That(ExampleGhost(recomposed, sense), Is.Null,
1597+
"with an example present the ghost row gives way to the example's own rows");
1598+
var exampleRow = recomposed.Model.Fields.FirstOrDefault(f =>
1599+
f.Field == "Example" && f.ObjectHvo == example.Hvo);
1600+
Assert.That(exampleRow, Is.Not.Null, "the new example composes its Example row");
1601+
Assert.That(exampleRow.Values.Any(v => v.Value == "una casa"), Is.True,
1602+
"the Example row shows the typed text");
1603+
}
1604+
1605+
[Test]
1606+
public void Compose_GhostExample_CreationAndTyping_AreOneUndoStep()
1607+
{
1608+
var sense = m_entry.SensesOS[0];
1609+
var composed = DetailComposer.Compose(m_entry, Cache);
1610+
var stepsBefore = Cache.ActionHandlerAccessor.UndoableSequenceCount;
1611+
1612+
TypeInto(composed, ExampleGhost(composed, sense), "una casa");
1613+
composed.EditContext.Commit();
1614+
Assert.That(Cache.ActionHandlerAccessor.UndoableSequenceCount, Is.EqualTo(stepsBefore + 1),
1615+
"creating the example and every keystroke typed into it are one undo step");
1616+
1617+
Cache.ActionHandlerAccessor.Undo();
1618+
Assert.That(sense.ExamplesOS.Count, Is.Zero, "one undo removes the example");
1619+
Assert.That(ExampleGhost(DetailComposer.Compose(m_entry, Cache), sense), Is.Not.Null,
1620+
"after the undo the sense shows its Example ghost row again");
1621+
}
1622+
1623+
[Test]
1624+
public void Compose_GhostExample_TypedThenCleared_CreatesNothing()
1625+
{
1626+
var sense = m_entry.SensesOS[0];
1627+
var composed = DetailComposer.Compose(m_entry, Cache);
1628+
var ghost = ExampleGhost(composed, sense);
1629+
var stepsBefore = Cache.ActionHandlerAccessor.UndoableSequenceCount;
1630+
1631+
TypeInto(composed, ghost, "un");
1632+
Assert.That(sense.ExamplesOS.Count, Is.EqualTo(1), "the first keystroke creates the example");
1633+
Assert.That(composed.EditContext.TrySetText(ghost, ghost.Values[0].WsAbbrev, "u"), Is.True);
1634+
Assert.That(composed.EditContext.TrySetText(ghost, ghost.Values[0].WsAbbrev, ""), Is.True,
1635+
"clearing the row is accepted");
1636+
var sessionOpenAfterClear = composed.EditContext.IsOpen;
1637+
var examplesAfterClear = sense.ExamplesOS.Count;
1638+
// Close the session first, so a failure cannot break the next test's setup.
1639+
composed.EditContext.Commit();
1640+
1641+
Assert.That(sessionOpenAfterClear, Is.False, "clearing the row ends its edit session");
1642+
Assert.That(examplesAfterClear, Is.Zero, "clearing the row removes the example it created");
1643+
Assert.That(Cache.ActionHandlerAccessor.UndoableSequenceCount, Is.EqualTo(stepsBefore),
1644+
"nothing reaches the undo stack, not even a step that changes nothing");
1645+
1646+
TypeInto(composed, ghost, "b");
1647+
composed.EditContext.Commit();
1648+
Assert.That(sense.ExamplesOS.Count, Is.EqualTo(1), "typing again creates the example afresh");
1649+
Assert.That(sense.ExamplesOS[0].Example.get_String(Cache.DefaultVernWs).Text, Is.EqualTo("b"));
1650+
}
1651+
1652+
[Test]
1653+
public void Compose_GhostExample_ClearedAfterAnotherRowsEdit_KeepsThatEdit()
1654+
{
1655+
var sense = m_entry.SensesOS[0];
1656+
var composed = DetailComposer.Compose(m_entry, Cache);
1657+
var gloss = composed.Model.Fields.First(f => f.Field == "Gloss" && f.ObjectHvo == sense.Hvo);
1658+
var ghost = ExampleGhost(composed, sense);
1659+
1660+
var glossStaged = composed.EditContext.TrySetText(gloss, gloss.Values[0].WsAbbrev, "home");
1661+
var typedStaged = composed.EditContext.TrySetText(ghost, ghost.Values[0].WsAbbrev, "u");
1662+
var clearStaged = composed.EditContext.TrySetText(ghost, ghost.Values[0].WsAbbrev, "");
1663+
var sessionOpenAfterClear = composed.EditContext.IsOpen;
1664+
// Close the session first, so a failure cannot break the next test's setup.
1665+
composed.EditContext.Commit();
1666+
1667+
Assert.That(glossStaged && typedStaged && clearStaged, Is.True, "every edit stages");
1668+
Assert.That(sessionOpenAfterClear, Is.True,
1669+
"the session holds another row's edit, so clearing the ghost row does not cancel it");
1670+
Assert.That(sense.Gloss.get_String(Cache.DefaultAnalWs).Text, Is.EqualTo("home"),
1671+
"the other row's edit is saved");
1672+
}
1673+
1674+
[Test]
1675+
public void Compose_GhostExample_ClearedAfterAnotherRowsRejectedEdit_CreatesNothing()
1676+
{
1677+
var sense = m_entry.SensesOS[0];
1678+
var composed = DetailComposer.Compose(m_entry, Cache);
1679+
var gloss = composed.Model.Fields.First(f => f.Field == "Gloss" && f.ObjectHvo == sense.Hvo);
1680+
var ghost = ExampleGhost(composed, sense);
1681+
1682+
TypeInto(composed, ghost, "u");
1683+
var glossStaged = composed.EditContext.TrySetText(gloss, "no-such-ws", "home");
1684+
var clearStaged = composed.EditContext.TrySetText(ghost, ghost.Values[0].WsAbbrev, "");
1685+
var sessionOpenAfterClear = composed.EditContext.IsOpen;
1686+
var examplesAfterClear = sense.ExamplesOS.Count;
1687+
// Close the session first, so a failure cannot break the next test's setup.
1688+
composed.EditContext.Commit();
1689+
1690+
Assert.That(glossStaged, Is.False, "precondition: the gloss row rejects an unknown writing system");
1691+
Assert.That(clearStaged, Is.True);
1692+
Assert.That(sessionOpenAfterClear, Is.False,
1693+
"a rejected edit wrote nothing, so the session still holds only the ghost row's edits");
1694+
Assert.That(examplesAfterClear, Is.Zero, "clearing the row removes the example it created");
1695+
}
1696+
1697+
// An undo task opened before the row is typed into belongs to its opener, and cancelling
1698+
// the edit session cannot roll it back.
1699+
[Test]
1700+
public void Compose_GhostExample_ClearedInsideAnOuterUndoTask_KeepsNoTypedText()
1701+
{
1702+
var sense = m_entry.SensesOS[0];
1703+
var composed = DetailComposer.Compose(m_entry, Cache);
1704+
var ghost = ExampleGhost(composed, sense);
1705+
1706+
Cache.DomainDataByFlid.BeginUndoTask("outer", "outer");
1707+
try
1708+
{
1709+
TypeInto(composed, ghost, "u");
1710+
Assert.That(composed.EditContext.TrySetText(ghost, ghost.Values[0].WsAbbrev, ""), Is.True,
1711+
"clearing the row is accepted");
1712+
composed.EditContext.Commit();
1713+
}
1714+
finally
1715+
{
1716+
Cache.DomainDataByFlid.EndUndoTask();
1717+
}
1718+
1719+
Assert.That(sense.ExamplesOS.Select(e => e.Example.get_String(Cache.DefaultVernWs).Text ?? ""),
1720+
Has.All.Empty, "the text the user cleared is not saved by the outer task");
1721+
}
1722+
1723+
[Test]
1724+
public void Compose_GhostSubsense_TypingCreatesASubsense_WithTheTypedGloss()
1725+
{
1726+
var sense = m_entry.SensesOS[0];
1727+
var composed = DetailComposer.Compose(m_entry, Cache);
1728+
var ghost = SubsenseGhost(composed, sense);
1729+
Assert.That(ghost, Is.Not.Null, "a sense with no subsenses shows the SubSenses ghost row");
1730+
Assert.That(ghost.IsEditable, Is.True);
1731+
var stepsBefore = Cache.ActionHandlerAccessor.UndoableSequenceCount;
1732+
1733+
TypeInto(composed, ghost, "small house");
1734+
composed.EditContext.Commit();
1735+
1736+
Assert.That(sense.SensesOS.Count, Is.EqualTo(1), "typing created the subsense");
1737+
var subsense = sense.SensesOS[0];
1738+
Assert.That(subsense.Gloss.get_String(Cache.DefaultAnalWs).Text, Is.EqualTo("small house"),
1739+
"the typed text became the subsense's gloss");
1740+
Assert.That(Cache.ActionHandlerAccessor.UndoableSequenceCount, Is.EqualTo(stepsBefore + 1),
1741+
"creating the subsense and typing its gloss are one undo step");
1742+
1743+
var recomposed = DetailComposer.Compose(m_entry, Cache);
1744+
Assert.That(SubsenseGhost(recomposed, sense), Is.Null,
1745+
"with a subsense present the ghost row gives way to the subsense's own rows");
1746+
Assert.That(recomposed.Model.Fields.Any(f => f.Field == "Gloss" && f.ObjectHvo == subsense.Hvo),
1747+
Is.True, "the new subsense composes its Gloss row");
1748+
1749+
Cache.ActionHandlerAccessor.Undo();
1750+
Assert.That(sense.SensesOS.Count, Is.Zero, "one undo removes the subsense");
1751+
Assert.That(SubsenseGhost(DetailComposer.Compose(m_entry, Cache), sense), Is.Not.Null,
1752+
"after the undo the sense shows its SubSenses ghost row again");
1753+
}
1754+
15581755
// Conditional display: the real shipped variant/complex-form
15591756
// divergence. LexEntryRef/Normal's VariantEntryTypes and ComplexEntryTypes parts are
15601757
// <if field="RefType" intequals="0|1"> twins (LexEntryParts.xml:1133-1162); exactly one may

0 commit comments

Comments
 (0)