diff --git a/spec/helpers/outfits_helper_spec.rb b/spec/helpers/outfits_helper_spec.rb new file mode 100644 index 00000000..f7faa2fa --- /dev/null +++ b/spec/helpers/outfits_helper_spec.rb @@ -0,0 +1,310 @@ +require_relative '../rails_helper' + +RSpec.describe OutfitsHelper, type: :helper do + fixtures :zones, :colors, :species, :pet_types + + # Use the Blue Acara's body_id throughout tests + let(:body_id) { pet_types(:blue_acara).body_id } + + # Helper to create a test outfit with a pet type + # Biology assets are just setup noise - we only care about pet_type.body_id + def create_test_outfit + pet_type = pet_types(:blue_acara) + + # PetState requires at least one biology asset (validation requirement) + bio_asset = SwfAsset.create!( + type: "biology", + remote_id: (@bio_remote_id = (@bio_remote_id || 1000) + 1), + url: "https://images.neopets.example/bio_#{@bio_remote_id}.swf", + zone: zones(:body), + zones_restrict: "", + body_id: 0 + ) + + pet_state = PetState.create!( + pet_type: pet_type, + pose: "HAPPY_MASC", + swf_assets: [bio_asset], + swf_asset_ids: [bio_asset.id] + ) + + Outfit.create!(pet_state: pet_state) + end + + # Helper to create SwfAssets for items (matches pattern from item_spec.rb) + def build_item_asset(zone, body_id:) + @item_remote_id = (@item_remote_id || 0) + 1 + SwfAsset.create!( + type: "object", + remote_id: @item_remote_id, + url: "https://images.neopets.example/item_#{@item_remote_id}.swf", + zone: zone, + zones_restrict: "", + body_id: body_id + ) + end + + # Helper to create an item with zones + def create_item(name, zones_and_bodies) + item = Item.create!( + name: name, + description: "", + thumbnail_url: "https://images.neopets.example/#{name.parameterize}.gif", + rarity: "Common", + price: 100, + zones_restrict: "0" * 52 + ) + + zones_and_bodies.each do |zone, body_id| + item.swf_assets << build_item_asset(zone, body_id: body_id) + end + + item + end + + describe '#outfit_items_by_zone' do + context 'with nil pet_type' do + it 'returns empty array' do + # Create an outfit without a pet_state (pet_type will be nil) + outfit = Outfit.new + # Allow the delegation to fail gracefully + allow(outfit).to receive(:pet_type).and_return(nil) + + result = helper.outfit_items_by_zone(outfit) + expect(result).to eq([]) + end + end + + context 'with empty outfit' do + it 'returns empty array' do + outfit = create_test_outfit + result = helper.outfit_items_by_zone(outfit) + expect(result).to eq([]) + end + end + + context 'with single-zone items' do + let(:outfit) { create_test_outfit } + let!(:hat_item) { create_item("Blue Hat", [[zones(:hat), body_id]]) } + let!(:jacket_item) { create_item("Red Jacket", [[zones(:jacket), body_id]]) } + + before do + outfit.worn_items << hat_item + outfit.worn_items << jacket_item + end + + it 'groups items by zone' do + result = helper.outfit_items_by_zone(outfit) + + expect(result.length).to eq(2) + zone_labels = result.map { |g| g[:zone_label] } + expect(zone_labels).to contain_exactly("Hat", "Jacket") + end + + it 'sorts zones alphabetically' do + result = helper.outfit_items_by_zone(outfit) + + zone_labels = result.map { |g| g[:zone_label] } + expect(zone_labels).to eq(["Hat", "Jacket"]) + end + + it 'includes items in their respective zones' do + result = helper.outfit_items_by_zone(outfit) + + hat_group = result.find { |g| g[:zone_label] == "Hat" } + jacket_group = result.find { |g| g[:zone_label] == "Jacket" } + + expect(hat_group[:items]).to eq([hat_item]) + expect(jacket_group[:items]).to eq([jacket_item]) + end + end + + context 'with multiple items in same zone' do + let(:outfit) { create_test_outfit } + let!(:hat1) { create_item("Awesome Hat", [[zones(:hat), body_id]]) } + let!(:hat2) { create_item("Cool Hat", [[zones(:hat), body_id]]) } + let!(:hat3) { create_item("Blue Hat", [[zones(:hat), body_id]]) } + + before do + outfit.worn_items << hat1 + outfit.worn_items << hat2 + outfit.worn_items << hat3 + end + + it 'sorts items alphabetically within zone' do + result = helper.outfit_items_by_zone(outfit) + + hat_group = result.find { |g| g[:zone_label] == "Hat" } + item_names = hat_group[:items].map(&:name) + + expect(item_names).to eq(["Awesome Hat", "Blue Hat", "Cool Hat"]) + end + end + + context 'with multi-zone item (no conflicts)' do + let(:outfit) { create_test_outfit } + let!(:bow_tie) do + create_item("Bow Tie", [ + [zones(:collar), body_id], + [zones(:necklace), body_id], + [zones(:earrings), body_id] + ]) + end + + before do + outfit.worn_items << bow_tie + end + + it 'shows item in only one zone (simplification)' do + result = helper.outfit_items_by_zone(outfit) + + # Should show in Collar zone only (first alphabetically) + expect(result.length).to eq(1) + expect(result[0][:zone_label]).to eq("Collar") + expect(result[0][:items]).to eq([bow_tie]) + end + end + + context 'with multi-zone simplification (item appears in conflict zone)' do + let(:outfit) { create_test_outfit } + let!(:multi_zone_item) do + create_item("Fancy Outfit", [ + [zones(:jacket), body_id], + [zones(:collar), body_id] + ]) + end + let!(:collar_item) { create_item("Simple Collar", [[zones(:collar), body_id]]) } + + before do + outfit.worn_items << multi_zone_item + outfit.worn_items << collar_item + end + + it 'keeps conflict zone and hides redundant single-item zone' do + result = helper.outfit_items_by_zone(outfit) + + zone_labels = result.map { |g| g[:zone_label] } + + # Should show Collar (has conflict with 2 items) + # Should NOT show Jacket (redundant - item already in Collar) + expect(zone_labels).to eq(["Collar"]) + + collar_group = result.find { |g| g[:zone_label] == "Collar" } + item_names = collar_group[:items].map(&:name).sort + expect(item_names).to eq(["Fancy Outfit", "Simple Collar"]) + end + end + + context 'with zone label disambiguation' do + let(:outfit) { create_test_outfit } + + # Create additional zones with duplicate labels for this test + let!(:markings_zone_a) do + Zone.create!(label: "Markings", depth: 100, plain_label: "markings_a", type_id: 2) + end + let!(:markings_zone_b) do + Zone.create!(label: "Markings", depth: 101, plain_label: "markings_b", type_id: 2) + end + + let!(:item_zone_a) { create_item("Tattoo A", [[markings_zone_a, body_id]]) } + let!(:item_zone_b) { create_item("Tattoo B", [[markings_zone_b, body_id]]) } + let!(:item_zone_a_b) { create_item("Tattoo C", [[markings_zone_a, body_id]]) } + + before do + outfit.worn_items << item_zone_a + outfit.worn_items << item_zone_b + outfit.worn_items << item_zone_a_b + end + + it 'adds zone IDs to duplicate labels' do + result = helper.outfit_items_by_zone(outfit) + + zone_labels = result.map { |g| g[:zone_label] } + + # Both should have IDs appended since they share the label "Markings" + expect(zone_labels).to contain_exactly( + "Markings (##{markings_zone_a.id})", + "Markings (##{markings_zone_b.id})" + ) + end + + it 'groups items correctly by zone despite same label' do + result = helper.outfit_items_by_zone(outfit) + + zone_a_group = result.find { |g| g[:zone_label] == "Markings (##{markings_zone_a.id})" } + zone_b_group = result.find { |g| g[:zone_label] == "Markings (##{markings_zone_b.id})" } + + expect(zone_a_group[:items].map(&:name).sort).to eq(["Tattoo A", "Tattoo C"]) + expect(zone_b_group[:items].map(&:name)).to eq(["Tattoo B"]) + end + end + + context 'with incompatible items' do + let(:outfit) { create_test_outfit } + let!(:compatible_item) { create_item("Fits Pet", [[zones(:hat), body_id]]) } + let!(:incompatible_item) { create_item("Wrong Body", [[zones(:jacket), 999]]) } + + before do + outfit.worn_items << compatible_item + outfit.worn_items << incompatible_item + end + + it 'separates incompatible items into their own section' do + result = helper.outfit_items_by_zone(outfit) + + zone_labels = result.map { |g| g[:zone_label] } + expect(zone_labels).to contain_exactly("Hat", "Incompatible") + + incompatible_group = result.find { |g| g[:zone_label] == "Incompatible" } + expect(incompatible_group[:items]).to eq([incompatible_item]) + end + + it 'sorts incompatible items alphabetically' do + outfit.worn_items << create_item("Alpha Item", [[zones(:jacket), 999]]) + outfit.worn_items << create_item("Zulu Item", [[zones(:jacket), 999]]) + + result = helper.outfit_items_by_zone(outfit) + incompatible_group = result.find { |g| g[:zone_label] == "Incompatible" } + + item_names = incompatible_group[:items].map(&:name) + expect(item_names).to eq(["Alpha Item", "Wrong Body", "Zulu Item"]) + end + end + + context 'with complex multi-zone scenario' do + let(:outfit) { create_test_outfit } + let!(:bg1) { create_item("Forest Background", [[zones(:background), 0]]) } + let!(:bg2) { create_item("Beach Background", [[zones(:background), 0]]) } + let!(:multi_item) do + create_item("Wings and Hat", [ + [zones(:wings), 0], + [zones(:hat), 0] + ]) + end + let!(:hat_item) { create_item("Simple Hat", [[zones(:hat), 0]]) } + + before do + outfit.worn_items << bg1 + outfit.worn_items << bg2 + outfit.worn_items << multi_item + outfit.worn_items << hat_item + end + + it 'correctly applies all sorting and grouping rules' do + result = helper.outfit_items_by_zone(outfit) + + # Background: has conflict (2 items) + # Hat: has conflict (2 items, including multi-zone item) + # Wings: should be hidden (multi-zone item already in Hat conflict) + zone_labels = result.map { |g| g[:zone_label] } + expect(zone_labels).to eq(["Background", "Hat"]) + + bg_group = result.find { |g| g[:zone_label] == "Background" } + expect(bg_group[:items].map(&:name)).to eq(["Beach Background", "Forest Background"]) + + hat_group = result.find { |g| g[:zone_label] == "Hat" } + expect(hat_group[:items].map(&:name)).to eq(["Simple Hat", "Wings and Hat"]) + end + end + end +end