test(desktop): unit tests for bannerFor + suppress redundant banner in lockout
Phase 4 polish: GraceBanner was rendering for state=locked/tampered even though LockoutOverlay covers the screen for those states. The banner leaked through the overlay's 96%-opaque background as a faint red strip - confusing. - GraceBanner.tsx: bannerFor() returns null for locked/tampered (LockoutOverlay handles the messaging). Also exported bannerFor so the new unit tests can exercise the pure decision logic. - __tests__/GraceBanner.test.ts (new): 13 vitest cases covering pre-expiry amber/red thresholds (trial_ends_at vs subscription_period_end), fallback to claims.exp for legacy JWTs, soft/hard grace messaging, dismissibility rules. - vitest.config.ts: include also matches src/**/__tests__/ so the desktop tests are discovered without moving them. Runtime ~600ms vs 5-25 min for a full installer rebuild - lets future iterations on the banner state machine skip the build cycle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6f63fceb20
commit
161335a5cf
|
|
@ -61,16 +61,32 @@ type BannerInfo = {
|
|||
dismissible: boolean;
|
||||
};
|
||||
|
||||
function bannerFor(status: LicenseStatus, now: number): BannerInfo | null {
|
||||
// Exported for unit testing in __tests__/GraceBanner.test.ts.
|
||||
// Pure function: deterministic output for given (status, now) - easy
|
||||
// to assert against without rendering React.
|
||||
export function bannerFor(status: LicenseStatus, now: number): BannerInfo | null {
|
||||
if (status.state === 'active') {
|
||||
const secondsUntilExp = status.claims.exp - Math.floor(now / 1000);
|
||||
if (secondsUntilExp <= 0) return null; // shell hasn't updated state yet, ignore
|
||||
// Pre-expiry warnings target the REAL expiry of the
|
||||
// entitlement (trial_ends_at for trials, subscription_period_end
|
||||
// for paid), NOT claims.exp - that one is the JWT cache window
|
||||
// (typically 7d) which gets refreshed every 6h by the checkin
|
||||
// loop, so it would never trigger the "5d / 24h" thresholds
|
||||
// under normal online use. Fall back to claims.exp only when
|
||||
// neither real-expiry field is present.
|
||||
const isTrial = status.claims.plan === 'trial';
|
||||
const realExp = isTrial
|
||||
? status.claims.trial_ends_at
|
||||
: status.claims.subscription_period_end;
|
||||
const effectiveExp = realExp ?? status.claims.exp;
|
||||
const secondsUntilExp = effectiveExp - Math.floor(now / 1000);
|
||||
if (secondsUntilExp <= 0) return null; // shell hasn't transitioned state yet, ignore
|
||||
const hoursUntilExp = secondsUntilExp / 3600;
|
||||
const subject = isTrial ? 'free trial' : 'Velxio Pro subscription';
|
||||
if (hoursUntilExp <= 24) {
|
||||
const h = Math.max(1, Math.round(hoursUntilExp));
|
||||
return {
|
||||
tone: 'red',
|
||||
message: `Your Velxio Pro subscription expires in ${h}h. Renew now to avoid interruption.`,
|
||||
message: `Your ${subject} expires in ${h}h. ${isTrial ? 'Upgrade' : 'Renew'} now to avoid interruption.`,
|
||||
dismissible: false,
|
||||
};
|
||||
}
|
||||
|
|
@ -78,7 +94,7 @@ function bannerFor(status: LicenseStatus, now: number): BannerInfo | null {
|
|||
const d = Math.max(1, Math.round(hoursUntilExp / 24));
|
||||
return {
|
||||
tone: 'amber',
|
||||
message: `Your Velxio Pro subscription expires in ${d} day${d === 1 ? '' : 's'}. Renew to avoid interruption.`,
|
||||
message: `Your ${subject} expires in ${d} day${d === 1 ? '' : 's'}. ${isTrial ? 'Upgrade' : 'Renew'} to avoid interruption.`,
|
||||
dismissible: true,
|
||||
};
|
||||
}
|
||||
|
|
@ -100,19 +116,14 @@ function bannerFor(status: LicenseStatus, now: number): BannerInfo | null {
|
|||
dismissible: false,
|
||||
};
|
||||
}
|
||||
if (status.state === 'locked') {
|
||||
return {
|
||||
tone: 'red',
|
||||
message: 'Your Velxio Desktop license has expired offline. Reconnect to continue using the editor.',
|
||||
dismissible: false,
|
||||
};
|
||||
}
|
||||
if (status.state === 'tampered') {
|
||||
return {
|
||||
tone: 'red',
|
||||
message: 'Velxio could not verify the stored license. Sign out and sign in again.',
|
||||
dismissible: false,
|
||||
};
|
||||
// Locked + Tampered are covered by the full-screen LockoutOverlay
|
||||
// (z-index 10001) that index.ts mounts on `velxio://license-required`.
|
||||
// Returning a banner here would render behind the overlay and bleed
|
||||
// through its 96%-opaque background - users see a confusing red
|
||||
// strip behind the modal. The overlay's own copy already explains
|
||||
// the state; banner is redundant.
|
||||
if (status.state === 'locked' || status.state === 'tampered') {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,239 @@
|
|||
/**
|
||||
* Vitest for GraceBanner.bannerFor — pure function that picks the
|
||||
* right banner tone based on license status + current time.
|
||||
*
|
||||
* Run from velxio/frontend:
|
||||
* npx vitest run src/desktop/__tests__/GraceBanner.test.ts
|
||||
*
|
||||
* Or via the umbrella script:
|
||||
* E:\Hardware\velxio-prod\pro\desktop\testeo\run-tests.bat
|
||||
*
|
||||
* These tests are pure logic (no DOM, no React) so they finish in
|
||||
* milliseconds. Render-level assertions for the actual component
|
||||
* are covered by manual smoke-tests after a real install.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { bannerFor } from '../GraceBanner';
|
||||
|
||||
type Claims = {
|
||||
sub: string;
|
||||
plan: string;
|
||||
ent: Record<string, boolean>;
|
||||
iat: number;
|
||||
exp: number;
|
||||
trial_ends_at?: number | null;
|
||||
subscription_period_end?: number | null;
|
||||
hard_grace_hours?: number;
|
||||
};
|
||||
|
||||
function claimsTrial(overrides: Partial<Claims> = {}): Claims {
|
||||
return {
|
||||
sub: 'vlx_trial_test',
|
||||
plan: 'trial',
|
||||
ent: { desktop: true },
|
||||
iat: 0,
|
||||
exp: 0,
|
||||
trial_ends_at: null,
|
||||
subscription_period_end: null,
|
||||
hard_grace_hours: 24,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const DAY = 86400 * 1000;
|
||||
const HOUR = 3600 * 1000;
|
||||
|
||||
describe('bannerFor — active state pre-expiry', () => {
|
||||
const nowMs = Date.now();
|
||||
|
||||
it('returns null when trial expires in >5 days', () => {
|
||||
const claims = claimsTrial({
|
||||
exp: Math.floor((nowMs + 30 * DAY) / 1000),
|
||||
trial_ends_at: Math.floor((nowMs + 10 * DAY) / 1000),
|
||||
});
|
||||
const banner = bannerFor({ state: 'active', claims }, nowMs);
|
||||
expect(banner).toBeNull();
|
||||
});
|
||||
|
||||
it('returns amber when trial expires in 5 days', () => {
|
||||
const claims = claimsTrial({
|
||||
exp: Math.floor((nowMs + 30 * DAY) / 1000),
|
||||
trial_ends_at: Math.floor((nowMs + 4 * DAY) / 1000),
|
||||
});
|
||||
const banner = bannerFor({ state: 'active', claims }, nowMs);
|
||||
expect(banner).not.toBeNull();
|
||||
expect(banner!.tone).toBe('amber');
|
||||
expect(banner!.dismissible).toBe(true);
|
||||
expect(banner!.message).toMatch(/free trial/);
|
||||
expect(banner!.message).toMatch(/4 day/);
|
||||
});
|
||||
|
||||
it('returns red when trial expires in 23 hours', () => {
|
||||
const claims = claimsTrial({
|
||||
exp: Math.floor((nowMs + 30 * DAY) / 1000),
|
||||
trial_ends_at: Math.floor((nowMs + 23 * HOUR) / 1000),
|
||||
});
|
||||
const banner = bannerFor({ state: 'active', claims }, nowMs);
|
||||
expect(banner).not.toBeNull();
|
||||
expect(banner!.tone).toBe('red');
|
||||
expect(banner!.dismissible).toBe(false);
|
||||
expect(banner!.message).toMatch(/23h/);
|
||||
});
|
||||
|
||||
it('falls back to claims.exp when trial_ends_at is missing', () => {
|
||||
// Pre-v0.3.0 JWTs didn't carry trial_ends_at separately. Make
|
||||
// sure we still produce a banner so cached old JWTs don't go
|
||||
// silent during the upgrade window.
|
||||
const claims = claimsTrial({
|
||||
exp: Math.floor((nowMs + 3 * DAY) / 1000),
|
||||
trial_ends_at: null,
|
||||
});
|
||||
const banner = bannerFor({ state: 'active', claims }, nowMs);
|
||||
expect(banner).not.toBeNull();
|
||||
expect(banner!.tone).toBe('amber');
|
||||
});
|
||||
|
||||
it('uses subscription_period_end for paid plans', () => {
|
||||
const claims = claimsTrial({
|
||||
plan: 'pro',
|
||||
exp: Math.floor((nowMs + 30 * DAY) / 1000),
|
||||
trial_ends_at: null,
|
||||
subscription_period_end: Math.floor((nowMs + 3 * DAY) / 1000),
|
||||
});
|
||||
const banner = bannerFor({ state: 'active', claims }, nowMs);
|
||||
expect(banner).not.toBeNull();
|
||||
expect(banner!.tone).toBe('amber');
|
||||
expect(banner!.message).toMatch(/Velxio Pro subscription/);
|
||||
expect(banner!.message).not.toMatch(/free trial/);
|
||||
});
|
||||
|
||||
it('returns null when secondsUntilExp <= 0 (state should be soft_grace already)', () => {
|
||||
const claims = claimsTrial({
|
||||
exp: Math.floor((nowMs - 60 * 1000) / 1000),
|
||||
trial_ends_at: Math.floor((nowMs - 60 * 1000) / 1000),
|
||||
});
|
||||
const banner = bannerFor({ state: 'active', claims }, nowMs);
|
||||
expect(banner).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bannerFor — post-expiry grace states', () => {
|
||||
const nowMs = Date.now();
|
||||
const expiredClaims = claimsTrial({
|
||||
exp: Math.floor((nowMs - 5 * DAY) / 1000),
|
||||
trial_ends_at: Math.floor((nowMs - 5 * DAY) / 1000),
|
||||
});
|
||||
|
||||
it('soft_grace returns amber with offline-grace messaging', () => {
|
||||
const banner = bannerFor(
|
||||
{ state: 'soft_grace', claims: expiredClaims, days_remaining: 2 },
|
||||
nowMs,
|
||||
);
|
||||
expect(banner).not.toBeNull();
|
||||
expect(banner!.tone).toBe('amber');
|
||||
expect(banner!.dismissible).toBe(false);
|
||||
expect(banner!.message).toMatch(/offline grace/);
|
||||
expect(banner!.message).toMatch(/2 day/);
|
||||
});
|
||||
|
||||
it('hard_grace returns red and disables operations', () => {
|
||||
const banner = bannerFor(
|
||||
{ state: 'hard_grace', claims: expiredClaims, hours_remaining: 12 },
|
||||
nowMs,
|
||||
);
|
||||
expect(banner).not.toBeNull();
|
||||
expect(banner!.tone).toBe('red');
|
||||
expect(banner!.dismissible).toBe(false);
|
||||
expect(banner!.message).toMatch(/Compile and Save are temporarily disabled/);
|
||||
});
|
||||
|
||||
it('locked returns null (LockoutOverlay covers the UI)', () => {
|
||||
const banner = bannerFor(
|
||||
{ state: 'locked', last_plan: 'trial' },
|
||||
nowMs,
|
||||
);
|
||||
expect(banner).toBeNull();
|
||||
});
|
||||
|
||||
it('tampered returns null (LockoutOverlay covers the UI)', () => {
|
||||
const banner = bannerFor({ state: 'tampered' }, nowMs);
|
||||
expect(banner).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bannerFor — quiet states', () => {
|
||||
const nowMs = Date.now();
|
||||
|
||||
it('unauthenticated returns null (no key = no banner, LockoutOverlay handles UI)', () => {
|
||||
const banner = bannerFor({ state: 'unauthenticated' }, nowMs);
|
||||
expect(banner).toBeNull();
|
||||
});
|
||||
|
||||
it('active well-future expiry returns null', () => {
|
||||
const claims = claimsTrial({
|
||||
exp: Math.floor((nowMs + 90 * DAY) / 1000),
|
||||
trial_ends_at: Math.floor((nowMs + 60 * DAY) / 1000),
|
||||
});
|
||||
const banner = bannerFor({ state: 'active', claims }, nowMs);
|
||||
expect(banner).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bannerFor — dismissibility', () => {
|
||||
const nowMs = Date.now();
|
||||
|
||||
it('only the amber pre-expiry banner is dismissible', () => {
|
||||
const dismissible = [
|
||||
bannerFor(
|
||||
{
|
||||
state: 'active',
|
||||
claims: claimsTrial({
|
||||
exp: Math.floor((nowMs + 30 * DAY) / 1000),
|
||||
trial_ends_at: Math.floor((nowMs + 4 * DAY) / 1000),
|
||||
}),
|
||||
},
|
||||
nowMs,
|
||||
),
|
||||
];
|
||||
const nonDismissible = [
|
||||
bannerFor(
|
||||
{
|
||||
state: 'active',
|
||||
claims: claimsTrial({
|
||||
exp: Math.floor((nowMs + 30 * DAY) / 1000),
|
||||
trial_ends_at: Math.floor((nowMs + 1 * HOUR) / 1000),
|
||||
}),
|
||||
},
|
||||
nowMs,
|
||||
),
|
||||
bannerFor(
|
||||
{
|
||||
state: 'soft_grace',
|
||||
claims: claimsTrial(),
|
||||
days_remaining: 2,
|
||||
},
|
||||
nowMs,
|
||||
),
|
||||
bannerFor(
|
||||
{
|
||||
state: 'hard_grace',
|
||||
claims: claimsTrial(),
|
||||
hours_remaining: 12,
|
||||
},
|
||||
nowMs,
|
||||
),
|
||||
];
|
||||
|
||||
for (const b of dismissible) {
|
||||
expect(b?.dismissible).toBe(true);
|
||||
}
|
||||
for (const b of nonDismissible) {
|
||||
// null banners (locked/tampered) are filtered out by the
|
||||
// returned-null short-circuit in GraceBanner itself.
|
||||
if (b !== null) {
|
||||
expect(b.dismissible).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -20,7 +20,7 @@ export default defineConfig({
|
|||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/__tests__/**/*.test.ts'],
|
||||
include: ['src/__tests__/**/*.test.ts', 'src/**/__tests__/**/*.test.ts'],
|
||||
testTimeout: 30_000,
|
||||
hookTimeout: 30_000,
|
||||
// Vitest 4 removed `test.poolOptions` — config moved to top-level
|
||||
|
|
|
|||
Loading…
Reference in New Issue