fix(website): harden account peak-lean states, stale metrics hygiene, and demo-chat error precedence
- Handle loading, unauthenticated, permission-denied, and unavailable states truthfully in metric-card-lean (Peak Lean Angle KPI) rather than falling back to '--' and 'No IMU roll data' - Reset metrics state to null on auth change and in onStatusChange loading/error branches to prevent stale metrics retention - Add race condition guard in AppCloudData.loadData to discard in-flight results if firebaseAuth.currentUser changed during asynchronous fetch - Prioritize Disconnected and Connecting states over sample example messages in real club mode in founder-chat.tsx - Clear chat messages on channel switch in founder-chat.tsx to eliminate cross-channel message bleed, and fix invalid py-0.2 Tailwind classes to py-0.5 - Add regression tests in account-metrics-truthfulness.test.tsx and founder-routes-chat.test.tsx
This commit is contained in:
parent
5e1ce969cc
commit
04e56a985d
|
|
@ -47,6 +47,9 @@ export default function AccountPage() {
|
||||||
setMetricsStatus('unauthenticated');
|
setMetricsStatus('unauthenticated');
|
||||||
setMetrics(null);
|
setMetrics(null);
|
||||||
setSyncError(null);
|
setSyncError(null);
|
||||||
|
} else {
|
||||||
|
setMetricsStatus('loading');
|
||||||
|
setMetrics(null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => unsubscribe();
|
return () => unsubscribe();
|
||||||
|
|
@ -380,17 +383,33 @@ export default function AccountPage() {
|
||||||
<div className="glass-card p-5 rounded-xl border border-brand-border glow-border-cyan transition duration-200" data-testid="metric-card-lean">
|
<div className="glass-card p-5 rounded-xl border border-brand-border glow-border-cyan transition duration-200" data-testid="metric-card-lean">
|
||||||
<div className="flex items-center justify-between text-xs font-mono text-slate-400 tracking-normal">
|
<div className="flex items-center justify-between text-xs font-mono text-slate-400 tracking-normal">
|
||||||
<span>Peak Lean Angle</span>
|
<span>Peak Lean Angle</span>
|
||||||
<span className="text-brand-cyan font-mono text-[10px]">
|
{metricsStatus === 'loading' ? (
|
||||||
{metricsStatus === 'permission-denied' || metricsStatus === 'unavailable' || metricsStatus === 'error'
|
<span className="text-slate-400 bg-slate-800 px-1.5 py-0.5 rounded text-[10px]">Loading…</span>
|
||||||
? 'Unavailable'
|
) : metricsStatus === 'unauthenticated' ? (
|
||||||
: 'Lateral Roll'}
|
<span className="text-slate-400 bg-slate-800 px-1.5 py-0.5 rounded text-[10px]">Sign in to view</span>
|
||||||
</span>
|
) : metricsStatus === 'permission-denied' ? (
|
||||||
|
<span className="text-rose-400 bg-rose-500/10 px-1.5 py-0.5 rounded text-[10px]">Permission denied</span>
|
||||||
|
) : metricsStatus === 'unavailable' || metricsStatus === 'error' ? (
|
||||||
|
<span className="text-amber-400 bg-amber-500/10 px-1.5 py-0.5 rounded text-[10px]">Sync unavailable</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-brand-cyan bg-brand-cyan/10 px-1.5 py-0.5 rounded text-[10px]">Lateral Roll</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex items-baseline space-x-2">
|
<div className="mt-3 flex items-baseline space-x-2">
|
||||||
|
{metricsStatus === 'ready' && metrics !== null ? (
|
||||||
|
<>
|
||||||
<span className="text-3xl font-extrabold text-brand-cyan font-mono">
|
<span className="text-3xl font-extrabold text-brand-cyan font-mono">
|
||||||
{metricsStatus === 'ready' && metrics !== null && metrics.peakLean > 0 ? `${metrics.peakLean}°` : '--'}
|
{metrics.peakLean > 0 ? `${metrics.peakLean}°` : '--'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-mono text-slate-400">lateral roll</span>
|
<span className="text-sm font-mono text-slate-400">lateral roll</span>
|
||||||
|
</>
|
||||||
|
) : metricsStatus === 'loading' ? (
|
||||||
|
<span className="text-2xl font-bold text-slate-400 font-mono animate-pulse">Loading…</span>
|
||||||
|
) : metricsStatus === 'unauthenticated' ? (
|
||||||
|
<span className="text-3xl font-bold text-slate-500 font-mono">—</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-2xl font-bold text-rose-400 font-mono">Unavailable</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-xs text-slate-400 flex items-center space-x-1.5">
|
<div className="mt-2 text-xs text-slate-400 flex items-center space-x-1.5">
|
||||||
{metricsStatus === 'ready' && metrics !== null && metrics.peakLean > 0 ? (
|
{metricsStatus === 'ready' && metrics !== null && metrics.peakLean > 0 ? (
|
||||||
|
|
@ -398,7 +417,11 @@ export default function AccountPage() {
|
||||||
) : metricsStatus === 'loading' ? (
|
) : metricsStatus === 'loading' ? (
|
||||||
<span>Retrieving lean telemetry…</span>
|
<span>Retrieving lean telemetry…</span>
|
||||||
) : metricsStatus === 'unauthenticated' ? (
|
) : metricsStatus === 'unauthenticated' ? (
|
||||||
<span>No active session data</span>
|
<span>Sign in to view lean telemetry</span>
|
||||||
|
) : metricsStatus === 'permission-denied' ? (
|
||||||
|
<span className="text-rose-400">Lean telemetry restricted</span>
|
||||||
|
) : metricsStatus === 'unavailable' || metricsStatus === 'error' ? (
|
||||||
|
<span className="text-amber-400">Lean telemetry unavailable</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-slate-500">No IMU roll data</span>
|
<span className="text-slate-500">No IMU roll data</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -432,10 +455,13 @@ export default function AccountPage() {
|
||||||
setSyncError(null);
|
setSyncError(null);
|
||||||
setLastSynced(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
|
setLastSynced(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
|
||||||
} else if (status === 'permission-denied' || status === 'unavailable' || status === 'error') {
|
} else if (status === 'permission-denied' || status === 'unavailable' || status === 'error') {
|
||||||
|
setMetrics(null);
|
||||||
setSyncError('Sync failed');
|
setSyncError('Sync failed');
|
||||||
} else if (status === 'unauthenticated') {
|
} else if (status === 'unauthenticated') {
|
||||||
setMetrics(null);
|
setMetrics(null);
|
||||||
setSyncError(null);
|
setSyncError(null);
|
||||||
|
} else if (status === 'loading') {
|
||||||
|
setMetrics(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMetricsLoaded={(m) => {
|
onMetricsLoaded={(m) => {
|
||||||
|
|
|
||||||
|
|
@ -400,6 +400,11 @@ export function AppCloudData({
|
||||||
getDocs(collection(firestore, 'users', currentUser.uid, 'rides')),
|
getDocs(collection(firestore, 'users', currentUser.uid, 'rides')),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Guard against race conditions if user logged out or changed while async queries were in-flight
|
||||||
|
if (firebaseAuth.currentUser?.uid !== currentUser.uid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Process Motorcycles
|
// Process Motorcycles
|
||||||
if (motorcycleRes.status === 'fulfilled') {
|
if (motorcycleRes.status === 'fulfilled') {
|
||||||
const loadedMotorcycles: Motorcycle[] = motorcycleRes.value.docs.map((snapshot) => {
|
const loadedMotorcycles: Motorcycle[] = motorcycleRes.value.docs.map((snapshot) => {
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ export function FounderChat({ context }: FounderChatProps) {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setMessages([]);
|
||||||
let unsubscribe: () => void = () => {};
|
let unsubscribe: () => void = () => {};
|
||||||
|
|
||||||
if (isDemo) {
|
if (isDemo) {
|
||||||
|
|
@ -154,20 +155,25 @@ export function FounderChat({ context }: FounderChatProps) {
|
||||||
<h1 className="font-heading text-2xl font-bold tracking-wide text-white">
|
<h1 className="font-heading text-2xl font-bold tracking-wide text-white">
|
||||||
Squad Live Chat
|
Squad Live Chat
|
||||||
</h1>
|
</h1>
|
||||||
{isExampleThread ? (
|
{isDemo ? (
|
||||||
<span className="font-mono text-xs px-2.5 py-0.5 rounded-full bg-amber-500/15 text-amber-300 border border-amber-500/30 font-semibold flex items-center gap-1.5">
|
<span className="font-mono text-xs px-2.5 py-0.5 rounded-full bg-amber-500/15 text-amber-300 border border-amber-500/30 font-semibold flex items-center gap-1.5">
|
||||||
<span className="material-symbols-outlined text-[14px]">visibility</span>
|
<span className="material-symbols-outlined text-[14px]">visibility</span>
|
||||||
Example thread
|
Example thread
|
||||||
</span>
|
</span>
|
||||||
|
) : connectionState === 'error' || error ? (
|
||||||
|
<span className="font-mono text-xs px-2.5 py-0.5 rounded-full bg-rose-500/15 text-rose-400 border border-rose-500/30 font-semibold flex items-center gap-1.5">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-rose-400" />
|
||||||
|
Disconnected
|
||||||
|
</span>
|
||||||
) : connectionState === 'connecting' ? (
|
) : connectionState === 'connecting' ? (
|
||||||
<span className="font-mono text-xs px-2.5 py-0.5 rounded-full bg-slate-500/15 text-slate-300 border border-slate-500/30 font-semibold flex items-center gap-1.5">
|
<span className="font-mono text-xs px-2.5 py-0.5 rounded-full bg-slate-500/15 text-slate-300 border border-slate-500/30 font-semibold flex items-center gap-1.5">
|
||||||
<span className="w-2 h-2 rounded-full bg-slate-400 animate-pulse" />
|
<span className="w-2 h-2 rounded-full bg-slate-400 animate-pulse" />
|
||||||
Connecting…
|
Connecting…
|
||||||
</span>
|
</span>
|
||||||
) : connectionState === 'error' || error ? (
|
) : hasExampleMessages ? (
|
||||||
<span className="font-mono text-xs px-2.5 py-0.5 rounded-full bg-rose-500/15 text-rose-400 border border-rose-500/30 font-semibold flex items-center gap-1.5">
|
<span className="font-mono text-xs px-2.5 py-0.5 rounded-full bg-amber-500/15 text-amber-300 border border-amber-500/30 font-semibold flex items-center gap-1.5">
|
||||||
<span className="w-2 h-2 rounded-full bg-rose-400" />
|
<span className="material-symbols-outlined text-[14px]">visibility</span>
|
||||||
Disconnected
|
Example thread
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="font-mono text-xs px-2.5 py-0.5 rounded-full bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 font-semibold flex items-center gap-1.5">
|
<span className="font-mono text-xs px-2.5 py-0.5 rounded-full bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 font-semibold flex items-center gap-1.5">
|
||||||
|
|
@ -323,12 +329,12 @@ export function FounderChat({ context }: FounderChatProps) {
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{msg.authorDisplayRank && (
|
{msg.authorDisplayRank && (
|
||||||
<span className="font-mono px-1.5 py-0.2 rounded bg-surface-container border border-brand-border/40 text-brand-cyan text-[9px]">
|
<span className="font-mono px-1.5 py-0.5 rounded bg-surface-container border border-brand-border/40 text-brand-cyan text-[9px]">
|
||||||
{msg.authorDisplayRank}
|
{msg.authorDisplayRank}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{msg.isExample && (
|
{msg.isExample && (
|
||||||
<span className="font-mono px-1.5 py-0.2 rounded bg-amber-500/15 border border-amber-500/30 text-amber-300 text-[9px]">
|
<span className="font-mono px-1.5 py-0.5 rounded bg-amber-500/15 border border-amber-500/30 text-amber-300 text-[9px]">
|
||||||
Example
|
Example
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
import AccountPage from '../app/account/page';
|
import AccountPage from '../app/account/page';
|
||||||
import { AppCloudData } from '../components/app-cloud-data';
|
import { AppCloudData } from '../components/app-cloud-data';
|
||||||
|
import { firebaseAuth } from '../lib/firebase-client';
|
||||||
|
|
||||||
const authListeners = new Set<(user: unknown) => void>();
|
const authListeners = new Set<(user: unknown) => void>();
|
||||||
|
|
||||||
|
|
@ -48,6 +49,7 @@ vi.mock('firebase/firestore', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function deliverAuth(user: unknown) {
|
function deliverAuth(user: unknown) {
|
||||||
|
(firebaseAuth as { currentUser?: unknown }).currentUser = user;
|
||||||
authListeners.forEach((listener) => listener(user));
|
authListeners.forEach((listener) => listener(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,6 +57,7 @@ describe('Account Cloud Metrics Truthfulness (PR #38 Thread 1)', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
authListeners.clear();
|
authListeners.clear();
|
||||||
|
(firebaseAuth as { currentUser?: unknown }).currentUser = undefined;
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -89,9 +92,10 @@ describe('Account Cloud Metrics Truthfulness (PR #38 Thread 1)', () => {
|
||||||
expect(sessionsCard.textContent).not.toContain('0 rides');
|
expect(sessionsCard.textContent).not.toContain('0 rides');
|
||||||
expect(sessionsCard.textContent).not.toContain('100% Cloud Synced');
|
expect(sessionsCard.textContent).not.toContain('100% Cloud Synced');
|
||||||
|
|
||||||
// Metric 3: Lean Angle Peak must be '--'
|
// Metric 3: Lean Angle Peak must show Loading…, not 0 or false verified roll
|
||||||
const leanCard = screen.getByTestId('metric-card-lean');
|
const leanCard = screen.getByTestId('metric-card-lean');
|
||||||
expect(leanCard.textContent).toContain('--');
|
expect(leanCard.textContent).toContain('Loading…');
|
||||||
|
expect(leanCard.textContent).toContain('Retrieving lean telemetry…');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('truthfully handles signed-out state: shows sign-in required, never 0 miles or 100% Cloud Synced', async () => {
|
it('truthfully handles signed-out state: shows sign-in required, never 0 miles or 100% Cloud Synced', async () => {
|
||||||
|
|
@ -110,6 +114,10 @@ describe('Account Cloud Metrics Truthfulness (PR #38 Thread 1)', () => {
|
||||||
expect(sessionsCard.textContent).toContain('—');
|
expect(sessionsCard.textContent).toContain('—');
|
||||||
expect(sessionsCard.textContent).not.toContain('0 rides');
|
expect(sessionsCard.textContent).not.toContain('0 rides');
|
||||||
expect(sessionsCard.textContent).not.toContain('100% Cloud Synced');
|
expect(sessionsCard.textContent).not.toContain('100% Cloud Synced');
|
||||||
|
|
||||||
|
const leanCard = screen.getByTestId('metric-card-lean');
|
||||||
|
expect(leanCard.textContent).toContain('Sign in to view');
|
||||||
|
expect(leanCard.textContent).toContain('—');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('distinguishes real zero-ride account: shows verified 0 miles, 0 rides, Cloud Synced (NOT 100% Cloud Synced)', async () => {
|
it('distinguishes real zero-ride account: shows verified 0 miles, 0 rides, Cloud Synced (NOT 100% Cloud Synced)', async () => {
|
||||||
|
|
@ -232,6 +240,12 @@ describe('Account Cloud Metrics Truthfulness (PR #38 Thread 1)', () => {
|
||||||
expect(sessionsCard.textContent).not.toContain('0 rides');
|
expect(sessionsCard.textContent).not.toContain('0 rides');
|
||||||
expect(sessionsCard.textContent).not.toContain('100% Cloud Synced');
|
expect(sessionsCard.textContent).not.toContain('100% Cloud Synced');
|
||||||
|
|
||||||
|
const leanCard = screen.getByTestId('metric-card-lean');
|
||||||
|
expect(leanCard.textContent).toContain('Unavailable');
|
||||||
|
expect(leanCard.textContent).toContain('Permission denied');
|
||||||
|
expect(leanCard.textContent).toContain('Lean telemetry restricted');
|
||||||
|
expect(leanCard.textContent).not.toContain('No IMU roll data');
|
||||||
|
|
||||||
// AppCloudData error banners
|
// AppCloudData error banners
|
||||||
expect(screen.getByTestId('cloud-garage-error')).toBeDefined();
|
expect(screen.getByTestId('cloud-garage-error')).toBeDefined();
|
||||||
expect(screen.getByTestId('cloud-rides-error')).toBeDefined();
|
expect(screen.getByTestId('cloud-rides-error')).toBeDefined();
|
||||||
|
|
@ -261,6 +275,12 @@ describe('Account Cloud Metrics Truthfulness (PR #38 Thread 1)', () => {
|
||||||
expect(sessionsCard.textContent).not.toContain('0 rides');
|
expect(sessionsCard.textContent).not.toContain('0 rides');
|
||||||
expect(sessionsCard.textContent).not.toContain('100% Cloud Synced');
|
expect(sessionsCard.textContent).not.toContain('100% Cloud Synced');
|
||||||
|
|
||||||
|
const leanCard = screen.getByTestId('metric-card-lean');
|
||||||
|
expect(leanCard.textContent).toContain('Unavailable');
|
||||||
|
expect(leanCard.textContent).toContain('Sync unavailable');
|
||||||
|
expect(leanCard.textContent).toContain('Lean telemetry unavailable');
|
||||||
|
expect(leanCard.textContent).not.toContain('No IMU roll data');
|
||||||
|
|
||||||
expect(screen.getByTestId('cloud-rides-error')).toBeDefined();
|
expect(screen.getByTestId('cloud-rides-error')).toBeDefined();
|
||||||
expect(screen.getByText(/Recent Rides Cloud Feed Unavailable/i)).toBeDefined();
|
expect(screen.getByText(/Recent Rides Cloud Feed Unavailable/i)).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
@ -368,4 +388,94 @@ describe('Account Cloud Metrics Truthfulness (PR #38 Thread 1)', () => {
|
||||||
|
|
||||||
expect(screen.getByText(/Synced \(/i)).toBeDefined();
|
expect(screen.getByText(/Synced \(/i)).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('guards against race conditions when user logs out or switches accounts during in-flight fetch', async () => {
|
||||||
|
let resolveMotorcycles: (val: unknown) => void;
|
||||||
|
let resolveRides: (val: unknown) => void;
|
||||||
|
mocks.getDocs.mockImplementation((colRef: { path: string }) => {
|
||||||
|
if (colRef.path.includes('motorcycles')) {
|
||||||
|
return new Promise((res) => { resolveMotorcycles = res; });
|
||||||
|
}
|
||||||
|
return new Promise((res) => { resolveRides = res; });
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AccountPage />);
|
||||||
|
deliverAuth(authUser);
|
||||||
|
|
||||||
|
// Initial loading
|
||||||
|
expect(screen.getByTestId('metric-card-distance').textContent).toContain('Loading…');
|
||||||
|
|
||||||
|
// User logs out while fetch is still in-flight
|
||||||
|
deliverAuth(null);
|
||||||
|
|
||||||
|
// Now the in-flight promise resolves with User A's data
|
||||||
|
resolveMotorcycles!({
|
||||||
|
docs: [{
|
||||||
|
id: 'bike-user-a',
|
||||||
|
data: () => ({ year: 2024, makeName: 'Yamaha', modelName: 'R7', nickname: 'Ghost Bike' }),
|
||||||
|
}],
|
||||||
|
empty: false,
|
||||||
|
});
|
||||||
|
resolveRides!({
|
||||||
|
docs: [{
|
||||||
|
id: 'ride-user-a',
|
||||||
|
data: () => ({ distanceMiles: 50, maxLeanLeft: 35, maxLeanRight: 35, startTime: '2026-07-01T10:00:00Z' }),
|
||||||
|
}],
|
||||||
|
empty: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait to confirm User A's ghost data does NOT leak into logged-out state
|
||||||
|
await waitFor(() => {
|
||||||
|
const distanceCard = screen.getByTestId('metric-card-distance');
|
||||||
|
expect(distanceCard.textContent).toContain('Sign in to view');
|
||||||
|
expect(distanceCard.textContent).not.toContain('50');
|
||||||
|
expect(screen.queryByText('Ghost Bike')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears metrics state when an error occurs so stale metrics from a prior successful sync are not retained', async () => {
|
||||||
|
let shouldFail = false;
|
||||||
|
mocks.getDocs.mockImplementation((colRef: { path: string }) => {
|
||||||
|
if (shouldFail) {
|
||||||
|
const err = new Error('Permission denied on re-sync');
|
||||||
|
(err as unknown as { code: string }).code = 'permission-denied';
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
if (colRef.path.includes('rides')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
docs: [{
|
||||||
|
id: 'ride-1',
|
||||||
|
data: () => ({ distanceMiles: 25.0, maxLeanLeft: 30, maxLeanRight: 32, startTime: '2026-07-01T10:00:00Z' }),
|
||||||
|
}],
|
||||||
|
empty: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve({ docs: [], empty: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AccountPage />);
|
||||||
|
deliverAuth(authUser);
|
||||||
|
|
||||||
|
// Verify initial successful sync
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('metric-card-distance').textContent).toContain('25');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now trigger Quick Sync and make it fail
|
||||||
|
shouldFail = true;
|
||||||
|
const quickSyncBtn = screen.getByRole('button', { name: /Synced/i });
|
||||||
|
fireEvent.click(quickSyncBtn);
|
||||||
|
|
||||||
|
// Verify that metrics are immediately cleared and show Unavailable, not the stale 25 miles
|
||||||
|
await waitFor(() => {
|
||||||
|
const distanceCard = screen.getByTestId('metric-card-distance');
|
||||||
|
expect(distanceCard.textContent).toContain('Unavailable');
|
||||||
|
expect(distanceCard.textContent).not.toContain('25');
|
||||||
|
const sessionsCard = screen.getByTestId('metric-card-sessions');
|
||||||
|
expect(sessionsCard.textContent).toContain('Unavailable');
|
||||||
|
expect(sessionsCard.textContent).not.toContain('1 rides');
|
||||||
|
const leanCard = screen.getByTestId('metric-card-lean');
|
||||||
|
expect(leanCard.textContent).toContain('Unavailable');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -453,6 +453,52 @@ describe('Founder Chat Management', () => {
|
||||||
expect(screen.queryByText('Live Connected')).toBeNull();
|
expect(screen.queryByText('Live Connected')).toBeNull();
|
||||||
expect(screen.queryByText('Example thread')).toBeNull();
|
expect(screen.queryByText('Example thread')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows Disconnected rather than Example thread when real connection fails even if channel had sample messages', async () => {
|
||||||
|
let triggerError: (err: Error) => void;
|
||||||
|
mocks.subscribeChat.mockImplementation((_clubId, _channelId, onMsgs, onError) => {
|
||||||
|
onMsgs([
|
||||||
|
{
|
||||||
|
id: 'sample-1',
|
||||||
|
clubId: 'club-1',
|
||||||
|
channelId: 'general',
|
||||||
|
authorUid: 'admin',
|
||||||
|
authorDisplayName: 'Admin',
|
||||||
|
authorRole: 'admin',
|
||||||
|
content: 'Sample post',
|
||||||
|
createdAt: '2026-06-01T12:00:00Z',
|
||||||
|
isExample: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
triggerError = onError;
|
||||||
|
return vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<FounderChat context={mockContext} />);
|
||||||
|
await screen.findByText('Sample post');
|
||||||
|
expect(screen.getByText('Example thread')).toBeDefined();
|
||||||
|
|
||||||
|
// Now connection drops in real mode
|
||||||
|
triggerError!(new Error('Connection interrupted'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Disconnected')).toBeDefined();
|
||||||
|
expect(screen.queryByText('Example thread')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears message list when switching channels to prevent message bleed', async () => {
|
||||||
|
render(<FounderChat context={demoContext} />);
|
||||||
|
await screen.findByText('Squad Live Chat');
|
||||||
|
|
||||||
|
expect(screen.getByText(/Welcome to squad chat!/i)).toBeDefined();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('#road-intel'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText(/Welcome to squad chat!/i)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue