fix(website): truthful account metrics error states and persistent demo chat labeling
- Replace false zero metrics (0 miles / 0 rides / 100% Cloud Synced) on loading, unauthenticated, permission-denied, or network errors with truthful states - Update AppCloudData to query motorcycles and rides via Promise.allSettled and report granular CloudDataStatus - Update AccountPage KPI cards to display Loading…, —, or Unavailable with explicit contextual status and error recovery - Fix founder demo chat to permanently preserve Example thread and example badges during preview=demo simulation instead of flipping to Live Connected - Add 8 unit/component tests in account-metrics-truthfulness.test.tsx and 6 tests in founder-routes-chat.test.tsx
This commit is contained in:
parent
9c43a4f92e
commit
5e1ce969cc
|
|
@ -4,7 +4,7 @@ import React, { useEffect, useState } from 'react';
|
|||
import Link from 'next/link';
|
||||
import { onAuthStateChanged, multiFactor, User } from 'firebase/auth';
|
||||
import { firebaseAuth } from '../../lib/firebase-client';
|
||||
import { AppCloudData } from '../../components/app-cloud-data';
|
||||
import { AppCloudData, type CloudDataStatus } from '../../components/app-cloud-data';
|
||||
import { FirebaseLink } from '../../components/firebase-link';
|
||||
import { SignOutButton } from '../../components/sign-out-button';
|
||||
|
||||
|
|
@ -22,10 +22,12 @@ export default function AccountPage() {
|
|||
const [syncTrigger, setSyncTrigger] = useState(0);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [lastSynced, setLastSynced] = useState<string | null>(null);
|
||||
// Initialise to zero; AppCloudData replaces these with real totals from the rider's
|
||||
// Firestore rides (or leaves them at zero when signed out / no rides). Never seed
|
||||
// fabricated stats.
|
||||
const [metrics, setMetrics] = useState({ totalMiles: 0, rideCount: 0, peakLean: 0 });
|
||||
const [syncError, setSyncError] = useState<string | null>(null);
|
||||
// Truthful metrics & sync states:
|
||||
// Distinguish 'idle', 'loading', 'unauthenticated', 'ready', 'permission-denied', 'unavailable', and 'error'.
|
||||
// Never initialize unavailable information in a way that displays as verified zero metrics or 100% synced.
|
||||
const [metricsStatus, setMetricsStatus] = useState<CloudDataStatus>('loading');
|
||||
const [metrics, setMetrics] = useState<{ totalMiles: number; rideCount: number; peakLean: number } | null>(null);
|
||||
|
||||
// Active Device Sessions State
|
||||
const [sessions, setSessions] = useState<DeviceSession[]>([]);
|
||||
|
|
@ -41,6 +43,11 @@ export default function AccountPage() {
|
|||
const unsubscribe = onAuthStateChanged(firebaseAuth, (currentUser) => {
|
||||
setUser(currentUser);
|
||||
setAuthChecked(true);
|
||||
if (!currentUser) {
|
||||
setMetricsStatus('unauthenticated');
|
||||
setMetrics(null);
|
||||
setSyncError(null);
|
||||
}
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
|
@ -161,7 +168,10 @@ export default function AccountPage() {
|
|||
}
|
||||
|
||||
function handleQuickSync() {
|
||||
if (!user) return;
|
||||
setIsSyncing(true);
|
||||
setSyncError(null);
|
||||
setMetricsStatus('loading');
|
||||
setSyncTrigger((prev) => prev + 1);
|
||||
}
|
||||
|
||||
|
|
@ -239,7 +249,15 @@ export default function AccountPage() {
|
|||
strokeWidth="2"
|
||||
></path>
|
||||
</svg>
|
||||
<span>{isSyncing ? 'Syncing...' : lastSynced ? `Synced (${lastSynced})` : 'Quick Sync'}</span>
|
||||
<span>
|
||||
{isSyncing
|
||||
? 'Syncing...'
|
||||
: syncError
|
||||
? 'Sync Failed (Retry)'
|
||||
: lastSynced
|
||||
? `Synced (${lastSynced})`
|
||||
: 'Quick Sync'}
|
||||
</span>
|
||||
</button>
|
||||
<div className="px-3 py-1.5 rounded-lg bg-brand-cardAlt border border-brand-border text-xs font-mono text-rose-400">
|
||||
<SignOutButton />
|
||||
|
|
@ -262,46 +280,128 @@ export default function AccountPage() {
|
|||
{/* BEGIN: TelemetryKpiGrid */}
|
||||
<section className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4" data-purpose="telemetry-overview-stats">
|
||||
{/* Metric 1: Distance */}
|
||||
<div className="glass-card p-5 rounded-xl border border-brand-border glow-border-cyan transition duration-200">
|
||||
<div className="glass-card p-5 rounded-xl border border-brand-border glow-border-cyan transition duration-200" data-testid="metric-card-distance">
|
||||
<div className="flex items-center justify-between text-xs font-mono text-slate-400 tracking-normal">
|
||||
<span>Total Distance Logged</span>
|
||||
<span className="text-brand-cyan bg-brand-cyan/10 px-1.5 py-0.5 rounded text-[10px]">Cloud Aggregate</span>
|
||||
{metricsStatus === 'loading' ? (
|
||||
<span className="text-slate-400 bg-slate-800 px-1.5 py-0.5 rounded text-[10px]">Loading…</span>
|
||||
) : metricsStatus === 'unauthenticated' ? (
|
||||
<span className="text-slate-400 bg-slate-800 px-1.5 py-0.5 rounded text-[10px]">Sign in to view</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]">Cloud Aggregate</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex items-baseline space-x-2">
|
||||
<span className="text-3xl font-extrabold text-white font-mono">{metrics.totalMiles.toLocaleString()}</span>
|
||||
<span className="text-sm font-mono text-slate-400">miles</span>
|
||||
{metricsStatus === 'ready' && metrics !== null ? (
|
||||
<>
|
||||
<span className="text-3xl font-extrabold text-white font-mono">{metrics.totalMiles.toLocaleString()}</span>
|
||||
<span className="text-sm font-mono text-slate-400">miles</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 className="mt-2 text-xs text-slate-400 flex items-center space-x-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-brand-emerald"></span>
|
||||
<span>All-time telemetry aggregate</span>
|
||||
{metricsStatus === 'ready' ? (
|
||||
<>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-brand-emerald"></span>
|
||||
<span>All-time telemetry aggregate</span>
|
||||
</>
|
||||
) : metricsStatus === 'loading' ? (
|
||||
<span>Retrieving telemetry aggregate…</span>
|
||||
) : metricsStatus === 'unauthenticated' ? (
|
||||
<span>Sign in to view telemetry aggregate</span>
|
||||
) : metricsStatus === 'permission-denied' ? (
|
||||
<span className="text-rose-400">Access to cloud telemetry restricted</span>
|
||||
) : (
|
||||
<span className="text-amber-400">Telemetry aggregate unavailable</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Metric 2: Sessions */}
|
||||
<div className="glass-card p-5 rounded-xl border border-brand-border glow-border-cyan transition duration-200">
|
||||
<div className="glass-card p-5 rounded-xl border border-brand-border glow-border-cyan transition duration-200" data-testid="metric-card-sessions">
|
||||
<div className="flex items-center justify-between text-xs font-mono text-slate-400 tracking-normal">
|
||||
<span>Recorded Sessions</span>
|
||||
<span className="text-brand-emerald bg-brand-emerald/10 px-1.5 py-0.5 rounded text-[10px]">100% Cloud Synced</span>
|
||||
{metricsStatus === 'loading' ? (
|
||||
<span className="text-slate-400 bg-slate-800 px-1.5 py-0.5 rounded text-[10px]">Checking sync…</span>
|
||||
) : metricsStatus === 'unauthenticated' ? (
|
||||
<span className="text-slate-400 bg-slate-800 px-1.5 py-0.5 rounded text-[10px]">Sign-in required</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 status unavailable</span>
|
||||
) : metrics !== null && metrics.rideCount > 0 ? (
|
||||
<span className="text-brand-emerald bg-brand-emerald/10 px-1.5 py-0.5 rounded text-[10px]">100% Cloud Synced</span>
|
||||
) : (
|
||||
<span className="text-brand-cyan bg-brand-cyan/10 px-1.5 py-0.5 rounded text-[10px]">Cloud Synced</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex items-baseline space-x-2">
|
||||
<span className="text-3xl font-extrabold text-white font-mono">{metrics.rideCount}</span>
|
||||
<span className="text-sm font-mono text-slate-400">rides</span>
|
||||
{metricsStatus === 'ready' && metrics !== null ? (
|
||||
<>
|
||||
<span className="text-3xl font-extrabold text-white font-mono">{metrics.rideCount}</span>
|
||||
<span className="text-sm font-mono text-slate-400">rides</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 className="mt-2 text-xs text-slate-400 flex items-center space-x-1.5">
|
||||
<span>Canyons, Circuits & Commutes</span>
|
||||
{metricsStatus === 'ready' && metrics !== null ? (
|
||||
metrics.rideCount > 0 ? (
|
||||
<span>Canyons, Circuits & Commutes</span>
|
||||
) : (
|
||||
<span>No rides recorded yet</span>
|
||||
)
|
||||
) : metricsStatus === 'loading' ? (
|
||||
<span>Checking cloud telemetry sessions…</span>
|
||||
) : metricsStatus === 'unauthenticated' ? (
|
||||
<span>Sign in to load synced rides</span>
|
||||
) : metricsStatus === 'permission-denied' ? (
|
||||
<span className="text-rose-400">Check Firestore permissions</span>
|
||||
) : (
|
||||
<span className="text-amber-400">Could not retrieve sessions from cloud</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Metric 3: Lean Angle Peak */}
|
||||
<div className="glass-card p-5 rounded-xl border border-brand-border glow-border-cyan transition duration-200">
|
||||
<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">
|
||||
<span>Peak Lean Angle</span>
|
||||
<span className="text-brand-cyan font-mono text-[10px]">Lateral Roll</span>
|
||||
<span className="text-brand-cyan font-mono text-[10px]">
|
||||
{metricsStatus === 'permission-denied' || metricsStatus === 'unavailable' || metricsStatus === 'error'
|
||||
? 'Unavailable'
|
||||
: 'Lateral Roll'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 flex items-baseline space-x-2">
|
||||
<span className="text-3xl font-extrabold text-brand-cyan font-mono">{metrics.peakLean > 0 ? `${metrics.peakLean}°` : '--'}</span>
|
||||
<span className="text-3xl font-extrabold text-brand-cyan font-mono">
|
||||
{metricsStatus === 'ready' && metrics !== null && metrics.peakLean > 0 ? `${metrics.peakLean}°` : '--'}
|
||||
</span>
|
||||
<span className="text-sm font-mono text-slate-400">lateral roll</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-slate-400 flex items-center space-x-1.5">
|
||||
<span className="text-amber-400">6-Axis IMU Verified</span>
|
||||
{metricsStatus === 'ready' && metrics !== null && metrics.peakLean > 0 ? (
|
||||
<span className="text-amber-400">6-Axis IMU Verified</span>
|
||||
) : metricsStatus === 'loading' ? (
|
||||
<span>Retrieving lean telemetry…</span>
|
||||
) : metricsStatus === 'unauthenticated' ? (
|
||||
<span>No active session data</span>
|
||||
) : (
|
||||
<span className="text-slate-500">No IMU roll data</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Metric 4: Hardware Link */}
|
||||
|
|
@ -325,11 +425,23 @@ export default function AccountPage() {
|
|||
syncTrigger={syncTrigger}
|
||||
onSyncStateChange={(syncing) => {
|
||||
setIsSyncing(syncing);
|
||||
if (!syncing) {
|
||||
}}
|
||||
onStatusChange={(status, _err) => {
|
||||
setMetricsStatus(status);
|
||||
if (status === 'ready') {
|
||||
setSyncError(null);
|
||||
setLastSynced(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
|
||||
} else if (status === 'permission-denied' || status === 'unavailable' || status === 'error') {
|
||||
setSyncError('Sync failed');
|
||||
} else if (status === 'unauthenticated') {
|
||||
setMetrics(null);
|
||||
setSyncError(null);
|
||||
}
|
||||
}}
|
||||
onMetricsLoaded={(m) => setMetrics(m)}
|
||||
onMetricsLoaded={(m) => {
|
||||
setMetrics(m);
|
||||
setMetricsStatus('ready');
|
||||
}}
|
||||
/>
|
||||
{/* BEGIN: PlanAndClusterSection */}
|
||||
<section className="grid grid-cols-1 lg:grid-cols-12 gap-6" data-purpose="subscription-and-sync-status">
|
||||
|
|
|
|||
|
|
@ -139,15 +139,40 @@ function StockMotorcycleGraphic({ className = 'w-full h-40' }: { className?: str
|
|||
);
|
||||
}
|
||||
|
||||
interface AppCloudDataProps {
|
||||
syncTrigger?: number;
|
||||
onSyncStateChange?: (syncing: boolean) => void;
|
||||
onMetricsLoaded?: (metrics: { totalMiles: number; rideCount: number; peakLean: number }) => void;
|
||||
export interface AccountCloudMetrics {
|
||||
totalMiles: number;
|
||||
rideCount: number;
|
||||
peakLean: number;
|
||||
}
|
||||
|
||||
export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoaded }: AppCloudDataProps) {
|
||||
export type CloudDataStatus =
|
||||
| 'idle'
|
||||
| 'loading'
|
||||
| 'unauthenticated'
|
||||
| 'ready'
|
||||
| 'permission-denied'
|
||||
| 'unavailable'
|
||||
| 'error';
|
||||
|
||||
export interface AppCloudDataProps {
|
||||
syncTrigger?: number;
|
||||
onSyncStateChange?: (syncing: boolean) => void;
|
||||
onMetricsLoaded?: (metrics: AccountCloudMetrics) => void;
|
||||
onStatusChange?: (status: CloudDataStatus, error?: unknown) => void;
|
||||
}
|
||||
|
||||
export function AppCloudData({
|
||||
syncTrigger = 0,
|
||||
onSyncStateChange,
|
||||
onMetricsLoaded,
|
||||
onStatusChange,
|
||||
}: AppCloudDataProps) {
|
||||
const [motorcycles, setMotorcycles] = useState<Motorcycle[]>([]);
|
||||
const [rides, setRides] = useState<Ride[]>([]);
|
||||
const [garageStatus, setGarageStatus] = useState<CloudDataStatus>('loading');
|
||||
const [ridesStatus, setRidesStatus] = useState<CloudDataStatus>('loading');
|
||||
const [garageError, setGarageError] = useState<string | null>(null);
|
||||
const [ridesError, setRidesError] = useState<string | null>(null);
|
||||
const [state, setState] = useState('Loading telemetry cloud data...');
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
|
||||
|
|
@ -331,19 +356,53 @@ export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoad
|
|||
if (!currentUser) {
|
||||
setMotorcycles([]);
|
||||
setRides([]);
|
||||
setGarageStatus('unauthenticated');
|
||||
setRidesStatus('unauthenticated');
|
||||
setGarageError(null);
|
||||
setRidesError(null);
|
||||
setState('Sign in or connect your Google account to view your live telemetry.');
|
||||
if (onMetricsLoaded) onMetricsLoaded({ totalMiles: 0, rideCount: 0, peakLean: 0 });
|
||||
if (onStatusChange) onStatusChange('unauthenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
setGarageStatus('loading');
|
||||
setRidesStatus('loading');
|
||||
setGarageError(null);
|
||||
setRidesError(null);
|
||||
if (onStatusChange) onStatusChange('loading');
|
||||
if (onSyncStateChange) onSyncStateChange(true);
|
||||
|
||||
function classifyError(err: unknown): { status: CloudDataStatus; message: string } {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const code = (err as { code?: string })?.code;
|
||||
if (
|
||||
code === 'permission-denied' ||
|
||||
msg.includes('permission-denied') ||
|
||||
msg.includes('Missing or insufficient permissions')
|
||||
) {
|
||||
return { status: 'permission-denied', message: 'Permission denied: unable to access cloud telemetry.' };
|
||||
}
|
||||
if (
|
||||
code === 'unavailable' ||
|
||||
msg.includes('unavailable') ||
|
||||
msg.includes('offline') ||
|
||||
msg.includes('network') ||
|
||||
msg.includes('Failed to fetch')
|
||||
) {
|
||||
return { status: 'unavailable', message: 'Network offline: unable to reach MotorSignal cloud.' };
|
||||
}
|
||||
return { status: 'error', message: msg || 'An unexpected error occurred while loading cloud telemetry.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const [motorcycleSnapshot, rideSnapshot] = await Promise.all([
|
||||
const [motorcycleRes, rideRes] = await Promise.allSettled([
|
||||
getDocs(collection(firestore, 'users', currentUser.uid, 'motorcycles')),
|
||||
getDocs(collection(firestore, 'users', currentUser.uid, 'rides')),
|
||||
]);
|
||||
|
||||
const loadedMotorcycles: Motorcycle[] = motorcycleSnapshot.docs.map((snapshot) => {
|
||||
// Process Motorcycles
|
||||
if (motorcycleRes.status === 'fulfilled') {
|
||||
const loadedMotorcycles: Motorcycle[] = motorcycleRes.value.docs.map((snapshot) => {
|
||||
const data = snapshot.data();
|
||||
const yearVal = number(data.year) || 0;
|
||||
const makeVal = text(data.makeName) || text(data.make);
|
||||
|
|
@ -364,10 +423,22 @@ export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoad
|
|||
};
|
||||
}).sort((a, b) => Number(b.isDefault) - Number(a.isDefault));
|
||||
|
||||
setMotorcycles(loadedMotorcycles);
|
||||
setGarageStatus('ready');
|
||||
setGarageError(null);
|
||||
} else {
|
||||
const classified = classifyError(motorcycleRes.reason);
|
||||
setGarageStatus(classified.status);
|
||||
setGarageError(classified.message);
|
||||
console.error('Error loading motorcycles:', motorcycleRes.reason);
|
||||
}
|
||||
|
||||
// Process Rides & Metrics
|
||||
if (rideRes.status === 'fulfilled') {
|
||||
let totalMiles = 0;
|
||||
let peakLean = 0;
|
||||
|
||||
const loadedRides: Ride[] = rideSnapshot.docs.map((snapshot) => {
|
||||
const loadedRides: Ride[] = rideRes.value.docs.map((snapshot) => {
|
||||
const data = snapshot.data();
|
||||
const rawStart = text(data.startTime);
|
||||
const rawEnd = text(data.endTime);
|
||||
|
|
@ -420,20 +491,45 @@ export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoad
|
|||
};
|
||||
}).sort((a, b) => (b.startedAt?.getTime() ?? 0) - (a.startedAt?.getTime() ?? 0));
|
||||
|
||||
setMotorcycles(loadedMotorcycles);
|
||||
setRides(loadedRides);
|
||||
setState('');
|
||||
setRidesStatus('ready');
|
||||
setRidesError(null);
|
||||
|
||||
if (onMetricsLoaded) {
|
||||
onMetricsLoaded({
|
||||
const computedMetrics = {
|
||||
totalMiles: Math.round(totalMiles),
|
||||
rideCount: loadedRides.length,
|
||||
peakLean: Number(peakLean.toFixed(1)),
|
||||
});
|
||||
};
|
||||
|
||||
if (onMetricsLoaded) {
|
||||
onMetricsLoaded(computedMetrics);
|
||||
}
|
||||
if (onStatusChange) {
|
||||
onStatusChange('ready');
|
||||
}
|
||||
} else {
|
||||
const classified = classifyError(rideRes.reason);
|
||||
setRidesStatus(classified.status);
|
||||
setRidesError(classified.message);
|
||||
console.error('Error loading telemetry rides:', rideRes.reason);
|
||||
if (onStatusChange) {
|
||||
onStatusChange(classified.status, rideRes.reason);
|
||||
}
|
||||
}
|
||||
|
||||
if (motorcycleRes.status === 'rejected' && rideRes.status === 'rejected') {
|
||||
setState('App cloud data could not be loaded. Please re-authenticate or click Quick Sync.');
|
||||
} else if (motorcycleRes.status === 'rejected' || rideRes.status === 'rejected') {
|
||||
setState('Some cloud telemetry could not be loaded.');
|
||||
} else {
|
||||
setState('');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading MotorSignal cloud data:', error);
|
||||
setState('App cloud data could not be loaded. Please re-authenticate or click Quick Sync.');
|
||||
if (onStatusChange) {
|
||||
onStatusChange('error', error);
|
||||
}
|
||||
} finally {
|
||||
if (onSyncStateChange) onSyncStateChange(false);
|
||||
}
|
||||
|
|
@ -855,6 +951,34 @@ export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoad
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : garageStatus === 'loading' ? (
|
||||
<div className="glass-card rounded-2xl border border-slate-800 p-8 text-center space-y-3" data-testid="garage-loading">
|
||||
<div className="w-8 h-8 mx-auto rounded-full border-2 border-brand-cyan/40 border-t-brand-cyan animate-spin"></div>
|
||||
<p className="text-xs text-slate-400 font-mono">Loading motorcycle garage…</p>
|
||||
</div>
|
||||
) : garageStatus === 'permission-denied' || garageStatus === 'unavailable' || garageStatus === 'error' ? (
|
||||
<div className="glass-card rounded-2xl border border-rose-500/30 bg-rose-500/5 p-8 text-center space-y-3" data-testid="cloud-garage-error">
|
||||
<div className="w-10 h-10 mx-auto rounded-full bg-rose-500/20 text-rose-400 flex items-center justify-center font-bold">
|
||||
!
|
||||
</div>
|
||||
<h3 className="text-sm font-bold text-white">
|
||||
{garageStatus === 'permission-denied'
|
||||
? 'Garage Telemetry Restricted — Permission Denied'
|
||||
: 'Garage Telemetry Unavailable'}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 font-mono max-w-md mx-auto">
|
||||
{garageError || (garageStatus === 'permission-denied'
|
||||
? 'Your account does not have permission to read motorcycles from Firestore.'
|
||||
: 'Unable to load motorcycle garage from cloud. Please verify connection and retry.')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadData(user)}
|
||||
className="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-mono border border-slate-600 transition cursor-pointer"
|
||||
>
|
||||
Retry Sync
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="glass-card rounded-2xl border border-dashed border-slate-700 p-8 text-center space-y-4">
|
||||
<StockMotorcycleGraphic className="max-w-md mx-auto h-36 opacity-60" />
|
||||
|
|
@ -890,9 +1014,19 @@ export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoad
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{ridesStatus === 'loading' ? (
|
||||
<span className="text-xs font-mono text-slate-400 bg-slate-800 border border-slate-700 px-2.5 py-1 rounded-full">
|
||||
Checking sessions…
|
||||
</span>
|
||||
) : ridesStatus === 'permission-denied' || ridesStatus === 'unavailable' || ridesStatus === 'error' ? (
|
||||
<span className="text-xs font-mono text-rose-400 bg-rose-500/10 border border-rose-500/30 px-2.5 py-1 rounded-full">
|
||||
Feed Unavailable
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs font-mono text-brand-emerald bg-brand-emerald/10 border border-brand-emerald/30 px-2.5 py-1 rounded-full">
|
||||
{rides.length} {rides.length === 1 ? 'Session' : 'Sessions'} Recorded
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rides.length > 0 ? (
|
||||
|
|
@ -1098,6 +1232,34 @@ export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoad
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : ridesStatus === 'loading' ? (
|
||||
<div className="glass-card rounded-2xl border border-slate-800 p-8 text-center space-y-3" data-testid="rides-loading">
|
||||
<div className="w-8 h-8 mx-auto rounded-full border-2 border-brand-emerald/40 border-t-brand-emerald animate-spin"></div>
|
||||
<p className="text-xs text-slate-400 font-mono">Loading telemetry sessions…</p>
|
||||
</div>
|
||||
) : ridesStatus === 'permission-denied' || ridesStatus === 'unavailable' || ridesStatus === 'error' ? (
|
||||
<div className="glass-card rounded-2xl border border-rose-500/30 bg-rose-500/5 p-8 text-center space-y-3" data-testid="cloud-rides-error">
|
||||
<div className="w-10 h-10 mx-auto rounded-full bg-rose-500/20 text-rose-400 flex items-center justify-center font-bold">
|
||||
!
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-white">
|
||||
{ridesStatus === 'permission-denied'
|
||||
? 'Recent Rides Restricted — Permission Denied'
|
||||
: 'Recent Rides Cloud Feed Unavailable'}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 font-mono max-w-sm mx-auto">
|
||||
{ridesError || (ridesStatus === 'permission-denied'
|
||||
? 'Firestore rejected read access to telemetry sessions for this rider.'
|
||||
: 'Unable to retrieve telemetry sessions from MotorSignal cloud. Please check network and retry.')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadData(user)}
|
||||
className="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-mono border border-slate-600 transition cursor-pointer"
|
||||
>
|
||||
Retry Cloud Sync
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="glass-card rounded-2xl border border-dashed border-slate-700 p-8 text-center space-y-3">
|
||||
<div className="w-12 h-12 mx-auto rounded-full bg-surface-container flex items-center justify-center text-slate-500">
|
||||
|
|
|
|||
|
|
@ -49,11 +49,15 @@ const CHANNELS: ChannelInfo[] = [
|
|||
|
||||
export function FounderChat({ context }: FounderChatProps) {
|
||||
const { club, permissions, currentUser, isDemoFixture } = context;
|
||||
const isDemo = Boolean(isDemoFixture);
|
||||
const [activeChannelId, setActiveChannelId] = useState<ChannelId>('general');
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connectionState, setConnectionState] = useState<'connecting' | 'connected' | 'error'>(
|
||||
isDemo ? 'connected' : 'connecting'
|
||||
);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const isOfficer = permissions.canManageSettings || currentUser.role === 'owner' || currentUser.role === 'admin';
|
||||
|
|
@ -61,22 +65,27 @@ export function FounderChat({ context }: FounderChatProps) {
|
|||
const activeChannel = CHANNELS.find((c) => c.id === activeChannelId) || CHANNELS[0];
|
||||
const isRestrictedChannel = activeChannel.requiresOfficer && !isOfficer;
|
||||
const hasExampleMessages = messages.length > 0 && messages.every((m) => m.isExample);
|
||||
const isExampleThread = isDemo || hasExampleMessages;
|
||||
|
||||
useEffect(() => {
|
||||
setError(null);
|
||||
let unsubscribe: () => void = () => {};
|
||||
|
||||
if (isDemoFixture) {
|
||||
if (isDemo) {
|
||||
setConnectionState('connected');
|
||||
const channelMsgs = FIXTURE_CHAT_MESSAGES[activeChannelId] || [];
|
||||
setMessages(channelMsgs.map((m) => ({ ...m, clubId: club.id, channelId: activeChannelId, isExample: true })));
|
||||
} else {
|
||||
setConnectionState('connecting');
|
||||
unsubscribe = subscribeClubChat(
|
||||
club.id,
|
||||
activeChannelId,
|
||||
(newMsgs) => {
|
||||
setMessages(newMsgs);
|
||||
setConnectionState('connected');
|
||||
},
|
||||
(err) => {
|
||||
setConnectionState('error');
|
||||
setError(sanitizePortalError(err, 'Failed to connect to chat channel.'));
|
||||
}
|
||||
);
|
||||
|
|
@ -85,7 +94,7 @@ export function FounderChat({ context }: FounderChatProps) {
|
|||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [club.id, activeChannelId, isDemoFixture]);
|
||||
}, [club.id, activeChannelId, isDemo]);
|
||||
|
||||
// Auto-scroll on new message
|
||||
useEffect(() => {
|
||||
|
|
@ -103,7 +112,7 @@ export function FounderChat({ context }: FounderChatProps) {
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
if (isDemoFixture) {
|
||||
if (isDemo) {
|
||||
const optimisticMsg: ChatMessage = {
|
||||
id: `demo-msg-${Date.now()}`,
|
||||
clubId: club.id,
|
||||
|
|
@ -115,6 +124,7 @@ export function FounderChat({ context }: FounderChatProps) {
|
|||
authorRole: currentUser.role,
|
||||
content: clean,
|
||||
createdAt: new Date().toISOString(),
|
||||
isExample: true,
|
||||
};
|
||||
setMessages((prev) => [...prev, optimisticMsg]);
|
||||
} else {
|
||||
|
|
@ -144,11 +154,21 @@ export function FounderChat({ context }: FounderChatProps) {
|
|||
<h1 className="font-heading text-2xl font-bold tracking-wide text-white">
|
||||
Squad Live Chat
|
||||
</h1>
|
||||
{hasExampleMessages ? (
|
||||
{isExampleThread ? (
|
||||
<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>
|
||||
Example thread
|
||||
</span>
|
||||
) : 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="w-2 h-2 rounded-full bg-slate-400 animate-pulse" />
|
||||
Connecting…
|
||||
</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>
|
||||
) : (
|
||||
<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="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||
|
|
@ -187,12 +207,21 @@ export function FounderChat({ context }: FounderChatProps) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{hasExampleMessages && (
|
||||
{isExampleThread && (
|
||||
<div className="p-3 rounded-xl bg-amber-500/10 border border-amber-500/30 text-amber-200/90 text-xs flex items-center gap-2.5">
|
||||
<span className="material-symbols-outlined text-[16px] text-amber-400 shrink-0">info</span>
|
||||
<span>
|
||||
{isDemo ? (
|
||||
<>
|
||||
<strong className="font-semibold text-amber-100">Founder Demo Preview</strong> — this is a
|
||||
simulated conversation thread. Messages are not persisted or sent to club members.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
These are <strong className="font-semibold text-amber-100">example messages</strong> — this
|
||||
channel has no posts yet. Send a message to start the real conversation.
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -298,6 +327,11 @@ export function FounderChat({ context }: FounderChatProps) {
|
|||
{msg.authorDisplayRank}
|
||||
</span>
|
||||
)}
|
||||
{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]">
|
||||
Example
|
||||
</span>
|
||||
)}
|
||||
<span className="text-slate-500 font-mono ml-auto">
|
||||
{msg.createdAt
|
||||
? new Date(msg.createdAt).toLocaleTimeString([], {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,371 @@
|
|||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import AccountPage from '../app/account/page';
|
||||
import { AppCloudData } from '../components/app-cloud-data';
|
||||
|
||||
const authListeners = new Set<(user: unknown) => void>();
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getDocs: vi.fn(),
|
||||
deleteDoc: vi.fn(),
|
||||
updateDoc: vi.fn(),
|
||||
addDoc: vi.fn(),
|
||||
writeBatch: vi.fn(() => ({
|
||||
update: vi.fn(),
|
||||
commit: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => '/account',
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/firebase-client', () => ({
|
||||
firebaseAuth: {},
|
||||
firestore: {},
|
||||
}));
|
||||
|
||||
vi.mock('firebase/auth', () => ({
|
||||
onAuthStateChanged: (_auth: unknown, deliver: (user: unknown) => void) => {
|
||||
authListeners.add(deliver);
|
||||
return () => {
|
||||
authListeners.delete(deliver);
|
||||
};
|
||||
},
|
||||
multiFactor: () => ({ enrolledFactors: [] }),
|
||||
}));
|
||||
|
||||
vi.mock('firebase/firestore', () => ({
|
||||
collection: (...args: unknown[]) => ({ path: args.slice(1).join('/') }),
|
||||
doc: (...args: unknown[]) => ({ path: args.slice(1).join('/'), id: args[args.length - 1] }),
|
||||
getDocs: (...args: unknown[]) => mocks.getDocs(...args),
|
||||
deleteDoc: (...args: unknown[]) => mocks.deleteDoc(...args),
|
||||
updateDoc: (...args: unknown[]) => mocks.updateDoc(...args),
|
||||
addDoc: (...args: unknown[]) => mocks.addDoc(...args),
|
||||
writeBatch: () => mocks.writeBatch(),
|
||||
}));
|
||||
|
||||
function deliverAuth(user: unknown) {
|
||||
authListeners.forEach((listener) => listener(user));
|
||||
}
|
||||
|
||||
describe('Account Cloud Metrics Truthfulness (PR #38 Thread 1)', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
authListeners.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const authUser = {
|
||||
uid: 'rider-123',
|
||||
email: 'trackday@example.com',
|
||||
displayName: 'Test Rider',
|
||||
};
|
||||
|
||||
it('distinguishes initial loading: shows Loading… and Checking sync…, never 0 miles, 0 rides, or 100% Cloud Synced', async () => {
|
||||
// Keep getDocs pending so it stays in loading state
|
||||
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);
|
||||
|
||||
// Metric 1: Distance must show Loading…, not 0 miles
|
||||
const distanceCard = screen.getByTestId('metric-card-distance');
|
||||
expect(distanceCard.textContent).toContain('Loading…');
|
||||
expect(distanceCard.textContent).not.toContain('0 miles');
|
||||
|
||||
// Metric 2: Sessions must show Checking sync… or Loading…, not 0 rides or 100% Cloud Synced
|
||||
const sessionsCard = screen.getByTestId('metric-card-sessions');
|
||||
expect(sessionsCard.textContent).toContain('Checking sync…');
|
||||
expect(sessionsCard.textContent).not.toContain('0 rides');
|
||||
expect(sessionsCard.textContent).not.toContain('100% Cloud Synced');
|
||||
|
||||
// Metric 3: Lean Angle Peak must be '--'
|
||||
const leanCard = screen.getByTestId('metric-card-lean');
|
||||
expect(leanCard.textContent).toContain('--');
|
||||
});
|
||||
|
||||
it('truthfully handles signed-out state: shows sign-in required, never 0 miles or 100% Cloud Synced', async () => {
|
||||
render(<AccountPage />);
|
||||
deliverAuth(null);
|
||||
|
||||
await waitFor(() => {
|
||||
const distanceCard = screen.getByTestId('metric-card-distance');
|
||||
expect(distanceCard.textContent).toContain('Sign in to view');
|
||||
expect(distanceCard.textContent).toContain('—');
|
||||
expect(distanceCard.textContent).not.toContain('0 miles');
|
||||
});
|
||||
|
||||
const sessionsCard = screen.getByTestId('metric-card-sessions');
|
||||
expect(sessionsCard.textContent).toContain('Sign-in required');
|
||||
expect(sessionsCard.textContent).toContain('—');
|
||||
expect(sessionsCard.textContent).not.toContain('0 rides');
|
||||
expect(sessionsCard.textContent).not.toContain('100% Cloud Synced');
|
||||
});
|
||||
|
||||
it('distinguishes real zero-ride account: shows verified 0 miles, 0 rides, Cloud Synced (NOT 100% Cloud Synced)', async () => {
|
||||
mocks.getDocs.mockImplementation((colRef: { path: string }) => {
|
||||
return Promise.resolve({ docs: [], empty: true });
|
||||
});
|
||||
|
||||
render(<AccountPage />);
|
||||
deliverAuth(authUser);
|
||||
|
||||
await waitFor(() => {
|
||||
const distanceCard = screen.getByTestId('metric-card-distance');
|
||||
expect(distanceCard.textContent).toContain('0');
|
||||
expect(distanceCard.textContent).toContain('miles');
|
||||
expect(distanceCard.textContent).toContain('Cloud Aggregate');
|
||||
});
|
||||
|
||||
const sessionsCard = screen.getByTestId('metric-card-sessions');
|
||||
expect(sessionsCard.textContent).toContain('0');
|
||||
expect(sessionsCard.textContent).toContain('rides');
|
||||
// Must NOT claim "100% Cloud Synced" on an empty account with no rides
|
||||
expect(sessionsCard.textContent).toContain('Cloud Synced');
|
||||
expect(sessionsCard.textContent).not.toContain('100% Cloud Synced');
|
||||
expect(sessionsCard.textContent).toContain('No rides recorded yet');
|
||||
});
|
||||
|
||||
it('displays real calculated totals for populated accounts and claims 100% Cloud Synced only when rides exist', async () => {
|
||||
const mockRides = [
|
||||
{
|
||||
id: 'ride-1',
|
||||
data: () => ({
|
||||
startTime: '2026-07-01T10:00:00Z',
|
||||
endTime: '2026-07-01T11:00:00Z',
|
||||
distanceMiles: 45.2,
|
||||
topSpeedMph: 72.4,
|
||||
averageSpeed: 45.2,
|
||||
maxLeanLeft: 42.1,
|
||||
maxLeanRight: 45.6,
|
||||
maxAccelG: 0.65,
|
||||
maxBrakingG: 0.82,
|
||||
movingTime: 3600,
|
||||
elevationGain: 850,
|
||||
qualityScore: 98,
|
||||
motorcycleYear: 2024,
|
||||
motorcycleMake: 'Yamaha',
|
||||
motorcycleModel: 'YZF-R7',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'ride-2',
|
||||
data: () => ({
|
||||
startTime: '2026-07-02T14:00:00Z',
|
||||
endTime: '2026-07-02T15:30:00Z',
|
||||
distanceMiles: 54.8,
|
||||
topSpeedMph: 85.0,
|
||||
averageSpeed: 52.0,
|
||||
maxLeanLeft: 48.5,
|
||||
maxLeanRight: 46.0,
|
||||
maxAccelG: 0.75,
|
||||
maxBrakingG: 0.90,
|
||||
movingTime: 5400,
|
||||
elevationGain: 1200,
|
||||
qualityScore: 95,
|
||||
motorcycleYear: 2024,
|
||||
motorcycleMake: 'Yamaha',
|
||||
motorcycleModel: 'YZF-R7',
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
mocks.getDocs.mockImplementation((colRef: { path: string }) => {
|
||||
if (colRef.path.includes('rides')) {
|
||||
return Promise.resolve({ docs: mockRides, empty: false });
|
||||
}
|
||||
return Promise.resolve({ docs: [], empty: true });
|
||||
});
|
||||
|
||||
render(<AccountPage />);
|
||||
deliverAuth(authUser);
|
||||
|
||||
await waitFor(() => {
|
||||
const distanceCard = screen.getByTestId('metric-card-distance');
|
||||
// 45.2 + 54.8 = 100 miles
|
||||
expect(distanceCard.textContent).toContain('100');
|
||||
expect(distanceCard.textContent).toContain('miles');
|
||||
});
|
||||
|
||||
const sessionsCard = screen.getByTestId('metric-card-sessions');
|
||||
expect(sessionsCard.textContent).toContain('2');
|
||||
expect(sessionsCard.textContent).toContain('rides');
|
||||
// With real rides loaded, it is confirmed 100% Cloud Synced
|
||||
expect(sessionsCard.textContent).toContain('100% Cloud Synced');
|
||||
expect(sessionsCard.textContent).toContain('Canyons, Circuits & Commutes');
|
||||
|
||||
const leanCard = screen.getByTestId('metric-card-lean');
|
||||
expect(leanCard.textContent).toContain('48.5°');
|
||||
expect(leanCard.textContent).toContain('6-Axis IMU Verified');
|
||||
});
|
||||
|
||||
it('truthfully handles Firestore permission denied: shows Unavailable and Permission denied, never verified zero totals', async () => {
|
||||
mocks.getDocs.mockImplementation(() => {
|
||||
const err = new Error('Missing or insufficient permissions.');
|
||||
(err as unknown as { code: string }).code = 'permission-denied';
|
||||
return Promise.reject(err);
|
||||
});
|
||||
|
||||
render(<AccountPage />);
|
||||
deliverAuth(authUser);
|
||||
|
||||
await waitFor(() => {
|
||||
const distanceCard = screen.getByTestId('metric-card-distance');
|
||||
expect(distanceCard.textContent).toContain('Unavailable');
|
||||
expect(distanceCard.textContent).toContain('Permission denied');
|
||||
expect(distanceCard.textContent).not.toContain('0 miles');
|
||||
});
|
||||
|
||||
const sessionsCard = screen.getByTestId('metric-card-sessions');
|
||||
expect(sessionsCard.textContent).toContain('Unavailable');
|
||||
expect(sessionsCard.textContent).toContain('Permission denied');
|
||||
expect(sessionsCard.textContent).not.toContain('0 rides');
|
||||
expect(sessionsCard.textContent).not.toContain('100% Cloud Synced');
|
||||
|
||||
// AppCloudData error banners
|
||||
expect(screen.getByTestId('cloud-garage-error')).toBeDefined();
|
||||
expect(screen.getByTestId('cloud-rides-error')).toBeDefined();
|
||||
expect(screen.getByText(/Recent Rides Restricted — Permission Denied/i)).toBeDefined();
|
||||
});
|
||||
|
||||
it('truthfully handles network / temporary backend failure: shows Unavailable and Sync status unavailable', async () => {
|
||||
mocks.getDocs.mockImplementation(() => {
|
||||
const err = new Error('Failed to fetch: client is offline');
|
||||
(err as unknown as { code: string }).code = 'unavailable';
|
||||
return Promise.reject(err);
|
||||
});
|
||||
|
||||
render(<AccountPage />);
|
||||
deliverAuth(authUser);
|
||||
|
||||
await waitFor(() => {
|
||||
const distanceCard = screen.getByTestId('metric-card-distance');
|
||||
expect(distanceCard.textContent).toContain('Unavailable');
|
||||
expect(distanceCard.textContent).toContain('Sync unavailable');
|
||||
expect(distanceCard.textContent).not.toContain('0 miles');
|
||||
});
|
||||
|
||||
const sessionsCard = screen.getByTestId('metric-card-sessions');
|
||||
expect(sessionsCard.textContent).toContain('Unavailable');
|
||||
expect(sessionsCard.textContent).toContain('Sync status unavailable');
|
||||
expect(sessionsCard.textContent).not.toContain('0 rides');
|
||||
expect(sessionsCard.textContent).not.toContain('100% Cloud Synced');
|
||||
|
||||
expect(screen.getByTestId('cloud-rides-error')).toBeDefined();
|
||||
expect(screen.getByText(/Recent Rides Cloud Feed Unavailable/i)).toBeDefined();
|
||||
});
|
||||
|
||||
it('handles partial data availability: shows garage when motorcycles load, but keeps rides metrics Unavailable when rides query fails', async () => {
|
||||
const mockBike = {
|
||||
id: 'bike-1',
|
||||
data: () => ({
|
||||
year: 2024,
|
||||
makeName: 'Ducati',
|
||||
modelName: 'Panigale V4',
|
||||
variantName: 'SP2',
|
||||
nickname: 'Red Bullet',
|
||||
isDefault: true,
|
||||
}),
|
||||
};
|
||||
|
||||
mocks.getDocs.mockImplementation((colRef: { path: string }) => {
|
||||
if (colRef.path.includes('motorcycles')) {
|
||||
return Promise.resolve({ docs: [mockBike], empty: false });
|
||||
}
|
||||
const err = new Error('Rides query unavailable');
|
||||
(err as unknown as { code: string }).code = 'unavailable';
|
||||
return Promise.reject(err);
|
||||
});
|
||||
|
||||
render(<AccountPage />);
|
||||
deliverAuth(authUser);
|
||||
|
||||
await waitFor(() => {
|
||||
// Bike rendered in garage
|
||||
expect(screen.getByText('Red Bullet')).toBeDefined();
|
||||
});
|
||||
|
||||
// But ride metrics MUST show Unavailable, not zero!
|
||||
const sessionsCard = screen.getByTestId('metric-card-sessions');
|
||||
expect(sessionsCard.textContent).toContain('Unavailable');
|
||||
expect(sessionsCard.textContent).not.toContain('0 rides');
|
||||
expect(sessionsCard.textContent).not.toContain('100% Cloud Synced');
|
||||
|
||||
// Rides feed in AppCloudData shows error
|
||||
expect(screen.getByTestId('cloud-rides-error')).toBeDefined();
|
||||
});
|
||||
|
||||
it('recovers after retry: Quick Sync displays error on failure and updates to Synced on successful retry', async () => {
|
||||
let shouldFail = true;
|
||||
mocks.getDocs.mockImplementation((colRef: { path: string }) => {
|
||||
if (shouldFail) {
|
||||
const err = new Error('Network error');
|
||||
(err as unknown as { code: string }).code = 'unavailable';
|
||||
return Promise.reject(err);
|
||||
}
|
||||
if (colRef.path.includes('rides')) {
|
||||
return Promise.resolve({
|
||||
docs: [
|
||||
{
|
||||
id: 'ride-1',
|
||||
data: () => ({
|
||||
startTime: '2026-07-01T10:00:00Z',
|
||||
endTime: '2026-07-01T11:00:00Z',
|
||||
distanceMiles: 15.0,
|
||||
topSpeedMph: 60,
|
||||
averageSpeed: 35,
|
||||
maxLeanLeft: 30,
|
||||
maxLeanRight: 32,
|
||||
maxAccelG: 0.5,
|
||||
maxBrakingG: 0.6,
|
||||
movingTime: 1200,
|
||||
elevationGain: 200,
|
||||
qualityScore: 90,
|
||||
motorcycleYear: 2024,
|
||||
motorcycleMake: 'Yamaha',
|
||||
motorcycleModel: 'MT-07',
|
||||
}),
|
||||
},
|
||||
],
|
||||
empty: false,
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ docs: [], empty: true });
|
||||
});
|
||||
|
||||
render(<AccountPage />);
|
||||
deliverAuth(authUser);
|
||||
|
||||
// Initial failure
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Sync Failed \(Retry\)/i)).toBeDefined();
|
||||
});
|
||||
|
||||
// Make next fetch succeed
|
||||
shouldFail = false;
|
||||
|
||||
// Click retry via Quick Sync button
|
||||
const quickSyncBtn = screen.getByRole('button', { name: /Sync Failed \(Retry\)/i });
|
||||
fireEvent.click(quickSyncBtn);
|
||||
|
||||
// Recovered state
|
||||
await waitFor(() => {
|
||||
const sessionsCard = screen.getByTestId('metric-card-sessions');
|
||||
expect(sessionsCard.textContent).toContain('1');
|
||||
expect(sessionsCard.textContent).toContain('rides');
|
||||
expect(sessionsCard.textContent).toContain('100% Cloud Synced');
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Synced \(/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -355,6 +355,105 @@ describe('Founder Chat Management', () => {
|
|||
await screen.findByText('Officers Lounge Restricted');
|
||||
expect(screen.getByText(/strictly reserved for club Founders and Admins/i)).toBeDefined();
|
||||
});
|
||||
|
||||
describe('Founder Demo Chat Truthfulness (PR #38 Thread 2)', () => {
|
||||
const demoContext: FounderClubContext = {
|
||||
...mockContext,
|
||||
isDemoFixture: true,
|
||||
};
|
||||
|
||||
it('displays Example thread and Demo Preview banner on initial demo fixtures', async () => {
|
||||
render(<FounderChat context={demoContext} />);
|
||||
await screen.findByText('Squad Live Chat');
|
||||
|
||||
expect(screen.getByText('Example thread')).toBeDefined();
|
||||
expect(screen.getByText(/Founder Demo Preview/i)).toBeDefined();
|
||||
expect(screen.getByText(/simulated conversation thread/i)).toBeDefined();
|
||||
expect(screen.queryByText('Live Connected')).toBeNull();
|
||||
// subscribeChat must NOT be called in demo mode
|
||||
expect(mocks.subscribeChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps Example thread and does not flip to Live Connected after sending a simulated message', async () => {
|
||||
render(<FounderChat context={demoContext} />);
|
||||
await screen.findByText('Squad Live Chat');
|
||||
|
||||
const input = screen.getByPlaceholderText(/Message #general-squad/i);
|
||||
fireEvent.change(input, { target: { value: 'Testing demo simulated message' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Send/i }));
|
||||
|
||||
await screen.findByText('Testing demo simulated message');
|
||||
|
||||
// Crucial: Must NEVER switch to Live Connected
|
||||
expect(screen.getByText('Example thread')).toBeDefined();
|
||||
expect(screen.getByText(/Founder Demo Preview/i)).toBeDefined();
|
||||
expect(screen.queryByText('Live Connected')).toBeNull();
|
||||
|
||||
// Backend send must NOT be called in demo mode
|
||||
expect(mocks.sendChat).not.toHaveBeenCalled();
|
||||
|
||||
// Simulated message has Example badge
|
||||
expect(screen.getAllByText('Example').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('keeps Example thread after sending multiple simulated messages', async () => {
|
||||
render(<FounderChat context={demoContext} />);
|
||||
await screen.findByText('Squad Live Chat');
|
||||
|
||||
const input = screen.getByPlaceholderText(/Message #general-squad/i);
|
||||
|
||||
fireEvent.change(input, { target: { value: 'First simulated message' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Send/i }));
|
||||
await screen.findByText('First simulated message');
|
||||
|
||||
fireEvent.change(input, { target: { value: 'Second simulated message' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Send/i }));
|
||||
await screen.findByText('Second simulated message');
|
||||
|
||||
// Still Example thread
|
||||
expect(screen.getByText('Example thread')).toBeDefined();
|
||||
expect(screen.queryByText('Live Connected')).toBeNull();
|
||||
expect(mocks.sendChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('displays Example thread even when demo channel has zero messages', async () => {
|
||||
render(<FounderChat context={demoContext} />);
|
||||
await screen.findByText('Squad Live Chat');
|
||||
|
||||
// Switch to road-intel channel which has empty fixture in our mock
|
||||
fireEvent.click(screen.getByText('#road-intel'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No messages yet in #road-intel/i)).toBeDefined();
|
||||
});
|
||||
|
||||
// In demo mode, zero messages must still show Example thread, never Live Connected!
|
||||
expect(screen.getByText('Example thread')).toBeDefined();
|
||||
expect(screen.queryByText('Live Connected')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows Live Connected only when real backend subscription delivers messages', async () => {
|
||||
render(<FounderChat context={mockContext} />);
|
||||
await screen.findByText('Welcome to squad chat!');
|
||||
|
||||
expect(screen.getByText('Live Connected')).toBeDefined();
|
||||
expect(screen.queryByText('Example thread')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows Disconnected and error banner when backend subscription fails in real mode', async () => {
|
||||
mocks.subscribeChat.mockImplementation((_clubId, _channelId, _onMsgs, onError) => {
|
||||
onError(new Error('Backend connection failed'));
|
||||
return vi.fn();
|
||||
});
|
||||
|
||||
render(<FounderChat context={mockContext} />);
|
||||
await screen.findByText(/Failed to connect to chat channel/i);
|
||||
|
||||
expect(screen.getByText('Disconnected')).toBeDefined();
|
||||
expect(screen.queryByText('Live Connected')).toBeNull();
|
||||
expect(screen.queryByText('Example thread')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Founder Community Page & Sidebar Integration', () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue