itube-specs 0.0.956 → 0.0.957
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/card/webcam/index.vue +25 -4
- package/components/webcam/main.vue +2 -0
- package/composables/fetch/use-fetch-webcams.ts +26 -4
- package/composables/use-webcam-alive.ts +86 -0
- package/package.json +1 -1
- package/runtime/constants/webcams.ts +7 -0
- package/server/api/webcams/alive.post.ts +48 -0
- package/server/utils/__tests__/cached-fetch.test.ts +35 -0
- package/server/utils/cached-fetch.ts +19 -4
- package/types/webcams-config.d.ts +2 -0
- package/utils/__tests__/apply-replacements.test.ts +54 -0
- package/utils/webcams/apply-replacements.ts +50 -0
|
@@ -111,15 +111,17 @@
|
|
|
111
111
|
@mouseleave="onNameLeave"
|
|
112
112
|
>{{ model.name }}</a>
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
<!-- Возраст ведёт в фильтр ±1 год — правило боевого движка (`mf_age_from/to`). -->
|
|
115
|
+
<a
|
|
115
116
|
v-if="model.age"
|
|
117
|
+
:href="ageFilterUrl"
|
|
116
118
|
class="card-webcam__age"
|
|
117
|
-
>{{ t('webcams.years_old', { age: model.age }) }}</
|
|
119
|
+
>{{ t('webcams.years_old', { age: model.age }) }}</a>
|
|
118
120
|
|
|
119
121
|
<!-- Страна — в строке имени справа (в макете на узких экранах её нет вовсе). -->
|
|
120
122
|
<a
|
|
121
123
|
v-if="model.country"
|
|
122
|
-
:href="`${nichePath}/webcams
|
|
124
|
+
:href="`${nichePath}/webcams?${WEBCAM_FILTER_QUERY_PREFIX}country=${model.country}`"
|
|
123
125
|
class="card-webcam__country"
|
|
124
126
|
>{{ countryName }}</a>
|
|
125
127
|
</div>
|
|
@@ -163,6 +165,7 @@
|
|
|
163
165
|
<script setup lang="ts">
|
|
164
166
|
import type { IWebcamModel } from '../../../types';
|
|
165
167
|
import { isoCodeToCountryName } from '../../../utils/iso-code-to-country-name';
|
|
168
|
+
import { WEBCAM_FILTER_QUERY_PREFIX } from '../../../utils/webcams/facets';
|
|
166
169
|
import { preloadHls } from '../../../utils/webcams/preload-hls';
|
|
167
170
|
|
|
168
171
|
const props = withDefaults(defineProps<{
|
|
@@ -214,6 +217,20 @@ const isPreviewActive = previewSlot.isActive(previewId.value);
|
|
|
214
217
|
const isPreviewReady = ref(false);
|
|
215
218
|
|
|
216
219
|
/** Ниша в пути: у straight префикса нет, у остальных — `/gay`, `/shemale`. */
|
|
220
|
+
/**
|
|
221
|
+
* Возраст в тумбе — ссылка на раздел с фильтром возраста ±1 год: так делал боевой
|
|
222
|
+
* движок (`mf_age_from = age - 1`, `mf_age_to = age + 1`), и наш range-фильтр читает
|
|
223
|
+
* ровно эти границы из `filter_age_from` / `filter_age_to`.
|
|
224
|
+
*/
|
|
225
|
+
const ageFilterUrl = computed(() => {
|
|
226
|
+
const age = Number(props.model.age) || 0;
|
|
227
|
+
if (!age) return '';
|
|
228
|
+
|
|
229
|
+
const prefix = WEBCAM_FILTER_QUERY_PREFIX;
|
|
230
|
+
|
|
231
|
+
return `${nichePath.value}/webcams?${prefix}age_from=${age - 1}&${prefix}age_to=${age + 1}`;
|
|
232
|
+
});
|
|
233
|
+
|
|
217
234
|
const nichePath = computed(() => {
|
|
218
235
|
const niche = NicheHelper.getSiteDefaultNiche();
|
|
219
236
|
|
|
@@ -621,7 +638,7 @@ onBeforeUnmount(() => {
|
|
|
621
638
|
|
|
622
639
|
--line-clamp: 1;
|
|
623
640
|
|
|
624
|
-
font-weight:
|
|
641
|
+
font-weight: 400;
|
|
625
642
|
color: $card-webcam-name-text;
|
|
626
643
|
|
|
627
644
|
&:is(:hover, :focus) {
|
|
@@ -634,6 +651,10 @@ onBeforeUnmount(() => {
|
|
|
634
651
|
|
|
635
652
|
flex-shrink: 0;
|
|
636
653
|
color: $card-webcam-meta-text;
|
|
654
|
+
|
|
655
|
+
&:is(:hover, :focus) {
|
|
656
|
+
color: $card-webcam-meta-text--hover;
|
|
657
|
+
}
|
|
637
658
|
}
|
|
638
659
|
|
|
639
660
|
// обрезка своя, а не утилитой `_truncate`: та ставит `display` через !important и
|
|
@@ -62,6 +62,8 @@ const blockLimit = computed(() => props.limit || webcamsConfig.value.limit || WE
|
|
|
62
62
|
const { items, counts, isEnabled } = await useFetchWebcams({
|
|
63
63
|
limit: blockLimit.value + props.reserveTail,
|
|
64
64
|
category: props.category,
|
|
65
|
+
// тумбы блока висят на странице долго — перепроверяем, кто ещё в эфире
|
|
66
|
+
keepAlive: true,
|
|
65
67
|
});
|
|
66
68
|
|
|
67
69
|
/** Хвост списка отдан странице (врезки в сетку) — блок берёт только свою голову. */
|
|
@@ -11,24 +11,46 @@ import { WEBCAMS_DEFAULTS } from '../../runtime';
|
|
|
11
11
|
*
|
|
12
12
|
* Под флагом `FeatureWebcamsEnabled`: при выключенном флаге запроса нет вовсе.
|
|
13
13
|
*/
|
|
14
|
-
export const useFetchWebcams = async (options: {
|
|
14
|
+
export const useFetchWebcams = async (options: {
|
|
15
|
+
limit?: number,
|
|
16
|
+
category?: string,
|
|
17
|
+
clientOnly?: boolean,
|
|
18
|
+
/**
|
|
19
|
+
* Перепроверять на клиенте, что показанные модели ещё в эфире, и подставлять
|
|
20
|
+
* замену вместо ушедших (см. `useWebcamAlive`). Просят те места, где тумбы
|
|
21
|
+
* висят долго — блок «Live Cams» и врезки в сетку.
|
|
22
|
+
*/
|
|
23
|
+
keepAlive?: boolean,
|
|
24
|
+
} = {}) => {
|
|
15
25
|
const config = useRuntimeConfig().public.webcamsConfig as IWebcamsConfig | undefined;
|
|
16
|
-
const { limit = config?.limit || WEBCAMS_DEFAULTS.limit, category, clientOnly } = options;
|
|
26
|
+
const { limit = config?.limit || WEBCAMS_DEFAULTS.limit, category, clientOnly, keepAlive } = options;
|
|
17
27
|
const isEnabled = !!useRuntimeConfig().public.featureFlags.FeatureWebcamsEnabled;
|
|
18
28
|
const { locale } = useI18n();
|
|
19
29
|
const niche = NicheHelper.getSiteDefaultNiche();
|
|
20
30
|
|
|
21
|
-
|
|
31
|
+
/** Ключ фетча: он же адрес общего списка моделей для проверки живости. */
|
|
32
|
+
const cacheKey = `webcams-${limit}-${category || 'all'}-${niche}-${locale.value}`;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Список — ОБЩЕЕ состояние по ключу фетча, а не локальный ref: у одного ответа два
|
|
36
|
+
* потребителя (блок «Live Cams» и врезки в сетку), и подмена ушедшей в офлайн
|
|
37
|
+
* модели должна быть видна обоим. Патчит его `useWebcamAlive`.
|
|
38
|
+
*/
|
|
39
|
+
const items = useState<IStripcashModel[]>(`${cacheKey}:items`, () => []);
|
|
22
40
|
const counts = ref<IWebcamCounts>({ total: 0, byLanguages: {} });
|
|
23
41
|
|
|
24
42
|
if (!isEnabled) {
|
|
25
43
|
return { items, counts, isEnabled };
|
|
26
44
|
}
|
|
27
45
|
|
|
46
|
+
// Арминг проверки живости — ДО `await`: после него Nuxt теряет контекст
|
|
47
|
+
// компонента, и `onMounted` / `useState` внутри уже не работают.
|
|
48
|
+
if (keepAlive) useWebcamAlive(cacheKey);
|
|
49
|
+
|
|
28
50
|
const nuxtApp = useNuxtApp();
|
|
29
51
|
|
|
30
52
|
const { data } = await useFetch<{ items: IStripcashModel[] } & IWebcamCounts>('/bff/webcams/block', {
|
|
31
|
-
key:
|
|
53
|
+
key: cacheKey,
|
|
32
54
|
// выдача провайдера рандомная, поэтому повторный запрос на клиенте дал бы
|
|
33
55
|
// ДРУГИЕ модели и рассинхрон с разметкой сервера (ссылки от одной модели,
|
|
34
56
|
// подписи от другой) — берём то, что уже приехало в payload
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { IStripcashModel, IWebcamsConfig } from '../types';
|
|
2
|
+
import { WEBCAMS_DEFAULTS } from '../runtime';
|
|
3
|
+
import { applyWebcamReplacements } from '../utils/webcams/apply-replacements';
|
|
4
|
+
import type { WebcamAliveStatus, WebcamReplacement } from '../utils/webcams/apply-replacements';
|
|
5
|
+
|
|
6
|
+
type AliveResponse = { statuses: WebcamAliveStatus[], replacements: WebcamReplacement[] };
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Держит показанные вебкам-тумбы живыми: раз в `aliveInterval` спрашивает у BFF,
|
|
10
|
+
* кто из моделей ещё в эфире, и подставляет замену вместо ушедших.
|
|
11
|
+
*
|
|
12
|
+
* Патчим ОБЩЕЕ состояние списка (`<ключ фетча>:items`), которое отдаёт
|
|
13
|
+
* `useFetchWebcams`: у него два потребителя — блок «Live Cams» и врезки в сетку, —
|
|
14
|
+
* поэтому замена появляется сразу в обоих местах и одна модель не встаёт дважды.
|
|
15
|
+
*
|
|
16
|
+
* Опрашиваем только при видимой вкладке: в фоне это бессмысленный трафик, а при
|
|
17
|
+
* возврате на вкладку проверяем сразу, потому что за время «сна» модели могли уйти.
|
|
18
|
+
*
|
|
19
|
+
* Проверку арминг ставит ОДИН раз на страницу (`useState`-флаг): список общий, а
|
|
20
|
+
* потребителей у него два — блок и врезки.
|
|
21
|
+
*
|
|
22
|
+
* На вход идёт КЛЮЧ фетча, а не его результат: композабл вызывается до `await` в
|
|
23
|
+
* `useFetchWebcams`, иначе `onMounted`/`useState` уже вне контекста компонента
|
|
24
|
+
* (после await Nuxt его теряет) и компонент на клиенте падает — тумбы исчезали.
|
|
25
|
+
*/
|
|
26
|
+
export function useWebcamAlive(cacheKey: string) {
|
|
27
|
+
if (import.meta.server) return;
|
|
28
|
+
|
|
29
|
+
const armed = useState<boolean>('webcams-alive-armed', () => false);
|
|
30
|
+
if (armed.value) return;
|
|
31
|
+
|
|
32
|
+
const config = (useRuntimeConfig().public.webcamsConfig || {}) as IWebcamsConfig;
|
|
33
|
+
const interval = config.aliveInterval ?? WEBCAMS_DEFAULTS.aliveInterval;
|
|
34
|
+
if (!interval) return;
|
|
35
|
+
|
|
36
|
+
armed.value = true;
|
|
37
|
+
|
|
38
|
+
const { locale } = useI18n();
|
|
39
|
+
const niche = NicheHelper.getSiteDefaultNiche();
|
|
40
|
+
/** Тот же общий список, что отдаёт `useFetchWebcams` (ключ фетча + `:items`). */
|
|
41
|
+
const models = useState<IStripcashModel[]>(`${cacheKey}:items`, () => []);
|
|
42
|
+
|
|
43
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
44
|
+
let checking = false;
|
|
45
|
+
|
|
46
|
+
async function check() {
|
|
47
|
+
if (checking || document.visibilityState !== 'visible') return;
|
|
48
|
+
|
|
49
|
+
const usernames = models.value.map(model => model.username).filter(Boolean);
|
|
50
|
+
if (!usernames.length) return;
|
|
51
|
+
|
|
52
|
+
checking = true;
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const response = await $fetch<AliveResponse>('/bff/webcams/alive', {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
query: { niche, lang: locale.value },
|
|
58
|
+
body: { usernames },
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const next = applyWebcamReplacements(models.value, response?.statuses || [], response?.replacements || []);
|
|
62
|
+
// ссылку меняем только когда список действительно другой: иначе лишний
|
|
63
|
+
// ререндер сетки на каждой проверке
|
|
64
|
+
if (next !== models.value) models.value = next;
|
|
65
|
+
} catch {
|
|
66
|
+
// ручка не ответила — на следующей проверке попробуем снова
|
|
67
|
+
} finally {
|
|
68
|
+
checking = false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function onVisible() {
|
|
73
|
+
if (document.visibilityState === 'visible') check();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
onMounted(() => {
|
|
77
|
+
timer = setInterval(check, interval);
|
|
78
|
+
document.addEventListener('visibilitychange', onVisible);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
onScopeDispose(() => {
|
|
82
|
+
if (timer) clearInterval(timer);
|
|
83
|
+
document.removeEventListener('visibilitychange', onVisible);
|
|
84
|
+
armed.value = false;
|
|
85
|
+
});
|
|
86
|
+
}
|
package/package.json
CHANGED
|
@@ -14,4 +14,11 @@ export const WEBCAMS_DEFAULTS = {
|
|
|
14
14
|
limit: 12,
|
|
15
15
|
/** Порядок предпочтения качества превью стрима: лёгкое — первым. */
|
|
16
16
|
previewQuality: ['240p', '480p'],
|
|
17
|
+
/**
|
|
18
|
+
* Как часто перепроверять, что показанные модели ещё в эфире (мс). 90 секунд —
|
|
19
|
+
* компромисс: реже — на странице подолгу висят ушедшие, чаще — лишние запросы
|
|
20
|
+
* без пользы (стрим редко обрывается через минуту после начала).
|
|
21
|
+
* 0 отключает проверку.
|
|
22
|
+
*/
|
|
23
|
+
aliveInterval: 90_000,
|
|
17
24
|
} as const;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { IStripcashModel } from '../../../types';
|
|
2
|
+
|
|
3
|
+
type AliveResponse = {
|
|
4
|
+
statuses: Array<{ username: string, isOnline: boolean }>;
|
|
5
|
+
replacements: Array<{ username: string, model: IStripcashModel }>;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Проверка «модель ещё в эфире» и замена для ушедших — тонкий прокси к
|
|
10
|
+
* POST `/v2/stripcash/online-models-replace?replace=true`.
|
|
11
|
+
*
|
|
12
|
+
* Зачем ручка вообще: у провайдера нет ни статуса отдельной модели, ни фильтра по
|
|
13
|
+
* логину, а судить по исчезновению из выдачи нельзя — список это выборка из тысяч
|
|
14
|
+
* моделей онлайн. Бекенд сделал одну ручку, которая и статусы отдаёт, и сразу
|
|
15
|
+
* подбирает замену (в перспективе — похожую по фильтрам).
|
|
16
|
+
*
|
|
17
|
+
* Ниша и язык обязательны заголовками, как и у остальных ручек провайдера; клиент
|
|
18
|
+
* передаёт их query-параметрами. Кеша здесь нет намеренно: смысл вызова — свежесть
|
|
19
|
+
* статуса, а список логинов у каждого посетителя свой.
|
|
20
|
+
*/
|
|
21
|
+
export default defineEventHandler(async (event): Promise<AliveResponse> => {
|
|
22
|
+
const apiUrl = process.env.NUXT_PUBLIC_API_DOMAIN;
|
|
23
|
+
const query = getQuery(event);
|
|
24
|
+
const body = await readBody<{ usernames?: string[] }>(event);
|
|
25
|
+
const usernames = (body?.usernames || []).filter(Boolean);
|
|
26
|
+
|
|
27
|
+
if (!usernames.length) return { statuses: [], replacements: [] };
|
|
28
|
+
|
|
29
|
+
event.node.req.headers['x-niche'] = String(query.niche || '');
|
|
30
|
+
event.node.req.headers['x-language'] = String(query.lang || event.node.req.headers['x-language'] || 'en');
|
|
31
|
+
|
|
32
|
+
const rawData = await apiFetch(`${apiUrl}/v2/stripcash/online-models-replace?replace=true`, {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: event.node.req.headers as HeadersInit,
|
|
35
|
+
body: JSON.stringify({ usernames }),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const data = rawData?.data || {};
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
// приводим к нашему camelCase, чтобы наружу не текла форма провайдера
|
|
42
|
+
statuses: (data.items || []).map((item: { username: string, is_online: boolean }) => ({
|
|
43
|
+
username: item.username,
|
|
44
|
+
isOnline: item.is_online !== false,
|
|
45
|
+
})),
|
|
46
|
+
replacements: (data.replacements || []).filter((item: { model?: IStripcashModel }) => item?.model),
|
|
47
|
+
};
|
|
48
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildCacheKey } from '../cached-fetch';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Ручки контента разводят выдачу заголовками, а не URL: пока в ключе кеша был
|
|
6
|
+
* только язык, ответ первой пришедшей ниши отдавался всем остальным.
|
|
7
|
+
*/
|
|
8
|
+
describe('buildCacheKey', () => {
|
|
9
|
+
const url = 'https://api.example.com/v2/models/top?limit=6';
|
|
10
|
+
|
|
11
|
+
it('ниша попадает в ключ', () => {
|
|
12
|
+
const straight = buildCacheKey(url, { 'x-niche': 'straight', 'x-language': 'en' });
|
|
13
|
+
const gay = buildCacheKey(url, { 'x-niche': 'gay', 'x-language': 'en' });
|
|
14
|
+
|
|
15
|
+
expect(straight).not.toBe(gay);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('язык и домен тоже разводят кеш', () => {
|
|
19
|
+
const base = { 'x-niche': 'gay', 'x-language': 'en', 'x-domain': 'a.example' };
|
|
20
|
+
|
|
21
|
+
expect(buildCacheKey(url, base)).not.toBe(buildCacheKey(url, { ...base, 'x-language': 'ru' }));
|
|
22
|
+
expect(buildCacheKey(url, base)).not.toBe(buildCacheKey(url, { ...base, 'x-domain': 'b.example' }));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('одинаковый срез даёт одинаковый ключ, язык по умолчанию en', () => {
|
|
26
|
+
const withLang = buildCacheKey(url, { 'x-language': 'en', 'x-niche': 'gay' });
|
|
27
|
+
const withoutLang = buildCacheKey(url, { 'x-niche': 'gay' });
|
|
28
|
+
|
|
29
|
+
expect(withoutLang).toBe(withLang);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('массив в заголовке берётся первым значением', () => {
|
|
33
|
+
expect(buildCacheKey(url, { 'x-niche': ['gay', 'straight'] })).toBe(buildCacheKey(url, { 'x-niche': 'gay' }));
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -6,17 +6,32 @@ interface CacheEntry<T> {
|
|
|
6
6
|
expiresAt: number;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
// Хранилище закэшированных ответов (ключ — URL +
|
|
9
|
+
// Хранилище закэшированных ответов (ключ — URL + срез запроса, значение — данные + время истечения)
|
|
10
10
|
const cache = new Map<string, CacheEntry<unknown>>();
|
|
11
11
|
// Хранилище текущих незавершённых запросов для дедупликации параллельных вызовов
|
|
12
12
|
const inflight = new Map<string, Promise<unknown>>();
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
15
|
+
* Ключ кэша: URL плюс ВСЁ, от чего зависит ответ, но чего в URL нет.
|
|
16
|
+
*
|
|
17
|
+
* Ручки контента разводят выдачу заголовками: `X-Niche` (ниша), `X-Language`
|
|
18
|
+
* (язык), `X-Domain` (сайт). Пока в ключе был только язык, первый ответ занимал
|
|
19
|
+
* слот на весь TTL и отдавался остальным нишам: после переключения ниши
|
|
20
|
+
* категории, топ моделей и прочие кешируемые списки оставались straight'овыми.
|
|
21
|
+
* Домен важен по той же причине: один процесс обслуживает несколько хостов.
|
|
16
22
|
*/
|
|
23
|
+
export function buildCacheKey(url: string, headers: Record<string, unknown>): string {
|
|
24
|
+
const value = (name: string) => {
|
|
25
|
+
const raw = headers[name];
|
|
26
|
+
|
|
27
|
+
return String(Array.isArray(raw) ? raw[0] : raw ?? '');
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
return [value('x-domain'), value('x-language') || 'en', value('x-niche'), url].join('::');
|
|
31
|
+
}
|
|
32
|
+
|
|
17
33
|
function getCacheKey(url: string, event: H3Event): string {
|
|
18
|
-
|
|
19
|
-
return `${lang}::${url}`;
|
|
34
|
+
return buildCacheKey(url, event.node.req.headers as Record<string, unknown>);
|
|
20
35
|
}
|
|
21
36
|
|
|
22
37
|
/**
|
|
@@ -7,6 +7,8 @@ export interface IWebcamsConfig {
|
|
|
7
7
|
provider?: string;
|
|
8
8
|
limit?: number;
|
|
9
9
|
previewQuality?: string[];
|
|
10
|
+
/** Период перепроверки онлайна показанных моделей, мс; 0 — не проверять. */
|
|
11
|
+
aliveInterval?: number;
|
|
10
12
|
/** Шаблон ссылки на модель по нишам; `{username}` подставляется. */
|
|
11
13
|
modelLinks?: Record<string, string>;
|
|
12
14
|
/** Промо-ссылка партнёрки по нишам — та же, что в шапке сайта. */
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { IStripcashModel } from '../../types';
|
|
3
|
+
import { applyWebcamReplacements } from '../webcams/apply-replacements';
|
|
4
|
+
|
|
5
|
+
const model = (username: string) => ({ username } as IStripcashModel);
|
|
6
|
+
|
|
7
|
+
describe('applyWebcamReplacements', () => {
|
|
8
|
+
it('меняет только ушедших в офлайн, порядок и длина сохраняются', () => {
|
|
9
|
+
const result = applyWebcamReplacements(
|
|
10
|
+
[model('a'), model('b'), model('c')],
|
|
11
|
+
[{ username: 'a', isOnline: true }, { username: 'b', isOnline: false }, { username: 'c', isOnline: true }],
|
|
12
|
+
[{ username: 'b', model: model('new') }],
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
expect(result.map(item => item.username)).toEqual(['a', 'new', 'c']);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('без офлайна возвращает тот же массив', () => {
|
|
19
|
+
const models = [model('a'), model('b')];
|
|
20
|
+
const result = applyWebcamReplacements(models, [{ username: 'a', isOnline: true }], []);
|
|
21
|
+
|
|
22
|
+
expect(result).toBe(models);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('офлайн без замены остаётся на месте — дырки не делаем', () => {
|
|
26
|
+
const result = applyWebcamReplacements(
|
|
27
|
+
[model('a'), model('b')],
|
|
28
|
+
[{ username: 'b', isOnline: false }],
|
|
29
|
+
[],
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
expect(result.map(item => item.username)).toEqual(['a', 'b']);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('замену, которая уже на странице, не подставляет', () => {
|
|
36
|
+
const result = applyWebcamReplacements(
|
|
37
|
+
[model('a'), model('b')],
|
|
38
|
+
[{ username: 'b', isOnline: false }],
|
|
39
|
+
[{ username: 'b', model: model('a') }],
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
expect(result.map(item => item.username)).toEqual(['a', 'b']);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('две замены подряд не дублируют друг друга', () => {
|
|
46
|
+
const result = applyWebcamReplacements(
|
|
47
|
+
[model('a'), model('b'), model('c')],
|
|
48
|
+
[{ username: 'a', isOnline: false }, { username: 'b', isOnline: false }],
|
|
49
|
+
[{ username: 'a', model: model('x') }, { username: 'b', model: model('x') }],
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
expect(result.map(item => item.username)).toEqual(['x', 'b', 'c']);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { IStripcashModel } from '../../types';
|
|
2
|
+
|
|
3
|
+
/** Статус одной модели из ручки проверки онлайна (наша форма, camelCase). */
|
|
4
|
+
export type WebcamAliveStatus = { username: string, isOnline: boolean };
|
|
5
|
+
|
|
6
|
+
/** Замена для ушедшей в офлайн модели. */
|
|
7
|
+
export type WebcamReplacement = { username: string, model: IStripcashModel };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Собирает новый список моделей: ушедшие в офлайн заменяются на присланных
|
|
11
|
+
* провайдером, остальные остаются на своих местах.
|
|
12
|
+
*
|
|
13
|
+
* Правила:
|
|
14
|
+
* - порядок и длина списка сохраняются: тумбы не переставляются, дырок не появляется;
|
|
15
|
+
* - если замены для офлайн-модели не пришло, модель остаётся — пустая ячейка хуже
|
|
16
|
+
* подмороженной тумбы, а на следующей проверке замена, скорее всего, будет;
|
|
17
|
+
* - замена, которая уже есть на странице, не применяется: иначе одна и та же модель
|
|
18
|
+
* встанет дважды (у блока и врезок список общий);
|
|
19
|
+
* - неизвестные статусы игнорируются, поэтому неполный ответ ручки безопасен.
|
|
20
|
+
*/
|
|
21
|
+
export function applyWebcamReplacements(
|
|
22
|
+
models: IStripcashModel[],
|
|
23
|
+
statuses: WebcamAliveStatus[],
|
|
24
|
+
replacements: WebcamReplacement[],
|
|
25
|
+
): IStripcashModel[] {
|
|
26
|
+
const offline = new Set(
|
|
27
|
+
statuses.filter(status => status && status.isOnline === false).map(status => status.username),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
if (!offline.size) return models;
|
|
31
|
+
|
|
32
|
+
const byUsername = new Map(
|
|
33
|
+
replacements.filter(item => item?.username && item.model?.username).map(item => [item.username, item.model]),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
/** Кто уже на странице: и оставшиеся, и уже подставленные замены. */
|
|
37
|
+
const present = new Set(models.map(model => model.username));
|
|
38
|
+
|
|
39
|
+
return models.map((model) => {
|
|
40
|
+
if (!offline.has(model.username)) return model;
|
|
41
|
+
|
|
42
|
+
const replacement = byUsername.get(model.username);
|
|
43
|
+
if (!replacement || present.has(replacement.username)) return model;
|
|
44
|
+
|
|
45
|
+
present.delete(model.username);
|
|
46
|
+
present.add(replacement.username);
|
|
47
|
+
|
|
48
|
+
return replacement;
|
|
49
|
+
});
|
|
50
|
+
}
|