diff --git a/AGENTS.md b/AGENTS.md index a894fe0..33de5fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -169,8 +169,11 @@ production. - Adding a menu section touches `MainMenu.ActionKeys`, the keyboard rows, the menu test and the router mapping. - Charts are hand-drawn (`RgbCanvas` + 5x7 bitmap font + PNG over `ZLibStream`) to avoid native - dependencies; unknown glyphs render blank, so names stay in the caption, and the text screens - always carry the exact numbers. + dependencies; the font folds accents (`á` renders as `a`) and leaves what it does not know + blank, so the icon and the exact name stay in the caption, where the phone's font draws them. + A horizontal bar is a share of the `total` it is given, never a fraction of the longest bar: + the percentage in the row is what the length shows, and two rows are comparable. The text + screens always carry the exact numbers. **Settings, budgets and routing** @@ -211,8 +214,9 @@ the closing of the last month sent at 23:59 on the user's last local day, with t day as fallback; verified nightly backups; reproducible deploy. Settings erases everything and switches the daily reminder; a budget set once recurs every month with per-month overrides; the summary lists remaining budget and drills -into each category's movements. **828 tests green**, build with zero warnings, `dotnet format` -clean. +into each category's movements. The category chart measures each bar as its share of the month +and draws the category name; the breakdown has a way back. **835 tests green**, build with zero +warnings, `dotnet format` clean. Next: CSV export of a date range's expenses, then seed categories and recurring-rule editing. The backlog and decisions are in [`docs/HANDOFF.md`](docs/HANDOFF.md). diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 86e7cd4..010797c 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -4,7 +4,7 @@ The working context is [`AGENTS.md`](../AGENTS.md); the architecture is [`TECHNICAL-DESIGN.md`](TECHNICAL-DESIGN.md). This file is only the next task's prompt and the decisions already taken for what comes after. -## Done in this round (all verified, 828 tests green) +## Done in this round (all verified, 835 tests green) **Scheduled-notification foundation** @@ -54,11 +54,20 @@ decisions already taken for what comes after. **Statistics chart (category)** -- The category chart now numbers each bar and labels it `$gastado/$presupuesto uso%`, and the - caption carries a numbered legend with the category names (`Statistics.ChartCategoryLegend*`). - It reads `GetMonthlySummaryAsync` for the per-category budget; the daily chart is unchanged. -- `BitmapFont` gained `$` and `/` glyphs so the money and the separator are legible; category - names still stay in the caption because accented letters are not in the font. +- Each row of the category chart now carries the number, the category name and + `$gasto ($share %)` on one line, with the bar underneath: the bar is that share of the month's + total (`SpendingChartRenderer.HorizontalBars(entries, total)`), so 3 % is 3 % of the row + instead of a fraction of the longest bar, which is what made a "3 %" bar look halfway. The + caption keeps a numbered legend with the icon and the budget + (`Statistics.ChartCategoryLegend*`), read from `GetMonthlySummaryAsync`; the daily chart is + unchanged. +- `BitmapFont` gained `A-Z`, `a-z` and the punctuation a name needs, and folds accents before + drawing (`á` → `a`) because a 5x7 glyph cannot hold a diacritic. `BitmapFont.Truncate` cuts a + name that does not fit and marks the cut with `..`. Icons and other scripts still leave a + blank, which is why the exact name stays in the caption too. +- The breakdown chooser ("📂 ¿Qué categoría quieres revisar?") ends in an "↩️ Volver" row that + hands off to the summary (`CategoryDetailConversation.BackToSummaryCallback`), so the flow has + an exit besides the persistent menu. It is present even when the month has no movements. **Daily reminder at 21:00 local** diff --git a/src/MyBudget.Telegram/Charts/BitmapFont.cs b/src/MyBudget.Telegram/Charts/BitmapFont.cs index ba81c99..ebd7ad9 100644 --- a/src/MyBudget.Telegram/Charts/BitmapFont.cs +++ b/src/MyBudget.Telegram/Charts/BitmapFont.cs @@ -1,10 +1,16 @@ +using System.Globalization; +using System.Text; + namespace MyBudget.Telegram.Charts; /// -/// A 5x7 bitmap font for the only text a chart draws: digits, separators and a few symbols. +/// A 5x7 bitmap font for the text a chart draws: digits, money symbols and the letters a +/// category name needs. /// -/// Category and month names stay in the caption, where Telegram renders them with the phone's -/// own font and full accents. That is what keeps the image dependency-free: no TTF to embed, +/// Accents are folded rather than drawn, so "Alimentación" is rendered as "Alimentacion" inside +/// the five-by-seven grid. Anything the font still does not know (an icon, another script) leaves +/// a blank, and the exact name keeps its icon and accents in the caption, where Telegram renders +/// it with the phone's own font. That is what keeps the image dependency-free: no TTF to embed, /// no fontconfig in the container, and identical pixels in every environment. /// /// @@ -15,8 +21,36 @@ internal static class BitmapFont private const int Advance = GlyphWidth + 1; + /// Appended when a string does not fit, so a cut name cannot read as a full one. + private const string CutMark = ".."; + public static int Measure(string text, int scale) => - string.IsNullOrEmpty(text) ? 0 : ((text.Length * Advance) - 1) * scale; + string.IsNullOrEmpty(text) ? 0 : ((Normalized(text).Length * Advance) - 1) * scale; + + /// + /// Cuts a string down to the widest it can be in pixels. Every + /// character advances the same number of pixels, so the fit is a division rather than a + /// measurement loop. + /// + public static string Truncate(string text, int maxWidth, int scale) + { + var normalized = string.IsNullOrEmpty(text) ? string.Empty : Normalized(text); + + if (Measure(normalized, scale) <= maxWidth) + { + return normalized; + } + + var room = Math.Max(0, (((maxWidth / scale) + 1) / Advance) - CutMark.Length); + + // Never cut between the halves of a surrogate pair: the result is normalized again. + if (room > 0 && char.IsHighSurrogate(normalized[room - 1])) + { + room--; + } + + return room == 0 ? string.Empty : normalized[..room] + CutMark; + } public static void Draw(RgbCanvas canvas, string text, int x, int y, int scale, Rgb colour) { @@ -25,7 +59,7 @@ public static void Draw(RgbCanvas canvas, string text, int x, int y, int scale, var cursor = x; - foreach (var character in text) + foreach (var character in Normalized(text)) { if (Glyph(character) is { } rows) { @@ -46,6 +80,31 @@ public static void Draw(RgbCanvas canvas, string text, int x, int y, int scale, } } + /// + /// Removes the combining marks a five-by-seven glyph cannot hold: á decomposes into + /// a plus a acute, and the acute is what gets dropped. + /// + private static string Normalized(string text) + { + if (string.IsNullOrEmpty(text)) + { + return string.Empty; + } + + var decomposed = text.Normalize(NormalizationForm.FormD); + var builder = new StringBuilder(decomposed.Length); + + foreach (var character in decomposed) + { + if (CharUnicodeInfo.GetUnicodeCategory(character) != UnicodeCategory.NonSpacingMark) + { + builder.Append(character); + } + } + + return builder.ToString().Normalize(NormalizationForm.FormC); + } + private static byte[]? Glyph(char character) => character switch { '0' => Rows("01110", "10001", "10011", "10101", "11001", "10001", "01110"), @@ -61,10 +120,72 @@ public static void Draw(RgbCanvas canvas, string text, int x, int y, int scale, '.' => Rows("00000", "00000", "00000", "00000", "00000", "00110", "00110"), ',' => Rows("00000", "00000", "00000", "00000", "00110", "00110", "00100"), '%' => Rows("11001", "11010", "00010", "00100", "01000", "01011", "10011"), - '$' => Rows("00100", "01111", "10100", "01110", "00101", "11110", "00100"), + '$' => Rows("00100", "01111", "10100", "00110", "00001", "11110", "00100"), '/' => Rows("00001", "00010", "00010", "00100", "01000", "01000", "10000"), '-' => Rows("00000", "00000", "00000", "01110", "00000", "00000", "00000"), + ':' => Rows("00000", "00110", "00110", "00000", "00110", "00110", "00000"), + '(' => Rows("00010", "00100", "01000", "01000", "01000", "00100", "00010"), + ')' => Rows("01000", "00100", "00010", "00010", "00010", "00100", "01000"), + '+' => Rows("00000", "00100", "00100", "11111", "00100", "00100", "00000"), + '=' => Rows("00000", "00000", "11111", "00000", "11111", "00000", "00000"), + '?' => Rows("01110", "10001", "00001", "00110", "00100", "00000", "00100"), + '!' => Rows("00100", "00100", "00100", "00100", "00100", "00000", "00100"), + '\'' => Rows("00100", "00100", "01000", "00000", "00000", "00000", "00000"), + '&' => Rows("01100", "10010", "10010", "01100", "10101", "10010", "01101"), + '·' => Rows("00000", "00000", "00000", "00100", "00000", "00000", "00000"), ' ' => Rows("00000", "00000", "00000", "00000", "00000", "00000", "00000"), + 'A' => Rows("01110", "10001", "10001", "11111", "10001", "10001", "10001"), + 'B' => Rows("11110", "10001", "10001", "11110", "10001", "10001", "11110"), + 'C' => Rows("01110", "10001", "10000", "10000", "10000", "10001", "01110"), + 'D' => Rows("11110", "10001", "10001", "10001", "10001", "10001", "11110"), + 'E' => Rows("11111", "10000", "10000", "11110", "10000", "10000", "11111"), + 'F' => Rows("11111", "10000", "10000", "11110", "10000", "10000", "10000"), + 'G' => Rows("01110", "10001", "10000", "10111", "10001", "10001", "01111"), + 'H' => Rows("10001", "10001", "10001", "11111", "10001", "10001", "10001"), + 'I' => Rows("11111", "00100", "00100", "00100", "00100", "00100", "11111"), + 'J' => Rows("00111", "00010", "00010", "00010", "00010", "10010", "01100"), + 'K' => Rows("10001", "10010", "10100", "11000", "10100", "10010", "10001"), + 'L' => Rows("10000", "10000", "10000", "10000", "10000", "10000", "11111"), + 'M' => Rows("10001", "11011", "10101", "10101", "10001", "10001", "10001"), + 'N' => Rows("10001", "10001", "11001", "10101", "10011", "10001", "10001"), + 'O' => Rows("01110", "10001", "10001", "10001", "10001", "10001", "01110"), + 'P' => Rows("11110", "10001", "10001", "11110", "10000", "10000", "10000"), + 'Q' => Rows("01110", "10001", "10001", "10001", "10101", "10010", "01101"), + 'R' => Rows("11110", "10001", "10001", "11110", "10100", "10010", "10001"), + 'S' => Rows("01111", "10000", "10000", "01110", "00001", "00001", "11110"), + 'T' => Rows("11111", "00100", "00100", "00100", "00100", "00100", "00100"), + 'U' => Rows("10001", "10001", "10001", "10001", "10001", "10001", "01110"), + 'V' => Rows("10001", "10001", "10001", "10001", "10001", "01010", "00100"), + 'W' => Rows("10001", "10001", "10001", "10101", "10101", "10101", "01010"), + 'X' => Rows("10001", "10001", "01010", "00100", "01010", "10001", "10001"), + 'Y' => Rows("10001", "10001", "01010", "00100", "00100", "00100", "00100"), + 'Z' => Rows("11111", "00001", "00010", "00100", "01000", "10000", "11111"), + 'a' => Rows("00000", "00000", "01110", "00001", "01111", "10001", "01111"), + 'b' => Rows("10000", "10000", "11110", "10001", "10001", "10001", "11110"), + 'c' => Rows("00000", "00000", "01111", "10000", "10000", "10000", "01111"), + 'd' => Rows("00001", "00001", "01111", "10001", "10001", "10001", "01111"), + 'e' => Rows("00000", "00000", "01110", "10001", "11111", "10000", "01110"), + 'f' => Rows("00110", "01001", "01000", "11110", "01000", "01000", "01000"), + 'g' => Rows("00000", "00000", "01110", "10001", "10001", "01111", "11111"), + 'h' => Rows("10000", "10000", "11110", "10001", "10001", "10001", "10001"), + 'i' => Rows("00100", "00000", "01100", "00100", "00100", "00100", "01110"), + 'j' => Rows("00010", "00000", "00110", "00010", "00010", "10010", "01100"), + 'k' => Rows("10000", "10000", "10010", "10100", "11000", "10100", "10010"), + 'l' => Rows("01100", "00100", "00100", "00100", "00100", "00100", "01110"), + 'm' => Rows("00000", "00000", "11110", "10101", "10101", "10101", "10101"), + 'n' => Rows("00000", "00000", "11110", "10001", "10001", "10001", "10001"), + 'o' => Rows("00000", "00000", "01110", "10001", "10001", "10001", "01110"), + 'p' => Rows("00000", "00000", "11110", "10001", "10001", "11110", "10000"), + 'q' => Rows("00000", "00000", "01110", "10001", "10001", "01111", "00001"), + 'r' => Rows("00000", "00000", "10110", "11001", "10000", "10000", "10000"), + 's' => Rows("00000", "00000", "01111", "10000", "01110", "00001", "11110"), + 't' => Rows("01000", "01000", "11110", "01000", "01000", "01001", "00110"), + 'u' => Rows("00000", "00000", "10001", "10001", "10001", "10011", "01101"), + 'v' => Rows("00000", "00000", "10001", "10001", "10001", "01010", "00100"), + 'w' => Rows("00000", "00000", "10001", "10101", "10101", "10101", "01010"), + 'x' => Rows("00000", "00000", "10001", "01010", "00100", "01010", "10001"), + 'y' => Rows("00000", "00000", "10001", "10001", "01010", "00100", "00100"), + 'z' => Rows("00000", "00000", "11111", "00010", "00100", "01000", "11111"), _ => null, }; diff --git a/src/MyBudget.Telegram/Charts/SpendingChartRenderer.cs b/src/MyBudget.Telegram/Charts/SpendingChartRenderer.cs index a8195e5..c4437d1 100644 --- a/src/MyBudget.Telegram/Charts/SpendingChartRenderer.cs +++ b/src/MyBudget.Telegram/Charts/SpendingChartRenderer.cs @@ -1,15 +1,19 @@ namespace MyBudget.Telegram.Charts; -/// One drawn value. The label is optional and drawn with the bitmap font. -internal sealed record ChartEntry(long Value, string? Label = null); +/// +/// One drawn value. Name sits at the left of the row's header line, Label at its +/// right edge, and the bar fills the row underneath. +/// +internal sealed record ChartEntry(long Value, string? Label = null, string? Name = null); /// /// Turns report numbers into a PNG. /// -/// Two shapes cover the product: horizontal bars for spending per category (the label is the -/// amount, at the end of each bar) and vertical bars for spending per day (the label is the -/// day number). Category names, month names and totals stay in the caption: the image is the -/// visual comparison, the message is the exact data. +/// Two shapes cover the product: horizontal bars for spending per category and vertical bars for +/// spending per day. The horizontal bar is measured against the total it is given, never +/// against the longest bar, so a row's length is literally that category's share of the month and +/// two rows are comparable; the exact numbers travel in the row itself and in the caption, where +/// the phone's font draws the icon and the accents. /// /// internal static class SpendingChartRenderer @@ -21,10 +25,22 @@ internal static class SpendingChartRenderer internal const int Margin = 28; internal const int BarHeight = 26; - internal const int RowHeight = 44; + + /// Text on a horizontal row is drawn at twice the base glyph size. + internal const int LabelScale = 2; + + internal const int NameLineHeight = BitmapFont.GlyphHeight * LabelScale; + internal const int NameGap = 4; + + /// Where the bar starts inside its row: under the name and the numbers. + internal const int BarOffsetInRow = NameLineHeight + NameGap; + + internal const int RowGap = 16; + internal const int RowHeight = BarOffsetInRow + BarHeight + RowGap; internal const int VerticalAreaHeight = 180; private const int VerticalLabelHeight = 18; private const int LabelGap = 16; + private const int MinimumBarWidth = 3; private static readonly Rgb[] Palette = [ @@ -38,13 +54,16 @@ internal static class SpendingChartRenderer new(138, 138, 78), ]; - public static byte[] HorizontalBars(IReadOnlyList entries) => - PngEncoder.Encode(DrawHorizontalBars(entries)); + /// + /// What the bars are a share of: 100 % fills a row, so a 3 % category is 3 % of the row. + /// + public static byte[] HorizontalBars(IReadOnlyList entries, long total) => + PngEncoder.Encode(DrawHorizontalBars(entries, total)); public static byte[] VerticalBars(IReadOnlyList entries) => PngEncoder.Encode(DrawVerticalBars(entries)); - internal static RgbCanvas DrawHorizontalBars(IReadOnlyList entries) + internal static RgbCanvas DrawHorizontalBars(IReadOnlyList entries, long total) { ArgumentNullException.ThrowIfNull(entries); @@ -57,32 +76,40 @@ internal static RgbCanvas DrawHorizontalBars(IReadOnlyList entries) return canvas; } - const int scale = 2; - var maximum = bars.Max(entry => entry.Value); + // One width for every row: a bar is a share of the whole row, so rows stay comparable + // no matter how long their labels are. + var available = Width - (Margin * 2); for (var index = 0; index < bars.Count; index++) { var entry = bars[index]; - var label = entry.Label ?? string.Empty; - var labelWidth = BitmapFont.Measure(label, scale); + var rowTop = Margin + (index * RowHeight); - // The label owns the right edge; the bar grows in what is left of the row. - var available = Math.Max(0, Width - (Margin * 2) - labelWidth - LabelGap); - var barWidth = maximum > 0 ? (int)((decimal)entry.Value / maximum * available) : 0; - if (entry.Value > 0 && barWidth < 3) + var numbers = entry.Label ?? string.Empty; + var numbersWidth = BitmapFont.Measure(numbers, LabelScale); + var name = BitmapFont.Truncate( + entry.Name ?? string.Empty, available - numbersWidth - LabelGap, LabelScale); + + // The name and the exact numbers share the header line, name at the left edge, + // numbers at the right edge. + BitmapFont.Draw(canvas, name, Margin, rowTop, LabelScale, Rgb.Ink); + BitmapFont.Draw( + canvas, numbers, Width - Margin - numbersWidth, rowTop, LabelScale, Rgb.Ink); + + var barWidth = total > 0 ? (int)((decimal)entry.Value / total * available) : 0; + barWidth = Math.Clamp(barWidth, 0, available); + + if (total > 0 && entry.Value > 0 && barWidth < MinimumBarWidth) { - barWidth = 3; + barWidth = MinimumBarWidth; } - var y = Margin + (index * RowHeight); - canvas.Rectangle(Margin, y, barWidth, BarHeight, Palette[index % Palette.Length]); - BitmapFont.Draw( - canvas, - label, - Width - Margin - labelWidth, - y + ((BarHeight - (BitmapFont.GlyphHeight * scale)) / 2), - scale, - Rgb.Ink); + canvas.Rectangle( + Margin, + rowTop + BarOffsetInRow, + barWidth, + BarHeight, + Palette[index % Palette.Length]); } return canvas; diff --git a/src/MyBudget.Telegram/Conversations/CategoryDetailConversation.cs b/src/MyBudget.Telegram/Conversations/CategoryDetailConversation.cs index 44e6141..92e30ea 100644 --- a/src/MyBudget.Telegram/Conversations/CategoryDetailConversation.cs +++ b/src/MyBudget.Telegram/Conversations/CategoryDetailConversation.cs @@ -32,6 +32,9 @@ internal sealed class CategoryDetailConversation( private const string ExpensePrefix = CallbackPrefix + "exp:"; private const string BackCallback = CallbackPrefix + "back"; + /// Leaves the breakdown for the summary it was opened from. + internal const string BackToSummaryCallback = CallbackPrefix + "summary"; + private const string ChooseState = "choose"; private const string MovementsState = "movements"; private const int PageSize = 20; @@ -51,6 +54,15 @@ public async Task HandleCallbackAsync( { var period = CurrentPeriod(context); + if (callback.Data == BackToSummaryCallback) + { + // The breakdown is a side screen of the summary; going back resumes it. + return new ConversationTurn([]) + { + HandoffConversation = SummaryConversation.ConversationName, + }; + } + if (callback.Data.StartsWith(CategoryPrefix, StringComparison.Ordinal) && Guid.TryParse(callback.Data[CategoryPrefix.Length..], out var categoryId)) { @@ -93,6 +105,9 @@ private async Task BuildChooserAsync( ? messages.Get(language, MessageKeys.BreakdownEmpty) : messages.Get(language, MessageKeys.BreakdownChoose); + // Without this row the only way out of the breakdown is the persistent menu. + rows.Add([new BotButton(messages.Get(language, MessageKeys.ButtonBack), BackToSummaryCallback)]); + return new ConversationTurn([BotResponse.Message(text, BotKeyboard.Inline([.. rows]))]) { NextState = ChooseState, diff --git a/src/MyBudget.Telegram/Conversations/StatisticsConversation.cs b/src/MyBudget.Telegram/Conversations/StatisticsConversation.cs index d36476c..1461248 100644 --- a/src/MyBudget.Telegram/Conversations/StatisticsConversation.cs +++ b/src/MyBudget.Telegram/Conversations/StatisticsConversation.cs @@ -137,8 +137,10 @@ private async Task BuildChartAsync( } /// - /// The category bars are numbered and the amount and usage stay on the bar; the category - /// names, which the bitmap font cannot draw, go in a numbered legend under the chart. + /// Each row carries the category's name and the amount with its share of the month, and the + /// bar below fills the row in proportion to that share, so the number and the length always + /// agree. The budget detail stays in the numbered legend under the chart, where the phone's + /// font draws the icon and the accents. /// private async Task<(byte[] Png, string Caption)> BuildCategoryChartAsync( ConversationContext context, @@ -149,6 +151,7 @@ private async Task BuildChartAsync( var currency = context.User.Currency; var summary = await reports.GetMonthlySummaryAsync( context.User.Id, period, cancellationToken); + var total = summary.TotalSpent; var lines = summary.Lines .Where(line => line.Spent > 0) @@ -165,19 +168,24 @@ private async Task BuildChartAsync( var line = lines[index]; var number = index + 1; var spent = moneyFormatter.Format(line.Spent, currency); + var share = total > 0 ? (line.Spent * 100m) / total : 0m; + + entries.Add(new ChartEntry( + line.Spent, + $"{spent} ({moneyFormatter.FormatPercentage(share)})", + $"{number} {line.CategoryName}")); + var label = $"{line.Icon} {line.CategoryName}"; if (line.HasBudget) { var budget = moneyFormatter.Format(line.Budget, currency); var usage = moneyFormatter.FormatPercentage(line.UsagePercentage ?? 0m); - entries.Add(new ChartEntry(line.Spent, $"{number} {spent}/{budget} {usage}")); legend.Add(messages.Get( language, MessageKeys.StatisticsChartCategoryLegend, number, label, spent, budget, usage)); } else { - entries.Add(new ChartEntry(line.Spent, $"{number} {spent}")); legend.Add(messages.Get( language, MessageKeys.StatisticsChartCategoryLegendNoBudget, number, label, spent)); } @@ -191,7 +199,7 @@ private async Task BuildChartAsync( caption += "\n\n" + string.Join("\n", legend); } - return (SpendingChartRenderer.HorizontalBars(entries), caption); + return (SpendingChartRenderer.HorizontalBars(entries, total), caption); } /// diff --git a/tests/MyBudget.Telegram.Tests/CategoryDetailConversationTests.cs b/tests/MyBudget.Telegram.Tests/CategoryDetailConversationTests.cs index 8efaaf2..d297977 100644 --- a/tests/MyBudget.Telegram.Tests/CategoryDetailConversationTests.cs +++ b/tests/MyBudget.Telegram.Tests/CategoryDetailConversationTests.cs @@ -103,4 +103,44 @@ public async Task Opening_a_movement_hands_it_to_the_expenses_flow() harness.Conversations.SnapshotOf(harness.User.Id)!.Conversation .Should().Be(ExpensesConversation.ConversationName); } + + [Fact] + public async Task The_chooser_always_offers_a_way_back_to_the_summary() + { + var harness = TelegramHarness.Build(); + harness.ReportService + .GetStatisticsAsync(harness.User.Id, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new PeriodStatistics( + September, 700_000, 2, 30, 0, Shares(), [], [], + new PeriodComparison(September, September.Previous, 700_000, 0, true, false))); + + var turn = await harness.Router.RouteCallbackAsync( + ContextFor(harness, SummaryConversation.ConversationName, "summary"), + new IncomingCallback("cb", CategoryDetailConversation.OpenCallback), + CancellationToken.None); + + var back = turn.Responses[0].Keyboard!.Rows[^1].Single(); + + back.CallbackData.Should().Be(CategoryDetailConversation.BackToSummaryCallback); + back.Text.Should().Be(harness.Messages.Get("es", MessageKeys.ButtonBack)); + } + + [Fact] + public async Task Going_back_from_the_chooser_resumes_the_summary() + { + var harness = TelegramHarness.Build(); + harness.ReportService + .GetMonthlySummaryAsync(harness.User.Id, Arg.Any(), Arg.Any()) + .Returns(new MonthlySummary(September, [])); + + var turn = await harness.Router.RouteCallbackAsync( + ContextFor(harness, CategoryDetailConversation.ConversationName, "choose"), + new IncomingCallback("cb", CategoryDetailConversation.BackToSummaryCallback), + CancellationToken.None); + + harness.Conversations.SnapshotOf(harness.User.Id)!.Conversation + .Should().Be(SummaryConversation.ConversationName); + turn.Responses[0].Text.Should().StartWith( + harness.Messages.Get("es", MessageKeys.SummaryHeader, "septiembre 2026")); + } } diff --git a/tests/MyBudget.Telegram.Tests/ChartRendererTests.cs b/tests/MyBudget.Telegram.Tests/ChartRendererTests.cs index 38d99b0..f3a1528 100644 --- a/tests/MyBudget.Telegram.Tests/ChartRendererTests.cs +++ b/tests/MyBudget.Telegram.Tests/ChartRendererTests.cs @@ -15,7 +15,7 @@ public sealed class ChartRendererTests [Fact] public void A_chart_is_a_png_with_the_canvas_size_in_its_header() { - var png = SpendingChartRenderer.HorizontalBars([new ChartEntry(100, "100")]); + var png = SpendingChartRenderer.HorizontalBars([new ChartEntry(100, "100")], total: 100); png.Should().StartWith(PngSignature); ReadInt(png, 16).Should().Be(SpendingChartRenderer.Width); @@ -39,20 +39,44 @@ public void The_image_data_decompresses_to_the_expected_scanlines() } [Fact] - public void Horizontal_bars_are_proportional_to_their_values() + public void A_bar_is_its_share_of_the_total_so_the_number_and_the_length_agree() { - var canvas = SpendingChartRenderer.DrawHorizontalBars([new ChartEntry(100), new ChartEntry(50)]); + // Three of a hundred must be 3 % of the row: the percentage the user reads is the + // length the user sees, not a fraction of the longest bar. + var canvas = SpendingChartRenderer.DrawHorizontalBars([new ChartEntry(3)], total: 100); - var firstRow = SpendingChartRenderer.Margin + (SpendingChartRenderer.BarHeight / 2); - var secondRow = firstRow + SpendingChartRenderer.RowHeight; + var available = SpendingChartRenderer.Width - (SpendingChartRenderer.Margin * 2); + var bar = ColouredPixelsInRow(canvas, BarRow(index: 0)); - var longest = ColouredPixelsInRow(canvas, firstRow); - var half = ColouredPixelsInRow(canvas, secondRow); + ((double)bar).Should().BeApproximately(available * 0.03, 2.0); + } + + [Fact] + public void Two_bars_keep_their_ratio_against_the_same_total() + { + var canvas = SpendingChartRenderer.DrawHorizontalBars( + [new ChartEntry(100), new ChartEntry(50)], total: 200); + + var longest = ColouredPixelsInRow(canvas, BarRow(index: 0)); + var half = ColouredPixelsInRow(canvas, BarRow(index: 1)); half.Should().BeGreaterThan(0); ((double)half).Should().BeApproximately(longest / 2.0, 1.5); } + [Fact] + public void The_name_and_the_numbers_sit_above_the_bar() + { + var canvas = SpendingChartRenderer.DrawHorizontalBars( + [new ChartEntry(50, "50 %", "1 Comida")], total: 100); + + ColouredPixelsInRow(canvas, SpendingChartRenderer.Margin + 1) + .Should().BeGreaterThan(0, "the header line carries the name and the exact numbers"); + + ColouredPixelsInRow(canvas, SpendingChartRenderer.Margin + SpendingChartRenderer.BarOffsetInRow + 1) + .Should().BeGreaterThan(0, "the bar fills the row under them"); + } + [Fact] public void More_bars_than_the_cap_are_not_drawn() { @@ -60,7 +84,7 @@ public void More_bars_than_the_cap_are_not_drawn() .Select(index => new ChartEntry(index + 1)) .ToList(); - var png = SpendingChartRenderer.HorizontalBars(entries); + var png = SpendingChartRenderer.HorizontalBars(entries, total: 1000); ReadInt(png, 20).Should().Be( (SpendingChartRenderer.Margin * 2) @@ -95,17 +119,52 @@ public void The_font_measures_advances_and_draws_digits() } [Fact] - public void Text_the_font_does_not_know_draws_nothing() + public void A_category_name_is_drawn_letter_by_letter() + { + var canvas = new RgbCanvas(160, 16); + canvas.Fill(Rgb.White); + + BitmapFont.Draw(canvas, "Mercado", 0, 0, 2, Rgb.Ink); + + ColouredPixels(canvas).Should().BeGreaterThan(0); + } + + [Fact] + public void An_accent_is_folded_away_instead_of_dropping_the_word() { - // Category names live in the caption, not the image, so unknown glyphs are blank. + BitmapFont.Measure("holá", scale: 2).Should().Be(BitmapFont.Measure("hola", scale: 2)); + var canvas = new RgbCanvas(64, 16); canvas.Fill(Rgb.White); BitmapFont.Draw(canvas, "holá", 0, 0, 2, Rgb.Ink); + ColouredPixels(canvas).Should().BeGreaterThan(0); + } + + [Fact] + public void Characters_the_font_does_not_know_draw_nothing() + { + // An icon or another script leaves a blank; the exact name stays in the caption. + var canvas = new RgbCanvas(64, 16); + canvas.Fill(Rgb.White); + + BitmapFont.Draw(canvas, "日本語", 0, 0, 2, Rgb.Ink); + ColouredPixels(canvas).Should().Be(0); } + [Fact] + public void A_name_that_does_not_fit_is_cut_with_two_dots() + { + var room = BitmapFont.Measure("abc", scale: 2); + + var cut = BitmapFont.Truncate("una categoría muy larga", room, scale: 2); + + BitmapFont.Measure(cut, scale: 2).Should().BeLessOrEqualTo(room); + cut.Should().EndWith(".."); + } + [Fact] public void The_font_draws_the_money_symbols_used_on_chart_labels() { @@ -117,6 +176,12 @@ public void The_font_draws_the_money_symbols_used_on_chart_labels() ColouredPixels(canvas).Should().BeGreaterThan(0); } + private static int BarRow(int index) => + SpendingChartRenderer.Margin + + (index * SpendingChartRenderer.RowHeight) + + SpendingChartRenderer.BarOffsetInRow + + (SpendingChartRenderer.BarHeight / 2); + private static int ColouredPixelsInRow(RgbCanvas canvas, int y) { var count = 0;