mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
Merge remote-tracking branch 'origin/master' into claude/compassionate-carson-b1445d
# Conflicts: # apps/electron-backend-e2e/src/sidebar-width.e2e.ts # docs/architecture/iptvnator-ui-guidelines.md # libs/ui/components/src/lib/resizable/resizable.directive.spec.ts # libs/ui/components/src/lib/resizable/resizable.directive.ts
This commit is contained in:
commit
4ea9bdd19b
6 files changed
+161
-11
No files matched your search
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: fix
|
||||
area: import
|
||||
---
|
||||
|
||||
Adding a Stalker portal with an unlimited subscription no longer announces a bogus 1970 expiry date, and a portal that reports its expiry as a date shows that date instead of "Invalid Date".
|
||||
@@ -94,8 +94,9 @@ setups, so IPTVnator no longer guesses:
|
||||
that address, and if a provider migrates the portal, IPTVnator re-probes once on the
|
||||
first failure and repairs the stored connection.
|
||||
|
||||
On a full portal the profile also carries your subscription facts, and IPTVnator confirms
|
||||
the import with "Portal validated" and the expiry date. A portal that cannot be reached
|
||||
On a full portal the profile also carries your subscription facts. When they include an
|
||||
expiry date, IPTVnator confirms the import with "Portal validated" and that date; an account
|
||||
without a fixed expiry is added without that notice. A portal that cannot be reached
|
||||
during the import is still added when the address looks like a panel, with a notice that it
|
||||
was added without validation, so a temporarily offline provider does not block you; the next
|
||||
successful request completes the setup.
|
||||
|
||||
+50
@@ -449,6 +449,56 @@ describe('StalkerPortalImportComponent identity handling', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// Portals encode an unlimited account as -1, "0" or a zero date, and
|
||||
// the wire value is often a string despite the API's number type.
|
||||
it.each([-1, '0', '-1', '0000-00-00'])(
|
||||
'announces no expiry for the unlimited sentinel %p',
|
||||
async (expireDate) => {
|
||||
portalDiscovery.discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl:
|
||||
'https://portal.example.com/stalker_portal/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
token: 'token-1',
|
||||
accountInfo: { login: 'demo', expire_date: expireDate },
|
||||
});
|
||||
component.form.patchValue({
|
||||
title: 'Unlimited Portal',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/c',
|
||||
});
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
expect(snackBar.open).not.toHaveBeenCalled();
|
||||
expect(store.dispatch).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('announces a date-string expiry as that calendar day', async () => {
|
||||
const translate = TestBed.inject(TranslateService);
|
||||
portalDiscovery.discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl:
|
||||
'https://portal.example.com/stalker_portal/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
token: 'token-1',
|
||||
accountInfo: { login: 'demo', expire_date: '2030-01-15' },
|
||||
});
|
||||
component.form.patchValue({
|
||||
title: 'Dated Portal',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/c',
|
||||
});
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
expect(translate.instant).toHaveBeenCalledWith(
|
||||
'HOME.STALKER_PORTAL.VALIDATED_EXPIRES',
|
||||
{ date: new Date(2030, 0, 15).toLocaleDateString() }
|
||||
);
|
||||
});
|
||||
|
||||
it('drops a refusal once the user edits the form or retries', async () => {
|
||||
portalDiscovery.discover.mockResolvedValue({
|
||||
status: 'auth-rejected',
|
||||
|
||||
+8
-4
@@ -28,6 +28,7 @@ import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import {
|
||||
legacyTransformStalkerPortalUrl,
|
||||
normalizeStalkerPortalInputUrl,
|
||||
parseStalkerDate,
|
||||
STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS,
|
||||
StalkerPortalDiscoveryService,
|
||||
normalizeStalkerPortalIdentity,
|
||||
@@ -462,10 +463,13 @@ export class StalkerPortalImportComponent {
|
||||
};
|
||||
}
|
||||
|
||||
if (stalkerAccountInfo?.expireDate) {
|
||||
const expireDate = new Date(
|
||||
stalkerAccountInfo.expireDate * 1000
|
||||
);
|
||||
// The raw value may be a date string or an "unlimited"
|
||||
// sentinel (-1, "0"), so read it like every other consumer.
|
||||
const expiresAt = parseStalkerDate(
|
||||
stalkerAccountInfo?.expireDate
|
||||
);
|
||||
if (expiresAt) {
|
||||
const expireDate = new Date(expiresAt * 1000);
|
||||
this.snackBar.open(
|
||||
this.translate.instant(
|
||||
'HOME.STALKER_PORTAL.VALIDATED_EXPIRES',
|
||||
|
||||
@@ -757,6 +757,11 @@ function capOnly(term) {
|
||||
return Boolean(term.cap) && !offScale;
|
||||
}
|
||||
|
||||
/** What a finding reports (see `describeFinding`): one key, one message. */
|
||||
function findingKey({ file, line, name, value, cap, computed }) {
|
||||
return JSON.stringify([file, line, name, value, cap, computed]);
|
||||
}
|
||||
|
||||
/** A string literal in code, whole: its quote and its text. */
|
||||
const STRING_LITERAL = /^\s*(['"`])([\s\S]*)\1\s*$/;
|
||||
|
||||
@@ -940,6 +945,8 @@ export function scanWeights(file, written, modules = {}) {
|
||||
return {
|
||||
...running,
|
||||
findings: union(running.findings, after.findings),
|
||||
// A variable a weight reads is followed from either reading.
|
||||
references: union(running.references, after.references),
|
||||
deferred: union(running.deferred, after.deferred),
|
||||
// A call meets the families of either reading.
|
||||
includeCalls: new Map(
|
||||
@@ -2362,7 +2369,13 @@ export function findIndirectWeights(scans) {
|
||||
);
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
// A definition several references reach (two rules reading one Sass
|
||||
// variable, or both readings of a keyframe) is reported once.
|
||||
return [
|
||||
...new Map(
|
||||
findings.map((finding) => [findingKey(finding), finding])
|
||||
).values(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2448,13 +2461,11 @@ export function findWorkspaceWeights(sources) {
|
||||
...scans.flatMap((scan) => scan.findings),
|
||||
...findIndirectWeights(scans),
|
||||
];
|
||||
const keyOf = ({ file, line, name, value, cap, computed }) =>
|
||||
JSON.stringify([file, line, name, value, cap, computed]);
|
||||
const seen = new Set(all.filter((f) => !f.landed).map(keyOf));
|
||||
const seen = new Set(all.filter((f) => !f.landed).map(findingKey));
|
||||
const findings = [];
|
||||
for (const { landed, ...finding } of all) {
|
||||
if (landed) {
|
||||
const key = keyOf(finding);
|
||||
const key = findingKey(finding);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
}
|
||||
|
||||
@@ -197,6 +197,27 @@ test('follows variables a weight refers to, through chains and files', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('reports a definition once however many references reach it', () => {
|
||||
const mono = "'JetBrains Mono'";
|
||||
for (const [source, expected] of [
|
||||
['$w: 650; .a { font-weight: $w; } .b { font-weight: $w; }', '650'],
|
||||
[
|
||||
'$w: calc(400 + 100); .a { font-weight: $w; } .b { font-weight: $w; }',
|
||||
'calc(400 + 100)',
|
||||
],
|
||||
[
|
||||
`$w: calc(400 + 100); .a { font-family: ${mono}; font-weight: $w; } .b { font-weight: $w; }`,
|
||||
'calc(400 + 100)',
|
||||
],
|
||||
]) {
|
||||
assert.deepEqual(
|
||||
offScale('libs/a.scss', source),
|
||||
[`1 $w: ${expected}`],
|
||||
source
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('checks Angular style bindings and literal DOM writes', () => {
|
||||
const template = [
|
||||
'<p [style.font-weight]="active() ? 650 : 400">a</p>',
|
||||
@@ -4939,6 +4960,63 @@ test('reads a keyframe where a rule runs it, over its own', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('follows a weight variable in either reading of a keyframe that does not hold', () => {
|
||||
const mono = "'JetBrains Mono'";
|
||||
const roboto = '@keyframes k { to { font-family: Roboto; } }';
|
||||
const report = (body) =>
|
||||
findOffScaleWeights('libs/s11/g.scss', body).findings.map(
|
||||
({ name, value, cap }) =>
|
||||
`${name}: ${value}${cap ? ` cap ${cap}` : ''}`
|
||||
);
|
||||
for (const [source, expected] of [
|
||||
// Mono once it has run: the variable meets the cap where it is set.
|
||||
[
|
||||
`$w: 700; ${roboto} .x { font-family: ${mono}; animation: k 1ms; font-weight: $w; }`,
|
||||
['$w: 700 cap 500'],
|
||||
],
|
||||
[
|
||||
`:root { --w: 700; } ${roboto} .x { font-family: ${mono}; animation: k 1ms; font-weight: var(--w); }`,
|
||||
['--w: 700 cap 500'],
|
||||
],
|
||||
[
|
||||
`$w: 700; ${roboto} .x { animation: k 1ms; font: $w 12px/1 ${mono}; }`,
|
||||
['$w: 700 cap 500'],
|
||||
],
|
||||
// A computed weight is one finding, whichever reading reaches it.
|
||||
[
|
||||
`$w: calc(400 + 100); ${roboto} .x { font-family: ${mono}; animation: k 1ms; font-weight: $w; }`,
|
||||
['$w: calc(400 + 100)'],
|
||||
],
|
||||
// Mono only while it runs.
|
||||
[
|
||||
`$w: 700; .x { font-family: Roboto; animation: k 1ms; font-weight: $w; } @keyframes k { to { font-family: ${mono}; } }`,
|
||||
['$w: 700 cap 500'],
|
||||
],
|
||||
// One that holds its frame keeps Roboto after it.
|
||||
[
|
||||
`$w: 700; ${roboto} .x { font-family: ${mono}; animation: k 1ms forwards; font-weight: $w; }`,
|
||||
[],
|
||||
],
|
||||
]) {
|
||||
assert.deepEqual(report(source), expected, source);
|
||||
}
|
||||
// And where another module's mixin runs it.
|
||||
for (const [run, expected] of [
|
||||
['k 1ms', ['libs/s11/c.scss:2 $w: 700']],
|
||||
['k 1ms forwards', []],
|
||||
]) {
|
||||
const face = `@mixin face { font-family: ${mono}; animation: ${run}; }`;
|
||||
assert.deepEqual(
|
||||
workspace({
|
||||
'libs/s11/_type.scss': `${roboto}\n${face}`,
|
||||
'libs/s11/c.scss': `@use 'type';\n$w: 700;\n.x { @include type.face; font-weight: $w; }`,
|
||||
}),
|
||||
expected,
|
||||
run
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('reads an `@at-root` rule where Sass writes it out', () => {
|
||||
const mono = "'JetBrains Mono'";
|
||||
const media = '@media (min-width: 1px)';
|
||||
|
||||
Reference in new issue
Block a user