Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions apps/web/src/app/layout/AppLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ vi.mock('./CommandPalette', () => ({
CommandPalette: () => null,
}));

vi.mock('@/features/assistant/components/AssistantPanel', () => ({
AssistantPanel: () => <div data-testid="assistant-panel">Assistant</div>,
}));

vi.mock('@/shared/hooks/useWorker', () => ({
useWorker: vi.fn(),
}));
Expand Down Expand Up @@ -174,6 +178,7 @@ describe('AppLayout', () => {
});

expect(container.textContent).toContain('Sim to Playoffs');
expect(container.textContent).toContain('Assistant');

const simToPlayoffsButton = Array.from(container.querySelectorAll('button')).find(
(button) => button.textContent?.includes('Sim to Playoffs'),
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/app/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { SeasonFlowCard } from './SeasonFlowCard';
import { MonthlyPulseOverlay } from './MonthlyPulseOverlay';
import { TickerBar } from './TickerBar';
import { PressConferenceModal } from '@/features/press-room/components/PressConferenceModal';
import { AssistantPanel } from '@/features/assistant/components/AssistantPanel';
import { TourProvider } from '@/shared/components/TourProvider';
import { KeyboardShortcutsPanel } from '@/shared/components/KeyboardShortcutsPanel';
import type { SeasonFlowState } from './seasonFlow';
Expand Down Expand Up @@ -494,6 +495,8 @@ export function AppLayout() {
flow={seasonFlow}
/>

<AssistantPanel tickerFeed={tickerFeed} />

{/* Command palette overlay */}
<CommandPalette
open={commandPaletteOpen}
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/app/routes/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ vi.mock('@/app/layout/AppLayout', async () => {
};
});

vi.mock('@/features/assistant/components/AssistantPanel', () => ({
AssistantPanel: () => <div data-testid="setup-assistant">Assistant</div>,
}));

vi.mock('@/features/setup/routes/SetupPage', () => ({
default: () => <div>Setup Route Ready</div>,
}));
Expand Down Expand Up @@ -122,6 +126,7 @@ describe('AppRoutes', () => {
});

expect(container.textContent).toContain('Setup Route Ready');
expect(container.textContent).toContain('Assistant');
});

it('navigates to an in-session route without breaking nested layout rendering', async () => {
Expand Down
93 changes: 53 additions & 40 deletions apps/web/src/app/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { lazy, Suspense } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { AppLayout } from '@/app/layout/AppLayout';
import { RouteErrorBoundary } from '@/app/providers/RouteErrorBoundary';
import { AssistantPanel } from '@/features/assistant/components/AssistantPanel';

// Lazy-loaded route components
const DashboardPage = lazy(
Expand Down Expand Up @@ -122,51 +123,63 @@ function withRouteBoundary(routeLabel: string, element: JSX.Element) {
);
}

function PreGameAssistantMount() {
const location = useLocation();
if (location.pathname !== '/' && location.pathname !== '/onboarding') {
return null;
}

return <AssistantPanel />;
}

export function AppRoutes() {
return (
<Suspense fallback={<LoadingFallback />}>
<Routes>
<Route path="/" element={withRouteBoundary('Save Hub', <SetupPage />)} />
<Route path="onboarding" element={withRouteBoundary('Onboarding', <RevisedOnboardingPage />)} />
<Route element={<AppLayout />}>
<Route path="dashboard" element={withRouteBoundary('Dashboard', <DashboardPage />)} />
<Route path="roster" element={withRouteBoundary('Roster', <RosterPage />)} />
<Route path="minors" element={withRouteBoundary('Minors', <MinorsPage />)} />
<Route path="players" element={withRouteBoundary('Players', <PlayersPage />)} />
<Route path="players/compare" element={withRouteBoundary('Player Comparison', <PlayerComparisonPage />)} />
<Route path="players/:playerId" element={withRouteBoundary('Player Profile', <PlayerProfilePage />)} />
<Route path="scouting" element={withRouteBoundary('Scouting', <ScoutingPage />)} />
<Route path="staff" element={withRouteBoundary('Staff', <StaffPage />)} />
<Route path="draft" element={withRouteBoundary('Draft', <DraftPage />)} />
<Route path="trade" element={withRouteBoundary('Trade', <TradePage />)} />
<Route path="standings" element={withRouteBoundary('Standings', <StandingsPage />)} />
<Route path="leaders" element={withRouteBoundary('Leaders', <LeadersPage />)} />
<Route path="league">
<>
<Routes>
<Route path="/" element={withRouteBoundary('Save Hub', <SetupPage />)} />
<Route path="onboarding" element={withRouteBoundary('Onboarding', <RevisedOnboardingPage />)} />
<Route element={<AppLayout />}>
<Route path="dashboard" element={withRouteBoundary('Dashboard', <DashboardPage />)} />
<Route path="roster" element={withRouteBoundary('Roster', <RosterPage />)} />
<Route path="minors" element={withRouteBoundary('Minors', <MinorsPage />)} />
<Route path="players" element={withRouteBoundary('Players', <PlayersPage />)} />
<Route path="players/compare" element={withRouteBoundary('Player Comparison', <PlayerComparisonPage />)} />
<Route path="players/:playerId" element={withRouteBoundary('Player Profile', <PlayerProfilePage />)} />
<Route path="scouting" element={withRouteBoundary('Scouting', <ScoutingPage />)} />
<Route path="staff" element={withRouteBoundary('Staff', <StaffPage />)} />
<Route path="draft" element={withRouteBoundary('Draft', <DraftPage />)} />
<Route path="trade" element={withRouteBoundary('Trade', <TradePage />)} />
<Route path="standings" element={withRouteBoundary('Standings', <StandingsPage />)} />
<Route path="leaders" element={withRouteBoundary('Leaders', <LeadersPage />)} />
<Route index element={<Navigate to="standings" replace />} />
<Route path="league">
<Route path="standings" element={withRouteBoundary('Standings', <StandingsPage />)} />
<Route path="leaders" element={withRouteBoundary('Leaders', <LeadersPage />)} />
<Route index element={<Navigate to="standings" replace />} />
</Route>
<Route path="schedule" element={withRouteBoundary('Schedule', <SchedulePage />)} />
<Route path="games/:gameIndex" element={withRouteBoundary('Box Score', <BoxScorePage />)} />
<Route path="press-room" element={withRouteBoundary('Press Room', <PressRoomPage />)} />
<Route path="playoffs" element={withRouteBoundary('Playoffs', <PlayoffsPage />)} />
<Route path="free-agency" element={withRouteBoundary('Free Agency', <FreeAgencyPage />)} />
<Route path="offseason" element={withRouteBoundary('Offseason', <OffseasonPage />)} />
<Route path="finance" element={withRouteBoundary('Finance', <FinancePage />)} />
<Route path="career" element={withRouteBoundary('GM Career', <GMCareerPage />)} />
<Route path="history" element={withRouteBoundary('History', <HistoryPage />)} />
<Route path="achievements" element={withRouteBoundary('Achievements', <AchievementsPage />)} />
<Route path="rivalries" element={withRouteBoundary('Rivalries', <RivalriesPage />)} />
<Route path="front-office" element={withRouteBoundary('Owner Intel', <FrontOfficePage />)} />
<Route path="pulse" element={withRouteBoundary('Pulse', <PulsePage />)} />
<Route path="scenarios" element={withRouteBoundary('Challenges', <ScenarioCatalogPage />)} />
<Route path="stats" element={withRouteBoundary('Stats Encyclopedia', <StatsEncyclopediaPage />)} />
<Route path="records" element={withRouteBoundary('Record Watch', <RecordWatchPage />)} />
<Route path="settings" element={withRouteBoundary('Settings', <SettingsPage />)} />
{/* Catch-all redirect */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Route>
<Route path="schedule" element={withRouteBoundary('Schedule', <SchedulePage />)} />
<Route path="games/:gameIndex" element={withRouteBoundary('Box Score', <BoxScorePage />)} />
<Route path="press-room" element={withRouteBoundary('Press Room', <PressRoomPage />)} />
<Route path="playoffs" element={withRouteBoundary('Playoffs', <PlayoffsPage />)} />
<Route path="free-agency" element={withRouteBoundary('Free Agency', <FreeAgencyPage />)} />
<Route path="offseason" element={withRouteBoundary('Offseason', <OffseasonPage />)} />
<Route path="finance" element={withRouteBoundary('Finance', <FinancePage />)} />
<Route path="career" element={withRouteBoundary('GM Career', <GMCareerPage />)} />
<Route path="history" element={withRouteBoundary('History', <HistoryPage />)} />
<Route path="achievements" element={withRouteBoundary('Achievements', <AchievementsPage />)} />
<Route path="rivalries" element={withRouteBoundary('Rivalries', <RivalriesPage />)} />
<Route path="front-office" element={withRouteBoundary('Owner Intel', <FrontOfficePage />)} />
<Route path="pulse" element={withRouteBoundary('Pulse', <PulsePage />)} />
<Route path="scenarios" element={withRouteBoundary('Challenges', <ScenarioCatalogPage />)} />
<Route path="stats" element={withRouteBoundary('Stats Encyclopedia', <StatsEncyclopediaPage />)} />
<Route path="records" element={withRouteBoundary('Record Watch', <RecordWatchPage />)} />
<Route path="settings" element={withRouteBoundary('Settings', <SettingsPage />)} />
{/* Catch-all redirect */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Route>
</Routes>
</Routes>
<PreGameAssistantMount />
</>
</Suspense>
);
}
114 changes: 114 additions & 0 deletions apps/web/src/features/assistant/components/AssistantPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { act } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter } from 'react-router-dom';
import { AssistantPanel } from './AssistantPanel';
import { useGameStore } from '@/shared/hooks/useGameStore';

vi.mock('@/shared/hooks/useGameStore', () => ({
useGameStore: vi.fn(),
}));

const mockedUseGameStore = vi.mocked(useGameStore);
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;

describe('AssistantPanel', () => {
let container: HTMLDivElement;
let root: Root;

beforeEach(() => {
localStorage.clear();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
mockedUseGameStore.mockReturnValue({
season: 2,
day: 95,
phase: 'regular',
isInitialized: true,
userTeamId: 'nym',
teamName: 'Tycoons',
gmName: 'Alex Rivera',
difficulty: 'standard',
activeSaveId: 'save-slot-2',
activeSaveSlot: 2,
playerCount: 780,
gamesPlayed: 95,
isSimulating: false,
setSeason: vi.fn(),
setDay: vi.fn(),
setPhase: vi.fn(),
setSimulating: vi.fn(),
setInitialized: vi.fn(),
setUserTeamId: vi.fn(),
setActiveSave: vi.fn(),
setActiveSaveSlot: vi.fn(),
updateFromSim: vi.fn(),
initializeGame: vi.fn(),
});
});

afterEach(async () => {
await act(async () => {
root.unmount();
});
container.remove();
vi.clearAllMocks();
localStorage.clear();
});

it('opens route-aware guidance, explains ratings, and persists dismissals by save slot', async () => {
await act(async () => {
root.render(
<MemoryRouter initialEntries={['/trade']}>
<AssistantPanel
tickerFeed={[
{
id: 'ticker-trade-1',
category: 'trade',
text: 'League rivals are calling about late-inning relief.',
},
]}
/>
</MemoryRouter>,
);
await Promise.resolve();
});

expect(container.textContent).toContain('Assistant');
expect(container.textContent).toContain('Trade');

const openButton = Array.from(container.querySelectorAll('button')).find((button) => (
button.textContent?.includes('What now?')
));
expect(openButton).toBeTruthy();

await act(async () => {
openButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});

expect(container.querySelector('[role="dialog"]')?.textContent).toContain('Price the player and the situation');
expect(container.textContent).toContain('Review one trade target');
expect(container.textContent).toContain('League rivals are calling about late-inning relief.');

const ratingsButton = Array.from(container.querySelectorAll('button')).find((button) => (
button.textContent?.includes('Explain ratings')
));
await act(async () => {
ratingsButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});

expect(container.textContent).toContain('OVR starts the value conversation');

const gotItButton = Array.from(container.querySelectorAll('button')).find((button) => (
button.textContent?.includes('Got it')
));
await act(async () => {
gotItButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});

expect(localStorage.getItem('mbd:assistant:v1:save-slot-2')).toContain('"trade":true');
});
});
Loading
Loading