require_relative '../rails_helper' RSpec.describe Outfit do fixtures :colors, :species, :zones let(:blue) { colors(:blue) } let(:acara) { species(:acara) } before do PetType.destroy_all @pet_type = PetType.create!(color: blue, species: acara, body_id: 1) @pet_state = create_pet_state(@pet_type, "HAPPY_MASC") @outfit = Outfit.new(pet_state: @pet_state) end def create_pet_state(pet_type, pose) # Create a basic biology asset so pet state saves correctly swf_asset = SwfAsset.create!( type: "biology", remote_id: (SwfAsset.maximum(:remote_id) || 0) + 1, url: "https://images.neopets.example/biology.swf", zone: zones(:body), zones_restrict: "", body_id: pet_type.body_id ) PetState.create!( pet_type: pet_type, pose: pose, swf_assets: [swf_asset], swf_asset_ids: [swf_asset.id] ) end def create_item(name, zone, body_id: 1, zones_restrict: "") item = Item.create!( name: name, description: "Test item", thumbnail_url: "https://images.neopets.example/item.png", zones_restrict: zones_restrict, rarity: "Common", price: 100 ) swf_asset = SwfAsset.create!( type: "object", remote_id: (SwfAsset.maximum(:remote_id) || 0) + 1, url: "https://images.neopets.example/#{name}.swf", zone: zone, zones_restrict: zones_restrict, body_id: body_id ) item.swf_assets << swf_asset item end describe "Item::Appearance#compatible_with?" do it "returns true for items in different zones with no restrictions" do hat = create_item("Hat", zones(:hat)) shirt = create_item("Shirt", zones(:shirtdress)) appearances = Item.appearances_for([hat, shirt], @pet_type) hat_appearance = appearances[hat.id] shirt_appearance = appearances[shirt.id] expect(hat_appearance.compatible_with?(shirt_appearance)).to be true expect(shirt_appearance.compatible_with?(hat_appearance)).to be true end it "returns false for items in the same zone" do hat1 = create_item("Hat 1", zones(:hat)) hat2 = create_item("Hat 2", zones(:hat)) appearances = Item.appearances_for([hat1, hat2], @pet_type) hat1_appearance = appearances[hat1.id] hat2_appearance = appearances[hat2.id] expect(hat1_appearance.compatible_with?(hat2_appearance)).to be false expect(hat2_appearance.compatible_with?(hat1_appearance)).to be false end it "returns false when one item restricts a zone the other occupies" do # Create a hat that restricts the ruff zone (zone 29) # The zones_restrict format is a 52-character bitstring where bit N corresponds to zone N+1 # Zones are 1-indexed, so zone 29 needs the bit at position 28 (0-indexed from right) # Build string from right to left: 28 zeros, then "1", then 23 zeros zones_restrict = ("0" * 23 + "1" + "0" * 28).reverse.chars.reverse.join # Simpler approach: create a 52-char string with bit 28 set to "1" zones_restrict_array = Array.new(52, "0") zones_restrict_array[28] = "1" # Set bit for zone 29 zones_restrict = zones_restrict_array.join restricting_hat = create_item("Restricting Hat", zones(:hat), zones_restrict: zones_restrict) # Create an item in the ruff zone ruff_item = create_item("Ruff Item", zones(:ruff)) appearances = Item.appearances_for([restricting_hat, ruff_item], @pet_type) hat_appearance = appearances[restricting_hat.id] ruff_appearance = appearances[ruff_item.id] expect(hat_appearance.compatible_with?(ruff_appearance)).to be false expect(ruff_appearance.compatible_with?(hat_appearance)).to be false end it "returns true for empty appearances" do # Create items that don't fit the current pet (wrong body_id) hat = create_item("Hat", zones(:hat), body_id: 999) shirt = create_item("Shirt", zones(:shirtdress), body_id: 999) appearances = Item.appearances_for([hat, shirt], @pet_type) hat_appearance = appearances[hat.id] shirt_appearance = appearances[shirt.id] # Both should be empty (no swf_assets for this pet) expect(hat_appearance).to be_empty expect(shirt_appearance).to be_empty # Empty appearances should be compatible expect(hat_appearance.compatible_with?(shirt_appearance)).to be true end end describe "#without_item" do it "returns a new outfit without the given item" do hat = create_item("Hat", zones(:hat)) outfit_with_hat = @outfit.with_item(hat) new_outfit = outfit_with_hat.without_item(hat) expect(new_outfit.worn_items).not_to include(hat) expect(outfit_with_hat.worn_items).to include(hat) # Original unchanged end it "returns a new outfit instance (immutable)" do hat = create_item("Hat", zones(:hat)) outfit_with_hat = @outfit.with_item(hat) new_outfit = outfit_with_hat.without_item(hat) expect(new_outfit).not_to eq(outfit_with_hat) expect(new_outfit.object_id).not_to eq(outfit_with_hat.object_id) end it "does nothing if the item is not worn" do hat = create_item("Hat", zones(:hat)) new_outfit = @outfit.without_item(hat) expect(new_outfit.worn_items).to be_empty end end describe "#with_item" do it "adds an item when there are no conflicts" do hat = create_item("Hat", zones(:hat)) new_outfit = @outfit.with_item(hat) expect(new_outfit.worn_items).to include(hat) end it "returns a new outfit instance (immutable)" do hat = create_item("Hat", zones(:hat)) new_outfit = @outfit.with_item(hat) expect(new_outfit).not_to eq(@outfit) expect(new_outfit.object_id).not_to eq(@outfit.object_id) expect(@outfit.worn_items).to be_empty # Original unchanged end it "is idempotent (adding same item twice has no effect)" do hat = create_item("Hat", zones(:hat)) outfit1 = @outfit.with_item(hat) outfit2 = outfit1.with_item(hat) expect(outfit1.worn_items.size).to eq(1) expect(outfit2.worn_items.size).to eq(1) expect(outfit2.worn_items).to include(hat) end it "does not add items that don't fit this pet" do # Create item with wrong body_id hat = create_item("Hat", zones(:hat), body_id: 999) new_outfit = @outfit.with_item(hat) expect(new_outfit.worn_items).to be_empty end context "with conflicting items" do it "moves conflicting item to closet when items occupy the same zone" do hat1 = create_item("Hat 1", zones(:hat)) hat2 = create_item("Hat 2", zones(:hat)) outfit_with_hat1 = @outfit.with_item(hat1) outfit_with_hat2 = outfit_with_hat1.with_item(hat2) expect(outfit_with_hat2.worn_items).to include(hat2) expect(outfit_with_hat2.worn_items).not_to include(hat1) expect(outfit_with_hat2.closeted_items).to include(hat1) end it "moves conflicting item to closet when new item restricts zone" do # Create item in ruff zone ruff_item = create_item("Ruff Item", zones(:ruff)) # Create hat that restricts ruff zone (zone 29) # zones_restrict is 0-indexed, so zone 29 needs bit 28 to be "1" zones_restrict_array = Array.new(52, "0") zones_restrict_array[28] = "1" zones_restrict = zones_restrict_array.join restricting_hat = create_item("Restricting Hat", zones(:hat), zones_restrict: zones_restrict) # First wear ruff item, then wear restricting hat outfit_with_ruff = @outfit.with_item(ruff_item) outfit_with_hat = outfit_with_ruff.with_item(restricting_hat) expect(outfit_with_hat.worn_items).to include(restricting_hat) expect(outfit_with_hat.worn_items).not_to include(ruff_item) expect(outfit_with_hat.closeted_items).to include(ruff_item) end it "keeps compatible items when adding new item" do hat = create_item("Hat", zones(:hat)) shirt = create_item("Shirt", zones(:shirtdress)) pants = create_item("Pants", zones(:trousers)) outfit1 = @outfit.with_item(hat).with_item(shirt) outfit2 = outfit1.with_item(pants) expect(outfit2.worn_items).to include(hat, shirt, pants) expect(outfit2.closeted_items).to be_empty end it "can move multiple conflicting items to closet" do hat1 = create_item("Hat 1", zones(:hat)) hat2 = create_item("Hat 2", zones(:hat)) hat3 = create_item("Hat 3", zones(:hat)) # Wear hat1 and hat2 by manually building the outfit # (normally you can't, but we're testing the conflict resolution) outfit = @outfit.dup outfit.worn_items << hat1 outfit.worn_items << hat2 # Now add hat3, which should move both hat1 and hat2 to closet outfit_with_hat3 = outfit.with_item(hat3) expect(outfit_with_hat3.worn_items).to contain_exactly(hat3) expect(outfit_with_hat3.closeted_items).to contain_exactly(hat1, hat2) end it "does not duplicate items in closet if already closeted" do hat1 = create_item("Hat 1", zones(:hat)) hat2 = create_item("Hat 2", zones(:hat)) # Wear hat1 outfit1 = @outfit.with_item(hat1) # Add hat2 (moves hat1 to closet) outfit2 = outfit1.with_item(hat2) # Add hat2 again (should be idempotent, not duplicate hat1 in closet) outfit3 = outfit2.with_item(hat2) expect(outfit3.closeted_items.size).to eq(1) expect(outfit3.closeted_items).to include(hat1) end end context "edge cases" do it "handles nil item gracefully" do expect { @outfit.with_item(nil) }.not_to raise_error end it "works with outfit that has no pet_state" do # This shouldn't happen in practice, but let's be defensive outfit_no_pet = Outfit.new hat = create_item("Hat", zones(:hat)) # Should not crash, but also won't add the item expect { outfit_no_pet.with_item(hat) }.not_to raise_error end end end end