itube-specs 0.0.706 → 0.0.710
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.
- package/components/page-components/s-filter-page.vue +89 -17
- package/components/page-components/s-info-grid.vue +9 -6
- package/components/ui/s-input.vue +9 -1
- package/composables/use-filter-scheme.test.ts +57 -0
- package/composables/use-get-pure-route-name.test.ts +30 -0
- package/composables/use-get-videos-filter-request.test.ts +45 -0
- package/composables/use-model-filter-chips.test.ts +67 -0
- package/composables/use-seo-links.test.ts +74 -0
- package/composables/use-slug.test.ts +36 -0
- package/composables/use-snackbar.test.ts +64 -0
- package/composables/use-units.test.ts +51 -0
- package/composables/use-units.ts +21 -0
- package/package.json +7 -2
- package/runtime/utils/format-time-ago.test.ts +49 -0
|
@@ -4,37 +4,77 @@
|
|
|
4
4
|
<h2
|
|
5
5
|
v-if="title"
|
|
6
6
|
class="s-filter-page__title"
|
|
7
|
-
>{{ title }}
|
|
7
|
+
>{{ title }}
|
|
8
|
+
</h2>
|
|
8
9
|
<div
|
|
9
10
|
v-for="(group, index) in groups"
|
|
10
11
|
:key="`filter-group-${index}`"
|
|
11
12
|
class="s-filter-page__group"
|
|
13
|
+
:class="{'--short': group.name === 'tags'}"
|
|
12
14
|
>
|
|
13
|
-
<
|
|
15
|
+
<div
|
|
14
16
|
v-if="filters.length > 0"
|
|
15
|
-
class="s-filter-page__group-
|
|
17
|
+
class="s-filter-page__group-header"
|
|
16
18
|
>
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
<span class="s-filter-page__group-title">
|
|
20
|
+
{{ group?.title }}
|
|
21
|
+
</span>
|
|
22
|
+
<FSegmentedControl
|
|
23
|
+
v-if="group?.name === 'physical'"
|
|
24
|
+
:items="unitItems"
|
|
25
|
+
:model-value="units"
|
|
26
|
+
small
|
|
27
|
+
@update:model-value="units = $event"
|
|
28
|
+
/>
|
|
29
|
+
</div>
|
|
30
|
+
<div
|
|
31
|
+
v-if="getGroupSliders(group?.name).length > 1"
|
|
32
|
+
class="s-filter-page__sliders"
|
|
33
|
+
:style="{ '--slider-count': getGroupSliders(group?.name).length }"
|
|
34
|
+
>
|
|
35
|
+
<SFilterSlider
|
|
36
|
+
v-for="(item, subIndex) in getGroupSliders(group?.name)"
|
|
37
|
+
:key="`slider-${subIndex}`"
|
|
38
|
+
:item="item"
|
|
39
|
+
:group-name="item.group.name"
|
|
40
|
+
:index="subIndex"
|
|
41
|
+
/>
|
|
42
|
+
</div>
|
|
19
43
|
<div class="s-filter-page__items">
|
|
20
44
|
<template v-for="(item, subIndex) in filters">
|
|
21
45
|
<SFilterSlider
|
|
22
|
-
v-if="(item.kind === 'range') && (item.group.name === group?.name)"
|
|
23
|
-
class="
|
|
46
|
+
v-if="(item.kind === 'range') && (item.group.name === group?.name) && !hiddenByUnits.has(item.name) && getGroupSliders(group?.name).length === 1"
|
|
47
|
+
class="s-filter-page__slider"
|
|
24
48
|
:item="item"
|
|
25
49
|
:group-name="item.group.name"
|
|
26
50
|
:index="subIndex"
|
|
27
51
|
/>
|
|
28
52
|
<SSelect
|
|
29
|
-
v-if="(item.kind === 'select') && (item.group.name === group?.name)"
|
|
53
|
+
v-if="(item.kind === 'select') && (item.group.name === group?.name) && !hiddenByUnits.has(item.name)"
|
|
54
|
+
class="s-filter-page__select"
|
|
30
55
|
:key="`model-filter-select-${subIndex}`"
|
|
31
56
|
:name="item.name"
|
|
32
|
-
:model-value="
|
|
57
|
+
:model-value="getValue(item.name)"
|
|
33
58
|
:items="selectItems(item)"
|
|
34
59
|
size="s"
|
|
35
60
|
:active="isActiveSelect(item.name)"
|
|
36
61
|
:label="item.title"
|
|
37
|
-
@update:model-value="val =>
|
|
62
|
+
@update:model-value="val => updateFilter(item.name, val)"
|
|
63
|
+
/>
|
|
64
|
+
<FSegmentedControl
|
|
65
|
+
v-if="(item.kind === 'radio') && (item.group.name === group?.name)"
|
|
66
|
+
class="s-filter-page__radio"
|
|
67
|
+
:items="getRadioItems(item.options)"
|
|
68
|
+
:title="item.title"
|
|
69
|
+
:model-value="getValue(item.name)"
|
|
70
|
+
@update:model-value="val => updateFilter(item.name, val)"
|
|
71
|
+
/>
|
|
72
|
+
<FFilterByChips
|
|
73
|
+
v-if="(item.kind === 'chips') && (item.group.name === group?.name)"
|
|
74
|
+
class="f-filters-main__chips"
|
|
75
|
+
:items="item.options"
|
|
76
|
+
:title="item.title"
|
|
77
|
+
:filter-name="item.name"
|
|
38
78
|
/>
|
|
39
79
|
</template>
|
|
40
80
|
</div>
|
|
@@ -44,7 +84,7 @@
|
|
|
44
84
|
</template>
|
|
45
85
|
|
|
46
86
|
<script setup lang="ts">
|
|
47
|
-
import type { IModelFilter } from '../../types';
|
|
87
|
+
import type { IModelFilter, IModelFilterOptions } from '../../types';
|
|
48
88
|
|
|
49
89
|
import { useRoute, useRouter } from 'vue-router';
|
|
50
90
|
|
|
@@ -57,6 +97,25 @@ const props = defineProps<{
|
|
|
57
97
|
const route = useRoute();
|
|
58
98
|
const router = useRouter();
|
|
59
99
|
|
|
100
|
+
const { t } = useI18n();
|
|
101
|
+
|
|
102
|
+
const { units, hiddenByUnits } = useUnits();
|
|
103
|
+
|
|
104
|
+
watch(units, () => {
|
|
105
|
+
const query = { ...route.query };
|
|
106
|
+
const unitFields = ['height_cm', 'height_in', 'weight_kg', 'weight_lb'];
|
|
107
|
+
for (const field of unitFields) {
|
|
108
|
+
delete query[`filter_${field}_from`];
|
|
109
|
+
delete query[`filter_${field}_to`];
|
|
110
|
+
}
|
|
111
|
+
router.replace({ query });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const unitItems = computed(() => [
|
|
115
|
+
{ name: 'imperial', title: t('units_imperial'), quantity: 0 },
|
|
116
|
+
{ name: 'metric', title: t('units_metric'), quantity: 0 },
|
|
117
|
+
]);
|
|
118
|
+
|
|
60
119
|
const groups = computed(() => {
|
|
61
120
|
const uniqueNames = [...new Set(props.filters.map(item => item.group.title))];
|
|
62
121
|
return uniqueNames.map(name => {
|
|
@@ -64,6 +123,12 @@ const groups = computed(() => {
|
|
|
64
123
|
}).sort((a, b) => a?.order - b?.order)
|
|
65
124
|
});
|
|
66
125
|
|
|
126
|
+
function getGroupSliders(groupName: string) {
|
|
127
|
+
return props.filters.filter(
|
|
128
|
+
(item) => item.kind === 'range' && item.group.name === groupName && !hiddenByUnits.value.has(item.name)
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
67
132
|
function selectItems(item: IModelFilter) {
|
|
68
133
|
return [
|
|
69
134
|
...item.options.map(item => ({
|
|
@@ -73,7 +138,7 @@ function selectItems(item: IModelFilter) {
|
|
|
73
138
|
]
|
|
74
139
|
}
|
|
75
140
|
|
|
76
|
-
function
|
|
141
|
+
function updateFilter(name: string, val: any) {
|
|
77
142
|
const query = { ...route.query };
|
|
78
143
|
|
|
79
144
|
if (!val || val === 'all') {
|
|
@@ -90,7 +155,7 @@ function isActiveSelect(name: string) {
|
|
|
90
155
|
return Object.keys(route.query).some(key => key.startsWith(`filter_${baseName}`));
|
|
91
156
|
}
|
|
92
157
|
|
|
93
|
-
function
|
|
158
|
+
function getValue(name: string) {
|
|
94
159
|
const value = route.query[ `filter_${name}` ];
|
|
95
160
|
if (value) {
|
|
96
161
|
return value;
|
|
@@ -102,10 +167,17 @@ function getSelectValue(name: string) {
|
|
|
102
167
|
return null;
|
|
103
168
|
}
|
|
104
169
|
|
|
105
|
-
function
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
return
|
|
170
|
+
function getRadioItems(items: IModelFilterOptions[]) {
|
|
171
|
+
const filteredItems = items.filter((item) => item.name !== 'unspecified');
|
|
172
|
+
const order = ['all', 'yes'];
|
|
173
|
+
return filteredItems.sort((a, b) => {
|
|
174
|
+
const aIndex = order.indexOf(a.name);
|
|
175
|
+
const bIndex = order.indexOf(b.name);
|
|
176
|
+
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
|
|
177
|
+
if (aIndex !== -1) return -1;
|
|
178
|
+
if (bIndex !== -1) return 1;
|
|
179
|
+
return 0;
|
|
180
|
+
});
|
|
109
181
|
}
|
|
110
182
|
</script>
|
|
111
183
|
|
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
>
|
|
8
8
|
<h3 class="s-info-grid__group-title">{{ group.title }}</h3>
|
|
9
9
|
<div class="s-info-grid__group-items">
|
|
10
|
-
<
|
|
11
|
-
|
|
10
|
+
<component
|
|
11
|
+
:is="item.isFilter ? NuxtLink : 'div'"
|
|
12
|
+
v-for="(item, index) in group.items.filter(i => !hiddenByUnits.has(i.name))"
|
|
12
13
|
:key="`s-info-grid-item-${gIndex}-${index}`"
|
|
13
14
|
class="s-info-grid__item"
|
|
14
|
-
:to="generateLink(link(item, item.values[0]))"
|
|
15
|
+
:to="item.isFilter ? generateLink(link(item, item.values[0].name)) : undefined"
|
|
15
16
|
>
|
|
16
17
|
<SIcon class="s-info-grid__item-icon" name="tattoos" size="12" />
|
|
17
18
|
<span class="s-info-grid__item-title">{{ item.title }}</span>
|
|
@@ -24,9 +25,9 @@
|
|
|
24
25
|
{'--success': isSuccess(value)},
|
|
25
26
|
{'--warning': isWarning(value)}
|
|
26
27
|
]"
|
|
27
|
-
>{{ value }}{{ vIndex < item.values.length - 1 ? ', ' : '' }}</span>
|
|
28
|
+
>{{ value.title }}{{ vIndex < item.values.length - 1 ? ', ' : '' }}</span>
|
|
28
29
|
</p>
|
|
29
|
-
</
|
|
30
|
+
</component>
|
|
30
31
|
</div>
|
|
31
32
|
</div>
|
|
32
33
|
</div>
|
|
@@ -39,7 +40,9 @@ defineProps<{
|
|
|
39
40
|
groups: IGroupedParameter[]
|
|
40
41
|
}>();
|
|
41
42
|
|
|
42
|
-
const
|
|
43
|
+
const NuxtLink = resolveComponent('NuxtLink');
|
|
44
|
+
const { hiddenByUnits } = useUnits();
|
|
45
|
+
const { generateLink } = useGenerateLink();
|
|
43
46
|
|
|
44
47
|
function link(item: IGroupedParameterItem, value: string) {
|
|
45
48
|
const formattedValue = value.toLowerCase().replace(/\s+/g, '+');
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
{'s-input--error': error},
|
|
9
9
|
{'s-input--textarea': isTextArea},
|
|
10
10
|
{'s-input--icon': isPassword || icon},
|
|
11
|
+
{'s-input--pre-icon': preIcon},
|
|
11
12
|
`s-input--${size}`,
|
|
12
13
|
]"
|
|
13
14
|
>
|
|
@@ -30,6 +31,12 @@
|
|
|
30
31
|
:for="name"
|
|
31
32
|
>{{ placeholder }}</label>
|
|
32
33
|
<div class="s-input__wrapper">
|
|
34
|
+
<SIcon
|
|
35
|
+
v-if="preIcon"
|
|
36
|
+
class="s-input__pre-icon"
|
|
37
|
+
:name="preIcon"
|
|
38
|
+
:size="preIconSize"
|
|
39
|
+
/>
|
|
33
40
|
<textarea
|
|
34
41
|
v-if="isTextArea"
|
|
35
42
|
:id="name"
|
|
@@ -59,7 +66,6 @@
|
|
|
59
66
|
autocapitalize="off"
|
|
60
67
|
:placeholder="placeholder"
|
|
61
68
|
spellcheck="false"
|
|
62
|
-
v-bind="$attrs"
|
|
63
69
|
@input="onInput"
|
|
64
70
|
@focus="onFocus"
|
|
65
71
|
@blur="onBlur"
|
|
@@ -110,6 +116,8 @@ const props = withDefaults(defineProps<{
|
|
|
110
116
|
placeholder?: string
|
|
111
117
|
icon?: string
|
|
112
118
|
labelIcon?: string
|
|
119
|
+
preIcon?: string
|
|
120
|
+
preIconSize?: string
|
|
113
121
|
}>(), {
|
|
114
122
|
type: 'text',
|
|
115
123
|
inputmode: 'text',
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// @vitest-environment nuxt
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { useFilterScheme } from './use-filter-scheme';
|
|
4
|
+
|
|
5
|
+
const t = (key: string) => key;
|
|
6
|
+
|
|
7
|
+
describe('useFilterScheme', () => {
|
|
8
|
+
it('пустой массив для null', () => {
|
|
9
|
+
const { filterScheme } = useFilterScheme(t, null);
|
|
10
|
+
expect(filterScheme.value).toEqual([]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('трансформирует группы в схему', () => {
|
|
14
|
+
const groups = [{
|
|
15
|
+
title: 'Age', name: 'age', icon: 'age-icon',
|
|
16
|
+
categories: [
|
|
17
|
+
{ title: 'teen', name: 'teen' },
|
|
18
|
+
{ title: 'milf', name: 'milf' },
|
|
19
|
+
],
|
|
20
|
+
}];
|
|
21
|
+
const { filterScheme } = useFilterScheme(t, groups);
|
|
22
|
+
expect(filterScheme.value).toHaveLength(1);
|
|
23
|
+
expect(filterScheme.value[0].label).toBe('Age');
|
|
24
|
+
expect(filterScheme.value[0].items).toEqual([
|
|
25
|
+
{ title: 'Teen', value: 'teen' },
|
|
26
|
+
{ title: 'Milf', value: 'milf' },
|
|
27
|
+
]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('capitalize для multi-word', () => {
|
|
31
|
+
const groups = [{
|
|
32
|
+
title: 'Body', name: 'body', icon: '',
|
|
33
|
+
categories: [{ title: 'big tits', name: 'big-tits' }],
|
|
34
|
+
}];
|
|
35
|
+
const { filterScheme } = useFilterScheme(t, groups);
|
|
36
|
+
expect(filterScheme.value[0].items[0].title).toBe('Big Tits');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('фильтрует пустые группы', () => {
|
|
40
|
+
const groups = [
|
|
41
|
+
{ title: 'Empty', name: 'empty', icon: '', categories: [] },
|
|
42
|
+
{ title: 'Body', name: 'body', icon: '', categories: [{ title: 'slim', name: 'slim' }] },
|
|
43
|
+
];
|
|
44
|
+
const { filterScheme } = useFilterScheme(t, groups);
|
|
45
|
+
expect(filterScheme.value).toHaveLength(1);
|
|
46
|
+
expect(filterScheme.value[0].name).toBe('body');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('placeholder из t()', () => {
|
|
50
|
+
const groups = [{
|
|
51
|
+
title: 'Age', name: 'age', icon: '',
|
|
52
|
+
categories: [{ title: 'teen', name: 'teen' }],
|
|
53
|
+
}];
|
|
54
|
+
const { filterScheme } = useFilterScheme(t, groups);
|
|
55
|
+
expect(filterScheme.value[0].placeholder).toBe('choose_category');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { vi, describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('vue-router', () => ({
|
|
4
|
+
useRoute: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
import { useRoute } from 'vue-router';
|
|
8
|
+
import { useGetPureRouteName } from './use-get-pure-route-name';
|
|
9
|
+
|
|
10
|
+
describe('useGetPureRouteName', () => {
|
|
11
|
+
it('убирает суффикс ___en', () => {
|
|
12
|
+
vi.mocked(useRoute).mockReturnValue({ name: 'videos___en' } as any);
|
|
13
|
+
expect(useGetPureRouteName()).toBe('videos');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('убирает суффикс ___zh-hans', () => {
|
|
17
|
+
vi.mocked(useRoute).mockReturnValue({ name: 'user-settings___zh-hans' } as any);
|
|
18
|
+
expect(useGetPureRouteName()).toBe('user-settings');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('оставляет имя без суффикса', () => {
|
|
22
|
+
vi.mocked(useRoute).mockReturnValue({ name: 'videos' } as any);
|
|
23
|
+
expect(useGetPureRouteName()).toBe('videos');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('вложенный роут с суффиксом', () => {
|
|
27
|
+
vi.mocked(useRoute).mockReturnValue({ name: 'user-playlists-id___en' } as any);
|
|
28
|
+
expect(useGetPureRouteName()).toBe('user-playlists-id');
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { vi, describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('../runtime', () => ({
|
|
4
|
+
getMultipleQuery: () => ({}),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
import { useGetVideosFilterRequest } from './use-get-videos-filter-request';
|
|
8
|
+
|
|
9
|
+
describe('useGetVideosFilterRequest', () => {
|
|
10
|
+
it('всегда возвращает categories_filter_use_and', () => {
|
|
11
|
+
const route = { query: {} } as any;
|
|
12
|
+
const result = useGetVideosFilterRequest(route, [], []);
|
|
13
|
+
expect(result.categories_filter_use_and).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('парсит строковые категории', () => {
|
|
17
|
+
const route = { query: { categories: 'age_teen,body_slim' } } as any;
|
|
18
|
+
const result = useGetVideosFilterRequest(route, [], []);
|
|
19
|
+
expect(result.categories).toEqual(['teen', 'slim']);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('парсит массив категорий', () => {
|
|
23
|
+
const route = { query: { categories: ['age_teen', 'body_slim,body_athletic'] } } as any;
|
|
24
|
+
const result = useGetVideosFilterRequest(route, [], []);
|
|
25
|
+
expect(result.categories).toEqual(['teen', 'slim', 'athletic']);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('убирает префикс группы из категории', () => {
|
|
29
|
+
const route = { query: { categories: 'ethnicity_asian' } } as any;
|
|
30
|
+
const result = useGetVideosFilterRequest(route, [], []);
|
|
31
|
+
expect(result.categories).toEqual(['asian']);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('сохраняет _ в значении категории', () => {
|
|
35
|
+
const route = { query: { categories: 'body_big_tits' } } as any;
|
|
36
|
+
const result = useGetVideosFilterRequest(route, [], []);
|
|
37
|
+
expect(result.categories).toEqual(['big_tits']);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('нет categories при пустом query', () => {
|
|
41
|
+
const route = { query: {} } as any;
|
|
42
|
+
const result = useGetVideosFilterRequest(route, [], []);
|
|
43
|
+
expect(result.categories).toBeUndefined();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// @vitest-environment nuxt
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { useFilterChipsItems } from './use-model-filter-chips';
|
|
4
|
+
|
|
5
|
+
const t = (key: string) => key;
|
|
6
|
+
|
|
7
|
+
describe('useFilterChipsItems', () => {
|
|
8
|
+
it('пустой массив без filter_ в query', () => {
|
|
9
|
+
const filters = ref([]);
|
|
10
|
+
const route = { query: {} };
|
|
11
|
+
const { chipsItems } = useFilterChipsItems(filters, route, t);
|
|
12
|
+
expect(chipsItems.value).toEqual([]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('игнорирует не-filter параметры', () => {
|
|
16
|
+
const filters = ref([]);
|
|
17
|
+
const route = { query: { page: '1', sort: 'popular' } };
|
|
18
|
+
const { chipsItems } = useFilterChipsItems(filters, route, t);
|
|
19
|
+
expect(chipsItems.value).toEqual([]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('создаёт чип из одного фильтра', () => {
|
|
23
|
+
const filters = ref([{
|
|
24
|
+
name: 'hair_color',
|
|
25
|
+
title: 'Hair color',
|
|
26
|
+
options: [
|
|
27
|
+
{ name: 'blonde', title: 'Blonde' },
|
|
28
|
+
{ name: 'brunette', title: 'Brunette' },
|
|
29
|
+
],
|
|
30
|
+
}]);
|
|
31
|
+
const route = { query: { filter_hair_color: 'blonde' } };
|
|
32
|
+
const { chipsItems } = useFilterChipsItems(filters, route, t);
|
|
33
|
+
expect(chipsItems.value).toHaveLength(1);
|
|
34
|
+
expect(chipsItems.value[0].title).toBe('Hair color: Blonde');
|
|
35
|
+
expect(chipsItems.value[0].value).toEqual(['filter_hair_color']);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('группирует from/to фильтры', () => {
|
|
39
|
+
const filters = ref([{
|
|
40
|
+
name: 'age',
|
|
41
|
+
title: 'Age',
|
|
42
|
+
options: [
|
|
43
|
+
{ name: '18', title: '18' },
|
|
44
|
+
{ name: '50', title: '50' },
|
|
45
|
+
],
|
|
46
|
+
}]);
|
|
47
|
+
const route = { query: { filter_age_from: '18', filter_age_to: '30' } };
|
|
48
|
+
const { chipsItems } = useFilterChipsItems(filters, route, t);
|
|
49
|
+
expect(chipsItems.value).toHaveLength(1);
|
|
50
|
+
expect(chipsItems.value[0].title).toBe('Age: 18 - 30');
|
|
51
|
+
expect(chipsItems.value[0].value).toEqual(['filter_age_from', 'filter_age_to']);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('числовое значение возвращается как есть', () => {
|
|
55
|
+
const filters = ref([{
|
|
56
|
+
name: 'weight',
|
|
57
|
+
title: 'Weight',
|
|
58
|
+
options: [
|
|
59
|
+
{ name: '40', title: '40' },
|
|
60
|
+
{ name: '120', title: '120' },
|
|
61
|
+
],
|
|
62
|
+
}]);
|
|
63
|
+
const route = { query: { filter_weight: '65' } };
|
|
64
|
+
const { chipsItems } = useFilterChipsItems(filters, route, t);
|
|
65
|
+
expect(chipsItems.value[0].title).toBe('Weight: 65');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// @vitest-environment nuxt
|
|
2
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
3
|
+
import { mockNuxtImport } from '@nuxt/test-utils/runtime';
|
|
4
|
+
import { useSeoLinks } from './use-seo-links';
|
|
5
|
+
|
|
6
|
+
let mockRoute = { path: '/videos', query: {} as Record<string, any> };
|
|
7
|
+
|
|
8
|
+
mockNuxtImport('useRoute', () => () => mockRoute);
|
|
9
|
+
|
|
10
|
+
const mockI18n = {
|
|
11
|
+
locale: { value: 'en' },
|
|
12
|
+
locales: { value: ['en', 'fr', 'de', 'ja', 'zh'] },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const mockLocalePath = (path: string, locale: string) =>
|
|
16
|
+
locale === 'en' ? path : `/${locale}${path}`;
|
|
17
|
+
|
|
18
|
+
describe('useSeoLinks', () => {
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
mockRoute = { path: '/videos', query: {} };
|
|
21
|
+
mockI18n.locale.value = 'en';
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('canonical URL', () => {
|
|
25
|
+
const { canonicalUrl } = useSeoLinks('https://example.com', false, mockI18n, mockLocalePath);
|
|
26
|
+
expect(canonicalUrl.value).toBe('https://example.com/videos');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('canonical с query params', () => {
|
|
30
|
+
mockRoute = { path: '/videos', query: { page: '2', sort: 'popular' } };
|
|
31
|
+
const { canonicalUrl } = useSeoLinks('https://example.com', false, mockI18n, mockLocalePath);
|
|
32
|
+
expect(canonicalUrl.value).toContain('page=2');
|
|
33
|
+
expect(canonicalUrl.value).toContain('sort=popular');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('исключает sort при alonePage', () => {
|
|
37
|
+
mockRoute = { path: '/videos', query: { page: '2', sort: 'popular' } };
|
|
38
|
+
const { canonicalUrl } = useSeoLinks('https://example.com', true, mockI18n, mockLocalePath);
|
|
39
|
+
expect(canonicalUrl.value).toContain('page=2');
|
|
40
|
+
expect(canonicalUrl.value).not.toContain('sort');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('alternate links для всех локалей', () => {
|
|
44
|
+
const { alternateLinks } = useSeoLinks('https://example.com', false, mockI18n, mockLocalePath);
|
|
45
|
+
const hreflangs = alternateLinks.value.map(l => l.hreflang);
|
|
46
|
+
expect(hreflangs).toContain('en');
|
|
47
|
+
expect(hreflangs).toContain('fr');
|
|
48
|
+
expect(hreflangs).toContain('de');
|
|
49
|
+
expect(hreflangs).toContain('ja-JP');
|
|
50
|
+
expect(hreflangs).toContain('zh-CN');
|
|
51
|
+
expect(hreflangs).toContain('x-default');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('ja маппится в ja-JP', () => {
|
|
55
|
+
const { alternateLinks } = useSeoLinks('https://example.com', false, mockI18n, mockLocalePath);
|
|
56
|
+
const jaLink = alternateLinks.value.find(l => l.hreflang === 'ja-JP');
|
|
57
|
+
expect(jaLink).toBeDefined();
|
|
58
|
+
expect(jaLink!.href).toContain('/ja/videos');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('x-default указывает на en', () => {
|
|
62
|
+
const { alternateLinks } = useSeoLinks('https://example.com', false, mockI18n, mockLocalePath);
|
|
63
|
+
const xDefault = alternateLinks.value.find(l => l.hreflang === 'x-default');
|
|
64
|
+
expect(xDefault!.href).toBe('https://example.com/videos');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('игнорирует неразрешённые query params', () => {
|
|
68
|
+
mockRoute = { path: '/videos', query: { page: '1', foo: 'bar', categories: 'teen' } };
|
|
69
|
+
const { canonicalUrl } = useSeoLinks('https://example.com', false, mockI18n, mockLocalePath);
|
|
70
|
+
expect(canonicalUrl.value).toContain('page=1');
|
|
71
|
+
expect(canonicalUrl.value).toContain('categories=teen');
|
|
72
|
+
expect(canonicalUrl.value).not.toContain('foo');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// @vitest-environment nuxt
|
|
2
|
+
import { vi, describe, it, expect } from 'vitest';
|
|
3
|
+
|
|
4
|
+
vi.mock('vue-router', async (importOriginal) => {
|
|
5
|
+
const mod = await importOriginal<typeof import('vue-router')>();
|
|
6
|
+
return { ...mod, useRoute: vi.fn() };
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
import { useRoute } from 'vue-router';
|
|
10
|
+
import { useSlug } from './use-slug';
|
|
11
|
+
|
|
12
|
+
describe('useSlug', () => {
|
|
13
|
+
it('конвертирует kebab-case slug', () => {
|
|
14
|
+
vi.mocked(useRoute).mockReturnValue({ params: { slug: 'john-doe' } } as any);
|
|
15
|
+
const slug = useSlug();
|
|
16
|
+
expect(slug.value).toBe('john doe');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('пустая строка если slug отсутствует', () => {
|
|
20
|
+
vi.mocked(useRoute).mockReturnValue({ params: {} } as any);
|
|
21
|
+
const slug = useSlug();
|
|
22
|
+
expect(slug.value).toBe('');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('кастомное имя параметра', () => {
|
|
26
|
+
vi.mocked(useRoute).mockReturnValue({ params: { name: 'big-tits' } } as any);
|
|
27
|
+
const slug = useSlug('name');
|
|
28
|
+
expect(slug.value).toBe('big tits');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('slug с подчёркиваниями', () => {
|
|
32
|
+
vi.mocked(useRoute).mockReturnValue({ params: { slug: 'some_thing' } } as any);
|
|
33
|
+
const slug = useSlug();
|
|
34
|
+
expect(slug.value).toBe('some-thing');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// @vitest-environment nuxt
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
3
|
+
import { useSnackbar } from './use-snackbar';
|
|
4
|
+
|
|
5
|
+
describe('useSnackbar', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
useNuxtApp().payload.state = {};
|
|
8
|
+
vi.useFakeTimers();
|
|
9
|
+
const { resetSnackbar } = useSnackbar();
|
|
10
|
+
resetSnackbar();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
vi.useRealTimers();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('дефолтное состояние', () => {
|
|
18
|
+
const { snackbarTheme, snackbarText, snackbarIcon, snackbarTimer } = useSnackbar();
|
|
19
|
+
expect(snackbarTheme.value).toBe('success');
|
|
20
|
+
expect(snackbarText.value).toBe('');
|
|
21
|
+
expect(snackbarIcon.value).toBe('check');
|
|
22
|
+
expect(snackbarTimer.value).toBe(2000);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('setErrorState устанавливает ошибку', () => {
|
|
26
|
+
const { setErrorState, snackbarTheme, snackbarIcon, snackbarTimer, snackbarText } = useSnackbar();
|
|
27
|
+
setErrorState({ message: 'Something failed' });
|
|
28
|
+
expect(snackbarTheme.value).toBe('error');
|
|
29
|
+
expect(snackbarIcon.value).toBe('close');
|
|
30
|
+
expect(snackbarTimer.value).toBe(4000);
|
|
31
|
+
expect(snackbarText.value).toBe('Something failed');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('showErrorSnack показывает и авто-сбрасывает', () => {
|
|
35
|
+
const { showErrorSnack, snackbarText, snackbarTheme } = useSnackbar();
|
|
36
|
+
showErrorSnack('Error!');
|
|
37
|
+
expect(snackbarText.value).toBe('Error!');
|
|
38
|
+
expect(snackbarTheme.value).toBe('error');
|
|
39
|
+
|
|
40
|
+
vi.advanceTimersByTime(4000);
|
|
41
|
+
expect(snackbarText.value).toBe('');
|
|
42
|
+
expect(snackbarTheme.value).toBe('success');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('повторный showErrorSnack сбрасывает предыдущий таймер', () => {
|
|
46
|
+
const { showErrorSnack, snackbarText } = useSnackbar();
|
|
47
|
+
showErrorSnack('First');
|
|
48
|
+
vi.advanceTimersByTime(2000);
|
|
49
|
+
showErrorSnack('Second');
|
|
50
|
+
expect(snackbarText.value).toBe('Second');
|
|
51
|
+
|
|
52
|
+
vi.advanceTimersByTime(4000);
|
|
53
|
+
expect(snackbarText.value).toBe('');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('resetSnackbar сбрасывает всё', () => {
|
|
57
|
+
const { showErrorSnack, resetSnackbar, snackbarText, snackbarTheme, snackbarIcon } = useSnackbar();
|
|
58
|
+
showErrorSnack('Error');
|
|
59
|
+
resetSnackbar();
|
|
60
|
+
expect(snackbarText.value).toBe('');
|
|
61
|
+
expect(snackbarTheme.value).toBe('success');
|
|
62
|
+
expect(snackbarIcon.value).toBe('check');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// @vitest-environment nuxt
|
|
2
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
3
|
+
import { useUnits } from './use-units';
|
|
4
|
+
|
|
5
|
+
describe('useUnits', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
localStorage.clear();
|
|
8
|
+
useNuxtApp().payload.state = {};
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('по умолчанию metric', () => {
|
|
12
|
+
const { units } = useUnits();
|
|
13
|
+
expect(units.value).toBe('metric');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('imperial для US', () => {
|
|
17
|
+
useState<string>('clientCountryCode', () => 'us');
|
|
18
|
+
const { units } = useUnits();
|
|
19
|
+
expect(units.value).toBe('imperial');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('imperial для Liberia', () => {
|
|
23
|
+
useState<string>('clientCountryCode', () => 'lr');
|
|
24
|
+
const { units } = useUnits();
|
|
25
|
+
expect(units.value).toBe('imperial');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('берёт сохранённое значение из localStorage', () => {
|
|
29
|
+
localStorage.setItem('units', 'imperial');
|
|
30
|
+
const { units } = useUnits();
|
|
31
|
+
expect(units.value).toBe('imperial');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('localStorage приоритетнее country code', () => {
|
|
35
|
+
useState<string>('clientCountryCode', () => 'us');
|
|
36
|
+
localStorage.setItem('units', 'metric');
|
|
37
|
+
const { units } = useUnits();
|
|
38
|
+
expect(units.value).toBe('metric');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('hiddenByUnits для metric — скрывает дюймы и фунты', () => {
|
|
42
|
+
const { hiddenByUnits } = useUnits();
|
|
43
|
+
expect(hiddenByUnits.value).toEqual(new Set(['height_in', 'weight_lb']));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('hiddenByUnits для imperial — скрывает см и кг', () => {
|
|
47
|
+
localStorage.setItem('units', 'imperial');
|
|
48
|
+
const { hiddenByUnits } = useUnits();
|
|
49
|
+
expect(hiddenByUnits.value).toEqual(new Set(['height_cm', 'weight_kg']));
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const imperialCountries = new Set(['us', 'lr', 'mm']);
|
|
2
|
+
|
|
3
|
+
export function useUnits() {
|
|
4
|
+
const clientCountryCode = useState<string>('clientCountryCode', () => '');
|
|
5
|
+
const savedUnits = import.meta.client ? localStorage.getItem('units') : null;
|
|
6
|
+
const defaultUnits = imperialCountries.has(clientCountryCode.value) ? 'imperial' : 'metric';
|
|
7
|
+
const units = useState('units', () => savedUnits || defaultUnits);
|
|
8
|
+
|
|
9
|
+
const hiddenByUnits = computed(() => {
|
|
10
|
+
if (units.value === 'metric') {
|
|
11
|
+
return new Set(['height_in', 'weight_lb']);
|
|
12
|
+
}
|
|
13
|
+
return new Set(['height_cm', 'weight_kg']);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
watch(units, (val) => {
|
|
17
|
+
localStorage.setItem('units', val);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
return { units, hiddenByUnits };
|
|
21
|
+
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "itube-specs",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.710",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"types": "./types/index.d.ts",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"prepublishOnly": "npm install && npx nuxi prepare",
|
|
9
9
|
"patch": "npm version patch",
|
|
10
|
-
"eslint fix": "npx eslint . --ext .ts,.vue,.js --fix"
|
|
10
|
+
"eslint fix": "npx eslint . --ext .ts,.vue,.js --fix",
|
|
11
|
+
"test": "NODE_OPTIONS='--no-warnings' vitest"
|
|
11
12
|
},
|
|
12
13
|
"exports": {
|
|
13
14
|
".": {
|
|
@@ -40,11 +41,15 @@
|
|
|
40
41
|
"devDependencies": {
|
|
41
42
|
"@nuxt/eslint": "latest",
|
|
42
43
|
"@nuxt/icon": "1.15.0",
|
|
44
|
+
"@nuxt/test-utils": "^3.23.0",
|
|
43
45
|
"@nuxtjs/i18n": "9.5.6",
|
|
44
46
|
"@types/node": "^20.19.19",
|
|
47
|
+
"@vue/test-utils": "^2.4.6",
|
|
45
48
|
"eslint": "^9.37.0",
|
|
49
|
+
"happy-dom": "^18.0.1",
|
|
46
50
|
"nuxt": "3.17.6",
|
|
47
51
|
"typescript": "^5.9.3",
|
|
52
|
+
"vitest": "^3.2.4",
|
|
48
53
|
"vue": "3.5.17"
|
|
49
54
|
},
|
|
50
55
|
"dependencies": {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { formatTimeAgo } from './format-time-ago';
|
|
3
|
+
|
|
4
|
+
const t = (key: string) => key;
|
|
5
|
+
|
|
6
|
+
describe('formatTimeAgo', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
vi.useFakeTimers();
|
|
9
|
+
vi.setSystemTime(new Date('2025-06-15T12:00:00Z'));
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.useRealTimers();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const now = () => Math.floor(Date.now() / 1000);
|
|
17
|
+
|
|
18
|
+
it('сегодня', () => {
|
|
19
|
+
expect(formatTimeAgo(now(), t)).toBe('today');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('1 день назад', () => {
|
|
23
|
+
expect(formatTimeAgo(now() - 86400, t)).toBe('day_ago');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('5 дней назад', () => {
|
|
27
|
+
expect(formatTimeAgo(now() - 5 * 86400, t)).toBe('5 days_ago');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('29 дней назад', () => {
|
|
31
|
+
expect(formatTimeAgo(now() - 29 * 86400, t)).toBe('29 days_ago');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('1 месяц', () => {
|
|
35
|
+
expect(formatTimeAgo(now() - 31 * 86400, t)).toBe('1 month ago');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('3 месяца — plural', () => {
|
|
39
|
+
expect(formatTimeAgo(now() - 90 * 86400, t)).toBe('3 months ago');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('1 год', () => {
|
|
43
|
+
expect(formatTimeAgo(now() - 366 * 86400, t)).toBe('1 year ago');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('2 года — plural', () => {
|
|
47
|
+
expect(formatTimeAgo(now() - 730 * 86400, t)).toBe('2 years ago');
|
|
48
|
+
});
|
|
49
|
+
});
|