2026-05-07 14:25:46 +00:00
|
|
|
import { provideHttpClient } from '@angular/common/http';
|
|
|
|
|
import {
|
|
|
|
|
HttpTestingController,
|
|
|
|
|
provideHttpClientTesting,
|
|
|
|
|
} from '@angular/common/http/testing';
|
|
|
|
|
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
|
|
|
|
import { By } from '@angular/platform-browser';
|
|
|
|
|
import { TestBed } from '@angular/core/testing';
|
|
|
|
|
import { MissionSchema, MissionStateResponse, SurvivorSchema } from '@fog-explorer/api-interfaces';
|
|
|
|
|
import { EBS_BASE_URL } from '../ebs/ebs-api.service';
|
|
|
|
|
import { TwitchAuthService } from '../twitch/twitch-auth.service';
|
|
|
|
|
import { AmbientEventData } from './ambient-event.component';
|
|
|
|
|
import { PanelShellComponent } from './panel-shell.component';
|
|
|
|
|
|
|
|
|
|
// Stub child components using @Input()/@Output() — JIT doesn't recognise signal inputs
|
|
|
|
|
@Component({ selector: 'app-minimised-panel', standalone: true, template: '' })
|
|
|
|
|
class MinimisedPanelStub {
|
|
|
|
|
@Input() missionState!: NonNullable<MissionStateResponse>;
|
|
|
|
|
@Output() lanternClick = new EventEmitter<void>();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Component({ selector: 'app-ambient-event', standalone: true, template: '' })
|
|
|
|
|
class AmbientEventStub {
|
|
|
|
|
@Input() event!: AmbientEventData;
|
|
|
|
|
@Output() dismissed = new EventEmitter<void>();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Component({ selector: 'app-expanded-panel', standalone: true, template: '' })
|
|
|
|
|
class ExpandedPanelStub {
|
|
|
|
|
@Input() missionState!: NonNullable<MissionStateResponse>;
|
2026-05-11 08:38:19 +00:00
|
|
|
@Output() panelClose = new EventEmitter<void>();
|
2026-05-07 14:25:46 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const BASE = 'https://test.local';
|
|
|
|
|
const STATE_URL = `${BASE}/missions/state`;
|
|
|
|
|
|
|
|
|
|
function makeJwt(): string {
|
|
|
|
|
const payload = btoa(
|
|
|
|
|
JSON.stringify({ opaque_user_id: 'U123', channel_id: 'ch1', role: 'viewer', exp: 9999999999 })
|
|
|
|
|
).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
|
|
|
|
return `eyJhbGciOiJIUzI1NiJ9.${payload}.sig`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function makeAnonymousJwt(): string {
|
|
|
|
|
const payload = btoa(
|
|
|
|
|
JSON.stringify({ opaque_user_id: 'Aanon', channel_id: 'ch1', role: 'viewer', exp: 9999999999 })
|
|
|
|
|
).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
|
|
|
|
return `eyJhbGciOiJIUzI1NiJ9.${payload}.sig`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const SURVIVOR = SurvivorSchema.parse({
|
|
|
|
|
id: 'a1b2c3d4-e5f6-4789-a012-b3c4d5e6f701',
|
|
|
|
|
opaqueUserId: 'U123',
|
|
|
|
|
channelId: 'ch1',
|
|
|
|
|
name: 'Hana',
|
|
|
|
|
state: 'active',
|
|
|
|
|
stats: { objectives: 5, survival: 5, altruism: 5 },
|
|
|
|
|
perkSlots: [],
|
|
|
|
|
createdAt: new Date().toISOString(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const MISSION = MissionSchema.parse({
|
|
|
|
|
id: 'b2c3d4e5-f6a7-4890-b123-c4d5e6f7a801',
|
|
|
|
|
groupId: null,
|
|
|
|
|
participants: [{ survivorId: SURVIVOR.id, state: 'active', hookCount: 0 }],
|
|
|
|
|
difficulty: 2,
|
|
|
|
|
status: 'active',
|
|
|
|
|
encounterLibraryVersion: '1.0.0',
|
|
|
|
|
nextTickAt: new Date(Date.now() + 60000).toISOString(),
|
|
|
|
|
tickIndex: 3,
|
|
|
|
|
startedAt: new Date().toISOString(),
|
|
|
|
|
endedAt: null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const MISSION_STATE = { mission: MISSION, survivors: [SURVIVOR], recentLog: [] };
|
|
|
|
|
|
|
|
|
|
@Component({
|
|
|
|
|
standalone: true,
|
|
|
|
|
imports: [PanelShellComponent],
|
|
|
|
|
template: `<app-panel-shell />`,
|
|
|
|
|
})
|
|
|
|
|
class HostComponent {}
|
|
|
|
|
|
|
|
|
|
describe('PanelShellComponent', () => {
|
|
|
|
|
let controller: HttpTestingController;
|
|
|
|
|
let authService: TwitchAuthService;
|
|
|
|
|
let visibilityCallback: ((v: boolean, ctx: TwitchContext) => void) | undefined;
|
|
|
|
|
|
|
|
|
|
function mountTwitchExt(jwt = makeJwt()) {
|
|
|
|
|
(window as Window & { Twitch?: unknown }).Twitch = {
|
|
|
|
|
ext: {
|
|
|
|
|
onAuthorized: (cb: (a: TwitchAuth) => void) =>
|
|
|
|
|
cb({ channelId: 'ch1', clientId: 'c1', token: jwt, userId: 'U123' }),
|
|
|
|
|
onContext: () => undefined,
|
|
|
|
|
onVisibilityChanged: (cb: (v: boolean, ctx: TwitchContext) => void) => {
|
|
|
|
|
visibilityCallback = cb;
|
|
|
|
|
},
|
|
|
|
|
listen: () => undefined,
|
|
|
|
|
unlisten: () => undefined,
|
|
|
|
|
send: () => undefined,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createComponent() {
|
|
|
|
|
const fixture = TestBed.createComponent(HostComponent);
|
|
|
|
|
fixture.detectChanges();
|
|
|
|
|
return fixture;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
visibilityCallback = undefined;
|
|
|
|
|
TestBed.configureTestingModule({
|
|
|
|
|
providers: [
|
|
|
|
|
provideHttpClient(),
|
|
|
|
|
provideHttpClientTesting(),
|
|
|
|
|
{ provide: EBS_BASE_URL, useValue: BASE },
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
TestBed.overrideComponent(PanelShellComponent, {
|
|
|
|
|
set: {
|
|
|
|
|
imports: [MinimisedPanelStub, AmbientEventStub, ExpandedPanelStub],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
controller = TestBed.inject(HttpTestingController);
|
|
|
|
|
authService = TestBed.inject(TwitchAuthService);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
controller.verify();
|
|
|
|
|
delete (window as Window & { Twitch?: unknown }).Twitch;
|
|
|
|
|
vi.useRealTimers();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('renders nothing before onAuthorized fires', () => {
|
|
|
|
|
(window as Window & { Twitch?: unknown }).Twitch = {
|
|
|
|
|
ext: {
|
|
|
|
|
onAuthorized: () => undefined,
|
|
|
|
|
onContext: () => undefined,
|
|
|
|
|
onVisibilityChanged: (cb: (v: boolean, ctx: TwitchContext) => void) => { visibilityCallback = cb; },
|
|
|
|
|
listen: () => undefined,
|
|
|
|
|
unlisten: () => undefined,
|
|
|
|
|
send: () => undefined,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
const fixture = createComponent();
|
|
|
|
|
// Angular @if leaves comment nodes; check no element children are rendered
|
|
|
|
|
const shell = fixture.nativeElement.querySelector('app-panel-shell') as HTMLElement;
|
|
|
|
|
expect(shell.querySelectorAll('*').length).toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('shows onboarding when authorized but no active mission', () => {
|
|
|
|
|
mountTwitchExt();
|
|
|
|
|
const fixture = createComponent();
|
|
|
|
|
|
|
|
|
|
controller.expectOne(STATE_URL).flush(null);
|
|
|
|
|
fixture.detectChanges();
|
|
|
|
|
|
|
|
|
|
expect(fixture.nativeElement.textContent).toContain('The fog stirs');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('shows anonymous panel for A-prefixed opaque_user_id', () => {
|
|
|
|
|
mountTwitchExt(makeAnonymousJwt());
|
|
|
|
|
const fixture = createComponent();
|
|
|
|
|
|
|
|
|
|
controller.expectOne(STATE_URL).flush(null);
|
|
|
|
|
fixture.detectChanges();
|
|
|
|
|
|
|
|
|
|
expect(fixture.nativeElement.querySelector('.anon-panel')).not.toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('shows minimised panel when mission state is available', () => {
|
|
|
|
|
mountTwitchExt();
|
|
|
|
|
const fixture = createComponent();
|
|
|
|
|
|
|
|
|
|
controller.expectOne(STATE_URL).flush(MISSION_STATE);
|
|
|
|
|
fixture.detectChanges();
|
|
|
|
|
|
|
|
|
|
expect(fixture.nativeElement.querySelector('app-minimised-panel')).not.toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('toggles to expanded on lantern click and back on close', () => {
|
|
|
|
|
mountTwitchExt();
|
|
|
|
|
const fixture = createComponent();
|
|
|
|
|
controller.expectOne(STATE_URL).flush(MISSION_STATE);
|
|
|
|
|
fixture.detectChanges();
|
|
|
|
|
|
|
|
|
|
// Emit lanternClick from the stub component
|
|
|
|
|
fixture.debugElement.query(By.directive(MinimisedPanelStub))
|
|
|
|
|
.componentInstance.lanternClick.emit();
|
|
|
|
|
fixture.detectChanges();
|
|
|
|
|
expect(fixture.nativeElement.querySelector('app-expanded-panel')).not.toBeNull();
|
|
|
|
|
|
|
|
|
|
// Emit close from the expanded stub
|
|
|
|
|
fixture.debugElement.query(By.directive(ExpandedPanelStub))
|
2026-05-11 08:38:19 +00:00
|
|
|
.componentInstance.panelClose.emit();
|
2026-05-07 14:25:46 +00:00
|
|
|
fixture.detectChanges();
|
|
|
|
|
expect(fixture.nativeElement.querySelector('app-minimised-panel')).not.toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('shows ambient event on injury and auto-dismisses after 4s', async () => {
|
|
|
|
|
vi.useFakeTimers();
|
|
|
|
|
mountTwitchExt();
|
|
|
|
|
const fixture = createComponent();
|
|
|
|
|
controller.expectOne(STATE_URL).flush(MISSION_STATE);
|
|
|
|
|
fixture.detectChanges();
|
|
|
|
|
|
|
|
|
|
const injuredState = {
|
|
|
|
|
...MISSION_STATE,
|
|
|
|
|
survivors: [{ ...SURVIVOR, state: 'injured' as const }],
|
|
|
|
|
};
|
|
|
|
|
controller.expectNone(STATE_URL);
|
|
|
|
|
|
|
|
|
|
// Simulate a PubSub tick arriving that updates state to injured
|
|
|
|
|
controller.expectNone(STATE_URL);
|
|
|
|
|
// Directly set state by triggering a second fetch with an injured survivor
|
|
|
|
|
// (In production this comes from PubSub → store.refresh() → REST)
|
|
|
|
|
// We trigger via store.refresh() here
|
|
|
|
|
TestBed.inject(
|
|
|
|
|
(await import('../mission/mission-state.store')).MissionStateStore
|
|
|
|
|
).refresh();
|
|
|
|
|
controller.expectOne(STATE_URL).flush(injuredState);
|
|
|
|
|
fixture.detectChanges();
|
|
|
|
|
|
|
|
|
|
expect(fixture.nativeElement.querySelector('app-ambient-event')).not.toBeNull();
|
|
|
|
|
|
|
|
|
|
await vi.advanceTimersByTimeAsync(4000);
|
|
|
|
|
fixture.detectChanges();
|
|
|
|
|
expect(fixture.nativeElement.querySelector('app-minimised-panel')).not.toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('drops ambient event when overlay is hidden', async () => {
|
|
|
|
|
mountTwitchExt();
|
|
|
|
|
const fixture = createComponent();
|
|
|
|
|
controller.expectOne(STATE_URL).flush(MISSION_STATE);
|
|
|
|
|
fixture.detectChanges();
|
|
|
|
|
|
|
|
|
|
visibilityCallback!(false, {} as TwitchContext);
|
|
|
|
|
|
|
|
|
|
const injuredState = {
|
|
|
|
|
...MISSION_STATE,
|
|
|
|
|
survivors: [{ ...SURVIVOR, state: 'injured' as const }],
|
|
|
|
|
};
|
|
|
|
|
TestBed.inject(
|
|
|
|
|
(await import('../mission/mission-state.store')).MissionStateStore
|
|
|
|
|
).refresh();
|
|
|
|
|
controller.expectOne(STATE_URL).flush(injuredState);
|
|
|
|
|
fixture.detectChanges();
|
|
|
|
|
|
|
|
|
|
// Still minimised — ambient event dropped while hidden
|
|
|
|
|
expect(fixture.nativeElement.querySelector('app-minimised-panel')).not.toBeNull();
|
|
|
|
|
});
|
|
|
|
|
});
|