Files
sure/test/models/goal_test.rb
T
Guillem Arias Fauste 214a139a7e fix(goals): stop the days-left phrase splitting across lines (#2970)
* fix(goals): stop the days-left phrase splitting across lines

The goal header read "Target 700 € by 08 de Febrer de 2027 · 184 days left"
as one text node, so on a phone it wrapped wherever it ran out of room —
"184 days" on the first line and "left" orphaned on the second.

`header_summary_parts` now returns the dot-separated segments instead of a
joined string, and the view renders each as its own span. Only the segments
after the first are `whitespace-nowrap`: the first carries the target and a
long-format date and has to stay free to wrap, or it would overflow a narrow
screen. Checked at 320px, where the first segment takes two lines and
"675 days left" still moves down whole.

* test(goals): pin the clock for the days-left assertion

The count is `target_date - Date.current`, and the target was set from
`184.days.from_now` a moment earlier — a suite crossing midnight between the
two would compute 183 and fail. Wrap it in travel_to, matching the idiom in
budget_category_test and family_export_test.

The neighbouring 'omits days left once reached' test asserts only the segment
count, which 183 and 184 satisfy equally, so it is left alone.
2026-08-13 06:43:31 +02:00

515 lines
20 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
require "test_helper"
class GoalTest < ActiveSupport::TestCase
include EntriesTestHelper
setup do
@family = families(:dylan_family)
@depository = accounts(:depository)
@connected = accounts(:connected)
@goal = goals(:vacation_italy)
end
test "valid fixture goal saves" do
assert @goal.valid?
end
# The days-left segment is returned separately so the view can keep it
# unbroken; joined into one string it wrapped after "days" on a phone.
test "header_summary_parts keeps the days-left phrase in its own segment" do
# Pinned: the count is `target_date - Date.current`, so a suite that crossed
# midnight between setting the date and reading it would report 183.
travel_to Date.new(2026, 6, 1) do
# Target well above the linked account's balance, or the goal reads as
# :reached and drops the days-left segment on purpose.
@goal.target_amount = 1_000_000
@goal.target_date = Date.current + 184
parts = @goal.header_summary_parts
assert_equal 2, parts.size
assert_match(/184 days left/, parts.last)
assert_no_match(/days left/, parts.first)
end
end
test "header_summary_parts omits days left once the target is reached" do
@goal.target_date = 184.days.from_now.to_date
@goal.complete!
assert_equal 1, @goal.header_summary_parts.size
end
test "header_summary_parts is a single segment without a target date" do
@goal.target_date = nil
assert_equal 1, @goal.header_summary_parts.size
end
test "name is required" do
@goal.name = ""
assert_not @goal.valid?
assert_includes @goal.errors[:name], "can't be blank"
end
test "target_amount must be positive" do
@goal.target_amount = 0
assert_not @goal.valid?
end
test "color must match hex format" do
@goal.color = "red; cursor: pointer"
assert_not @goal.valid?
assert_includes @goal.errors[:color], "is invalid"
end
test "color accepts standard 6-digit hex" do
@goal.color = "#abcdef"
assert @goal.valid?, @goal.errors.full_messages.to_sentence
end
test "display_status follows AASM state after pause! on the same instance" do
@goal.update!(color: "#4da568") if @goal.color.blank?
initial = @goal.display_status
@goal.pause!
assert_equal :paused, @goal.display_status, "stale memo would have returned #{initial.inspect}"
end
test "must have at least one linked account on create" do
new_goal = @family.goals.new(name: "Test", target_amount: 100, currency: "USD")
assert_not new_goal.valid?
assert_match(/at least one/i, new_goal.errors[:base].join)
end
test "investment accounts are fundable and default to the contributions basis" do
investment = accounts(:investment)
new_goal = @family.goals.new(name: "Inv", target_amount: 100, currency: "USD")
new_goal.goal_accounts.build(account: investment)
assert new_goal.valid?, new_goal.errors.full_messages.to_sentence
new_goal.save! # basis is set on save (before_save), not on valid?
assert_equal "contributions", new_goal.progress_basis
end
test "non-fundable account types are rejected" do
credit = accounts(:credit_card)
new_goal = @family.goals.new(name: "Test", target_amount: 100, currency: "USD")
new_goal.goal_accounts.build(account: credit)
assert_not new_goal.valid?
assert_includes new_goal.errors[:linked_accounts], "All linked accounts must be cash or investment accounts."
end
test "linked accounts must belong to family" do
other_family = Family.create!(name: "Other", currency: "USD", locale: "en", country: "US", timezone: "UTC")
foreign_account = Account.create!(
family: other_family,
accountable: Depository.new,
name: "Foreign",
currency: "USD",
balance: 100
)
new_goal = @family.goals.new(name: "T", target_amount: 100, currency: "USD")
new_goal.goal_accounts.build(account: foreign_account)
assert_not new_goal.valid?
assert_includes new_goal.errors[:linked_accounts], "Linked accounts must belong to the same family as the goal."
end
test "linked accounts must share currency with goal" do
eur_account = Account.create!(
family: @family,
accountable: Depository.new,
name: "Euro Cash",
currency: "EUR",
balance: 100
)
new_goal = @family.goals.new(name: "T", target_amount: 100, currency: "USD")
new_goal.goal_accounts.build(account: eur_account)
assert_not new_goal.valid?
assert_includes new_goal.errors[:linked_accounts], "All linked accounts must share the same currency."
end
test "currency can't change once linked accounts exist" do
assert @goal.linked_accounts.exists?
@goal.currency = "EUR"
assert_not @goal.valid?
assert_includes @goal.errors[:currency], "Can't change the currency after the goal is linked to accounts."
end
test "current_balance sums linked account balances" do
expected = @goal.linked_accounts.sum(&:balance).to_d
assert_equal expected, @goal.current_balance.to_d
end
test "progress_percent caps at 100" do
@goal.target_amount = 1
assert_equal 100, @goal.progress_percent
end
test "progress_percent stays below 100 while remaining amount is positive" do
account = Account.create!(
family: @family,
accountable: Depository.new,
name: "Almost There Savings",
currency: "USD",
balance: BigDecimal("999.50")
)
goal = @family.goals.create!(
name: "Almost There",
target_amount: BigDecimal("1000"),
currency: "USD"
) { |new_goal| new_goal.goal_accounts.build(account: account) }
assert_equal BigDecimal("0.5"), goal.remaining_amount
assert_equal 99, goal.progress_percent
assert_equal :no_target_date, goal.status
end
test "status stays reached for a goal completed while underfunded" do
account = Account.create!(
family: @family,
accountable: Depository.new,
name: "Completed Underfunded Savings",
currency: "USD",
balance: BigDecimal("999.50")
)
goal = @family.goals.create!(
name: "Completed Underfunded",
target_amount: BigDecimal("1000"),
currency: "USD"
) { |new_goal| new_goal.goal_accounts.build(account: account) }
goal.complete!
assert_equal BigDecimal("0.5"), goal.remaining_amount
assert_equal 100, goal.progress_percent
assert_equal :reached, Goal.find(goal.id).status
end
test "progress_percent is 0 for empty active goal" do
fresh = goals(:car_paydown)
fresh.update!(target_amount: 10_000)
fresh.linked_accounts.update_all(balance: 0)
# Refetch instead of poking @current_balance directly so the test
# exercises the real memo lifecycle (a request reads progress_percent
# on a freshly-loaded record after the underlying balances changed).
reloaded = Goal.find(fresh.id)
assert_equal 0, reloaded.progress_percent
end
test "remaining_amount is non-negative" do
@goal.target_amount = 1
assert_equal 0, @goal.remaining_amount
end
test "pace is zero on a goal whose linked accounts have no transactions" do
fresh_account = Account.create!(
family: @family,
accountable: Depository.new,
name: "Empty Savings",
currency: "USD",
balance: 0
)
fresh = @family.goals.create!(
name: "Fresh goal",
target_amount: 100,
currency: "USD"
) { |g| g.goal_accounts.build(account: fresh_account) }
assert_equal 0, fresh.pace.to_d
end
test "pace averages 90-day net inflow, excluding pending and excluded entries" do
account = Account.create!(
family: @family,
accountable: Depository.new,
name: "Pace Savings",
currency: "USD",
balance: 0
)
goal = @family.goals.create!(
name: "Pace goal",
target_amount: 10_000,
currency: "USD"
) { |g| g.goal_accounts.build(account: account) }
# Three inflows over the 90-day window. Sure convention: inflows are
# negative. Net = -900 → pace = 900 / 3 = 300.
create_transaction(account: account, amount: -300, date: 80.days.ago.to_date)
create_transaction(account: account, amount: -300, date: 40.days.ago.to_date)
create_transaction(account: account, amount: -300, date: 5.days.ago.to_date)
# Pending inflow that must be excluded by `Transaction.excluding_pending`.
pending_entry = create_transaction(account: account, amount: -1_000, date: 10.days.ago.to_date)
pending_entry.transaction.update!(extra: { "plaid" => { "pending" => true } })
# User-excluded outflow that must be excluded by `entries.excluded = false`.
excluded_entry = create_transaction(account: account, amount: 500, date: 20.days.ago.to_date)
excluded_entry.update!(excluded: true)
# Entry outside the 90-day window — must be ignored.
create_transaction(account: account, amount: -10_000, date: 200.days.ago.to_date)
assert_equal 300, goal.pace.to_d
end
test "months_of_runway is nil when goal has a target date" do
assert_not_nil @goal.target_date
assert_nil @goal.months_of_runway
end
test "months_of_runway is nil when pace is zero" do
fresh = goals(:emergency_fund)
assert_nil fresh.months_of_runway
end
test "AASM transitions" do
fresh = goals(:emergency_fund)
assert fresh.active?
fresh.pause!
assert fresh.paused?
fresh.resume!
assert fresh.active?
fresh.complete!
assert fresh.completed?
fresh.archive!
assert fresh.archived?
fresh.unarchive!
assert fresh.active?
end
test "status: reached when balance >= target" do
@goal.target_amount = 1
assert_equal :reached, @goal.status
end
test "status: no_target_date when target_date is nil" do
@goal.target_date = nil
@goal.target_amount = 10_000
@goal.linked_accounts.update_all(balance: 100)
assert_equal :no_target_date, @goal.status
end
test "display_status returns :archived for archived goal regardless of progress" do
@goal.save!
@goal.archive!
assert_equal :archived, @goal.display_status
end
test "display_status returns :paused for paused goal regardless of progress" do
@goal.save!
@goal.pause!
assert_equal :paused, @goal.display_status
end
test "display_status falls through to status for active goals" do
@goal.target_amount = 1
assert_equal :reached, @goal.display_status
end
test "advisory_lock_key_for is stable per family" do
k1 = Goal.advisory_lock_key_for(@family.id)
k2 = Goal.advisory_lock_key_for(@family.id)
assert_equal k1, k2
assert_kind_of Integer, k1
end
test "any_connected_account? reflects plaid_account presence" do
assert @goal.any_connected_account?
only_manual = goals(:emergency_fund)
only_manual.goal_accounts.where(account_id: @connected.id).destroy_all
assert_not only_manual.reload.any_connected_account?
end
test "pledge_action_label_key flips on manual-only goals" do
assert_equal "goals.show.pledge_just_transferred", @goal.pledge_action_label_key
@goal.goal_accounts.where(account_id: @connected.id).destroy_all
# After removing the only connected account, the goal is manual-only;
# the copy must flip to "pledge_just_saved" so users aren't told to
# wait for a sync that won't run. Refetch to exercise the real
# request lifecycle rather than poking a memo on the same instance.
reloaded = Goal.find(@goal.id)
assert_equal "goals.show.pledge_just_saved", reloaded.pledge_action_label_key
end
test "explicit allocation backs only the earmarked slice" do
account = Account.create!(family: @family, accountable: Depository.new, name: "Split Savings", currency: "USD", balance: 5_000)
goal = @family.goals.create!(name: "Earmarked", target_amount: 10_000, currency: "USD") do |g|
g.goal_accounts.build(account: account, allocated_amount: 1_000)
end
assert_equal BigDecimal("1000"), goal.current_balance.to_d
end
test "explicit allocation is capped at the account balance via the haircut" do
account = Account.create!(family: @family, accountable: Depository.new, name: "Over Earmark", currency: "USD", balance: 800)
goal = @family.goals.create!(name: "Over", target_amount: 10_000, currency: "USD") do |g|
g.goal_accounts.build(account: account, allocated_amount: 5_000)
end
assert_equal BigDecimal("800"), goal.current_balance.to_d
end
test "unallocated link claims the balance left after another goal's earmark" do
account = Account.create!(family: @family, accountable: Depository.new, name: "Shared Savings", currency: "USD", balance: 5_000)
earmarked = @family.goals.create!(name: "Earmarked", target_amount: 10_000, currency: "USD") do |g|
g.goal_accounts.build(account: account, allocated_amount: 2_000)
end
whole = @family.goals.create!(name: "Whole", target_amount: 10_000, currency: "USD") do |g|
g.goal_accounts.build(account: account) # NULL = whole-balance remainder
end
assert_equal BigDecimal("2000"), earmarked.current_balance.to_d
assert_equal BigDecimal("3000"), whole.current_balance.to_d
# The two goals' shares of the shared account never exceed its balance.
assert_equal account.balance.to_d, earmarked.current_balance.to_d + whole.current_balance.to_d
end
test "over-earmarked account scales fixed slices pro-rata" do
account = Account.create!(family: @family, accountable: Depository.new, name: "Contested Savings", currency: "USD", balance: 5_000)
a = @family.goals.create!(name: "Goal A", target_amount: 10_000, currency: "USD") do |g|
g.goal_accounts.build(account: account, allocated_amount: 4_000)
end
b = @family.goals.create!(name: "Goal B", target_amount: 10_000, currency: "USD") do |g|
g.goal_accounts.build(account: account, allocated_amount: 4_000)
end
# sum_fixed 8000 > balance 5000 -> each scaled by 5000/8000 -> 2500.
assert_equal BigDecimal("2500"), a.current_balance.to_d
assert_equal BigDecimal("2500"), b.current_balance.to_d
assert_equal account.balance.to_d, a.current_balance.to_d + b.current_balance.to_d
end
test "archived goals release their earmark from the shared pool" do
account = Account.create!(family: @family, accountable: Depository.new, name: "Release Savings", currency: "USD", balance: 5_000)
whole = @family.goals.create!(name: "Whole", target_amount: 10_000, currency: "USD") do |g|
g.goal_accounts.build(account: account)
end
earmarked = @family.goals.create!(name: "Earmarked", target_amount: 10_000, currency: "USD") do |g|
g.goal_accounts.build(account: account, allocated_amount: 2_000)
end
assert_equal BigDecimal("3000"), Goal.find(whole.id).current_balance.to_d
earmarked.archive!
# Archived goal no longer reserves its slice -> whole reclaims it.
assert_equal BigDecimal("5000"), Goal.find(whole.id).current_balance.to_d
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|
g.goal_accounts.build(account: account, allocated_amount: 1_500)
end
assert_equal BigDecimal("1500"), account.goal_earmarked_total
assert_equal BigDecimal("3500"), account.free_to_earmark
end
test "an overdrawn account backs nothing for fixed or whole-balance links" do
account = Account.create!(family: @family, accountable: Depository.new, name: "Overdrawn", currency: "USD", balance: BigDecimal("-100"))
fixed = @family.goals.create!(name: "Fixed OD", target_amount: 1_000, currency: "USD") do |g|
g.goal_accounts.build(account: account, allocated_amount: 50)
end
whole = @family.goals.create!(name: "Whole OD", target_amount: 1_000, currency: "USD") do |g|
g.goal_accounts.build(account: account)
end
assert_equal 0.to_d, fixed.current_balance.to_d
assert_equal 0.to_d, whole.current_balance.to_d
end
test "an archived goal still shows its own earmark, not the whole balance" do
account = Account.create!(family: @family, accountable: Depository.new, name: "Archived Earmark", currency: "USD", balance: 5_000)
earmarked = @family.goals.create!(name: "Archived Fixed", target_amount: 10_000, currency: "USD") do |g|
g.goal_accounts.build(account: account, allocated_amount: 2_000)
end
earmarked.archive!
# Excluded from the shared pool, but its own earmark is read from its own
# goal_accounts — so it still reports 2,000, not the whole 5,000.
assert_equal BigDecimal("2000"), Goal.find(earmarked.id).current_balance.to_d
end
test "earmark edits to an existing linked account persist via goal.save!" do
account = Account.create!(family: @family, accountable: Depository.new, name: "Autosave Savings", currency: "USD", balance: 5_000)
goal = @family.goals.create!(name: "Autosave goal", target_amount: 10_000, currency: "USD") do |g|
g.goal_accounts.build(account: account) # NULL = whole balance
end
ga = goal.goal_accounts.first
assert_nil ga.allocated_amount
ga.allocated_amount = 1_500
goal.save! # autosave: true must persist the dirty existing child
assert_equal BigDecimal("1500"), goal.goal_accounts.first.reload.allocated_amount
end
test "progress_percent memo resets after complete! on the same instance" do
account = Account.create!(family: @family, accountable: Depository.new, name: "Memo Savings", currency: "USD", balance: 100)
goal = @family.goals.create!(name: "Memo goal", target_amount: 1_000, currency: "USD") do |g|
g.goal_accounts.build(account: account)
end
assert_operator goal.progress_percent, :<, 100 # memoize the underfunded value
goal.complete!
assert_equal 100, goal.progress_percent, "stale memo would still report the pre-complete percent"
end
test "contributions basis excludes market gains" do
account = Account.create!(family: @family, accountable: Investment.new, name: "Brokerage", currency: "USD", balance: 10_000)
account.balances.create!(date: 10.days.ago.to_date, balance: 10_000, currency: "USD", net_market_flows: 3_000)
goal = @family.goals.create!(name: "Invest goal", target_amount: 20_000, currency: "USD") do |g|
g.goal_accounts.build(account: account)
end
assert_equal "contributions", goal.progress_basis
# 10,000 value 3,000 market gain = 7,000 contributed.
assert_equal BigDecimal("7000"), goal.current_balance.to_d
assert_equal BigDecimal("10000"), goal.market_value_money.amount
end
test "reopen transitions a completed goal back to active" do
fresh = goals(:emergency_fund)
fresh.complete!
assert fresh.completed?
fresh.reopen!
assert fresh.active?
end
test "investment accounts default to transfer pledge kind, never manual_save" do
assert_equal "transfer", accounts(:investment).default_pledge_kind
end
test "adding an investment account via update flips a depository goal to contributions" do
goal = goals(:emergency_fund)
assert_equal "balance", goal.progress_basis
goal.goal_accounts.build(account: accounts(:investment))
goal.save!
assert_equal "contributions", goal.reload.progress_basis
end
test "earmark is respected on a contributions-basis goal" do
account = Account.create!(family: @family, accountable: Investment.new, name: "Brokerage2", currency: "USD", balance: 10_000)
account.balances.create!(date: 5.days.ago.to_date, balance: 10_000, currency: "USD", net_market_flows: 2_000)
goal = @family.goals.create!(name: "Earmarked invest", target_amount: 20_000, currency: "USD") do |g|
g.goal_accounts.build(account: account, allocated_amount: 1_000)
end
assert_equal "contributions", goal.progress_basis
# net contributed = 10,000 2,000 = 8,000; earmark 1,000 ≤ 8,000 → 1,000.
assert_equal BigDecimal("1000"), goal.current_balance.to_d
end
test "behind_pace? excludes paused goals even when their raw status is behind" do
goal = goals(:vacation_italy)
goal.stubs(:status).returns(:behind)
assert goal.behind_pace?
goal.update!(state: "paused")
assert goal.paused?
assert_not goal.behind_pace?
end
test "summary_for counts behind goals via behind_pace? and sums one currency" do
behind = goals(:vacation_italy)
behind.stubs(:status).returns(:behind)
paused_behind = goals(:emergency_fund)
paused_behind.update!(state: "paused")
paused_behind.stubs(:status).returns(:behind)
summary = Goal.summary_for([ behind, paused_behind ], currency: "USD")
assert_equal 1, summary[:behind_count]
assert_kind_of Money, summary[:saved_money]
end
end