From 368418da6ccd9a82550de6d52542a09ecb84d357 Mon Sep 17 00:00:00 2001 From: omnifaced Date: Sat, 17 Oct 2026 08:37:43 +0300 Subject: [PATCH] feat: wardrobe picker for avatar decoration and profile frame --- packages/i18n/src/core/am.json | 28 +- packages/i18n/src/core/de.json | 28 +- packages/i18n/src/core/en.json | 28 +- packages/i18n/src/core/en_tengwar.json | 28 +- packages/i18n/src/core/es.json | 28 +- packages/i18n/src/core/jp.json | 28 +- packages/i18n/src/core/kk.json | 28 +- packages/i18n/src/core/ko.json | 28 +- packages/i18n/src/core/pl.json | 28 +- packages/i18n/src/core/ru.json | 28 +- packages/i18n/src/core/ru_pt.json | 28 +- packages/i18n/src/core/uz.json | 28 +- src/components/SettingsWindow.vue | 12 +- src/components/settings/ProfileSettings.vue | 11 +- .../settings/cosmetics/CosmeticCell.vue | 93 ++ .../cosmetics/CosmeticPickerDialog.vue | 904 ++++++++++++++++++ .../settings/cosmetics/CosmeticRowPreview.vue | 138 +++ .../settings/cosmetics/CosmeticsPanel.vue | 256 +++++ .../settings/cosmetics/cosmeticLock.ts | 38 + .../settings/cosmetics/rovingRadio.ts | 44 + .../settings/cosmetics/wardrobeChange.ts | 53 + src/cosmetics/CosmeticLookPreview.vue | 218 +++++ src/cosmetics/CosmeticPreview.vue | 422 ++++++++ src/cosmetics/authoredCard.ts | 2 + src/cosmetics/preview/leadingGraphemes.ts | 18 + src/cosmetics/preview/previewUserId.ts | 2 + src/cosmetics/preview/stages/AvatarStage.vue | 78 ++ .../preview/stages/MemberRowStage.vue | 152 +++ src/cosmetics/preview/stages/MessageStage.vue | 151 +++ src/lib/cosmeticText.ts | 30 + src/lib/modalLayers.ts | 110 +++ src/lib/refusals.ts | 15 + test/components/CosmeticPickerDialog.test.ts | 488 ++++++++++ test/components/CosmeticsPanel.test.ts | 312 ++++++ test/cosmetics/cosmeticText.test.ts | 42 + test/cosmetics/leadingGraphemes.test.ts | 24 + test/cosmetics/wardrobeChange.test.ts | 98 ++ test/lib/cosmeticErrorKey.test.ts | 45 + test/lib/modalLayers.test.ts | 249 +++++ 39 files changed, 4326 insertions(+), 15 deletions(-) create mode 100644 src/components/settings/cosmetics/CosmeticCell.vue create mode 100644 src/components/settings/cosmetics/CosmeticPickerDialog.vue create mode 100644 src/components/settings/cosmetics/CosmeticRowPreview.vue create mode 100644 src/components/settings/cosmetics/CosmeticsPanel.vue create mode 100644 src/components/settings/cosmetics/cosmeticLock.ts create mode 100644 src/components/settings/cosmetics/rovingRadio.ts create mode 100644 src/components/settings/cosmetics/wardrobeChange.ts create mode 100644 src/cosmetics/CosmeticLookPreview.vue create mode 100644 src/cosmetics/CosmeticPreview.vue create mode 100644 src/cosmetics/authoredCard.ts create mode 100644 src/cosmetics/preview/leadingGraphemes.ts create mode 100644 src/cosmetics/preview/previewUserId.ts create mode 100644 src/cosmetics/preview/stages/AvatarStage.vue create mode 100644 src/cosmetics/preview/stages/MemberRowStage.vue create mode 100644 src/cosmetics/preview/stages/MessageStage.vue create mode 100644 src/lib/cosmeticText.ts create mode 100644 src/lib/modalLayers.ts create mode 100644 test/components/CosmeticPickerDialog.test.ts create mode 100644 test/components/CosmeticsPanel.test.ts create mode 100644 test/cosmetics/cosmeticText.test.ts create mode 100644 test/cosmetics/leadingGraphemes.test.ts create mode 100644 test/cosmetics/wardrobeChange.test.ts create mode 100644 test/lib/cosmeticErrorKey.test.ts create mode 100644 test/lib/modalLayers.test.ts diff --git a/packages/i18n/src/core/am.json b/packages/i18n/src/core/am.json index 364165fa..fc14f2a9 100644 --- a/packages/i18n/src/core/am.json +++ b/packages/i18n/src/core/am.json @@ -2504,5 +2504,31 @@ "setting_variant_windowmaterial_acrylic": "Ակրիլ", "setting_variant_windowmaterial_mica": "Mica", "setting_variant_windowmaterial_glass": "Ապակի", - "setting_variant_windowmaterial_solid": "Միագույն" + "setting_variant_windowmaterial_solid": "Միագույն", + "apply": "Կիրառել", + "cosmetic_wardrobe": "Զգեստապահարան", + "cosmetic_wardrobe_hint": "Այն, ինչ կրում եք ձեր ավատարի, պրոֆիլի քարտի և անվան վրա։", + "cosmetic_kind_avatar_decoration": "Ավատարի զարդ", + "cosmetic_kind_profile_frame": "Պրոֆիլի շրջանակ", + "cosmetic_kind_nickname_style": "Անվան ոճ", + "cosmetic_kind_option_font": "Տառատեսակներ", + "cosmetic_kind_option_text_effect": "Անվան էֆեկտներ", + "cosmetic_clear": "Հանել", + "cosmetic_locked": "Արգելափակված է", + "cosmetic_locked_ultima": "Ultima-ով", + "cosmetic_locked_grant": "Դեռ ձերը չէ", + "cosmetic_available_until": "Հասանելի է մինչև {date}", + "cosmetic_surface_card": "Պրոֆիլի քարտ", + "cosmetic_surface_avatar": "Ավատար", + "cosmetic_surface_message": "Զրուցարանում", + "cosmetic_surface_member_row": "Մասնակիցների ցանկ", + "cosmetic_equip_failed": "Չհաջողվեց հագնել", + "cosmetic_error_network": "Չհաջողվեց կապ հաստատել սերվերի հետ։ Փորձեք կրկին։", + "cosmetic_error_not_found": "Այս իրն այլևս գոյություն չունի։", + "cosmetic_error_unknown_kind": "Այս տեսակի կոսմետիկան չի աջակցվում։", + "cosmetic_error_kind_disabled": "Այս տեսակի կոսմետիկան այժմ անջատված է։", + "cosmetic_error_item_unavailable": "Այս իրն այլևս հասանելի չէ։", + "cosmetic_error_not_owned": "Դուք դեռ չունեք այս իրը։", + "cosmetic_error_choice_invalid": "Ընտրված տարբերակներից մեկն այստեղ չի համապատասխանում։", + "cosmetic_error_value_out_of_range": "Կարգավորումներից մեկը թույլատրելի սահմաններից դուրս է։" } diff --git a/packages/i18n/src/core/de.json b/packages/i18n/src/core/de.json index a1f3447b..3ec54810 100644 --- a/packages/i18n/src/core/de.json +++ b/packages/i18n/src/core/de.json @@ -2504,5 +2504,31 @@ "setting_variant_windowmaterial_acrylic": "Acryl", "setting_variant_windowmaterial_mica": "Mica", "setting_variant_windowmaterial_glass": "Glas", - "setting_variant_windowmaterial_solid": "Einfarbig" + "setting_variant_windowmaterial_solid": "Einfarbig", + "apply": "Anwenden", + "cosmetic_wardrobe": "Garderobe", + "cosmetic_wardrobe_hint": "Was du an deinem Avatar, auf deiner Profilkarte und an deinem Namen trägst.", + "cosmetic_kind_avatar_decoration": "Avatar-Dekoration", + "cosmetic_kind_profile_frame": "Profilrahmen", + "cosmetic_kind_nickname_style": "Namensstil", + "cosmetic_kind_option_font": "Schriftarten", + "cosmetic_kind_option_text_effect": "Namenseffekte", + "cosmetic_clear": "Ablegen", + "cosmetic_locked": "Gesperrt", + "cosmetic_locked_ultima": "Mit Ultima", + "cosmetic_locked_grant": "Noch nicht deins", + "cosmetic_available_until": "Verfügbar bis {date}", + "cosmetic_surface_card": "Profilkarte", + "cosmetic_surface_avatar": "Avatar", + "cosmetic_surface_message": "Im Chat", + "cosmetic_surface_member_row": "Mitgliederliste", + "cosmetic_equip_failed": "Konnte nicht angelegt werden", + "cosmetic_error_network": "Server nicht erreichbar. Versuch es noch einmal.", + "cosmetic_error_not_found": "Dieses Item gibt es nicht mehr.", + "cosmetic_error_unknown_kind": "Diese Art von Kosmetik wird nicht unterstützt.", + "cosmetic_error_kind_disabled": "Diese Art von Kosmetik ist gerade ausgeschaltet.", + "cosmetic_error_item_unavailable": "Dieses Item ist nicht mehr verfügbar.", + "cosmetic_error_not_owned": "Du hast dieses Item noch nicht.", + "cosmetic_error_choice_invalid": "Eine der gewählten Optionen passt hier nicht.", + "cosmetic_error_value_out_of_range": "Eine der Einstellungen liegt außerhalb des erlaubten Bereichs." } diff --git a/packages/i18n/src/core/en.json b/packages/i18n/src/core/en.json index 8724b295..f8f83e27 100644 --- a/packages/i18n/src/core/en.json +++ b/packages/i18n/src/core/en.json @@ -63,6 +63,7 @@ "create_new_server": "Create New Server", "announcement": "Announcement", "privacy_settings": "Privacy Settings", + "apply": "Apply", "cancel": "Cancel", "voice": "Voice", "start_video": "Start Video", @@ -2546,5 +2547,30 @@ "setting_variant_windowmaterial_acrylic": "Acrylic", "setting_variant_windowmaterial_mica": "Mica", "setting_variant_windowmaterial_glass": "Glass", - "setting_variant_windowmaterial_solid": "Solid" + "setting_variant_windowmaterial_solid": "Solid", + "cosmetic_wardrobe": "Wardrobe", + "cosmetic_wardrobe_hint": "What you wear on your avatar, your profile card and your name.", + "cosmetic_kind_avatar_decoration": "Avatar decoration", + "cosmetic_kind_profile_frame": "Profile frame", + "cosmetic_kind_nickname_style": "Name style", + "cosmetic_kind_option_font": "Fonts", + "cosmetic_kind_option_text_effect": "Name effects", + "cosmetic_clear": "Take off", + "cosmetic_locked": "Locked", + "cosmetic_locked_ultima": "With Ultima", + "cosmetic_locked_grant": "Not yours yet", + "cosmetic_available_until": "Available until {date}", + "cosmetic_surface_card": "Profile card", + "cosmetic_surface_avatar": "Avatar", + "cosmetic_surface_message": "In chat", + "cosmetic_surface_member_row": "Member list", + "cosmetic_equip_failed": "That could not be put on", + "cosmetic_error_network": "Could not reach the server. Try again.", + "cosmetic_error_not_found": "That item no longer exists.", + "cosmetic_error_unknown_kind": "This kind of cosmetic is not supported.", + "cosmetic_error_kind_disabled": "This kind of cosmetic is switched off right now.", + "cosmetic_error_item_unavailable": "That item is no longer available.", + "cosmetic_error_not_owned": "You don't have that item yet.", + "cosmetic_error_choice_invalid": "One of the chosen options can't be used here.", + "cosmetic_error_value_out_of_range": "One of the settings is out of range." } diff --git a/packages/i18n/src/core/en_tengwar.json b/packages/i18n/src/core/en_tengwar.json index 44dfc6ae..da171d5b 100644 --- a/packages/i18n/src/core/en_tengwar.json +++ b/packages/i18n/src/core/en_tengwar.json @@ -63,6 +63,7 @@ "create_new_server": "  ", "announcement": "", "privacy_settings": " ", + "apply": "", "cancel": "", "voice": "", "start_video": " ", @@ -2546,5 +2547,30 @@ "setting_variant_windowmaterial_acrylic": "", "setting_variant_windowmaterial_mica": "", "setting_variant_windowmaterial_glass": "", - "setting_variant_windowmaterial_solid": "" + "setting_variant_windowmaterial_solid": "", + "cosmetic_wardrobe": "", + "cosmetic_wardrobe_hint": "      ⸱       :", + "cosmetic_kind_avatar_decoration": " ", + "cosmetic_kind_profile_frame": " ", + "cosmetic_kind_nickname_style": " ", + "cosmetic_kind_option_font": "", + "cosmetic_kind_option_text_effect": " ", + "cosmetic_clear": " ", + "cosmetic_locked": "", + "cosmetic_locked_ultima": " ", + "cosmetic_locked_grant": "  ", + "cosmetic_available_until": "  {date}", + "cosmetic_surface_card": " ", + "cosmetic_surface_avatar": "", + "cosmetic_surface_message": " ", + "cosmetic_surface_member_row": " ", + "cosmetic_equip_failed": "     ", + "cosmetic_error_network": "     :   :", + "cosmetic_error_not_found": "     :", + "cosmetic_error_unknown_kind": "       :", + "cosmetic_error_kind_disabled": "         :", + "cosmetic_error_item_unavailable": "      :", + "cosmetic_error_not_owned": "      :", + "cosmetic_error_choice_invalid": "        :", + "cosmetic_error_value_out_of_range": "      :" } diff --git a/packages/i18n/src/core/es.json b/packages/i18n/src/core/es.json index f8bc396e..24443758 100644 --- a/packages/i18n/src/core/es.json +++ b/packages/i18n/src/core/es.json @@ -2504,5 +2504,31 @@ "setting_variant_windowmaterial_acrylic": "Acrílico", "setting_variant_windowmaterial_mica": "Mica", "setting_variant_windowmaterial_glass": "Cristal", - "setting_variant_windowmaterial_solid": "Sólido" + "setting_variant_windowmaterial_solid": "Sólido", + "apply": "Aplicar", + "cosmetic_wardrobe": "Vestuario", + "cosmetic_wardrobe_hint": "Lo que llevas en tu avatar, en tu tarjeta de perfil y en tu nombre.", + "cosmetic_kind_avatar_decoration": "Decoración de avatar", + "cosmetic_kind_profile_frame": "Marco de perfil", + "cosmetic_kind_nickname_style": "Estilo del nombre", + "cosmetic_kind_option_font": "Fuentes", + "cosmetic_kind_option_text_effect": "Efectos del nombre", + "cosmetic_clear": "Quitar", + "cosmetic_locked": "Bloqueado", + "cosmetic_locked_ultima": "Con Ultima", + "cosmetic_locked_grant": "Aún no es tuyo", + "cosmetic_available_until": "Disponible hasta {date}", + "cosmetic_surface_card": "Tarjeta de perfil", + "cosmetic_surface_avatar": "Avatar", + "cosmetic_surface_message": "En el chat", + "cosmetic_surface_member_row": "Lista de miembros", + "cosmetic_equip_failed": "No se pudo poner", + "cosmetic_error_network": "No se pudo conectar con el servidor. Inténtalo de nuevo.", + "cosmetic_error_not_found": "Ese objeto ya no existe.", + "cosmetic_error_unknown_kind": "Este tipo de cosmético no es compatible.", + "cosmetic_error_kind_disabled": "Este tipo de cosmético está desactivado ahora mismo.", + "cosmetic_error_item_unavailable": "Ese objeto ya no está disponible.", + "cosmetic_error_not_owned": "Todavía no tienes ese objeto.", + "cosmetic_error_choice_invalid": "Una de las opciones elegidas no sirve aquí.", + "cosmetic_error_value_out_of_range": "Uno de los ajustes está fuera de rango." } diff --git a/packages/i18n/src/core/jp.json b/packages/i18n/src/core/jp.json index 679ac620..e4c20128 100644 --- a/packages/i18n/src/core/jp.json +++ b/packages/i18n/src/core/jp.json @@ -2504,5 +2504,31 @@ "setting_variant_windowmaterial_acrylic": "アクリル", "setting_variant_windowmaterial_mica": "Mica", "setting_variant_windowmaterial_glass": "ガラス", - "setting_variant_windowmaterial_solid": "単色" + "setting_variant_windowmaterial_solid": "単色", + "apply": "適用", + "cosmetic_wardrobe": "ワードローブ", + "cosmetic_wardrobe_hint": "アバター、プロフィールカード、名前に身につけるもの。", + "cosmetic_kind_avatar_decoration": "アバターデコレーション", + "cosmetic_kind_profile_frame": "プロフィールフレーム", + "cosmetic_kind_nickname_style": "名前のスタイル", + "cosmetic_kind_option_font": "フォント", + "cosmetic_kind_option_text_effect": "名前のエフェクト", + "cosmetic_clear": "外す", + "cosmetic_locked": "ロック中", + "cosmetic_locked_ultima": "Ultima で利用可能", + "cosmetic_locked_grant": "まだ持っていません", + "cosmetic_available_until": "{date} まで利用可能", + "cosmetic_surface_card": "プロフィールカード", + "cosmetic_surface_avatar": "アバター", + "cosmetic_surface_message": "チャット", + "cosmetic_surface_member_row": "メンバーリスト", + "cosmetic_equip_failed": "身につけられませんでした", + "cosmetic_error_network": "サーバーに接続できませんでした。もう一度お試しください。", + "cosmetic_error_not_found": "このアイテムはもう存在しません。", + "cosmetic_error_unknown_kind": "この種類のコスメティックには対応していません。", + "cosmetic_error_kind_disabled": "この種類のコスメティックは現在オフになっています。", + "cosmetic_error_item_unavailable": "このアイテムはもう利用できません。", + "cosmetic_error_not_owned": "このアイテムはまだ持っていません。", + "cosmetic_error_choice_invalid": "選んだオプションのひとつはここでは使えません。", + "cosmetic_error_value_out_of_range": "設定のひとつが範囲外です。" } diff --git a/packages/i18n/src/core/kk.json b/packages/i18n/src/core/kk.json index 2bfa463b..06efeae9 100644 --- a/packages/i18n/src/core/kk.json +++ b/packages/i18n/src/core/kk.json @@ -2504,5 +2504,31 @@ "setting_variant_windowmaterial_acrylic": "Акрил", "setting_variant_windowmaterial_mica": "Mica", "setting_variant_windowmaterial_glass": "Шыны", - "setting_variant_windowmaterial_solid": "Тұтас" + "setting_variant_windowmaterial_solid": "Тұтас", + "apply": "Қолдану", + "cosmetic_wardrobe": "Гардероб", + "cosmetic_wardrobe_hint": "Аватарыңызда, профиль картаңызда және атыңызда не киетініңіз.", + "cosmetic_kind_avatar_decoration": "Аватар әшекейі", + "cosmetic_kind_profile_frame": "Профиль жиегі", + "cosmetic_kind_nickname_style": "Ат стилі", + "cosmetic_kind_option_font": "Қаріптер", + "cosmetic_kind_option_text_effect": "Ат эффектілері", + "cosmetic_clear": "Шешу", + "cosmetic_locked": "Құлыпталған", + "cosmetic_locked_ultima": "Ultima арқылы", + "cosmetic_locked_grant": "Әзірге сіздікі емес", + "cosmetic_available_until": "{date} дейін қолжетімді", + "cosmetic_surface_card": "Профиль картасы", + "cosmetic_surface_avatar": "Аватар", + "cosmetic_surface_message": "Чатта", + "cosmetic_surface_member_row": "Қатысушылар тізімі", + "cosmetic_equip_failed": "Кию мүмкін болмады", + "cosmetic_error_network": "Сервермен байланыс орнатылмады. Қайталап көріңіз.", + "cosmetic_error_not_found": "Бұл зат енді жоқ.", + "cosmetic_error_unknown_kind": "Косметиканың бұл түріне қолдау көрсетілмейді.", + "cosmetic_error_kind_disabled": "Косметиканың бұл түрі қазір өшірулі.", + "cosmetic_error_item_unavailable": "Бұл зат енді қолжетімсіз.", + "cosmetic_error_not_owned": "Сізде бұл зат әлі жоқ.", + "cosmetic_error_choice_invalid": "Таңдалған нұсқалардың бірі мұнда жарамайды.", + "cosmetic_error_value_out_of_range": "Баптаулардың бірі рұқсат етілген ауқымнан тыс." } diff --git a/packages/i18n/src/core/ko.json b/packages/i18n/src/core/ko.json index 0b08cfc5..678a86d0 100644 --- a/packages/i18n/src/core/ko.json +++ b/packages/i18n/src/core/ko.json @@ -2504,5 +2504,31 @@ "setting_variant_windowmaterial_acrylic": "아크릴", "setting_variant_windowmaterial_mica": "Mica", "setting_variant_windowmaterial_glass": "유리", - "setting_variant_windowmaterial_solid": "단색" + "setting_variant_windowmaterial_solid": "단색", + "apply": "적용", + "cosmetic_wardrobe": "옷장", + "cosmetic_wardrobe_hint": "아바타, 프로필 카드, 이름에 착용하는 것들입니다.", + "cosmetic_kind_avatar_decoration": "아바타 장식", + "cosmetic_kind_profile_frame": "프로필 테두리", + "cosmetic_kind_nickname_style": "이름 스타일", + "cosmetic_kind_option_font": "글꼴", + "cosmetic_kind_option_text_effect": "이름 효과", + "cosmetic_clear": "벗기", + "cosmetic_locked": "잠김", + "cosmetic_locked_ultima": "Ultima 전용", + "cosmetic_locked_grant": "아직 보유하지 않음", + "cosmetic_available_until": "{date}까지 이용 가능", + "cosmetic_surface_card": "프로필 카드", + "cosmetic_surface_avatar": "아바타", + "cosmetic_surface_message": "채팅", + "cosmetic_surface_member_row": "멤버 목록", + "cosmetic_equip_failed": "착용하지 못했습니다", + "cosmetic_error_network": "서버에 연결할 수 없습니다. 다시 시도해 주세요.", + "cosmetic_error_not_found": "더 이상 존재하지 않는 아이템입니다.", + "cosmetic_error_unknown_kind": "지원하지 않는 종류의 꾸미기 아이템입니다.", + "cosmetic_error_kind_disabled": "이 종류의 꾸미기 아이템은 현재 꺼져 있습니다.", + "cosmetic_error_item_unavailable": "더 이상 사용할 수 없는 아이템입니다.", + "cosmetic_error_not_owned": "아직 이 아이템을 가지고 있지 않습니다.", + "cosmetic_error_choice_invalid": "선택한 옵션 중 하나를 여기에서 사용할 수 없습니다.", + "cosmetic_error_value_out_of_range": "설정 값 중 하나가 허용 범위를 벗어났습니다." } diff --git a/packages/i18n/src/core/pl.json b/packages/i18n/src/core/pl.json index 2d124519..606555f7 100644 --- a/packages/i18n/src/core/pl.json +++ b/packages/i18n/src/core/pl.json @@ -2504,5 +2504,31 @@ "setting_variant_windowmaterial_acrylic": "Akryl", "setting_variant_windowmaterial_mica": "Mica", "setting_variant_windowmaterial_glass": "Szkło", - "setting_variant_windowmaterial_solid": "Jednolity" + "setting_variant_windowmaterial_solid": "Jednolity", + "apply": "Zastosuj", + "cosmetic_wardrobe": "Garderoba", + "cosmetic_wardrobe_hint": "To, co nosisz na awatarze, na karcie profilu i przy nazwie.", + "cosmetic_kind_avatar_decoration": "Ozdoba awatara", + "cosmetic_kind_profile_frame": "Ramka profilu", + "cosmetic_kind_nickname_style": "Styl nazwy", + "cosmetic_kind_option_font": "Czcionki", + "cosmetic_kind_option_text_effect": "Efekty nazwy", + "cosmetic_clear": "Zdejmij", + "cosmetic_locked": "Zablokowane", + "cosmetic_locked_ultima": "Z Ultima", + "cosmetic_locked_grant": "Jeszcze nie twoje", + "cosmetic_available_until": "Dostępne do {date}", + "cosmetic_surface_card": "Karta profilu", + "cosmetic_surface_avatar": "Awatar", + "cosmetic_surface_message": "Na czacie", + "cosmetic_surface_member_row": "Lista członków", + "cosmetic_equip_failed": "Nie udało się założyć", + "cosmetic_error_network": "Nie udało się połączyć z serwerem. Spróbuj ponownie.", + "cosmetic_error_not_found": "Ten przedmiot już nie istnieje.", + "cosmetic_error_unknown_kind": "Ten rodzaj kosmetyku nie jest obsługiwany.", + "cosmetic_error_kind_disabled": "Ten rodzaj kosmetyku jest teraz wyłączony.", + "cosmetic_error_item_unavailable": "Ten przedmiot nie jest już dostępny.", + "cosmetic_error_not_owned": "Nie masz jeszcze tego przedmiotu.", + "cosmetic_error_choice_invalid": "Jedna z wybranych opcji tu nie pasuje.", + "cosmetic_error_value_out_of_range": "Jedno z ustawień jest poza zakresem." } diff --git a/packages/i18n/src/core/ru.json b/packages/i18n/src/core/ru.json index 47804f67..433af7e9 100644 --- a/packages/i18n/src/core/ru.json +++ b/packages/i18n/src/core/ru.json @@ -7,6 +7,7 @@ "create_new_server": "Создать новый сервер", "announcement": "Объявление", "privacy_settings": "Настройки приватности", + "apply": "Применить", "cancel": "Отмена", "voice": "Голос", "start_video": "Начать видео", @@ -2546,5 +2547,30 @@ "setting_variant_windowmaterial_acrylic": "Акрил", "setting_variant_windowmaterial_mica": "Mica", "setting_variant_windowmaterial_glass": "Стекло", - "setting_variant_windowmaterial_solid": "Сплошной" + "setting_variant_windowmaterial_solid": "Сплошной", + "cosmetic_wardrobe": "Гардероб", + "cosmetic_wardrobe_hint": "Что вы носите на аватаре, в карточке профиля и в имени.", + "cosmetic_kind_avatar_decoration": "Украшение аватара", + "cosmetic_kind_profile_frame": "Рамка профиля", + "cosmetic_kind_nickname_style": "Стиль имени", + "cosmetic_kind_option_font": "Шрифты", + "cosmetic_kind_option_text_effect": "Эффекты имени", + "cosmetic_clear": "Убрать", + "cosmetic_locked": "Недоступно", + "cosmetic_locked_ultima": "С Ultima", + "cosmetic_locked_grant": "Пока не ваше", + "cosmetic_available_until": "Доступно до {date}", + "cosmetic_surface_card": "Карточка профиля", + "cosmetic_surface_avatar": "Аватар", + "cosmetic_surface_message": "В чате", + "cosmetic_surface_member_row": "Список участников", + "cosmetic_equip_failed": "Не удалось надеть", + "cosmetic_error_network": "Не удалось связаться с сервером. Попробуйте ещё раз.", + "cosmetic_error_not_found": "Этого предмета больше нет.", + "cosmetic_error_unknown_kind": "Этот вид украшений не поддерживается.", + "cosmetic_error_kind_disabled": "Этот вид украшений сейчас отключён.", + "cosmetic_error_item_unavailable": "Этот предмет больше недоступен.", + "cosmetic_error_not_owned": "У вас пока нет этого предмета.", + "cosmetic_error_choice_invalid": "Один из выбранных вариантов здесь не подходит.", + "cosmetic_error_value_out_of_range": "Одна из настроек вне допустимого диапазона." } diff --git a/packages/i18n/src/core/ru_pt.json b/packages/i18n/src/core/ru_pt.json index dd31a0bb..d7325a5c 100644 --- a/packages/i18n/src/core/ru_pt.json +++ b/packages/i18n/src/core/ru_pt.json @@ -2529,5 +2529,31 @@ "setting_variant_windowmaterial_acrylic": "Акрил", "setting_variant_windowmaterial_mica": "Слюда", "setting_variant_windowmaterial_glass": "Иллюминатор", - "setting_variant_windowmaterial_solid": "Глухой борт" + "setting_variant_windowmaterial_solid": "Глухой борт", + "apply": "Принять на борт", + "cosmetic_wardrobe": "Сундук с нарядами", + "cosmetic_wardrobe_hint": "Что ты носишь на аватаре, на карточке и на имени, юнга.", + "cosmetic_kind_avatar_decoration": "Украшение аватара", + "cosmetic_kind_profile_frame": "Рамка профиля", + "cosmetic_kind_nickname_style": "Стиль имени", + "cosmetic_kind_option_font": "Шрифты", + "cosmetic_kind_option_text_effect": "Эффекты имени", + "cosmetic_clear": "Снять, к акулам", + "cosmetic_locked": "Под замком", + "cosmetic_locked_ultima": "С Ultima", + "cosmetic_locked_grant": "Пока не твоя добыча", + "cosmetic_available_until": "Доступно до {date}", + "cosmetic_surface_card": "Карточка профиля", + "cosmetic_surface_avatar": "Аватар", + "cosmetic_surface_message": "В кубрике", + "cosmetic_surface_member_row": "Судовая роль", + "cosmetic_equip_failed": "Надеть не вышло", + "cosmetic_error_network": "Сервер не отвечает, штиль. Попробуй ещё раз.", + "cosmetic_error_not_found": "Этой вещицы больше нет — ушла на дно.", + "cosmetic_error_unknown_kind": "Такой вид украшений здесь не водится.", + "cosmetic_error_kind_disabled": "Этот вид украшений сейчас убран в трюм.", + "cosmetic_error_item_unavailable": "Эта вещица больше недоступна.", + "cosmetic_error_not_owned": "Эта добыча пока не твоя.", + "cosmetic_error_choice_invalid": "Один из выбранных вариантов тут не годится.", + "cosmetic_error_value_out_of_range": "Одна из настроек за бортом допустимого." } diff --git a/packages/i18n/src/core/uz.json b/packages/i18n/src/core/uz.json index 1781367e..fb1e14b8 100644 --- a/packages/i18n/src/core/uz.json +++ b/packages/i18n/src/core/uz.json @@ -2504,5 +2504,31 @@ "setting_variant_windowmaterial_acrylic": "Akril", "setting_variant_windowmaterial_mica": "Mica", "setting_variant_windowmaterial_glass": "Shisha", - "setting_variant_windowmaterial_solid": "Yaxlit" + "setting_variant_windowmaterial_solid": "Yaxlit", + "apply": "Qoʻllash", + "cosmetic_wardrobe": "Garderob", + "cosmetic_wardrobe_hint": "Avatar, profil kartasi va ismingizda nima kiyganingiz.", + "cosmetic_kind_avatar_decoration": "Avatar bezagi", + "cosmetic_kind_profile_frame": "Profil ramkasi", + "cosmetic_kind_nickname_style": "Ism uslubi", + "cosmetic_kind_option_font": "Shriftlar", + "cosmetic_kind_option_text_effect": "Ism effektlari", + "cosmetic_clear": "Yechish", + "cosmetic_locked": "Qulflangan", + "cosmetic_locked_ultima": "Ultima bilan", + "cosmetic_locked_grant": "Hali sizniki emas", + "cosmetic_available_until": "{date} gacha mavjud", + "cosmetic_surface_card": "Profil kartasi", + "cosmetic_surface_avatar": "Avatar", + "cosmetic_surface_message": "Chatda", + "cosmetic_surface_member_row": "Aʼzolar roʻyxati", + "cosmetic_equip_failed": "Kiyib boʻlmadi", + "cosmetic_error_network": "Server bilan bogʻlanib boʻlmadi. Qayta urinib koʻring.", + "cosmetic_error_not_found": "Bu buyum endi mavjud emas.", + "cosmetic_error_unknown_kind": "Bu turdagi kosmetika qoʻllab-quvvatlanmaydi.", + "cosmetic_error_kind_disabled": "Bu turdagi kosmetika hozir oʻchirilgan.", + "cosmetic_error_item_unavailable": "Bu buyum endi mavjud emas.", + "cosmetic_error_not_owned": "Sizda hali bu buyum yoʻq.", + "cosmetic_error_choice_invalid": "Tanlangan variantlardan biri bu yerga mos kelmaydi.", + "cosmetic_error_value_out_of_range": "Sozlamalardan biri ruxsat etilgan oraliqdan tashqarida." } diff --git a/src/components/SettingsWindow.vue b/src/components/SettingsWindow.vue index 9ceae24d..73f242db 100644 --- a/src/components/SettingsWindow.vue +++ b/src/components/SettingsWindow.vue @@ -73,6 +73,7 @@ const TransactionSettings = defineAsyncComponent(() => import("./settings/Transa const ConnectionsSettings = defineAsyncComponent(() => import("./settings/ConnectionsSettings.vue")); import { useConfigStore } from "@/store/ui/configStore"; import { useFeatureFlags } from "@/store/features/featureFlagsStore"; +import { isEscapeLayerOpen } from "@/lib/modalLayers"; // Stores const { t } = useLocale(); @@ -165,9 +166,16 @@ const changeCategory = (categoryId: string) => { }; const handleEscape = (event: KeyboardEvent) => { - if (event.key === "Escape" && windows.settingsOpen) { - closeSettings(); + if (event.key !== "Escape" || !windows.settingsOpen) { + return; } + + // A dialog that claimed Escape owns it; one press must not close both. + if (isEscapeLayerOpen()) { + return; + } + + closeSettings(); }; // Lifecycle diff --git a/src/components/settings/ProfileSettings.vue b/src/components/settings/ProfileSettings.vue index a8d04f8f..c7fc92a1 100644 --- a/src/components/settings/ProfileSettings.vue +++ b/src/components/settings/ProfileSettings.vue @@ -212,6 +212,13 @@ + + +
@@ -829,6 +836,7 @@ + + diff --git a/src/components/settings/cosmetics/CosmeticPickerDialog.vue b/src/components/settings/cosmetics/CosmeticPickerDialog.vue new file mode 100644 index 00000000..4aa89e4f --- /dev/null +++ b/src/components/settings/cosmetics/CosmeticPickerDialog.vue @@ -0,0 +1,904 @@ + + + + + + + diff --git a/src/components/settings/cosmetics/CosmeticRowPreview.vue b/src/components/settings/cosmetics/CosmeticRowPreview.vue new file mode 100644 index 00000000..2ae5fc3b --- /dev/null +++ b/src/components/settings/cosmetics/CosmeticRowPreview.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/src/components/settings/cosmetics/CosmeticsPanel.vue b/src/components/settings/cosmetics/CosmeticsPanel.vue new file mode 100644 index 00000000..a3e5c6dc --- /dev/null +++ b/src/components/settings/cosmetics/CosmeticsPanel.vue @@ -0,0 +1,256 @@ + + + + + diff --git a/src/components/settings/cosmetics/cosmeticLock.ts b/src/components/settings/cosmetics/cosmeticLock.ts new file mode 100644 index 00000000..dd5ba816 --- /dev/null +++ b/src/components/settings/cosmetics/cosmeticLock.ts @@ -0,0 +1,38 @@ +import type { CatalogueCosmetic } from "@argon/glue"; + +/** Why a catalogue row cannot be put on: it comes with Ultima, or it is handed out rather than taken. */ +export type CosmeticLockReason = "ultima" | "granted"; + +/** `wearable` is the store's `mayWear`: free, or owned (Ultima rows arrive as owned via the subscription). */ +export function lockReasonOf(item: CatalogueCosmetic, wearable: boolean): CosmeticLockReason | null { + if (wearable) { + return null; + } + + return item.ultima ? "ultima" : "granted"; +} + +export function availableUntilOf(item: Pick): Date | null { + const until = item.availableUntil; + + if (!until || typeof until.toDate !== "function") { + return null; + } + + const date = until.toDate(); + + if (Number.isNaN(date.getTime())) { + return null; + } + + return date; +} + +/** A row's name as assistive technology hears it: a locked row says so, since its padlock is only drawn. */ +export function lockedAccessibleName(name: string, wearable: boolean, lockedWord: string): string { + if (wearable) { + return name; + } + + return `${name}, ${lockedWord}`; +} diff --git a/src/components/settings/cosmetics/rovingRadio.ts b/src/components/settings/cosmetics/rovingRadio.ts new file mode 100644 index 00000000..470fe39b --- /dev/null +++ b/src/components/settings/cosmetics/rovingRadio.ts @@ -0,0 +1,44 @@ +const RADIO_SELECTOR = '[role="radio"]'; + +const GROUP_SELECTOR = '[role="radiogroup"]'; + +/** Where an arrow, Home or End moves from `from` in a group of `count`, wrapping at the ends; null for any other key. */ +export function radioStepTarget(event: KeyboardEvent, from: number, count: number): number | null { + if (count === 0 || event.altKey || event.ctrlKey || event.metaKey) { + return null; + } + + switch (event.key) { + case "ArrowRight": + case "ArrowDown": + return (from + 1) % count; + + case "ArrowLeft": + case "ArrowUp": + return (from - 1 + count) % count; + + case "Home": + return 0; + + case "End": + return count - 1; + + default: + return null; + } +} + +/** Focus follows the choice: the radio at `index` of the group `radio` belongs to. */ +export function focusRadioAt(radio: Element, index: number): void { + const group = radio.closest(GROUP_SELECTOR); + + if (group === null) { + return; + } + + const target = group.querySelectorAll(RADIO_SELECTOR)[index]; + + if (target !== undefined) { + target.focus(); + } +} diff --git a/src/components/settings/cosmetics/wardrobeChange.ts b/src/components/settings/cosmetics/wardrobeChange.ts new file mode 100644 index 00000000..05555bb5 --- /dev/null +++ b/src/components/settings/cosmetics/wardrobeChange.ts @@ -0,0 +1,53 @@ +import { + CosmeticError, + type IWornCosmetic, +} from "@argon/glue"; + +import { toWornItem } from "@/cosmetics/wire"; + +/** What one Apply in the wardrobe asks the server for: put one look on, or take a kind off. */ +export type WardrobeChange = + | { + readonly action: "equip"; + readonly kindKey: string; + readonly worn: IWornCosmetic; + } + | { + readonly action: "unequip"; + readonly kindKey: string; + }; + +export function equipChange(kindKey: string, worn: IWornCosmetic): WardrobeChange { + return { action: "equip", kindKey, worn }; +} + +export function unequipChange(kindKey: string): WardrobeChange { + return { action: "unequip", kindKey }; +} + +/** A kind worn as one catalogue row: a row replaces whatever is on, no row takes the kind off. */ +export function itemChange(kindKey: string, itemId: string | null): WardrobeChange { + if (itemId === null) { + return unequipChange(kindKey); + } + + return equipChange(kindKey, toWornItem(itemId)); +} + +/** The entry a change leaves on the profile for its kind, for drawing a draft before it is applied. */ +export function wornAfter(change: WardrobeChange): IWornCosmetic | null { + if (change.action === "equip") { + return change.worn; + } + + return null; +} + +/** Taking off a kind that was already off is the state the wearer asked for, not a failure. */ +export function refusalOf(change: WardrobeChange, refusal: CosmeticError | null): CosmeticError | null { + if (change.action === "unequip" && refusal === CosmeticError.NOT_FOUND) { + return null; + } + + return refusal; +} diff --git a/src/cosmetics/CosmeticLookPreview.vue b/src/cosmetics/CosmeticLookPreview.vue new file mode 100644 index 00000000..723db5ab --- /dev/null +++ b/src/cosmetics/CosmeticLookPreview.vue @@ -0,0 +1,218 @@ + + + + + + + diff --git a/src/cosmetics/CosmeticPreview.vue b/src/cosmetics/CosmeticPreview.vue new file mode 100644 index 00000000..fe1e0ae6 --- /dev/null +++ b/src/cosmetics/CosmeticPreview.vue @@ -0,0 +1,422 @@ + + + + + + + diff --git a/src/cosmetics/authoredCard.ts b/src/cosmetics/authoredCard.ts new file mode 100644 index 00000000..10b4850f --- /dev/null +++ b/src/cosmetics/authoredCard.ts @@ -0,0 +1,2 @@ +/** The card width a cosmetic's pixels are authored against; a smaller drawing scales from it. */ +export const AUTHORED_CARD_WIDTH = 384; diff --git a/src/cosmetics/preview/leadingGraphemes.ts b/src/cosmetics/preview/leadingGraphemes.ts new file mode 100644 index 00000000..1a301827 --- /dev/null +++ b/src/cosmetics/preview/leadingGraphemes.ts @@ -0,0 +1,18 @@ +const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + +/** The first `count` user-perceived characters, so a shortened name never ends in half an emoji. */ +export function leadingGraphemes(text: string, count: number): string { + let taken = ""; + let takenCount = 0; + + for (const { segment } of graphemes.segment(text)) { + if (takenCount >= count) { + break; + } + + taken += segment; + takenCount++; + } + + return taken; +} diff --git a/src/cosmetics/preview/previewUserId.ts b/src/cosmetics/preview/previewUserId.ts new file mode 100644 index 00000000..f435173b --- /dev/null +++ b/src/cosmetics/preview/previewUserId.ts @@ -0,0 +1,2 @@ +/** The one person in the preview. Fixed, so a re-render patches the card rather than remounting it. */ +export const PREVIEW_USER_ID = "00000000-0000-0000-0000-0000000c0de0"; diff --git a/src/cosmetics/preview/stages/AvatarStage.vue b/src/cosmetics/preview/stages/AvatarStage.vue new file mode 100644 index 00000000..fa895a55 --- /dev/null +++ b/src/cosmetics/preview/stages/AvatarStage.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/src/cosmetics/preview/stages/MemberRowStage.vue b/src/cosmetics/preview/stages/MemberRowStage.vue new file mode 100644 index 00000000..940046f8 --- /dev/null +++ b/src/cosmetics/preview/stages/MemberRowStage.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/src/cosmetics/preview/stages/MessageStage.vue b/src/cosmetics/preview/stages/MessageStage.vue new file mode 100644 index 00000000..e5249d1a --- /dev/null +++ b/src/cosmetics/preview/stages/MessageStage.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/src/lib/cosmeticText.ts b/src/lib/cosmeticText.ts new file mode 100644 index 00000000..2cd43751 --- /dev/null +++ b/src/lib/cosmeticText.ts @@ -0,0 +1,30 @@ +import type { + CatalogueCosmetic, + CosmeticText, +} from "@argon/glue"; + +const FALLBACK_LOCALE = "en"; + +/** + * The catalogue carries every language an operator wrote, so a locale switch needs no refetch. + * The name key is the last resort: rendered as itself, it is the visible sign a row shipped unnamed. + */ +function writtenText(item: Pick, locale: string): CosmeticText | null { + const texts = item.text; + + if (!texts || texts.length === 0) { + return null; + } + + return texts.find(text => text.locale === locale) + ?? texts.find(text => text.locale === FALLBACK_LOCALE) + ?? null; +} + +export function cosmeticName(item: Pick, locale: string): string { + return writtenText(item, locale)?.name ?? item.nameKey; +} + +export function cosmeticDescription(item: Pick, locale: string): string | null { + return writtenText(item, locale)?.description ?? null; +} diff --git a/src/lib/modalLayers.ts b/src/lib/modalLayers.ts new file mode 100644 index 00000000..05e13a32 --- /dev/null +++ b/src/lib/modalLayers.ts @@ -0,0 +1,110 @@ +import { + onBeforeUnmount, + onMounted, + watch, +} from "vue"; + +interface EscapeLayer { + readonly isOpen: () => boolean; + readonly dismiss: () => void; +} + +/** Ordered by when each layer last opened, so the last open one is the one on top. */ +const layers: EscapeLayer[] = []; + +function topOpenLayer(): EscapeLayer | null { + for (let index = layers.length - 1; index >= 0; index--) { + const layer = layers[index]; + + if (layer.isOpen()) { + return layer; + } + } + + return null; +} + +/** + * Whether a layer registered through `useEscapeDismiss` is open, for window-level Escape handlers that + * must not close their window underneath it. Dialogs that never registered keep the key's old meaning. + */ +export function isEscapeLayerOpen(): boolean { + return topOpenLayer() !== null; +} + +/** + * The settings windows are vaul drawers with `dismissible: false`, whose own Escape handler calls + * `preventDefault()` first, and reka only dismisses when the event is not prevented. A capture + * listener on `window` is the one position ahead of both; it stops the event so one press closes + * exactly one thing. + */ +function onKeyDown(event: KeyboardEvent): void { + if (event.key !== "Escape" || event.defaultPrevented) { + return; + } + + const top = topOpenLayer(); + + if (top === null) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + top.dismiss(); +} + +function remove(layer: EscapeLayer): void { + const index = layers.indexOf(layer); + + if (index !== -1) { + layers.splice(index, 1); + } +} + +function register(layer: EscapeLayer): void { + if (layers.length === 0) { + window.addEventListener("keydown", onKeyDown, true); + } + + layers.push(layer); +} + +function unregister(layer: EscapeLayer): void { + remove(layer); + + if (layers.length === 0) { + window.removeEventListener("keydown", onKeyDown, true); + } +} + +function raise(layer: EscapeLayer): void { + if (!layers.includes(layer)) { + return; + } + + remove(layer); + layers.push(layer); +} + +/** + * Closes a dialog on Escape ahead of everything else that wants the key. Only the topmost open layer + * takes a press, and a press something earlier already claimed is left alone. + */ +export function useEscapeDismiss(isOpen: () => boolean, dismiss: () => void): void { + const layer: EscapeLayer = { isOpen, dismiss }; + + watch(isOpen, open => { + if (open) { + raise(layer); + } + }); + + onMounted(() => { + register(layer); + }); + + onBeforeUnmount(() => { + unregister(layer); + }); +} diff --git a/src/lib/refusals.ts b/src/lib/refusals.ts index c49aa18d..71a103ab 100644 --- a/src/lib/refusals.ts +++ b/src/lib/refusals.ts @@ -1,6 +1,7 @@ import { ArchetypeError, ChannelLayoutError, + CosmeticError, EmailChangeError, ExpressionError, RegistrationError, @@ -62,6 +63,16 @@ const EXPRESSION: Partial> = { [ExpressionError.RATE_LIMITED]: "expression_error_rate_limited", }; +const COSMETIC: Partial> = { + [CosmeticError.NOT_FOUND]: "cosmetic_error_not_found", + [CosmeticError.UNKNOWN_KIND]: "cosmetic_error_unknown_kind", + [CosmeticError.KIND_DISABLED]: "cosmetic_error_kind_disabled", + [CosmeticError.ITEM_UNAVAILABLE]: "cosmetic_error_item_unavailable", + [CosmeticError.NOT_OWNED]: "cosmetic_error_not_owned", + [CosmeticError.CHOICE_INVALID]: "cosmetic_error_choice_invalid", + [CosmeticError.VALUE_OUT_OF_RANGE]: "cosmetic_error_value_out_of_range", +}; + const UPDATE_ME: Partial> = { [UpdateMeError.INVALID_STATUS_EMOJI]: "status_emoji_invalid", }; @@ -133,6 +144,10 @@ export function expressionErrorKey(error: ExpressionError): string { return EXPRESSION[error] ?? "expression_error_unknown"; } +export function cosmeticErrorKey(error: CosmeticError): string { + return COSMETIC[error] ?? "cosmetic_equip_failed"; +} + export function updateMeErrorKey(error: UpdateMeError): string { return UPDATE_ME[error] ?? "profile_update_failed"; } diff --git a/test/components/CosmeticPickerDialog.test.ts b/test/components/CosmeticPickerDialog.test.ts new file mode 100644 index 00000000..0989fa60 --- /dev/null +++ b/test/components/CosmeticPickerDialog.test.ts @@ -0,0 +1,488 @@ +/** + * The wardrobe picker: a draft until Apply, which emits exactly what goes over the wire — a WornItem + * for a catalogue kind — and nothing at all while the draft holds a row this account may not wear. + * Taking a kind off is a draft too, sent as an unequip. + */ + +import { + beforeEach, + describe, + expect, + test, + vi, +} from "vitest"; +import { + createPinia, + setActivePinia, +} from "pinia"; +import { + flushPromises, + mount, +} from "@vue/test-utils"; + +const h = vi.hoisted(() => ({ + getCatalogue: vi.fn(), + getMyCosmetics: vi.fn(), + me: { + me: { userId: "me", displayName: "Omni", username: "omni", avatarFileId: null }, + meProfile: null as unknown, + isPremium: false, + }, +})); + +vi.mock("@argon/core", () => ({ + logger: { log() {}, debug() {}, info() {}, warn() {}, error() {} }, +})); +vi.mock("@/lib/telemetry/metrics", () => ({ + metrics: { count() {} }, + errorKind: () => "network", +})); +vi.mock("@/store/system/apiStore", () => ({ + useApi: () => ({ + cosmeticsInteraction: { + GetCatalogue: h.getCatalogue, + GetMyCosmetics: h.getMyCosmetics, + Equip: vi.fn(), + Unequip: vi.fn(), + }, + }), +})); +vi.mock("@/store/auth/meStore", () => ({ useMe: () => h.me })); +vi.mock("@/store/data/profileCacheStore", () => ({ + useProfileCacheStore: () => ({ updateCosmetics: async () => {} }), +})); +vi.mock("@/store/system/localeStore", async () => { + const { ref } = await import("vue"); + const currentLocale = ref("en"); + + return { + useLocale: () => ({ + t: (key: string, params?: Record) => (params ? `${key}:${JSON.stringify(params)}` : key), + currentLocale, + }), + documentLanguage: (locale: string) => locale, + }; +}); +vi.mock("@/composables/useReducedMotion", async () => { + const { ref } = await import("vue"); + + return { reduceMotion: ref(false) }; +}); +vi.mock("@/store/system/fileStorage", () => ({ + cdnUrl: (fileId: string) => `cdn:${fileId}`, + cdnCrossOrigin: () => undefined, +})); +vi.mock("@argon/ui/dialog", async () => { + const { defineComponent, h: renderNode } = await import("vue"); + const pass = (name: string) => defineComponent({ name, setup: (_, { slots }) => () => renderNode("div", { class: name }, slots.default?.()) }); + + return { + Dialog: defineComponent({ + props: { open: Boolean }, + setup: (props, { slots }) => () => (props.open ? renderNode("div", { "data-dialog": "" }, slots.default?.()) : null), + }), + DialogContent: pass("DialogContent"), + DialogHeader: pass("DialogHeader"), + DialogTitle: pass("DialogTitle"), + }; +}); +vi.mock("@argon/ui/button", async () => { + const { defineComponent, h: renderNode } = await import("vue"); + + return { + Button: defineComponent({ + inheritAttrs: false, + setup: (_, { attrs, slots }) => () => renderNode("button", { type: "button", ...attrs }, slots.default?.()), + }), + }; +}); +vi.mock("@/cosmetics/CosmeticLookPreview.vue", async () => { + const { defineComponent, h: renderNode } = await import("vue"); + + return { + default: defineComponent({ + name: "CosmeticLookPreview", + props: { profile: { type: Object, default: null }, surfaces: { type: Array, default: () => [] } }, + setup: () => () => renderNode("div", { class: "look-preview" }), + }), + }; +}); +vi.mock("@/components/settings/cosmetics/CosmeticCell.vue", async () => { + const { defineComponent, h: renderNode } = await import("vue"); + + return { default: defineComponent({ setup: () => () => renderNode("span") }) }; +}); + +import { + FrameRing, + PayloadAvatarDecoration, + PayloadProfileFrame, + WornItem, + type ArgonUserProfile, + type CatalogueCosmetic, + type CosmeticCatalogue, + type ICosmeticPayload, + type IWornCosmetic, +} from "@argon/glue"; + +import type { WardrobeChange } from "@/components/settings/cosmetics/wardrobeChange"; +import CosmeticPickerDialog from "@/components/settings/cosmetics/CosmeticPickerDialog.vue"; +import { resolveKind } from "@/cosmetics/registry"; +import { runSessionReset } from "@/store/system/sessionLifecycle"; +import { useCosmeticsStore } from "@/store/features/cosmeticsStore"; + +const FREE_FRAME = "aaaaaaaa-0000-0000-0000-000000000001"; +const LOCKED_FRAME = "aaaaaaaa-0000-0000-0000-000000000002"; +const WORN_FRAME = "aaaaaaaa-0000-0000-0000-000000000003"; +const DECORATION = "aaaaaaaa-0000-0000-0000-000000000004"; + +const ring = new FrameRing(true, 100, null, null, 2, [0xff00ff00 | 0], 90, 0); + +function row( + cosmeticId: string, + kindKey: string, + slug: string, + payload: ICosmeticPayload, + overrides: Partial = {}, +): CatalogueCosmetic { + return { + cosmeticId, + kindKey, + slug, + nameKey: `cosmetic_${slug}`, + descriptionKey: null, + rarity: null, + version: 1, + payload, + assets: [], + availableUntil: null, + free: false, + ultima: false, + text: [{ locale: "en", name: slug, description: null }], + ...overrides, + }; +} + +const catalogue: CosmeticCatalogue = { + enabledKinds: ["avatar.decoration", "profile.frame"], + items: [ + row(FREE_FRAME, "profile.frame", "vines", new PayloadProfileFrame([ring]), { free: true }), + row(LOCKED_FRAME, "profile.frame", "gilded", new PayloadProfileFrame([ring]), { ultima: true }), + row(WORN_FRAME, "profile.frame", "thorns", new PayloadProfileFrame([ring]), { free: true }), + row(DECORATION, "avatar.decoration", "halo", new PayloadAvatarDecoration(10, false), { free: true }), + ], +}; + +function wearing(...cosmetics: IWornCosmetic[]): ArgonUserProfile { + return { userId: "me", cosmetics } as unknown as ArgonUserProfile; +} + +async function mountPicker( + kindKey: string, + profile: ArgonUserProfile, + extra: { busy?: boolean; canUpsell?: boolean } = {}, +) { + h.getCatalogue.mockResolvedValue(catalogue); + await useCosmeticsStore().ensureLoaded(); + + const wrapper = mount(CosmeticPickerDialog, { + props: { + open: true, + kind: resolveKind(kindKey)!, + profile, + items: useCosmeticsStore().optionsFor(kindKey), + busy: false, + ...extra, + }, + attachTo: document.body, + }); + + await flushPromises(); + + return wrapper; +} + +type Picker = Awaited>; + +function tileNamed(wrapper: Picker, name: string) { + const tile = wrapper.findAll(".picker-cell").find(candidate => candidate.text().includes(name)); + + if (tile === undefined) { + throw new Error(`no tile named ${name}`); + } + + return tile; +} + +/** Pointer events reach a disabled button in a browser, but test-utils will not trigger them on one. */ +async function pointAt(tile: ReturnType, type: "mouseenter" | "mouseleave"): Promise { + tile.element.dispatchEvent(new MouseEvent(type)); + await flushPromises(); +} + +function buttonSaying(wrapper: Picker, key: string) { + return wrapper.findAll(".picker-actions button").find(button => button.text() === key); +} + +function applyButton(wrapper: Picker) { + return buttonSaying(wrapper, "apply")!; +} + +function applied(wrapper: Picker): WardrobeChange[] { + return (wrapper.emitted("apply") ?? []).map(args => args[0] as WardrobeChange); +} + +beforeEach(async () => { + await runSessionReset(); + setActivePinia(createPinia()); + h.getCatalogue.mockReset(); + h.getMyCosmetics.mockReset(); + h.getMyCosmetics.mockResolvedValue({ owned: [] }); + document.body.innerHTML = ""; +}); + +describe("catalogue kinds", () => { + test("only rows of the kind are offered, and the worn one opens selected", async () => { + const wrapper = await mountPicker("profile.frame", wearing(new WornItem(WORN_FRAME.toUpperCase()))); + + expect(wrapper.findAll(".picker-cell")).toHaveLength(3); + expect(tileNamed(wrapper, "thorns").attributes("aria-checked")).toBe("true"); + expect(tileNamed(wrapper, "thorns").classes()).toContain("picker-cell--active"); + expect(applyButton(wrapper).attributes("disabled")).toBeDefined(); + + wrapper.unmount(); + }); + + test("apply sends the chosen row as a WornItem, and leaves closing to whoever sends it", async () => { + const wrapper = await mountPicker("profile.frame", wearing(new WornItem(WORN_FRAME))); + + await tileNamed(wrapper, "vines").trigger("click"); + await applyButton(wrapper).trigger("click"); + + const [change] = applied(wrapper); + + expect(change.action).toBe("equip"); + expect(change.kindKey).toBe("profile.frame"); + expect(change.action === "equip" && change.worn).toBeInstanceOf(WornItem); + expect(change.action === "equip" && (change.worn as WornItem).itemId).toBe(FREE_FRAME); + expect(wrapper.emitted("update:open")).toBeUndefined(); + + wrapper.unmount(); + }); + + test("a locked row cannot be chosen, says so in its name, and its padlock is explained under the list", async () => { + const wrapper = await mountPicker("profile.frame", wearing()); + const gilded = tileNamed(wrapper, "gilded"); + + expect(gilded.attributes("disabled")).toBeDefined(); + expect(gilded.classes()).toContain("picker-cell--locked"); + expect(gilded.attributes("aria-label")).toBe("gilded, cosmetic_locked"); + expect(wrapper.get(".picker-caption").text()).toBe(""); + + await pointAt(gilded, "mouseenter"); + + expect(wrapper.get(".picker-caption").text()).toContain("cosmetic_locked_ultima"); + expect(wrapper.find(".picker-upsell").exists()).toBe(false); + + await pointAt(gilded, "mouseleave"); + + expect(wrapper.get(".picker-caption").text()).toBe(""); + + wrapper.unmount(); + }); + + test("an Ultima padlock offers Ultima when the account can get it", async () => { + const wrapper = await mountPicker("profile.frame", wearing(), { canUpsell: true }); + + await pointAt(tileNamed(wrapper, "gilded"), "mouseenter"); + await wrapper.get(".picker-upsell").trigger("click"); + + expect(wrapper.emitted("upsell")).toHaveLength(1); + + wrapper.unmount(); + }); + + test("a worn row this account may no longer wear keeps explaining itself, whatever is pointed at", async () => { + const wrapper = await mountPicker("profile.frame", wearing(new WornItem(LOCKED_FRAME))); + + expect(wrapper.get(".picker-caption").text()).toContain("cosmetic_locked_ultima"); + + await tileNamed(wrapper, "vines").trigger("mouseenter"); + + expect(wrapper.get(".picker-caption").text()).toContain("cosmetic_locked_ultima"); + + wrapper.unmount(); + }); + + test("an offer that ends says until when", async () => { + const ending = { toDate: () => new Date("2026-12-31T00:00:00Z") } as unknown as CatalogueCosmetic["availableUntil"]; + + catalogue.items[0] = row(FREE_FRAME, "profile.frame", "vines", new PayloadProfileFrame([ring]), { free: true, availableUntil: ending }); + + try { + const wrapper = await mountPicker("profile.frame", wearing()); + + await tileNamed(wrapper, "vines").trigger("mouseenter"); + + expect(wrapper.get(".picker-caption").text()).toContain("cosmetic_available_until"); + + wrapper.unmount(); + } finally { + catalogue.items[0] = row(FREE_FRAME, "profile.frame", "vines", new PayloadProfileFrame([ring]), { free: true }); + } + }); + + test("taking a worn kind off is a draft until Apply, which sends the unequip", async () => { + const wrapper = await mountPicker("avatar.decoration", wearing(new WornItem(DECORATION))); + + await buttonSaying(wrapper, "cosmetic_clear")!.trigger("click"); + + expect(applied(wrapper)).toEqual([]); + expect(buttonSaying(wrapper, "cosmetic_clear")).toBeUndefined(); + expect(tileNamed(wrapper, "halo").attributes("aria-checked")).toBe("false"); + + await applyButton(wrapper).trigger("click"); + + expect(applied(wrapper)).toEqual([{ action: "unequip", kindKey: "avatar.decoration" }]); + + wrapper.unmount(); + }); + + test("while a change is on its way nothing can be applied, chosen, taken off or dismissed", async () => { + const wrapper = await mountPicker("profile.frame", wearing(new WornItem(WORN_FRAME)), { busy: true }); + + expect(applyButton(wrapper).attributes("disabled")).toBeDefined(); + expect(buttonSaying(wrapper, "cosmetic_clear")!.attributes("disabled")).toBeDefined(); + expect(buttonSaying(wrapper, "cancel")!.attributes("disabled")).toBeDefined(); + + for (const tile of wrapper.findAll(".picker-cell")) { + expect(tile.attributes("disabled")).toBeDefined(); + } + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + + expect(wrapper.emitted("update:open")).toBeUndefined(); + + wrapper.unmount(); + }); + + test("Escape and Cancel close it when nothing is on its way", async () => { + const wrapper = await mountPicker("profile.frame", wearing()); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + await buttonSaying(wrapper, "cancel")!.trigger("click"); + + expect(wrapper.emitted("update:open")).toEqual([[false], [false]]); + + wrapper.unmount(); + }); + + test("a draft is thrown away on close and the worn row is selected again on reopen", async () => { + const wrapper = await mountPicker("profile.frame", wearing(new WornItem(WORN_FRAME))); + + await tileNamed(wrapper, "vines").trigger("click"); + await wrapper.setProps({ open: false }); + await wrapper.setProps({ open: true }); + + expect(tileNamed(wrapper, "thorns").attributes("aria-checked")).toBe("true"); + expect(applyButton(wrapper).attributes("disabled")).toBeDefined(); + + wrapper.unmount(); + }); + + test("asks again what may be worn every time it opens", async () => { + const wrapper = await mountPicker("profile.frame", wearing()); + + expect(h.getMyCosmetics).toHaveBeenCalledTimes(2); + + await wrapper.setProps({ open: false }); + await wrapper.setProps({ open: true }); + await flushPromises(); + + expect(h.getMyCosmetics).toHaveBeenCalledTimes(3); + + wrapper.unmount(); + }); + + test("an untouched draft follows the look when it changes elsewhere; a touched one stays", async () => { + const wrapper = await mountPicker("profile.frame", wearing(new WornItem(WORN_FRAME))); + + await wrapper.setProps({ profile: wearing(new WornItem(FREE_FRAME)) }); + + expect(tileNamed(wrapper, "vines").attributes("aria-checked")).toBe("true"); + expect(applyButton(wrapper).attributes("disabled")).toBeDefined(); + + await tileNamed(wrapper, "thorns").trigger("click"); + await wrapper.setProps({ profile: wearing() }); + + expect(tileNamed(wrapper, "thorns").attributes("aria-checked")).toBe("true"); + + wrapper.unmount(); + }); + + test("nothing worn offers nothing to take off", async () => { + const wrapper = await mountPicker("avatar.decoration", wearing()); + + expect(buttonSaying(wrapper, "cosmetic_clear")).toBeUndefined(); + + wrapper.unmount(); + }); + + test("the previews draw the draft over the rest of the look", async () => { + const wrapper = await mountPicker("profile.frame", wearing(new WornItem(WORN_FRAME), new WornItem(DECORATION))); + + await tileNamed(wrapper, "vines").trigger("click"); + + const previews = wrapper.findAllComponents({ name: "CosmeticLookPreview" }); + + expect(previews).toHaveLength(2); + + for (const preview of previews) { + const drawn = (preview.props("profile") as ArgonUserProfile).cosmetics ?? []; + + expect(drawn.map(entry => (entry as WornItem).itemId)).toEqual([DECORATION, FREE_FRAME]); + } + + wrapper.unmount(); + }); +}); + +describe("keyboard", () => { + test("the chosen tile is the only tab stop; with none chosen the first that can be chosen stands in", async () => { + const chosen = await mountPicker("profile.frame", wearing(new WornItem(WORN_FRAME))); + + expect(chosen.findAll(".picker-cell").map(tile => tile.attributes("tabindex"))).toEqual(["-1", "0", "-1"]); + + chosen.unmount(); + + const none = await mountPicker("profile.frame", wearing()); + + expect(none.findAll(".picker-cell").map(tile => tile.attributes("tabindex"))).toEqual(["0", "-1", "-1"]); + + none.unmount(); + }); + + test("arrows, Home and End choose along what can be chosen, wrapping and skipping a locked tile", async () => { + const wrapper = await mountPicker("profile.frame", wearing(new WornItem(FREE_FRAME))); + + await tileNamed(wrapper, "vines").trigger("keydown", { key: "ArrowRight" }); + + expect(tileNamed(wrapper, "thorns").attributes("aria-checked")).toBe("true"); + expect(document.activeElement).toBe(tileNamed(wrapper, "thorns").element); + + await tileNamed(wrapper, "thorns").trigger("keydown", { key: "ArrowRight" }); + + expect(tileNamed(wrapper, "vines").attributes("aria-checked")).toBe("true"); + + await tileNamed(wrapper, "vines").trigger("keydown", { key: "End" }); + + expect(tileNamed(wrapper, "thorns").attributes("aria-checked")).toBe("true"); + expect(tileNamed(wrapper, "gilded").attributes("aria-checked")).toBe("false"); + + await tileNamed(wrapper, "thorns").trigger("keydown", { key: "Home", altKey: true }); + + expect(tileNamed(wrapper, "thorns").attributes("aria-checked")).toBe("true"); + + wrapper.unmount(); + }); +}); diff --git a/test/components/CosmeticsPanel.test.ts b/test/components/CosmeticsPanel.test.ts new file mode 100644 index 00000000..a587232d --- /dev/null +++ b/test/components/CosmeticsPanel.test.ts @@ -0,0 +1,312 @@ +/** + * The wardrobe list: each line says what is worn on its kind, and the picker it opens closes only once + * the server took the change. A refusal is said in its own words, a lost call is said as one, and + * either leaves the picker up with the draft intact; taking off something already off is not a failure. + */ + +import { + beforeEach, + describe, + expect, + test, + vi, +} from "vitest"; +import { + flushPromises, + mount, + type VueWrapper, +} from "@vue/test-utils"; + +const h = vi.hoisted(() => ({ + equip: vi.fn(), + unequip: vi.fn(), + toast: vi.fn(), + worn: new Map(), + items: new Map(), +})); + +vi.mock("@argon/core", () => ({ + logger: { log() {}, debug() {}, info() {}, warn() {}, error() {} }, +})); +vi.mock("@argon/ui/toast", () => ({ useToast: () => ({ toast: h.toast }) })); +vi.mock("@/store/system/localeStore", async () => { + const { ref } = await import("vue"); + const currentLocale = ref("en"); + + return { + useLocale: () => ({ t: (key: string) => key, currentLocale }), + }; +}); +vi.mock("@/store/auth/meStore", async () => { + const { ref } = await import("vue"); + const me = { + me: { userId: "me", displayName: "Omni", username: "omni", avatarFileId: null }, + meProfile: ref({ userId: "me", cosmetics: [] }), + isPremium: false, + }; + + return { useMe: () => me }; +}); +vi.mock("@/store/features/cosmeticsStore", async () => { + const { reactive, ref } = await import("vue"); + const wearableKinds = ref([ + { key: "profile.frame", labelKey: "kind_profile_frame", bare: false }, + { key: "nickname.style", labelKey: "kind_nickname_style", bare: true }, + ]); + const store = reactive({ + wearableKinds, + ensureLoaded: async () => {}, + optionsFor: () => [...h.items.values()], + wornOfKind: (_profile: unknown, kindKey: string) => h.worn.get(kindKey) ?? null, + itemById: (itemId: string | null | undefined) => (itemId ? h.items.get(itemId) ?? null : null), + equip: (...args: unknown[]) => h.equip(...args), + unequip: (...args: unknown[]) => h.unequip(...args), + }); + + return { useCosmeticsStore: () => store }; +}); +vi.mock("@/components/settings/cosmetics/CosmeticRowPreview.vue", async () => { + const { defineComponent, h: render } = await import("vue"); + + return { default: defineComponent({ setup: () => () => render("span") }) }; +}); +vi.mock("@/components/settings/cosmetics/CosmeticPickerDialog.vue", async () => { + const { defineComponent, h: render } = await import("vue"); + + return { + default: defineComponent({ + name: "CosmeticPickerDialog", + props: { + open: { type: Boolean, required: true }, + kind: { type: Object, required: true }, + profile: { type: Object, default: null }, + items: { type: Array, required: true }, + busy: { type: Boolean, required: true }, + canUpsell: { type: Boolean, default: false }, + }, + emits: ["update:open", "apply", "upsell"], + setup: props => () => (props.open ? render("div", { class: "picker-stub" }) : null), + }), + }; +}); + +import { + CosmeticError, + WornItem, + type CatalogueCosmetic, +} from "@argon/glue"; + +import CosmeticsPanel from "@/components/settings/cosmetics/CosmeticsPanel.vue"; +import { + equipChange, + unequipChange, + type WardrobeChange, +} from "@/components/settings/cosmetics/wardrobeChange"; + +const FRAME = "aaaaaaaa-0000-0000-0000-000000000001"; + +const KIND_INDEX: Record = { "profile.frame": 0 }; + +function frameRow(): CatalogueCosmetic { + return { + cosmeticId: FRAME, + kindKey: "profile.frame", + slug: "vines", + nameKey: "cosmetic_vines", + text: [{ locale: "en", name: "Vines", description: null }], + } as unknown as CatalogueCosmetic; +} + +async function mountPanel(props: { canUpsell?: boolean } = {}): Promise { + const wrapper = mount(CosmeticsPanel, { props }); + + await flushPromises(); + + return wrapper; +} + +function rowOf(wrapper: VueWrapper, kindKey: string) { + return wrapper.findAll(".worn-row")[KIND_INDEX[kindKey]]; +} + +function wornLabelOf(wrapper: VueWrapper, kindKey: string): string { + return rowOf(wrapper, kindKey).get(".worn-value").text(); +} + +function picker(wrapper: VueWrapper) { + return wrapper.getComponent({ name: "CosmeticPickerDialog" }); +} + +function pickerIsOpen(wrapper: VueWrapper): boolean { + return wrapper.find(".picker-stub").exists(); +} + +async function openPicker(wrapper: VueWrapper, kindKey: string): Promise { + await rowOf(wrapper, kindKey).trigger("click"); +} + +async function send(wrapper: VueWrapper, change: WardrobeChange): Promise { + picker(wrapper).vm.$emit("apply", change); + await flushPromises(); +} + +function frameChange(itemId = FRAME): WardrobeChange { + return equipChange("profile.frame", new WornItem(itemId)); +} + +beforeEach(() => { + h.equip.mockReset(); + h.unequip.mockReset(); + h.toast.mockReset(); + h.worn.clear(); + h.items.clear(); + h.items.set(FRAME, frameRow()); +}); + +describe("worn label", () => { + test("names the item worn on the kind", async () => { + h.worn.set("profile.frame", new WornItem(FRAME)); + + const wrapper = await mountPanel(); + + expect(wornLabelOf(wrapper, "profile.frame")).toBe("Vines"); + }); + + test("says none when nothing is worn", async () => { + const wrapper = await mountPanel(); + + expect(wornLabelOf(wrapper, "profile.frame")).toBe("none"); + }); + + test("says none when the worn item is not in the catalogue", async () => { + h.worn.set("profile.frame", new WornItem("bbbbbbbb-0000-0000-0000-000000000009")); + + const wrapper = await mountPanel(); + + expect(wornLabelOf(wrapper, "profile.frame")).toBe("none"); + }); + + test("a bare kind is not offered", async () => { + const wrapper = await mountPanel(); + + expect(wrapper.findAll(".worn-row")).toHaveLength(1); + }); +}); + +describe("the picker", () => { + test("opens on the kind of its row, with that kind's rows to choose between", async () => { + const wrapper = await mountPanel(); + + await openPicker(wrapper, "profile.frame"); + + expect(pickerIsOpen(wrapper)).toBe(true); + expect((picker(wrapper).props("kind") as { key: string }).key).toBe("profile.frame"); + expect(picker(wrapper).props("items")).toEqual([frameRow()]); + }); + + test("closes when it asks to", async () => { + const wrapper = await mountPanel(); + + await openPicker(wrapper, "profile.frame"); + picker(wrapper).vm.$emit("update:open", false); + await flushPromises(); + + expect(pickerIsOpen(wrapper)).toBe(false); + }); + + test("an upsell closes it and is passed up", async () => { + const wrapper = await mountPanel({ canUpsell: true }); + + await openPicker(wrapper, "profile.frame"); + + expect(picker(wrapper).props("canUpsell")).toBe(true); + + picker(wrapper).vm.$emit("upsell"); + await flushPromises(); + + expect(pickerIsOpen(wrapper)).toBe(false); + expect(wrapper.emitted("upsell")).toHaveLength(1); + }); +}); + +describe("applying from the picker", () => { + test("an equip goes to the store with the worn entry, and closes once it takes", async () => { + h.equip.mockResolvedValue(null); + + const wrapper = await mountPanel(); + const change = frameChange(); + + await openPicker(wrapper, "profile.frame"); + await send(wrapper, change); + + expect(h.equip).toHaveBeenCalledWith(change.action === "equip" ? change.worn : null); + expect(h.toast).not.toHaveBeenCalled(); + expect(pickerIsOpen(wrapper)).toBe(false); + }); + + test("an unequip goes to the store by kind, and taking off something already off is quietly fine", async () => { + h.unequip.mockResolvedValue(CosmeticError.NOT_FOUND); + + const wrapper = await mountPanel(); + + await openPicker(wrapper, "profile.frame"); + await send(wrapper, unequipChange("profile.frame")); + + expect(h.unequip).toHaveBeenCalledWith("profile.frame"); + expect(h.toast).not.toHaveBeenCalled(); + expect(pickerIsOpen(wrapper)).toBe(false); + }); + + test("a refusal is reported in its own words and leaves the picker up", async () => { + h.equip.mockResolvedValue(CosmeticError.NOT_OWNED); + + const wrapper = await mountPanel(); + + await openPicker(wrapper, "profile.frame"); + await send(wrapper, frameChange()); + + expect(h.toast).toHaveBeenCalledWith(expect.objectContaining({ + title: "error", + description: "cosmetic_error_not_owned", + variant: "destructive", + })); + expect(pickerIsOpen(wrapper)).toBe(true); + }); + + test("a call that never arrives is reported as one and leaves the picker up", async () => { + h.equip.mockRejectedValue(new Error("offline")); + + const wrapper = await mountPanel(); + + await openPicker(wrapper, "profile.frame"); + await send(wrapper, frameChange()); + + expect(h.toast).toHaveBeenCalledWith(expect.objectContaining({ description: "cosmetic_error_network" })); + expect(pickerIsOpen(wrapper)).toBe(true); + expect(picker(wrapper).props("busy")).toBe(false); + }); + + test("while one change is on its way the rows and the picker are busy, and a second is ignored", async () => { + let settle!: (value: null) => void; + + h.equip.mockReturnValue(new Promise(resolve => { + settle = resolve; + })); + + const wrapper = await mountPanel(); + + await openPicker(wrapper, "profile.frame"); + await send(wrapper, frameChange("a")); + + expect(picker(wrapper).props("busy")).toBe(true); + expect(rowOf(wrapper, "profile.frame").attributes("disabled")).toBeDefined(); + + await send(wrapper, frameChange("b")); + + settle(null); + await flushPromises(); + + expect(h.equip).toHaveBeenCalledTimes(1); + expect(pickerIsOpen(wrapper)).toBe(false); + }); +}); diff --git a/test/cosmetics/cosmeticText.test.ts b/test/cosmetics/cosmeticText.test.ts new file mode 100644 index 00000000..5782c87d --- /dev/null +++ b/test/cosmetics/cosmeticText.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { cosmeticDescription, cosmeticName } from "@/lib/cosmeticText"; + +function item(text: Array<{ locale: string; name: string; description: string | null }> | null) { + return { nameKey: "cosmetic_background_sakura", text } as never; +} + +describe("cosmeticText", () => { + it("prefers the reader's own language", () => { + const subject = item([ + { locale: "en", name: "Sakura", description: null }, + { locale: "am", name: "Սակուրա", description: null }, + ]); + + expect(cosmeticName(subject, "am")).toBe("Սակուրա"); + }); + + it("falls back to english when the language is not written", () => { + const subject = item([{ locale: "en", name: "Sakura", description: null }]); + + expect(cosmeticName(subject, "jp")).toBe("Sakura"); + }); + + it("falls back to the key when nothing is written", () => { + expect(cosmeticName(item(null), "am")).toBe("cosmetic_background_sakura"); + expect(cosmeticName(item([]), "am")).toBe("cosmetic_background_sakura"); + }); + + it("does not take english text for a language that has its own name but no description", () => { + const subject = item([ + { locale: "en", name: "Sakura", description: "A tree" }, + { locale: "am", name: "Սակուրա", description: null }, + ]); + + expect(cosmeticDescription(subject, "am")).toBeNull(); + }); + + it("has no description when nothing is written", () => { + expect(cosmeticDescription(item(null), "en")).toBeNull(); + }); +}); diff --git a/test/cosmetics/leadingGraphemes.test.ts b/test/cosmetics/leadingGraphemes.test.ts new file mode 100644 index 00000000..8d46273a --- /dev/null +++ b/test/cosmetics/leadingGraphemes.test.ts @@ -0,0 +1,24 @@ +import { + describe, + expect, + test, +} from "vitest"; + +import { leadingGraphemes } from "@/cosmetics/preview/leadingGraphemes"; + +describe("leadingGraphemes", () => { + test("keeps a short name whole and cuts a long one", () => { + expect(leadingGraphemes("Omni", 6)).toBe("Omni"); + expect(leadingGraphemes("Omnifaced", 6)).toBe("Omnifa"); + }); + + test("never splits a surrogate pair or a joined emoji", () => { + expect(leadingGraphemes("ab😀cd", 3)).toBe("ab😀"); + expect(leadingGraphemes("👨‍👩‍👧x", 1)).toBe("👨‍👩‍👧"); + }); + + test("an empty name or a zero count is empty", () => { + expect(leadingGraphemes("", 6)).toBe(""); + expect(leadingGraphemes("Omni", 0)).toBe(""); + }); +}); diff --git a/test/cosmetics/wardrobeChange.test.ts b/test/cosmetics/wardrobeChange.test.ts new file mode 100644 index 00000000..9a45f278 --- /dev/null +++ b/test/cosmetics/wardrobeChange.test.ts @@ -0,0 +1,98 @@ +/** + * What one Apply in the wardrobe sends. A catalogue kind wears one row (`WornItem`), and choosing + * nothing takes the kind off instead of saving an empty look. + */ + +import { + describe, + expect, + test, +} from "vitest"; +import { + CosmeticError, + WornItem, + type CatalogueCosmetic, + type IWornCosmetic, +} from "@argon/glue"; + +import { + equipChange, + itemChange, + refusalOf, + unequipChange, + wornAfter, +} from "@/components/settings/cosmetics/wardrobeChange"; +import { + availableUntilOf, + lockReasonOf, + lockedAccessibleName, +} from "@/components/settings/cosmetics/cosmeticLock"; + +function row(overrides: Partial = {}): CatalogueCosmetic { + return { + cosmeticId: "aaaaaaaa-0000-0000-0000-000000000001", + kindKey: "profile.frame", + slug: "frame", + nameKey: "cosmetic_frame", + descriptionKey: null, + rarity: null, + version: 1, + payload: null as never, + assets: [], + availableUntil: null, + free: false, + ultima: false, + text: [], + ...overrides, + }; +} + +describe("item kinds", () => { + test("a chosen row is equipped as a WornItem carrying its id", () => { + const change = itemChange("profile.frame", "frame-1"); + + expect(change.action).toBe("equip"); + expect(change.kindKey).toBe("profile.frame"); + + const worn = wornAfter(change); + + expect(worn).toBeInstanceOf(WornItem); + expect((worn as WornItem).itemId).toBe("frame-1"); + }); + + test("no row is an unequip of the kind", () => { + expect(itemChange("avatar.decoration", null)).toEqual({ action: "unequip", kindKey: "avatar.decoration" }); + }); +}); + +describe("locks", () => { + test("the caption says why", () => { + expect(lockReasonOf(row({ ultima: true }), false)).toBe("ultima"); + expect(lockReasonOf(row(), false)).toBe("granted"); + expect(lockReasonOf(row(), true)).toBeNull(); + }); + + test("availability is read off the row when it has an end", () => { + const end = new Date("2026-12-31T00:00:00Z"); + + expect(availableUntilOf(row())).toBeNull(); + expect(availableUntilOf(row({ availableUntil: { toDate: () => end } as never }))).toEqual(end); + }); + + test("a locked row says so in its accessible name", () => { + expect(lockedAccessibleName("Vines", true, "Locked")).toBe("Vines"); + expect(lockedAccessibleName("Vines", false, "Locked")).toBe("Vines, Locked"); + }); +}); + +describe("refusals", () => { + test("NOT_FOUND on an unequip is the asked-for state", () => { + expect(refusalOf(unequipChange("profile.frame"), CosmeticError.NOT_FOUND)).toBeNull(); + }); + + test("NOT_FOUND on an equip is still a refusal, and so is anything else on an unequip", () => { + expect(refusalOf(equipChange("profile.frame", new WornItem("x")), CosmeticError.NOT_FOUND)).toBe(CosmeticError.NOT_FOUND); + expect(refusalOf(unequipChange("profile.frame"), CosmeticError.KIND_DISABLED)).toBe(CosmeticError.KIND_DISABLED); + expect(refusalOf(unequipChange("profile.frame"), null)).toBeNull(); + }); +}); diff --git a/test/lib/cosmeticErrorKey.test.ts b/test/lib/cosmeticErrorKey.test.ts new file mode 100644 index 00000000..707d4c90 --- /dev/null +++ b/test/lib/cosmeticErrorKey.test.ts @@ -0,0 +1,45 @@ +/** + * Every reason the server gives for refusing an equip has its own sentence; a reason this build does + * not know falls back to the generic one rather than rendering a key. + */ + +import { + describe, + expect, + test, +} from "vitest"; +import { CosmeticError } from "@argon/glue"; + +import { cosmeticErrorKey } from "@/lib/refusals"; + +import en from "../../packages/i18n/src/core/en.json"; +import ru from "../../packages/i18n/src/core/ru.json"; + +describe("cosmeticErrorKey", () => { + test.each([ + [CosmeticError.NOT_FOUND, "cosmetic_error_not_found"], + [CosmeticError.UNKNOWN_KIND, "cosmetic_error_unknown_kind"], + [CosmeticError.KIND_DISABLED, "cosmetic_error_kind_disabled"], + [CosmeticError.ITEM_UNAVAILABLE, "cosmetic_error_item_unavailable"], + [CosmeticError.NOT_OWNED, "cosmetic_error_not_owned"], + [CosmeticError.CHOICE_INVALID, "cosmetic_error_choice_invalid"], + [CosmeticError.VALUE_OUT_OF_RANGE, "cosmetic_error_value_out_of_range"], + ])("%s maps to %s", (error, key) => { + expect(cosmeticErrorKey(error)).toBe(key); + }); + + test("a reason from a newer server gets the generic sentence", () => { + expect(cosmeticErrorKey(99 as CosmeticError)).toBe("cosmetic_equip_failed"); + }); + + test("every key it can return is written in English and Russian", () => { + const reasons = [0, 1, 2, 3, 4, 5, 6, 99] as CosmeticError[]; + + for (const reason of reasons) { + const key = cosmeticErrorKey(reason); + + expect(en).toHaveProperty([key]); + expect(ru).toHaveProperty([key]); + } + }); +}); diff --git a/test/lib/modalLayers.test.ts b/test/lib/modalLayers.test.ts new file mode 100644 index 00000000..d74e3699 --- /dev/null +++ b/test/lib/modalLayers.test.ts @@ -0,0 +1,249 @@ +/** + * The settings window closes itself on Escape from a `window` listener, and a dialog opened inside it + * may claim that key. Only dialogs that registered through useEscapeDismiss claim it, the topmost of + * them takes one press, and every other dialog leaves the key to the window as before. + */ + +import { + afterEach, + describe, + expect, + test, + vi, +} from "vitest"; +import { + defineComponent, + h, + nextTick, + ref, +} from "vue"; +import { + mount, + type VueWrapper, +} from "@vue/test-utils"; + +import { + isEscapeLayerOpen, + useEscapeDismiss, +} from "@/lib/modalLayers"; + +const Layer = defineComponent({ + props: { + open: { type: Boolean, required: true }, + dismiss: { type: Function, required: true }, + }, + setup(props) { + useEscapeDismiss(() => props.open, () => props.dismiss()); + + return () => h("div"); + }, +}); + +const mounted: VueWrapper[] = []; + +function mountLayer(open: boolean, dismiss: () => void = vi.fn()): VueWrapper { + const wrapper = mount(Layer, { props: { open, dismiss } }); + + mounted.push(wrapper); + + return wrapper; +} + +function press(key = "Escape", target: EventTarget = window): KeyboardEvent { + const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }); + + target.dispatchEvent(event); + + return event; +} + +afterEach(() => { + for (const wrapper of mounted.splice(0)) { + wrapper.unmount(); + } + + document.body.innerHTML = ""; +}); + +describe("the settings window's guard", () => { + test("nothing open is nothing open", () => { + expect(isEscapeLayerOpen()).toBe(false); + }); + + test("a dialog that never registered leaves Escape to the window", () => { + document.body.innerHTML = `
`; + + expect(isEscapeLayerOpen()).toBe(false); + }); + + test("an open registered layer claims it", () => { + mountLayer(true); + + expect(isEscapeLayerOpen()).toBe(true); + }); + + test("a registered layer that is closed, or closing, does not", () => { + mountLayer(false); + + expect(isEscapeLayerOpen()).toBe(false); + }); + + test("an unmounted layer stops counting", () => { + mountLayer(true).unmount(); + mounted.length = 0; + + expect(isEscapeLayerOpen()).toBe(false); + }); + + test("the window's own handler sees the press only when no registered layer is open", async () => { + const closeSettings = vi.fn(); + + function handleEscape(event: KeyboardEvent): void { + if (event.key === "Escape" && !isEscapeLayerOpen()) { + closeSettings(); + } + } + + window.addEventListener("keydown", handleEscape); + + try { + const layer = mountLayer(true); + + press("Escape", document.body); + expect(closeSettings).not.toHaveBeenCalled(); + + await layer.setProps({ open: false }); + press("Escape", document.body); + expect(closeSettings).toHaveBeenCalledOnce(); + } finally { + window.removeEventListener("keydown", handleEscape); + } + }); +}); + +describe("escape dismiss", () => { + test("an open layer takes the key and stops it reaching the window's own handler", () => { + const dismiss = vi.fn(); + const seenByWindow = vi.fn(); + + window.addEventListener("keydown", seenByWindow); + + try { + mountLayer(true, dismiss); + + const event = press(); + + expect(dismiss).toHaveBeenCalledOnce(); + expect(event.defaultPrevented).toBe(true); + expect(seenByWindow).not.toHaveBeenCalled(); + } finally { + window.removeEventListener("keydown", seenByWindow); + } + }); + + test("other keys pass", () => { + const dismiss = vi.fn(); + + mountLayer(true, dismiss); + + expect(press("Enter").defaultPrevented).toBe(false); + expect(dismiss).not.toHaveBeenCalled(); + }); + + test("a closed layer lets the key past", () => { + const dismiss = vi.fn(); + + mountLayer(false, dismiss); + + expect(press().defaultPrevented).toBe(false); + expect(dismiss).not.toHaveBeenCalled(); + }); + + test("an unmounted layer stops listening", () => { + const dismiss = vi.fn(); + + mountLayer(true, dismiss).unmount(); + mounted.length = 0; + press(); + + expect(dismiss).not.toHaveBeenCalled(); + }); + + test("a press something earlier already claimed is left alone", () => { + const dismiss = vi.fn(); + + function claim(event: KeyboardEvent): void { + event.preventDefault(); + } + + window.addEventListener("keydown", claim, true); + + try { + mountLayer(true, dismiss); + press(); + + expect(dismiss).not.toHaveBeenCalled(); + } finally { + window.removeEventListener("keydown", claim, true); + } + }); +}); + +describe("stacked layers", () => { + test("one press dismisses only the layer on top", () => { + const dismissBottom = vi.fn(); + const dismissTop = vi.fn(); + + mountLayer(true, dismissBottom); + mountLayer(true, dismissTop); + + press(); + + expect(dismissTop).toHaveBeenCalledOnce(); + expect(dismissBottom).not.toHaveBeenCalled(); + }); + + test("the top is the layer opened last, not the one mounted last", async () => { + const dismissFirstMounted = vi.fn(); + const dismissLastMounted = vi.fn(); + + const firstMounted = mountLayer(false, dismissFirstMounted); + + mountLayer(true, dismissLastMounted); + await firstMounted.setProps({ open: true }); + + press(); + + expect(dismissFirstMounted).toHaveBeenCalledOnce(); + expect(dismissLastMounted).not.toHaveBeenCalled(); + }); + + test("once the top closes, the next press reaches the one beneath", async () => { + const dismissBottom = vi.fn(); + const topOpen = ref(true); + + mountLayer(true, dismissBottom); + + const top = mount(defineComponent({ + setup() { + useEscapeDismiss(() => topOpen.value, () => { + topOpen.value = false; + }); + + return () => h("div"); + }, + })); + + mounted.push(top); + + press(); + await nextTick(); + + expect(topOpen.value).toBe(false); + expect(dismissBottom).not.toHaveBeenCalled(); + + press(); + + expect(dismissBottom).toHaveBeenCalledOnce(); + }); +});