fix(xtream): scroll to the selected category (#1663)

This commit is contained in:
Kate Markson authored and GitHub committed 2026-09-26 21:59:09 +02:00
1 parent e39c854a41
commit e619a2be86
7 files changed
+224 -8

No files matched your search

@@ -0,0 +1,6 @@
---
type: fix
area: xtream
---
Selecting an Xtream category no longer scrolls the category panel to an unrelated category, including when categories are hidden or sorted alphabetically.
@@ -1,4 +1,6 @@
import { Locator, Page } from '@playwright/test';
import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces';
import { ok as assert } from 'node:assert';
import {
addXtreamPortal,
closeElectronApp,
@@ -14,8 +16,96 @@ import {
waitForSourceRowIdle,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import { applyTheme } from './theme-contrast';
test.describe('Electron Xtream Category Management', () => {
test('keeps the selected category in view with 800 categories, 600 hidden and A-Z sorting', async ({
dataDir,
request,
}) => {
test.slow();
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
username: 'category-scroll',
password: 'category-scroll',
});
await openWorkspaceSection(app.mainWindow, 'Live TV');
await waitForXtreamWorkspaceReady(app.mainWindow);
const panel = app.mainWindow.locator('app-workspace-context-panel');
const rows = panel.locator('.category-item');
await expect(rows).toHaveCount(800);
const dialog = await openManageCategoriesDialog(app.mainWindow);
await dialog
.getByRole('button', { name: 'Deselect All', exact: true })
.click();
await dialog.locator('input[type="search"]').fill('Visible');
await dialog
.getByRole('button', { name: 'Select Filtered', exact: true })
.click();
await expect(dialog.locator('.selection-info')).toHaveText(
'Total selected: 200 / 800'
);
await dialog
.getByRole('button', { name: 'Save', exact: true })
.click();
await expect(dialog).toBeHidden();
await expect(rows).toHaveCount(200);
await panel
.getByRole('button', { name: 'Sort categories', exact: true })
.click();
await app.mainWindow
.getByRole('menuitem', { name: 'Name A-Z' })
.click();
const categories = await app.mainWindow.evaluate(async () => {
const playlistId = location.pathname.match(
/\/workspace\/xtreams\/([^/]+)/
)?.[1];
if (!playlistId)
throw new Error('Xtream playlist route is missing');
const api = (
window as unknown as { electron: ElectronBridgeApi }
).electron;
return api.dbGetCategories(playlistId, 'live');
});
categories.sort((left, right) =>
left.name.localeCompare(right.name)
);
await expect(rows.locator('.nav-item-label')).toHaveText(
categories.map((category) => category.name)
);
// Exercise collisions above and below the clicked row. Reading the
// imported IDs avoids relying on SQLite allocation/import order.
for (const [theme, direction] of [
['dark', -1],
['light', 1],
] as const) {
await applyTheme(app.mainWindow, theme);
const category = categories.find((candidate, index) => {
const wrongIndex = categories.findIndex(
(other) => other.xtream_id === candidate.id
);
return (
wrongIndex >= 0 && (wrongIndex - index) * direction > 15
);
});
assert(
category,
`Missing fixture collision in direction ${direction}`
);
const row = rows.filter({ hasText: category.name });
await row.click();
await expect(row).toHaveAttribute('aria-current', 'true');
await expectCategoryCentered(row);
}
} finally {
await closeElectronApp(app);
}
});
for (const section of ['Live TV', 'Movies', 'Series']) {
test(`bulk edits only filtered ${section} categories and saves or discards the draft`, async ({
dataDir,
@@ -407,6 +497,44 @@ async function openManageCategoriesDialog(page: Page) {
return dialog;
}
/** Wait for the real smooth scroll to finish with the selected row centered. */
async function expectCategoryCentered(row: Locator): Promise<void> {
let previousTop = -1;
let stableSamples = 0;
await expect
.poll(
async () => {
const position = await row.evaluate((element) => {
const container = element.closest(
'app-workspace-context-category-view'
) as HTMLElement;
const bounds = container.getBoundingClientRect();
const rowBounds = element.getBoundingClientRect();
const target =
container.scrollTop +
rowBounds.top -
bounds.top -
container.clientHeight / 2 +
rowBounds.height / 2;
const clamped = Math.min(
container.scrollHeight - container.clientHeight,
Math.max(0, target)
);
return {
top: container.scrollTop,
centered: Math.abs(container.scrollTop - clamped) < 2,
};
});
stableSamples =
position.top === previousTop ? stableSamples + 1 : 0;
previousTop = position.top;
return position.centered && stableSamples >= 3;
},
{ intervals: [100] }
)
.toBe(true);
}
async function refreshFromWorkspaceHeader(page: Page): Promise<void> {
await page
.getByRole('button', { name: 'Refresh playlist', exact: true })
@@ -84,6 +84,15 @@ function generatePortalData(username: string, password: string): PortalData {
};
}
let liveCategories = generateCategories('live', categoryCount.live);
if (scenario.categoryFixture === 'scroll') {
liveCategories = liveCategories.map((category, index) => ({
...category,
// Coprime step permutes names independently of provider/SQLite IDs.
category_name: `${index % 4 === 0 ? 'Visible' : 'Hidden'} ${String(
(index * 137) % categoryCount.live
).padStart(3, '0')}`,
}));
}
const vodCategories = generateCategories('vod', categoryCount.vod);
const seriesCategories = generateCategories('series', categoryCount.series);
const epgListingsByStreamId = new Map<number, RawEpgListing[]>();
@@ -13,6 +13,8 @@ export interface ScenarioConfig {
expiryDate: string;
/** Optional deterministic EPG fixture profile for scenario-specific tests. */
epgFixture?: 'timezone-focus';
/** Large, deliberately reordered categories for sidebar scroll coverage. */
categoryFixture?: 'scroll';
/**
* Optional `server_info` clock override. `timezone` is reported
* verbatim (real panels sometimes send spellings such as `UTC+3` that
@@ -54,6 +56,18 @@ export interface ScenarioConfig {
* Unknown credential pairs use a hash of "username:password" as seed.
*/
export const SCENARIOS: Record<string, ScenarioConfig> = {
'category-scroll:category-scroll': {
name: 'category-scroll',
description: '800 live categories, including 200 marked Visible',
seed: 800,
categoryCount: { live: 800, vod: 0, series: 0 },
itemsPerCategory: 1,
seasonsPerSeries: 1,
episodesPerSeason: 1,
accountStatus: 'Active',
expiryDate: '2099-12-31',
categoryFixture: 'scroll',
},
'live-fallback:live-fallback': {
name: 'live-format-fallback',
description: 'Local HLS failures and playable TS',
+5
View File
@@ -87,6 +87,11 @@ ALTER TABLE categories ADD COLUMN hidden INTEGER DEFAULT 0
- **No content deletion**: Hiding a category only affects sidebar visibility; the category and its content remain in the database
- **Display order**: The sidebar defaults to server order. Users can switch the
category panel to `A-Z` or `Z-A` from the sort menu next to category search.
- **Selection scrolling**: The panel centers the rendered selected row after
selection changes. Electron selects by local SQLite category ID; the row's
`data-category-id` can contain its provider ID. Those IDs are not
interchangeable when locating the scroll target, including after filtering
hidden categories or sorting.
- **All-hidden recovery**: Once the selected Xtream type is loaded, the manage
categories button remains available even if every visible category has been
hidden. The sidebar category list is filtered, but the dialog reads all
@@ -61,7 +61,7 @@ export class WorkspaceContextCategoryViewComponent {
}
}
private readonly hostEl = inject(ElementRef<HTMLElement>);
private readonly hostEl = inject<ElementRef<HTMLElement>>(ElementRef);
readonly categoryClicked = output<WorkspaceCategoryViewItem>();
@@ -74,12 +74,11 @@ export class WorkspaceContextCategoryViewComponent {
queueMicrotask(() => {
const container = this.hostEl.nativeElement;
const candidates = Array.from(
container.querySelectorAll('[data-category-id]')
) as HTMLElement[];
const selected = candidates.find(
(el) =>
el.dataset['categoryId'] === String(selectedCategory)
// Follow the rendered selection: Electron selects by SQLite
// ID, while data-category-id can contain a provider ID that
// coincides with a different row's SQLite ID.
const selected = container.querySelector<HTMLElement>(
'.category-item[aria-current="true"]'
);
if (!selected) {
return;
@@ -62,7 +62,9 @@ function getCategoryLabels(
describe('WorkspaceContextPanelComponent', () => {
let fixture: ComponentFixture<WorkspaceContextPanelComponent>;
const xtreamCategories = signal([
const xtreamCategories = signal<
Array<{ id: number; name: string; xtream_id?: number }>
>([
{ id: 1, name: 'News' },
{ id: 2, name: 'Sports' },
]);
@@ -323,6 +325,59 @@ describe('WorkspaceContextPanelComponent', () => {
);
});
it.each([
{ id: 7, name: 'Alpha', top: 520 },
{ id: 8, name: 'Zulu', top: 2420 },
])(
'scrolls to the selected local category $name when provider IDs collide after sorting',
async ({ id, name, top }) => {
fixture.componentRef.setInput('section', 'live');
xtreamSelectedTypeContentState.set('ready');
// SQLite IDs drive selection; provider IDs can belong to another row.
xtreamCategories.set([
{ id: 8, xtream_id: 7, name: 'Zulu' },
{ id: 50, xtream_id: 500, name: 'Middle' },
{ id: 7, xtream_id: 8, name: 'Alpha' },
]);
fixture.componentInstance.setCategorySortMode('name-asc');
fixture.detectChanges();
const container = fixture.nativeElement.querySelector(
'app-workspace-context-category-view'
) as HTMLElement;
Object.defineProperties(container, {
clientHeight: { value: 400 },
scrollHeight: { value: 3000 },
});
container.scrollTop = 500;
container.getBoundingClientRect = () =>
new DOMRect(0, 100, 200, 400);
container.scrollTo = jest.fn();
const rows = Array.from(
container.querySelectorAll<HTMLButtonElement>('.category-item')
);
rows.forEach((row, index) => {
row.getBoundingClientRect = () =>
new DOMRect(0, [300, 800, 2200][index], 200, 40);
});
const selected = rows.find((row) =>
row.textContent?.includes(name)
);
if (!selected) throw new Error(`Missing category ${name}`);
selected.click();
expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(id);
xtreamSelectedCategoryId.set(id);
fixture.detectChanges();
await fixture.whenStable();
expect(selected.getAttribute('aria-current')).toBe('true');
expect(container.scrollTo).toHaveBeenCalledWith({
behavior: 'smooth',
top,
});
}
);
it('uses translated category sort labels and distinct mode icons', () => {
fixture.componentRef.setInput('section', 'vod');
xtreamSelectedTypeContentState.set('ready');