mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
fix(i18n): follow runtime language switches in stored and memoized labels (#1872)
* fix(i18n): follow runtime language switches in stored and memoized labels
A label translated once and kept kept its language after Settings ->
Language until the data reloaded or the app restarted. The Stalker store
baked `translate.instant('PORTALS.ALL_CATEGORIES' | 'PORTALS.ALL_RADIO')`
into its category list, so the every-item genre stayed English in the
rail, the live header, the fullscreen panel title, the catalog title and
the search scope.
Stalker: the every-item genre now carries `labelKey` and an empty
`category_name`; the category views render the key through the translate
pipe. `getSelectedCategoryName` becomes `getSelectedCategoryLabel`
({ name, labelKey }) and every text consumer goes through
`stalkerCategoryLabelText()` inside a computed that reads a language
signal, so no consumer can show the empty name or a stale translation.
Same class elsewhere (computed or stored `instant` text without a language
signal): the Xtream import overlay title and progress (shell-provided),
the Settings About version note (stored in a signal on the page where the
language changes), the remove-all-playlists progress, the context panel's
status/error text and category search, and the movie/series heroes
(`createVodDetailsHeroState`, `createSeriesHeroState`, the Xtream movie
presenter), cast & crew "Director", the M3U sidebar count, the M3U movie
hero and the collection panel title. These read
`toSignal(onLangChange.pipe(startWith(null)))` and also recover when the
translation file lands after the first render.
Documents the rule in the UI guidelines and the Stalker store contract.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(stalker): record getSelectedCategoryLabel in the store API baseline
getSelectedCategoryName is gone without an alias: a name-only selector is
empty for the every-item genre, whose label is a translation key.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(i18n): one translation tick that also follows late dictionaries
Greptile asked for coverage of a dictionary that lands after the first
render. That exposed a gap: every hand-written tick listened to
`onLangChange` only, but a start-up without a saved language never calls
`use()` - the default dictionary landing fires `onDefaultLangChange` alone,
so computeds that ran before it kept raw keys. Dictionary updates
(`onTranslationChange`) were missed the same way.
`injectTranslationTick()` in `@iptvnator/pipes` merges the three events the
translate pipe re-renders on (the embedded MPV player already did). All 37
ticks use it now, including the hero factories' `language` dependency. New
specs cover the delayed default-language load, a late `use()` dictionary
and a dictionary update; the hero specs add the start-up case.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(i18n): stub the Xtream selection the merged search scope reads
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(i18n): series view reads the shared translation tick
#1871 added an onLangChange-only tick for the synthetic episode titles; a
start-up dictionary that lands without use() would leave raw keys.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(i18n): move injectTranslationTick to @iptvnator/services
The tick injects TranslateService and subscribes, so it is injectable
runtime state; nx-workspace-boundaries.md reserves type:util for pure
helpers and contracts. @iptvnator/services is the shared type:data-access
project every consumer domain may depend on, and it imports none of them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
17a2b1b3b0
commit
e1d4f00d93
87 files changed
+1442
-341
No files matched your search
+9
-5
@@ -26,11 +26,15 @@
|
||||
(contextmenu)="onCategoryContextMenu(item, $event)"
|
||||
>
|
||||
<span class="nav-item-label">
|
||||
{{
|
||||
item.category_name ||
|
||||
$any(item).name ||
|
||||
('PORTALS.UNNAMED_CATEGORY' | translate)
|
||||
}}
|
||||
@if (item.labelKey) {
|
||||
{{ item.labelKey | translate }}
|
||||
} @else {
|
||||
{{
|
||||
item.category_name ||
|
||||
$any(item).name ||
|
||||
('PORTALS.UNNAMED_CATEGORY' | translate)
|
||||
}}
|
||||
}
|
||||
</span>
|
||||
@if (showCounts()) {
|
||||
@if (countDisplayMode() === 'loading') {
|
||||
|
||||
+5
@@ -17,6 +17,11 @@ export interface WorkspaceCategoryViewItem {
|
||||
readonly category_name?: string;
|
||||
readonly count?: number;
|
||||
readonly id?: string | number;
|
||||
/**
|
||||
* Translation key of an entry the app adds itself (a provider's "All"
|
||||
* genre); rendered through the pipe so a language switch re-labels it.
|
||||
*/
|
||||
readonly labelKey?: string;
|
||||
readonly name?: string;
|
||||
}
|
||||
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
StalkerItvCacheService,
|
||||
StalkerSessionService,
|
||||
withStalkerContent,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { DataService, ParentalLockService } from '@iptvnator/services';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { WorkspaceContextCategoryViewComponent } from './workspace-context-category-view.component';
|
||||
|
||||
type ContentType = 'vod' | 'series' | 'itv' | 'radio';
|
||||
|
||||
const PLAYLIST = {
|
||||
_id: 'playlist-1',
|
||||
title: 'Demo Stalker',
|
||||
count: 0,
|
||||
autoRefresh: false,
|
||||
importDate: '2026-04-14T00:00:00.000Z',
|
||||
portalUrl: 'http://demo.example/stalker_portal/server/load.php',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
isFullStalkerPortal: false,
|
||||
} as PlaylistMeta;
|
||||
|
||||
/** The real category loader, minus the selection features it reads. */
|
||||
const CategoryStore = signalStore(
|
||||
{ protectedState: false },
|
||||
withState({
|
||||
currentPlaylist: undefined as PlaylistMeta | undefined,
|
||||
selectedContentType: 'itv' as ContentType,
|
||||
selectedCategoryId: undefined as string | null | undefined,
|
||||
searchPhrase: '',
|
||||
page: 0,
|
||||
}),
|
||||
withMethods((store) => ({
|
||||
load(playlist: PlaylistMeta, contentType: ContentType) {
|
||||
patchState(store, {
|
||||
currentPlaylist: playlist,
|
||||
selectedContentType: contentType,
|
||||
});
|
||||
},
|
||||
})),
|
||||
withStalkerContent()
|
||||
);
|
||||
|
||||
@Component({
|
||||
imports: [WorkspaceContextCategoryViewComponent],
|
||||
template: `<app-workspace-context-category-view
|
||||
[items]="store.getCategoryResource()"
|
||||
/>`,
|
||||
})
|
||||
class CategoryRailHostComponent {
|
||||
readonly store = inject(CategoryStore);
|
||||
}
|
||||
|
||||
async function waitFor(predicate: () => boolean): Promise<void> {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
if (predicate()) {
|
||||
return;
|
||||
}
|
||||
TestBed.flushEffects();
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
}
|
||||
throw new Error('Timed out waiting for the categories to load');
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression: the Stalker store used to bake `translate.instant` of its
|
||||
* every-item genre into the loaded list, so a runtime language switch left
|
||||
* that row in the language the list loaded in until a reload or restart.
|
||||
*/
|
||||
describe('Stalker category rail across a runtime language switch', () => {
|
||||
let translate: TranslateService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot(), CategoryRailHostComponent],
|
||||
providers: [
|
||||
CategoryStore,
|
||||
{
|
||||
provide: DataService,
|
||||
useValue: {
|
||||
sendIpcEvent: jest.fn().mockResolvedValue({
|
||||
js: [{ id: '7', title: 'Drama' }],
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ParentalLockService,
|
||||
useValue: {
|
||||
active: () => false,
|
||||
version: signal(0),
|
||||
lockedStalkerIds: () => [],
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: StalkerItvCacheService,
|
||||
useValue: {
|
||||
versionFor: () => 0,
|
||||
getChannels: () => null,
|
||||
isReady: () => false,
|
||||
isLoading: () => false,
|
||||
isUnsupported: () => false,
|
||||
progressOf: () => null,
|
||||
ensureLoaded: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: StalkerSessionService,
|
||||
useValue: {
|
||||
makeAuthenticatedRequest: jest.fn(),
|
||||
ensureToken: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ token: null }),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
translate = TestBed.inject(TranslateService);
|
||||
translate.setTranslation('en', {
|
||||
PORTALS: {
|
||||
ALL_CATEGORIES: 'All categories',
|
||||
ALL_RADIO: 'All radio',
|
||||
},
|
||||
});
|
||||
translate.setTranslation('ru', {
|
||||
PORTALS: {
|
||||
ALL_CATEGORIES: 'Все категории',
|
||||
ALL_RADIO: 'Всё радио',
|
||||
},
|
||||
});
|
||||
translate.use('en');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['itv', 'All categories', 'Все категории'],
|
||||
['radio', 'All radio', 'Всё радио'],
|
||||
] as const)(
|
||||
're-labels the %s every-item genre loaded before the switch',
|
||||
async (contentType, english, russian) => {
|
||||
const fixture = TestBed.createComponent(CategoryRailHostComponent);
|
||||
const store = fixture.componentInstance.store;
|
||||
store.load(PLAYLIST, contentType);
|
||||
await waitFor(() => store.getCategoryResource().length === 2);
|
||||
fixture.detectChanges();
|
||||
const labels = () =>
|
||||
Array.from(
|
||||
fixture.nativeElement.querySelectorAll(
|
||||
'.nav-item-label'
|
||||
) as NodeListOf<HTMLElement>,
|
||||
(label) => label.textContent?.trim()
|
||||
);
|
||||
|
||||
expect(labels()).toEqual([english, 'Drama']);
|
||||
|
||||
translate.use('ru');
|
||||
fixture.detectChanges();
|
||||
|
||||
// The provider's own genre name is never translated.
|
||||
expect(labels()).toEqual([russian, 'Drama']);
|
||||
}
|
||||
);
|
||||
});
|
||||
+19
-2
@@ -31,6 +31,7 @@ const translations: Record<string, string> = {
|
||||
'WORKSPACE.SORT_SERVER': 'Server sorting',
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING':
|
||||
'Fetching playlist data from source...',
|
||||
'PORTALS.ALL_CATEGORIES': 'All Categories',
|
||||
};
|
||||
|
||||
interface RouteSnapshotStub {
|
||||
@@ -103,7 +104,11 @@ describe('WorkspaceContextPanelComponent', () => {
|
||||
{ category_id: '1', category_name: 'News' },
|
||||
]),
|
||||
getCategoryResource: signal<
|
||||
Array<{ category_id: string; category_name: string }>
|
||||
Array<{
|
||||
category_id: string;
|
||||
category_name: string;
|
||||
labelKey?: string;
|
||||
}>
|
||||
>([]),
|
||||
selectedCategoryId: signal<string | null>(null),
|
||||
isCategoryResourceLoading: signal(false),
|
||||
@@ -442,8 +447,13 @@ describe('WorkspaceContextPanelComponent', () => {
|
||||
playlistId: 'stalker-1',
|
||||
});
|
||||
fixture.componentRef.setInput('section', 'series');
|
||||
// The store names its every-item entry by a key, not a name.
|
||||
stalkerStore.getCategoryResource.set([
|
||||
{ category_id: '*', category_name: 'All Categories' },
|
||||
{
|
||||
category_id: '*',
|
||||
category_name: '',
|
||||
labelKey: 'PORTALS.ALL_CATEGORIES',
|
||||
},
|
||||
{ category_id: 'z', category_name: 'Zulu' },
|
||||
{ category_id: 'a', category_name: 'Alpha' },
|
||||
{ category_id: 'm', category_name: 'Movies' },
|
||||
@@ -457,6 +467,13 @@ describe('WorkspaceContextPanelComponent', () => {
|
||||
'Movies',
|
||||
]);
|
||||
|
||||
// The filter matches the label on screen, translated key included.
|
||||
fixture.componentInstance.categorySearchTerm.set('all c');
|
||||
fixture.detectChanges();
|
||||
expect(getCategoryLabels(fixture)).toEqual(['All Categories']);
|
||||
fixture.componentInstance.categorySearchTerm.set('');
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentInstance.setCategorySortMode('name-asc');
|
||||
fixture.detectChanges();
|
||||
|
||||
|
||||
+19
-1
@@ -31,7 +31,10 @@ import {
|
||||
sortPortalCategoryItems,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { ParentalLockService } from '@iptvnator/services';
|
||||
import {
|
||||
ParentalLockService,
|
||||
injectTranslationTick,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
toParentalLockStalkerCategoryType,
|
||||
toParentalLockXtreamCategoryType,
|
||||
@@ -66,6 +69,7 @@ interface WorkspaceCategoryLike {
|
||||
readonly category_id?: string | number;
|
||||
readonly category_name?: string;
|
||||
readonly id?: string | number;
|
||||
readonly labelKey?: string;
|
||||
readonly name?: string;
|
||||
}
|
||||
|
||||
@@ -92,6 +96,12 @@ export class WorkspaceContextPanelComponent {
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly translate = inject(TranslateService);
|
||||
/**
|
||||
* Computeds below that call `translate.instant` (or filter by a
|
||||
* translated label) read this, so a runtime language switch re-runs them
|
||||
* instead of keeping the text of the language they first ran in.
|
||||
*/
|
||||
private readonly languageTick = injectTranslationTick();
|
||||
// Root-provided; optional keeps standalone unit tests light. Only relevant
|
||||
// when the panel renders as the phone drawer. Some selections here (e.g.
|
||||
// Stalker ITV/radio) update the store without navigating, so the drawer's
|
||||
@@ -244,6 +254,7 @@ export class WorkspaceContextPanelComponent {
|
||||
this.parentalLock.withholdsEverything()
|
||||
);
|
||||
readonly xtreamStatusText = computed(() => {
|
||||
this.languageTick();
|
||||
if (
|
||||
!this.isXtreamCategories() ||
|
||||
this.isXtreamCategoryLoading() ||
|
||||
@@ -277,6 +288,7 @@ export class WorkspaceContextPanelComponent {
|
||||
* refusal gets its own actionable text.
|
||||
*/
|
||||
readonly stalkerCategoryErrorDescription = computed(() => {
|
||||
this.languageTick();
|
||||
const portalError = asStalkerPortalError(
|
||||
this.isStalkerCategoryFailed()
|
||||
);
|
||||
@@ -368,6 +380,7 @@ export class WorkspaceContextPanelComponent {
|
||||
);
|
||||
|
||||
readonly filteredXtreamCategories = computed(() => {
|
||||
this.languageTick();
|
||||
const cats = this.xtreamCategories();
|
||||
const term = foldSearchText(this.categorySearchTerm().trim());
|
||||
const filtered = term
|
||||
@@ -385,6 +398,7 @@ export class WorkspaceContextPanelComponent {
|
||||
});
|
||||
|
||||
readonly filteredStalkerCategories = computed(() => {
|
||||
this.languageTick();
|
||||
const cats = this.stalkerCategories();
|
||||
const term = foldSearchText(this.categorySearchTerm().trim());
|
||||
const filtered = term
|
||||
@@ -778,7 +792,11 @@ export class WorkspaceContextPanelComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** The label the rail shows, so the search filters what is on screen. */
|
||||
private getCategoryLabel(category: WorkspaceCategoryLike): string {
|
||||
if (category.labelKey) {
|
||||
return this.translate.instant(category.labelKey);
|
||||
}
|
||||
return category.category_name ?? category.name ?? '';
|
||||
}
|
||||
|
||||
|
||||
+2
-6
@@ -1,9 +1,8 @@
|
||||
import { computed, inject, Injectable } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { Router } from '@angular/router';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { startWith } from 'rxjs';
|
||||
import { injectTranslationTick } from '@iptvnator/services';
|
||||
import {
|
||||
PlaylistInfoComponent,
|
||||
PlaylistRefreshActionService,
|
||||
@@ -41,10 +40,7 @@ export class WorkspaceShellHeaderService {
|
||||
);
|
||||
private readonly liveSidebar = inject(LiveLayoutSidebarStateService);
|
||||
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
private readonly languageTick = injectTranslationTick();
|
||||
|
||||
readonly playlistTitle = computed(() => {
|
||||
const playlist = this.routeState.activePlaylist();
|
||||
|
||||
+7
-7
@@ -5,18 +5,21 @@ import {
|
||||
Injectable,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { filter, startWith } from 'rxjs';
|
||||
import { filter } from 'rxjs';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import {
|
||||
buildPortalRailLinks,
|
||||
PortalRailLink,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import {
|
||||
injectTranslationTick,
|
||||
RuntimeCapabilitiesService,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
parseWorkspaceShellRoute,
|
||||
WorkspacePortalContext,
|
||||
@@ -37,10 +40,7 @@ export class WorkspaceShellRouteStateService {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
private readonly languageTick = injectTranslationTick();
|
||||
|
||||
readonly activePlaylist = this.playlistContext.activePlaylist;
|
||||
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
|
||||
|
||||
+7
-2
@@ -3,7 +3,7 @@ import { TestBed } from '@angular/core/testing';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { Subject, of } from 'rxjs';
|
||||
import { EMPTY, of, Subject } from 'rxjs';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
@@ -96,6 +96,8 @@ describe('WorkspaceShellSearchSyncService', () => {
|
||||
useValue: {
|
||||
instant: jest.fn((key: string) => key),
|
||||
onLangChange: of(null),
|
||||
onDefaultLangChange: EMPTY,
|
||||
onTranslationChange: EMPTY,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -119,7 +121,10 @@ describe('WorkspaceShellSearchSyncService', () => {
|
||||
provide: StalkerStore,
|
||||
useValue: {
|
||||
setSearchPhrase: jest.fn(),
|
||||
getSelectedCategoryName: signal(''),
|
||||
getSelectedCategoryLabel: signal({
|
||||
name: '',
|
||||
labelKey: null,
|
||||
}),
|
||||
itvFullListActive: signal(false),
|
||||
},
|
||||
},
|
||||
|
||||
+15
-10
@@ -1,11 +1,15 @@
|
||||
import { computed, inject, Injectable } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { startWith } from 'rxjs';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
StalkerStore,
|
||||
stalkerCategoryLabelText,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import {
|
||||
RuntimeCapabilitiesService,
|
||||
injectTranslationTick,
|
||||
} from '@iptvnator/services';
|
||||
import { WorkspaceSearchCapability } from '@iptvnator/workspace/shell/util';
|
||||
import { SettingsSearchService } from '@iptvnator/workspace/shell/util/settings-search';
|
||||
import {
|
||||
@@ -32,10 +36,7 @@ export class WorkspaceShellSearchService {
|
||||
private readonly searchSync = inject(WorkspaceShellSearchSyncService);
|
||||
private readonly settingsSearch = inject(SettingsSearchService);
|
||||
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
private readonly languageTick = injectTranslationTick();
|
||||
|
||||
readonly searchQuery = this.searchSync.searchQuery;
|
||||
readonly appliedSearchQuery = this.searchSync.appliedSearchQuery;
|
||||
@@ -160,8 +161,12 @@ export class WorkspaceShellSearchService {
|
||||
xtreamCategory: this.xtreamStore.selectedCategoryId()
|
||||
? this.xtreamStore.getSelectedCategory()
|
||||
: { name: this.translateText('PORTALS.ALL_ITEMS') },
|
||||
stalkerCategoryName:
|
||||
this.stalkerStore.getSelectedCategoryName(),
|
||||
// This computed reads the language tick, so the every-item
|
||||
// genre is translated in the current language.
|
||||
stalkerCategoryName: stalkerCategoryLabelText(
|
||||
this.stalkerStore.getSelectedCategoryLabel(),
|
||||
(key) => this.translateText(key)
|
||||
),
|
||||
}),
|
||||
statusLabel: isDegradedStalkerLive
|
||||
? this.translateText(SEARCH_LOADED_ONLY_STATUS)
|
||||
|
||||
+9
-2
@@ -2,7 +2,7 @@ import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { EMPTY, of } from 'rxjs';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
@@ -63,13 +63,20 @@ describe('WorkspaceShellSearchService on settings routes', () => {
|
||||
},
|
||||
{
|
||||
provide: StalkerStore,
|
||||
useValue: { getSelectedCategoryName: () => '' },
|
||||
useValue: {
|
||||
getSelectedCategoryLabel: () => ({
|
||||
name: '',
|
||||
labelKey: null,
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => key,
|
||||
onLangChange: of(null),
|
||||
onDefaultLangChange: EMPTY,
|
||||
onTranslationChange: EMPTY,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
type StalkerCategoryLabel,
|
||||
StalkerStore,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { parseWorkspaceShellRoute } from '@iptvnator/workspace/shell/util';
|
||||
import { SettingsSearchService } from '@iptvnator/workspace/shell/util/settings-search';
|
||||
import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service';
|
||||
import { WorkspaceShellSearchSyncService } from './workspace-shell-search-sync.service';
|
||||
import { WorkspaceShellSearchService } from './workspace-shell-search.service';
|
||||
|
||||
describe('WorkspaceShellSearchService Stalker search scope', () => {
|
||||
let service: WorkspaceShellSearchService;
|
||||
let translate: TranslateService;
|
||||
const categoryLabel = signal<StalkerCategoryLabel>({
|
||||
name: '',
|
||||
labelKey: null,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
const route = parseWorkspaceShellRoute('/workspace/stalker/pl-1/vod');
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot()],
|
||||
providers: [
|
||||
WorkspaceShellSearchService,
|
||||
{ provide: Router, useValue: { navigate: jest.fn() } },
|
||||
{
|
||||
provide: XtreamStore,
|
||||
useValue: {
|
||||
getSelectedCategory: () => null,
|
||||
selectedCategoryId: () => null,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: StalkerStore,
|
||||
useValue: {
|
||||
getSelectedCategoryLabel: categoryLabel,
|
||||
itvFullListActive: signal(false),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: { isElectron: true },
|
||||
},
|
||||
{
|
||||
provide: WorkspaceShellRouteStateService,
|
||||
useValue: {
|
||||
currentRoute: signal(route),
|
||||
playlists: signal([]),
|
||||
dashboardXtreamContext: signal(null),
|
||||
currentContext: signal(route.context),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceShellSearchSyncService,
|
||||
useValue: {
|
||||
searchQuery: signal(''),
|
||||
appliedSearchQuery: signal(''),
|
||||
},
|
||||
},
|
||||
{ provide: SettingsSearchService, useValue: {} },
|
||||
],
|
||||
});
|
||||
translate = TestBed.inject(TranslateService);
|
||||
translate.setTranslation('en', {
|
||||
PORTALS: { ALL_CATEGORIES: 'All categories' },
|
||||
WORKSPACE: { SHELL: { RAIL_MOVIES: 'Movies' } },
|
||||
});
|
||||
translate.setTranslation('ru', {
|
||||
PORTALS: { ALL_CATEGORIES: 'Все категории' },
|
||||
WORKSPACE: { SHELL: { RAIL_MOVIES: 'Фильмы' } },
|
||||
});
|
||||
translate.use('en');
|
||||
service = TestBed.inject(WorkspaceShellSearchService);
|
||||
});
|
||||
|
||||
it('re-words the every-item genre in the scope after a language switch', () => {
|
||||
categoryLabel.set({ name: '', labelKey: 'PORTALS.ALL_CATEGORIES' });
|
||||
|
||||
expect(service.searchScopeLabel()).toBe('Movies / All categories');
|
||||
|
||||
translate.use('ru');
|
||||
|
||||
expect(service.searchScopeLabel()).toBe('Фильмы / Все категории');
|
||||
});
|
||||
|
||||
it('keeps a portal genre name as the portal sent it', () => {
|
||||
categoryLabel.set({ name: 'Drama', labelKey: null });
|
||||
|
||||
translate.use('ru');
|
||||
|
||||
expect(service.searchScopeLabel()).toBe('Фильмы / Drama');
|
||||
});
|
||||
});
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistRefreshActionService } from '@iptvnator/playlist/shared/ui';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service';
|
||||
import { WorkspaceShellXtreamImportService } from './workspace-shell-xtream-import.service';
|
||||
|
||||
describe('WorkspaceShellXtreamImportService', () => {
|
||||
it('re-words the import overlay after a runtime language switch', () => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot()],
|
||||
providers: [
|
||||
WorkspaceShellXtreamImportService,
|
||||
{
|
||||
provide: XtreamStore,
|
||||
useValue: {
|
||||
getImportCount: signal(0),
|
||||
itemsToImport: signal(0),
|
||||
activeImportContentType: signal(null),
|
||||
activeImportCurrentCount: signal(0),
|
||||
activeImportTotalCount: signal(0),
|
||||
currentImportPhase: signal(null),
|
||||
isCancellingImport: signal(false),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlaylistRefreshActionService,
|
||||
useValue: { refreshPreparation: signal(null) },
|
||||
},
|
||||
{ provide: RuntimeCapabilitiesService, useValue: {} },
|
||||
{ provide: WorkspaceShellRouteStateService, useValue: {} },
|
||||
],
|
||||
});
|
||||
const translate = TestBed.inject(TranslateService);
|
||||
translate.setTranslation('en', {
|
||||
WORKSPACE: { SHELL: { XTREAM_IMPORT_TITLE: 'Importing' } },
|
||||
});
|
||||
translate.setTranslation('ru', {
|
||||
WORKSPACE: { SHELL: { XTREAM_IMPORT_TITLE: 'Импорт' } },
|
||||
});
|
||||
translate.use('en');
|
||||
// The shell provides this service, so it outlives the Settings
|
||||
// visit where the language is switched.
|
||||
const service = TestBed.inject(WorkspaceShellXtreamImportService);
|
||||
|
||||
expect(service.xtreamImportTitleLabel()).toBe('Importing');
|
||||
|
||||
translate.use('ru');
|
||||
|
||||
expect(service.xtreamImportTitleLabel()).toBe('Импорт');
|
||||
});
|
||||
});
|
||||
+7
-7
@@ -1,10 +1,11 @@
|
||||
import { computed, inject, Injectable } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { startWith } from 'rxjs';
|
||||
import { PlaylistRefreshActionService } from '@iptvnator/playlist/shared/ui';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import {
|
||||
RuntimeCapabilitiesService,
|
||||
injectTranslationTick,
|
||||
} from '@iptvnator/services';
|
||||
import type { XtreamImportPhaseTone } from './helpers/workspace-shell-constants';
|
||||
import {
|
||||
buildXtreamImportDetailLabel,
|
||||
@@ -29,10 +30,7 @@ export class WorkspaceShellXtreamImportService {
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly routeState = inject(WorkspaceShellRouteStateService);
|
||||
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
private readonly languageTick = injectTranslationTick();
|
||||
|
||||
isSourceBusy(playlistId: string): boolean {
|
||||
return (
|
||||
@@ -106,6 +104,7 @@ export class WorkspaceShellXtreamImportService {
|
||||
});
|
||||
|
||||
readonly xtreamImportTitleLabel = computed(() => {
|
||||
this.languageTick();
|
||||
if (this.activeRefreshPreparation()) {
|
||||
return this.translateText('WORKSPACE.SHELL.XTREAM_REFRESH_TITLE');
|
||||
}
|
||||
@@ -122,6 +121,7 @@ export class WorkspaceShellXtreamImportService {
|
||||
});
|
||||
|
||||
readonly xtreamImportProgressLabel = computed(() => {
|
||||
this.languageTick();
|
||||
const formatNumber = (value: number): string =>
|
||||
formatLocalizedNumber(
|
||||
value,
|
||||
|
||||
+4
-1
@@ -72,7 +72,10 @@ class MockXtreamStore {
|
||||
|
||||
class MockStalkerStore {
|
||||
readonly searchPhrase = signal('');
|
||||
readonly getSelectedCategoryName = signal('All Items');
|
||||
readonly getSelectedCategoryLabel = signal({
|
||||
name: 'All Items',
|
||||
labelKey: null,
|
||||
});
|
||||
readonly itvFullListActive = signal(false);
|
||||
|
||||
setSearchPhrase = jest.fn((term: string) => this.searchPhrase.set(term));
|
||||
|
||||
+2
-6
@@ -1,8 +1,6 @@
|
||||
import { computed, DestroyRef, inject, Injectable } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { startWith } from 'rxjs';
|
||||
import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access';
|
||||
import {
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
@@ -14,6 +12,7 @@ import {
|
||||
ParentalLockService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
injectTranslationTick,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
CommandBuilderActions,
|
||||
@@ -48,10 +47,7 @@ export class WorkspaceShellFacade {
|
||||
readonly headerContext = inject(WorkspaceHeaderContextService);
|
||||
private readonly backNavigation = inject(WorkspaceBackNavigationService);
|
||||
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
private readonly languageTick = injectTranslationTick();
|
||||
// Root-provided; optional keeps standalone unit tests light. While the phone context drawer is modal, opening the command
|
||||
// palette over it would stack two competing focus-trapped surfaces.
|
||||
private readonly contextDrawer = inject(
|
||||
|
||||
+3
-5
@@ -8,6 +8,7 @@ import {
|
||||
import {
|
||||
RuntimeCapabilitiesService,
|
||||
SourceActivityService,
|
||||
injectTranslationTick,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
@@ -37,7 +38,7 @@ import {
|
||||
selectActiveTypeFilters,
|
||||
selectAllPlaylistsMeta,
|
||||
} from '@iptvnator/m3u-state';
|
||||
import { map, startWith } from 'rxjs';
|
||||
import { map } from 'rxjs';
|
||||
import { SortBy, SortOrder, SortService } from '@iptvnator/services';
|
||||
import {
|
||||
WORKSPACE_SHELL_ACTIONS,
|
||||
@@ -126,10 +127,7 @@ export class WorkspaceSourcesComponent {
|
||||
private readonly playlists = this.store.selectSignal(
|
||||
selectAllPlaylistsMeta
|
||||
);
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
private readonly languageTick = injectTranslationTick();
|
||||
|
||||
readonly sortOptions: SortOption[] = [
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user