Compare commits
3 commits
23e951edcd
...
55fa50c22a
| Author | SHA1 | Date | |
|---|---|---|---|
| 55fa50c22a | |||
| f823bac717 | |||
| cf80f96410 |
10 changed files with 892 additions and 30 deletions
3
Gemfile
3
Gemfile
|
|
@ -61,6 +61,9 @@ gem "async", "~> 2.17", require: false
|
|||
gem "async-http", "~> 0.89.0", require: false
|
||||
gem "thread-local", "~> 1.1", require: false
|
||||
|
||||
# For image processing (outfit PNG rendering).
|
||||
gem "ruby-vips", "~> 2.2"
|
||||
|
||||
# For debugging.
|
||||
group :development do
|
||||
gem 'debug', '~> 1.9.2'
|
||||
|
|
|
|||
|
|
@ -245,7 +245,6 @@ GEM
|
|||
memory_profiler (1.1.0)
|
||||
metrics (0.15.0)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.9)
|
||||
minitest (6.0.1)
|
||||
prism (~> 1.5)
|
||||
msgpack (1.8.0)
|
||||
|
|
@ -263,9 +262,6 @@ GEM
|
|||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.18.10)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.10-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.10-arm64-darwin)
|
||||
|
|
@ -431,6 +427,9 @@ GEM
|
|||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.4)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-vips (2.3.0)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
samovar (2.4.1)
|
||||
console (~> 1.0)
|
||||
mapping (~> 1.0)
|
||||
|
|
@ -541,7 +540,6 @@ GEM
|
|||
PLATFORMS
|
||||
aarch64-linux
|
||||
arm64-darwin
|
||||
ruby
|
||||
x86_64-linux
|
||||
|
||||
DEPENDENCIES
|
||||
|
|
@ -571,6 +569,7 @@ DEPENDENCIES
|
|||
rails-i18n (~> 8.0, >= 8.0.1)
|
||||
rdiscount (~> 2.2, >= 2.2.7.1)
|
||||
rspec-rails (~> 7.0)
|
||||
ruby-vips (~> 2.2)
|
||||
sanitize (~> 6.0, >= 6.0.2)
|
||||
sass-rails (~> 6.0)
|
||||
sentry-rails (~> 5.12)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,26 @@ class OutfitsController < ApplicationController
|
|||
end
|
||||
|
||||
def edit
|
||||
render "outfits/edit", layout: false
|
||||
respond_to do |format|
|
||||
format.html { render "outfits/edit", layout: false }
|
||||
format.png do
|
||||
@outfit = build_outfit_from_wardrobe_params
|
||||
if @outfit.valid?
|
||||
renderer = OutfitImageRenderer.new(@outfit)
|
||||
png_data = renderer.render
|
||||
|
||||
if png_data
|
||||
send_data png_data, type: "image/png", disposition: "inline",
|
||||
filename: "outfit.png"
|
||||
expires_in 1.day, public: true
|
||||
else
|
||||
head :not_found
|
||||
end
|
||||
else
|
||||
head :bad_request
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def index
|
||||
|
|
@ -117,6 +136,40 @@ class OutfitsController < ApplicationController
|
|||
biology: [:species_id, :color_id, :pose, :pet_state_id])
|
||||
end
|
||||
|
||||
def build_outfit_from_wardrobe_params
|
||||
# Load items first
|
||||
worn_item_ids = params[:objects] ? Array(params[:objects]).map(&:to_i) : []
|
||||
closeted_item_ids = params[:closet] ? Array(params[:closet]).map(&:to_i) : []
|
||||
|
||||
worn_items = Item.where(id: worn_item_ids)
|
||||
closeted_items = Item.where(id: closeted_item_ids)
|
||||
|
||||
# Build outfit with biology and items
|
||||
outfit = Outfit.new(
|
||||
worn_items: worn_items,
|
||||
closeted_items: closeted_items,
|
||||
)
|
||||
|
||||
# Set biology from species, color, and pose params
|
||||
if params[:species] && params[:color] && params[:pose]
|
||||
outfit.biology = {
|
||||
species_id: params[:species],
|
||||
color_id: params[:color],
|
||||
pose: params[:pose]
|
||||
}
|
||||
elsif params[:state]
|
||||
# Alternative: use pet_state_id directly
|
||||
outfit.biology = { pet_state_id: params[:state] }
|
||||
end
|
||||
|
||||
# Set alt style if provided
|
||||
if params[:style]
|
||||
outfit.alt_style_id = params[:style].to_i
|
||||
end
|
||||
|
||||
outfit
|
||||
end
|
||||
|
||||
def find_authorized_outfit
|
||||
raise ActiveRecord::RecordNotFound unless user_signed_in?
|
||||
@outfit = current_user.outfits.find(params[:id])
|
||||
|
|
|
|||
|
|
@ -170,52 +170,67 @@ class Outfit < ApplicationRecord
|
|||
end
|
||||
|
||||
def visible_layers
|
||||
# TODO: This method doesn't currently handle alt styles! If the outfit has
|
||||
# an alt_style, we should use its layers instead of pet_state layers, and
|
||||
# filter items to only those with body_id=0. This isn't needed yet because
|
||||
# this method is only used on item pages, which don't support alt styles.
|
||||
# See useOutfitAppearance.js for the complete logic including alt styles.
|
||||
item_appearances = item_appearances(swf_asset_includes: [:zone])
|
||||
# Step 1: Choose biology layers - use alt style if present, otherwise pet state
|
||||
if alt_style
|
||||
biology_layers = alt_style.swf_assets.includes(:zone).to_a
|
||||
body = alt_style
|
||||
using_alt_style = true
|
||||
else
|
||||
biology_layers = pet_state.swf_assets.includes(:zone).to_a
|
||||
body = pet_type
|
||||
using_alt_style = false
|
||||
end
|
||||
|
||||
pet_layers = pet_state.swf_assets.includes(:zone).to_a
|
||||
# Step 2: Load item appearances for the appropriate body
|
||||
item_appearances = Item.appearances_for(
|
||||
worn_items,
|
||||
body,
|
||||
swf_asset_includes: [:zone]
|
||||
).values
|
||||
item_layers = item_appearances.map(&:swf_assets).flatten
|
||||
|
||||
pet_restricted_zone_ids = pet_layers.map(&:restricted_zone_ids).
|
||||
# For alt styles, only body_id=0 items are compatible
|
||||
if using_alt_style
|
||||
item_layers.reject! { |sa| sa.body_id != 0 }
|
||||
end
|
||||
|
||||
# Step 3: Apply restriction rules
|
||||
biology_restricted_zone_ids = biology_layers.map(&:restricted_zone_ids).
|
||||
flatten.to_set
|
||||
item_restricted_zone_ids = item_appearances.
|
||||
map(&:restricted_zone_ids).flatten.to_set
|
||||
|
||||
# When an item restricts a zone, it hides pet layers of the same zone.
|
||||
# Rule 3a: When an item restricts a zone, it hides biology layers of the same zone.
|
||||
# We use this to e.g. make a hat hide a hair ruff.
|
||||
#
|
||||
# NOTE: Items' restricted layers also affect what items you can wear at
|
||||
# the same time. We don't enforce anything about that here, and
|
||||
# instead assume that the input by this point is valid!
|
||||
pet_layers.reject! { |sa| item_restricted_zone_ids.include?(sa.zone_id) }
|
||||
biology_layers.reject! { |sa| item_restricted_zone_ids.include?(sa.zone_id) }
|
||||
|
||||
# When a pet appearance restricts a zone, or when the pet is Unconverted,
|
||||
# it makes body-specific items incompatible. We use this to disallow UCs
|
||||
# from wearing certain body-specific Biology Effects, Statics, etc, while
|
||||
# still allowing non-body-specific items in those zones! (I think this
|
||||
# happens for some Invisible pet stuff, too?)
|
||||
# Rule 3b: When a biology appearance restricts a zone, or when the pet is
|
||||
# Unconverted, it makes body-specific items incompatible. We use this to
|
||||
# disallow UCs from wearing certain body-specific Biology Effects, Statics,
|
||||
# etc, while still allowing non-body-specific items in those zones! (I think
|
||||
# this happens for some Invisible pet stuff, too?)
|
||||
#
|
||||
# TODO: We shouldn't be *hiding* these zones, like we do with items; we
|
||||
# should be doing this way earlier, to prevent the item from even
|
||||
# showing up even in search results!
|
||||
#
|
||||
# NOTE: This can result in both pet layers and items occupying the same
|
||||
# NOTE: This can result in both biology layers and items occupying the same
|
||||
# zone, like Static, so long as the item isn't body-specific! That's
|
||||
# correct, and the item layer should be on top! (Here, we implement
|
||||
# it by placing item layers second in the list, and rely on JS sort
|
||||
# stability, and *then* rely on the UI to respect that ordering when
|
||||
# rendering them by depth. Not great! 😅)
|
||||
#
|
||||
# NOTE: We used to also include the pet appearance's *occupied* zones in
|
||||
# NOTE: We used to also include the biology appearance's *occupied* zones in
|
||||
# this condition, not just the restricted zones, as a sensible
|
||||
# defensive default, even though we weren't aware of any relevant
|
||||
# items. But now we know that actually the "Bruce Brucey B Mouth"
|
||||
# occupies the real Mouth zone, and still should be visible and
|
||||
# above pet layers! So, we now only check *restricted* zones.
|
||||
# above biology layers! So, we now only check *restricted* zones.
|
||||
#
|
||||
# NOTE: UCs used to implement their restrictions by listing specific
|
||||
# zones, but it seems that the logic has changed to just be about
|
||||
|
|
@ -232,18 +247,20 @@ class Outfit < ApplicationRecord
|
|||
item_layers.reject! { |sa| sa.body_specific? }
|
||||
else
|
||||
item_layers.reject! { |sa| sa.body_specific? &&
|
||||
pet_restricted_zone_ids.include?(sa.zone_id) }
|
||||
biology_restricted_zone_ids.include?(sa.zone_id) }
|
||||
end
|
||||
|
||||
# A pet appearance can also restrict its own zones. The Wraith Uni is an
|
||||
# interesting example: it has a horn, but its zone restrictions hide it!
|
||||
pet_layers.reject! { |sa| pet_restricted_zone_ids.include?(sa.zone_id) }
|
||||
# Rule 3c: A biology appearance can also restrict its own zones. The Wraith
|
||||
# Uni is an interesting example: it has a horn, but its zone restrictions
|
||||
# hide it!
|
||||
biology_layers.reject! { |sa| biology_restricted_zone_ids.include?(sa.zone_id) }
|
||||
|
||||
(pet_layers + item_layers).sort_by(&:depth)
|
||||
# Step 4: Sort by depth and return
|
||||
(biology_layers + item_layers).sort_by(&:depth)
|
||||
end
|
||||
|
||||
def wardrobe_params
|
||||
{
|
||||
params = {
|
||||
name: name,
|
||||
color: color_id,
|
||||
species: species_id,
|
||||
|
|
@ -252,6 +269,8 @@ class Outfit < ApplicationRecord
|
|||
objects: worn_item_ids,
|
||||
closet: closeted_item_ids,
|
||||
}
|
||||
params[:style] = alt_style_id if alt_style_id.present?
|
||||
params
|
||||
end
|
||||
|
||||
def ensure_unique_name
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@
|
|||
name:
|
||||
- libmysqlclient-dev
|
||||
- libyaml-dev
|
||||
- libvips-dev
|
||||
|
||||
- name: Create the app folder
|
||||
file:
|
||||
|
|
|
|||
75
lib/outfit_image_renderer.rb
Normal file
75
lib/outfit_image_renderer.rb
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
require "vips"
|
||||
|
||||
class OutfitImageRenderer
|
||||
CANVAS_SIZE = 600
|
||||
|
||||
def initialize(outfit)
|
||||
@outfit = outfit
|
||||
end
|
||||
|
||||
def render
|
||||
layers = @outfit.visible_layers
|
||||
|
||||
# Filter out layers without image URLs
|
||||
layers_with_images = layers.select(&:image_url?)
|
||||
|
||||
return nil if layers_with_images.empty?
|
||||
|
||||
# Fetch all layer images in parallel
|
||||
image_data_by_layer = fetch_layer_images(layers_with_images)
|
||||
|
||||
# Create transparent canvas in sRGB colorspace
|
||||
canvas = Vips::Image.black(CANVAS_SIZE, CANVAS_SIZE, bands: 4)
|
||||
canvas = canvas.new_from_image([0, 0, 0, 0])
|
||||
canvas = canvas.copy(interpretation: :srgb)
|
||||
|
||||
# Composite each layer onto the canvas
|
||||
layers_with_images.each do |layer|
|
||||
image_data = image_data_by_layer[layer]
|
||||
next unless image_data
|
||||
|
||||
begin
|
||||
layer_image = Vips::Image.new_from_buffer(image_data, "")
|
||||
|
||||
# Center the layer on the canvas
|
||||
x_offset = (CANVAS_SIZE - layer_image.width) / 2
|
||||
y_offset = (CANVAS_SIZE - layer_image.height) / 2
|
||||
|
||||
# Composite this layer onto the canvas
|
||||
canvas = canvas.composite([layer_image], :over, x: x_offset, y: y_offset)
|
||||
rescue Vips::Error => e
|
||||
# Log and skip layers that fail to load/composite
|
||||
Rails.logger.warn "Failed to composite layer #{layer.id} (#{layer.image_url}): #{e.message}"
|
||||
next
|
||||
end
|
||||
end
|
||||
|
||||
# Return PNG data
|
||||
canvas.write_to_buffer(".png")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_layer_images(layers)
|
||||
image_data_by_layer = {}
|
||||
|
||||
DTIRequests.load_many(max_at_once: 10) do |semaphore|
|
||||
layers.each do |layer|
|
||||
semaphore.async do
|
||||
begin
|
||||
response = DTIRequests.get(layer.image_url)
|
||||
if response.success?
|
||||
image_data_by_layer[layer] = response.read
|
||||
else
|
||||
Rails.logger.warn "Failed to fetch image for layer #{layer.id} (#{layer.image_url}): HTTP #{response.status}"
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.warn "Error fetching image for layer #{layer.id} (#{layer.image_url}): #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
image_data_by_layer
|
||||
end
|
||||
end
|
||||
end
|
||||
166
spec/lib/outfit_image_renderer_spec.rb
Normal file
166
spec/lib/outfit_image_renderer_spec.rb
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
require 'webmock/rspec'
|
||||
require_relative '../rails_helper'
|
||||
|
||||
RSpec.describe OutfitImageRenderer do
|
||||
fixtures :zones, :colors, :species
|
||||
|
||||
# Helper to create a simple PNG image (1x1 pixel) with a specific color
|
||||
def create_test_png(red, green, blue, alpha = 255)
|
||||
require 'vips'
|
||||
image = Vips::Image.black(1, 1, bands: 4)
|
||||
image = image.new_from_image([red, green, blue, alpha])
|
||||
image.write_to_buffer('.png')
|
||||
end
|
||||
|
||||
# Helper to create a pet state with specific swf_assets
|
||||
def build_pet_state(pet_type, pose: "HAPPY_FEM", swf_assets: [])
|
||||
pet_state = PetState.create!(
|
||||
pet_type: pet_type,
|
||||
pose: pose,
|
||||
swf_asset_ids: swf_assets.empty? ? [0] : swf_assets.map(&:id)
|
||||
)
|
||||
pet_state.swf_assets = swf_assets
|
||||
pet_state
|
||||
end
|
||||
|
||||
# Helper to create a SwfAsset for biology (pet layers)
|
||||
def build_biology_asset(zone, body_id:)
|
||||
@remote_id = (@remote_id || 0) + 1
|
||||
SwfAsset.create!(
|
||||
type: "biology",
|
||||
remote_id: @remote_id,
|
||||
url: "https://images.neopets.example/biology_#{@remote_id}.swf",
|
||||
zone: zone,
|
||||
body_id: body_id,
|
||||
zones_restrict: "",
|
||||
has_image: true
|
||||
)
|
||||
end
|
||||
|
||||
# Helper to create a SwfAsset for items (object layers)
|
||||
def build_item_asset(zone, body_id:)
|
||||
@remote_id = (@remote_id || 0) + 1
|
||||
SwfAsset.create!(
|
||||
type: "object",
|
||||
remote_id: @remote_id,
|
||||
url: "https://images.neopets.example/object_#{@remote_id}.swf",
|
||||
zone: zone,
|
||||
body_id: body_id,
|
||||
zones_restrict: "",
|
||||
has_image: true
|
||||
)
|
||||
end
|
||||
|
||||
# Helper to create an item with specific swf_assets
|
||||
def build_item(name, swf_assets: [])
|
||||
item = Item.create!(
|
||||
name: name,
|
||||
description: "Test item",
|
||||
thumbnail_url: "https://images.neopets.example/thumbnail.png",
|
||||
rarity: "Common",
|
||||
price: 100,
|
||||
zones_restrict: "",
|
||||
species_support_ids: ""
|
||||
)
|
||||
swf_assets.each do |asset|
|
||||
ParentSwfAssetRelationship.create!(
|
||||
parent: item,
|
||||
swf_asset: asset
|
||||
)
|
||||
end
|
||||
item
|
||||
end
|
||||
|
||||
before do
|
||||
PetType.destroy_all
|
||||
@pet_type = PetType.create!(
|
||||
species: species(:acara),
|
||||
color: colors(:blue),
|
||||
body_id: 1,
|
||||
created_at: Time.new(2005)
|
||||
)
|
||||
end
|
||||
|
||||
describe "#render" do
|
||||
context "with a simple outfit" do
|
||||
it "composites biology and item layers into a single PNG" do
|
||||
# Create test PNG data
|
||||
red_png = create_test_png(255, 0, 0) # Red pixel
|
||||
blue_png = create_test_png(0, 0, 255) # Blue pixel
|
||||
|
||||
# Create biology and item assets
|
||||
biology_asset = build_biology_asset(zones(:head), body_id: 1)
|
||||
item_asset = build_item_asset(zones(:hat), body_id: 1)
|
||||
|
||||
# Stub HTTP requests for the actual image URLs that will be generated
|
||||
stub_request(:get, biology_asset.image_url).
|
||||
to_return(body: red_png, status: 200)
|
||||
stub_request(:get, item_asset.image_url).
|
||||
to_return(body: blue_png, status: 200)
|
||||
|
||||
# Build outfit
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [biology_asset])
|
||||
item = build_item("Test Hat", swf_assets: [item_asset])
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
outfit.item_ids = { worn: [item.id], closeted: [] }
|
||||
|
||||
# Render
|
||||
renderer = OutfitImageRenderer.new(outfit)
|
||||
result = renderer.render
|
||||
|
||||
# Verify we got PNG data back
|
||||
expect(result).not_to be_nil
|
||||
expect(result).to be_a(String)
|
||||
expect(result[0..7]).to eq("\x89PNG\r\n\x1A\n".b) # PNG magic bytes
|
||||
|
||||
# Verify the result is a valid 600x600 PNG
|
||||
image = Vips::Image.new_from_buffer(result, "")
|
||||
expect(image.width).to eq(600)
|
||||
expect(image.height).to eq(600)
|
||||
end
|
||||
end
|
||||
|
||||
context "when a layer image fails to load" do
|
||||
it "skips the failed layer and continues" do
|
||||
blue_png = create_test_png(0, 0, 255)
|
||||
|
||||
biology_asset = build_biology_asset(zones(:head), body_id: 1)
|
||||
item_asset = build_item_asset(zones(:hat), body_id: 1)
|
||||
|
||||
# Stub one successful request and one failure
|
||||
stub_request(:get, biology_asset.image_url).
|
||||
to_return(status: 404)
|
||||
stub_request(:get, item_asset.image_url).
|
||||
to_return(body: blue_png, status: 200)
|
||||
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [biology_asset])
|
||||
item = build_item("Test Hat", swf_assets: [item_asset])
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
outfit.item_ids = { worn: [item.id], closeted: [] }
|
||||
|
||||
renderer = OutfitImageRenderer.new(outfit)
|
||||
result = renderer.render
|
||||
|
||||
# Should still render successfully with just the one layer
|
||||
expect(result).not_to be_nil
|
||||
expect(result[0..7]).to eq("\x89PNG\r\n\x1A\n".b)
|
||||
end
|
||||
end
|
||||
|
||||
context "when no layers have images" do
|
||||
it "returns nil" do
|
||||
# Create an asset but stub image_url to return nil
|
||||
biology_asset = build_biology_asset(zones(:head), body_id: 1)
|
||||
allow_any_instance_of(SwfAsset).to receive(:image_url?).and_return(false)
|
||||
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [biology_asset])
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
|
||||
renderer = OutfitImageRenderer.new(outfit)
|
||||
result = renderer.render
|
||||
|
||||
expect(result).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
546
spec/models/outfit_spec.rb
Normal file
546
spec/models/outfit_spec.rb
Normal file
|
|
@ -0,0 +1,546 @@
|
|||
require_relative '../rails_helper'
|
||||
|
||||
RSpec.describe Outfit do
|
||||
fixtures :zones, :colors, :species
|
||||
|
||||
# Helper to create a pet state with specific swf_assets
|
||||
def build_pet_state(pet_type, pose: "HAPPY_FEM", swf_assets: [])
|
||||
pet_state = PetState.create!(
|
||||
pet_type: pet_type,
|
||||
pose: pose,
|
||||
swf_asset_ids: swf_assets.empty? ? [0] : swf_assets.map(&:id)
|
||||
)
|
||||
pet_state.swf_assets = swf_assets
|
||||
pet_state
|
||||
end
|
||||
|
||||
# Helper to create a SwfAsset for biology (pet layers)
|
||||
def build_biology_asset(zone, body_id:, zones_restrict: "")
|
||||
@remote_id = (@remote_id || 0) + 1
|
||||
SwfAsset.create!(
|
||||
type: "biology",
|
||||
remote_id: @remote_id,
|
||||
url: "https://images.neopets.example/biology_#{@remote_id}.swf",
|
||||
zone: zone,
|
||||
body_id: body_id,
|
||||
zones_restrict: zones_restrict
|
||||
)
|
||||
end
|
||||
|
||||
# Helper to create a SwfAsset for items (object layers)
|
||||
def build_item_asset(zone, body_id:, zones_restrict: "")
|
||||
@remote_id = (@remote_id || 0) + 1
|
||||
SwfAsset.create!(
|
||||
type: "object",
|
||||
remote_id: @remote_id,
|
||||
url: "https://images.neopets.example/object_#{@remote_id}.swf",
|
||||
zone: zone,
|
||||
body_id: body_id,
|
||||
zones_restrict: zones_restrict
|
||||
)
|
||||
end
|
||||
|
||||
# Helper to create an item with specific swf_assets
|
||||
def build_item(name, swf_assets: [])
|
||||
item = Item.create!(
|
||||
name: name,
|
||||
description: "Test item",
|
||||
thumbnail_url: "https://images.neopets.example/thumbnail.png",
|
||||
rarity: "Common",
|
||||
price: 100,
|
||||
zones_restrict: "",
|
||||
species_support_ids: ""
|
||||
)
|
||||
swf_assets.each do |asset|
|
||||
ParentSwfAssetRelationship.create!(
|
||||
parent: item,
|
||||
swf_asset: asset
|
||||
)
|
||||
end
|
||||
item
|
||||
end
|
||||
|
||||
describe "#visible_layers" do
|
||||
before do
|
||||
# Clean up any existing pet types to avoid conflicts
|
||||
PetType.destroy_all
|
||||
|
||||
# Create a basic pet type for testing
|
||||
@pet_type = PetType.create!(
|
||||
species: species(:acara),
|
||||
color: colors(:blue),
|
||||
body_id: 1,
|
||||
created_at: Time.new(2005)
|
||||
)
|
||||
end
|
||||
|
||||
context "basic layer composition" do
|
||||
it "returns pet layers when no items are worn" do
|
||||
# Create biology assets for the pet
|
||||
head = build_biology_asset(zones(:head), body_id: 1)
|
||||
body = build_biology_asset(zones(:body), body_id: 1)
|
||||
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [head, body])
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
expect(layers).to contain_exactly(head, body)
|
||||
end
|
||||
|
||||
it "returns pet layers and item layers when items are worn" do
|
||||
# Create pet layers
|
||||
head = build_biology_asset(zones(:head), body_id: 1)
|
||||
body = build_biology_asset(zones(:body), body_id: 1)
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [head, body])
|
||||
|
||||
# Create item layers
|
||||
hat_asset = build_item_asset(zones(:hat), body_id: 1)
|
||||
hat = build_item("Test Hat", swf_assets: [hat_asset])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
outfit.worn_items = [hat]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
expect(layers).to contain_exactly(head, body, hat_asset)
|
||||
end
|
||||
|
||||
it "includes body_id=0 items that fit all pets" do
|
||||
# Create pet layers
|
||||
head = build_biology_asset(zones(:head), body_id: 1)
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [head])
|
||||
|
||||
# Create a background item (body_id=0, fits all)
|
||||
bg_asset = build_item_asset(zones(:background), body_id: 0)
|
||||
background = build_item("Test Background", swf_assets: [bg_asset])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
outfit.worn_items = [background]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
expect(layers).to contain_exactly(head, bg_asset)
|
||||
end
|
||||
end
|
||||
|
||||
context "items restricting pet layers (Rule 3a)" do
|
||||
it "hides pet layers in zones that items restrict" do
|
||||
# Create pet layers including hair
|
||||
head = build_biology_asset(zones(:head), body_id: 1)
|
||||
hair = build_biology_asset(zones(:hairfront), body_id: 1)
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [head, hair])
|
||||
|
||||
# Create a hat that restricts the hair zone
|
||||
# zones_restrict is a bitfield where position 37 (Hair Front zone id) is "1"
|
||||
zones_restrict = "0" * 36 + "1" + "0" * 20 # bit 37 = 1
|
||||
hat_asset = build_item_asset(zones(:hat), body_id: 1, zones_restrict: zones_restrict)
|
||||
hat = build_item("Hair-hiding Hat", swf_assets: [hat_asset])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
outfit.worn_items = [hat]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# Hair should be hidden, but head and hat should be visible
|
||||
expect(layers).to contain_exactly(head, hat_asset)
|
||||
expect(layers).not_to include(hair)
|
||||
end
|
||||
|
||||
it "hides multiple pet layers when item restricts multiple zones" do
|
||||
# Create pet layers
|
||||
head = build_biology_asset(zones(:head), body_id: 1)
|
||||
hair_front = build_biology_asset(zones(:hairfront), body_id: 1)
|
||||
head_transient = build_biology_asset(zones(:headtransientbiology), body_id: 1)
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [head, hair_front, head_transient])
|
||||
|
||||
# Create an item that restricts both Hair Front (37) and Head Transient Biology (38)
|
||||
zones_restrict = "0" * 36 + "11" + "0" * 20 # bits 37 and 38 = 1
|
||||
hood_asset = build_item_asset(zones(:hat), body_id: 1, zones_restrict: zones_restrict)
|
||||
hood = build_item("Agent Hood", swf_assets: [hood_asset])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
outfit.worn_items = [hood]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# Both hair_front and head_transient should be hidden
|
||||
expect(layers).to contain_exactly(head, hood_asset)
|
||||
expect(layers).not_to include(hair_front, head_transient)
|
||||
end
|
||||
end
|
||||
|
||||
context "pets restricting body-specific item layers (Rule 3b)" do
|
||||
it "hides body-specific items in zones the pet restricts" do
|
||||
# Create a pet with a layer that restricts the Static zone (46)
|
||||
head = build_biology_asset(zones(:head), body_id: 1)
|
||||
zones_restrict = "0" * 45 + "1" + "0" * 10 # bit 46 = 1
|
||||
restricting_layer = build_biology_asset(zones(:body), body_id: 1, zones_restrict: zones_restrict)
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [head, restricting_layer])
|
||||
|
||||
# Create a body-specific Static item
|
||||
static_asset = build_item_asset(zones(:static), body_id: 1)
|
||||
static_item = build_item("Body-specific Static", swf_assets: [static_asset])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
outfit.worn_items = [static_item]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# The body-specific static item should be hidden
|
||||
expect(layers).to contain_exactly(head, restricting_layer)
|
||||
expect(layers).not_to include(static_asset)
|
||||
end
|
||||
|
||||
it "allows body_id=0 items even in zones the pet restricts" do
|
||||
# Create a pet with a layer that restricts the Background Item zone (48)
|
||||
# Background Item is type_id 3 (universal zone), so body_id=0 items should always work
|
||||
head = build_biology_asset(zones(:head), body_id: 1)
|
||||
zones_restrict = "0" * 47 + "1" + "0" * 10 # bit 48 = 1
|
||||
restricting_layer = build_biology_asset(zones(:body), body_id: 1, zones_restrict: zones_restrict)
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [head, restricting_layer])
|
||||
|
||||
# Create a body_id=0 Background Item (fits all bodies, universal zone)
|
||||
bg_item_asset = build_item_asset(zones(:backgrounditem), body_id: 0)
|
||||
bg_item = build_item("Universal Background Item", swf_assets: [bg_item_asset])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
outfit.worn_items = [bg_item]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# The body_id=0 item should be visible even though the zone is restricted
|
||||
expect(layers).to contain_exactly(head, restricting_layer, bg_item_asset)
|
||||
end
|
||||
end
|
||||
|
||||
context "UNCONVERTED pets (Rule 3b special case)" do
|
||||
it "rejects all body-specific items" do
|
||||
# Create an UNCONVERTED pet
|
||||
head = build_biology_asset(zones(:head), body_id: 1)
|
||||
body = build_biology_asset(zones(:body), body_id: 1)
|
||||
pet_state = build_pet_state(@pet_type, pose: "UNCONVERTED", swf_assets: [head, body])
|
||||
|
||||
# Create both body-specific and body_id=0 items
|
||||
body_specific_asset = build_item_asset(zones(:hat), body_id: 1)
|
||||
body_specific_item = build_item("Body-specific Hat", swf_assets: [body_specific_asset])
|
||||
|
||||
universal_asset = build_item_asset(zones(:background), body_id: 0)
|
||||
universal_item = build_item("Universal Background", swf_assets: [universal_asset])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
outfit.worn_items = [body_specific_item, universal_item]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# Only body_id=0 items should be visible
|
||||
expect(layers).to contain_exactly(head, body, universal_asset)
|
||||
expect(layers).not_to include(body_specific_asset)
|
||||
end
|
||||
|
||||
it "rejects body-specific items regardless of zone restrictions" do
|
||||
# Create an UNCONVERTED pet with no zone restrictions
|
||||
head = build_biology_asset(zones(:head), body_id: 1)
|
||||
pet_state = build_pet_state(@pet_type, pose: "UNCONVERTED", swf_assets: [head])
|
||||
|
||||
# Create a body-specific item in a zone the pet doesn't restrict
|
||||
hat_asset = build_item_asset(zones(:hat), body_id: 1)
|
||||
hat = build_item("Body-specific Hat", swf_assets: [hat_asset])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
outfit.worn_items = [hat]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# The body-specific item should still be hidden
|
||||
expect(layers).to contain_exactly(head)
|
||||
expect(layers).not_to include(hat_asset)
|
||||
end
|
||||
end
|
||||
|
||||
context "pets restricting their own layers (Rule 3c)" do
|
||||
it "hides pet layers in zones the pet itself restricts" do
|
||||
# Create a pet with a horn asset and a layer that restricts the horn's zone
|
||||
# (Simulating the Wraith Uni case)
|
||||
body = build_biology_asset(zones(:body), body_id: 1)
|
||||
|
||||
# Create a horn in the Head Transient Biology zone (38)
|
||||
horn = build_biology_asset(zones(:headtransientbiology), body_id: 1)
|
||||
|
||||
# Create a layer that restricts zone 38
|
||||
zones_restrict = "0" * 37 + "1" + "0" * 20 # bit 38 = 1
|
||||
restricting_layer = build_biology_asset(zones(:head), body_id: 1, zones_restrict: zones_restrict)
|
||||
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [body, horn, restricting_layer])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# The horn should be hidden by the pet's own restrictions
|
||||
expect(layers).to contain_exactly(body, restricting_layer)
|
||||
expect(layers).not_to include(horn)
|
||||
end
|
||||
|
||||
it "applies self-restrictions in combination with item restrictions" do
|
||||
# Create a pet with multiple layers, some restricted by itself
|
||||
body = build_biology_asset(zones(:body), body_id: 1)
|
||||
hair = build_biology_asset(zones(:hairfront), body_id: 1)
|
||||
|
||||
# Pet restricts its own Head zone (30)
|
||||
zones_restrict = "0" * 29 + "1" + "0" * 25 # bit 30 = 1
|
||||
head = build_biology_asset(zones(:head), body_id: 1)
|
||||
restricting_layer = build_biology_asset(zones(:eyes), body_id: 1, zones_restrict: zones_restrict)
|
||||
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [body, hair, head, restricting_layer])
|
||||
|
||||
# Add an item that restricts Hair Front (37)
|
||||
item_zones_restrict = "0" * 36 + "1" + "0" * 20 # bit 37 = 1
|
||||
hat_asset = build_item_asset(zones(:hat), body_id: 1, zones_restrict: item_zones_restrict)
|
||||
hat = build_item("Hair-hiding Hat", swf_assets: [hat_asset])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
outfit.worn_items = [hat]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# Hair should be hidden by item, Head should be hidden by pet's own restrictions
|
||||
expect(layers).to contain_exactly(body, restricting_layer, hat_asset)
|
||||
expect(layers).not_to include(hair, head)
|
||||
end
|
||||
end
|
||||
|
||||
context "depth sorting and layer ordering" do
|
||||
it "sorts layers by zone depth" do
|
||||
# Create layers in various zones with different depths
|
||||
background = build_biology_asset(zones(:background), body_id: 1) # depth 3
|
||||
body_layer = build_biology_asset(zones(:body), body_id: 1) # depth 18
|
||||
head_layer = build_biology_asset(zones(:head), body_id: 1) # depth 34
|
||||
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [head_layer, background, body_layer])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# Should be sorted by depth: background (3) < body (18) < head (34)
|
||||
expect(layers[0]).to eq(background)
|
||||
expect(layers[1]).to eq(body_layer)
|
||||
expect(layers[2]).to eq(head_layer)
|
||||
end
|
||||
|
||||
it "places item layers after pet layers at the same depth" do
|
||||
# Create a pet layer and item layer in zones with the same depth
|
||||
# Static zone has depth 48
|
||||
pet_static = build_biology_asset(zones(:static), body_id: 1)
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [pet_static])
|
||||
|
||||
item_static = build_item_asset(zones(:static), body_id: 0)
|
||||
static_item = build_item("Static Item", swf_assets: [item_static])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
outfit.worn_items = [static_item]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# Both should be present, with item layer last (on top)
|
||||
expect(layers).to eq([pet_static, item_static])
|
||||
end
|
||||
|
||||
it "sorts complex outfits correctly by depth" do
|
||||
# Create a complex outfit with multiple pet and item layers
|
||||
background = build_biology_asset(zones(:background), body_id: 1) # depth 3
|
||||
body_layer = build_biology_asset(zones(:body), body_id: 1) # depth 18
|
||||
head_layer = build_biology_asset(zones(:head), body_id: 1) # depth 34
|
||||
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [head_layer, background, body_layer])
|
||||
|
||||
# Add items at various depths
|
||||
bg_item = build_item_asset(zones(:backgrounditem), body_id: 0) # depth 4
|
||||
hat_asset = build_item_asset(zones(:hat), body_id: 1) # depth 16
|
||||
shirt_asset = build_item_asset(zones(:shirtdress), body_id: 1) # depth 26
|
||||
|
||||
bg = build_item("Background Item", swf_assets: [bg_item])
|
||||
hat = build_item("Hat", swf_assets: [hat_asset])
|
||||
shirt = build_item("Shirt", swf_assets: [shirt_asset])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
outfit.worn_items = [hat, bg, shirt]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# Expected order by depth:
|
||||
# background (3), bg_item (4), hat_asset (16), body_layer (18),
|
||||
# shirt_asset (26), head_layer (34)
|
||||
expect(layers.map(&:depth)).to eq([3, 4, 16, 18, 26, 34])
|
||||
expect(layers).to eq([background, bg_item, hat_asset, body_layer, shirt_asset, head_layer])
|
||||
end
|
||||
end
|
||||
|
||||
context "alt styles (alternative pet appearances)" do
|
||||
before do
|
||||
# Create an alt style with its own body_id distinct from regular pets
|
||||
@alt_style = AltStyle.create!(
|
||||
species: species(:acara),
|
||||
color: colors(:blue),
|
||||
body_id: 999, # Distinct from the regular pet's body_id (1)
|
||||
series_name: "Nostalgic",
|
||||
thumbnail_url: "https://images.neopets.example/alt_style.png"
|
||||
)
|
||||
end
|
||||
|
||||
it "uses alt style layers instead of pet state layers" do
|
||||
# Create regular pet layers
|
||||
regular_head = build_biology_asset(zones(:head), body_id: 1)
|
||||
regular_body = build_biology_asset(zones(:body), body_id: 1)
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [regular_head, regular_body])
|
||||
|
||||
# Create alt style layers (with the alt style's body_id)
|
||||
alt_head = build_biology_asset(zones(:head), body_id: 999)
|
||||
alt_body = build_biology_asset(zones(:body), body_id: 999)
|
||||
@alt_style.swf_assets = [alt_head, alt_body]
|
||||
|
||||
# Create outfit with alt_style
|
||||
outfit = Outfit.new(pet_state: pet_state, alt_style: @alt_style)
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# Should use alt style layers, not pet state layers
|
||||
expect(layers).to contain_exactly(alt_head, alt_body)
|
||||
expect(layers).not_to include(regular_head, regular_body)
|
||||
end
|
||||
|
||||
it "only includes body_id=0 items with alt styles" do
|
||||
# Create alt style layers
|
||||
alt_head = build_biology_asset(zones(:head), body_id: 999)
|
||||
@alt_style.swf_assets = [alt_head]
|
||||
pet_state = build_pet_state(@pet_type)
|
||||
|
||||
# Create a body-specific item for the alt style's body_id
|
||||
body_specific_asset = build_item_asset(zones(:hat), body_id: 999)
|
||||
body_specific_item = build_item("Body-specific Hat", swf_assets: [body_specific_asset])
|
||||
|
||||
# Create a universal item (body_id=0)
|
||||
universal_asset = build_item_asset(zones(:background), body_id: 0)
|
||||
universal_item = build_item("Universal Background", swf_assets: [universal_asset])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state, alt_style: @alt_style)
|
||||
outfit.worn_items = [body_specific_item, universal_item]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# Only the universal item should appear
|
||||
expect(layers).to contain_exactly(alt_head, universal_asset)
|
||||
expect(layers).not_to include(body_specific_asset)
|
||||
end
|
||||
|
||||
it "does not include items from the regular pet's body_id" do
|
||||
# Create alt style layers
|
||||
alt_body = build_biology_asset(zones(:body), body_id: 999)
|
||||
@alt_style.swf_assets = [alt_body]
|
||||
pet_state = build_pet_state(@pet_type)
|
||||
|
||||
# Create an item that fits the regular pet's body_id (1)
|
||||
regular_item_asset = build_item_asset(zones(:hat), body_id: 1)
|
||||
regular_item = build_item("Regular Pet Hat", swf_assets: [regular_item_asset])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state, alt_style: @alt_style)
|
||||
outfit.worn_items = [regular_item]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# The regular pet item should not appear on the alt style
|
||||
expect(layers).to contain_exactly(alt_body)
|
||||
expect(layers).not_to include(regular_item_asset)
|
||||
end
|
||||
|
||||
it "applies item restriction rules with alt styles" do
|
||||
# Create alt style layers including hair
|
||||
alt_head = build_biology_asset(zones(:head), body_id: 999)
|
||||
alt_hair = build_biology_asset(zones(:hairfront), body_id: 999)
|
||||
@alt_style.swf_assets = [alt_head, alt_hair]
|
||||
pet_state = build_pet_state(@pet_type)
|
||||
|
||||
# Create a universal hat that restricts the hair zone
|
||||
zones_restrict = "0" * 36 + "1" + "0" * 20 # bit 37 (Hair Front) = 1
|
||||
hat_asset = build_item_asset(zones(:hat), body_id: 0, zones_restrict: zones_restrict)
|
||||
hat = build_item("Hair-hiding Hat", swf_assets: [hat_asset])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state, alt_style: @alt_style)
|
||||
outfit.worn_items = [hat]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# Hair should be hidden by the hat's zone restrictions
|
||||
expect(layers).to contain_exactly(alt_head, hat_asset)
|
||||
expect(layers).not_to include(alt_hair)
|
||||
end
|
||||
|
||||
it "applies pet restriction rules with alt styles" do
|
||||
# Create alt style with a layer that restricts a zone
|
||||
alt_head = build_biology_asset(zones(:head), body_id: 999)
|
||||
zones_restrict = "0" * 47 + "1" + "0" * 10 # bit 48 (Background Item) = 1
|
||||
restricting_layer = build_biology_asset(zones(:body), body_id: 999, zones_restrict: zones_restrict)
|
||||
@alt_style.swf_assets = [alt_head, restricting_layer]
|
||||
pet_state = build_pet_state(@pet_type)
|
||||
|
||||
# Create a universal Background Item
|
||||
bg_item_asset = build_item_asset(zones(:backgrounditem), body_id: 0)
|
||||
bg_item = build_item("Universal Background Item", swf_assets: [bg_item_asset])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state, alt_style: @alt_style)
|
||||
outfit.worn_items = [bg_item]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# body_id=0 items should still appear even in restricted zones
|
||||
# (because they're not body-specific)
|
||||
expect(layers).to contain_exactly(alt_head, restricting_layer, bg_item_asset)
|
||||
end
|
||||
|
||||
it "applies self-restriction rules with alt styles" do
|
||||
# Create alt style that restricts its own horn layer
|
||||
alt_body = build_biology_asset(zones(:body), body_id: 999)
|
||||
alt_horn = build_biology_asset(zones(:headtransientbiology), body_id: 999)
|
||||
zones_restrict = "0" * 37 + "1" + "0" * 20 # bit 38 (Head Transient Biology) = 1
|
||||
restricting_layer = build_biology_asset(zones(:head), body_id: 999, zones_restrict: zones_restrict)
|
||||
@alt_style.swf_assets = [alt_body, alt_horn, restricting_layer]
|
||||
pet_state = build_pet_state(@pet_type)
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state, alt_style: @alt_style)
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# The horn should be hidden by the alt style's own restrictions
|
||||
expect(layers).to contain_exactly(alt_body, restricting_layer)
|
||||
expect(layers).not_to include(alt_horn)
|
||||
end
|
||||
|
||||
it "sorts alt style and item layers by depth correctly" do
|
||||
# Create alt style layers at various depths
|
||||
alt_background = build_biology_asset(zones(:background), body_id: 999) # depth 3
|
||||
alt_body = build_biology_asset(zones(:body), body_id: 999) # depth 18
|
||||
alt_head = build_biology_asset(zones(:head), body_id: 999) # depth 34
|
||||
@alt_style.swf_assets = [alt_head, alt_background, alt_body]
|
||||
pet_state = build_pet_state(@pet_type)
|
||||
|
||||
# Add universal items at various depths
|
||||
bg_item = build_item_asset(zones(:backgrounditem), body_id: 0) # depth 4
|
||||
trinket = build_item_asset(zones(:righthanditem), body_id: 0) # depth 5
|
||||
|
||||
bg = build_item("Background Item", swf_assets: [bg_item])
|
||||
trinket_item = build_item("Trinket", swf_assets: [trinket])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state, alt_style: @alt_style)
|
||||
outfit.worn_items = [trinket_item, bg]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# Expected order by depth:
|
||||
# alt_background (3), bg_item (4), trinket (5), alt_body (18), alt_head (34)
|
||||
expect(layers.map(&:depth)).to eq([3, 4, 5, 18, 34])
|
||||
expect(layers).to eq([alt_background, bg_item, trinket, alt_body, alt_head])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
BIN
vendor/cache/mini_portile2-2.8.9.gem
vendored
BIN
vendor/cache/mini_portile2-2.8.9.gem
vendored
Binary file not shown.
BIN
vendor/cache/ruby-vips-2.3.0.gem
vendored
Normal file
BIN
vendor/cache/ruby-vips-2.3.0.gem
vendored
Normal file
Binary file not shown.
Loading…
Reference in a new issue