diff --git a/docs/TECHNICAL-DESIGN.md b/docs/TECHNICAL-DESIGN.md index 1dddc2d..f1718cd 100644 --- a/docs/TECHNICAL-DESIGN.md +++ b/docs/TECHNICAL-DESIGN.md @@ -240,6 +240,12 @@ Behaviours settled in Phase 4: - **A tap on the persistent menu aborts the active flow.** The router matches the menu before delegating text to a conversation. Delegating first would swallow a label such as `📊 Resumen` as flow input and, in the category flow, store it as a category name. +- **A screen that only owns buttons lets a compact expense pass through.** A conversation + returns `null` from `HandleTextAsync` when the text is not its input (the summary, the + statistics, the lists, the category breakdown, the settings). The router then drops that + state and parses the message as a compact expense, so a report or a list left on screen can + never swallow the next `35.000 verduras` and re-render itself. Only the states that wait for + a typed value (amount, description, name, date, time zone) own their text. - **A draft that identifies a large value carries the position, not the value.** Telegram caps callback data at 64 bytes, so the alias screen lists remove buttons by index into a list held in the conversation payload and removes the keyword by term afterwards. No user text is diff --git a/docs/specs/gotchas.md b/docs/specs/gotchas.md index fd09cde..cad1693 100644 --- a/docs/specs/gotchas.md +++ b/docs/specs/gotchas.md @@ -39,6 +39,10 @@ whenever a gotcha is learned or disproven. - Callback data is capped at 64 bytes; identify a row by position or term when the id does not fit. A menu tap outranks the active conversation, and some callbacks (Undo) arrive after the flow is gone, handled as global callbacks. +- A conversation returns `null` from `HandleTextAsync` when free text is not its input; the + router then drops its state and parses the message as a compact expense. This is what stops a + report or a list left on screen from swallowing the next entry. Only the states that wait for + a typed value own their text. - A confirmation is claimed once with a conditional `UPDATE`, never re-read; the callback carries only the pending id. An empty listing must still carry the notices it was built with. diff --git a/src/MyBudget.Telegram/Conversations/CategoriesConversation.cs b/src/MyBudget.Telegram/Conversations/CategoriesConversation.cs index ad98cf6..1478342 100644 --- a/src/MyBudget.Telegram/Conversations/CategoriesConversation.cs +++ b/src/MyBudget.Telegram/Conversations/CategoriesConversation.cs @@ -52,11 +52,13 @@ public Task StartAsync( ConversationContext context, CancellationToken cancellationToken) => BuildListAsync(context, cancellationToken, []); - public async Task HandleTextAsync( + public async Task HandleTextAsync( ConversationContext context, IncomingText text, CancellationToken cancellationToken) { var payload = CategoriesPayload.Parse(context.Conversation?.Payload); + // Only the states that wait for a typed value own the text; the list, the detail and the + // budget scope are navigated with buttons, so free text passes through them. return context.CurrentState switch { AwaitingNameState => await HandleNameAsync(context, text.Text, cancellationToken), @@ -70,8 +72,7 @@ public async Task HandleTextAsync( context, payload, text.Text, cancellationToken), AwaitingBudgetAmountState => await HandleBudgetAmountAsync( context, payload, text.Text, cancellationToken), - BudgetScopeState => BuildScopePrompt(context, payload), - _ => await BuildListAsync(context, cancellationToken, []), + _ => null, }; } diff --git a/src/MyBudget.Telegram/Conversations/CategoryDetailConversation.cs b/src/MyBudget.Telegram/Conversations/CategoryDetailConversation.cs index 92e30ea..95a0e75 100644 --- a/src/MyBudget.Telegram/Conversations/CategoryDetailConversation.cs +++ b/src/MyBudget.Telegram/Conversations/CategoryDetailConversation.cs @@ -45,9 +45,10 @@ public Task StartAsync( ConversationContext context, CancellationToken cancellationToken) => BuildChooserAsync(context, cancellationToken); - public Task HandleTextAsync( + // The breakdown only owns its navigation callbacks: free text passes through it. + public Task HandleTextAsync( ConversationContext context, IncomingText text, CancellationToken cancellationToken) => - BuildChooserAsync(context, cancellationToken); + Task.FromResult(null); public async Task HandleCallbackAsync( ConversationContext context, IncomingCallback callback, CancellationToken cancellationToken) diff --git a/src/MyBudget.Telegram/Conversations/ConversationRouter.cs b/src/MyBudget.Telegram/Conversations/ConversationRouter.cs index 5cf857e..f537ddd 100644 --- a/src/MyBudget.Telegram/Conversations/ConversationRouter.cs +++ b/src/MyBudget.Telegram/Conversations/ConversationRouter.cs @@ -56,26 +56,50 @@ private async Task RouteTextCoreAsync( if (ActiveConversation(context) is { } active) { var turn = await active.HandleTextAsync(context, new IncomingText(text), cancellationToken); - await PersistAsync(context, active.Name, turn, cancellationToken); - return turn; + + if (turn is not null) + { + await PersistAsync(context, active.Name, turn, cancellationToken); + return turn; + } + + // A screen that only owns callbacks (a report, a list, the settings) returns null for + // free text: drop its state and fall through so the message is not swallowed. + await store.ClearAsync(context.User.Id, cancellationToken); } // Free text with nothing active is a compact entry: "35.000 verduras" becomes the same // confirmation. Text that carries no amount is not an entry and falls through to help. - var compact = compactParser.Parse(text, context.User.Currency); - if (compact.Outcome != CompactExpenseOutcome.Unparsed - && Find(ExpenseConversation.ConversationName) is IExpenseEntry entry) + if (await TryStartCompactAsync(context, text, cancellationToken) is { } entryTurn) { - await store.ClearAsync(context.User.Id, cancellationToken); - var fresh = context with { Conversation = null }; - var turn = await entry.StartFromCompactAsync(fresh, compact, cancellationToken); - await PersistAsync(fresh, ExpenseConversation.ConversationName, turn, cancellationToken); - return turn; + return entryTurn; } return Finished(HelpTurn(context)); } + /// + /// Starts the expense flow from a free-text compact entry. Returns null when the text + /// carries no amount, so the caller falls through to the active flow or to help. + /// + private async Task TryStartCompactAsync( + ConversationContext context, string text, CancellationToken cancellationToken) + { + var compact = compactParser.Parse(text, context.User.Currency); + + if (compact.Outcome == CompactExpenseOutcome.Unparsed + || Find(ExpenseConversation.ConversationName) is not IExpenseEntry entry) + { + return null; + } + + await store.ClearAsync(context.User.Id, cancellationToken); + var fresh = context with { Conversation = null }; + var turn = await entry.StartFromCompactAsync(fresh, compact, cancellationToken); + await PersistAsync(fresh, ExpenseConversation.ConversationName, turn, cancellationToken); + return turn; + } + public async Task RouteCallbackAsync( ConversationContext context, IncomingCallback callback, CancellationToken cancellationToken) => await ResolveHandoffAsync( diff --git a/src/MyBudget.Telegram/Conversations/ExpenseConversation.cs b/src/MyBudget.Telegram/Conversations/ExpenseConversation.cs index 1095d3f..cf15f3e 100644 --- a/src/MyBudget.Telegram/Conversations/ExpenseConversation.cs +++ b/src/MyBudget.Telegram/Conversations/ExpenseConversation.cs @@ -88,7 +88,7 @@ public async Task StartFromCompactAsync( } } - public async Task HandleTextAsync( + public async Task HandleTextAsync( ConversationContext context, IncomingText text, CancellationToken cancellationToken) { var payload = ExpensePayload.Parse(context.Conversation?.Payload); diff --git a/src/MyBudget.Telegram/Conversations/ExpensesConversation.cs b/src/MyBudget.Telegram/Conversations/ExpensesConversation.cs index 463df33..41092af 100644 --- a/src/MyBudget.Telegram/Conversations/ExpensesConversation.cs +++ b/src/MyBudget.Telegram/Conversations/ExpensesConversation.cs @@ -77,17 +77,18 @@ public Task StartWithAsync( ? BuildDetailAsync(context, expenseId, [], cancellationToken) : StartAsync(context, cancellationToken); - public Task HandleTextAsync( + public async Task HandleTextAsync( ConversationContext context, IncomingText text, CancellationToken cancellationToken) { var payload = ExpensesPayload.Parse(context.Conversation?.Payload); + // Only the edit states wait for typed values; the list and the detail are button-driven. return context.CurrentState switch { - EditAmountState => HandleEditAmountAsync(context, payload, text.Text, cancellationToken), - EditDescriptionState => HandleEditDescriptionAsync(context, payload, text.Text, cancellationToken), - EditDateState => HandleEditDateAsync(context, payload, text.Text, cancellationToken), - _ => BuildListAsync(context, [], cancellationToken), + EditAmountState => await HandleEditAmountAsync(context, payload, text.Text, cancellationToken), + EditDescriptionState => await HandleEditDescriptionAsync(context, payload, text.Text, cancellationToken), + EditDateState => await HandleEditDateAsync(context, payload, text.Text, cancellationToken), + _ => null, }; } diff --git a/src/MyBudget.Telegram/Conversations/IConversation.cs b/src/MyBudget.Telegram/Conversations/IConversation.cs index 5cf50c8..f8a6837 100644 --- a/src/MyBudget.Telegram/Conversations/IConversation.cs +++ b/src/MyBudget.Telegram/Conversations/IConversation.cs @@ -67,7 +67,12 @@ public interface IConversation Task StartAsync(ConversationContext context, CancellationToken cancellationToken); - Task HandleTextAsync( + /// + /// Handles a plain message. Returns null when the text is not this flow's input, so a + /// screen that only owns callbacks never swallows a free-text entry; the router then falls + /// through to the compact-expense parsing. + /// + Task HandleTextAsync( ConversationContext context, IncomingText text, CancellationToken cancellationToken); Task HandleCallbackAsync( @@ -83,3 +88,4 @@ internal interface IHandoffConversation Task StartWithAsync( ConversationContext context, string payload, CancellationToken cancellationToken); } + diff --git a/src/MyBudget.Telegram/Conversations/RecurringConversation.cs b/src/MyBudget.Telegram/Conversations/RecurringConversation.cs index 226dd3f..a64b632 100644 --- a/src/MyBudget.Telegram/Conversations/RecurringConversation.cs +++ b/src/MyBudget.Telegram/Conversations/RecurringConversation.cs @@ -56,17 +56,18 @@ public Task StartAsync( ConversationContext context, CancellationToken cancellationToken) => ListTurnAsync(context, [], cancellationToken); - public async Task HandleTextAsync( + public async Task HandleTextAsync( ConversationContext context, IncomingText text, CancellationToken cancellationToken) { var payload = RecurringPayload.Parse(context.Conversation?.Payload); + // Only the creation states wait for typed values; the list and the detail are button-driven. return context.CurrentState switch { AmountState => HandleAmount(context, payload, text.Text), DescriptionState => await HandleDescriptionAsync(context, payload, text.Text, cancellationToken), DayState => HandleDay(context, payload, text.Text), - _ => await ListTurnAsync(context, [], cancellationToken), + _ => null, }; } diff --git a/src/MyBudget.Telegram/Conversations/SettingsConversation.cs b/src/MyBudget.Telegram/Conversations/SettingsConversation.cs index 855e057..4b519aa 100644 --- a/src/MyBudget.Telegram/Conversations/SettingsConversation.cs +++ b/src/MyBudget.Telegram/Conversations/SettingsConversation.cs @@ -33,9 +33,10 @@ public Task StartAsync( ConversationContext context, CancellationToken cancellationToken) => Task.FromResult(Build(context)); - public Task HandleTextAsync( + // The settings screen only owns its toggle callbacks: free text passes through it. + public Task HandleTextAsync( ConversationContext context, IncomingText text, CancellationToken cancellationToken) => - Task.FromResult(Build(context)); + Task.FromResult(null); public async Task HandleCallbackAsync( ConversationContext context, IncomingCallback callback, CancellationToken cancellationToken) diff --git a/src/MyBudget.Telegram/Conversations/StartConversation.cs b/src/MyBudget.Telegram/Conversations/StartConversation.cs index 66ff47a..ded0bc6 100644 --- a/src/MyBudget.Telegram/Conversations/StartConversation.cs +++ b/src/MyBudget.Telegram/Conversations/StartConversation.cs @@ -43,24 +43,24 @@ public Task StartAsync(ConversationContext context, Cancellati return Task.FromResult(turn); } - public Task HandleTextAsync( + public async Task HandleTextAsync( ConversationContext context, IncomingText text, CancellationToken cancellationToken) { var language = context.Language; if (context.CurrentState != AwaitingCustomTimezone) { - return Task.FromResult(TimeZonePrompt(language, AwaitingTimezone)); + return TimeZonePrompt(language, AwaitingTimezone); } var candidate = text.Text.Trim(); if (TimeZoneInfo.TryFindSystemTimeZoneById(candidate, out _)) { - return ConfirmAsync(context, candidate, cancellationToken); + return await ConfirmAsync(context, candidate, cancellationToken); } - return Task.FromResult(new ConversationTurn( + return new ConversationTurn( [ BotResponse.Message(messages.Get(language, MessageKeys.OnboardingTimezoneCustomInvalid)), BotResponse.Message(messages.Get(language, MessageKeys.OnboardingTimezoneCustomPrompt)), @@ -68,7 +68,7 @@ public Task HandleTextAsync( { NextState = AwaitingCustomTimezone, NextPayload = "{}", - }); + }; } public Task HandleCallbackAsync( diff --git a/src/MyBudget.Telegram/Conversations/StatisticsConversation.cs b/src/MyBudget.Telegram/Conversations/StatisticsConversation.cs index 1461248..2559745 100644 --- a/src/MyBudget.Telegram/Conversations/StatisticsConversation.cs +++ b/src/MyBudget.Telegram/Conversations/StatisticsConversation.cs @@ -39,9 +39,10 @@ public Task StartAsync( ConversationContext context, CancellationToken cancellationToken) => BuildAsync(context, new ReportingPayload(), cancellationToken); - public Task HandleTextAsync( + // The report only owns its navigation callbacks: free text passes through it. + public Task HandleTextAsync( ConversationContext context, IncomingText text, CancellationToken cancellationToken) => - BuildAsync(context, ReportingPayload.Parse(context.Conversation?.Payload), cancellationToken); + Task.FromResult(null); public async Task HandleCallbackAsync( ConversationContext context, IncomingCallback callback, CancellationToken cancellationToken) diff --git a/src/MyBudget.Telegram/Conversations/SummaryConversation.cs b/src/MyBudget.Telegram/Conversations/SummaryConversation.cs index d5d2329..a8759dd 100644 --- a/src/MyBudget.Telegram/Conversations/SummaryConversation.cs +++ b/src/MyBudget.Telegram/Conversations/SummaryConversation.cs @@ -40,9 +40,10 @@ public Task StartAsync( ConversationContext context, CancellationToken cancellationToken) => BuildAsync(context, new ReportingPayload(), cancellationToken); - public Task HandleTextAsync( + // The report only owns its navigation callbacks: free text passes through it. + public Task HandleTextAsync( ConversationContext context, IncomingText text, CancellationToken cancellationToken) => - BuildAsync(context, ReportingPayload.Parse(context.Conversation?.Payload), cancellationToken); + Task.FromResult(null); public Task HandleCallbackAsync( ConversationContext context, IncomingCallback callback, CancellationToken cancellationToken) diff --git a/tests/MyBudget.Telegram.Tests/ConversationRouterTests.cs b/tests/MyBudget.Telegram.Tests/ConversationRouterTests.cs index 653a010..68531f1 100644 --- a/tests/MyBudget.Telegram.Tests/ConversationRouterTests.cs +++ b/tests/MyBudget.Telegram.Tests/ConversationRouterTests.cs @@ -1,9 +1,11 @@ using FluentAssertions; using MyBudget.Application.Abstractions.Telegram; using MyBudget.Application.Localization; +using MyBudget.Domain.Categories; using MyBudget.Telegram.Conversations; using MyBudget.Telegram.Presentation; using MyBudget.Telegram.Tests.Fakes; +using NSubstitute; namespace MyBudget.Telegram.Tests; @@ -123,4 +125,29 @@ public async Task A_settings_tap_opens_the_settings_screen() turn.Responses.Should().ContainSingle() .Which.Text.Should().Contain(harness.Messages.Get("es", MessageKeys.SettingsTitle)); } + + [Theory] + [InlineData(CategoriesConversation.ConversationName, "menu")] + [InlineData(ExpensesConversation.ConversationName, "list")] + [InlineData(RecurringConversation.ConversationName, "list")] + [InlineData(SummaryConversation.ConversationName, "summary")] + [InlineData(StatisticsConversation.ConversationName, "statistics")] + [InlineData(CategoryDetailConversation.ConversationName, "choose")] + [InlineData(SettingsConversation.ConversationName, "settings")] + public async Task A_compact_expense_passes_through_a_screen_that_only_owns_buttons( + string conversation, string state) + { + var harness = TelegramHarness.Build(); + var food = new BudgetCategory(harness.User.Id, "Comida", "🍔"); + harness.CategoryService + .ListAsync(harness.User.Id, false, Arg.Any()) + .Returns(Task.FromResult>([food])); + + var turn = await harness.Router.RouteTextAsync( + ContextFor(harness, state, conversation), "115509 carnes colanta", CancellationToken.None); + + // The screen must not swallow the message: it becomes the expense flow. + turn.NextState.Should().Be("category"); + harness.Conversations.SnapshotOf(harness.User.Id)!.Conversation.Should().Be("expense"); + } } diff --git a/tests/MyBudget.Telegram.Tests/StartConversationTests.cs b/tests/MyBudget.Telegram.Tests/StartConversationTests.cs index 686f3e2..da70893 100644 --- a/tests/MyBudget.Telegram.Tests/StartConversationTests.cs +++ b/tests/MyBudget.Telegram.Tests/StartConversationTests.cs @@ -75,7 +75,7 @@ public async Task A_typed_time_zone_identifier_is_accepted() CancellationToken.None); harness.User.TimeZone.Should().Be("America/Argentina/Buenos_Aires"); - turn.Completed.Should().BeTrue(); + turn!.Completed.Should().BeTrue(); } [Fact] @@ -90,7 +90,7 @@ public async Task A_typed_value_that_is_not_a_time_zone_is_rejected() harness.User.TimeZone.Should().Be("America/Bogota", "the default must survive an invalid answer"); harness.UnitOfWork.SaveCount.Should().Be(0); - turn.NextState.Should().Be("awaiting-custom-timezone"); + turn!.NextState.Should().Be("awaiting-custom-timezone"); turn.Responses.Should().HaveCount(2); turn.Responses[0].Text.Should().Be(harness.Messages.Get("es", MessageKeys.OnboardingTimezoneCustomInvalid)); } diff --git a/tests/MyBudget.Telegram.Tests/StatisticsConversationTests.cs b/tests/MyBudget.Telegram.Tests/StatisticsConversationTests.cs index 0849d8d..1ffca9d 100644 --- a/tests/MyBudget.Telegram.Tests/StatisticsConversationTests.cs +++ b/tests/MyBudget.Telegram.Tests/StatisticsConversationTests.cs @@ -3,6 +3,7 @@ using MyBudget.Application.Localization; using MyBudget.Application.Reporting; using MyBudget.Domain.Budgets; +using MyBudget.Domain.Categories; using MyBudget.Telegram.Conversations; using MyBudget.Telegram.Tests.Fakes; using NSubstitute; @@ -278,4 +279,25 @@ public async Task Asking_for_a_chart_in_an_empty_month_returns_the_text_screen_i turn.Responses[0].Photo.Should().BeNull(); turn.Responses[0].Text.Should().Contain("No hay gastos registrados en este mes."); } + + [Fact] + public async Task A_compact_expense_typed_while_the_statistics_are_on_screen_is_recorded() + { + var harness = TelegramHarness.Build(); + var food = new BudgetCategory(harness.User.Id, "Comida", "🍔"); + harness.CategoryService + .ListAsync(harness.User.Id, false, Arg.Any()) + .Returns(Task.FromResult>([food])); + + var turn = await harness.Router.RouteTextAsync( + ContextFor(harness, September, "statistics"), + "115509 carnes colanta", + CancellationToken.None); + + // The statistics screen does not swallow the message: it becomes the expense flow. + turn.NextState.Should().Be("category"); + harness.Conversations.SnapshotOf(harness.User.Id)!.Conversation.Should().Be("expense"); + await harness.ReportService.DidNotReceive().GetStatisticsAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } } diff --git a/tests/MyBudget.Telegram.Tests/SummaryConversationTests.cs b/tests/MyBudget.Telegram.Tests/SummaryConversationTests.cs index 05f53a6..95033a1 100644 --- a/tests/MyBudget.Telegram.Tests/SummaryConversationTests.cs +++ b/tests/MyBudget.Telegram.Tests/SummaryConversationTests.cs @@ -3,6 +3,7 @@ using MyBudget.Application.Localization; using MyBudget.Application.Reporting; using MyBudget.Domain.Budgets; +using MyBudget.Domain.Categories; using MyBudget.Telegram.Conversations; using MyBudget.Telegram.Tests.Fakes; using NSubstitute; @@ -151,4 +152,38 @@ public async Task The_previous_button_moves_to_the_previous_month() turn.Responses[0].Text.Should().StartWith("📊 Resumen de agosto 2026"); } + + [Fact] + public async Task A_compact_expense_typed_while_the_summary_is_on_screen_is_recorded() + { + var harness = TelegramHarness.Build(); + var food = new BudgetCategory(harness.User.Id, "Comida", "🍔"); + harness.CategoryService + .ListAsync(harness.User.Id, false, Arg.Any()) + .Returns(Task.FromResult>([food])); + + var turn = await harness.Router.RouteTextAsync( + ContextFor(harness, new MonthPeriod(2026, 10), "summary"), + "115509 carnes colanta", + CancellationToken.None); + + // The summary does not swallow the message: it becomes the expense flow. + turn.NextState.Should().Be("category"); + harness.Conversations.SnapshotOf(harness.User.Id)!.Conversation.Should().Be("expense"); + await harness.ReportService.DidNotReceive().GetMonthlySummaryAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Text_that_is_not_an_expense_falls_through_to_help() + { + var harness = TelegramHarness.Build(); + + var turn = await harness.Router.RouteTextAsync( + ContextFor(harness, new MonthPeriod(2026, 10), "summary"), "hola", CancellationToken.None); + + turn.Completed.Should().BeTrue(); + turn.Responses[0].Text.Should().Be(harness.Messages.Get("es", MessageKeys.Help)); + harness.Conversations.Count.Should().Be(0); + } }