Files
TREK/client/src/components/Layout/Navbar.test.tsx
T
Maurice 86513fda0d Managed mode: hand place search, routing, plugins and docs to the operator
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.
2026-11-03 08:44:45 +01:00

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();
}
});
});