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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ PrinterAPP/

- **Service-oriented with dependency injection** via `MauiProgram.cs`. Every service registered as `AddSingleton<IFoo, Foo>()`.
- **MVVM with code-behind**: standard MAUI pattern. View binds to code-behind directly; pure ViewModel layer is intentionally absent at this scale.
- **Intelligent printer routing**: orders dispatch to Cashier, Front Kitchen, or Back Kitchen printers based on item category.
- **Intelligent printer routing**: orders dispatch to Cashier, Front Kitchen, or Back Kitchen printers based on each line's `KitchenType`. Since backend PR #237 made `OrderDto.Items` **root-only**, a bundle's components live only in `OrderItem.SideItems`, nested to arbitrary depth — so routing walks the whole tree (`KitchenTicketFilter`, a pure function like `FeedWatchdogDecision`), never just the top level. A component whose kitchen differs from its parent's goes to *its own* kitchen's ticket, which carries the parent line as context. **Anything that reasons about "the order's items" must recurse** — a top-level-only scan silently prints no ticket at all for that kitchen.
- **5-second SSE polling** with a 1-hour deduplication window so re-emitted orders during reconnects don't double-print. The poll cursor and dedup set are **persisted** (`IFeedCursorStore`), so a restart resumes instead of re-printing the last 30 minutes. A dedup entry is persisted only once the print path confirms the order (`ConfirmOrderHandled`) — a kill between dispatch and print must re-drive the order, never silently suppress it. Three timings are coupled and must stay ordered — `unconfirmed retention (25 min) < cursor look-back clamp (30 min) < dedup window (1 h)`. The clamp must stay under the dedup window or a re-fetch reaches orders no surviving dedup entry guards (reprints); unconfirmed retention must stay under the clamp or an unconfirmed order's cursor floor is discarded by the very load it exists to influence (silently dropped ticket). An unconfirmed order that ages out is reported to the Errors page — past that point it genuinely cannot be recovered.
- **Feed watchdog** (`IFeedWatchdog`): restarts a feed that died or stopped completing polls. It must never override a deliberate stop — see `FeedWatchdogDecision`, where all of that logic lives as a pure, tested function.
- **Headless order pipeline** (`IOrderPipeline`): the feed→print→history→ack path is owned by a service, not a page, so it runs with no Activity. On Android an `OrderFeedForegroundService` hosts it (see [ADR-007](docs/adr/ADR-007-android-foreground-service.md)); on Windows it runs in-process. **Never move print or feed logic back into a page** — that is what made the app stop printing whenever it was minimised.
Expand All @@ -86,6 +86,7 @@ PrinterAPP/

The printer-app is a **strict consumer** of the backend API. Models in `PrinterAPP/Models/` MUST mirror backend DTO field names and types exactly — silent drift breaks order printing in production.

- **`OrderDto.Items` is ROOT-ONLY** (backend PR #237 / issue #234, merged 2026-07-27). Bundle components and add-on sides are NOT top-level entries — they hang off their parent in `OrderItemDto.SideItems`, to arbitrary depth, each with its own `KitchenType`. No field changed, so drift here does not fail deserialization; it fails silently at the till.
- Auth: `X-Api-Key` header on the printer-feed endpoint (since printer-app !1 / backend MR !20). Legacy `Authorization: Bearer <jwt>` flow is removed.
- Backend base URL is configured per-deployment in `PrinterConfiguration.cs` and persisted in the user's local config JSON.

Expand Down
228 changes: 228 additions & 0 deletions PrinterAPP.Tests/KitchenTicketFilterTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
using PrinterAPP.Models;
using PrinterAPP.Services;
using Xunit;

namespace PrinterAPP.Tests;

/// <summary>
/// Kitchen routing over the nested item tree (backend PR #237 / issue #234 made OrderDto.Items
/// ROOT-ONLY — a bundle's components now exist only inside their parent's SideItems).
/// <para>The regression these guard: a "Menu Deal" whose product is FrontKitchen containing
/// BackKitchen fries. Routing off the top level alone saw no BackKitchen item, so the back kitchen
/// got NO ticket and the fries printed on the front kitchen's instead — a silently dropped order
/// line in a live restaurant.</para>
/// </summary>
public class KitchenTicketFilterTests
{
private const string Front = "FrontKitchen";
private const string Back = "BackKitchen";

// ── single-kitchen bundle ───────────────────────────────────────────────────────────────────

[Fact]
public void SingleKitchenBundle_GoesToThatKitchenOnly_ComponentsNested()
{
var items = new List<OrderItem>
{
Item("Menu Deal", Front, children:
[
Item("Kofte", Front),
Item("Pide", Front),
]),
};

var front = KitchenTicketFilter.ItemsForKitchen(items, Front);
var back = KitchenTicketFilter.ItemsForKitchen(items, Back);

// One ticket, one root line, components still nested under their combo.
var combo = Assert.Single(front);
Assert.Equal("Menu Deal", combo.ProductName);
Assert.Equal(new[] { "Kofte", "Pide" }, combo.SideItems!.Select(s => s.ProductName));

// Each component exactly once across the whole ticket — not also promoted to a root line.
Assert.Equal(new[] { "Menu Deal", "Kofte", "Pide" }, Flatten(front).Select(i => i.ProductName));

// The kitchen with nothing to make gets no ticket at all.
Assert.Empty(back);
}

[Fact]
public void SingleKitchenBundle_KeepsComponentsWithNoKitchenOfTheirOwn()
{
// A drink or an extra sauce carries no KitchenType: it is a modifier of its parent, so it
// rides with the parent rather than vanishing from every ticket.
var items = new List<OrderItem>
{
Item("Menu Deal", Front, children:
[
Item("Kofte", Front),
Item("Coke", kitchenType: null),
Item("Extra sauce", kitchenType: "None"),
]),
};

var front = KitchenTicketFilter.ItemsForKitchen(items, Front);

var combo = Assert.Single(front);
Assert.Equal(
new[] { "Kofte", "Coke", "Extra sauce" },
combo.SideItems!.Select(s => s.ProductName));

// They ride along as work this kitchen does, not as context — the ticket prints them as
// real lines. "No KitchenType" is what makes this underivable from the item alone.
Assert.All(Flatten(front), item => Assert.False(item.IsContextOnly));
}

// ── mixed-kitchen bundle (the reported failure) ─────────────────────────────────────────────

[Fact]
public void MixedKitchenBundle_BothKitchensGetATicket_EachComponentOnExactlyOne()
{
var items = new List<OrderItem>
{
Item("Menu Deal", Front, children:
[
Item("Kofte", Front),
Item("Fries", Back, quantity: 2),
Item("Coke", kitchenType: null),
]),
};

var front = KitchenTicketFilter.ItemsForKitchen(items, Front);
var back = KitchenTicketFilter.ItemsForKitchen(items, Back);

// Front makes the combo itself, so it keeps the combo and everything that is its own —
// and the back kitchen's fries no longer ride along on its ticket.
var frontCombo = Assert.Single(front);
Assert.Equal("Menu Deal", frontCombo.ProductName);
Assert.Equal(new[] { "Kofte", "Coke" }, frontCombo.SideItems!.Select(s => s.ProductName));
Assert.All(Flatten(front), item => Assert.False(item.IsContextOnly));

// Back gets a ticket at all (this is what was missing), carrying the combo line only as
// context for the one component it makes.
var backCombo = Assert.Single(back);
Assert.Equal("Menu Deal", backCombo.ProductName);
Assert.True(backCombo.IsContextOnly, "the back kitchen does not make the combo itself");
var fries = Assert.Single(backCombo.SideItems!);
Assert.Equal("Fries", fries.ProductName);
Assert.Equal(2, fries.Quantity);
Assert.False(fries.IsContextOnly);

// Every component appears exactly once across the two tickets combined.
var printed = Flatten(front).Concat(Flatten(back)).Select(i => i.ProductName).ToList();
Assert.Equal(new[] { "Kofte", "Coke", "Fries" }, printed.Where(n => n != "Menu Deal"));
}

[Fact]
public void MixedKitchenBundle_DoesNotMutateTheOrderItRoutes()
{
// The same tree is filtered once per kitchen, and the order is shared with the history list
// and the UI — filtering has to copy.
var combo = Item("Menu Deal", Front, children:
[
Item("Kofte", Front),
Item("Fries", Back),
]);
var items = new List<OrderItem> { combo };

KitchenTicketFilter.ItemsForKitchen(items, Front);
var back = KitchenTicketFilter.ItemsForKitchen(items, Back);

Assert.Equal(new[] { "Kofte", "Fries" }, combo.SideItems!.Select(s => s.ProductName));
Assert.Equal(new[] { "Fries" }, Assert.Single(back).SideItems!.Select(s => s.ProductName));
}

// ── depth, and the rest of the tree ─────────────────────────────────────────────────────────

[Fact]
public void NestedGrandchild_ReachesItsOwnKitchen_ThroughTheParentsAboveIt()
{
// The backend builds an arbitrary-depth tree (OrderItemFactory nests grandchildren), so a
// one-level scan is not enough — the whole chain above a component may belong elsewhere.
var items = new List<OrderItem>
{
Item("Feast Menu", Front, children:
[
Item("Burger Combo", Front, children:
[
Item("Patty", Front),
Item("Fries", Back),
]),
]),
};

var back = KitchenTicketFilter.ItemsForKitchen(items, Back);
var front = KitchenTicketFilter.ItemsForKitchen(items, Front);

var backRoot = Assert.Single(back);
Assert.Equal("Feast Menu", backRoot.ProductName);
var backCombo = Assert.Single(backRoot.SideItems!);
Assert.Equal("Burger Combo", backCombo.ProductName);
Assert.Equal("Fries", Assert.Single(backCombo.SideItems!).ProductName);

Assert.Equal(
new[] { "Feast Menu", "Burger Combo", "Patty" },
Flatten(front).Select(i => i.ProductName));
}

[Fact]
public void PlainItems_RouteByTheirOwnKitchen_AsBefore()
{
var items = new List<OrderItem>
{
Item("Adana Kebab", Back),
Item("Ayran", Front),
Item("Napkins", kitchenType: null),
};

Assert.Equal(new[] { "Adana Kebab" }, KitchenTicketFilter.ItemsForKitchen(items, Back).Select(i => i.ProductName));
Assert.Equal(new[] { "Ayran" }, KitchenTicketFilter.ItemsForKitchen(items, Front).Select(i => i.ProductName));
}

[Fact]
public void OrderWithNothingForThisKitchen_YieldsNoTicket()
{
var items = new List<OrderItem> { Item("Ayran", Front) };

Assert.Empty(KitchenTicketFilter.ItemsForKitchen(items, Back));
Assert.Empty(KitchenTicketFilter.ItemsForKitchen(null, Back));
Assert.Empty(KitchenTicketFilter.ItemsForKitchen(new List<OrderItem>(), Back));
}

[Fact]
public void KitchenTypeMatchIsCaseInsensitive()
{
var items = new List<OrderItem> { Item("Fries", "backkitchen") };

Assert.Equal("Fries", Assert.Single(KitchenTicketFilter.ItemsForKitchen(items, Back)).ProductName);
}

// ── helpers ─────────────────────────────────────────────────────────────────────────────────

private static OrderItem Item(
string productName,
string? kitchenType,
int quantity = 1,
List<OrderItem>? children = null) =>
new()
{
Id = productName,
ProductName = productName,
KitchenType = kitchenType,
Quantity = quantity,
SideItems = children,
};

/// <summary>Every line a ticket would print, parents before their children.</summary>
private static IEnumerable<OrderItem> Flatten(IEnumerable<OrderItem> items)
{
foreach (var item in items)
{
yield return item;
foreach (var child in Flatten(item.SideItems ?? Enumerable.Empty<OrderItem>()))
{
yield return child;
}
}
}
}
Loading