feat(goals): call a reserve's amount what the rest of the app calls it (#3230)

* 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
buzzromain
2026-08-28 07:32:30 +02:00
committed by GitHub
co-authored by Claude Opus 5 Juan José Mata
parent 390ed50616
commit 3a4e92c6f8
8 changed files with 231 additions and 34 deletions
@@ -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))
})
}
+25 -3
View File
@@ -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)
+21 -1
View File
@@ -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" } %>
<p class="<%= "hidden" unless goal.errors[:target_amount].any? %> mt-1.5 text-xs text-destructive" data-goal-form-target="amountError"><%= t("goals.form.errors.amount_required") %></p>
</div>
<%# 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. %>
<div data-goal-kind-target="amountDerived" class="hidden">
<p class="form-field__label"><%= t("goals.form.fields.target_balance") %></p>
<p class="mt-1 text-lg font-medium text-primary tabular-nums privacy-sensitive">
<% if goal.target_amount.to_d.positive? %>
<%= goal.target_amount_money.format(precision: 0) %>
<% else %>
<span class="text-subdued text-sm font-normal"><%= t("goals.form.fields.target_balance_pending") %></span>
<% end %>
</p>
<p class="mt-1 text-xs text-subdued"><%= t("goals.form.fields.target_balance_hint") %></p>
</div>
<div data-goal-kind-target="dateField">
<%= f.date_field :target_date,
label: t("goals.form.fields.target_date"),
+11 -8
View File
@@ -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)
+13 -10
View File
@@ -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 <strong class="text-primary whitespace-nowrap">%{date}</strong>.
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
+51
View File
@@ -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
+33
View File
@@ -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|
+33
View File
@@ -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