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; @Output() lanternClick = new EventEmitter(); } @Component({ selector: 'app-ambient-event', standalone: true, template: '' }) class AmbientEventStub { @Input() event!: AmbientEventData; @Output() dismissed = new EventEmitter(); } @Component({ selector: 'app-expanded-panel', standalone: true, template: '' }) class ExpandedPanelStub { @Input() missionState!: NonNullable; @Output() close = new EventEmitter(); } 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: ``, }) 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)) .componentInstance.close.emit(); 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(); }); });