Files
sure/test/helpers/goals_helper_test.rb
T
b6029c1e28 feat(goals): show what each account still has room to earmark (#3166)
* feat(goals): show what each account still has room to earmark

`Account#free_to_earmark` has existed, unused, since earmarks shipped —
its own comment said the UI was a follow-up. This is that follow-up, and
the wording is the substance of it.

It does not say "over-allocated". `free_to_earmark` is negative for as
long as the saving is unfinished, which is the normal condition of anyone
with goals in progress: a 6,000 account backing two goals of 5,000 gives
−4,000 and is a perfectly correct setup. A warning phrased as a fault
would fire permanently and teach people to ignore it. The message states
the consequence instead — the goals come to X for a balance of Y, so they
progress pro rata — and is never styled as an error.

The trap is the goal being edited. `goal_earmarked_total` counts every
goal including that one, so reopening a goal that earmarks 5,000 on a
6,000 account shows 1,000 of headroom, and re-entering the same 5,000
trips a message about a setup the user has not touched.
`earmarked_by_other_goals` excludes it, and only when it is persisted —
a goal being created has nothing to exclude.

The pool is read once per render and passed down, never per account: the
form lists every fundable account the user can see. A test counts the
query and fails at two.

The Stimulus controller is its own, with 3 targets. goal_form_controller
is at 10 against the 7 the project guidelines suggest, needs none of this
state, and is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ1npaGEHr6t2HW1rYZdt4

* fix(goals): read the typed amount strictly, and format it in the app's locale

Addresses review feedback on #3166.

`Number.parseFloat` accepts prefixes, so "500abc" became 500, and the bare
comma-to-dot swap turned a thousands-separated "1,500" into 1.5. Either way the
preview described an amount the user had not typed — and the second case is a
habit from another locale, not a typo, so it would have gone unnoticed. The
value now has to match a complete number before anything is computed.

`Intl.NumberFormat(undefined, ...)` let the BROWSER pick the locale, so a
French user on an English-locale browser read separators and symbol placement
matching nothing else on the page. The amounts cannot be formatted server-side
— they change with every keystroke — so the server passes `I18n.locale` and the
client applies it. That puts the decision where the rest of the app's
formatting already lives.

bin/rails test: 6954 runs, 0 failures. RuboCop, erb_lint and biome clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(goals): let the assistant create a second goal on a claimed account

Review on #3166. The function always built whole-account links and had no
way to express an earmark, so once exclusivity landed, asking for a second
goal on an account another goal already claimed came back as a bare
`validation_failed` — while the account list still advertised the account
as available. A common request became an unexplained refusal.

Three changes, and the list is the important one: it now says what is left
on each account and which are claimed in full, because the assistant
reasons from that list and had no way to know otherwise.

`earmarks` is an optional map of account name to amount, so the assistant
can reserve a slice rather than the whole balance. Accounts left out keep
the previous behaviour and take whatever is spare.

The refusal is named before the save — `account_claimed_in_full`, with the
account names — so the assistant gets a reason it can act on and ask about,
rather than a validation message it can only relay. Checked after the
currency check, which is the more fundamental of the two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* test(goals): move the spend tests back out of the private section

The merge of `main` into this branch landed #3176's tests between
`count_pool_queries` and the helpers below it, inside the `private`
section and at the wrong indentation. `ci / lint` has been failing on
`Layout/IndentationConsistency` since.

They still ran — `test` is a class method, so `private` does not hide them
— which is why the unit job stayed green while lint went red.

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>
2026-08-26 21:12:06 +02:00

75 lines
2.3 KiB
Ruby

require "test_helper"
class GoalsHelperTest < ActionView::TestCase
include GoalsHelper
setup do
@family = families(:dylan_family)
@account = Account.create!(
family: @family, accountable: Depository.new,
name: "Helper Savings", currency: "USD", balance: 6_000
)
end
test "sums the fixed earmarks other goals hold on the account" do
build_goal("A", 2_000)
build_goal("B", 1_500)
assert_equal BigDecimal("3500"), earmarked_by_other_goals(@account, pooled: pooled)
end
# The whole point of the helper: reopening a goal must not count itself.
test "excludes the goal currently being edited" do
mine = build_goal("Mine", 5_000)
build_goal("Theirs", 500)
assert_equal BigDecimal("500"), earmarked_by_other_goals(@account, pooled: pooled, current_goal: mine)
end
test "a goal that does not exist yet excludes nothing" do
build_goal("Existing", 2_000)
unsaved = @family.goals.new(name: "New", target_amount: 1_000, currency: "USD")
assert_equal BigDecimal("2000"), earmarked_by_other_goals(@account, pooled: pooled, current_goal: unsaved)
end
test "whole-balance links reserve no fixed slice" do
build_goal("Whole", nil)
assert_equal BigDecimal("0"), earmarked_by_other_goals(@account, pooled: pooled)
end
test "an account no goal touches has nothing earmarked" do
untouched = Account.create!(
family: @family, accountable: Depository.new,
name: "Untouched", currency: "USD", balance: 1_000
)
assert_equal BigDecimal("0"), earmarked_by_other_goals(untouched, pooled: pooled)
end
# Depends on Lot B1: both released states drop out of the pool, so neither
# can inflate the headroom warning.
test "archived and completed goals are absent from the pool" do
archived = build_goal("Archived", 1_000)
completed = build_goal("Completed", 1_000)
build_goal("Live", 750)
archived.archive!
completed.complete!
assert_equal BigDecimal("750"), earmarked_by_other_goals(@account, pooled: pooled)
end
private
def build_goal(name, allocated)
@family.goals.create!(name: name, target_amount: 10_000, currency: "USD") do |g|
g.goal_accounts.build(account: @account, allocated_amount: allocated)
end
end
def pooled
Goal.pooled_allocations_for(@family)
end
end