itube-specs 0.0.779 → 0.0.781
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/package.json +6 -1
- package/services/__tests__/site-data.service.test.ts +61 -0
- package/services/api/authorization.service.ts +172 -0
- package/services/api/categories.service.ts +239 -0
- package/services/api/channels.service.ts +101 -0
- package/services/api/contacts.service.ts +22 -0
- package/services/api/dictionaries.service.ts +34 -0
- package/services/api/models.service.ts +315 -0
- package/services/api/playlists.service.ts +250 -0
- package/services/api/search.service.ts +63 -0
- package/services/api/videos.service.ts +558 -0
- package/services/site-data.service.ts +72 -0
- package/utils/__tests__/get-sorted-filter-groups.test.ts +28 -0
- package/utils/__tests__/parse-filter-categories.test.ts +21 -0
- package/utils/__tests__/sort-filter-radio-options.test.ts +23 -0
- package/utils/category-letter-groups/__tests__/group-by-first-letter.test.ts +42 -0
- package/utils/category-letter-groups/group-by-first-letter.ts +25 -0
- package/utils/filter-main/__tests__/build-filter-query.test.ts +56 -0
- package/utils/filter-main/__tests__/get-active-filter-values.test.ts +22 -0
- package/utils/filter-main/__tests__/parse-category-filter-values.test.ts +20 -0
- package/utils/filter-main/build-filter-query.ts +42 -0
- package/utils/filter-main/get-active-filter-values.ts +33 -0
- package/utils/filter-main/parse-category-filter-values.ts +15 -0
- package/utils/filter-slider/__tests__/build-range-filter-query.test.ts +39 -0
- package/utils/filter-slider/__tests__/get-slider-range-info.test.ts +28 -0
- package/utils/filter-slider/build-range-filter-query.ts +32 -0
- package/utils/filter-slider/get-slider-range-info.ts +28 -0
- package/utils/get-sorted-filter-groups.ts +13 -0
- package/utils/info-main/__tests__/group-parameters-by-group.test.ts +42 -0
- package/utils/info-main/group-parameters-by-group.ts +37 -0
- package/utils/iso-code-map.ts +2 -0
- package/utils/parse-filter-categories.ts +14 -0
- package/utils/player-channel/__tests__/get-name-initials.test.ts +24 -0
- package/utils/player-channel/get-name-initials.ts +11 -0
- package/utils/player-like/__tests__/apply-reaction.test.ts +28 -0
- package/utils/player-like/__tests__/get-reaction-percents.test.ts +16 -0
- package/utils/player-like/apply-reaction.ts +23 -0
- package/utils/player-like/get-reaction-percents.ts +19 -0
- package/utils/search-result/__tests__/build-search-tabs.test.ts +28 -0
- package/utils/search-result/build-search-tabs.ts +19 -0
- package/utils/section-seo-text/__tests__/split-text-by-keyword-links.test.ts +46 -0
- package/utils/section-seo-text/split-text-by-keyword-links.ts +60 -0
- package/utils/sort-filter-radio-options.ts +18 -0
- package/utils/ui-full-descr/__tests__/split-paragraphs.test.ts +24 -0
- package/utils/ui-full-descr/split-paragraphs.ts +12 -0
- package/utils/ui-pagination/__tests__/get-pagination-link.test.ts +24 -0
- package/utils/ui-pagination/__tests__/get-pagination-numbers.test.ts +18 -0
- package/utils/ui-pagination/get-pagination-link.ts +34 -0
- package/utils/ui-pagination/get-pagination-numbers.ts +14 -0
- package/components/auth/__tests__/login.test.ts +0 -123
- package/components/auth/__tests__/recovery.test.ts +0 -86
- package/components/auth/__tests__/register.test.ts +0 -122
- package/components/player/__tests__/like.test.ts +0 -85
- package/components/playlist/__tests__/add.test.ts +0 -115
- package/components/playlist/__tests__/edit.test.ts +0 -91
- package/components/profile/__tests__/password.test.ts +0 -60
- package/components/profile/__tests__/setup.test.ts +0 -100
- package/components/report/__tests__/main.test.ts +0 -106
- package/composables/__tests__/use-next-video.test.ts +0 -123
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { groupByFirstLetter } from '../group-by-first-letter';
|
|
3
|
+
|
|
4
|
+
describe('groupByFirstLetter', () => {
|
|
5
|
+
it('группирует по первой букве в верхнем регистре', () => {
|
|
6
|
+
const result = groupByFirstLetter([
|
|
7
|
+
{ title: 'apple' },
|
|
8
|
+
{ title: 'Avocado' },
|
|
9
|
+
{ title: 'banana' },
|
|
10
|
+
]);
|
|
11
|
+
expect(result['A']).toHaveLength(2);
|
|
12
|
+
expect(result['B']).toHaveLength(1);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('нелатинские и нелитерные символы попадают в #', () => {
|
|
16
|
+
const result = groupByFirstLetter([
|
|
17
|
+
{ title: '123' },
|
|
18
|
+
{ title: 'Яблоко' },
|
|
19
|
+
{ title: 'Apple' },
|
|
20
|
+
]);
|
|
21
|
+
expect(result['#']).toHaveLength(2);
|
|
22
|
+
expect(result['A']).toHaveLength(1);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('# сортируется в конец', () => {
|
|
26
|
+
const result = groupByFirstLetter([
|
|
27
|
+
{ title: 'zebra' },
|
|
28
|
+
{ title: '1one' },
|
|
29
|
+
{ title: 'apple' },
|
|
30
|
+
]);
|
|
31
|
+
expect(Object.keys(result)).toEqual(['A', 'Z', '#']);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('кастомное поле группировки', () => {
|
|
35
|
+
const result = groupByFirstLetter([{ name: 'Bob' }, { name: 'Alice' }], 'name');
|
|
36
|
+
expect(Object.keys(result)).toEqual(['A', 'B']);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('пустой массив → {}', () => {
|
|
40
|
+
expect(groupByFirstLetter([])).toEqual({});
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Группирует объекты по первой латинской букве указанного поля; нелатинские и нелитерные
|
|
3
|
+
* символы (цифры, кириллица) попадают под ключ `#`. Группы отсортированы по алфавиту, `#` в конце.
|
|
4
|
+
* @param items - массив объектов
|
|
5
|
+
* @param field - поле для группировки (по умолчанию `title`)
|
|
6
|
+
*/
|
|
7
|
+
export function groupByFirstLetter<T extends Record<string, any>>(
|
|
8
|
+
items: T[],
|
|
9
|
+
field = 'title',
|
|
10
|
+
): Record<string, T[]> {
|
|
11
|
+
const groups: Record<string, T[]> = {};
|
|
12
|
+
for (const item of items) {
|
|
13
|
+
const firstChar = String(item[field] ?? '')[0]?.toUpperCase() ?? '#';
|
|
14
|
+
const key = /[A-Z]/.test(firstChar) ? firstChar : '#';
|
|
15
|
+
if (!groups[key]) groups[key] = [];
|
|
16
|
+
groups[key].push(item);
|
|
17
|
+
}
|
|
18
|
+
return Object.fromEntries(
|
|
19
|
+
Object.entries(groups).sort(([a], [b]) => {
|
|
20
|
+
if (a === '#') return 1;
|
|
21
|
+
if (b === '#') return -1;
|
|
22
|
+
return a.localeCompare(b);
|
|
23
|
+
}),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildFilterQuery } from '../build-filter-query';
|
|
3
|
+
|
|
4
|
+
describe('buildFilterQuery', () => {
|
|
5
|
+
it('строит строку категорий из filterValue', () => {
|
|
6
|
+
const result = buildFilterQuery({
|
|
7
|
+
currentQuery: {},
|
|
8
|
+
filterValue: { age: '18', body: 'slim' },
|
|
9
|
+
durationValue: '',
|
|
10
|
+
addedValue: '',
|
|
11
|
+
});
|
|
12
|
+
expect(result.categories).toBe('age_18,body_slim');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('добавляет duration и added когда заданы', () => {
|
|
16
|
+
const result = buildFilterQuery({
|
|
17
|
+
currentQuery: {},
|
|
18
|
+
filterValue: {},
|
|
19
|
+
durationValue: '10',
|
|
20
|
+
addedValue: 'week',
|
|
21
|
+
});
|
|
22
|
+
expect(result.duration).toBe('10');
|
|
23
|
+
expect(result.added).toBe('week');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('categories = undefined когда фильтров нет', () => {
|
|
27
|
+
const result = buildFilterQuery({
|
|
28
|
+
currentQuery: {},
|
|
29
|
+
filterValue: {},
|
|
30
|
+
durationValue: '',
|
|
31
|
+
addedValue: '',
|
|
32
|
+
});
|
|
33
|
+
expect(result.categories).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('сохраняет sort и сбрасывает page', () => {
|
|
37
|
+
const result = buildFilterQuery({
|
|
38
|
+
currentQuery: { sort: 'new', page: '3' },
|
|
39
|
+
filterValue: { age: '18' },
|
|
40
|
+
durationValue: '',
|
|
41
|
+
addedValue: '',
|
|
42
|
+
});
|
|
43
|
+
expect(result.sort).toBe('new');
|
|
44
|
+
expect(result.page).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('отбрасывает пустые значения filterValue', () => {
|
|
48
|
+
const result = buildFilterQuery({
|
|
49
|
+
currentQuery: {},
|
|
50
|
+
filterValue: { age: '18', body: '' },
|
|
51
|
+
durationValue: '',
|
|
52
|
+
addedValue: '',
|
|
53
|
+
});
|
|
54
|
+
expect(result.categories).toBe('age_18');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { getActiveFilterValues } from '../get-active-filter-values';
|
|
3
|
+
|
|
4
|
+
describe('getActiveFilterValues', () => {
|
|
5
|
+
it('собирает категории, duration и added в плоский список', () => {
|
|
6
|
+
const result = getActiveFilterValues({ categories: 'age_18,body_slim', duration: '10', added: 'week' });
|
|
7
|
+
expect(result).toEqual(['age_18', 'body_slim', '10', 'week']);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('поддерживает массив категорий', () => {
|
|
11
|
+
expect(getActiveFilterValues({ categories: ['age_18', 'body_slim'] }))
|
|
12
|
+
.toEqual(['age_18', 'body_slim']);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('игнорирует пустые duration/added', () => {
|
|
16
|
+
expect(getActiveFilterValues({ duration: '', added: '' })).toEqual([]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('пустой query → []', () => {
|
|
20
|
+
expect(getActiveFilterValues({})).toEqual([]);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseCategoryFilterValues } from '../parse-category-filter-values';
|
|
3
|
+
|
|
4
|
+
describe('parseCategoryFilterValues', () => {
|
|
5
|
+
it('разбирает key_value в объект', () => {
|
|
6
|
+
expect(parseCategoryFilterValues(['age_18', 'body_slim'])).toEqual({ age: '18', body: 'slim' });
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('склеивает значение с подчёркиваниями обратно', () => {
|
|
10
|
+
expect(parseCategoryFilterValues(['orientation_bi_curious'])).toEqual({ orientation: 'bi_curious' });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('пропускает сегменты без значения', () => {
|
|
14
|
+
expect(parseCategoryFilterValues(['age_18', 'broken'])).toEqual({ age: '18' });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('пустой список → {}', () => {
|
|
18
|
+
expect(parseCategoryFilterValues([])).toEqual({});
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
type QueryParam = string | (string | null)[] | null | undefined;
|
|
2
|
+
type QueryRecord = Record<string, QueryParam>;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Собирает query для применения фильтра: непустые значения `filterValue` склеивает в строку
|
|
6
|
+
* категорий `key_value,...`, добавляет duration/added, сохраняет sort и сбрасывает page.
|
|
7
|
+
* @param params.currentQuery - текущий query роута
|
|
8
|
+
* @param params.filterValue - выбранные категории (ключ → значение)
|
|
9
|
+
* @param params.durationValue - выбранная длительность
|
|
10
|
+
* @param params.addedValue - выбранный период добавления
|
|
11
|
+
*/
|
|
12
|
+
export function buildFilterQuery(params: {
|
|
13
|
+
currentQuery: QueryRecord;
|
|
14
|
+
filterValue: Record<string, string>;
|
|
15
|
+
durationValue: string;
|
|
16
|
+
addedValue: string;
|
|
17
|
+
}): QueryRecord {
|
|
18
|
+
const { currentQuery, filterValue, durationValue, addedValue } = params;
|
|
19
|
+
|
|
20
|
+
const oldQuery = { ...currentQuery };
|
|
21
|
+
delete oldQuery['page'];
|
|
22
|
+
|
|
23
|
+
const mainFiltersValue = {
|
|
24
|
+
...(durationValue ? { duration: durationValue } : {}),
|
|
25
|
+
...(addedValue ? { added: addedValue } : {}),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const preserved = {
|
|
29
|
+
...mainFiltersValue,
|
|
30
|
+
...(oldQuery.sort ? { sort: oldQuery.sort } : {}),
|
|
31
|
+
...(oldQuery.page ? { page: oldQuery.page } : {}),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const categories = Object.entries(filterValue)
|
|
35
|
+
.filter(([, value]) => typeof value === 'string' && value.length > 0)
|
|
36
|
+
.map(([key, value]) => `${key}_${value}`);
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
...preserved,
|
|
40
|
+
categories: categories.length ? categories.join(',') : undefined,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
type QueryParam = string | (string | null)[] | null | undefined;
|
|
2
|
+
type QueryRecord = Record<string, QueryParam>;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Собирает плоский список всех активных значений фильтра (категории + `duration` + `added`)
|
|
6
|
+
* из query — используется для подсчёта количества применённых фильтров.
|
|
7
|
+
* @param query - query роута
|
|
8
|
+
*/
|
|
9
|
+
export function getActiveFilterValues(query: QueryRecord): string[] {
|
|
10
|
+
const result: string[] = [];
|
|
11
|
+
|
|
12
|
+
const categoriesRaw = query['categories'];
|
|
13
|
+
if (categoriesRaw) {
|
|
14
|
+
if (Array.isArray(categoriesRaw)) {
|
|
15
|
+
categoriesRaw.forEach(item => {
|
|
16
|
+
if (typeof item === 'string') {
|
|
17
|
+
result.push(...item.split(',').filter(Boolean));
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
} else if (typeof categoriesRaw === 'string') {
|
|
21
|
+
result.push(...categoriesRaw.split(',').filter(Boolean));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
['duration', 'added'].forEach(key => {
|
|
26
|
+
const val = query[key];
|
|
27
|
+
if (typeof val === 'string' && val.length > 0) {
|
|
28
|
+
result.push(val);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Преобразует список категорий вида `key_value` в объект `{ key: value }`.
|
|
3
|
+
* Сегменты без значения (нет `_`) пропускаются; значение склеивается обратно по `_`.
|
|
4
|
+
* @param categories - плоский список категорий (см. parseFilterCategories)
|
|
5
|
+
*/
|
|
6
|
+
export function parseCategoryFilterValues(categories: string[]): Record<string, string> {
|
|
7
|
+
const result: Record<string, string> = {};
|
|
8
|
+
categories.forEach(str => {
|
|
9
|
+
const [key, ...rest] = str.split('_');
|
|
10
|
+
if (key && rest.length > 0) {
|
|
11
|
+
result[key] = rest.join('_');
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
return result;
|
|
15
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildRangeFilterQuery } from '../build-range-filter-query';
|
|
3
|
+
|
|
4
|
+
describe('buildRangeFilterQuery', () => {
|
|
5
|
+
it('проставляет filter_<name>_from и _to', () => {
|
|
6
|
+
const result = buildRangeFilterQuery({
|
|
7
|
+
query: {},
|
|
8
|
+
name: 'age',
|
|
9
|
+
from: 20,
|
|
10
|
+
to: 35,
|
|
11
|
+
rangeInfo: { min: 18, max: 40 },
|
|
12
|
+
});
|
|
13
|
+
expect(result['filter_age_from']).toBe('20');
|
|
14
|
+
expect(result['filter_age_to']).toBe('35');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('удаляет параметры когда диапазон равен полному (from=min, to=max)', () => {
|
|
18
|
+
const result = buildRangeFilterQuery({
|
|
19
|
+
query: { filter_age_from: '18', filter_age_to: '40' },
|
|
20
|
+
name: 'age',
|
|
21
|
+
from: 18,
|
|
22
|
+
to: 40,
|
|
23
|
+
rangeInfo: { min: 18, max: 40 },
|
|
24
|
+
});
|
|
25
|
+
expect(result['filter_age_from']).toBeUndefined();
|
|
26
|
+
expect(result['filter_age_to']).toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('сохраняет прочие параметры query', () => {
|
|
30
|
+
const result = buildRangeFilterQuery({
|
|
31
|
+
query: { sort: 'new' },
|
|
32
|
+
name: 'age',
|
|
33
|
+
from: 20,
|
|
34
|
+
to: 35,
|
|
35
|
+
rangeInfo: { min: 18, max: 40 },
|
|
36
|
+
});
|
|
37
|
+
expect(result.sort).toBe('new');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { getSliderRangeInfo } from '../get-slider-range-info';
|
|
3
|
+
|
|
4
|
+
describe('getSliderRangeInfo', () => {
|
|
5
|
+
it('возвращает min/max/step для числовых опций', () => {
|
|
6
|
+
const result = getSliderRangeInfo([{ name: '18' }, { name: '40' }, { name: '25' }]);
|
|
7
|
+
expect(result).toEqual({ min: 18, max: 40, step: 1 });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('сортирует значения перед расчётом', () => {
|
|
11
|
+
const result = getSliderRangeInfo([{ name: '100' }, { name: '5' }]);
|
|
12
|
+
expect(result?.min).toBe(5);
|
|
13
|
+
expect(result?.max).toBe(100);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('step = 0 для единственного значения', () => {
|
|
17
|
+
expect(getSliderRangeInfo([{ name: '18' }])?.step).toBe(0);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('null когда нет числовых опций', () => {
|
|
21
|
+
expect(getSliderRangeInfo([{ name: 'abc' }, { name: 'xyz' }])).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('отбрасывает нечисловые опции', () => {
|
|
25
|
+
const result = getSliderRangeInfo([{ name: '18' }, { name: 'abc' }, { name: '40' }]);
|
|
26
|
+
expect(result).toEqual({ min: 18, max: 40, step: 1 });
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
type QueryParam = string | (string | null)[] | null;
|
|
2
|
+
type QueryRecord = Record<string, QueryParam>;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Строит query для range-фильтра, проставляя `filter_<name>_from` / `_to`. Если выбранный
|
|
6
|
+
* диапазon совпадает с полным (`from === min && to === max`) — параметры убираются из query.
|
|
7
|
+
* @param params.query - текущий query
|
|
8
|
+
* @param params.name - имя фильтра
|
|
9
|
+
* @param params.from - нижняя граница выбора
|
|
10
|
+
* @param params.to - верхняя граница выбора
|
|
11
|
+
* @param params.rangeInfo - полный диапазон фильтра (min/max)
|
|
12
|
+
*/
|
|
13
|
+
export function buildRangeFilterQuery(params: {
|
|
14
|
+
query: QueryRecord;
|
|
15
|
+
name: string;
|
|
16
|
+
from: string | number;
|
|
17
|
+
to: string | number;
|
|
18
|
+
rangeInfo: { min: number; max: number };
|
|
19
|
+
}): QueryRecord {
|
|
20
|
+
const { query: currentQuery, name, from, to, rangeInfo } = params;
|
|
21
|
+
const query = { ...currentQuery };
|
|
22
|
+
|
|
23
|
+
query[`filter_${name}_from`] = String(from);
|
|
24
|
+
query[`filter_${name}_to`] = String(to);
|
|
25
|
+
|
|
26
|
+
if (from === rangeInfo.min && to === rangeInfo.max) {
|
|
27
|
+
delete query[`filter_${name}_from`];
|
|
28
|
+
delete query[`filter_${name}_to`];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return query;
|
|
32
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface RangeInfo {
|
|
2
|
+
min: number;
|
|
3
|
+
step: number;
|
|
4
|
+
max: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Вычисляет границы и шаг диапазона слайдера из числовых значений `name` опций
|
|
9
|
+
* (мин, макс по отсортированным числам; шаг 1 при наличии >1 значения, иначе 0).
|
|
10
|
+
* Возвращает null, если числовых опций нет.
|
|
11
|
+
* @param options - опции фильтра (поле `name` ожидается числовым)
|
|
12
|
+
*/
|
|
13
|
+
export function getSliderRangeInfo(options: { name: string }[]): RangeInfo | null {
|
|
14
|
+
const numericValues = options
|
|
15
|
+
.map(opt => Number(opt.name))
|
|
16
|
+
.filter(v => !isNaN(v))
|
|
17
|
+
.sort((a, b) => a - b);
|
|
18
|
+
|
|
19
|
+
if (numericValues.length === 0) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const min = numericValues[0] || 0;
|
|
24
|
+
const max = numericValues[numericValues.length - 1] || 100;
|
|
25
|
+
const step = numericValues.length > 1 ? 1 : 0;
|
|
26
|
+
|
|
27
|
+
return { min, step, max };
|
|
28
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Возвращает уникальные группы фильтров (по `group.title`), отсортированные по `group.order`.
|
|
3
|
+
* @param filters - список фильтров, у каждого поле `group`
|
|
4
|
+
*/
|
|
5
|
+
export function getSortedFilterGroups<G extends { title: string; order: number }>(
|
|
6
|
+
filters: { group: G }[],
|
|
7
|
+
): G[] {
|
|
8
|
+
const uniqueTitles = [...new Set(filters.map(item => item.group.title))];
|
|
9
|
+
return uniqueTitles
|
|
10
|
+
.map(title => filters.find(filter => filter.group.title === title)?.group)
|
|
11
|
+
.filter((group): group is G => Boolean(group))
|
|
12
|
+
.sort((a, b) => a.order - b.order);
|
|
13
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { groupParametersByGroup } from '../group-parameters-by-group';
|
|
3
|
+
|
|
4
|
+
function param(name: string, groupName: string, order: number, isFilter = false) {
|
|
5
|
+
return {
|
|
6
|
+
name,
|
|
7
|
+
title: name.toUpperCase(),
|
|
8
|
+
values: [],
|
|
9
|
+
kind: 'select' as const,
|
|
10
|
+
is_filter: isFilter,
|
|
11
|
+
icon: '',
|
|
12
|
+
group: { name: groupName, title: `${groupName}-title`, order },
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('groupParametersByGroup', () => {
|
|
17
|
+
it('группирует по group.name и сортирует группы по order', () => {
|
|
18
|
+
const result = groupParametersByGroup([
|
|
19
|
+
param('a', 'second', 2),
|
|
20
|
+
param('b', 'first', 1),
|
|
21
|
+
]);
|
|
22
|
+
expect(result.map(g => g.title)).toEqual(['first-title', 'second-title']);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('собирает несколько параметров одной группы в items', () => {
|
|
26
|
+
const result = groupParametersByGroup([
|
|
27
|
+
param('a', 'g', 1),
|
|
28
|
+
param('b', 'g', 1),
|
|
29
|
+
]);
|
|
30
|
+
expect(result).toHaveLength(1);
|
|
31
|
+
expect(result[0].items.map(i => i.name)).toEqual(['a', 'b']);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('переименовывает is_filter → isFilter', () => {
|
|
35
|
+
const result = groupParametersByGroup([param('a', 'g', 1, true)]);
|
|
36
|
+
expect(result[0].items[0].isFilter).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('пустой список → []', () => {
|
|
40
|
+
expect(groupParametersByGroup([])).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { IGroupedParameter } from '../../types';
|
|
2
|
+
|
|
3
|
+
interface RawParameter {
|
|
4
|
+
name: string;
|
|
5
|
+
title: string;
|
|
6
|
+
values: { name: string; title: string }[];
|
|
7
|
+
kind: 'range' | 'select' | 'chips' | 'radio';
|
|
8
|
+
is_filter: boolean;
|
|
9
|
+
icon: string;
|
|
10
|
+
group: { name: string; title: string; order: number };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Группирует параметры карточки по `group.name` в объекты с заголовком/порядком группы
|
|
15
|
+
* и списком items (с переименованием `is_filter` → `isFilter`); группы сортируются по `order`.
|
|
16
|
+
* @param parameters - плоский список параметров
|
|
17
|
+
*/
|
|
18
|
+
export function groupParametersByGroup(parameters: RawParameter[]): IGroupedParameter[] {
|
|
19
|
+
const groups = new Map<string, IGroupedParameter>();
|
|
20
|
+
|
|
21
|
+
for (const param of parameters) {
|
|
22
|
+
const groupName = param.group.name;
|
|
23
|
+
if (!groups.has(groupName)) {
|
|
24
|
+
groups.set(groupName, { title: param.group.title, order: param.group.order, items: [] });
|
|
25
|
+
}
|
|
26
|
+
groups.get(groupName)!.items.push({
|
|
27
|
+
name: param.name,
|
|
28
|
+
title: param.title,
|
|
29
|
+
values: param.values,
|
|
30
|
+
kind: param.kind,
|
|
31
|
+
isFilter: param.is_filter,
|
|
32
|
+
icon: param.icon,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return [...groups.values()].sort((a, b) => a.order - b.order);
|
|
37
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
type QueryParam = string | (string | null)[] | null | undefined;
|
|
2
|
+
type QueryRecord = Record<string, QueryParam>;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Разбирает query-параметр `categories` (строку через запятую или массив строк)
|
|
6
|
+
* в плоский список значений вида `key_value`, отбрасывая пустые сегменты.
|
|
7
|
+
* @param query - query роута
|
|
8
|
+
*/
|
|
9
|
+
export function parseFilterCategories(query: QueryRecord): string[] {
|
|
10
|
+
const raw = query.categories;
|
|
11
|
+
if (!raw) return [];
|
|
12
|
+
if (typeof raw === 'string') return raw.split(',').filter(Boolean);
|
|
13
|
+
return (raw as string[]).flatMap(c => (c || '').split(',')).filter(Boolean);
|
|
14
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { getNameInitials } from '../get-name-initials';
|
|
3
|
+
|
|
4
|
+
describe('getNameInitials', () => {
|
|
5
|
+
it('берёт первые буквы первых двух слов', () => {
|
|
6
|
+
expect(getNameInitials('John Doe')).toBe('JD');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('игнорирует слова после второго', () => {
|
|
10
|
+
expect(getNameInitials('John Michael Doe')).toBe('JM');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('одно слово → одна буква', () => {
|
|
14
|
+
expect(getNameInitials('madonna')).toBe('M');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('схлопывает множественные пробелы', () => {
|
|
18
|
+
expect(getNameInitials('John Doe')).toBe('JD');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('пустая строка → пустая строка', () => {
|
|
22
|
+
expect(getNameInitials('')).toBe('');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Возвращает инициалы из имени: берёт первые буквы первых двух слов в верхнем регистре.
|
|
3
|
+
* @param name - имя (слова разделяются пробелами)
|
|
4
|
+
*/
|
|
5
|
+
export function getNameInitials(name: string): string {
|
|
6
|
+
return name
|
|
7
|
+
.split(/\s+/)
|
|
8
|
+
.slice(0, 2)
|
|
9
|
+
.map(word => word.charAt(0).toUpperCase())
|
|
10
|
+
.join('');
|
|
11
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { applyReaction } from '../apply-reaction';
|
|
3
|
+
|
|
4
|
+
describe('applyReaction', () => {
|
|
5
|
+
it('like: +1 лайк, -1 дизлайк', () => {
|
|
6
|
+
expect(applyReaction('like', 10, 5)).toEqual({ likes: 11, dislikes: 4 });
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('like: дизлайк не уходит ниже нуля', () => {
|
|
10
|
+
expect(applyReaction('like', 10, 0)).toEqual({ likes: 11, dislikes: 0 });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('like: лайк не растёт выше 1000', () => {
|
|
14
|
+
expect(applyReaction('like', 1000, 5)).toEqual({ likes: 1000, dislikes: 4 });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('dislike: +1 дизлайк, -1 лайк', () => {
|
|
18
|
+
expect(applyReaction('dislike', 10, 5)).toEqual({ likes: 9, dislikes: 6 });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('dislike: лайк не уходит ниже нуля', () => {
|
|
22
|
+
expect(applyReaction('dislike', 0, 5)).toEqual({ likes: 0, dislikes: 6 });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('dislike: дизлайк не растёт выше 1000', () => {
|
|
26
|
+
expect(applyReaction('dislike', 10, 1000)).toEqual({ likes: 9, dislikes: 1000 });
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { getReactionPercents } from '../get-reaction-percents';
|
|
3
|
+
|
|
4
|
+
describe('getReactionPercents', () => {
|
|
5
|
+
it('0/0 → 0% и 0% (без деления на ноль)', () => {
|
|
6
|
+
expect(getReactionPercents(0, 0)).toEqual({ likePercent: 0, dislikePercent: 0 });
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('равные значения → 50/50', () => {
|
|
10
|
+
expect(getReactionPercents(5, 5)).toEqual({ likePercent: 50, dislikePercent: 50 });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('только лайки → 100/0', () => {
|
|
14
|
+
expect(getReactionPercents(8, 0)).toEqual({ likePercent: 100, dislikePercent: 0 });
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Пересчитывает счётчики при новой реакции: выбранному типу +1 (но не выше 1000),
|
|
3
|
+
* противоположному -1 (но не ниже 0).
|
|
4
|
+
* @param type - выбранная реакция
|
|
5
|
+
* @param likes - текущее число лайков
|
|
6
|
+
* @param dislikes - текущее число дизлайков
|
|
7
|
+
*/
|
|
8
|
+
export function applyReaction(
|
|
9
|
+
type: 'like' | 'dislike',
|
|
10
|
+
likes: number,
|
|
11
|
+
dislikes: number,
|
|
12
|
+
): { likes: number; dislikes: number } {
|
|
13
|
+
if (type === 'like') {
|
|
14
|
+
return {
|
|
15
|
+
likes: likes < 1000 ? likes + 1 : likes,
|
|
16
|
+
dislikes: dislikes > 0 ? dislikes - 1 : dislikes,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
likes: likes > 0 ? likes - 1 : likes,
|
|
21
|
+
dislikes: dislikes < 1000 ? dislikes + 1 : dislikes,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Считает доли лайков и дизлайков в процентах от их суммы; при нулевой сумме
|
|
3
|
+
* делитель = 1, чтобы избежать деления на ноль (обе доли тогда 0%).
|
|
4
|
+
* @param likes - число лайков
|
|
5
|
+
* @param dislikes - число дизлайков
|
|
6
|
+
*/
|
|
7
|
+
export function getReactionPercents(
|
|
8
|
+
likes: number,
|
|
9
|
+
dislikes: number,
|
|
10
|
+
): { likePercent: number; dislikePercent: number } {
|
|
11
|
+
const likesCount = likes || 0;
|
|
12
|
+
const dislikesCount = dislikes || 0;
|
|
13
|
+
const total = likesCount + dislikesCount || 1;
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
likePercent: (likesCount / total) * 100,
|
|
17
|
+
dislikePercent: (dislikesCount / total) * 100,
|
|
18
|
+
};
|
|
19
|
+
}
|