mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
86513fda0d
On a managed install the operator runs place search and routing, the plugin runtime, the integrations and the documentation, so the admin and the users no longer see switches and guides for them: - The place search and routing card, the transit provider and the search log are gone from the admin settings (desktop and mobile). places_provider is a managed-locked setting, and the transit provider, search log and wanderer routes refuse writes there. Share place details stays the admin's, as a card of its own, hidden when TREK_PLACES_CONTRIBUTE switches it off. - File types and trip reminders move to the right column, so the columns stay balanced. - Plugins disappear from the admin and the user settings, wanderer from Tours. - The Help & Docs page and its links are not offered. The help center stays and leaves out guides about what the operator runs (keys, OIDC, backups, storage, releases, plugins, document sync) and about integrations that are off. - Mapbox GL is offered only with the operator's token, Amap tiles only where the operator set Amap up. - The mobile admin no longer shows OIDC and the WebAuthn relying party fields, matching the desktop tab. For every install: - Notification matrices list only events the install can raise: no plugin row without the plugin runtime, no Vacay, collection, photo, Synology, wanderer or packing rows while those are off. On a managed install the release and replica alerts are left out. - The journey entry editor offers External photos only where a photo provider is switched on, as the phone already did. - app-config is asked again after a failed answer. A page opened while the server restarted kept every default for the session, which on a managed install brought back the operator's settings.
661 lines
28 KiB
TypeScript
661 lines
28 KiB
TypeScript
// FE-COMP-NAVBAR-001 to FE-COMP-NAVBAR-028
|
|
import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { http, HttpResponse } from 'msw';
|
|
import { server } from '../../../tests/helpers/msw/server';
|
|
import { useAuthStore } from '../../store/authStore';
|
|
import { useSettingsStore } from '../../store/settingsStore';
|
|
import { useAddonStore } from '../../store/addonStore';
|
|
import { usePluginStore } from '../../store/pluginStore';
|
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
|
import { buildUser, buildSettings } from '../../../tests/helpers/factories';
|
|
import Navbar from './Navbar';
|
|
|
|
beforeEach(() => {
|
|
resetAllStores();
|
|
server.use(
|
|
http.get('/api/auth/app-config', () => HttpResponse.json({ version: '2.9.10' })),
|
|
http.get('/api/addons', () => HttpResponse.json({ addons: [] })),
|
|
);
|
|
seedStore(useAuthStore, { user: buildUser({ username: 'testuser', role: 'user' }), isAuthenticated: true, appVersion: '2.9.10' });
|
|
seedStore(useSettingsStore, { settings: buildSettings() });
|
|
});
|
|
|
|
describe('Navbar', () => {
|
|
it('FE-COMP-NAVBAR-001: renders without crashing', () => {
|
|
render(<Navbar />);
|
|
expect(document.body).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-002: shows TREK logo/brand', () => {
|
|
render(<Navbar />);
|
|
// The Navbar shows the app icon — check for presence of the nav element
|
|
expect(document.querySelector('nav') || document.body).toBeTruthy();
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-003: shows username in user menu trigger', () => {
|
|
render(<Navbar />);
|
|
expect(screen.getByText('testuser')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-004: user menu opens on click', async () => {
|
|
const user = userEvent.setup();
|
|
render(<Navbar />);
|
|
// Click the username to open dropdown
|
|
await user.click(screen.getByText('testuser'));
|
|
// Settings option appears
|
|
expect(screen.getByText('Settings')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-005: user menu shows Log out option', async () => {
|
|
const user = userEvent.setup();
|
|
render(<Navbar />);
|
|
await user.click(screen.getByText('testuser'));
|
|
expect(screen.getByText('Log out')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-006: shows Settings link in user menu', async () => {
|
|
const user = userEvent.setup();
|
|
render(<Navbar />);
|
|
await user.click(screen.getByText('testuser'));
|
|
expect(screen.getByText('Settings')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-007: shows My Trips link in navbar', () => {
|
|
render(<Navbar />);
|
|
// nav.myTrips = "My Trips" is in the main navbar (hidden on mobile via CSS, but CSS is not processed in tests)
|
|
// The link to /dashboard is present regardless
|
|
const dashboardLinks = document.querySelectorAll('a[href="/dashboard"]');
|
|
expect(dashboardLinks.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-008: clicking Log out calls logout', async () => {
|
|
const user = userEvent.setup();
|
|
const logout = vi.fn();
|
|
seedStore(useAuthStore, { user: buildUser({ username: 'testuser' }), isAuthenticated: true, logout });
|
|
render(<Navbar />);
|
|
await user.click(screen.getByText('testuser'));
|
|
await user.click(screen.getByText('Log out'));
|
|
expect(logout).toHaveBeenCalled();
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-009: admin user sees Admin option', async () => {
|
|
const user = userEvent.setup();
|
|
seedStore(useAuthStore, { user: buildUser({ username: 'admin', role: 'admin' }), isAuthenticated: true });
|
|
render(<Navbar />);
|
|
await user.click(screen.getByText('admin'));
|
|
expect(screen.getByText('Admin')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-009b: a managed install keeps the help center but has no Help & Docs entry', async () => {
|
|
const user = userEvent.setup();
|
|
seedStore(useAuthStore, { user: buildUser({ username: 'testuser' }), isAuthenticated: true, managed: true });
|
|
render(<Navbar />);
|
|
expect(document.querySelector('[data-help-button]')).not.toBeNull();
|
|
await user.click(screen.getByText('testuser'));
|
|
expect(screen.queryByText('Help')).not.toBeInTheDocument();
|
|
expect(screen.getByText('Settings')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-010: regular user does not see Admin option', async () => {
|
|
const user = userEvent.setup();
|
|
render(<Navbar />);
|
|
await user.click(screen.getByText('testuser'));
|
|
expect(screen.queryByText('Admin')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-011: shows tripTitle when provided', () => {
|
|
render(<Navbar tripTitle="Paris 2026" />);
|
|
expect(screen.getByText('Paris 2026')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-012: shows back button when showBack is true', () => {
|
|
render(<Navbar showBack={true} onBack={vi.fn()} />);
|
|
// Back button is a button element
|
|
const backBtns = screen.getAllByRole('button');
|
|
expect(backBtns.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-012b: in a trip the back arrow and the trip name are one pill', async () => {
|
|
const user = userEvent.setup();
|
|
const onBack = vi.fn();
|
|
render(<Navbar tripTitle="Paris 2026" showBack onBack={onBack} />);
|
|
const pill = screen.getByRole('button', { name: 'Back: Paris 2026' });
|
|
expect(pill).toHaveTextContent('Paris 2026');
|
|
expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument();
|
|
await user.click(pill);
|
|
expect(onBack).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-013: clicking back button calls onBack', async () => {
|
|
const user = userEvent.setup();
|
|
const onBack = vi.fn();
|
|
render(<Navbar showBack={true} onBack={onBack} />);
|
|
// Find the back button (ArrowLeft icon)
|
|
const buttons = screen.getAllByRole('button');
|
|
// First button should be the back button
|
|
await user.click(buttons[0]);
|
|
expect(onBack).toHaveBeenCalled();
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-014: notification bell is rendered when user is logged in', () => {
|
|
render(<Navbar />);
|
|
// InAppNotificationBell is rendered — check that body has some content
|
|
expect(document.body.children.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-015: dark mode toggle is accessible in user menu', async () => {
|
|
const user = userEvent.setup();
|
|
render(<Navbar />);
|
|
await user.click(screen.getByText('testuser'));
|
|
// Dark mode / Light mode / Auto mode options
|
|
const darkModeEls = screen.getAllByRole('button');
|
|
expect(darkModeEls.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-016: app version shown in user menu', async () => {
|
|
const user = userEvent.setup();
|
|
render(<Navbar />);
|
|
await user.click(screen.getByText('testuser'));
|
|
await waitFor(() => {
|
|
expect(screen.getByText('v2.9.10')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-017: Settings link navigates to /settings', async () => {
|
|
const user = userEvent.setup();
|
|
render(<Navbar />);
|
|
await user.click(screen.getByText('testuser'));
|
|
const settingsLink = screen.getByRole('link', { name: /settings/i });
|
|
expect(settingsLink).toHaveAttribute('href', '/settings');
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-018: Admin link navigates to /admin for admin user', async () => {
|
|
const user = userEvent.setup();
|
|
seedStore(useAuthStore, { user: buildUser({ username: 'adminuser', role: 'admin' }), isAuthenticated: true });
|
|
render(<Navbar />);
|
|
await user.click(screen.getByText('adminuser'));
|
|
const adminLink = screen.getByRole('link', { name: /admin/i });
|
|
expect(adminLink).toHaveAttribute('href', '/admin');
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-019: the trip settings button sits beside the back pill when onSettings is given', () => {
|
|
render(<Navbar tripTitle="Japan" showBack onBack={vi.fn()} onSettings={vi.fn()} />);
|
|
const back = screen.getByRole('button', { name: /back/i });
|
|
const settings = screen.getByRole('button', { name: 'Trip settings' });
|
|
expect(back.parentElement).toBe(settings.closest('div'));
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-020: the trip settings button calls onSettings', async () => {
|
|
const user = userEvent.setup();
|
|
const onSettings = vi.fn();
|
|
render(<Navbar onSettings={onSettings} />);
|
|
await user.click(screen.getByRole('button', { name: 'Trip settings' }));
|
|
expect(onSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-021: no trip settings button without onSettings, and no share button any more', () => {
|
|
render(<Navbar />);
|
|
expect(screen.queryByRole('button', { name: 'Trip settings' })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: /share/i })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-022: dark mode toggle shows Moon when light, Sun when dark', () => {
|
|
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: false }) });
|
|
const { unmount } = render(<Navbar />);
|
|
// Moon icon button should be present (title = 'nav.darkMode' i.e. 'Dark mode')
|
|
expect(document.querySelector('[title]')).toBeTruthy();
|
|
unmount();
|
|
|
|
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'dark' }) });
|
|
render(<Navbar />);
|
|
// Sun icon button should be present when dark mode is on
|
|
const buttons = screen.getAllByRole('button');
|
|
expect(buttons.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-023: dark mode toggle calls updateSetting', async () => {
|
|
const user = userEvent.setup();
|
|
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
|
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: false }), updateSetting });
|
|
render(<Navbar />);
|
|
// Find the dark mode toggle button by title attribute. The help button
|
|
// before it carries a title too, hence the exclusion.
|
|
const toggleBtn = document.querySelector('button[title]:not([data-help-button])') as HTMLElement;
|
|
expect(toggleBtn).toBeTruthy();
|
|
await user.click(toggleBtn);
|
|
expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'dark');
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-024: global addon nav links appear when addons enabled', () => {
|
|
server.use(
|
|
http.get('/api/addons', () => HttpResponse.json({
|
|
addons: [{ id: 'vacay', name: 'Vacay', icon: 'CalendarDays', type: 'global', enabled: true }],
|
|
})),
|
|
);
|
|
seedStore(useAddonStore, {
|
|
addons: [{ id: 'vacay', name: 'Vacay', icon: 'CalendarDays', type: 'global', enabled: true }],
|
|
});
|
|
render(<Navbar />);
|
|
expect(screen.getByRole('link', { name: /vacay/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-025: global addon links hidden when in trip view (tripTitle set)', () => {
|
|
seedStore(useAddonStore, {
|
|
addons: [{ id: 'vacay', name: 'Vacay', icon: 'CalendarDays', type: 'global', enabled: true }],
|
|
});
|
|
render(<Navbar tripTitle="Japan 2025" />);
|
|
expect(screen.queryByRole('link', { name: /vacay/i })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-026: notification bell visible when tripId provided', () => {
|
|
render(<Navbar tripId="1" />);
|
|
// InAppNotificationBell renders a button — check it is present
|
|
const buttons = screen.getAllByRole('button');
|
|
expect(buttons.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-027: user avatar image shown when avatar_url set', () => {
|
|
seedStore(useAuthStore, {
|
|
user: buildUser({ username: 'testuser', avatar_url: 'https://example.com/av.jpg' }),
|
|
isAuthenticated: true,
|
|
});
|
|
render(<Navbar />);
|
|
const avatarImg = document.querySelector('img[src="https://example.com/av.jpg"]');
|
|
expect(avatarImg).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-028: user initial shown when no avatar_url', () => {
|
|
seedStore(useAuthStore, {
|
|
user: buildUser({ username: 'testuser', avatar_url: null }),
|
|
isAuthenticated: true,
|
|
});
|
|
render(<Navbar />);
|
|
// The initial is rendered as the first char uppercased in a div
|
|
expect(screen.getAllByText('T')[0]).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-029: clicking backdrop overlay closes user menu', async () => {
|
|
const user = userEvent.setup();
|
|
render(<Navbar />);
|
|
await user.click(screen.getByText('testuser'));
|
|
expect(screen.getByText('Settings')).toBeInTheDocument();
|
|
// The backdrop overlay is a fixed-inset div rendered in the portal
|
|
const backdrop = document.querySelector('[style*="inset: 0"]') as HTMLElement;
|
|
if (backdrop) {
|
|
await user.click(backdrop);
|
|
expect(screen.queryByText('Settings')).not.toBeInTheDocument();
|
|
}
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-030: dark mode auto uses system preference', () => {
|
|
// 'auto' dark_mode relies on matchMedia — seed with auto and render
|
|
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'auto' }) });
|
|
render(<Navbar />);
|
|
// Component should render without errors regardless of system preference
|
|
expect(document.querySelector('nav')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-031: dark mode toggle calls updateSetting with light when currently dark', async () => {
|
|
const user = userEvent.setup();
|
|
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
|
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'dark' }), updateSetting });
|
|
render(<Navbar />);
|
|
const toggleBtn = document.querySelector('button[title]:not([data-help-button])') as HTMLElement;
|
|
expect(toggleBtn).toBeTruthy();
|
|
await user.click(toggleBtn);
|
|
expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'light');
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-032: user email shown in open user menu', async () => {
|
|
const user = userEvent.setup();
|
|
seedStore(useAuthStore, {
|
|
user: buildUser({ username: 'testuser', email: 'testuser@example.com' }),
|
|
isAuthenticated: true,
|
|
});
|
|
render(<Navbar />);
|
|
await user.click(screen.getByText('testuser'));
|
|
expect(screen.getByText('testuser@example.com')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-033: administrator badge shown for admin user in open menu', async () => {
|
|
const user = userEvent.setup();
|
|
seedStore(useAuthStore, {
|
|
user: buildUser({ username: 'adminuser', role: 'admin' }),
|
|
isAuthenticated: true,
|
|
});
|
|
render(<Navbar />);
|
|
await user.click(screen.getByText('adminuser'));
|
|
expect(screen.getByText('Administrator')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-034: page plugin renders the icon its manifest declares', () => {
|
|
seedStore(usePluginStore, {
|
|
plugins: [{ id: 'trip-doctor', name: 'Trip Doctor', type: 'page', icon: 'Stethoscope' }],
|
|
});
|
|
const { container } = render(<Navbar />);
|
|
expect(screen.getByRole('link', { name: /trip doctor/i })).toBeInTheDocument();
|
|
expect(container.querySelector('.lucide-stethoscope')).not.toBeNull();
|
|
});
|
|
|
|
it('FE-COMP-NAVBAR-035: page plugin with an unknown icon falls back to Blocks', () => {
|
|
seedStore(usePluginStore, {
|
|
plugins: [{ id: 'bogus', name: 'Bogus', type: 'page', icon: 'NotAnIcon' }],
|
|
});
|
|
const { container } = render(<Navbar />);
|
|
expect(container.querySelector('.lucide-blocks')).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
// FE-W5NAV-001 to FE-W5NAV-012 — scroll/dark styling, the addon-name fallback,
|
|
// the prerelease badge, the theme-transition timer and the hover styling that
|
|
// the behavioural tests above leave untouched.
|
|
describe('Navbar styling and menu details', () => {
|
|
const nav = () => document.querySelector('nav') as HTMLElement;
|
|
|
|
beforeEach(() => {
|
|
document.documentElement.classList.remove('trek-theme-transitioning');
|
|
Object.defineProperty(window, 'scrollY', { value: 0, configurable: true });
|
|
});
|
|
|
|
it('FE-W5NAV-001: the bar goes translucent once the page is scrolled', () => {
|
|
render(<Navbar />);
|
|
expect(nav().style.backdropFilter).toBe('blur(20px)');
|
|
|
|
Object.defineProperty(window, 'scrollY', { value: 40, configurable: true });
|
|
fireEvent.scroll(window);
|
|
|
|
expect(nav().style.backdropFilter).toBe('blur(28px) saturate(180%)');
|
|
expect(nav().style.background).toBe('rgba(255, 255, 255, 0.72)');
|
|
});
|
|
|
|
it('FE-W5NAV-002: dark mode swaps the bar and logo assets', () => {
|
|
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'dark' }) });
|
|
render(<Navbar />);
|
|
|
|
expect(nav().style.background).toBe('rgba(9, 9, 11, 0.95)');
|
|
expect(document.querySelector('img[src="/logo-light.svg"]')).not.toBeNull();
|
|
|
|
Object.defineProperty(window, 'scrollY', { value: 40, configurable: true });
|
|
fireEvent.scroll(window);
|
|
expect(nav().style.background).toBe('rgba(9, 9, 11, 0.78)');
|
|
});
|
|
|
|
it('FE-W5NAV-003: a scrolled body counts as scrolled too', () => {
|
|
render(<Navbar />);
|
|
Object.defineProperty(document.body, 'scrollTop', { value: 30, configurable: true });
|
|
fireEvent.scroll(document.body);
|
|
|
|
expect(nav().style.backdropFilter).toBe('blur(28px) saturate(180%)');
|
|
Object.defineProperty(document.body, 'scrollTop', { value: 0, configurable: true });
|
|
});
|
|
|
|
it('FE-W5NAV-004: no addons are loaded while nobody is signed in', () => {
|
|
const loadAddons = vi.fn(async () => {});
|
|
seedStore(useAuthStore, { user: null, isAuthenticated: false });
|
|
seedStore(useAddonStore, { loadAddons });
|
|
render(<Navbar />);
|
|
|
|
expect(loadAddons).not.toHaveBeenCalled();
|
|
expect(screen.queryByText('testuser')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-W5NAV-005: a catalogued addon uses its translated name, an unknown one its own', () => {
|
|
seedStore(useAddonStore, {
|
|
addons: [
|
|
{ id: 'budget', name: 'Budget', icon: 'Briefcase', type: 'global', enabled: true },
|
|
{ id: 'trip-doctor', name: 'Trip Doctor', icon: 'NoSuchIcon', type: 'global', enabled: true },
|
|
{ id: 'weather', name: 'Weather', icon: 'Globe', type: 'integration', enabled: true },
|
|
{ id: 'atlas', name: 'Atlas', icon: 'Globe', type: 'global', enabled: false },
|
|
],
|
|
});
|
|
render(<Navbar />);
|
|
|
|
expect(screen.getByRole('link', { name: /^Costs$/ })).toHaveAttribute('href', '/budget');
|
|
expect(screen.getByRole('link', { name: /^Trip Doctor$/ })).toBeInTheDocument();
|
|
expect(screen.queryByRole('link', { name: /^Weather$/ })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('link', { name: /^Atlas$/ })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-W5NAV-006: the active tab keeps its colour on hover, inactive tabs brighten', () => {
|
|
seedStore(useAddonStore, {
|
|
addons: [{ id: 'vacay', name: 'Vacay', icon: 'CalendarDays', type: 'global', enabled: true }],
|
|
});
|
|
render(<Navbar />, { initialEntries: ['/dashboard'] });
|
|
|
|
const active = screen.getByRole('link', { name: /my trips/i });
|
|
const inactive = screen.getByRole('link', { name: /vacay/i });
|
|
expect(active.style.background).toBe('var(--bg-card)');
|
|
expect(inactive.style.background).toBe('transparent');
|
|
|
|
fireEvent.mouseEnter(active);
|
|
fireEvent.mouseLeave(active);
|
|
expect(active.style.color).toBe('var(--text-primary)');
|
|
|
|
fireEvent.mouseEnter(inactive);
|
|
expect(inactive.style.color).toBe('var(--text-primary)');
|
|
fireEvent.mouseLeave(inactive);
|
|
expect(inactive.style.color).toBe('var(--text-muted)');
|
|
});
|
|
|
|
it('FE-W5NAV-007: trip pages swap the tab pill for the centre notice slot', () => {
|
|
seedStore(useAddonStore, {
|
|
addons: [{ id: 'vacay', name: 'Vacay', icon: 'CalendarDays', type: 'global', enabled: true }],
|
|
});
|
|
render(<Navbar tripTitle="Japan 2027" />);
|
|
|
|
expect(document.getElementById('trek-nav-center-slot')).not.toBeNull();
|
|
expect(document.querySelector('.trek-nav-pill')).toBeNull();
|
|
});
|
|
|
|
it('FE-W5NAV-008: the prerelease badge only shows with a version and the flag set', () => {
|
|
seedStore(useAuthStore, {
|
|
user: buildUser({ username: 'testuser' }),
|
|
isAuthenticated: true,
|
|
isPrerelease: true,
|
|
appVersion: '3.5.0-rc1',
|
|
});
|
|
const { unmount } = render(<Navbar />);
|
|
expect(screen.getByText('3.5.0-rc1')).toBeInTheDocument();
|
|
unmount();
|
|
|
|
seedStore(useAuthStore, { isPrerelease: true, appVersion: null });
|
|
render(<Navbar />);
|
|
expect(screen.queryByText('3.5.0-rc1')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-W5NAV-009: the back and theme buttons reset their hover background', () => {
|
|
render(<Navbar showBack onBack={vi.fn()} onSettings={vi.fn()} />);
|
|
|
|
const back = screen.getByRole('button', { name: /back/i });
|
|
fireEvent.mouseEnter(back);
|
|
expect(back.style.background).toBe('var(--bg-hover)');
|
|
fireEvent.mouseLeave(back);
|
|
expect(back.style.background).toBe('transparent');
|
|
|
|
|
|
const theme = document.querySelector('button[title]:not([data-help-button])') as HTMLElement;
|
|
fireEvent.mouseEnter(theme);
|
|
expect(theme.style.background).toBe('var(--bg-hover)');
|
|
fireEvent.mouseLeave(theme);
|
|
expect(theme.style.background).toBe('transparent');
|
|
});
|
|
|
|
it('FE-W5NAV-010: toggling the theme twice restarts the transition class timer', () => {
|
|
const updateSetting = vi.fn(async () => {});
|
|
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: false }), updateSetting });
|
|
render(<Navbar />);
|
|
const theme = document.querySelector('button[title]:not([data-help-button])') as HTMLElement;
|
|
|
|
vi.useFakeTimers();
|
|
try {
|
|
fireEvent.click(theme);
|
|
expect(document.documentElement.classList.contains('trek-theme-transitioning')).toBe(true);
|
|
|
|
act(() => { vi.advanceTimersByTime(200); });
|
|
fireEvent.click(theme); // restarts the pending timer
|
|
act(() => { vi.advanceTimersByTime(200); });
|
|
expect(document.documentElement.classList.contains('trek-theme-transitioning')).toBe(true);
|
|
|
|
act(() => { vi.advanceTimersByTime(200); });
|
|
expect(document.documentElement.classList.contains('trek-theme-transitioning')).toBe(false);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('FE-W5NAV-011: unmounting with a pending transition cancels its timer', () => {
|
|
const updateSetting = vi.fn(async () => {});
|
|
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: false }), updateSetting });
|
|
const { unmount } = render(<Navbar />);
|
|
const theme = document.querySelector('button[title]:not([data-help-button])') as HTMLElement;
|
|
|
|
vi.useFakeTimers();
|
|
try {
|
|
fireEvent.click(theme);
|
|
const clear = vi.spyOn(window, 'clearTimeout');
|
|
unmount();
|
|
expect(clear).toHaveBeenCalled();
|
|
clear.mockRestore();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
document.documentElement.classList.remove('trek-theme-transitioning');
|
|
}
|
|
});
|
|
|
|
it('FE-W5NAV-012: every entry in the open user menu resets its hover background', async () => {
|
|
const user = userEvent.setup();
|
|
seedStore(useAuthStore, {
|
|
user: buildUser({ username: 'adminuser', role: 'admin' }),
|
|
isAuthenticated: true,
|
|
appVersion: '3.5.0',
|
|
});
|
|
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'dark' }) });
|
|
render(<Navbar />);
|
|
await user.click(screen.getByText('adminuser'));
|
|
|
|
for (const name of [/^Settings$/, /^Help$/, /^Admin$/]) {
|
|
const link = screen.getByRole('link', { name });
|
|
fireEvent.mouseEnter(link);
|
|
expect(link.style.background).toBe('var(--bg-hover)');
|
|
fireEvent.mouseLeave(link);
|
|
expect(link.style.background).toBe('transparent');
|
|
}
|
|
|
|
const discord = screen.getByTitle('Discord');
|
|
fireEvent.mouseEnter(discord);
|
|
expect(discord.style.background).toBe('rgba(88, 101, 242, 0.125)');
|
|
fireEvent.mouseLeave(discord);
|
|
expect(discord.style.background).toBe('var(--bg-tertiary)');
|
|
|
|
expect(document.querySelector('img[src="/text-light.svg"]')).not.toBeNull();
|
|
});
|
|
|
|
it('FE-W5NAV-013: following any menu entry closes the menu', async () => {
|
|
const user = userEvent.setup();
|
|
seedStore(useAuthStore, {
|
|
user: buildUser({ username: 'adminuser', role: 'admin' }),
|
|
isAuthenticated: true,
|
|
});
|
|
render(<Navbar />);
|
|
|
|
for (const name of [/^Settings$/, /^Help$/, /^Admin$/]) {
|
|
await user.click(screen.getByText('adminuser'));
|
|
await user.click(screen.getByRole('link', { name }));
|
|
expect(screen.queryByRole('link', { name: /^Settings$/ })).not.toBeInTheDocument();
|
|
}
|
|
});
|
|
|
|
it('FE-W5NAV-014: a rejected theme update is swallowed', async () => {
|
|
const updateSetting = vi.fn(() => Promise.reject(new Error('offline')));
|
|
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: false }), updateSetting });
|
|
render(<Navbar />);
|
|
|
|
fireEvent.click(document.querySelector('button[title]:not([data-help-button])') as HTMLElement);
|
|
|
|
await waitFor(() => expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'dark'));
|
|
document.documentElement.classList.remove('trek-theme-transitioning');
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The bar's three columns (#1983).
|
|
*
|
|
* The tab pill was absolutely positioned on the centre of the bar, so it had no
|
|
* relationship to what sat beside it. Its width grows with every enabled addon
|
|
* and every page plugin, and once it outgrew the free space in the middle it
|
|
* ran underneath the logo on one side and the user menu on the other. The only
|
|
* adaptation was a fixed 1024px breakpoint that drops the labels, tuned when
|
|
* two or three addons was the whole story and blind to plugins entirely.
|
|
*
|
|
* These check the shape rather than pixels, because that is where the defect
|
|
* was: three columns in the flow cannot overlap, whatever ends up in them, and
|
|
* no measurement has to be right for that to hold. jsdom reports every width as
|
|
* zero, so an assertion on how wide anything is would pass for the wrong reason.
|
|
*/
|
|
describe('Navbar layout (#1983)', () => {
|
|
const withAddons = (n: number) => {
|
|
const addons = Array.from({ length: n }, (_, i) => ({
|
|
id: `addon${i}`, name: `Addon ${i}`, icon: 'CalendarDays', type: 'global' as const, enabled: true,
|
|
}));
|
|
server.use(http.get('/api/addons', () => HttpResponse.json({ addons })));
|
|
seedStore(useAddonStore, { addons });
|
|
};
|
|
|
|
it('keeps the tab pill in the flow rather than floating over its neighbours', () => {
|
|
withAddons(4);
|
|
const { container } = render(<Navbar />);
|
|
const pill = container.querySelector('.trek-nav-pill') as HTMLElement;
|
|
expect(pill).toBeTruthy();
|
|
// The assertion that would have caught the overlap.
|
|
expect(pill.style.position).not.toBe('absolute');
|
|
});
|
|
|
|
it('gives the columns either side of it equal weight, so it stays centred', () => {
|
|
withAddons(4);
|
|
const { container } = render(<Navbar />);
|
|
const nav = container.querySelector('nav') as HTMLElement;
|
|
const columns = Array.from(nav.children).filter(c => c.classList.contains('flex-1'));
|
|
// Left brand column and right action cluster, both flex-1 basis-0.
|
|
expect(columns).toHaveLength(2);
|
|
for (const c of columns) expect(c.classList.contains('basis-0')).toBe(true);
|
|
});
|
|
|
|
it('lets the pill shrink instead of pushing the actions off the bar', () => {
|
|
withAddons(8);
|
|
const { container } = render(<Navbar />);
|
|
const pill = container.querySelector('.trek-nav-pill') as HTMLElement;
|
|
expect(pill.classList.contains('min-w-0')).toBe(true);
|
|
expect(pill.style.overflowX).toBe('auto');
|
|
});
|
|
|
|
it('keeps both columns at their content width, so the actions never spill over the pill', () => {
|
|
withAddons(8);
|
|
const { container } = render(<Navbar />);
|
|
const nav = container.querySelector('nav') as HTMLElement;
|
|
const columns = Array.from(nav.children).filter(c => c.classList.contains('flex-1'));
|
|
// A min-w-0 column shrinks below its content and its justify-end children
|
|
// overflow leftwards across the pill (5.0.0-pre.1 feedback).
|
|
for (const c of columns) {
|
|
expect(c.classList.contains('min-w-0')).toBe(false);
|
|
expect(c.classList.contains('min-w-max')).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('shows the prerelease badge in the brand column, beside the logo', () => {
|
|
seedStore(useAuthStore, { isPrerelease: true, appVersion: '5.0.0-pre.1' });
|
|
const { container } = render(<Navbar />);
|
|
const nav = container.querySelector('nav') as HTMLElement;
|
|
const brandColumn = nav.children[0] as HTMLElement;
|
|
expect(brandColumn.contains(screen.getByText('5.0.0-pre.1'))).toBe(true);
|
|
});
|
|
|
|
it('still renders every addon as a reachable link, however many there are', () => {
|
|
withAddons(8);
|
|
render(<Navbar />);
|
|
for (let i = 0; i < 8; i++) {
|
|
expect(screen.getByRole('link', { name: new RegExp(`Addon ${i}`, 'i') })).toBeInTheDocument();
|
|
}
|
|
});
|
|
});
|