Compare commits

...

10 commits

Author SHA1 Message Date
f4417f7fb0 [WV2] Add tests for item sorting & grouping 2025-11-03 00:31:05 +00:00
e8d768961b [WV2] Group items by zone 2025-11-03 00:07:08 +00:00
dad185150c [WV2] Add badges to items 2025-11-02 23:48:39 +00:00
f96569b2bf [WV2] Persist state in URL 2025-11-02 08:55:17 +00:00
58fabad3c2 [WV2] Filter colors, using advanced fallbacks 2025-11-02 08:46:53 +00:00
ddb89dc2fa [WV2] Fix iframe border in outfit-viewer
I think our application reset CSS usually kills this, and maybe our wardrobe v2 CSS should reset too? But seems smart to have it here for consistency.
2025-11-02 08:19:53 +00:00
14298fafa9 [WV2] Extract species-color-picker component 2025-11-02 08:19:16 +00:00
2dc5505147 [WV2] Move species color picker into outfit area 2025-11-02 08:06:27 +00:00
0651a2871c Simplify error handling in wardrobe v2 2025-11-02 07:58:30 +00:00
a00d57bcbb Fix outfit sizing in wardrobe v2 2025-11-02 07:58:11 +00:00
21 changed files with 952 additions and 237 deletions

View file

@ -24,24 +24,6 @@ document.addEventListener("turbo:frame-missing", (e) => {
e.preventDefault();
});
class SpeciesColorPicker extends HTMLElement {
#internals;
constructor() {
super();
this.#internals = this.attachInternals();
}
connectedCallback() {
// Listen for changes to auto-submit the form, then tell CSS about it!
this.addEventListener("change", this.#handleChange);
this.#internals.states.add("auto-loading");
}
#handleChange(e) {
this.querySelector("form").requestSubmit();
}
}
class SpeciesFacePicker extends HTMLElement {
connectedCallback() {
@ -109,7 +91,6 @@ class MeasuredContainer extends HTMLElement {
}
}
customElements.define("species-color-picker", SpeciesColorPicker);
customElements.define("species-face-picker", SpeciesFacePicker);
customElements.define("species-face-picker-options", SpeciesFacePickerOptions);
customElements.define("measured-container", MeasuredContainer);

View file

@ -126,7 +126,7 @@ class OutfitLayer extends HTMLElement {
} else {
throw new Error(
`<outfit-layer> got unexpected status: ` +
JSON.stringify(data.status),
JSON.stringify(data.status),
);
}
} else {
@ -196,7 +196,7 @@ function morphWithOutfitLayers(currentElement, newElement) {
if (
newNode.tagName === "OUTFIT-LAYER" &&
newNode.getAttribute("data-asset-id") !==
currentNode.getAttribute("data-asset-id")
currentNode.getAttribute("data-asset-id")
) {
currentNode.replaceWith(newNode);
return false;
@ -205,10 +205,19 @@ function morphWithOutfitLayers(currentElement, newElement) {
},
});
}
addEventListener("turbo:before-frame-render", (event) => {
function onTurboRender(event) {
// Rather than enforce Idiomorph must be loaded, let's just be resilient
// and only bother if we have it. (Replacing content is not *that* bad!)
if (typeof Idiomorph !== "undefined") {
event.detail.render = (a, b) => morphWithOutfitLayers(a, b);
}
});
}
// On most pages, we only apply this to Turbo frames, to be conservative. (Morphing the whole page is hard!)
addEventListener("turbo:before-frame-render", onTurboRender);
// But on pages that opt into it (namely the wardrobe), we do it for the full page too.
if (document.querySelector('meta[name=outfit-viewer-morph-mode][value=full-page]') !== null) {
addEventListener("turbo:before-render", onTurboRender);
}

View file

@ -0,0 +1,28 @@
/**
* SpeciesColorPicker web component
*
* Progressive enhancement for species/color picker forms:
* - Auto-submits the form when species or color changes (if JS is enabled)
* - Shows a submit button as fallback (if JS is disabled or slow to load)
* - Uses Custom Element internals API to communicate state to CSS
*/
class SpeciesColorPicker extends HTMLElement {
#internals;
constructor() {
super();
this.#internals = this.attachInternals();
}
connectedCallback() {
// Listen for changes to auto-submit the form, then tell CSS about it!
this.addEventListener("change", this.#handleChange);
this.#internals.states.add("auto-loading");
}
#handleChange(e) {
this.querySelector("form").requestSubmit();
}
}
customElements.define("species-color-picker", SpeciesColorPicker);

View file

@ -0,0 +1,32 @@
/*
* Shared item badge styles for NC/NP/PB badges
* Used across item pages, wardrobe, search results, etc.
*
* These colors are from DTI 2020, based on Chakra UI's color palette.
*/
.item-badge {
padding: 0.25em 0.5em;
border-radius: 0.25em;
text-decoration: none;
font-size: .75rem;
font-weight: bold;
line-height: 1;
background: #E2E8F0;
color: #1A202C;
&[data-item-kind="nc"] {
background: #E9D8FD;
color: #44337A;
}
&[data-item-kind="pb"] {
background: #FEEBC8;
color: #7B341E;
}
.icon {
vertical-align: middle;
}
}

View file

@ -46,6 +46,7 @@ outfit-viewer
img, iframe
width: 100%
height: 100%
border: 0
.loading-indicator
position: absolute

View file

@ -0,0 +1,275 @@
@import "../application/item-badges.css";
body.wardrobe-v2 {
margin: 0;
padding: 0;
height: 100vh;
overflow: hidden;
font-family: sans-serif;
}
.wardrobe-container {
display: flex;
height: 100vh;
background: #000;
@media (max-width: 800px) {
flex-direction: column;
}
}
.outfit-preview-section {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #000;
position: relative;
min-height: 400px;
outfit-viewer {
width: 600px;
height: 600px;
max-width: min(100%, 100cqh);
max-height: min(100%, 100cqw);
aspect-ratio: 1;
container-type: size;
}
.no-preview-message {
color: white;
text-align: center;
padding: 2rem;
font-size: 1.2rem;
}
/* Species/color picker floats over the preview at the bottom */
species-color-picker {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
pointer-events: none;
/* Allow clicks through when hidden */
/* Start hidden, reveal on hover or focus */
opacity: 0;
transition: opacity 0.2s;
form {
pointer-events: auto;
/* Re-enable clicks on the form itself */
}
select {
padding: 0.5rem 2rem 0.5rem 0.75rem;
font-size: 1rem;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 0.375rem;
background: rgba(0, 0, 0, 0.7);
color: white;
cursor: pointer;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
background-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1.5em 1.5em;
&:hover {
background-color: rgba(0, 0, 0, 0.8);
border-color: rgba(255, 255, 255, 0.5);
}
&:focus {
outline: none;
border-color: rgba(255, 255, 255, 0.8);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1);
}
option {
background: #2D3748;
color: white;
}
}
/* Submit button: progressive enhancement pattern */
/* If JS is disabled, the button is always visible */
/* If JS is enabled but slow to load, fade in after 0.75s */
/* Once the web component loads, hide the button completely */
input[type="submit"] {
padding: 0.5rem 1rem;
font-size: 1rem;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 0.375rem;
background: rgba(0, 0, 0, 0.7);
color: white;
cursor: pointer;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
&:hover {
background-color: rgba(0, 0, 0, 0.8);
border-color: rgba(255, 255, 255, 0.5);
}
&:focus {
outline: none;
border-color: rgba(255, 255, 255, 0.8);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1);
}
}
/* If JS is enabled, hide the submit button initially with a delay */
@media (scripting: enabled) {
input[type="submit"] {
position: absolute;
margin-left: 0.5em;
opacity: 0;
animation: fade-in 0.25s forwards;
animation-delay: 0.75s;
}
}
/* Once auto-loading is ready, hide the submit button completely */
&:state(auto-loading) {
input[type="submit"] {
display: none;
}
}
}
/* Show picker on hover (real hover only, not simulated touch hover) */
@media (hover: hover) {
&:hover species-color-picker {
opacity: 1;
}
}
/* Show picker when it has focus */
&:has(species-color-picker:focus-within) species-color-picker {
opacity: 1;
}
}
.outfit-controls-section {
width: 400px;
background: #fff;
padding: 2rem;
overflow-y: auto;
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.3);
@media (max-width: 800px) {
width: 100%;
max-height: 40vh;
}
h1 {
margin-top: 0;
font-size: 1.75rem;
color: #448844;
}
h2 {
font-size: 1.25rem;
color: #448844;
margin-top: 2rem;
}
}
.current-selection {
padding: 1rem;
background: #f0f0f0;
border-radius: 4px;
margin: 1rem 0;
p {
margin: 0;
color: #666;
}
strong {
color: #000;
}
}
.worn-items {
margin-top: 2rem;
.items-list {
list-style: none;
padding: 0;
margin: 0.5rem 0;
}
.item-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: #f9f9f9;
margin-bottom: 0.5rem;
border-radius: 8px;
color: #333;
transition: background 0.2s, box-shadow 0.2s;
&:hover {
background: #f0f0f0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
}
.item-thumbnail {
flex-shrink: 0;
width: 50px;
height: 50px;
border-radius: 6px;
overflow: hidden;
background: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
}
.item-info {
flex: 1;
min-width: 0; /* Allow text to truncate */
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.item-name {
font-weight: 500;
color: #2D3748;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-badges {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

View file

@ -1,106 +0,0 @@
body.wardrobe-v2
margin: 0
padding: 0
height: 100vh
overflow: hidden
font-family: sans-serif
.wardrobe-container
display: flex
height: 100vh
background: #000
@media (max-width: 800px)
flex-direction: column
.outfit-preview-section
flex: 1
display: flex
align-items: center
justify-content: center
background: #000
position: relative
min-height: 400px
outfit-viewer
width: 100%
height: 100%
.no-preview-message
color: white
text-align: center
padding: 2rem
font-size: 1.2rem
.outfit-controls-section
width: 400px
background: #fff
padding: 2rem
overflow-y: auto
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.3)
@media (max-width: 800px)
width: 100%
max-height: 40vh
h1
margin-top: 0
font-size: 1.75rem
color: #448844
h2
font-size: 1.25rem
color: #448844
margin-top: 2rem
.species-color-picker
margin: 1.5rem 0
.form-group
margin-bottom: 1rem
label
display: block
font-weight: bold
margin-bottom: 0.5rem
color: #333
select
width: 100%
padding: 0.5rem
font-size: 1rem
border: 1px solid #ccc
border-radius: 4px
font-family: inherit
&:focus
outline: none
border-color: #448844
.current-selection
padding: 1rem
background: #f0f0f0
border-radius: 4px
margin: 1rem 0
p
margin: 0
color: #666
strong
color: #000
.worn-items
margin-top: 2rem
ul
list-style: none
padding: 0
margin: 0.5rem 0
li
padding: 0.5rem
background: #f9f9f9
margin-bottom: 0.5rem
border-radius: 4px
color: #333

View file

@ -1,5 +1,6 @@
@import "../partials/clean/constants"
@import "../partials/clean/mixins"
@import "../application/item-badges"
=item-header
border-bottom: 1px solid $module-border-color
@ -27,7 +28,7 @@
text-align: left
line-height: 100%
margin-bottom: 0
.item-links
grid-area: links
@ -41,32 +42,6 @@
abbr
cursor: help
.item-kind, .first-seen-at
padding: .25em .5em
border-radius: .25em
text-decoration: none
font-weight: bold
line-height: 1
background: #E2E8F0
color: #1A202C
.icon
vertical-align: middle
.item-kind
// These colors are copied from DTI 2020, for initial consistency!
// They're based on the Chakra UI colors, which I think are in turn the
// Bootstrap colors? Or something?
// NOTE: For the data-type=np case, we use the default gray colors.
&[data-type=nc]
background: #E9D8FD
color: #44337A
&[data-type=pb]
background: #FEEBC8
color: #7B341E
.support-form
grid-area: support
font-size: 85%
@ -100,21 +75,21 @@
font-size: 150%
font-weight: bold
margin-bottom: .75em
.closet-hangers-ownership-groups
+clearfix
margin-bottom: .5em
div
float: left
margin: 0 5%
text-align: left
width: 40%
li
list-style: none
word-wrap: break-word
label.unlisted
font-style: italic

View file

@ -78,17 +78,24 @@ class OutfitsController < ApplicationController
end
def new_v2
@colors = Color.alphabetical
@species = Species.alphabetical
# Get selected species and color from params, or default to Blue Acara
species_id = params[:species] || Species.find_by_name("Acara")&.id
color_id = params[:color] || Color.find_by_name("Blue")&.id
@selected_species = params[:species] ? Species.find_by_id(params[:species]) : Species.find_by_name("Acara")
@selected_color = params[:color] ? Color.find_by_id(params[:color]) : Color.find_by_name("Blue")
# Find the pet type (species+color combination)
@selected_species = Species.find_by(id: species_id)
@selected_color = Color.find_by(id: color_id)
@pet_type = PetType.find_by(species_id: species_id, color_id: color_id) if @selected_species && @selected_color
# Load valid colors for the selected species (colors that have existing pet types)
@species = Species.alphabetical
@colors = @selected_species.compatible_colors
# Find the best pet type for this species+color combo
# If the exact combo doesn't exist, this will fall back to a simple color
@pet_type = PetType.for_species_and_color(
species_id: @selected_species.id,
color_id: @selected_color.id
)
# Use the pet type's actual color as the selected color
# (might differ from requested color if we fell back to a simple color)
@selected_color = @pet_type&.color
# Load items from the objects[] parameter
item_ids = params[:objects] || []
@ -102,7 +109,7 @@ class OutfitsController < ApplicationController
# Preload the manifests for all visible layers, so they load efficiently
# in parallel rather than sequentially when rendering
SwfAsset.preload_manifests(@outfit.visible_layers) if @outfit.pet_state
SwfAsset.preload_manifests(@outfit.visible_layers)
render layout: false
end

View file

@ -75,8 +75,133 @@ module OutfitsHelper
locals: parse_outfit_viewer_options(...)
end
# Group outfit items by zone, applying smart multi-zone simplification.
# Returns an array of hashes: {zone:, items:}
# This matches the logic from wardrobe-2020's getZonesAndItems function.
def outfit_items_by_zone(outfit)
return [] if outfit.pet_type.nil?
# Get item appearances for this outfit
item_appearances = Item.appearances_for(
outfit.worn_items,
outfit.pet_type,
swf_asset_includes: [:zone]
)
# Separate incompatible items (no layers for this pet)
compatible_items = []
incompatible_items = []
outfit.worn_items.each do |item|
appearance = item_appearances[item.id]
if appearance&.present?
compatible_items << {item: item, appearance: appearance}
else
incompatible_items << item
end
end
# Group items by zone - multi-zone items appear in each zone
items_by_zone = Hash.new { |h, k| h[k] = [] }
zones_by_id = {}
compatible_items.each do |item_with_appearance|
item = item_with_appearance[:item]
appearance = item_with_appearance[:appearance]
# Get unique zones for this item (an item may have multiple assets per zone)
appearance.swf_assets.map(&:zone).uniq.each do |zone|
zones_by_id[zone.id] = zone
items_by_zone[zone.id] << item
end
end
# Create zone groups with sorted items
zones_and_items = items_by_zone.map do |zone_id, items|
{
zone_id: zone_id,
zone_label: zones_by_id[zone_id].label,
items: items.sort_by { |item| item.name.downcase }
}
end
# Sort zone groups alphabetically by label, then by ID for tiebreaking
zones_and_items.sort_by! do |group|
[group[:zone_label].downcase, group[:zone_id]]
end
# Apply multi-zone simplification: remove redundant single-item groups
zones_and_items = simplify_multi_zone_groups(zones_and_items)
# Add zone ID disambiguation for duplicate labels
zones_and_items = disambiguate_zone_labels(zones_and_items)
# Add incompatible items section if any
if incompatible_items.any?
zones_and_items << {
zone_id: nil,
zone_label: "Incompatible",
items: incompatible_items.sort_by { |item| item.name.downcase }
}
end
zones_and_items
end
private
# Simplify zone groups by removing redundant single-item groups.
# Keep groups with multiple items (conflicts). For single-item groups,
# only keep them if the item doesn't appear in a multi-item group.
def simplify_multi_zone_groups(zones_and_items)
# Find groups with conflicts (multiple items)
groups_with_conflicts = zones_and_items.select { |g| g[:items].length > 1 }
# Track which items appear in conflict groups
items_with_conflicts = Set.new(
groups_with_conflicts.flat_map { |g| g[:items].map(&:id) }
)
# Track which items we've already shown
items_we_have_seen = Set.new
# Filter groups
zones_and_items.select do |group|
# Always keep groups with multiple items
if group[:items].length > 1
group[:items].each { |item| items_we_have_seen.add(item.id) }
true
else
# For single-item groups, only keep if:
# - Item hasn't been seen yet AND
# - Item won't appear in a conflict group
item = group[:items].first
item_id = item.id
if items_we_have_seen.include?(item_id) || items_with_conflicts.include?(item_id)
false
else
items_we_have_seen.add(item_id)
true
end
end
end
end
# Add zone IDs to labels when there are duplicates
def disambiguate_zone_labels(zones_and_items)
label_counts = zones_and_items.group_by { |g| g[:zone_label] }
.transform_values(&:count)
zones_and_items.each do |group|
if label_counts[group[:zone_label]] > 1
group[:zone_label] = "#{group[:zone_label]} (##{group[:zone_id]})"
end
end
zones_and_items
end
def parse_outfit_viewer_options(
outfit=nil, pet_state: nil, preferred_image_format: :png, **html_options
)

View file

@ -170,6 +170,8 @@ class Outfit < ApplicationRecord
end
def visible_layers
return [] if pet_state.nil?
# 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

View file

@ -48,6 +48,26 @@ class PetType < ApplicationRecord
random_pet_types
end
# Given a species ID and color ID, return the best matching PetType.
#
# If the exact species+color combo exists, return it.
# Otherwise, find the best fallback for that species:
# - Prefer the requested color if available
# - Otherwise prefer simple colors (basic > standard > alphabetical)
#
# This matches the wardrobe behavior where we automatically switch to a valid
# color when the user selects a species that doesn't support their current color.
#
# Returns the PetType, or nil if no pet types exist for this species.
def self.for_species_and_color(species_id:, color_id:)
return nil if species_id.nil?
where(species_id: species_id)
.preferring_color(color_id)
.preferring_simple
.first
end
def as_json(options={})
super({
only: [:id],

View file

@ -34,4 +34,13 @@ class Species < ApplicationRecord
def self.param_to_id(param)
param.match?(/\A\d+\Z/) ? param.to_i : find_by_name!(param).id
end
# Get all colors that are compatible with this species (have pet types)
# Returns an ActiveRecord::Relation of Color records
def compatible_colors
Color.alphabetical
.joins(:pet_types)
.where(pet_types: { species_id: id })
.distinct
end
end

View file

@ -9,26 +9,8 @@
= image_tag item.thumbnail_url, class: 'item-thumbnail'
%h2.item-name= item.name
%nav.item-links
- if item.currently_in_mall?
= link_to "https://ncmall.neopets.com/", class: "item-kind", data: {type: "nc"},
title: "Currently in NC Mall!", target: "_blank" do
= cart_icon alt: "Buy"
#{item.current_nc_price} NC
- elsif item.nc?
%abbr.item-kind{'data-type' => 'nc', title: t('items.show.item_kinds.nc.description')}
= t('items.show.item_kinds.nc.label')
- elsif item.pb?
%abbr.item-kind{'data-type' => 'pb', title: t('items.show.item_kinds.pb.description')}
= t('items.show.item_kinds.pb.label')
- else
%abbr.item-kind{'data-type' => 'np', title: t('items.show.item_kinds.np.description')}
= t('items.show.item_kinds.np.label')
- if item.created_at?
%time.first-seen-at{
datetime: item.created_at.iso8601,
title: "First seen on #{item.created_at.to_date.to_fs(:long)}",
}= time_with_only_month_if_old item.created_at
= render "items/badges/kind", item: item
= render "items/badges/first_seen", item: item
= link_to t('items.show.resources.jn_items'), jn_items_url_for(item)
= link_to t('items.show.resources.impress_2020'), impress_2020_url_for(item)

View file

@ -0,0 +1,13 @@
-# Renders a "first seen" timestamp badge for an item
-#
-# Usage:
-# = render "items/badges/first_seen", item: @item
-#
-# Shows when the item was first added to the database.
-# Only renders if the item has a created_at timestamp.
- if item.created_at?
%time.item-badge.first-seen-at{
datetime: item.created_at.iso8601,
title: "First seen on #{item.created_at.to_date.to_fs(:long)}",
}= time_with_only_month_if_old item.created_at

View file

@ -0,0 +1,25 @@
-# Renders an item kind badge (NC/NP/PB)
-#
-# Usage:
-# = render "items/item_kind_badge", item: @item
-#
-# The badge shows:
-# - For NC Mall items: clickable link with price
-# - For NC items: purple "NC" badge
-# - For PB items: orange "PB" badge
-# - For NP items: gray "NP" badge
- if item.currently_in_mall?
= link_to "https://ncmall.neopets.com/", class: "item-badge", data: {item_kind: "nc"},
title: "Currently in NC Mall!", target: "_blank" do
= cart_icon alt: "Buy"
#{item.current_nc_price} NC
- elsif item.nc?
%abbr.item-badge{data: {item_kind: "nc"}, title: t('items.show.item_kinds.nc.description')}
= t('items.show.item_kinds.nc.label')
- elsif item.pb?
%abbr.item-badge{data: {item_kind: "pb"}, title: t('items.show.item_kinds.pb.description')}
= t('items.show.item_kinds.pb.label')
- else
%abbr.item-badge{data: {item_kind: "np"}, title: t('items.show.item_kinds.np.description')}
= t('items.show.item_kinds.np.label')

View file

@ -131,4 +131,5 @@
- content_for :javascripts do
= javascript_include_tag "idiomorph", async: true
= javascript_include_tag "outfit-viewer", async: true
= javascript_include_tag "species-color-picker", async: true
= javascript_include_tag "items/show", async: true

View file

@ -10,59 +10,58 @@
= stylesheet_link_tag "application/hanger-spinner"
= stylesheet_link_tag "application/outfit-viewer"
= page_stylesheet_link_tag "outfits/new_v2"
= csrf_meta_tags
= javascript_include_tag "application", async: true
= javascript_include_tag "idiomorph", async: true
= javascript_include_tag "outfit-viewer", async: true
= javascript_include_tag "species-color-picker", async: true
= javascript_include_tag "outfits/new_v2", async: true
= csrf_meta_tags
%meta{name: 'outfit-viewer-morph-mode', value: 'full-page'}
%body.wardrobe-v2
= turbo_frame_tag "outfit-editor" do
.wardrobe-container
.outfit-preview-section
- if @outfit.pet_state
= outfit_viewer @outfit
- elsif @pet_type.nil?
.no-preview-message
%p
We haven't seen this kind of pet before! Try a different species/color
combination.
- else
.no-preview-message
%p Loading...
.wardrobe-container
.outfit-preview-section
- if @pet_type.nil?
.no-preview-message
%p
We haven't seen this kind of pet before! Try a different species/color
combination.
- else
= outfit_viewer @outfit
.outfit-controls-section
%h1 Customize your pet
%species-color-picker
= form_with url: wardrobe_v2_path, method: :get do |f|
= select_tag :color,
options_from_collection_for_select(@colors, "id", "human_name",
@selected_color&.id),
"aria-label": "Pet color"
= select_tag :species,
options_from_collection_for_select(@species, "id", "human_name",
@selected_species&.id),
"aria-label": "Pet species"
= submit_tag "Go", name: nil
.species-color-picker
= form_with url: wardrobe_v2_path, method: :get do |f|
.form-group
= label_tag :species, "Species:"
= select_tag :species,
options_from_collection_for_select(@species, "id", "human_name",
@selected_species&.id),
onchange: "this.form.requestSubmit()"
-# Preserve item IDs in the URL
- if params[:objects].present?
- params[:objects].each do |item_id|
= hidden_field_tag "objects[]", item_id
.form-group
= label_tag :color, "Color:"
= select_tag :color,
options_from_collection_for_select(@colors, "id", "human_name",
@selected_color&.id),
onchange: "this.form.requestSubmit()"
.outfit-controls-section
%h1 Customize your pet
-# Preserve item IDs in the URL
- if params[:objects].present?
- params[:objects].each do |item_id|
= hidden_field_tag "objects[]", item_id
- if @outfit.worn_items.any?
.worn-items
%h2 Items (#{@outfit.worn_items.size})
- if @outfit.pet_state
.current-selection
%p
Currently showing:
%strong= "#{@selected_color.human_name} #{@selected_species.human_name}"
- if @outfit.worn_items.any?
.worn-items
%h2 Items (#{@outfit.worn_items.size})
%ul
- @outfit.worn_items.each do |item|
%li= item.name
- outfit_items_by_zone(@outfit).each do |zone_group|
.zone-group
%h3.zone-label= zone_group[:zone_label]
%ul.items-list
- zone_group[:items].each do |item|
%li.item-card
.item-thumbnail
= image_tag item.thumbnail_url, alt: item.name, loading: "lazy"
.item-info
.item-name= item.name
.item-badges
= render "items/badges/kind", item: item
= render "items/badges/first_seen", item: item

View file

@ -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

View file

@ -38,4 +38,23 @@ RSpec.describe PetType do
expect(PetType.find_by_param!("123-456")).to eq pet_types(:newcolor_newspecies)
end
end
describe ".for_species_and_color" do
it('returns the exact match when it exists') do
result = PetType.for_species_and_color(species_id: species(:acara), color_id: colors(:blue))
expect(result).to eq pet_types(:blue_acara)
end
it('returns nil when species is nil') do
result = PetType.for_species_and_color(species_id: nil, color_id: colors(:blue))
expect(result).to be_nil
end
it('falls back to a simple color when exact match does not exist') do
# Request a species that exists but with a color that might not
# It should fall back to a basic/standard color for that species
result = PetType.for_species_and_color(species_id: species(:acara), color_id: 999)
expect(result).to eq pet_types(:blue_acara)
end
end
end

View file

@ -1,7 +1,15 @@
require_relative '../rails_helper'
RSpec.describe Species do
fixtures :species
fixtures :species, :colors
describe "#valid_colors_for_species" do
it('returns colors that have pet types for the species') do
# The Blue Acara exists in fixtures, as does a "Color #123 Acara", which we'll ignore.
compatible_colors = species(:acara).compatible_colors
expect(compatible_colors.map(&:id)).to eq [8]
end
end
describe '#to_param' do
it("uses name when possible") do