Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 6 additions & 11 deletions frontend/src/app/app.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, ErrorHandler, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter, Router } from '@angular/router';
import { ErrorHandler, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
Expand All @@ -11,8 +11,7 @@ import { GlobalErrorHandler } from './error/global-error-handler';
import { API_BASE_URL } from './tokens';

/** Lightweight stub used as a route target to avoid recursively rendering AppComponent. */
@Component({ selector: 'app-stub', template: '', standalone: true })
class StubComponent {}
// StubComponent removed — no longer needed after isLandingPage removal

describe('AppComponent', () => {
let fixture: ComponentFixture<AppComponent>;
Expand Down Expand Up @@ -133,8 +132,8 @@ describe('AppComponent (mobile viewport)', () => {
imports: [AppComponent, NoopAnimationsModule],
providers: [
provideZonelessChangeDetection(),
// Provide a non-root route so we can navigate away from `/` to reveal the app shell.
provideRouter([{ path: 'compiler', component: StubComponent }]),
// Provide empty routes — no longer need StubComponent
provideRouter([]),
provideHttpClient(),
provideHttpClientTesting(),
{ provide: ErrorHandler, useClass: GlobalErrorHandler },
Expand Down Expand Up @@ -165,11 +164,7 @@ describe('AppComponent (mobile viewport)', () => {
localStorage.clear();
});

it('should render menu button with aria-label on non-landing routes', async () => {
// The app shell hamburger is only shown on non-landing-page routes.
// Navigate away from `/` to make isLandingPage() = false and reveal the shell header.
const router = TestBed.inject(Router);
await router.navigate(['/compiler']);
it('should render menu button with aria-label', async () => {
await fixture.whenStable();
fixture.detectChanges();
const menuBtn = fixture.nativeElement.querySelector('button[aria-label="Toggle navigation"]');
Expand Down
35 changes: 4 additions & 31 deletions frontend/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@
*/

import { Component, inject, signal, viewChild, effect } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet, Router, NavigationEnd } from '@angular/router';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { MatSidenav, MatSidenavModule } from '@angular/material/sidenav';
import { MatListModule } from '@angular/material/list';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { toSignal } from '@angular/core/rxjs-interop';
import { filter, map } from 'rxjs/operators';
import { map } from 'rxjs/operators';
import { ThemeService } from './services/theme.service';
import { ErrorBoundaryComponent } from './error/error-boundary.component';
import { NotificationContainerComponent } from './notification/notification-container.component';
Expand Down Expand Up @@ -105,8 +105,7 @@ interface NavItem {
<mat-sidenav-content>
<div class="page-wrapper">

<!-- Header — Bloqr nav bar: sticky, glass morphism, orange accent (hidden on landing page) -->
@if (!isLandingPage()) {
<!-- Header — Bloqr nav bar: sticky, glass morphism, orange accent -->
<header class="app-header-shell">
<div class="app-title-row">
@if (isMobile()) {
Expand Down Expand Up @@ -158,7 +157,6 @@ interface NavItem {
}
</nav>
</header>
} <!-- end @if (!isLandingPage()) header -->

<!-- Main content area -->
<main id="main-content" class="app-main-content" role="main" aria-label="Main content" tabindex="-1">
Expand All @@ -168,12 +166,10 @@ interface NavItem {
<router-outlet />
</main>

<!-- Footer (hidden on landing page which has its own footer) -->
@if (!isLandingPage()) {
<!-- Footer matching original -->
<footer class="app-footer-shell">
<p>Powered by <a href="https://github.com/jaypatrick/adblock-compiler" target="_blank" rel="noopener noreferrer">@jk-com/adblock-compiler<span class="visually-hidden"> (opens in new tab)</span></a></p>
</footer>
} <!-- end @if (!isLandingPage()) footer -->

</div>
</mat-sidenav-content>
Expand Down Expand Up @@ -241,29 +237,6 @@ export class AppComponent {
*/
readonly themeService = inject(ThemeService);

/**
* Router — used to detect the landing page URL.
* isLandingPage drives @if blocks that hide the shell header/footer on `/`.
* initialValue reads router.url synchronously so direct navigation to non-root
* routes (e.g. /compiler) starts with the correct false value, preventing a
* brief flash of hidden/shown shell on deep-link access.
*/
private readonly router = inject(Router);

private isLandingPageUrl(url: string): boolean {
const normalizedUrl = url.split(/[?#]/u, 1)[0];

return normalizedUrl === '' || normalizedUrl === '/';
}

readonly isLandingPage = toSignal(
this.router.events.pipe(
filter(e => e instanceof NavigationEnd),
map(e => this.isLandingPageUrl((e as NavigationEnd).urlAfterRedirects)),
),
{ initialValue: this.isLandingPageUrl(this.router.url) },
);

constructor() {
// Close the mobile drawer whenever desktop layout is active (horizontal nav takes over).
// Runs whenever isMobile() is false to ensure the drawer stays closed on desktop.
Expand Down
132 changes: 115 additions & 17 deletions frontend/src/app/home/home.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,149 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideRouter, Router } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { HomeComponent } from './home.component';
import { API_BASE_URL } from '../tokens';

describe('HomeComponent', () => {
let fixture: ComponentFixture<HomeComponent>;
let component: HomeComponent;
let httpTesting: HttpTestingController;

/** Flush the initial MetricsStore HTTP requests so signals settle. */
function flushPendingRequests(opts: { status?: 'healthy' | 'degraded' | 'down' } = {}): void {
const status = opts.status ?? 'healthy';
httpTesting.match('/api/metrics').forEach(req =>
req.flush({ totalRequests: 100, averageDuration: 45.5, cacheHitRate: 0.85, successRate: 0.98 }),
);
httpTesting.match('/api/health').forEach(req =>
req.flush({ status, version: '1.2.3' }),
);
httpTesting.match('/api/queue/stats').forEach(req =>
req.flush({ currentDepth: 3, pending: 2, completed: 50, failed: 1, processingRate: 5, lag: 0, depthHistory: [] }),
);
}

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HomeComponent],
imports: [HomeComponent, NoopAnimationsModule],
providers: [
provideZonelessChangeDetection(),
provideRouter([]),
provideHttpClient(),
provideHttpClientTesting(),
{ provide: API_BASE_URL, useValue: '/api' },
],
}).compileComponents();

fixture = TestBed.createComponent(HomeComponent);
component = fixture.componentInstance;
httpTesting = TestBed.inject(HttpTestingController);

flushPendingRequests();
await fixture.whenStable();
});

afterEach(() => {
vi.restoreAllMocks();
httpTesting.match(() => true).forEach(req => req.flush({}));
httpTesting.verify();
});

it('should create', () => {
expect(component).toBeTruthy();
});

it('should render nav bar', async () => {
await fixture.whenStable();
const nav = fixture.nativeElement.querySelector('app-nav-bar');
expect(nav).toBeTruthy();
it('should have 6 navigation cards', () => {
expect(component.navCards.length).toBe(6);
});

it('should render hero section', async () => {
await fixture.whenStable();
const hero = fixture.nativeElement.querySelector('app-hero-section');
expect(hero).toBeTruthy();
it('should include Compiler card', () => {
const card = component.navCards.find(c => c.path === '/compiler');
expect(card).toBeTruthy();
expect(card!.label).toContain('Compiler');
});

it('should render landing content wrapper', async () => {
await fixture.whenStable();
const wrapper = fixture.nativeElement.querySelector('.landing-content');
expect(wrapper).toBeTruthy();
it('should include Admin card with warn tag', () => {
const card = component.navCards.find(c => c.path === '/admin');
expect(card).toBeTruthy();
expect(card!.tag).toBe('Admin');
});

it('should derive live stats from metrics', () => {
const stats = component.liveStats();
expect(stats.totalRequests).toContain('100');
expect(stats.avgDuration).toContain('45.5');
expect(stats.cacheHitRate).toContain('85.0%');
expect(stats.successRate).toContain('98.0%');
});

it('should navigate when navigateTo is called', async () => {
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
component.navigateTo('/compiler');
expect(navigateSpy).toHaveBeenCalledWith(['/compiler']);
});

it('should open absolute external URLs in a new tab via window.open', () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
component.navigateTo('https://docs.example.com/', true);
expect(openSpy).toHaveBeenCalledWith('https://docs.example.com/', '_blank', 'noopener,noreferrer');
});

it('should open worker-handled relative paths in a new tab when external flag is true', () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
component.navigateTo('/admin', true);
expect(openSpy).toHaveBeenCalledWith('/admin', '_blank', 'noopener,noreferrer');
});

it('should NOT call window.open for internal paths without external flag', async () => {
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
component.navigateTo('/performance');
expect(openSpy).not.toHaveBeenCalled();
expect(navigateSpy).toHaveBeenCalledWith(['/performance']);
});

it('should navigate to performance on stat card click for Total Requests', async () => {
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
component.onStatCardClicked('Total Requests');
expect(navigateSpy).toHaveBeenCalledWith(['/performance']);
});

it('should navigate to performance on stat card click for Avg Response Time', async () => {
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
component.onStatCardClicked('Avg Response Time');
expect(navigateSpy).toHaveBeenCalledWith(['/performance']);
});

it('should render footer section', async () => {
it('should not navigate for non-metric stat card clicks', async () => {
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
component.onStatCardClicked('Cache Hit Rate');
expect(navigateSpy).not.toHaveBeenCalled();
});

it('should show default health icon when no data', async () => {
// After the flush in beforeEach, health is 'healthy'
expect(component.healthIcon()).toBe('check_circle');
});

it('should show default health color when no data', async () => {
// After the flush in beforeEach, health is 'healthy'
expect(component.healthColor()).toBe('var(--app-success, #4caf50)');
});

it('should render the page heading', async () => {
await fixture.whenStable();
const footer = fixture.nativeElement.querySelector('app-footer-section');
expect(footer).toBeTruthy();
fixture.detectChanges();
const h1 = fixture.nativeElement.querySelector('h1');
expect(h1?.textContent).toContain('Adblock Compiler Dashboard');
});
});

Loading
Loading