Compare commits
10 commits
276cc1b5ea
...
f4417f7fb0
| Author | SHA1 | Date | |
|---|---|---|---|
| f4417f7fb0 | |||
| e8d768961b | |||
| dad185150c | |||
| f96569b2bf | |||
| 58fabad3c2 | |||
| ddb89dc2fa | |||
| 14298fafa9 | |||
| 2dc5505147 | |||
| 0651a2871c | |||
| a00d57bcbb |
21 changed files with 952 additions and 237 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
28
app/assets/javascripts/species-color-picker.js
Normal file
28
app/assets/javascripts/species-color-picker.js
Normal 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);
|
||||
32
app/assets/stylesheets/application/item-badges.css
Normal file
32
app/assets/stylesheets/application/item-badges.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -46,6 +46,7 @@ outfit-viewer
|
|||
img, iframe
|
||||
width: 100%
|
||||
height: 100%
|
||||
border: 0
|
||||
|
||||
.loading-indicator
|
||||
position: absolute
|
||||
|
|
|
|||
275
app/assets/stylesheets/outfits/new_v2.css
Normal file
275
app/assets/stylesheets/outfits/new_v2.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
13
app/views/items/badges/_first_seen.html.haml
Normal file
13
app/views/items/badges/_first_seen.html.haml
Normal 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
|
||||
25
app/views/items/badges/_kind.html.haml
Normal file
25
app/views/items/badges/_kind.html.haml
Normal 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')
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
310
spec/helpers/outfits_helper_spec.rb
Normal file
310
spec/helpers/outfits_helper_spec.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue