itube-specs 0.0.946 → 0.0.948

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,14 +21,15 @@
21
21
  <div class="card-category__wrapper-header">
22
22
  <span class="card-category__title">
23
23
  {{ card.title }}
24
+ <!-- Флаг декоративный: страна уже названа в заголовке рядом -->
24
25
  <img
25
26
  v-if="card.icon"
26
27
  :src="`/icons/flags/${card.icon}.svg`"
27
- :alt="card.icon"
28
28
  class="card-category__icon"
29
29
  width="17"
30
30
  height="17"
31
31
  loading="lazy"
32
+ alt=""
32
33
  >
33
34
  </span>
34
35
  <span
@@ -13,10 +13,12 @@
13
13
  <div class="card-model-mini__wrapper">
14
14
  <p class="card-model-mini__name">
15
15
  {{ card.title }}
16
+ <!-- Название страны текстом рядом не продублировано, поэтому оно в `alt`.
17
+ Форма как в CardModel: `name` — код для файла флага, `title` — читаемое имя. -->
16
18
  <img
17
- v-if="card.country"
18
- :alt="card.country"
19
- :src="`/icons/flags/${card.country}.svg`"
19
+ v-if="card.country?.name"
20
+ :alt="card.country.title"
21
+ :src="`/icons/flags/${card.country.name}.svg`"
20
22
  class="card-model-mini__flag"
21
23
  width="16"
22
24
  height="12"
@@ -9,14 +9,15 @@
9
9
  class="category-list__link"
10
10
  >
11
11
  {{ category.title }}
12
+ <!-- Флаг декоративный: страна уже названа в тексте ссылки -->
12
13
  <img
13
14
  v-if="category.icon"
14
15
  :src="`/icons/flags/${category.icon}.svg`"
15
- :alt="category.icon"
16
16
  class="category-list__link-image"
17
17
  width="17"
18
18
  height="17"
19
19
  loading="lazy"
20
+ alt=""
20
21
  >
21
22
  </NuxtLink>
22
23
  <NuxtLink
@@ -41,13 +41,14 @@
41
41
  ]"
42
42
  class="info-grid__item-value"
43
43
  >
44
+ <!-- Флаг декоративный: страна названа в `value.title` ниже -->
44
45
  <img
45
46
  v-if="item.name === 'nationality_iso_codes' && isoCodes"
46
47
  :src="`/icons/flags/${isoCodes[vIndex]}.svg`"
47
- :alt="isoCodes[vIndex]"
48
48
  class="info-grid__item-flag"
49
49
  width="20"
50
50
  height="14"
51
+ alt=""
51
52
  >
52
53
  <UiIcon
53
54
  v-else-if="item.icon"
@@ -77,11 +77,13 @@
77
77
  size="14"
78
78
  />
79
79
  <span class="info-main__born-text">{{ card.birthSummary }}</span>
80
+ <!-- Национальность нигде больше не подписана — флаг единственный её носитель,
81
+ поэтому в `alt` идёт название страны, а не ISO-код. -->
80
82
  <img
81
83
  v-for="(item, index) in card.nationalityIsoCodes"
82
84
  :key="`nationalityIsoCodes-${index}`"
83
85
  :src="`/icons/flags/${item}.svg`"
84
- :alt="item"
86
+ :alt="isoCodeToCountryName(item, locale)"
85
87
  class="info-main__born-flag"
86
88
  width="20"
87
89
  height="14"
@@ -157,8 +159,9 @@ import type {
157
159
  } from '../../types';
158
160
  import { AuthorizationApiService } from '../../services/api/authorization.service';
159
161
  import { groupParametersByGroup } from '../../utils/info-main/group-parameters-by-group';
162
+ import { isoCodeToCountryName } from '../../utils/iso-code-to-country-name';
160
163
 
161
- const { t } = useI18n();
164
+ const { t, locale } = useI18n();
162
165
 
163
166
  const props = defineProps<{
164
167
  card: ICardInfo
@@ -211,13 +214,26 @@ function getParameter(name: string) {
211
214
  return props.card.parameters?.find(p => p.name === name)?.values?.[ 0 ]?.name;
212
215
  }
213
216
 
217
+ /**
218
+ * `IModelCard.country` — пара «код файла флага + читаемое имя», как её отдаёт
219
+ * cleanModelCard. Раньше сюда уезжал целиком массив `nationalityIsoCodes`: у модели с
220
+ * двумя национальностями путь к флагу выходил `/icons/flags/us,gb.svg`, а у модели без
221
+ * национальностей пустой массив проходил `v-if` и давал битую картинку. Берём первый код.
222
+ */
223
+ const shareCountry = computed(() => {
224
+ const iso = props.card.nationalityIsoCodes?.[0] || '';
225
+ if (!iso) return undefined;
226
+
227
+ return { name: iso, title: isoCodeToCountryName(iso, locale.value) };
228
+ });
229
+
214
230
  const shareModelCard = computed<IModelCard>(() => ({
215
231
  title: props.card.title,
216
232
  videosCount: props.card.videosCount,
217
233
  thumbUrl: props.card.img || '',
218
234
  guid: props.card.guid || '',
219
235
  parameters: [],
220
- country: props.card.nationalityIsoCodes || '',
236
+ country: shareCountry.value,
221
237
  age: getParameter('age'),
222
238
  }));
223
239
 
@@ -132,6 +132,7 @@
132
132
 
133
133
  <script setup lang="ts">
134
134
  import type { ISelectItem } from '../../types';
135
+ import { isoCodeToCountryName } from '../../utils/iso-code-to-country-name';
135
136
 
136
137
  const { locale, locales } = useI18n();
137
138
  const switchLocalePath = useSwitchLocalePath();
@@ -143,20 +144,46 @@ defineProps<{
143
144
  }>();
144
145
 
145
146
  const {
147
+ code: clientCountryCode,
146
148
  icon: clientCountryIcon,
147
- name: clientCountryName,
149
+ place: clientCountryPlace,
148
150
  flagSrc: clientCountryFlag,
149
151
  countriesItems,
150
152
  setCountry,
151
153
  loadCountries,
152
154
  } = useClientCountry();
153
155
 
156
+ // Именительный падеж считает ICU, своей таблицы имён нет. Пустой `place` = служебный код
157
+ // гео-заголовка (`XX`, `T1`, `AP`), такую «страну» не подписываем.
158
+ const clientCountryName = computed(() => (
159
+ clientCountryPlace.value ? isoCodeToCountryName(clientCountryCode.value, locale.value) : ''
160
+ ));
161
+
154
162
  const isCountryOpen = ref(false);
155
163
  const countryValue = ref('');
164
+
165
+ // Список стран приходит с API только по-английски — подписываем его тем же ICU-словарём,
166
+ // что и текущую страну, и пересортировываем коллатором: порядок с бэка — английский
167
+ // алфавит. Нераспознанный код `isoCodeToCountryName` вернёт как есть — тогда имя с API.
168
+ const localizedCountries = computed(() => {
169
+ const collator = new Intl.Collator(locale.value);
170
+
171
+ return countriesItems.value
172
+ .map((item) => {
173
+ const code = (item.key || '').toUpperCase();
174
+ const name = isoCodeToCountryName(code, locale.value);
175
+ const title = name === code ? item.title : name;
176
+ return { ...item, title, value: title, apiTitle: item.title };
177
+ })
178
+ .sort((a, b) => collator.compare(a.title, b.title));
179
+ });
180
+
181
+ // Ищем и по английскому имени: привычка набирать `germany` не должна ломаться на ru-локали
156
182
  const filteredCountries = computed(() => {
157
- if (!countryValue.value) return countriesItems.value;
183
+ if (!countryValue.value) return localizedCountries.value;
158
184
  const query = countryValue.value.toLowerCase();
159
- return countriesItems.value.filter(item => item.title.toLowerCase().includes(query));
185
+ return localizedCountries.value.filter(item => item.title.toLowerCase().includes(query)
186
+ || item.apiTitle.toLowerCase().includes(query));
160
187
  });
161
188
 
162
189
  function onCountryToggle(e: Event) {
@@ -9,7 +9,8 @@ vi.mock('../../services/api/dictionaries.service', () => ({
9
9
  }));
10
10
 
11
11
  const setCode = (code: string) => useState<string>('clientCountryCode', () => '').value = code;
12
- const setResolvedName = (name: string) => useState<string>('clientCountryName', () => '').value = name;
12
+ /** Признак реальной страны: ставит SSR-плагин из lib/countries */
13
+ const setPlace = (place: string) => useState<string>('clientCountryPlace', () => '').value = place;
13
14
 
14
15
  describe('useClientCountry', () => {
15
16
  beforeEach(() => {
@@ -17,17 +18,17 @@ describe('useClientCountry', () => {
17
18
  getCountries.mockReset();
18
19
  });
19
20
 
20
- it('без кода не отдаёт ни флаг, ни имя', () => {
21
- const { icon, name, flagSrc } = useClientCountry();
21
+ it('без кода не отдаёт ни флаг, ни обстоятельство места', () => {
22
+ const { icon, place, flagSrc } = useClientCountry();
22
23
 
23
24
  expect(icon.value).toBe('');
24
- expect(name.value).toBe('');
25
+ expect(place.value).toBe('');
25
26
  expect(flagSrc.value).toBe('');
26
27
  });
27
28
 
28
29
  it('строит иконку и путь к флагу из кода', () => {
29
30
  setCode('ad');
30
- setResolvedName('Andorra');
31
+ setPlace('в Андорре');
31
32
  const { icon, flagSrc } = useClientCountry();
32
33
 
33
34
  expect(icon.value).toBe('ad');
@@ -36,7 +37,7 @@ describe('useClientCountry', () => {
36
37
 
37
38
  it('нормализует код: регистр и пробелы', () => {
38
39
  setCode(' AD ');
39
- setResolvedName('Andorra');
40
+ setPlace('в Андорре');
40
41
  const { icon, flagSrc } = useClientCountry();
41
42
 
42
43
  expect(icon.value).toBe('ad');
@@ -45,7 +46,7 @@ describe('useClientCountry', () => {
45
46
 
46
47
  it('учитывает страны, где код флага отличается от ISO-кода', () => {
47
48
  setCode('cn');
48
- setResolvedName('China');
49
+ setPlace('в Китае');
49
50
  const { icon, flagSrc } = useClientCountry();
50
51
 
51
52
  expect(icon.value).toBe('zh');
@@ -53,19 +54,28 @@ describe('useClientCountry', () => {
53
54
  });
54
55
 
55
56
  it('не показывает флаг для служебного кода, которого нет в словаре', () => {
56
- // Cloudflare отдаёт XX для неизвестного IP и T1 для Tor — файла такого флага нет
57
+ // Cloudflare отдаёт XX для неизвестного IP и T1 для Tor — файла такого флага нет.
58
+ // Реальность страны определяет непустой `place`: по Intl.DisplayNames не проверить,
59
+ // она вернёт `XX` как `XX`, а `US` в en-локали — как `US`
57
60
  setCode('xx');
58
- const { icon, name, flagSrc } = useClientCountry();
61
+ const { icon, place, flagSrc } = useClientCountry();
59
62
 
60
63
  expect(icon.value).toBe('xx');
61
- expect(name.value).toBe('');
64
+ expect(place.value).toBe('');
62
65
  expect(flagSrc.value).toBe('');
63
66
  });
64
67
 
65
- it('имя следует за кодом и не может остаться от другой страны', async () => {
68
+ it('обстоятельство места берёт из состояния, поставленного SSR', () => {
69
+ setCode('cz');
70
+ setPlace('в Чехии');
71
+ const { place } = useClientCountry();
72
+
73
+ expect(place.value).toBe('в Чехии');
74
+ });
75
+
76
+ it('загруженный список стран флаг не ломает', async () => {
66
77
  setCode('ad');
67
- // имя, доставшееся от предыдущей страны — именно этот рассинхрон ловим
68
- setResolvedName('USA');
78
+ setPlace('в Андорре');
69
79
  getCountries.mockResolvedValue({
70
80
  countries: [
71
81
  { title: 'Andorra', isoCodes: ['AD', 'AND'] },
@@ -73,30 +83,20 @@ describe('useClientCountry', () => {
73
83
  ],
74
84
  });
75
85
 
76
- const { name, flagSrc, loadCountries } = useClientCountry();
86
+ const { flagSrc, loadCountries } = useClientCountry();
77
87
  await loadCountries();
78
88
 
79
- expect(name.value).toBe('Andorra');
80
89
  expect(flagSrc.value).toBe('/icons/flags/ad.svg');
81
90
  });
82
91
 
83
- it('до загрузки списка берёт имя, отрезолвленное на сервере', () => {
84
- setCode('ad');
85
- setResolvedName('Andorra');
86
- const { name } = useClientCountry();
87
-
88
- expect(name.value).toBe('Andorra');
89
- });
90
-
91
- it('выбор страны меняет и код, и имя', () => {
92
+ it('выбор страны меняет код и флаг', () => {
92
93
  setCode('ad');
93
- setResolvedName('Andorra');
94
- const { code, name, flagSrc, setCountry } = useClientCountry();
94
+ setPlace('в Андорре');
95
+ const { code, flagSrc, setCountry } = useClientCountry();
95
96
 
96
97
  setCountry({ title: 'USA', value: 'USA', icon: 'us', key: 'us' });
97
98
 
98
99
  expect(code.value).toBe('us');
99
- expect(name.value).toBe('USA');
100
100
  expect(flagSrc.value).toBe('/icons/flags/us.svg');
101
101
  });
102
102
 
@@ -7,17 +7,23 @@ const COOKIE_OPTS = { maxAge: 60 * 60 * 24 * 365, path: '/', secure: true, sameS
7
7
 
8
8
  /**
9
9
  * Единый источник страны клиента: ISO-код (гео-заголовок прокси или ручной выбор
10
- * в селекторе), иконка флага и локализованное имя.
10
+ * в селекторе), иконка флага и обстоятельство места для заголовков.
11
11
  *
12
- * Имя всегда резолвится ОТ ТЕКУЩЕГО кода — своего кеша у него нет. Раньше имя
13
- * лежало в отдельной куке `clientCountryName` рядом с парной `clientCountryNameCode`,
14
- * и пара могла разъехаться: заголовок писал одну страну, флаг и селектор показывали
15
- * другую (реальный кейс на проде: `clientCountryName=USA` при коде `ad`).
12
+ * Всё резолвится ОТ ТЕКУЩЕГО кода — своего кеша нет. Раньше имя страны лежало в отдельной
13
+ * куке `clientCountryName` рядом с парной `clientCountryNameCode`, и пара могла разъехаться:
14
+ * заголовок писал одну страну, флаг и селектор показывали другую (реальный кейс на проде:
15
+ * `clientCountryName=USA` при коде `ad`).
16
+ *
17
+ * Имени тут нет намеренно: именительный падеж один в один отдаёт `Intl.DisplayNames`, см.
18
+ * utils/iso-code-to-country-name.ts — потребитель зовёт её со своей локалью.
16
19
  */
17
20
  export const useClientCountry = () => {
18
21
  const code = useState<string>('clientCountryCode', () => '');
19
- const resolvedName = useState<string>('clientCountryName', () => '');
20
22
  const countriesItems = useState<ISelectItem[]>('countriesItems', () => []);
23
+ // Обстоятельство места («в Чехии») ставит только SSR-плагин из lib/countries: косвенных
24
+ // падежей и артиклей в ECMA-402 нет. Ручной выбор страны в селекторе перезагружает
25
+ // страницу, поэтому клиенту словарь не нужен.
26
+ const place = useState<string>('clientCountryPlace', () => '');
21
27
  const codeCookie = useCookie<string | null>('clientCountryCode', COOKIE_OPTS);
22
28
 
23
29
  /** ISO-код → имя файла флага (у части стран код локали отличается от кода страны) */
@@ -28,22 +34,18 @@ export const useClientCountry = () => {
28
34
 
29
35
  const icon = computed(() => toIcon(code.value));
30
36
 
31
- // Список стран догружается лениво на клиенте (см. layout/language.vue) — если он
32
- // уже есть, имя берём из него: там оно точно совпадает с текущим кодом.
33
- const name = computed(() => {
34
- if (!icon.value) return '';
35
- return countriesItems.value.find(item => item.icon === icon.value)?.title || resolvedName.value;
36
- });
37
-
38
- // Флаг только для страны, которую смогли назвать: гео-заголовок умеет отдавать
39
- // служебные коды (Cloudflare `XX` для неизвестного IP и `T1` для Tor, MaxMind `AP`),
40
- // их нет ни в словаре стран, ни среди файлов флагов — вышла бы битая картинка.
41
- const flagSrc = computed(() => (icon.value && name.value ? `/icons/flags/${icon.value}.svg` : ''));
37
+ // Флаг только для страны, которая есть в словаре: гео-заголовок умеет присылать служебные
38
+ // коды (Cloudflare `XX` для неизвестного IP и `T1` для Tor, MaxMind `AP`) — файла такого
39
+ // флага нет, вышла бы битая картинка. Непустой `place` и есть этот признак: по
40
+ // `Intl.DisplayNames` не проверить, она вернёт `XX` как `XX`, а `US` в en-локали — как `US`.
41
+ const flagSrc = computed(() => (icon.value && place.value ? `/icons/flags/${icon.value}.svg` : ''));
42
42
 
43
- /** Ручной выбор страны в селекторе: наружу переживает перезагрузку только код. */
43
+ /**
44
+ * Ручной выбор страны в селекторе: наружу переживает перезагрузку только код. `place`
45
+ * пересчитает следующий SSR — layout/language.vue тут же уходит в `location.href`.
46
+ */
44
47
  const setCountry = (item: ISelectItem) => {
45
48
  code.value = item.key || '';
46
- resolvedName.value = item.title;
47
49
  codeCookie.value = code.value || null;
48
50
  };
49
51
 
@@ -69,5 +71,5 @@ export const useClientCountry = () => {
69
71
  }
70
72
  };
71
73
 
72
- return { code, icon, name, flagSrc, countriesItems, setCountry, loadCountries };
74
+ return { code, icon, place, flagSrc, countriesItems, setCountry, loadCountries };
73
75
  };
@@ -0,0 +1,60 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { getCountryPlace } from '../index';
3
+ import en from '../en.json';
4
+
5
+ describe('getCountryPlace', () => {
6
+ it('отдаёт обстоятельство места с предлогом и падежом', async () => {
7
+ expect(await getCountryPlace('cz', 'ru')).toBe('в Чехии');
8
+ expect(await getCountryPlace('cu', 'ru')).toBe('на Кубе');
9
+ expect(await getCountryPlace('fr', 'ru')).toBe('во Франции');
10
+ });
11
+
12
+ it('учитывает артикли и сочленённые предлоги других локалей', async () => {
13
+ expect(await getCountryPlace('us', 'en')).toBe('in the United States');
14
+ expect(await getCountryPlace('ph', 'de')).toBe('auf den Philippinen');
15
+ expect(await getCountryPlace('us', 'it')).toBe('negli Stati Uniti');
16
+ expect(await getCountryPlace('us', 'fr')).toBe('aux États-Unis');
17
+ });
18
+
19
+ it('для локалей без предлога отдаёт именительный падеж — частица живёт в шаблоне', async () => {
20
+ expect(await getCountryPlace('cz', 'ja')).toBe('チェコ');
21
+ expect(await getCountryPlace('cz', 'ko')).toBe('체코');
22
+ expect(await getCountryPlace('cz', 'zh')).toBe('捷克');
23
+ });
24
+
25
+ it('нормализует код: регистр и пробелы', async () => {
26
+ expect(await getCountryPlace(' CZ ', 'ru')).toBe('в Чехии');
27
+ });
28
+
29
+ it('на пустом и служебном коде отдаёт пустую строку', async () => {
30
+ expect(await getCountryPlace('', 'ru')).toBe('');
31
+ // Cloudflare XX (неизвестный IP) и T1 (Tor), MaxMind AP — не ISO-страны
32
+ expect(await getCountryPlace('xx', 'ru')).toBe('');
33
+ expect(await getCountryPlace('t1', 'ru')).toBe('');
34
+ expect(await getCountryPlace('ap', 'ru')).toBe('');
35
+ });
36
+
37
+ it('неизвестную локаль резолвит английским словарём', async () => {
38
+ expect(await getCountryPlace('cz', 'pt')).toBe('in Czechia');
39
+ });
40
+
41
+ it('словари покрывают все локали одним набором кодов', async () => {
42
+ const codes = Object.keys(en).sort();
43
+
44
+ for (const locale of ['de', 'es', 'fr', 'it', 'ja', 'ko', 'ru', 'zh']) {
45
+ const { default: places } = await import(`../${locale}.json`);
46
+
47
+ expect(Object.keys(places).sort(), locale).toEqual(codes);
48
+ }
49
+ });
50
+
51
+ it('именительного падежа не держит — его отдаёт ICU один в один', async () => {
52
+ for (const locale of ['de', 'en', 'es', 'fr', 'it', 'ja', 'ko', 'ru', 'zh']) {
53
+ const { default: places } = await import(`../${locale}.json`);
54
+ const displayNames = new Intl.DisplayNames([locale], { type: 'region' });
55
+ const missing = Object.keys(places).filter(code => !displayNames.of(code.toUpperCase()));
56
+
57
+ expect(missing, locale).toEqual([]);
58
+ }
59
+ });
60
+ });