diff --git a/app/helpers/budgets_helper.rb b/app/helpers/budgets_helper.rb new file mode 100644 index 000000000..8ffb27ad8 --- /dev/null +++ b/app/helpers/budgets_helper.rb @@ -0,0 +1,70 @@ +module BudgetsHelper + def budget_has_over_budget?(budget) + return false unless budget.initialized? + + budget.budget_categories.any?(&:any_over_budget?) + end + + def budget_categories_view_state(budget) + @budget_categories_view_state ||= {} + @budget_categories_view_state[budget.object_id] ||= build_budget_categories_view_state(budget) + end + + private + + def build_budget_categories_view_state(budget) + uncategorized_budget_category = budget.uncategorized_budget_category + all_category_groups = BudgetCategory::Group.for(budget.budget_categories) + + over_budget_groups = if budget.initialized? + filtered_groups_for(all_category_groups) { |budget_category| budget_category.any_over_budget? } + else + [] + end + + show_over_budget_uncategorized = budget.initialized? && uncategorized_budget_category.any_over_budget? + over_budget_count = visible_count_for(over_budget_groups) { |budget_category| budget_category.any_over_budget? } + over_budget_count += 1 if show_over_budget_uncategorized + + on_track_groups = if budget.initialized? + filtered_groups_for(all_category_groups) { |budget_category| budget_category.visible_on_track? } + else + all_category_groups + end + + show_on_track_uncategorized = all_category_groups.any? && (!budget.initialized? || uncategorized_budget_category.on_track?) + on_track_count = visible_count_for(on_track_groups) { |budget_category| parent_visible_for_on_track?(budget, budget_category) } + on_track_count += 1 if show_on_track_uncategorized + visible_expenses_empty = on_track_count.zero? + + { + uncategorized_budget_category: uncategorized_budget_category, + visible_expenses_empty: visible_expenses_empty, + over_budget_groups: over_budget_groups, + show_over_budget_uncategorized: show_over_budget_uncategorized, + over_budget_count: over_budget_count, + on_track_groups: on_track_groups, + show_on_track_uncategorized: show_on_track_uncategorized, + on_track_count: on_track_count + } + end + + def parent_visible_for_on_track?(budget, budget_category) + budget.initialized? ? budget_category.visible_on_track? : true + end + + def filtered_groups_for(groups) + groups.each_with_object([]) do |group, filtered_groups| + visible_subcategories = group.budget_subcategories.select { |budget_category| yield(budget_category) } + next unless yield(group.budget_category) || visible_subcategories.any? + + filtered_groups << BudgetCategory::Group.new(group.budget_category, visible_subcategories) + end + end + + def visible_count_for(groups) + groups.sum do |group| + (yield(group.budget_category) ? 1 : 0) + group.budget_subcategories.count + end + end +end diff --git a/app/javascript/controllers/budget_filter_controller.js b/app/javascript/controllers/budget_filter_controller.js new file mode 100644 index 000000000..a6337c9fe --- /dev/null +++ b/app/javascript/controllers/budget_filter_controller.js @@ -0,0 +1,57 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["onTrack", "overBudget", "tab"]; + static values = { filter: { type: String, default: "all" } }; + + connect() { + const filterParam = new URLSearchParams(window.location.search).get("filter"); + + if (this.#isValidFilter(filterParam) && filterParam !== this.filterValue) { + this.filterValue = filterParam; + } else if (filterParam && !this.#isValidFilter(filterParam)) { + this.#syncFilterParam(); + } + } + + setFilter(event) { + this.filterValue = event.params.filter; + this.#syncFilterParam(); + } + + filterValueChanged() { + const filter = this.filterValue; + + if (this.hasOnTrackTarget) { + this.onTrackTarget.hidden = filter === "over_budget"; + } + + if (this.hasOverBudgetTarget) { + this.overBudgetTarget.hidden = filter === "on_track"; + } + + this.tabTargets.forEach((tab) => { + const isActive = tab.dataset.budgetFilterFilterParam === filter; + tab.classList.toggle("bg-container", isActive); + tab.classList.toggle("text-primary", isActive); + tab.classList.toggle("shadow-sm", isActive); + tab.classList.toggle("text-secondary", !isActive); + }); + } + + #isValidFilter(filter) { + return ["all", "over_budget", "on_track"].includes(filter); + } + + #syncFilterParam() { + const url = new URL(window.location.href); + + if (this.filterValue === "all") { + url.searchParams.delete("filter"); + } else { + url.searchParams.set("filter", this.filterValue); + } + + window.history.replaceState({}, "", url); + } +} diff --git a/app/models/budget_category.rb b/app/models/budget_category.rb index 764e3d744..312db666a 100644 --- a/app/models/budget_category.rb +++ b/app/models/budget_category.rb @@ -162,6 +162,35 @@ class BudgetCategory < ApplicationRecord available_to_spend.negative? end + def budgeted? + display_budgeted_spending.to_d.positive? + end + + def unbudgeted_with_spending? + !budgeted? && actual_spending.to_d.positive? + end + + def over_budget_with_budget? + budgeted? && over_budget? + end + + def on_track? + budgeted? && !over_budget? + end + + def any_over_budget? + unbudgeted_with_spending? || over_budget_with_budget? + end + + def visible_on_track? + return false unless on_track? + + # Subcategories inheriting parent budget are hidden until they have spending. + return true unless subcategory? && inherits_parent_budget? + + actual_spending.to_d.positive? + end + def near_limit? !over_budget? && percent_of_budget_spent >= 90 end diff --git a/app/views/budget_categories/_budget_category.html.erb b/app/views/budget_categories/_budget_category.html.erb index 8597a1b93..fc93282af 100644 --- a/app/views/budget_categories/_budget_category.html.erb +++ b/app/views/budget_categories/_budget_category.html.erb @@ -1,4 +1,4 @@ -<%# locals: (budget_category:) %> +<%# locals: (budget_category:, show_budget_meta: true) %> <%= turbo_frame_tag dom_id(budget_category), class: "flex-1 min-w-0 block" do %> <%= link_to budget_budget_category_path(budget_category.budget, budget_category), class: "group block w-full px-4 py-2 bg-container", data: { turbo_frame: "drawer" } do %> @@ -49,7 +49,7 @@
<% bar_color = budget_category.over_budget? ? "bg-red-500" : (budget_category.near_limit? ? "bg-yellow-500" : "bg-green-500") %>
+ style="inline-size: <%= budget_category.bar_width_percent %>%">
@@ -61,6 +61,7 @@ <%= format_money(budget_category.actual_spending_money) %> + <% if show_budget_meta %>
<%= t("reports.budget_performance.budgeted") %>: @@ -70,6 +71,16 @@ <%= t("reports.budget_performance.shared") %> <% end %>
+ <% if budget_category.suggested_daily_spending.present? %> + <% daily_info = budget_category.suggested_daily_spending %> +
+ <%= t("reports.budget_performance.suggested_daily", + amount: daily_info[:amount].format, + days: daily_info[:days_remaining]) + %> +
+ <% end %> + <% end %>
<% if budget_category.available_to_spend >= 0 %> <%= t("reports.budget_performance.remaining") %>: @@ -85,18 +96,6 @@
- <%# Suggested Daily Limit (if remaining days in month) %> - <% if budget_category.suggested_daily_spending.present? %> - <% daily_info = budget_category.suggested_daily_spending %> -
-

- <%= t("reports.budget_performance.suggested_daily", - amount: daily_info[:amount].format, - days: daily_info[:days_remaining]) %> -

-
- <% end %> - <% else %> <%# Uninitialized budget - show simple view %>
diff --git a/app/views/budgets/_budget_categories.html.erb b/app/views/budgets/_budget_categories.html.erb index 46824bac3..addb349f3 100644 --- a/app/views/budgets/_budget_categories.html.erb +++ b/app/views/budgets/_budget_categories.html.erb @@ -1,44 +1,34 @@ <%# locals: (budget:) %> -
-
-

Categories

- · -

<%= budget.budget_categories.count %>

+<% categories_state = budget_categories_view_state(budget) %> +<% uncategorized_budget_category = categories_state[:uncategorized_budget_category] %> +<% visible_expenses_empty = categories_state[:visible_expenses_empty] %> +<% over_budget_groups = categories_state[:over_budget_groups] %> +<% show_over_budget_uncategorized = categories_state[:show_over_budget_uncategorized] %> +<% over_budget_count = categories_state[:over_budget_count] %> +<% on_track_groups = categories_state[:on_track_groups] %> +<% show_on_track_uncategorized = categories_state[:show_on_track_uncategorized] %> +<% on_track_count = categories_state[:on_track_count] %> -

Amount

-
+
-
- <% if budget.family.categories.expenses.empty? %> -
- <%= render "budget_categories/no_categories" %> -
- <% else %> - <% category_groups = BudgetCategory::Group.for(budget.budget_categories) %> + <% if over_budget_count.positive? %> + <%= render "budgets/category_section", + budget: budget, + count: over_budget_count, + groups: over_budget_groups, + uncategorized: uncategorized_budget_category, + show_uncategorized: show_over_budget_uncategorized, + over_budget_mode: true %> + <% end %> - <% category_groups.each_with_index do |group, index| %> -
- <%= render "budget_categories/budget_category", budget_category: group.budget_category %> + <%= render "budgets/category_section", + budget: budget, + count: on_track_count, + groups: on_track_groups, + uncategorized: uncategorized_budget_category, + show_uncategorized: show_on_track_uncategorized, + over_budget_mode: false + %> -
- <% group.budget_subcategories.each do |budget_subcategory| %> -
-
- <%= icon "corner-down-right" %> -
- - <%= render "budget_categories/budget_category", budget_category: budget_subcategory %> -
- <% end %> -
-
- - <%= render "shared/ruler" %> - <% end %> -
- <%= render "budget_categories/budget_category", budget_category: budget.uncategorized_budget_category %> -
- <% end %> -
-
+
\ No newline at end of file diff --git a/app/views/budgets/_category_group.html.erb b/app/views/budgets/_category_group.html.erb new file mode 100644 index 000000000..7ef0ad347 --- /dev/null +++ b/app/views/budgets/_category_group.html.erb @@ -0,0 +1,28 @@ +<%# locals: (group:, parent_visible:, over_budget_mode: false) %> + +<% if parent_visible %> +
+ <%= render "budget_categories/budget_category", + budget_category: group.budget_category, + show_budget_meta: (over_budget_mode ? group.budget_category.over_budget_with_budget? : true) %> +
+<% end %> + +<% group.budget_subcategories.each do |budget_subcategory| %> + <% if parent_visible %> +
+
+ <%= icon "corner-down-right" %> +
+ <%= render "budget_categories/budget_category", + budget_category: budget_subcategory, + show_budget_meta: (over_budget_mode ? budget_subcategory.over_budget_with_budget? : true) %> +
+ <% else %> +
+ <%= render "budget_categories/budget_category", + budget_category: budget_subcategory, + show_budget_meta: (over_budget_mode ? budget_subcategory.over_budget_with_budget? : true) %> +
+ <% end %> +<% end %> diff --git a/app/views/budgets/_category_section.html.erb b/app/views/budgets/_category_section.html.erb new file mode 100644 index 000000000..ee6602a3a --- /dev/null +++ b/app/views/budgets/_category_section.html.erb @@ -0,0 +1,56 @@ +<%# locals: (budget:, count:, groups:, uncategorized:, show_uncategorized:, over_budget_mode:) %> + +<%# derive display config from over_budget_mode %> +<% + if over_budget_mode + target = "overBudget" + title = t("budgets.show.over_budget_categories.short_title") + else + target = "onTrack" + title = t("budgets.show.on_track_categories.short_title") + end +%> + +
+ + +
+

<%= title %>

+ · +

<%= count %>

+

<%= t("budgets.show.categories.amount") %>

+
+ + +
+ + <% groups.each_with_index do |group, index| %> + + <%# derive parent visibility based on mode %> + <% + parent_visible = + if over_budget_mode + group.budget_category.any_over_budget? + else + budget.initialized? ? group.budget_category.visible_on_track? : true + end + %> + <%= render "shared/ruler" unless index == 0 %> + <%= render "budgets/category_group", + group: group, + parent_visible: parent_visible, + over_budget_mode: over_budget_mode %> + + <% end %> + + <% if show_uncategorized %> + <%= render "shared/ruler" unless groups.size == 0 %> +
+ <%= render "budget_categories/budget_category", + budget_category: uncategorized, + show_budget_meta: (over_budget_mode ? uncategorized.over_budget_with_budget? : true) %> +
+ <% end %> + +
+
\ No newline at end of file diff --git a/app/views/budgets/show.html.erb b/app/views/budgets/show.html.erb index 768cf7727..ce7e21b0b 100644 --- a/app/views/budgets/show.html.erb +++ b/app/views/budgets/show.html.erb @@ -47,23 +47,66 @@
<%# Bottom Section: Categories full width %> -
-
-

Categories

+ <% has_over_budget = budget_has_over_budget?(@budget) %> + <%= content_tag :div, + class: "w-full bg-container rounded-xl shadow-border-xs p-4", + data: (has_over_budget ? { controller: "budget-filter" } : {}) do %> +
+

+ <%= t("budgets.show.categories.title") %> +

- <% if @budget.initialized? %> - <%= render DS::Link.new( - text: "Edit", - variant: "secondary", - icon: "settings-2", - href: budget_budget_categories_path(@budget) - ) %> + <% if has_over_budget %> +
+
+
+
+ + + + + + + +
+
+
+
<% end %> + +
"> + <% if @budget.initialized? %> + <%= render DS::Link.new( + text: t("budgets.show.categories.edit"), + variant: "secondary", + icon: "settings-2", + href: budget_budget_categories_path(@budget) + ) %> + <% end %> +
<%= render "budgets/budget_categories", budget: @budget %>
-
+ <% end %>
diff --git a/config/locales/views/budgets/ca.yml b/config/locales/views/budgets/ca.yml index dd581e49c..6f3d53e3d 100644 --- a/config/locales/views/budgets/ca.yml +++ b/config/locales/views/budgets/ca.yml @@ -2,6 +2,20 @@ ca: budgets: show: + categories: + amount: Quantitat + edit: Edita + title: Categories + on_track_categories: + short_title: En bon cami + title: En bon cami + over_budget_categories: + short_title: Sobre pressupost + title: Sobre pressupost + filter: + all: Totes + on_track: En bon cami + over_budget: Sobre pressupost tabs: actual: Real budgeted: Pressupostat diff --git a/config/locales/views/budgets/de.yml b/config/locales/views/budgets/de.yml index 00ac0252b..c02980333 100644 --- a/config/locales/views/budgets/de.yml +++ b/config/locales/views/budgets/de.yml @@ -5,6 +5,20 @@ de: custom_range: "%{start} - %{end_date}" month_year: "%{month}" show: + categories: + amount: Betrag + edit: Bearbeiten + title: Kategorien + on_track_categories: + short_title: Im Plan + title: Im Plan + over_budget_categories: + short_title: Uber Budget + title: Uber Budget + filter: + all: Alle + on_track: Im Plan + over_budget: Uber Budget tabs: actual: Ist budgeted: Budgetiert diff --git a/config/locales/views/budgets/en.yml b/config/locales/views/budgets/en.yml index c727dc37c..fb06af681 100644 --- a/config/locales/views/budgets/en.yml +++ b/config/locales/views/budgets/en.yml @@ -5,6 +5,20 @@ en: custom_range: "%{start} - %{end_date}" month_year: "%{month}" show: + categories: + amount: Amount + edit: Edit + title: Categories + on_track_categories: + short_title: On Track + title: On Track + over_budget_categories: + short_title: Over Budget + title: Over Budget + filter: + all: All + on_track: On Track + over_budget: Over Budget tabs: actual: Actual budgeted: Budgeted diff --git a/config/locales/views/budgets/es.yml b/config/locales/views/budgets/es.yml index 87bc1649b..4f87cff2c 100644 --- a/config/locales/views/budgets/es.yml +++ b/config/locales/views/budgets/es.yml @@ -5,6 +5,20 @@ es: custom_range: "%{start} - %{end_date}" month_year: "%{month}" show: + categories: + amount: Importe + edit: Editar + title: Categorías + on_track_categories: + short_title: En camino + title: En camino + over_budget_categories: + short_title: Sobre presupuesto + title: Sobre presupuesto + filter: + all: Todas + on_track: En camino + over_budget: Sobre presupuesto tabs: actual: Real budgeted: Presupuestado \ No newline at end of file diff --git a/config/locales/views/budgets/nl.yml b/config/locales/views/budgets/nl.yml index f7ba1833d..64265428d 100644 --- a/config/locales/views/budgets/nl.yml +++ b/config/locales/views/budgets/nl.yml @@ -1,6 +1,20 @@ nl: budgets: show: + categories: + amount: Bedrag + edit: Bewerken + title: Categorieën + on_track_categories: + short_title: Op schema + title: Op schema + over_budget_categories: + short_title: Over budget + title: Over budget + filter: + all: Alles + on_track: Op schema + over_budget: Over budget tabs: actual: "Actueel" budgeted: "Begroot" diff --git a/config/locales/views/budgets/zh-CN.yml b/config/locales/views/budgets/zh-CN.yml index 37115d450..e111d4d91 100644 --- a/config/locales/views/budgets/zh-CN.yml +++ b/config/locales/views/budgets/zh-CN.yml @@ -2,6 +2,20 @@ zh-CN: budgets: show: + categories: + amount: 金额 + edit: 编辑 + title: 分类 + on_track_categories: + short_title: 正常 + title: 正常 + over_budget_categories: + short_title: 超预算 + title: 超预算 + filter: + all: 全部 + on_track: 正常 + over_budget: 超预算 tabs: actual: 实际 budgeted: 预算 diff --git a/config/locales/views/budgets/zh-TW.yml b/config/locales/views/budgets/zh-TW.yml index e35d802ce..bca3d0cb9 100644 --- a/config/locales/views/budgets/zh-TW.yml +++ b/config/locales/views/budgets/zh-TW.yml @@ -2,6 +2,20 @@ zh-TW: budgets: show: + categories: + amount: 金額 + edit: 編輯 + title: 分類 + on_track_categories: + short_title: 正常 + title: 正常 + over_budget_categories: + short_title: 超預算 + title: 超預算 + filter: + all: 全部 + on_track: 正常 + over_budget: 超預算 tabs: actual: 實際 budgeted: 預算 diff --git a/test/helpers/budgets_helper_test.rb b/test/helpers/budgets_helper_test.rb new file mode 100644 index 000000000..4c94f627e --- /dev/null +++ b/test/helpers/budgets_helper_test.rb @@ -0,0 +1,107 @@ +require "test_helper" + +class BudgetsHelperTest < ActionView::TestCase + setup do + @family = families(:dylan_family) + @budget = budgets(:one) + + @parent_category = Category.create!( + name: "Helper Parent #{SecureRandom.hex(4)}", + family: @family, + color: "#4da568", + lucide_icon: "utensils" + ) + + @child_category = Category.create!( + name: "Helper Child #{SecureRandom.hex(4)}", + parent: @parent_category, + family: @family + ) + + @parent_budget_category = BudgetCategory.create!( + budget: @budget, + category: @parent_category, + budgeted_spending: 200, + currency: "USD" + ) + + @child_budget_category = BudgetCategory.create!( + budget: @budget, + category: @child_category, + budgeted_spending: 0, + currency: "USD" + ) + end + + test "hides inheriting subcategory with no budget and no spending from on-track section" do + state = budget_categories_view_state(@budget) + group = state[:on_track_groups].find { |g| g.budget_category.id == @parent_budget_category.id } + + assert group.present? + assert_empty group.budget_subcategories + end + + test "shows inheriting subcategory in on-track section when it has spending" do + Entry.create!( + account: accounts(:depository), + entryable: Transaction.create!(category: @child_category), + date: Date.current, + name: "Helper Child Spending", + amount: 25, + currency: "USD" + ) + + budget = Budget.find(@budget.id) + state = budget_categories_view_state(budget) + group = state[:on_track_groups].find { |g| g.budget_category.category_id == @parent_category.id } + + assert group.present? + assert_includes group.budget_subcategories.map(&:category_id), @child_category.id + end + + test "keeps group when only subcategory is over budget" do + parent = Category.create!( + name: "Helper Group Parent #{SecureRandom.hex(4)}", + family: @family, + color: "#22c55e", + lucide_icon: "utensils" + ) + + child = Category.create!( + name: "Helper Group Child #{SecureRandom.hex(4)}", + parent: parent, + family: @family + ) + + BudgetCategory.create!( + budget: @budget, + category: parent, + budgeted_spending: 300, + currency: "USD" + ) + + BudgetCategory.create!( + budget: @budget, + category: child, + budgeted_spending: 50, + currency: "USD" + ) + + Entry.create!( + account: accounts(:depository), + entryable: Transaction.create!(category: child), + date: Date.current, + name: "Helper Child Over Budget", + amount: 100, + currency: "USD" + ) + + state = budget_categories_view_state(Budget.find(@budget.id)) + group = state[:over_budget_groups].find { |g| g.budget_category.category_id == parent.id } + + assert group.present? + refute group.budget_category.any_over_budget? + assert_equal [ child.id ], group.budget_subcategories.map(&:category_id) + assert group.budget_subcategories.first.any_over_budget? + end +end diff --git a/test/models/budget_category_test.rb b/test/models/budget_category_test.rb index d0f0b71a6..d6408c530 100644 --- a/test/models/budget_category_test.rb +++ b/test/models/budget_category_test.rb @@ -169,4 +169,90 @@ class BudgetCategoryTest < ActiveSupport::TestCase assert_equal 20, @subcategory_with_limit_bc.reload.budgeted_spending assert_equal 50, @subcategory_inheriting_bc.reload.budgeted_spending end + + test "budgeted? returns true only when display_budgeted_spending > 0" do + @subcategory_with_limit_bc.stubs(:display_budgeted_spending).returns(100) + assert @subcategory_with_limit_bc.budgeted? + + @subcategory_with_limit_bc.stubs(:display_budgeted_spending).returns(0) + refute @subcategory_with_limit_bc.budgeted? + + @subcategory_with_limit_bc.stubs(:display_budgeted_spending).returns(nil) + refute @subcategory_with_limit_bc.budgeted? + end + + test "unbudgeted_with_spending? is true only when not budgeted and has spending" do + @subcategory_with_limit_bc.stubs(:budgeted?).returns(false) + @subcategory_with_limit_bc.stubs(:actual_spending).returns(10) + assert @subcategory_with_limit_bc.unbudgeted_with_spending? + + @subcategory_with_limit_bc.stubs(:budgeted?).returns(true) + assert_not @subcategory_with_limit_bc.unbudgeted_with_spending? + + @subcategory_with_limit_bc.stubs(:budgeted?).returns(false) + @subcategory_with_limit_bc.stubs(:actual_spending).returns(0) + assert_not @subcategory_with_limit_bc.unbudgeted_with_spending? + + @subcategory_with_limit_bc.stubs(:actual_spending).returns(nil) + assert_not @subcategory_with_limit_bc.unbudgeted_with_spending? + end + + test "over_budget_with_budget? requires both budgeted and over_budget" do + @subcategory_with_limit_bc.stubs(:budgeted?).returns(true) + @subcategory_with_limit_bc.stubs(:over_budget?).returns(true) + assert @subcategory_with_limit_bc.over_budget_with_budget? + + @subcategory_with_limit_bc.stubs(:over_budget?).returns(false) + assert_not @subcategory_with_limit_bc.over_budget_with_budget? + + @subcategory_with_limit_bc.stubs(:budgeted?).returns(false) + @subcategory_with_limit_bc.stubs(:over_budget?).returns(true) + assert_not @subcategory_with_limit_bc.over_budget_with_budget? + end + + test "on_track? is true only when budgeted and not over_budget" do + @subcategory_with_limit_bc.stubs(:budgeted?).returns(true) + @subcategory_with_limit_bc.stubs(:over_budget?).returns(false) + assert @subcategory_with_limit_bc.on_track? + + @subcategory_with_limit_bc.stubs(:over_budget?).returns(true) + assert_not @subcategory_with_limit_bc.on_track? + + @subcategory_with_limit_bc.stubs(:budgeted?).returns(false) + @subcategory_with_limit_bc.stubs(:over_budget?).returns(false) + assert_not @subcategory_with_limit_bc.on_track? + end + + test "any_over_budget? is true if either condition is true" do + @subcategory_with_limit_bc.stubs(:unbudgeted_with_spending?).returns(true) + @subcategory_with_limit_bc.stubs(:over_budget_with_budget?).returns(false) + assert @subcategory_with_limit_bc.any_over_budget? + + @subcategory_with_limit_bc.stubs(:unbudgeted_with_spending?).returns(false) + @subcategory_with_limit_bc.stubs(:over_budget_with_budget?).returns(true) + assert @subcategory_with_limit_bc.any_over_budget? + + @subcategory_with_limit_bc.stubs(:unbudgeted_with_spending?).returns(false) + @subcategory_with_limit_bc.stubs(:over_budget_with_budget?).returns(false) + assert_not @subcategory_with_limit_bc.any_over_budget? + end + + test "visible_on_track? behavior for different category types" do + # 1. not on_track => always false + @subcategory_with_limit_bc.stubs(:on_track?).returns(false) + assert_not @subcategory_with_limit_bc.visible_on_track? + + # 2. normal category (not subcategory) => true if on_track + @parent_budget_category.stubs(:on_track?).returns(true) + assert @parent_budget_category.visible_on_track? + + # 3. subcategory inheriting, no spending => hidden + @subcategory_inheriting_bc.stubs(:on_track?).returns(true) + @subcategory_inheriting_bc.stubs(:actual_spending).returns(0) + assert_not @subcategory_inheriting_bc.visible_on_track? + + # 4. subcategory inheriting, has spending => visible + @subcategory_inheriting_bc.stubs(:actual_spending).returns(10) + assert @subcategory_inheriting_bc.visible_on_track? + end end