Add user-defined invoice set structure dialog (ADR 0016)
Playwright Tests / test (push) Canceled after 0s
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <b>fett</b></p>", (string)co["t"]!);
|
||||
Assert.Equal(0, Lvl(block, id!));
|
||||
Assert.Equal("text", (string)Item(block, id!)["type"]!);
|
||||
Assert.Equal("<p>Hinweis <b>fett</b></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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user