mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(xtream): scroll to the selected category (#1663)
This commit is contained in:
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',
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
-7
@@ -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;
|
||||
|
||||
+56
-1
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user