mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
* perf(web): make the app root and settings components OnPush Plan item C6 step 3 for apps/web: the fifteen Eager components switch to OnPush, among them the app root and the update notification panel that the idle audit found re-rendering on every idle tick. Their template state is signals from the settings facades, signal inputs and the shared reactive settings form. The checklist flagged the backup import, which patches the form from a detached file input with no template event. A new spec patches only a value, which changes no form status, and confirms the OnPush general section still shows the new theme; it guards that path for the zoneless flag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): re-render OnPush sections when the form changes outside them Review follow-ups (Greptile, Codex): - The settings sections read form values in their templates (selected theme and cover size, epgField.value, form().value.player), and the parent changes the form outside their events: Discard and backup import patch it, the store hydrates it, the EPG file picker sets a control after an await. Under OnPush the section kept the old selection or EPG status. Each section now marks itself on its form's events (markSectionForCheckOnFormEvents). - The value-only patch test no longer forces detectChanges(); with the fixture rendering on its own it fails without the marking, and so does a new test for a control set outside the EPG section. - The zoneless guard counts only changeDetection metadata outside comments, so a comment naming the strategy is not an Eager component. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(settings): guard the unsaved-changes bar after a save off the sections Review follow-up (Codex): Save marks the form pristine after an async store write, also on Backup, Reset or search, where no form section is rendered. The OnPush page re-renders anyway because pristine and valid read the form's state signals; the new test checks that on the Backup page without forcing a render (it waits for the scheduled one). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
149 lines
5.2 KiB
TypeScript
149 lines
5.2 KiB
TypeScript
/* eslint-disable playwright/expect-expect -- These are Node assertion-based repository contract tests. */
|
|
import assert from 'node:assert/strict';
|
|
import { readdirSync, readFileSync } from 'node:fs';
|
|
import { join, relative, sep } from 'node:path';
|
|
import test from 'node:test';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
// docs/architecture/zoneless-migration.md lists every component that still
|
|
// opts out of OnPush. This keeps the checklist and the code in step: a new
|
|
// Eager component fails here, and so does a converted one left unticked.
|
|
|
|
const workspaceRoot = fileURLToPath(new URL('../../../../', import.meta.url));
|
|
const checklistPath = 'docs/architecture/zoneless-migration.md';
|
|
const sourceRoots = ['apps', 'libs'];
|
|
const skippedDirectories = new Set([
|
|
'node_modules',
|
|
'dist',
|
|
'coverage',
|
|
'test-stubs',
|
|
]);
|
|
// Test-only files follow the repository's `.spec` / `.test` naming, with an
|
|
// optional suffix of one or more segments (`.spec-stubs.ts`,
|
|
// `.test-helpers.ts`, `.test-data-stubs.ts`).
|
|
const testOnlyFile = /(\.(spec|test)(-\w+)*|^test-setup)\.ts$/;
|
|
|
|
function listProductionSources(directory: string): string[] {
|
|
const files: string[] = [];
|
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
const path = join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (skippedDirectories.has(entry.name)) continue;
|
|
if (entry.name.endsWith('-e2e')) continue;
|
|
files.push(...listProductionSources(path));
|
|
} else if (
|
|
entry.name.endsWith('.ts') &&
|
|
!entry.name.endsWith('.d.ts') &&
|
|
!testOnlyFile.test(entry.name)
|
|
) {
|
|
files.push(relative(workspaceRoot, path).split(sep).join('/'));
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
function readSources(): Map<string, string> {
|
|
const sources = new Map<string, string>();
|
|
for (const root of sourceRoots) {
|
|
for (const file of listProductionSources(join(workspaceRoot, root))) {
|
|
sources.set(file, readFileSync(join(workspaceRoot, file), 'utf8'));
|
|
}
|
|
}
|
|
return sources;
|
|
}
|
|
|
|
function readEagerChecklist(): { open: string[]; done: string[] } {
|
|
const markdown = readFileSync(join(workspaceRoot, checklistPath), 'utf8');
|
|
const section = markdown.split(/^## Eager components$/m)[1];
|
|
assert.ok(
|
|
section,
|
|
`${checklistPath} must have an "Eager components" section`
|
|
);
|
|
const body = section.split(/^## /m)[0];
|
|
const open: string[] = [];
|
|
const done: string[] = [];
|
|
for (const match of body.matchAll(/^- \[( |x)\] `([^`]+\.ts)`/gm)) {
|
|
(match[1] === 'x' ? done : open).push(match[2]);
|
|
}
|
|
return { open: open.sort(), done: done.sort() };
|
|
}
|
|
|
|
const sources = readSources();
|
|
|
|
// Component metadata only: a comment or string that names the strategy is
|
|
// not an Eager component.
|
|
const eagerMetadata = /changeDetection\s*:\s*ChangeDetectionStrategy\.Eager\b/;
|
|
|
|
function withoutComments(text: string): string {
|
|
return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
|
|
}
|
|
|
|
function isEagerComponent(text: string): boolean {
|
|
return eagerMetadata.test(withoutComments(text));
|
|
}
|
|
|
|
test('the zoneless checklist lists exactly the components that are still Eager', () => {
|
|
const eager = [...sources]
|
|
.filter(([, text]) => isEagerComponent(text))
|
|
.map(([file]) => file)
|
|
.sort();
|
|
const { open } = readEagerChecklist();
|
|
|
|
assert.deepEqual(
|
|
eager,
|
|
open,
|
|
`Production files with ChangeDetectionStrategy.Eager must match the unticked entries in ${checklistPath}. ` +
|
|
'Do not add Eager components; tick an entry when its component is converted.'
|
|
);
|
|
});
|
|
|
|
test('the guard skips test-only file names and keeps production ones', () => {
|
|
for (const name of [
|
|
'player.component.spec.ts',
|
|
'serial-details.test-stubs.ts',
|
|
'dashboard.spec-stubs.ts',
|
|
'rail.test-data-stubs.ts',
|
|
'test-setup.ts',
|
|
]) {
|
|
assert.ok(testOnlyFile.test(name), `${name} is test-only`);
|
|
}
|
|
for (const name of [
|
|
'player.component.ts',
|
|
'spec-utils.ts',
|
|
'contest.ts',
|
|
'latest-setup.ts',
|
|
'testing.service.ts',
|
|
]) {
|
|
assert.ok(!testOnlyFile.test(name), `${name} ships`);
|
|
}
|
|
});
|
|
|
|
test('a comment that names the Eager strategy is not an Eager component', () => {
|
|
assert.equal(
|
|
isEagerComponent(
|
|
'// was ChangeDetectionStrategy.Eager before C6\n' +
|
|
'/* changeDetection: ChangeDetectionStrategy.Eager */\n' +
|
|
'@Component({ changeDetection: ChangeDetectionStrategy.OnPush })'
|
|
),
|
|
false
|
|
);
|
|
assert.equal(
|
|
isEagerComponent(
|
|
'@Component({\n changeDetection: ChangeDetectionStrategy.Eager,\n})'
|
|
),
|
|
true
|
|
);
|
|
});
|
|
|
|
test('ticked checklist entries name files that exist', () => {
|
|
for (const file of readEagerChecklist().done) {
|
|
assert.ok(sources.has(file), `${file} is ticked but does not exist`);
|
|
}
|
|
});
|
|
|
|
test('no production component uses the deprecated Default strategy alias', () => {
|
|
for (const [file, text] of sources) {
|
|
assert.doesNotMatch(text, /ChangeDetectionStrategy\.Default\b/, file);
|
|
}
|
|
});
|