fix(sources): sort by the shown name; start the benchmark's Undo window in its trigger

Local review round 3 (Greptile; Codex was clean):

- Name order compared the stored title, while the rows show a URL-named
  or credential-bearing source by its host: a source stored as
  get.php?username=… sorted by its hidden account details. sortPlaylists
  takes the list's name function, and Sources and the switcher pass
  sourceSortName, the name they display.
- The Xtream delete benchmark clicked Remove while preparing, which starts
  the four-second Undo window; capture setup before the trigger could
  outlast it, so the delete committed on its own before the measurement
  and the close button was gone. Preparation now stops at the open menu,
  and the trigger clicks Remove and then the toast's close button, with
  the trigger time taken right before the commit click as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-13 18:15:09 +02:00
1 parent 123bc170e9
commit 85e6cb3816
10 files changed
+102 -10

No files matched your search

@@ -28,6 +28,7 @@ import {
PlaylistContextFacade,
PlaylistRecentHistoryService,
SourceBucketOf,
sourceSortName,
SourcesTypeFilter,
} from '@iptvnator/playlist/shared/util';
import { SortBy, SortService } from '@iptvnator/services';
@@ -138,7 +139,8 @@ export class PlaylistSwitcherPanelComponent {
const options = this.sortOptions();
const sorted = this.sortService.sortPlaylists(
this.playlists(),
options
options,
sourceSortName
);
return options.failingFirst && options.by !== SortBy.CUSTOM
? orderFailingFirst(sorted, this.bucketOf)
@@ -190,6 +190,22 @@ describe('RecentPlaylistsComponent', () => {
restarted.destroy();
});
it('sorts by the name it shows: the host for a URL-named source', () => {
store.overrideSelector(selectAllPlaylistsMeta, [
M3U,
XTREAM,
FILE,
source('url', {
title: 'get.php?username=a&password=b',
url: 'http://zzz.example/get.php?username=a&password=b',
}),
]);
store.refreshState();
sort.next({ by: SortBy.NAME, order: SortOrder.ASC });
fixture.detectChanges();
expect(ids()).toEqual(['file', 'm3u', 'xtream', 'url']);
});
it('applies the type and status filters together', () => {
viewState.setType('m3u');
expect(ids()).toEqual(['m3u', 'file']);
@@ -32,6 +32,7 @@ import {
orderFailingFirst,
PlaylistContextFacade,
SourceBucketOf,
sourceSortName,
SourcesViewStateService,
} from '@iptvnator/playlist/shared/util';
import {
@@ -128,7 +129,11 @@ export class RecentPlaylistsComponent {
this.searchQueryInput(),
bucketOf
);
const sorted = this.sortService.sortPlaylists(filtered, options);
const sorted = this.sortService.sortPlaylists(
filtered,
options,
sourceSortName
);
return options.failingFirst && options.by !== SortBy.CUSTOM
? orderFailingFirst(sorted, bucketOf)
: sorted;
@@ -155,7 +160,8 @@ export class RecentPlaylistsComponent {
const visibleIds = new Set(visible.map((item) => item._id));
const ordered = this.sortService.sortPlaylists(
this.allPlaylists(),
this.sortOptions()
this.sortOptions(),
sourceSortName
);
let shown = 0;
const merged = ordered.map((item) =>
@@ -1,6 +1,7 @@
import {
sourceCopyUrl,
sourceDisplayName,
sourceSortName,
sourceDisplayUrl,
sourceHost,
} from './source-display';
@@ -83,6 +84,15 @@ describe('sourceDisplayName', () => {
});
});
describe('sourceSortName', () => {
it('sorts a source by the name it shows', () => {
expect(sourceSortName({ title: 'get.php?x=1', url: M3U_URL })).toBe(
sourceDisplayName({ title: 'get.php?x=1', url: M3U_URL })
);
expect(sourceSortName({ title: '' })).toBe('');
});
});
describe('sourceDisplayUrl', () => {
it('shows an M3U URL without credentials', () => {
expect(sourceDisplayUrl({ url: M3U_URL })).toBe(
@@ -135,6 +135,11 @@ export function sourceDisplayName(
return containsSourceSecret(raw, playlist) ? host : raw;
}
/** The name a list shows and sorts a source by; empty when it has none. */
export function sourceSortName(playlist: SourceDisplayFields): string {
return sourceDisplayName(playlist) ?? '';
}
/** `origin/…/last` once a path has more than two segments. */
function compactPath(pathname: string): string {
const segments = pathname.split('/').filter(Boolean);
@@ -41,6 +41,31 @@ describe('SortService', () => {
expect(ids).toEqual(['refreshed', 'new', 'old']);
});
it('sorts by name as the list shows it, when told how', () => {
const sources = [
playlist('zed', { title: 'zed' }),
playlist('url', { title: 'get.php?username=zzz&password=x' }),
playlist('mid', { title: 'mid' }),
];
const service = create();
const byTitle = service
.sortPlaylists(sources, { by: SortBy.NAME, order: SortOrder.ASC })
.map((item) => item._id);
expect(byTitle).toEqual(['url', 'mid', 'zed']);
// The URL-named source shows as its host, "abc.test".
const shown = (item: PlaylistMeta) =>
item._id === 'url' ? 'abc.test' : item.title;
const byShownName = service
.sortPlaylists(
sources,
{ by: SortBy.NAME, order: SortOrder.DESC },
shown
)
.map((item) => item._id);
expect(byShownName).toEqual(['zed', 'mid', 'url']);
});
it('sorts by channel count, with no count as zero', () => {
const ids = create()
.sortPlaylists(
+10 -3
View File
@@ -103,9 +103,16 @@ export class SortService {
}
}
/**
* @param nameOf The name a list shows for a source, for Name order: a
* list that shows a source by its host must sort it by its host, not by
* a stored title such as `get.php?username=…`.
*/
sortPlaylists(
playlists: PlaylistMeta[],
options: SortOptions
options: SortOptions,
nameOf: (playlist: PlaylistMeta) => string = (playlist) =>
playlist.title
): PlaylistMeta[] {
const { by, order } = options;
@@ -113,9 +120,9 @@ export class SortService {
let comparison = 0;
if (by === SortBy.NAME) {
comparison = a.title
comparison = nameOf(a)
.toLowerCase()
.localeCompare(b.title.toLowerCase());
.localeCompare(nameOf(b).toLowerCase());
} else if (by === SortBy.DATE_ADDED) {
const dateA = new Date(a.importDate).getTime();
const dateB = new Date(b.importDate).getTime();