mirror of
https://github.com/we-promise/sure.git
synced 2026-09-03 05:41:18 +00:00
* fix(transactions): don't crash the rule-prompt flash when clearing a category needs_rule_notification? only checked saved_change_to_category_id? and eligible_for_category_rule?, neither of which accounts for category_id being nil. Clearing a category (Clear category / entryable_attributes category_id: nil) satisfies both, so the caller went on to read transaction.category.name against a nil category and crashed. A rule prompt only makes sense when a category was assigned, not cleared, so bail out early when there's no category to build a rule around. * feat(transactions): surface recently-used categories in the category picker Reframes the recency-vs-muscle-memory question as additive, not either/or: a small "Recent" section pinned above the existing alphabetical list, which stays exactly where it always was below it. Precedent for reordering the primary list by frequency (Office's old adaptive menus, browser-history-style resorting) is a well-known anti-pattern — position drifts under the user's hand. Every picker that does recency well (VS Code's command palette, Spotify, Slack's emoji picker) adds a small separate recent cluster instead. - Category#last_used_at, touched only in TransactionCategoriesController#update — the one place a category is actually hand-picked by a person, as opposed to a rule or import auto-assigning one. - Category.recently_used_for(family:, excluding:, limit:) batches the family-scoped query; dropdowns_controller excludes the already- selected category from the Recent section since it's already pinned to the top of the main list. - "Recent" hides itself the moment a search query is typed — it's a pre-search shortcut, not a second copy of search results. Its rows are force-hidden (not just filtered) so keyboard nav can't land on a row that's invisible only because its ancestor section is hidden. * fix(categories): address review feedback on recent-categories picker - Track last_used_at from every manual assignment path (transaction edit form, categorization wizard bulk-update, create-and-assign), not just the category-picker endpoint. Centralized as Transaction#record_category_usage!, called explicitly from each manual controller action rather than wired to a blanket after_save callback, since rule/import auto-assignment must not count as a "recent" pick. - Give recent-section rows a distinct DOM id (recent_category_option_<id>) from their canonical-list counterpart so aria-activedescendant can't resolve to a hidden duplicate during keyboard nav. - Fix migration to ActiveRecord::Migration[7.2] to match the rest of the repo. - Materialize @recent_categories with .to_a to avoid a redundant query.
191 lines
6.1 KiB
Ruby
191 lines
6.1 KiB
Ruby
require "test_helper"
|
|
|
|
class CategoryTest < ActiveSupport::TestCase
|
|
def setup
|
|
@family = families(:dylan_family)
|
|
end
|
|
|
|
test "replacing and destroying" do
|
|
transactions = categories(:food_and_drink).transactions.to_a
|
|
|
|
categories(:food_and_drink).replace_and_destroy!(categories(:income))
|
|
|
|
assert_equal categories(:income), transactions.map { |t| t.reload.category }.uniq.first
|
|
end
|
|
|
|
test "replacing with nil should nullify the category" do
|
|
transactions = categories(:food_and_drink).transactions.to_a
|
|
|
|
categories(:food_and_drink).replace_and_destroy!(nil)
|
|
|
|
assert_nil transactions.map { |t| t.reload.category }.uniq.first
|
|
end
|
|
|
|
test "destroying parent category preserves subcategory transaction assignments" do
|
|
parent = @family.categories.create!(
|
|
name: "Parent With Child Transactions",
|
|
color: "#000000",
|
|
lucide_icon: "folder"
|
|
)
|
|
subcategory = @family.categories.create!(
|
|
name: "Child With Transactions",
|
|
color: "#111111",
|
|
lucide_icon: "folder",
|
|
parent: parent
|
|
)
|
|
transaction = Transaction.create!(category: subcategory)
|
|
|
|
assert_difference "Category.count", -1 do
|
|
parent.destroy!
|
|
end
|
|
|
|
assert_nil subcategory.reload.parent_id
|
|
assert_equal subcategory, transaction.reload.category
|
|
end
|
|
|
|
test "invalid parent_id does not raise during validation" do
|
|
category = Category.new(
|
|
name: "Orphan Subcategory",
|
|
color: "#000000",
|
|
lucide_icon: "folder",
|
|
family: @family,
|
|
parent_id: SecureRandom.uuid
|
|
)
|
|
|
|
assert_nothing_raised { category.valid? }
|
|
assert_not category.subcategory?
|
|
assert_nil category.parent
|
|
end
|
|
|
|
test "subcategory can only be one level deep" do
|
|
category = categories(:subcategory)
|
|
|
|
error = assert_raises(ActiveRecord::RecordInvalid) do
|
|
category.subcategories.create!(name: "Invalid category", family: @family)
|
|
end
|
|
|
|
assert_equal "Validation failed: Parent can't have more than 2 levels of subcategories", error.message
|
|
end
|
|
|
|
test "all_investment_contributions_names returns all locale variants" do
|
|
names = Category.all_investment_contributions_names
|
|
|
|
assert_includes names, "Investment Contributions" # English
|
|
assert_includes names, "Contributions aux investissements" # French
|
|
assert_includes names, "Investeringsbijdragen" # Dutch
|
|
assert names.all? { |name| name.is_a?(String) }
|
|
assert_equal names, names.uniq # No duplicates
|
|
end
|
|
|
|
test "display_name localizes default category names" do
|
|
I18n.with_locale(:"zh-CN") do
|
|
assert_equal "餐饮", categories(:food_and_drink).display_name
|
|
assert_equal "未分类", Category.uncategorized.display_name
|
|
end
|
|
end
|
|
|
|
test "display_name returns default category names in english" do
|
|
I18n.with_locale(:en) do
|
|
assert_equal "Food & Drink", categories(:food_and_drink).display_name
|
|
assert_equal "Uncategorized", Category.uncategorized.display_name
|
|
end
|
|
end
|
|
|
|
test "display_name preserves custom category names" do
|
|
category = Category.new(name: "School Supplies", color: "#123456", lucide_icon: "book", family: @family)
|
|
|
|
I18n.with_locale(:"zh-CN") do
|
|
assert_equal "School Supplies", category.display_name
|
|
end
|
|
end
|
|
|
|
test "display_name_with_parent localizes default parent and child names" do
|
|
category = Category.new(
|
|
name: "Groceries",
|
|
color: "#123456",
|
|
lucide_icon: "shopping-bag",
|
|
family: @family,
|
|
parent: categories(:food_and_drink)
|
|
)
|
|
|
|
I18n.with_locale(:"zh-CN") do
|
|
assert_equal "餐饮 > 杂货", category.display_name_with_parent
|
|
end
|
|
end
|
|
|
|
test "display_name_with_parent preserves custom child names" do
|
|
category = Category.new(
|
|
name: "Coffee Beans",
|
|
color: "#123456",
|
|
lucide_icon: "coffee",
|
|
family: @family,
|
|
parent: categories(:food_and_drink)
|
|
)
|
|
|
|
I18n.with_locale(:"zh-CN") do
|
|
assert_equal "餐饮 > Coffee Beans", category.display_name_with_parent
|
|
end
|
|
end
|
|
|
|
test "should accept valid 6-digit hex colors" do
|
|
[ "#FFFFFF", "#000000", "#123456", "#ABCDEF", "#abcdef" ].each do |color|
|
|
category = Category.new(name: "Category #{color}", color: color, lucide_icon: "shapes", family: @family)
|
|
assert category.valid?, "#{color} should be valid"
|
|
end
|
|
end
|
|
|
|
test "should reject invalid colors" do
|
|
[ "invalid", "#123", "#1234567", "#GGGGGG", "red", "ffffff", "#ffff", "" ].each do |color|
|
|
category = Category.new(name: "Category #{color}", color: color, lucide_icon: "shapes", family: @family)
|
|
assert_not category.valid?, "#{color} should be invalid"
|
|
assert_includes category.errors[:color], "is invalid"
|
|
end
|
|
end
|
|
|
|
test "ids_with_transactions returns a lookup hash for categorized transactions" do
|
|
category = categories(:food_and_drink)
|
|
transaction = Transaction.create!(category: category)
|
|
Entry.create!(
|
|
account: accounts(:depository),
|
|
entryable: transaction,
|
|
name: "Lookup transaction",
|
|
date: Date.current,
|
|
amount: 10,
|
|
currency: "USD"
|
|
)
|
|
|
|
lookup = Category.ids_with_transactions(family: @family, category_ids: [ category.id, 0 ])
|
|
|
|
assert lookup.key?(category.id)
|
|
assert_not lookup.key?(0)
|
|
end
|
|
|
|
test "recently_used_for orders by last_used_at, most recent first" do
|
|
older = categories(:income)
|
|
newer = categories(:food_and_drink)
|
|
older.update!(last_used_at: 2.days.ago)
|
|
newer.update!(last_used_at: 1.day.ago)
|
|
|
|
assert_equal [ newer, older ], Category.recently_used_for(family: @family).to_a
|
|
end
|
|
|
|
test "recently_used_for excludes categories with no usage yet" do
|
|
categories(:food_and_drink).update!(last_used_at: 1.day.ago)
|
|
|
|
assert_not_includes Category.recently_used_for(family: @family).to_a, categories(:income)
|
|
end
|
|
|
|
test "recently_used_for excludes given categories and respects limit" do
|
|
a = categories(:income)
|
|
b = categories(:food_and_drink)
|
|
c = categories(:subcategory)
|
|
a.update!(last_used_at: 3.days.ago)
|
|
b.update!(last_used_at: 2.days.ago)
|
|
c.update!(last_used_at: 1.day.ago)
|
|
|
|
result = Category.recently_used_for(family: @family, excluding: b, limit: 1)
|
|
|
|
assert_equal [ c ], result.to_a
|
|
end
|
|
end
|