Add user-defined invoice set structure dialog (ADR 0016)
Playwright Tests / test (push) Canceled after 0s

Lets a back-office user correct/define which invoice line belongs to
which "set" and how deep it is nested, instead of relying only on the
implicit "everything after a Set header until the next Set" grouping
fds__prepInvoice derives. Sets may contain sets; every change applies
immediately to the cached draft session and is reflected in the editor
and PDF (ADR 0006/0008).

Backend:
- InvoiceSetStructure: pure parent-link forest (assign, change level,
  create, place/move-sibling, depth-first linearise + level recompute).
- InvoiceDraftSetStructureDeltas: new inv/dpatch targets set.assign,
  set.level, set.create, set.move.
- InvoiceSetPricing.Build is now order-preserving and nesting-aware
  (governed by the nearest converted ancestor); the set-item switch
  sums a header's transitive descendants so nested conversions never
  double-count.
- FuchsPdf indents nested set members per level, mirroring the editor.

Frontend:
- New fis.inv_sets.js: "Set-Struktur bearbeiten" dialog — per-block
  table of all lines with drag & drop reorder (before/after/into),
  ▲▼ order and ◀▶ level buttons, fold/unfold per set (view-only), and
  a create bar for new Set/Text lines. Posts one delta per action; the
  dialog is a pure view of the server state (ADR 0006).
- _Layout.cshtml / fis_main.js: cache-bust the dynamically imported
  per-module bundles (web/fis.<mdl>.<locale>.js/.css) with an asset
  version derived from the newest bundle write time, so a gulp rebuild
  is picked up without a stale cached module.

Docs: ADR 0016, live-draft-editing concept, INVOICE_SET_PRICING.md,
USER_GUIDE.md, ARCHITECTURE.md updated. Tests: InvoiceSetStructureTests
(new) + additions to InvoiceDraftServiceTests/InvoiceSetPricingTests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 08:55:05 +02:00
co-authored by Claude Sonnet 5
parent c1936a327e
commit ecc74cf3c6
31 changed files with 3003 additions and 147 deletions
+337
View File
@@ -870,4 +870,341 @@ public class InvoiceDraftServiceTests
Assert.Equal(4, final.History.Count);
Assert.DoesNotContain(final.ValidationMessages, m => m.Severity == "error");
}
// ── User-defined set structure (ADR 0016): set.assign / set.level / set.create ───────────
// A(set) > B(set) > m1, m2 ; A > m3 ; x standalone. Delivered linearised, like fds__prepInvoice + Linearize would.
private static JObject NestedSetPayload() => JObject.Parse(@"{
'admin':{'p13b':false,'type':'r','paymentterms':'10wd'},
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
'req':[{'Id':'1','text':'Auftrag','itm':[
{'id':'A','typ':'set','vt':0,'vv':0,'vs':0,'vsv':0,'vat':'19%','SetItmId':'A'},
{'id':'B','typ':'set','vt':0,'vv':0,'vs':0,'vsv':0,'vat':'19%','SetItmId':'A'},
{'id':'m1','typ':'material','vt':60,'vv':11.4,'vs':0,'vsv':0,'vat':'19%','SetItmId':'B'},
{'id':'m2','typ':'material','vt':40,'vv':7.6,'vs':10,'vsv':1.9,'vat':'19%','SetItmId':'B'},
{'id':'m3','typ':'material','vt':25,'vv':4.75,'vs':0,'vsv':0,'vat':'19%','SetItmId':'A'},
{'id':'x','typ':'material','vt':10,'vv':1.9,'vs':0,'vsv':0,'vat':'19%','SetItmId':null}],
'items':[{'id':'A','type':'set','total_net':0},{'id':'B','type':'set','total_net':0},
{'id':'m1','type':'material','total_net':60,'vat':'19%'},{'id':'m2','type':'material','total_net':40,'vat':'19%'},
{'id':'m3','type':'material','total_net':25,'vat':'19%'},{'id':'x','type':'material','total_net':10,'vat':'19%'}]}]
}");
private static string[] LineOrder(InvoiceDraftSession s, int block = 0) =>
((JArray)((JObject)s.Req[block])["itm"]!).OfType<JObject>().Select(l => (string)l["id"]!).ToArray();
private static JObject LineOf(InvoiceDraftSession s, string id, int block = 0) =>
((JArray)((JObject)s.Req[block])["itm"]!).OfType<JObject>().Single(l => (string)l["id"]! == id);
private static JObject ItemOf(InvoiceDraftSession s, string id, int block = 0) =>
((JArray)((JObject)s.Req[block])["items"]!).OfType<JObject>().Single(l => (string)l["id"]! == id);
[Fact]
public void OpenFromPayload_NestedSets_PublishesLevelsAndMirrorsSetIdOntoItems()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
Assert.Equal(0, LineOf(s, "A")["lvl"]!.Value<int>());
Assert.Equal(1, LineOf(s, "B")["lvl"]!.Value<int>());
Assert.Equal(2, LineOf(s, "m1")["lvl"]!.Value<int>());
Assert.Equal(1, LineOf(s, "m3")["lvl"]!.Value<int>());
Assert.Equal(0, LineOf(s, "x")["lvl"]!.Value<int>());
Assert.Equal("A", (string)ItemOf(s, "B")["setId"]!); // a nested set is a member of its parent
Assert.Equal("B", (string)ItemOf(s, "m1")["setId"]!);
Assert.Equal(JTokenType.Null, ItemOf(s, "A")["setId"]!.Type); // self-reference -> no parent
Assert.Equal(2, ItemOf(s, "m1")["level"]!.Value<int>());
Assert.Equal(135m, s.Sums.TotalNet);
}
[Fact]
public void ApplyPatch_SetAssign_DropOntoSet_BecomesLastMember_BumpsVersionRecordsHistory()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.assign", Ref = "x", Value = JObject.Parse("{'parent':'A'}") });
Assert.NotNull(s2);
Assert.Equal(1, s2!.Version);
Assert.Equal(new[] { "A", "B", "m1", "m2", "m3", "x" }, LineOrder(s2));
Assert.Equal(1, LineOf(s2, "x")["lvl"]!.Value<int>());
Assert.Equal("A", (string)LineOf(s2, "x")["SetItmId"]!);
Assert.Equal("A", (string)ItemOf(s2, "x")["setId"]!);
Assert.Equal(135m, s2.Sums.TotalNet); // structure never changes the total
var h = Assert.Single(s2.History);
Assert.Equal("set.assign", h.Target); Assert.Equal("x", h.Ref); Assert.Equal("", h.OldValue); Assert.Equal("A", h.NewValue);
Assert.Equal("1.3", (string)LineOf(s2, "x")["p"]!); // positions renumbered along the new order (hierarchical, ADR 0017)
}
[Fact]
public void ApplyPatch_SetAssign_PullSetOut_ItsMembersFollowOneLevelUp()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.assign", Ref = "B", Value = JToken.FromObject("") });
Assert.Equal(new[] { "A", "m3", "B", "m1", "m2", "x" }, LineOrder(s2!));
Assert.Equal(0, LineOf(s2!, "B")["lvl"]!.Value<int>());
Assert.Equal(1, LineOf(s2!, "m2")["lvl"]!.Value<int>());
Assert.Equal("B", (string)LineOf(s2!, "m2")["SetItmId"]!);
Assert.Equal("A", s2!.History[0].OldValue);
}
[Theory]
[InlineData("A", "B")] // onto its own descendant — cycle
[InlineData("x", "m3")] // onto a non-set line
[InlineData("m1", "B")] // unchanged
[InlineData("nope", "A")] // unknown item
[InlineData("", "A")] // missing ref
public void ApplyPatch_SetAssign_Invalid_IsNoOp_NoVersionBumpNoHistory(string item, string parent)
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var before = LineOrder(s);
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.assign", Ref = item, Value = JObject.Parse("{'parent':'" + parent + "'}") });
Assert.Equal(0, s2!.Version);
Assert.Empty(s2.History);
Assert.Equal(before, LineOrder(s2));
}
[Theory]
[InlineData("m3", 1, "1", "2", new[] { "A", "B", "m1", "m2", "m3", "x" })] // indent: joins preceding sibling set B
[InlineData("m1", -1, "2", "1", new[] { "A", "B", "m2", "m1", "m3", "x" })] // outdent: leaves B, placed after B's subtree
[InlineData("B", -1, "1", "0", new[] { "A", "m3", "B", "m1", "m2", "x" })] // outdent a set: members travel with it
public void ApplyPatch_SetLevel_IndentOutdent_ReordersAndRecordsLevels(string item, int delta, string oldLvl, string newLvl, string[] order)
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.level", Ref = item, Value = JToken.FromObject(delta) });
Assert.Equal(1, s2!.Version);
Assert.Equal(order, LineOrder(s2));
var h = Assert.Single(s2.History);
Assert.Equal("set.level", h.Target); Assert.Equal(oldLvl, h.OldValue); Assert.Equal(newLvl, h.NewValue);
Assert.Equal(int.Parse(newLvl), LineOf(s2, item)["lvl"]!.Value<int>());
Assert.Equal(135m, s2.Sums.TotalNet);
}
[Theory]
[InlineData("x", "-1")] // already top level
[InlineData("A", "in")] // nothing precedes the first line
[InlineData("m1", "0")] // no direction
[InlineData("m1", "abc")] // unparseable
public void ApplyPatch_SetLevel_NoMove_IsNoOp(string item, string value)
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var before = LineOrder(s);
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.level", Ref = item, Value = JToken.FromObject(value) });
Assert.Equal(0, s2!.Version);
Assert.Equal(before, LineOrder(s2));
}
[Fact]
public void ApplyPatch_SetCreate_Set_InsideParent_AppendsAsLastMember_UnpricedAndNumbered()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.create", Ref = "1", Value = JObject.Parse("{'typ':'set','title':'Unterset','parent':'B'}") });
Assert.Equal(1, s2!.Version);
var order = LineOrder(s2);
Assert.Equal(7, order.Length);
string id = order[4]; // A, B, m1, m2, NEW, m3, x
Assert.StartsWith("sN", id);
Assert.Equal(new[] { "A", "B", "m1", "m2", id, "m3", "x" }, order);
var co = LineOf(s2, id);
Assert.Equal("set", (string)co["typ"]!);
Assert.Equal("Unterset", (string)co["t"]!);
Assert.Equal(0m, co["vt"]!.Value<decimal>()); // unconverted — the "Auf Setpreis umstellen" switch applies later
Assert.Equal(2, co["lvl"]!.Value<int>());
Assert.Equal("1.1.3", (string)co["p"]!); // a set header is numbered, hierarchically (ADR 0017)
Assert.Equal("set", (string)ItemOf(s2, id)["type"]!);
Assert.Equal("B", (string)ItemOf(s2, id)["setId"]!);
Assert.Equal(135m, s2.Sums.TotalNet);
var h = Assert.Single(s2.History);
Assert.Equal("set.create", h.Target); Assert.Equal("1", h.Ref); Assert.Equal("set:Unterset", h.NewValue);
}
[Fact]
public void ApplyPatch_SetCreate_Text_TopLevel_IsUnnumberedFreeText()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.create", Ref = "1", Value = JObject.Parse("{'typ':'text','title':'Hinweis','parent':''}") });
string id = LineOrder(s2!).Last();
var co = LineOf(s2!, id);
Assert.Equal("Text", (string)co["typ"]!);
Assert.Equal("<p>Hinweis</p>", (string)co["t"]!);
Assert.Equal("", (string)co["p"]!); // free text carries no position number
Assert.Equal(0, co["lvl"]!.Value<int>());
Assert.Equal("text", (string)ItemOf(s2!, id)["type"]!);
Assert.Equal("<p>Hinweis</p>", (string)ItemOf(s2!, id)["desc"]!);
}
[Theory]
[InlineData("1", "{'typ':'material','title':'Neu','parent':''}")] // unsupported type
[InlineData("1", "{'typ':'set','title':' ','parent':''}")] // blank title
[InlineData("1", "{'typ':'set','title':'Neu','parent':'m3'}")] // parent is not a set
[InlineData("99", "{'typ':'set','title':'Neu','parent':''}")] // unknown block
[InlineData("1", "'not an object'")] // wrong value shape
public void ApplyPatch_SetCreate_Invalid_IsNoOp(string blockRef, string valueJson)
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.create", Ref = blockRef, Value = JToken.Parse(valueJson) });
Assert.Equal(0, s2!.Version);
Assert.Equal(6, LineOrder(s2).Length);
Assert.Empty(s2.History);
}
[Fact]
public void ApplyPatch_ItemSetPrice_NestedSet_SumsTransitiveDescendants_NullsAll_TotalUnchanged()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "A" });
var a = LineOf(s2!, "A");
Assert.Equal(125m, a["vt"]!.Value<decimal>()); // 60 + 40 + 25 (B itself contributed 0 — unconverted)
Assert.Equal(23.75m, a["vv"]!.Value<decimal>());
Assert.Equal(10m, a["vs"]!.Value<decimal>());
foreach (var id in new[] { "B", "m1", "m2", "m3" })
Assert.Equal(JTokenType.Null, LineOf(s2!, id)["vt"]!.Type);
Assert.Equal(10m, LineOf(s2!, "x")["vt"]!.Value<decimal>()); // standalone untouched
Assert.Equal(135m, s2!.Sums.TotalNet);
Assert.Equal(125m, ItemOf(s2, "A")["total_net"]!.Value<decimal>());
Assert.Equal(JTokenType.Null, ItemOf(s2, "B")["total_net"]!.Type);
}
[Fact]
public void ApplyPatch_ItemSetPrice_InnerThenOuter_NoDoubleCounting()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "B" });
Assert.Equal(100m, LineOf(svc.Get(s.Token)!, "B")["vt"]!.Value<decimal>());
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "A" });
Assert.Equal(125m, LineOf(s2!, "A")["vt"]!.Value<decimal>()); // B's set price (100) + m3 (25)
Assert.Equal(JTokenType.Null, LineOf(s2!, "B")["vt"]!.Type);
Assert.Equal(135m, s2!.Sums.TotalNet);
Assert.Equal(2, s2.Version);
}
[Fact]
public void ApplyPatch_SetCreateThenAssignThenSetPrice_UserDefinedSetGetsMembersSum()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.create", Ref = "1", Value = JObject.Parse("{'typ':'set','title':'Extra','parent':''}") });
string newId = LineOrder(svc.Get(s.Token)!).Last();
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.assign", Ref = "x", Value = JObject.Parse("{'parent':'" + newId + "'}") });
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = newId });
Assert.Equal(3, s2!.Version);
Assert.Equal(10m, LineOf(s2, newId)["vt"]!.Value<decimal>());
Assert.Equal(JTokenType.Null, LineOf(s2, "x")["vt"]!.Type);
Assert.Equal(135m, s2.Sums.TotalNet);
Assert.Equal(new[] { "A", "B", "m1", "m2", "m3", newId, "x" }, LineOrder(s2));
}
[Fact]
public void BuildState_NestedConvertedSet_SetDisplayBlanksWholeSubtree_AndLinesCarryLevels()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "A" });
var state = JObject.FromObject(svc.BuildState(s2!));
var disp = (JObject)state["setDisplay"]!;
Assert.True(disp["A"]!["p"]!.Value<bool>()); Assert.True(disp["A"]!["h"]!.Value<bool>());
Assert.False(disp["B"]!["p"]!.Value<bool>()); Assert.True(disp["B"]!["h"]!.Value<bool>()); // nested header inside a converted set: blank, still emphasised
Assert.False(disp["m1"]!["p"]!.Value<bool>());
Assert.False(disp["m3"]!["p"]!.Value<bool>());
Assert.True(disp["x"]!["p"]!.Value<bool>());
Assert.Equal(2, state["req"]![0]!["itm"]![2]!["lvl"]!.Value<int>());
}
[Fact]
public void ApplyPatch_BlockReplace_KeepsUserDefinedStructure_FromPostedParentLinks()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
// The editor re-posts the block after a local edit, carrying SetItmId per row (rrw -> co).
var nb = JObject.Parse(@"{'Id':'1','text':'Auftrag','itm':[
{'id':'A','typ':'set','vt':0,'vat':'19%','SetItmId':'A'},
{'id':'x','typ':'material','vt':10,'vv':1.9,'vat':'19%','SetItmId':'A'}],
'items':[{'id':'A','type':'set'},{'id':'x','type':'material','total_net':10}]}");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "1", Value = nb });
Assert.Equal(1, LineOf(s2!, "x")["lvl"]!.Value<int>());
Assert.Equal("A", (string)ItemOf(s2!, "x")["setId"]!);
Assert.Equal(10m, s2!.Sums.TotalNet);
}
[Theory]
[InlineData("B", "1", "0", "1", new[] { "A", "m3", "B", "m1", "m2", "x" })] // down among A's members, subtree travels
[InlineData("x", "-1", "1", "0", new[] { "x", "A", "B", "m1", "m2", "m3" })] // up at top level
[InlineData("m2", "up", "1", "0", new[] { "A", "B", "m2", "m1", "m3", "x" })] // word form
public void ApplyPatch_SetMove_Sibling_ReordersAndRecordsIndices(string item, string value, string oldIdx, string newIdx, string[] order)
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.move", Ref = item, Value = JToken.Parse(value.StartsWith("-") || char.IsDigit(value[0]) ? value : "'" + value + "'") });
Assert.Equal(1, s2!.Version);
Assert.Equal(order, LineOrder(s2));
var h = Assert.Single(s2.History);
Assert.Equal("set.move", h.Target); Assert.Equal(oldIdx, h.OldValue); Assert.Equal(newIdx, h.NewValue);
Assert.Equal(135m, s2.Sums.TotalNet);
}
[Fact]
public void ApplyPatch_SetMove_Anchor_DragBetweenRows_AdoptsAnchorsParent()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.move", Ref = "x", Value = JObject.Parse("{'anchor':'m1','where':'before'}") });
Assert.Equal(new[] { "A", "B", "x", "m1", "m2", "m3" }, LineOrder(s2!));
Assert.Equal(2, LineOf(s2!, "x")["lvl"]!.Value<int>());
Assert.Equal("B", (string)LineOf(s2!, "x")["SetItmId"]!);
Assert.Equal("", s2!.History[0].OldValue);
Assert.StartsWith("before:m1", s2.History[0].NewValue);
Assert.Equal(3, LineOf(s2, "x")["p"]!.Value<int>());
}
[Theory]
[InlineData("A", "-1")] // first line, no previous sibling
[InlineData("A", "{'anchor':'m1','where':'after'}")] // anchor inside own subtree
[InlineData("x", "{'anchor':'m1','where':'sideways'}")] // bad direction
[InlineData("x", "{'anchor':'','where':'before'}")] // missing anchor
[InlineData("nope", "1")]
public void ApplyPatch_SetMove_Invalid_IsNoOp(string item, string valueJson)
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var before = LineOrder(s);
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.move", Ref = item, Value = JToken.Parse(valueJson) });
Assert.Equal(0, s2!.Version);
Assert.Equal(before, LineOrder(s2));
Assert.Empty(s2.History);
}
}
+135
View File
@@ -201,4 +201,139 @@ public class InvoiceSetPricingTests
.Sum(i => decimal.Parse(i["total_net"]!.ToString()!, System.Globalization.CultureInfo.InvariantCulture));
Assert.Equal(memberSum, setLine.TotalNet);
}
// ── Nested sets (ADR 0016): order preserved, levels reported, nearest converted ancestor rules ──
private static Dictionary<string, object?> NestedHeader(string id, string parent, string title, string total) => new()
{
["type"] = "set", ["id"] = id, ["setId"] = parent, ["title"] = title, ["qty"] = "1", ["total_net"] = total, ["price_net"] = total
};
// A(converted 125) > B(unconverted) > m1, m2 ; A > m3 ; x standalone
private static List<Dictionary<string, object?>> NestedOuterConverted() => new()
{
SetHeader("A", "Komplett", "125.00"),
NestedHeader("B", "A", "Unterset", "0"),
Member("B", "m1", "60.00", "60.00"),
Member("B", "m2", "40.00", "40.00"),
Member("A", "m3", "25.00", "25.00"),
Standalone("x", "10.00", "10.00")
};
[Fact]
public void Build_Nested_OuterConverted_SetPrice_BlanksWholeSubtree_ReportsLevels()
{
var lines = InvoiceSetPricing.Build(NestedOuterConverted(), SetDisplayMode.SetPrice);
Assert.Equal(new[] { "Komplett", "Unterset", "m1", "m2", "m3", "x" }, lines.Select(l => l.Title)); // input order kept
Assert.Equal(new[] { 0, 1, 2, 2, 1, 0 }, lines.Select(l => l.Level));
Assert.True(lines[0].IsSetHeader); Assert.True(lines[0].ShowPrice); Assert.Equal(125m, lines[0].TotalNet);
Assert.True(lines[1].IsSetHeader); Assert.False(lines[1].ShowPrice); // nested header inside a converted set
Assert.False(lines[2].ShowPrice); Assert.False(lines[3].ShowPrice); Assert.False(lines[4].ShowPrice);
Assert.True(lines[5].ShowPrice); Assert.Equal(10m, lines[5].TotalNet);
}
[Fact]
public void Build_Nested_OuterConverted_SetOnly_DropsWholeSubtree_KeepsStandalone()
{
var lines = InvoiceSetPricing.Build(NestedOuterConverted(), SetDisplayMode.SetOnly);
Assert.Equal(new[] { "Komplett", "x" }, lines.Select(l => l.Title));
Assert.True(lines[0].ShowPrice); Assert.True(lines[1].ShowPrice);
}
// A(unconverted) > B(converted 100) > m1, m2 ; A > m3(25)
private static List<Dictionary<string, object?>> NestedInnerConverted() => new()
{
SetHeader("A", "Komplett"),
NestedHeader("B", "A", "Unterset", "100.00"),
Member("B", "m1", "60.00", "60.00"),
Member("B", "m2", "40.00", "40.00"),
Member("A", "m3", "25.00", "25.00")
};
[Theory]
[InlineData(SetDisplayMode.SetPrice, 5)]
[InlineData(SetDisplayMode.SetOnly, 3)]
public void Build_Nested_InnerConvertedOnly_InnerSubtreeCollapses_OuterStaysIndividuallyPriced(SetDisplayMode mode, int count)
{
var lines = InvoiceSetPricing.Build(NestedInnerConverted(), mode);
Assert.Equal(count, lines.Count);
var a = lines.Single(l => l.Title == "Komplett");
Assert.True(a.IsSetHeader); Assert.False(a.ShowPrice); // outer still unconverted -> blank
var b = lines.Single(l => l.Title == "Unterset");
Assert.True(b.IsSetHeader); Assert.True(b.ShowPrice); Assert.Equal(100m, b.TotalNet); Assert.Equal(1, b.Level);
var m3 = lines.Single(l => l.Title == "m3");
Assert.True(m3.ShowPrice); Assert.Equal(25m, m3.TotalNet); Assert.Equal(1, m3.Level);
if (mode == SetDisplayMode.SetPrice) Assert.All(lines.Where(l => l.Title is "m1" or "m2"), l => Assert.False(l.ShowPrice));
else Assert.DoesNotContain(lines, l => l.Title is "m1" or "m2");
}
[Fact]
public void Build_PreservesInputOrder_EvenWhenStandaloneSitsBetweenHeaderAndMember()
{
var items = new List<Dictionary<string, object?>>
{
SetHeader("10", "Set", "1000.00"),
Standalone("Anfahrt", "50.00", "50.00"),
Member("10", "Waschbecken", "600.00", "600.00")
};
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice);
Assert.Equal(new[] { "Set", "Anfahrt", "Waschbecken" }, lines.Select(l => l.Title)); // editor == PDF sequence (ADR 0008)
Assert.True(lines[1].ShowPrice);
Assert.False(lines[2].ShowPrice);
}
[Fact]
public void Build_HeaderSelfReferencingSetId_IsTopLevel_NotItsOwnMember()
{
var header = SetHeader("7", "Set", "100.00"); header["setId"] = "7"; // the shape fds__prepInvoice delivers
var lines = InvoiceSetPricing.Build(new List<Dictionary<string, object?>> { header, Member("7", "A", "100.00", "100.00") }, SetDisplayMode.SetOnly);
Assert.Single(lines);
Assert.Equal(0, lines[0].Level);
Assert.True(lines[0].ShowPrice);
}
[Fact]
public void Build_MemberPointingAtUnknownSet_IsTreatedAsStandalone()
{
var lines = InvoiceSetPricing.Build(new List<Dictionary<string, object?>> { Member("ghost", "A", "5.00", "5.00") }, SetDisplayMode.SetOnly);
var l = Assert.Single(lines);
Assert.True(l.ShowPrice); Assert.Equal(0, l.Level);
}
[Fact]
public void AssignPositionLabels_SetWithMembersPlusStandalone_IsHierarchical()
{
// Set(10) header + 2 members, then a standalone item — mirrors Sample().
var lines = InvoiceSetPricing.Build(Sample(), SetDisplayMode.SetPrice);
InvoiceSetPricing.AssignPositionLabels(new[] { lines });
Assert.Equal(new[] { "1", "1.1", "1.2", "2" }, lines.Select(l => l.PositionLabel));
}
[Fact]
public void AssignPositionLabels_TextLineGetsNoLabel_AndDoesNotShiftFollowingNumbers()
{
var items = new List<Dictionary<string, object?>>
{
Standalone("A", "1.00", "1.00"),
new() { ["type"] = "text", ["title"] = "", ["desc"] = "Hinweis", ["qty"] = "" },
Standalone("B", "2.00", "2.00")
};
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice);
InvoiceSetPricing.AssignPositionLabels(new[] { lines });
Assert.Equal(new[] { "1", "", "2" }, lines.Select(l => l.PositionLabel));
}
[Fact]
public void AssignPositionLabels_SpansMultipleBlocks_Continuously()
{
var block1 = InvoiceSetPricing.Build(new List<Dictionary<string, object?>> { Standalone("A", "1.00", "1.00") }, SetDisplayMode.SetPrice);
var block2 = InvoiceSetPricing.Build(Sample(), SetDisplayMode.SetPrice);
InvoiceSetPricing.AssignPositionLabels(new[] { block1, block2 });
Assert.Equal(new[] { "1" }, block1.Select(l => l.PositionLabel));
Assert.Equal(new[] { "2", "2.1", "2.2", "3" }, block2.Select(l => l.PositionLabel));
}
}
+386
View File
@@ -0,0 +1,386 @@
using System.Collections.Generic;
using System.Linq;
using Fuchs.intranet;
using Newtonsoft.Json.Linq;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Exercises the pure set-structure logic (ADR 0016): parent links (<c>SetItmId</c>) on a block's
/// <c>itm</c> lines form a forest; levels, depth-first linearisation, assign / level / create
/// mutations and the mirror onto the <c>items</c> contract shape are all verified without a
/// service or database. Ids: A/B are sets, m* are members, x is standalone.
/// </summary>
public class InvoiceSetStructureTests
{
// A(set) > B(set) > m1, m2 ; A > m3 ; x standalone — delivered already linearised.
private static JObject NestedBlock() => JObject.Parse(@"{
'Id':'1','text':'Auftrag',
'itm':[
{'id':'A','typ':'set','vt':0,'vat':'19%','SetItmId':'A'},
{'id':'B','typ':'set','vt':0,'vat':'19%','SetItmId':'A'},
{'id':'m1','typ':'material','vt':60,'vat':'19%','SetItmId':'B'},
{'id':'m2','typ':'material','vt':40,'vat':'19%','SetItmId':'B'},
{'id':'m3','typ':'material','vt':25,'vat':'19%','SetItmId':'A'},
{'id':'x','typ':'material','vt':10,'vat':'19%','SetItmId':null}],
'items':[
{'id':'A','type':'set'},{'id':'B','type':'set'},
{'id':'m1','type':'material','total_net':60},{'id':'m2','type':'material','total_net':40},
{'id':'m3','type':'material','total_net':25},{'id':'x','type':'material','total_net':10}]
}");
private static string[] Order(JObject block) => InvoiceSetStructure.Lines(block).Select(InvoiceSetStructure.Id).ToArray();
private static string[] ItemOrder(JObject block) => ((JArray)block["items"]!).OfType<JObject>().Select(i => (string)i["id"]!).ToArray();
private static int Lvl(JObject block, string id) => InvoiceSetStructure.Lines(block).Single(l => InvoiceSetStructure.Id(l) == id)[InvoiceSetStructure.LevelKey]!.Value<int>();
private static JObject Item(JObject block, string id) => ((JArray)block["items"]!).OfType<JObject>().Single(i => (string)i["id"]! == id);
private static string? Parent(JObject block, string id)
{
var lines = InvoiceSetStructure.Lines(block);
return InvoiceSetStructure.ParentOf(lines.Single(l => InvoiceSetStructure.Id(l) == id), InvoiceSetStructure.ById(lines));
}
// ── Levels / parent normalisation ─────────────────────────────────────────
[Fact]
public void Levels_NestedForest_ReportsDepthPerLine()
{
var levels = InvoiceSetStructure.Levels(InvoiceSetStructure.Lines(NestedBlock()));
Assert.Equal(0, levels["A"]);
Assert.Equal(1, levels["B"]);
Assert.Equal(2, levels["m1"]);
Assert.Equal(2, levels["m2"]);
Assert.Equal(1, levels["m3"]);
Assert.Equal(0, levels["x"]);
}
[Theory]
[InlineData("A")] // self-reference (the shape fds__prepInvoice delivers for a header)
[InlineData("nope")] // unknown id
[InlineData("m3")] // points at a non-set line
[InlineData("")]
public void ParentOf_SelfUnknownNonSetOrEmpty_IsTopLevel(string parent)
{
var block = NestedBlock();
InvoiceSetStructure.Lines(block).Single(l => InvoiceSetStructure.Id(l) == "A")["SetItmId"] = parent;
Assert.Null(Parent(block, "A"));
Assert.Equal(0, InvoiceSetStructure.Levels(InvoiceSetStructure.Lines(block))["A"]);
}
[Fact]
public void Levels_CyclicChain_DegradesToTopLevel_AndLinearizeKeepsEveryLine()
{
var block = JObject.Parse(@"{'Id':'1','itm':[
{'id':'A','typ':'set','SetItmId':'B'},{'id':'B','typ':'set','SetItmId':'A'},{'id':'m','typ':'material','SetItmId':'A'}]}");
var levels = InvoiceSetStructure.Levels(InvoiceSetStructure.Lines(block));
Assert.Equal(0, levels["A"]);
Assert.Equal(0, levels["B"]);
InvoiceSetStructure.Linearize(block);
Assert.Equal(3, Order(block).Length); // nothing lost
Assert.Contains("m", Order(block));
}
[Fact]
public void Descendants_AreTransitive_ExcludeTheSetItself_AndUnrelatedLines()
{
var ids = InvoiceSetStructure.Descendants(InvoiceSetStructure.Lines(NestedBlock()), "A").Select(InvoiceSetStructure.Id).ToArray();
Assert.Equal(new[] { "B", "m3", "m1", "m2" }, ids); // breadth-first: direct members first, then B's
Assert.DoesNotContain("A", ids);
Assert.DoesNotContain("x", ids);
Assert.Equal(new[] { "m1", "m2" }, InvoiceSetStructure.Descendants(InvoiceSetStructure.Lines(NestedBlock()), "B").Select(InvoiceSetStructure.Id));
Assert.Empty(InvoiceSetStructure.Descendants(InvoiceSetStructure.Lines(NestedBlock()), "x"));
}
// ── Linearize / RecomputeLevels ───────────────────────────────────────────
[Fact]
public void Linearize_ScrambledInput_EmitsEachSetFollowedByItsMembersRecursively_AndMirrorsItems()
{
var block = JObject.Parse(@"{'Id':'1',
'itm':[{'id':'m1','typ':'material','SetItmId':'B'},{'id':'x','typ':'material'},{'id':'B','typ':'set','SetItmId':'A'},
{'id':'m3','typ':'material','SetItmId':'A'},{'id':'A','typ':'set'},{'id':'m2','typ':'material','SetItmId':'B'}],
'items':[{'id':'m2'},{'id':'osum0','type':'osum'},{'id':'A'},{'id':'x'},{'id':'B'},{'id':'m1'},{'id':'m3'}]}");
InvoiceSetStructure.Linearize(block);
Assert.Equal(new[] { "x", "A", "B", "m1", "m2", "m3" }, Order(block)); // roots keep their relative order (x came before A)
Assert.Equal(new[] { "x", "A", "B", "m1", "m2", "m3", "osum0" }, ItemOrder(block)); // items mirrored; unmatched entries kept at the end
Assert.Equal(0, Lvl(block, "A")); Assert.Equal(1, Lvl(block, "B")); Assert.Equal(2, Lvl(block, "m2")); Assert.Equal(1, Lvl(block, "m3"));
Assert.Equal("A", (string)Item(block, "B")["setId"]!);
Assert.Equal("B", (string)Item(block, "m1")["setId"]!);
Assert.Equal(JTokenType.Null, Item(block, "A")["setId"]!.Type);
Assert.Equal(2, Item(block, "m1")["level"]!.Value<int>());
Assert.Null(Item(block, "osum0")["level"]); // untouched
}
[Fact]
public void RecomputeLevels_DoesNotReorder_ButPublishesLevelsAndMirrorsSetId()
{
var block = JObject.Parse(@"{'Id':'1',
'itm':[{'id':'m1','typ':'material','SetItmId':'A'},{'id':'A','typ':'set','SetItmId':'A'}],
'items':[{'id':'m1','setId':'stale'},{'id':'A','setId':'A'}]}");
var s = new InvoiceDraftSession { Req = new JArray { block } };
InvoiceSetStructure.RecomputeLevels(s);
Assert.Equal(new[] { "m1", "A" }, Order(block)); // order untouched
Assert.Equal(1, Lvl(block, "m1"));
Assert.Equal("A", (string)Item(block, "m1")["setId"]!); // stale mirror corrected
Assert.Equal(JTokenType.Null, Item(block, "A")["setId"]!.Type); // self-reference mirrored as "no parent"
}
// ── Assign ────────────────────────────────────────────────────────────────
[Fact]
public void Assign_StandaloneOntoSet_BecomesLastMember()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.Assign(block, "x", "A", out string oldParent));
Assert.Equal("", oldParent);
Assert.Equal(new[] { "A", "B", "m1", "m2", "m3", "x" }, Order(block));
Assert.Equal(1, Lvl(block, "x"));
Assert.Equal("A", (string)Item(block, "x")["setId"]!);
}
[Fact]
public void Assign_SetOntoSet_KeepsItsOwnMembersOneLevelDeeper()
{
var block = JObject.Parse(@"{'Id':'1','itm':[
{'id':'A','typ':'set'},{'id':'m3','typ':'material','SetItmId':'A'},
{'id':'B','typ':'set'},{'id':'m1','typ':'material','SetItmId':'B'},{'id':'m2','typ':'material','SetItmId':'B'}]}");
Assert.True(InvoiceSetStructure.Assign(block, "B", "A", out _));
Assert.Equal(new[] { "A", "m3", "B", "m1", "m2" }, Order(block));
Assert.Equal(1, Lvl(block, "B"));
Assert.Equal(2, Lvl(block, "m1"));
Assert.Equal("B", Parent(block, "m1")); // still B's member
}
[Fact]
public void Assign_PullOutToTopLevel_PlacesLineAfterItsFormerSubtree_MembersFollow()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.Assign(block, "B", "", out string oldParent));
Assert.Equal("A", oldParent);
Assert.Equal(new[] { "A", "m3", "B", "m1", "m2", "x" }, Order(block));
Assert.Equal(0, Lvl(block, "B"));
Assert.Equal(1, Lvl(block, "m1"));
Assert.Equal(JTokenType.Null, Item(block, "B")["setId"]!.Type);
}
[Fact]
public void Assign_DeepMemberToTopLevel_LandsAfterTheWholeRootSubtree()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.Assign(block, "m1", "", out _));
Assert.Equal(new[] { "A", "B", "m2", "m3", "m1", "x" }, Order(block));
Assert.Equal(0, Lvl(block, "m1"));
}
[Theory]
[InlineData("x", "")] // already top level
[InlineData("m1", "B")] // unchanged parent
[InlineData("A", "A")] // onto itself
[InlineData("A", "B")] // onto its own descendant (cycle)
[InlineData("x", "m3")] // onto a non-set line
[InlineData("x", "nope")] // unknown target
[InlineData("nope", "A")] // unknown item
public void Assign_InvalidOrUnchanged_IsNoOp(string item, string parent)
{
var block = NestedBlock();
var before = Order(block);
Assert.False(InvoiceSetStructure.Assign(block, item, parent, out _));
Assert.Equal(before, Order(block));
}
// ── ChangeLevel ───────────────────────────────────────────────────────────
[Fact]
public void ChangeLevel_Indent_JoinsNearestPrecedingSiblingSetAsLastMember()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.ChangeLevel(block, "m3", +1, out int oldLevel, out int newLevel));
Assert.Equal(1, oldLevel); Assert.Equal(2, newLevel);
Assert.Equal(new[] { "A", "B", "m1", "m2", "m3", "x" }, Order(block));
Assert.Equal("B", Parent(block, "m3"));
}
[Fact]
public void ChangeLevel_Outdent_JoinsGrandparentRightAfterFormerParentSubtree()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.ChangeLevel(block, "m1", -1, out int oldLevel, out int newLevel));
Assert.Equal(2, oldLevel); Assert.Equal(1, newLevel);
Assert.Equal(new[] { "A", "B", "m2", "m1", "m3", "x" }, Order(block));
Assert.Equal("A", Parent(block, "m1"));
}
[Fact]
public void ChangeLevel_OutdentSet_TakesItsMembersAlong()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.ChangeLevel(block, "B", -1, out _, out int newLevel));
Assert.Equal(0, newLevel);
Assert.Equal(new[] { "A", "m3", "B", "m1", "m2", "x" }, Order(block));
Assert.Equal(1, Lvl(block, "m2"));
}
[Fact]
public void ChangeLevel_IndentTopLevelLineAfterASet_JoinsThatSet()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.ChangeLevel(block, "x", +1, out int oldLevel, out int newLevel));
Assert.Equal(0, oldLevel); Assert.Equal(1, newLevel);
Assert.Equal(new[] { "A", "B", "m1", "m2", "m3", "x" }, Order(block));
Assert.Equal("A", Parent(block, "x"));
}
[Theory]
[InlineData("A", +1)] // first line — nothing precedes it
[InlineData("m1", +1)] // first member of B — no preceding sibling set inside B
[InlineData("x", -1)] // already top level
[InlineData("m1", 0)] // no direction
[InlineData("nope", -1)] // unknown
public void ChangeLevel_NoMovePossible_IsNoOp(string item, int delta)
{
var block = NestedBlock();
var before = Order(block);
Assert.False(InvoiceSetStructure.ChangeLevel(block, item, delta, out _, out _));
Assert.Equal(before, Order(block));
}
// ── Create ────────────────────────────────────────────────────────────────
[Fact]
public void Create_Set_InsideParent_AppendsAsLastMember_WritesBothShapes()
{
var block = NestedBlock();
string? id = InvoiceSetStructure.Create(block, "set", " Unterset ", "B");
Assert.NotNull(id);
Assert.StartsWith("sN", id);
Assert.Equal(new[] { "A", "B", "m1", "m2", id!, "m3", "x" }, Order(block));
var co = InvoiceSetStructure.Lines(block).Single(l => InvoiceSetStructure.Id(l) == id);
Assert.Equal("set", (string)co["typ"]!);
Assert.Equal("Unterset", (string)co["t"]!);
Assert.Equal(0m, co["vt"]!.Value<decimal>());
Assert.Equal("19%", (string)co["vat"]!); // adopts the block's rate
Assert.Equal(2, Lvl(block, id!));
var io = Item(block, id!);
Assert.Equal("set", (string)io["type"]!);
Assert.Equal("Unterset", (string)io["title"]!);
Assert.Equal("B", (string)io["setId"]!);
Assert.Equal(0m, io["total_net"]!.Value<decimal>());
}
[Fact]
public void Create_Text_TopLevel_AppendsAtBlockEnd_HtmlEncodesTitle()
{
var block = NestedBlock();
string? id = InvoiceSetStructure.Create(block, "TEXT", "Hinweis <b>fett</b>", "");
Assert.NotNull(id);
Assert.Equal(id, Order(block).Last());
var co = InvoiceSetStructure.Lines(block).Last();
Assert.Equal("Text", (string)co["typ"]!);
Assert.Equal("<p>Hinweis &lt;b&gt;fett&lt;/b&gt;</p>", (string)co["t"]!);
Assert.Equal(0, Lvl(block, id!));
Assert.Equal("text", (string)Item(block, id!)["type"]!);
Assert.Equal("<p>Hinweis &lt;b&gt;fett&lt;/b&gt;</p>", (string)Item(block, id!)["desc"]!);
}
[Fact]
public void Create_WhenBlockHasNoLinesOrItems_CreatesBothArrays_WithFallbackRate()
{
var block = JObject.Parse(@"{'Id':'9'}");
string? id = InvoiceSetStructure.Create(block, "set", "Neu", "");
Assert.NotNull(id);
Assert.Single((JArray)block["itm"]!);
Assert.Single((JArray)block["items"]!);
Assert.Equal("19%", (string)((JArray)block["itm"]!)[0]["vat"]!);
}
[Theory]
[InlineData("material", "Neu", "")] // unsupported type
[InlineData("set", " ", "")] // blank title
[InlineData("set", "Neu", "m3")] // parent is not a set
[InlineData("set", "Neu", "nope")] // unknown parent
public void Create_Invalid_ReturnsNullAndLeavesBlockUntouched(string typ, string title, string parent)
{
var block = NestedBlock();
var before = Order(block);
Assert.Null(InvoiceSetStructure.Create(block, typ, title, parent));
Assert.Equal(before, Order(block));
Assert.Equal(6, ((JArray)block["items"]!).Count);
}
// ── Place / MoveSibling (reorder) ─────────────────────────────────────────
[Fact]
public void Place_BeforeAnchor_BecomesAnchorsSibling_SubtreeTravels()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.Place(block, "B", "x", after: false, out string oldParent, out string newParent));
Assert.Equal("A", oldParent); Assert.Equal("", newParent);
Assert.Equal(new[] { "A", "m3", "B", "m1", "m2", "x" }, Order(block));
Assert.Equal(0, Lvl(block, "B")); Assert.Equal(1, Lvl(block, "m1"));
}
[Fact]
public void Place_AfterAnchor_LandsAfterAnchorsWholeSubtree()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.Place(block, "x", "B", after: true, out _, out string newParent));
Assert.Equal("A", newParent); // x is now B's sibling inside A
Assert.Equal(new[] { "A", "B", "m1", "m2", "x", "m3" }, Order(block));
Assert.Equal(1, Lvl(block, "x"));
}
[Fact]
public void Place_MemberBeforeItsOwnParent_MovesUpOneLevel()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.Place(block, "m1", "B", after: false, out _, out string newParent));
Assert.Equal("A", newParent);
Assert.Equal(new[] { "A", "m1", "B", "m2", "m3", "x" }, Order(block));
Assert.Equal(1, Lvl(block, "m1"));
}
[Theory]
[InlineData("A", "m1", true)] // anchor inside own subtree
[InlineData("A", "A", false)] // itself
[InlineData("nope", "x", true)] // unknown item
[InlineData("x", "nope", false)] // unknown anchor
public void Place_Invalid_IsNoOp(string item, string anchor, bool after)
{
var block = NestedBlock();
var before = Order(block);
Assert.False(InvoiceSetStructure.Place(block, item, anchor, after, out _, out _));
Assert.Equal(before, Order(block));
}
[Fact]
public void MoveSibling_Down_SwapsWithNextSibling_SubtreeTravels()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.MoveSibling(block, "B", +1, out int oldIndex, out int newIndex));
Assert.Equal(0, oldIndex); Assert.Equal(1, newIndex);
Assert.Equal(new[] { "A", "m3", "B", "m1", "m2", "x" }, Order(block));
Assert.Equal(1, Lvl(block, "B")); Assert.Equal(2, Lvl(block, "m1")); // still inside A
}
[Fact]
public void MoveSibling_Up_TopLevel_MovesWholeSetAboveStandalone()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.MoveSibling(block, "x", -1, out int oldIndex, out int newIndex));
Assert.Equal(1, oldIndex); Assert.Equal(0, newIndex);
Assert.Equal(new[] { "x", "A", "B", "m1", "m2", "m3" }, Order(block));
}
[Theory]
[InlineData("A", -1)] // first top-level line
[InlineData("x", +1)] // last top-level line
[InlineData("m1", -1)] // first member of B
[InlineData("m2", +1)] // last member of B (m3 belongs to A, not B)
[InlineData("m1", 0)]
[InlineData("nope", 1)]
public void MoveSibling_AtEdge_IsNoOp(string item, int delta)
{
var block = NestedBlock();
var before = Order(block);
Assert.False(InvoiceSetStructure.MoveSibling(block, item, delta, out _, out _));
Assert.Equal(before, Order(block));
}
}