From 3a4e92c6f85ed78da7d1e13621b8e7f76273e549 Mon Sep 17 00:00:00 2001 From: buzzromain <18685603+buzzromain@users.noreply.github.com> Date: Fri, 28 Aug 2026 07:32:30 +0200 Subject: [PATCH] feat(goals): call a reserve's amount what the rest of the app calls it (#3230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(goals): let a months-of-expenses reserve be created at all The mode could not be used from the UI. The form makes the amount field read-only in months mode — correctly, since the figure is derived — so the form submits it empty. `target_amount` is required and positive, and validations run before every save callback, so the derivation that fills it never got the chance. Creation came back 422 with "can't be blank" on a field the user is not allowed to type in. Reproduced through the controller before changing anything: response 422, no goal created. Every existing test set `target_amount` explicitly, which is why the model looked healthy — the gap was entirely on the path a user actually takes. The derivation moves to `before_validation`, where a derived value belongs: it is computed, then validated like any other. The dirty-state predicates change with it, since `will_save_change_to_*` describes a save that has not been decided on yet at that point. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * test(goals): move the new tests out of the private section Review flagged them as never running. They do — Rails' `test` macro goes through `define_method` from a class method, which defines a public method whatever the surrounding visibility, and `-n` confirms Minitest picks both up. Verified before touching anything: 2 runs, 7 assertions. Moved anyway. My insertion targeted the file's last `private` rather than its first, so they landed among the helper methods, where they read as a mistake whether or not they behave like one — three separate reviewers have now stopped on it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * test(goals): put the page tests where nobody has to check they run Review on #3229. The four page tests sat between `private` and a later `public`, and every reader so far has stopped to work out whether they run. They do — Rails' `test` macro calls `define_method` from a class method, and a method defined that way is public whatever the surrounding visibility — but a test whose behaviour has to be reasoned about is a test nobody trusts. They move above the first `private`, where the question does not come up. The second `private` goes with them: everything between it and the first was already private, so it did nothing. The fixed-amount test also gained the assertion it was missing. It named the guard it was protecting and then checked only the status, so a 422 arriving for any other reason would have kept it green. It now asserts the error the form actually puts in front of the user; flipping that paragraph's condition makes it fail, which is the point. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * feat(goals): settle on target balance, the term this kind of app uses A reserve holds a balance rather than reaching an amount, and the page had three words for that one idea: "floor" twice, "level" seven times, and a field labelled "Target amount". The mode selector said "How the floor is set" three lines above a field called "Target amount". They are all replaced by **target balance** / **solde cible**. That is the term this kind of application uses, and it keeps the noun the rest of the page already leans on — "target" appears 55 times here. My first pass invented "Level to hold", which was internally tidy and standard nowhere: it fought 55 uses of a word that was not actually wrong. A target need not be a finish line; a target balance is one you hold. In months mode the balance also stops pretending to be a field. It is worked out from spending, so it is shown as a result with a line saying where it comes from — "Worked out when you save" on a goal that has none yet, and the balance it currently holds when editing one. That removes the `readOnly` toggle, which existed only to stop people typing into something that should not have been an input. Three smaller corrections while in the file: - `exceeds_earmark` had the actor backwards in both languages. The account does not earmark; the goal earmarks on the account. - The French `not_active` was a comma splice, where the file already uses a colon for that construction. - "se recomplète" is not standard French; a reserve "se reconstitue", and "ce qui lui manque" reads better than "son manque". A test asserts neither locale still says floor or niveau, so the three vocabularies cannot quietly come back. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): stop a hidden field blocking the reserve it belongs to Replacing the read-only amount input with a hidden one left it `required`, and a required input is still validated by the browser while `display: none`. Submitting a months-based reserve was refused over a field the user could not see, and could not have filled in either — the reserve became impossible to create, which is the very thing the previous change set out to fix. Disabled rather than hidden, so it is barred from validation and its value stays out of the params, letting the derived figure land. The field also has to come back when there is nothing to derive from: with no spending history the model keeps whatever was typed, so the typed amount is then the only way to set a target at all. The form now asks the family that question up front and keeps the field where the answer is no. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): stop the label swap deleting the required marker Review on #3230. `_money_field` puts the required-field asterisk inside the label, in a span of its own. Swapping the wording with `textContent = label` replaces every child of that label, so the asterisk went with it — and `refresh()` runs on connect, so this fired on every goal form, one-off and fixed reserve included, not only the derived-months case this PR is about. Nothing put it back for the life of the page. Only the wording changes now: the label's text node is rewritten and the span left alone. A system test covers it, because nothing short of a browser can. It fails on the old code at the first assertion, before anything is clicked, which is where the bug actually landed. Also from review: the months derivation asked for the median twice, building an IncomeStatement each time. It reads it once now — the method stays un-memoized, which is what the refresh job needs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye --------- Signed-off-by: Juan José Mata Co-authored-by: Claude Opus 5 Co-authored-by: Juan José Mata --- .../controllers/goal_kind_controller.js | 56 +++++++++++++++---- app/models/goal.rb | 28 +++++++++- app/views/goals/_form.html.erb | 22 +++++++- config/locales/views/goals/en.yml | 19 ++++--- config/locales/views/goals/fr.yml | 23 ++++---- test/controllers/goals_controller_test.rb | 51 +++++++++++++++++ test/models/goal_test.rb | 33 +++++++++++ test/system/goals_form_test.rb | 33 +++++++++++ 8 files changed, 231 insertions(+), 34 deletions(-) create mode 100644 test/system/goals_form_test.rb diff --git a/app/javascript/controllers/goal_kind_controller.js b/app/javascript/controllers/goal_kind_controller.js index a68234498..4975e14ad 100644 --- a/app/javascript/controllers/goal_kind_controller.js +++ b/app/javascript/controllers/goal_kind_controller.js @@ -1,10 +1,16 @@ import { Controller } from "@hotwired/stimulus" -// A maintained reserve has no deadline: it is a level to hold, not something -// due on a date. Hiding the target-date field keeps a stale value from being +// A maintained reserve has no deadline: it is a target balance to hold, not +// something due on a date. Hiding the target-date field keeps a stale value from being // submitted and driving a pace the goal does not have. export default class extends Controller { - static targets = ["radio", "dateField", "modeField", "modeSelect", "monthsField", "amountField"] + static targets = ["radio", "dateField", "modeField", "modeSelect", "monthsField", "amountField", "amountDerived"] + + static values = { + amountLabel: String, + balanceLabel: String, + derivable: Boolean, + } connect() { this.refresh() @@ -26,16 +32,42 @@ export default class extends Controller { this.monthsFieldTargets.forEach((field) => field.classList.toggle("hidden", !months)) if (!months) this.#clearInputs(this.monthsFieldTargets) - // In months mode the floor is derived from the family's spending, not - // chosen. Shown, because it is the figure the user is saving against, but - // not editable — the model overwrites a typed one anyway, and a field that - // silently discards what you put in it is worse than one you cannot type - // into. + // Three states. A one-off saves toward a target amount; a fixed reserve + // holds a target balance you type; a months-based reserve holds one worked + // out from spending, so the input gives way to the figure itself — a + // greyed-out input still reads as something to fill in, beside the months + // that are the actual question. + // + // Only where there IS spending to work one out from, though. With none the + // model keeps whatever was typed, so the field has to stay: it is then the + // only way to give the reserve a target at all. + const derived = months && this.derivableValue + this.amountFieldTargets.forEach((field) => field.classList.toggle("hidden", derived)) + this.amountDerivedTargets.forEach((field) => field.classList.toggle("hidden", !derived)) + + // Disabled, not merely hidden. A `required` input is still validated by + // the browser while `display: none`, which then blocks a submit it cannot + // show the error on — the reserve became impossible to create. Disabling + // also keeps the typed figure out of the params, so what lands is the + // figure the model derived. this.amountFieldTargets.forEach((field) => { - field.querySelectorAll("input").forEach((input) => { - input.readOnly = months - input.classList.toggle("opacity-60", months) - }) + field.querySelectorAll("input").forEach((input) => { input.disabled = derived }) + }) + + // Only the wording changes. Assigning `textContent` would replace every + // child of the label, and the label carries the required-field asterisk in + // a span of its own — wiped on the first `refresh()`, which runs on connect + // for every goal form, with nothing to put it back. + const label = maintained ? this.balanceLabelValue : this.amountLabelValue + this.amountFieldTargets.forEach((field) => { + const el = field.querySelector(".form-field__label") + if (!el || !label) return + + const wording = [...el.childNodes].find( + (node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim(), + ) + if (wording) wording.textContent = label + else el.prepend(document.createTextNode(label)) }) } diff --git a/app/models/goal.rb b/app/models/goal.rb index fb3d2f8a9..fda5f954a 100644 --- a/app/models/goal.rb +++ b/app/models/goal.rb @@ -457,8 +457,22 @@ class Goal < ApplicationRecord computed end + # Whether a months-based target could be worked out AT ALL right now. The + # form has to know before the user has picked anything, so this deliberately + # ignores the goal's own kind, mode and months: it answers "is there spending + # history to derive from", nothing else. With none, the typed amount is the + # only way to set a target, so the form must keep offering the field. + def months_target_derivable? + return false if family.nil? || currency.blank? + + median = median_monthly_expense + return false unless median.positive? + + convert_to_goal_currency(median).to_d.positive? + end + private - # The floor this reserve should hold, or nil when it cannot be computed. + # The family's median monthly spend, in FAMILY currency. # # ⚠️ The account scope is passed EXPLICITLY, and that is the whole point # of this method. IncomeStatement's constructor does `user || Current.user` @@ -468,11 +482,19 @@ class Goal < ApplicationRecord # whole family: derived from a viewer's slice of the accounts it would # change depending on who last triggered it. The rollover chain hit # exactly this and had to be pinned the same way. + # Deliberately not memoized. The refresh job derives again on an instance + # it has already saved through, and a median frozen on first read would + # hand it back the figure it started with. + def median_monthly_expense + IncomeStatement.new(family, accounts: family.accounts.visible.included_in_reports) + .median_expense(interval: "month").to_d + end + + # The balance this reserve should hold, or nil when it cannot be computed. def months_of_expenses_amount return nil unless maintained? && months_of_expenses_target? && target_months.to_i.positive? - statement = IncomeStatement.new(family, accounts: family.accounts.visible.included_in_reports) - median = statement.median_expense(interval: "month").to_d + median = median_monthly_expense return nil unless median.positive? computed = (median * target_months).round(2) diff --git a/app/views/goals/_form.html.erb b/app/views/goals/_form.html.erb index 4b832d6ab..1cdd3949f 100644 --- a/app/views/goals/_form.html.erb +++ b/app/views/goals/_form.html.erb @@ -8,7 +8,10 @@ data-goal-form-currency-value="<%= Current.family.primary_currency_code %>" data-goal-form-require-account-value="<%= !goal.persisted? %>" data-goal-form-suggested-with-date-value="<%= t("goals.form.suggested_with_date") %>" - data-goal-form-suggested-no-date-value="<%= t("goals.form.suggested_no_date") %>"> + data-goal-form-suggested-no-date-value="<%= t("goals.form.suggested_no_date") %>" + data-goal-kind-amount-label-value="<%= t("goals.form.fields.target_amount") %>" + data-goal-kind-balance-label-value="<%= t("goals.form.fields.target_balance") %>" + data-goal-kind-derivable-value="<%= goal.months_target_derivable? %>"> <%= styled_form_with model: goal, url: goal.persisted? ? goal_path(goal) : goals_path, method: goal.persisted? ? :patch : :post, @@ -76,6 +79,23 @@ amount_data: { goal_form_target: "amountInput", action: "input->goal-form#suggestedChanged" } %>

mt-1.5 text-xs text-destructive" data-goal-form-target="amountError"><%= t("goals.form.errors.amount_required") %>

+ + <%# In months mode the balance is worked out, not typed. A read-only + input still reads as something to fill in, sitting beside the months + that are the actual question — so it is shown as what it is. Only + when there is spending to work it out from, though: with none, the + controller leaves the field above in place and this stays hidden. %> +
<%= f.date_field :target_date, label: t("goals.form.fields.target_date"), diff --git a/config/locales/views/goals/en.yml b/config/locales/views/goals/en.yml index de0543d71..5a709b14d 100644 --- a/config/locales/views/goals/en.yml +++ b/config/locales/views/goals/en.yml @@ -112,7 +112,7 @@ en: account_required: This goal is funded by several accounts — say which one the money came out of. account_not_linked: That account does not fund this goal. not_active: This goal is no longer active, so nothing can be recorded against it. - exceeds_earmark: That is more than this account has earmarked for the goal. + exceeds_earmark: That is more than the goal has earmarked on this account. transaction_already_claimed: That outflow has already been attributed. transaction_not_found: That outflow is no longer available to attribute. no_linked_account: This goal has no funding account left. @@ -131,8 +131,8 @@ en: reopen: Reopen goal delete: Delete reserve_shortfall: - heading: Reserve below its level - body: "You are holding %{saved} of %{target}. Add %{missing} to bring it back to its floor." + heading: Reserve below its target balance + body: "You are holding %{saved} of %{target}. Add %{missing} to bring it back to its target balance." record_pledge_cta: Record pledge pledge_just_transferred: Log a transfer you made pledge_just_saved: Log money you set aside @@ -146,7 +146,7 @@ en: funding_last_30d: last 30d funding_last_90d: last 90d status_callout: - depleted: "%{amount} short of its level — top it up to bring the reserve back" + depleted: "%{amount} below its target balance — top it up to bring the reserve back" behind: "save %{amount}/mo more to catch up" behind_covered: "pending pledges close the gap" on_track: "reaches goal around %{date}" @@ -182,7 +182,7 @@ en: legend_saved: Saved legend_projection: Projection legend_required: Required - reserve: "A reserve holds a level, so there is no finish line to project." + reserve: "A reserve holds a target balance, so there is no finish line to project." reached: You've hit the target. No projection needed. no_target_date: No target date set. Set one to project a finish line. no_pace: No deposits yet. Add money to a linked account to start a projection. @@ -217,7 +217,7 @@ en: restore_cta: Restore goal celebration: heading_reserve: Reserve intact - body_reserve: "Your reserve is at its full level, %{saved} of %{target}. Nothing to do — it stays earmarked." + body_reserve: "Your reserve is at its target balance, %{saved} of %{target}. Nothing to do — it stays earmarked." heading: Goal reached. Nice work. body_reached: "You've hit your target at %{saved} of %{target}. It is not closed yet — the money is still earmarked for it." close_cta: Close this goal @@ -302,7 +302,7 @@ en: hint: Save toward something, then close it when you spend it. label: One-off goal maintained: - hint: A level to keep, like an emergency fund. Never closed. + hint: A balance to keep, like an emergency fund. Never closed. label: Reserve to maintain create: Create goal save: Save changes @@ -317,12 +317,15 @@ en: prorata: "Your goals on this account come to {total} for a balance of {balance} — they will progress pro rata." whole_balance: This account will fund whatever is left after the other earmarks. fields: - target_mode: How the floor is set + target_mode: How the target balance is set target_months: Months of expenses target_months_hint: Recomputed on the 1st of each month from your median monthly spending. name: Name name_placeholder: Emergency fund, House down payment… target_amount: Target amount + target_balance: "Target balance" + target_balance_pending: "Worked out when you save" + target_balance_hint: "From your median monthly spending over the months above. Refreshed each month." target_date: Target date color: Color notes: Notes (optional) diff --git a/config/locales/views/goals/fr.yml b/config/locales/views/goals/fr.yml index 2b338813e..f0e41b46a 100644 --- a/config/locales/views/goals/fr.yml +++ b/config/locales/views/goals/fr.yml @@ -35,7 +35,7 @@ fr: months_of_expenses: Un nombre de mois de dépenses kinds: maintained: - hint: Un niveau à tenir, comme une épargne de précaution. Jamais clôturée. + hint: Un solde à maintenir, comme une épargne de précaution. Jamais clôturée. label: Réserve à maintenir one_off: hint: Épargner pour quelque chose, puis clôturer une fois la dépense faite. @@ -50,7 +50,7 @@ fr: prorata: "Vos objectifs sur ce compte totalisent {total} pour un solde de {balance} : ils progresseront au prorata." whole_balance: Ce compte financera le solde restant après les autres affectations. fields: - target_mode: Comment le seuil est fixé + target_mode: Comment le solde cible est fixé target_months: Mois de dépenses target_months_hint: Recalculé le 1er de chaque mois à partir de vos dépenses mensuelles médianes. color: Couleur @@ -63,6 +63,9 @@ fr: notes: Notes (facultatif) notes_placeholder: Un rappel pour plus tard… target_amount: Montant cible + target_balance: "Solde cible" + target_balance_pending: "Calculé à l'enregistrement" + target_balance_hint: "D'après vos dépenses mensuelles médianes sur le nombre de mois ci-dessus. Recalculé chaque mois." target_date: Date cible whole_balance: Solde total save: Enregistrer les modifications @@ -189,12 +192,12 @@ fr: success: "%{amount} enregistrés comme utilisés." errors: non_positive: Indiquez un montant supérieur à zéro. - maintained: Une réserve se ponctionne et se recomplète, elle ne se dépense pas — son manque apparaît déjà sur l'objectif. + maintained: "Une réserve se ponctionne et se reconstitue, elle ne se dépense pas — ce qui lui manque apparaît déjà sur l'objectif." exceeds_target: C'est plus que ce que cet objectif visait à épargner. account_required: Cet objectif est financé par plusieurs comptes — indiquez celui d'où vient l'argent. account_not_linked: Ce compte ne finance pas cet objectif. - not_active: Cet objectif n'est plus actif, rien ne peut y être enregistré. - exceeds_earmark: C'est plus que ce que ce compte a affecté à l'objectif. + not_active: "Cet objectif n'est plus actif : rien ne peut y être enregistré." + exceeds_earmark: "C'est plus que ce que l'objectif a affecté sur ce compte." transaction_already_claimed: Cette sortie a déjà été attribuée. transaction_not_found: Cette sortie n'est plus disponible. no_linked_account: Cet objectif n'a plus de compte de financement. @@ -213,7 +216,7 @@ fr: body: Rythme actuel %{avg}/mois · requis %{required}/mois pour atteindre votre objectif. title: Épargnez %{amount}/mois de plus pour rattraper le retard celebration: - body_reserve: "Votre réserve a atteint son niveau cible, %{saved} sur %{target}. Rien à faire — elle reste affectée." + body_reserve: "Votre réserve est à son solde cible, %{saved} sur %{target}. Rien à faire — elle reste affectée." heading_reserve: Réserve constituée body_reached: "Vous avez atteint votre cible, à %{saved} sur %{target}. Il n'est pas encore clôturé — l'argent lui reste affecté." close_cta: Clôturer cet objectif @@ -290,15 +293,15 @@ fr: no_pace: Aucun dépôt pour le moment. Ajoutez de l'argent sur un compte lié pour démarrer la projection. no_target_date: Aucune date cible définie. Définissez-en une pour projeter la fin. on_track_html: Au rythme actuel, vous atteindrez cet objectif vers le %{date}. - reserve: "Une réserve maintient un niveau : il n'y a pas d'échéance à projeter." + reserve: "Une réserve maintient un solde cible : il n'y a pas d'échéance à projeter." reached: Vous avez atteint l'objectif. Aucune projection nécessaire. today_marker: Aujourd'hui tooltip_projected: 'Projeté : %{amount}' tooltip_saved: 'Épargné : %{amount}' tooltip_target_relation: "%{percent}% de la cible de %{target}" reserve_shortfall: - heading: Réserve sous son niveau - body: "Vous détenez %{saved} sur %{target}. Complétez de %{missing} pour la ramener à son niveau." + heading: Réserve sous son solde cible + body: "Vous détenez %{saved} sur %{target}. Complétez de %{missing} pour la ramener à son solde cible." record_pledge_cta: Enregistrer une promesse reopen: Réouvrir l'objectif resume: Reprendre @@ -312,7 +315,7 @@ fr: saved: Épargné to_go: "%{amount} restant(s)" status_callout: - depleted: "il manque %{amount} — complétez pour ramener la réserve à son niveau" + depleted: "il manque %{amount} — complétez pour ramener la réserve à son solde cible" behind: épargnez %{amount}/mois de plus pour rattraper le retard behind_covered: les promesses en attente comblent le retard no_target_date: définissez une date cible pour projeter une date de fin diff --git a/test/controllers/goals_controller_test.rb b/test/controllers/goals_controller_test.rb index 750e11e38..0bffa4316 100644 --- a/test/controllers/goals_controller_test.rb +++ b/test/controllers/goals_controller_test.rb @@ -783,6 +783,57 @@ class GoalsControllerTest < ActionDispatch::IntegrationTest assert_no_match(/already used|déjà utilisés/, label) end + + # A reserve holds a balance rather than reaching an amount. "Target balance" + # is what this kind of app calls that, and it keeps the noun the rest of the + # page already uses 55 times — rather than inventing a "level" or a "floor" + # that nothing else in the domain says. + test "the form carries both the amount and the balance wording" do + unclaimed_account("Vocab Pot") + + get new_goal_url + + assert_response :success + assert_includes response.body, I18n.t("goals.form.fields.target_amount") + assert_includes response.body, I18n.t("goals.form.fields.target_balance") + end + + # In months mode the balance is worked out, not typed. A read-only input + # still reads as something to fill in, beside the months that are the real + # question, so the figure is presented as a result instead. + test "the form carries the derived balance as a result, not a field" do + unclaimed_account("Derived Pot") + + get new_goal_url + + assert_response :success + assert_select "[data-goal-kind-target=amountDerived]", 1 + assert_includes response.body, I18n.t("goals.form.fields.target_balance_pending") + end + + test "editing a months reserve shows the balance it currently holds" do + account = unclaimed_account("Existing Pot") + IncomeStatement.any_instance.stubs(:median_expense).returns(500) + goal = @user.family.goals.create!( + name: "Precaution", target_amount: 3_000, currency: "USD", kind: "maintained", + target_mode: "months_of_expenses", target_months: 6 + ) { |g| g.goal_accounts.build(account: account, allocated_amount: 3_000) } + + get edit_goal_url(goal) + + assert_response :success + assert_includes response.body, goal.target_amount_money.format(precision: 0) + end + + # One vocabulary, not three. "level" and "floor" said the same thing as + # "target balance" in different words, on the same page. + test "the goals copy settles on one word for a reserve's balance" do + %i[en fr].each do |locale| + copy = YAML.load_file(Rails.root.join("config/locales/views/goals/#{locale}.yml")).to_s + assert_no_match(/\bfloor\b|\bniveau\b/i, copy, "#{locale} still mixes vocabularies") + end + end + private def spent_goal_for_display diff --git a/test/models/goal_test.rb b/test/models/goal_test.rb index 06d5d26a1..209d3e1c1 100644 --- a/test/models/goal_test.rb +++ b/test/models/goal_test.rb @@ -779,6 +779,39 @@ class GoalTest < ActiveSupport::TestCase assert_equal BigDecimal("1000"), goal.reload.target_amount.to_d end + # The form asks this BEFORE the user has picked a kind or a number of months, + # to decide whether to keep offering the amount field. So it must answer for + # the family alone, not for what this goal happens to be right now. + test "derivability is answered for the family, whatever the goal is set to" do + IncomeStatement.any_instance.stubs(:median_expense).returns(BigDecimal("500")) + goal = reserve_goal(balance: 3_000, target: 1_000) + + assert goal.months_target_derivable?, + "a one-off with no months set still has spending to derive from" + end + + # With nothing to derive from, the typed amount is the only way to give the + # reserve a target. The form has to be told, or it hides the only field that + # would let the user do it. + test "a family with no spending history can derive nothing" do + IncomeStatement.any_instance.stubs(:median_expense).returns(BigDecimal("0")) + goal = reserve_goal(balance: 3_000, target: 1_000) + + assert_not goal.months_target_derivable? + end + + test "derivability follows the goal's currency, not the family's" do + IncomeStatement.any_instance.stubs(:median_expense).returns(BigDecimal("500")) + goal = reserve_goal(balance: 3_000, target: 1_000) + goal.stubs(:currency).returns("EUR") + Money.any_instance.stubs(:exchange_to).raises( + Money::ConversionError.new(from_currency: "USD", to_currency: "EUR", date: Date.current) + ) + + assert_not goal.months_target_derivable?, + "no rate for the day means no figure to show, so the field must stay" + end + test "account free_to_earmark subtracts non-archived fixed earmarks" do account = Account.create!(family: @family, accountable: Depository.new, name: "Headroom Savings", currency: "USD", balance: 5_000) @family.goals.create!(name: "Earmarker", target_amount: 10_000, currency: "USD") do |g| diff --git a/test/system/goals_form_test.rb b/test/system/goals_form_test.rb new file mode 100644 index 000000000..0985d4b00 --- /dev/null +++ b/test/system/goals_form_test.rb @@ -0,0 +1,33 @@ +require "application_system_test_case" + +# The goal form swaps the amount field's label between "Target amount" and +# "Target balance" as the kind changes. The label also carries the +# required-field asterisk, in a span of its own, so how that swap is made +# decides whether the asterisk survives it — and only a browser can say. +class GoalsFormTest < ApplicationSystemTestCase + setup do + @user = users(:family_admin) + # Goals sit behind the preview gate; without it the visit redirects to the + # dashboard and the form never renders. + @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true)) + sign_in @user + end + + test "the required asterisk survives the label swap" do + visit new_goal_path + + # This first check is the one that bites: `refresh()` runs on connect, so a + # swap that replaced the label's children has already wiped the asterisk by + # the time the page is idle — on every goal form, one-off included, with + # nothing to put it back. + label = find(".form-field__label", text: I18n.t("goals.form.fields.target_amount")) + assert label.has_css?("span", text: "*"), + "the required marker was gone before anything was even clicked" + + choose I18n.t("goals.form.kinds.maintained.label") + + swapped = find(".form-field__label", text: I18n.t("goals.form.fields.target_balance")) + assert swapped.has_css?("span", text: "*"), + "the label swap deleted the required marker" + end +end