Files
sure/test/components/goals/lifecycle_panel_component_test.rb
T
buzzromainandClaude Opus 5 ff48acd04b feat(goals): offer recording a spend where the user is standing (#3215)
* feat(goals): offer recording a spend where the user is standing

Adding money had a button on the goal page. Using it had none — the entry
lived in the overflow menu, behind three conditions, and nowhere else.

The asymmetry bit hardest at the one moment it mattered. "Record pledge"
disappears once a goal is reached, so a user who hit the target and then
spent some of it arrived at a page offering a single action: "Close this
goal". That releases the earmark, and its own hint tells them to do it
"once you have actually spent it" — asking for something the page gave
them no way to say.

The celebration panel now offers it beside closing, in that order, because
that is the order the two happen in and closing is the one another click
cannot undo. Same condition as the menu entry, which stays where it is:
this is a second door, not a move.

Beside, never instead. Plenty of goals are closed with nothing recorded,
and the offer must not read as a step to clear first — a test pins that
closing is still offered whenever it was before.

The row is conditional rather than always rendered: a reserve gets neither
action, and an empty flex div still carries its top margin, which would
open a gap under copy that says there is nothing to do.

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

* fix(goals): hold the spend offer to accounts the reader can reach

Review on #3215. `offer_recording_a_spend?` rode on `current_balance`,
which counts every linked account — private ones included. A reader backed
only by somebody else's private account was shown the link, then sent to a
dialog with nothing to pick and a refusal on submit.

The dialog has applied this scoping since #3176; the panel that points at
it had not. It goes through `backing_within` now, on the reader's own
accessible accounts.

The component tests gained a session for the same reason: without a reader
the offer is correctly withheld, so every assertion about it was measuring
the wrong thing.

Also from review: the reserve's empty-row test renders the component rather
than only asking its predicates. Both could stay false while the template
emitted the row anyway, which is precisely the gap that test exists for —
it needed `ViewComponent::TestCase` to do it.

The two page tests move above the first `private`. They did run where they
were — Rails' `test` macro defines methods through `define_method` from a
class method, which is public regardless of the surrounding visibility, and
`-n` confirms Minitest picks them up — but tests wedged between private
helpers read as a mistake whether or not they behave like one.

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

* fix(goals): close the second door to the spend dialog

Review on #3215. Scoping the offer to the reader's own accounts fixed the
lifecycle panel and left the overflow menu on the old condition, so the two
doors to the same dialog disagreed — and the older one still had the bug the
newer one was written to avoid. A reader backed only by another member's
private account was shown the menu entry, opened a dialog with nothing to
pick, and was refused on submit.

The question moves to the goal, where both doors ask it, and the reader's
accounts come from the list the controller already builds for the dialog. That
also removes the component's own broader lookup: it was plucking every
accessible account on each goal-show render, duplicating work done upstream in
the same request. It now takes the ids in, defaulting to none — a caller that
forgets them withholds the offer, which is the safe way to be wrong about a
permission.

A controller test pins it page-wide, since the point is that neither door may
offer it; putting the old condition back makes it fail.

Also from review: the panel test claimed to be scoped to the panel while
selecting `section`, which DS::Card emits for every card on the page. The
action row has an id now and both tests use it.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 07:49:01 +02:00

154 lines
6.2 KiB
Ruby

require "test_helper"
# The panel choice was five predicates deep in ERB where the ordering between
# them mattered and nothing said so. These pin the order, not the markup.
class Goals::LifecyclePanelComponentTest < ViewComponent::TestCase
setup do
@family = families(:dylan_family)
# The panel scopes what it offers to the reader's accessible accounts, so
# these need a reader — without one the offer is correctly withheld and
# every assertion about it would be measuring the wrong thing.
Current.session = sessions(:one)
end
teardown { Current.session = nil }
test "an archived or paused goal gets the inactive panel whatever its progress" do
goal = funded_goal
goal.archive!
assert_equal :inactive, panel_for(goal)
other = funded_goal(name: "Paused")
other.pause!
assert_equal :inactive, panel_for(other)
end
test "a reserve at its floor celebrates rather than reporting a shortfall" do
assert_equal :celebration, panel_for(funded_goal(kind: "maintained"))
end
# A brand-new reserve sits at zero balance and zero pace, which is also what
# the generic "make your first transfer" card tests for. It must not win:
# what the reserve needs is how far below its floor it is.
test "an empty reserve reports its shortfall rather than looking untouched" do
goal = @family.goals.create!(
name: "Precaution", target_amount: 6_000, currency: @family.currency, kind: "maintained"
) { |g| g.goal_accounts.build(account: pot(balance: 0), allocated_amount: 0) }
assert_equal :reserve_shortfall, panel_for(goal)
end
test "a one-off goal with nothing in it yet is asked for its first transfer" do
goal = @family.goals.create!(
name: "Trip", target_amount: 5_000, currency: @family.currency
) { |g| g.goal_accounts.build(account: pot(balance: 0), allocated_amount: 0) }
assert_equal :empty, panel_for(goal)
end
# A reserve is never offered closing: releasing the money would undo the
# thing it exists for.
test "a funded reserve is offered no closing action" do
assert_equal :none, component_for(funded_goal(kind: "maintained")).celebration_action
end
# Closing releases the earmark; archiving does not. A goal merely at 100% is
# still holding its money, so it gets the gesture that lets go of it.
test "a goal at its target is offered closing, not archiving" do
assert_equal :close, component_for(funded_goal).celebration_action
end
# Adding money had a button on this page; using it had none. And the moment
# it was most wanted — target reached, trip taken — the page offered only
# "Close this goal", which does something else and whose own hint tells you
# to have spent the money first.
test "a goal still holding money offers to record a spend" do
assert component_for(funded_goal).offer_recording_a_spend?
end
# Beside closing, never instead of it: a goal is often closed without
# anything being recorded, and this must not read as a step to clear first.
test "the closing action is unaffected by the offer" do
goal = funded_goal
assert_equal :close, component_for(goal).celebration_action
assert component_for(goal).offer_recording_a_spend?
end
# A reserve refuses consumption outright, so offering it would be offering
# something the model then declines.
test "a reserve is never offered a spend" do
assert_not component_for(funded_goal(kind: "maintained")).offer_recording_a_spend?
end
# Nothing left to spend, and nothing to spend it from once closed.
test "a closed goal is not offered a spend" do
goal = funded_goal
goal.complete!
assert_not component_for(goal.reload).offer_recording_a_spend?
end
test "a goal holding nothing is not offered a spend" do
goal = @family.goals.create!(
name: "Empty", target_amount: 1_000, currency: @family.currency
) { |g| g.goal_accounts.build(account: pot(balance: 0), allocated_amount: 0) }
assert_not component_for(goal).offer_recording_a_spend?
end
# `current_balance` counts every linked account, private ones included. The
# offer used to ride on that, so a reader backed only by somebody else's
# private account was sent to a dialog with nothing to pick and a refusal on
# submit.
test "money the reader cannot reach does not earn the offer" do
private_account = Account.create!(
family: @family, owner: users(:family_member), accountable: Depository.new,
name: "Member Private", currency: @family.currency, balance: 5_000
)
private_account.account_shares.destroy_all
goal = @family.goals.create!(
name: "Hidden", target_amount: 5_000, currency: @family.currency
) { |g| g.goal_accounts.build(account: private_account, allocated_amount: 5_000) }
assert goal.current_balance.to_d.positive?, "the goal is backed, just not for this reader"
assert_not component_for(goal).offer_recording_a_spend?
end
# A reserve gets neither action, so the row must not render at all — an
# empty flex div still carries its top margin, and would open a gap under
# copy that says there is nothing to do.
test "a reserve renders no action row" do
goal = funded_goal(kind: "maintained")
# Rendered, not merely asked: the predicates could both stay false while
# the template emitted the row anyway, which is the gap this guards.
render_inline(component_for(goal))
assert_no_selector "#goal-celebration-actions"
end
private
def pot(balance:, name: "Pot #{SecureRandom.hex(3)}")
Account.create!(family: @family, accountable: Depository.new, name: name,
currency: @family.currency, balance: balance)
end
def funded_goal(name: "Trip", kind: "one_off")
@family.goals.create!(
name: name, target_amount: 1_000, currency: @family.currency, kind: kind
) { |g| g.goal_accounts.build(account: pot(balance: 1_000), allocated_amount: 1_000) }
end
# The same list the controller hands the panel and the overflow menu: the
# goal's accounts this reader can actually reach.
def component_for(goal)
visible = Current.user.accessible_accounts.where(id: goal.linked_accounts.map(&:id)).pluck(:id)
Goals::LifecyclePanelComponent.new(goal: goal, viewer_account_ids: visible)
end
def panel_for(goal)
component_for(goal).panel
end
end