feat(layout): finalize p01 and p02 changes
Complete UI foundation and app layout implementation, stabilize container health checks, and archive both OpenSpec changes after verification.
This commit is contained in:
25
frontend/tests/component/app-layout.test.ts
Normal file
25
frontend/tests/component/app-layout.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import AppLayout, { getContentOffsetClass } from '../../src/lib/components/layout/AppLayout.svelte';
|
||||
|
||||
describe('AppLayout component', () => {
|
||||
it('exports a component module', () => {
|
||||
expect(AppLayout).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders children via slot', () => {
|
||||
const source = readFileSync(
|
||||
resolve(process.cwd(), 'src/lib/components/layout/AppLayout.svelte'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
expect(source).toContain('<slot />');
|
||||
});
|
||||
|
||||
it('maps sidebar states to layout offsets', () => {
|
||||
expect(getContentOffsetClass('expanded')).toBe('md:ml-60');
|
||||
expect(getContentOffsetClass('collapsed')).toBe('md:ml-16');
|
||||
expect(getContentOffsetClass('hidden')).toBe('md:ml-0');
|
||||
});
|
||||
});
|
||||
14
frontend/tests/component/breadcrumbs.test.ts
Normal file
14
frontend/tests/component/breadcrumbs.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import Breadcrumbs, { generateBreadcrumbs } from '../../src/lib/components/layout/Breadcrumbs.svelte';
|
||||
|
||||
describe('Breadcrumbs component', () => {
|
||||
it('exports a component module', () => {
|
||||
expect(Breadcrumbs).toBeDefined();
|
||||
});
|
||||
|
||||
it('generates correct crumbs', () => {
|
||||
const crumbs = generateBreadcrumbs('/reports/allocation-matrix');
|
||||
expect(crumbs.map((crumb) => crumb.label)).toEqual(['Home', 'Reports', 'Allocation Matrix']);
|
||||
expect(crumbs[2].href).toBe('/reports/allocation-matrix');
|
||||
});
|
||||
});
|
||||
9
frontend/tests/component/lucide-icon.test.ts
Normal file
9
frontend/tests/component/lucide-icon.test.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Menu } from 'lucide-svelte';
|
||||
|
||||
describe('lucide icon', () => {
|
||||
it('exports menu icon component', () => {
|
||||
expect(Menu).toBeDefined();
|
||||
expect(typeof Menu).toBe('function');
|
||||
});
|
||||
});
|
||||
37
frontend/tests/component/month-selector.test.ts
Normal file
37
frontend/tests/component/month-selector.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import MonthSelector, {
|
||||
formatMonth,
|
||||
generateMonthOptions
|
||||
} from '../../src/lib/components/layout/MonthSelector.svelte';
|
||||
import { selectedPeriod, setPeriod } from '../../src/lib/stores/period';
|
||||
|
||||
function getStoreValue<T>(store: { subscribe: (run: (value: T) => void) => () => void }): T {
|
||||
let value!: T;
|
||||
const unsubscribe = store.subscribe((current) => {
|
||||
value = current;
|
||||
});
|
||||
unsubscribe();
|
||||
return value;
|
||||
}
|
||||
|
||||
describe('MonthSelector component', () => {
|
||||
it('exports a component module', () => {
|
||||
expect(MonthSelector).toBeDefined();
|
||||
});
|
||||
|
||||
it('formats month labels', () => {
|
||||
expect(formatMonth('2026-02')).toBe('Feb 2026');
|
||||
});
|
||||
|
||||
it('builds +/- 6 month options', () => {
|
||||
const options = generateMonthOptions(new Date(2026, 1, 1));
|
||||
expect(options).toHaveLength(13);
|
||||
expect(options[0]).toBe('2025-08');
|
||||
expect(options[12]).toBe('2026-08');
|
||||
});
|
||||
|
||||
it('selection updates period store', () => {
|
||||
setPeriod('2026-02');
|
||||
expect(getStoreValue(selectedPeriod)).toBe('2026-02');
|
||||
});
|
||||
});
|
||||
9
frontend/tests/component/sidebar-item.test.ts
Normal file
9
frontend/tests/component/sidebar-item.test.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import SidebarItem from '../../src/lib/components/layout/SidebarItem.svelte';
|
||||
|
||||
describe('SidebarItem component', () => {
|
||||
it('exports a component module', () => {
|
||||
expect(SidebarItem).toBeDefined();
|
||||
expect(typeof SidebarItem).toBe('function');
|
||||
});
|
||||
});
|
||||
9
frontend/tests/component/sidebar-section.test.ts
Normal file
9
frontend/tests/component/sidebar-section.test.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import SidebarSection from '../../src/lib/components/layout/SidebarSection.svelte';
|
||||
|
||||
describe('SidebarSection component', () => {
|
||||
it('exports a component module', () => {
|
||||
expect(SidebarSection).toBeDefined();
|
||||
expect(typeof SidebarSection).toBe('function');
|
||||
});
|
||||
});
|
||||
14
frontend/tests/component/sidebar.test.ts
Normal file
14
frontend/tests/component/sidebar.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import Sidebar, { isSectionVisible } from '../../src/lib/components/layout/Sidebar.svelte';
|
||||
|
||||
describe('Sidebar component', () => {
|
||||
it('exports a component module', () => {
|
||||
expect(Sidebar).toBeDefined();
|
||||
});
|
||||
|
||||
it('supports role-based visibility checks', () => {
|
||||
expect(isSectionVisible({ title: 'ADMIN', roles: ['superuser'], items: [] }, 'superuser')).toBe(true);
|
||||
expect(isSectionVisible({ title: 'ADMIN', roles: ['superuser'], items: [] }, 'manager')).toBe(false);
|
||||
expect(isSectionVisible({ title: 'PLANNING', items: [] }, null)).toBe(true);
|
||||
});
|
||||
});
|
||||
21
frontend/tests/component/topbar.test.ts
Normal file
21
frontend/tests/component/topbar.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import TopBar from '../../src/lib/components/layout/TopBar.svelte';
|
||||
|
||||
describe('TopBar component', () => {
|
||||
it('exports a component module', () => {
|
||||
expect(TopBar).toBeDefined();
|
||||
});
|
||||
|
||||
it('includes breadcrumbs, month selector, and user menu', () => {
|
||||
const source = readFileSync(
|
||||
resolve(process.cwd(), 'src/lib/components/layout/TopBar.svelte'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
expect(source).toContain('<Breadcrumbs />');
|
||||
expect(source).toContain('<MonthSelector />');
|
||||
expect(source).toContain('<UserMenu />');
|
||||
});
|
||||
});
|
||||
167
frontend/tests/e2e/layout.spec.ts
Normal file
167
frontend/tests/e2e/layout.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
function createToken() {
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' }), 'utf-8').toString('base64');
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({ sub: 'e2e', exp: Math.floor(Date.now() / 1000) + 3600 }),
|
||||
'utf-8'
|
||||
).toString('base64');
|
||||
return `${header}.${payload}.sig`;
|
||||
}
|
||||
|
||||
async function openDashboard(page: import('@playwright/test').Page) {
|
||||
const token = createToken();
|
||||
await page.goto('/login');
|
||||
await page.evaluate((accessToken) => {
|
||||
localStorage.setItem('headroom_access_token', accessToken);
|
||||
localStorage.setItem('headroom_refresh_token', accessToken);
|
||||
}, token);
|
||||
await page.goto('/dashboard');
|
||||
await page.waitForURL('/dashboard');
|
||||
}
|
||||
|
||||
async function loginThroughForm(page: import('@playwright/test').Page) {
|
||||
await page.goto('/login');
|
||||
await page.fill('input[type="email"]', 'superuser@headroom.test');
|
||||
await page.fill('input[type="password"]', 'password');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL('/dashboard');
|
||||
}
|
||||
|
||||
test.describe('Layout E2E', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.context().clearCookies();
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
});
|
||||
|
||||
test('dashboard page has sidebar', async ({ page }) => {
|
||||
await openDashboard(page);
|
||||
await expect(page.locator('[data-testid="sidebar"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('login redirects to dashboard with sidebar', async ({ page }) => {
|
||||
await loginThroughForm(page);
|
||||
await expect(page.locator('[data-testid="sidebar"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('sidebar toggle works', async ({ page }) => {
|
||||
await openDashboard(page);
|
||||
|
||||
const initial = await page.evaluate(() => document.documentElement.getAttribute('data-sidebar'));
|
||||
await page.click('[data-testid="sidebar-toggle"]');
|
||||
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => document.documentElement.getAttribute('data-sidebar')))
|
||||
.not.toBe(initial);
|
||||
});
|
||||
|
||||
test('theme toggle works', async ({ page }) => {
|
||||
await openDashboard(page);
|
||||
|
||||
const before = await page.evaluate(() => localStorage.getItem('headroom_theme') ?? 'light');
|
||||
await page.click('[data-testid="theme-toggle"]');
|
||||
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => localStorage.getItem('headroom_theme')))
|
||||
.not.toBe(before);
|
||||
});
|
||||
|
||||
test('month selector updates period store', async ({ page }) => {
|
||||
await openDashboard(page);
|
||||
|
||||
const before = await page.evaluate(() => localStorage.getItem('headroom_selected_period'));
|
||||
await page.click('[data-testid="month-selector"] button');
|
||||
await page.getByRole('button', { name: 'Next' }).click();
|
||||
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => localStorage.getItem('headroom_selected_period')))
|
||||
.not.toBe(before);
|
||||
});
|
||||
|
||||
test('breadcrumbs reflect current route', async ({ page }) => {
|
||||
await openDashboard(page);
|
||||
await expect(page.locator('[data-testid="breadcrumbs"]')).toContainText('Dashboard');
|
||||
});
|
||||
|
||||
test('login page has no sidebar', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await expect(page.locator('[data-testid="sidebar"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('sidebar hidden by default on mobile', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openDashboard(page);
|
||||
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => document.documentElement.getAttribute('data-sidebar')))
|
||||
.toBe('hidden');
|
||||
});
|
||||
|
||||
test('hamburger shows sidebar on mobile', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openDashboard(page);
|
||||
|
||||
await page.click('[data-testid="mobile-hamburger"]');
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => document.documentElement.getAttribute('data-sidebar')))
|
||||
.toBe('expanded');
|
||||
});
|
||||
|
||||
test('sidebar overlays content and closes on backdrop click on mobile', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openDashboard(page);
|
||||
|
||||
await page.click('[data-testid="mobile-hamburger"]');
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate(() => {
|
||||
const main = document.querySelector('[data-testid="layout-main"]');
|
||||
if (!main) return null;
|
||||
return getComputedStyle(main).marginLeft;
|
||||
})
|
||||
)
|
||||
.toBe('0px');
|
||||
|
||||
await page.evaluate(() => {
|
||||
const backdrop = document.querySelector('[data-testid="sidebar-backdrop"]') as HTMLElement | null;
|
||||
backdrop?.click();
|
||||
});
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => document.documentElement.getAttribute('data-sidebar')))
|
||||
.toBe('hidden');
|
||||
});
|
||||
|
||||
test('all key breakpoints render expected sidebar state', async ({ page }) => {
|
||||
const breakpoints: Array<[number, number, 'hidden' | 'collapsed' | 'expanded']> = [
|
||||
[320, 700, 'hidden'],
|
||||
[768, 900, 'collapsed'],
|
||||
[1024, 900, 'collapsed'],
|
||||
[1280, 900, 'expanded']
|
||||
];
|
||||
|
||||
for (const [width, height, expected] of breakpoints) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.goto('/login');
|
||||
await page.evaluate(() => localStorage.setItem('headroom_sidebar_state', 'expanded'));
|
||||
await openDashboard(page);
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => document.documentElement.getAttribute('data-sidebar')))
|
||||
.toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard shortcut toggles sidebar', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await openDashboard(page);
|
||||
|
||||
const before = await page.evaluate(() => document.documentElement.getAttribute('data-sidebar'));
|
||||
await page.keyboard.down('Control');
|
||||
await page.keyboard.press('\\');
|
||||
await page.keyboard.up('Control');
|
||||
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => document.documentElement.getAttribute('data-sidebar')))
|
||||
.not.toBe(before);
|
||||
});
|
||||
});
|
||||
37
frontend/tests/e2e/theme.spec.ts
Normal file
37
frontend/tests/e2e/theme.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
async function expectTheme(page: import('@playwright/test').Page, theme: 'light' | 'dark') {
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate(() => ({
|
||||
attr: document.documentElement.getAttribute('data-theme'),
|
||||
stored: localStorage.getItem('headroom_theme')
|
||||
}))
|
||||
)
|
||||
.toEqual({ attr: theme, stored: theme });
|
||||
|
||||
}
|
||||
|
||||
test.describe('Theme persistence', () => {
|
||||
test('applies dark theme from localStorage on reload', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('headroom_theme', 'dark');
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
await expectTheme(page, 'dark');
|
||||
});
|
||||
|
||||
test('applies light theme from localStorage on reload', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('headroom_theme', 'light');
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
await expectTheme(page, 'light');
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,19 @@
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Mock localStorage
|
||||
global.localStorage = {
|
||||
const localStorageMock: Storage = {
|
||||
get length() {
|
||||
return 0;
|
||||
},
|
||||
key: vi.fn(() => null),
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
|
||||
global.localStorage = localStorageMock;
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn();
|
||||
|
||||
|
||||
55
frontend/tests/unit/layout.store.test.ts
Normal file
55
frontend/tests/unit/layout.store.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
||||
|
||||
function getStoreValue<T>(store: { subscribe: (run: (value: T) => void) => () => void }): T {
|
||||
let value!: T;
|
||||
const unsubscribe = store.subscribe((current) => {
|
||||
value = current;
|
||||
});
|
||||
unsubscribe();
|
||||
return value;
|
||||
}
|
||||
|
||||
describe('layout store', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
(localStorage.getItem as Mock).mockReturnValue(null);
|
||||
});
|
||||
|
||||
it('initializes with default values', async () => {
|
||||
const store = await import('../../src/lib/stores/layout');
|
||||
|
||||
expect(getStoreValue(store.sidebarState)).toBe('expanded');
|
||||
expect(getStoreValue(store.theme)).toBe('light');
|
||||
});
|
||||
|
||||
it('toggleSidebar cycles through states', async () => {
|
||||
const store = await import('../../src/lib/stores/layout');
|
||||
|
||||
store.setSidebarState('expanded');
|
||||
store.toggleSidebar();
|
||||
expect(getStoreValue(store.sidebarState)).toBe('collapsed');
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith('headroom_sidebar_state', 'collapsed');
|
||||
|
||||
store.toggleSidebar();
|
||||
expect(getStoreValue(store.sidebarState)).toBe('hidden');
|
||||
|
||||
store.toggleSidebar();
|
||||
expect(getStoreValue(store.sidebarState)).toBe('expanded');
|
||||
});
|
||||
|
||||
it('theme toggle works and applies to document', async () => {
|
||||
const store = await import('../../src/lib/stores/layout');
|
||||
|
||||
store.setTheme('light');
|
||||
store.toggleTheme();
|
||||
expect(getStoreValue(store.theme)).toBe('dark');
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith('headroom_theme', 'dark');
|
||||
|
||||
store.toggleTheme();
|
||||
expect(getStoreValue(store.theme)).toBe('light');
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith('headroom_theme', 'light');
|
||||
});
|
||||
});
|
||||
17
frontend/tests/unit/navigation.config.test.ts
Normal file
17
frontend/tests/unit/navigation.config.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { navigationSections } from '../../src/lib/config/navigation';
|
||||
|
||||
describe('navigation config', () => {
|
||||
it('has expected section structure', () => {
|
||||
expect(navigationSections).toHaveLength(3);
|
||||
|
||||
expect(navigationSections[0].title).toBe('PLANNING');
|
||||
expect(navigationSections[0].items).toHaveLength(5);
|
||||
|
||||
expect(navigationSections[1].title).toBe('REPORTS');
|
||||
expect(navigationSections[1].items).toHaveLength(5);
|
||||
|
||||
expect(navigationSections[2].title).toBe('ADMIN');
|
||||
expect(navigationSections[2].roles).toEqual(['superuser']);
|
||||
});
|
||||
});
|
||||
44
frontend/tests/unit/period.store.test.ts
Normal file
44
frontend/tests/unit/period.store.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
||||
|
||||
function getStoreValue<T>(store: { subscribe: (run: (value: T) => void) => () => void }): T {
|
||||
let value!: T;
|
||||
const unsubscribe = store.subscribe((current) => {
|
||||
value = current;
|
||||
});
|
||||
unsubscribe();
|
||||
return value;
|
||||
}
|
||||
|
||||
describe('period store', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
(localStorage.getItem as Mock).mockReturnValue(null);
|
||||
});
|
||||
|
||||
it('initializes with current month', async () => {
|
||||
const store = await import('../../src/lib/stores/period');
|
||||
|
||||
const now = new Date();
|
||||
const expected = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
expect(getStoreValue(store.selectedPeriod)).toBe(expected);
|
||||
});
|
||||
|
||||
it('previousMonth decrements correctly', async () => {
|
||||
const store = await import('../../src/lib/stores/period');
|
||||
|
||||
store.setPeriod('2025-01');
|
||||
store.previousMonth();
|
||||
|
||||
expect(getStoreValue(store.selectedPeriod)).toBe('2024-12');
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith('headroom_selected_period', '2024-12');
|
||||
});
|
||||
|
||||
it('nextMonth increments correctly', async () => {
|
||||
const store = await import('../../src/lib/stores/period');
|
||||
|
||||
store.setPeriod('2025-12');
|
||||
store.nextMonth();
|
||||
|
||||
expect(getStoreValue(store.selectedPeriod)).toBe('2026-01');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user