521 lines
14 KiB
TypeScript
521 lines
14 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
|
|
import { GetDashboardOverviewUseCase } from '@gridpilot/racing/application/use-cases/GetDashboardOverviewUseCase';
|
|
import type {
|
|
IDashboardOverviewPresenter,
|
|
DashboardOverviewViewModel,
|
|
DashboardFeedItemSummaryViewModel,
|
|
} from '@gridpilot/racing/application/presenters/IDashboardOverviewPresenter';
|
|
|
|
class FakeDashboardOverviewPresenter implements IDashboardOverviewPresenter {
|
|
viewModel: DashboardOverviewViewModel | null = null;
|
|
|
|
reset(): void {
|
|
this.viewModel = null;
|
|
}
|
|
|
|
present(viewModel: DashboardOverviewViewModel): void {
|
|
this.viewModel = viewModel;
|
|
}
|
|
|
|
getViewModel(): DashboardOverviewViewModel | null {
|
|
return this.viewModel;
|
|
}
|
|
}
|
|
|
|
interface TestImageService {
|
|
getDriverAvatar(driverId: string): string;
|
|
}
|
|
|
|
function createTestImageService(): TestImageService {
|
|
return {
|
|
getDriverAvatar: (driverId: string) => `avatar-${driverId}`,
|
|
};
|
|
}
|
|
|
|
describe('GetDashboardOverviewUseCase', () => {
|
|
it('partitions upcoming races into myUpcomingRaces and otherUpcomingRaces and selects nextRace from myUpcomingRaces', async () => {
|
|
// Given a driver with memberships in two leagues and future races with mixed registration
|
|
const driverId = 'driver-1';
|
|
|
|
const driver = { id: driverId, name: 'Alice Racer', country: 'US' };
|
|
|
|
const leagues = [
|
|
{ id: 'league-1', name: 'Alpha League' },
|
|
{ id: 'league-2', name: 'Beta League' },
|
|
];
|
|
|
|
const now = Date.now();
|
|
|
|
const races = [
|
|
{
|
|
id: 'race-1',
|
|
leagueId: 'league-1',
|
|
track: 'Monza',
|
|
car: 'GT3',
|
|
scheduledAt: new Date(now + 60 * 60 * 1000),
|
|
status: 'scheduled' as const,
|
|
},
|
|
{
|
|
id: 'race-2',
|
|
leagueId: 'league-1',
|
|
track: 'Spa',
|
|
car: 'GT3',
|
|
scheduledAt: new Date(now + 2 * 60 * 60 * 1000),
|
|
status: 'scheduled' as const,
|
|
},
|
|
{
|
|
id: 'race-3',
|
|
leagueId: 'league-2',
|
|
track: 'Silverstone',
|
|
car: 'GT4',
|
|
scheduledAt: new Date(now + 3 * 60 * 60 * 1000),
|
|
status: 'scheduled' as const,
|
|
},
|
|
{
|
|
id: 'race-4',
|
|
leagueId: 'league-2',
|
|
track: 'Imola',
|
|
car: 'GT4',
|
|
scheduledAt: new Date(now + 4 * 60 * 60 * 1000),
|
|
status: 'scheduled' as const,
|
|
},
|
|
];
|
|
|
|
const results: unknown[] = [];
|
|
|
|
const memberships = [
|
|
{
|
|
leagueId: 'league-1',
|
|
driverId,
|
|
status: 'active',
|
|
},
|
|
{
|
|
leagueId: 'league-2',
|
|
driverId,
|
|
status: 'active',
|
|
},
|
|
];
|
|
|
|
const registeredRaceIds = new Set<string>(['race-1', 'race-3']);
|
|
|
|
const feedItems: DashboardFeedItemSummaryViewModel[] = [];
|
|
const friends: Array<{ id: string }> = [];
|
|
|
|
const driverRepository: {
|
|
findById: (id: string) => Promise<{ id: string; name: string; country: string } | null>;
|
|
} = {
|
|
findById: async (id: string) => (id === driver.id ? driver : null),
|
|
};
|
|
|
|
const raceRepository: {
|
|
findAll: () => Promise<
|
|
Array<{
|
|
id: string;
|
|
leagueId: string;
|
|
track: string;
|
|
car: string;
|
|
scheduledAt: Date;
|
|
status: 'scheduled';
|
|
}>
|
|
>;
|
|
} = {
|
|
findAll: async () => races,
|
|
};
|
|
|
|
const resultRepository: {
|
|
findAll: () => Promise<unknown[]>;
|
|
} = {
|
|
findAll: async () => results,
|
|
};
|
|
|
|
const leagueRepository: {
|
|
findAll: () => Promise<Array<{ id: string; name: string }>>;
|
|
} = {
|
|
findAll: async () => leagues,
|
|
};
|
|
|
|
const standingRepository: {
|
|
findByLeagueId: (leagueId: string) => Promise<unknown[]>;
|
|
} = {
|
|
findByLeagueId: async () => [],
|
|
};
|
|
|
|
const leagueMembershipRepository: {
|
|
getMembership: (
|
|
leagueId: string,
|
|
driverIdParam: string,
|
|
) => Promise<{ leagueId: string; driverId: string; status: string } | null>;
|
|
} = {
|
|
getMembership: async (leagueId: string, driverIdParam: string) => {
|
|
return (
|
|
memberships.find(
|
|
(m) => m.leagueId === leagueId && m.driverId === driverIdParam,
|
|
) ?? null
|
|
);
|
|
},
|
|
};
|
|
|
|
const raceRegistrationRepository: {
|
|
isRegistered: (raceId: string, driverIdParam: string) => Promise<boolean>;
|
|
} = {
|
|
isRegistered: async (raceId: string, driverIdParam: string) => {
|
|
if (driverIdParam !== driverId) return false;
|
|
return registeredRaceIds.has(raceId);
|
|
},
|
|
};
|
|
|
|
const feedRepository: {
|
|
getFeedForDriver: (driverIdParam: string) => Promise<DashboardFeedItemSummaryViewModel[]>;
|
|
} = {
|
|
getFeedForDriver: async () => feedItems,
|
|
};
|
|
|
|
const socialRepository: {
|
|
getFriends: (driverIdParam: string) => Promise<Array<{ id: string }>>;
|
|
} = {
|
|
getFriends: async () => friends,
|
|
};
|
|
|
|
const imageService = createTestImageService();
|
|
|
|
const getDriverStats = (id: string) =>
|
|
id === driverId
|
|
? {
|
|
rating: 1600,
|
|
wins: 5,
|
|
podiums: 12,
|
|
totalRaces: 40,
|
|
overallRank: 42,
|
|
consistency: 88,
|
|
}
|
|
: null;
|
|
|
|
const presenter = new FakeDashboardOverviewPresenter();
|
|
|
|
const useCase = new GetDashboardOverviewUseCase(
|
|
driverRepository,
|
|
raceRepository,
|
|
resultRepository,
|
|
leagueRepository,
|
|
standingRepository,
|
|
leagueMembershipRepository,
|
|
raceRegistrationRepository,
|
|
feedRepository,
|
|
socialRepository,
|
|
imageService,
|
|
getDriverStats,
|
|
);
|
|
|
|
// When
|
|
await useCase.execute({ driverId }, presenter);
|
|
|
|
const viewModel = presenter.getViewModel();
|
|
expect(viewModel).not.toBeNull();
|
|
|
|
const vm = viewModel!;
|
|
|
|
// Then myUpcomingRaces only contains registered races from the driver's leagues
|
|
expect(vm.myUpcomingRaces.map((r) => r.id)).toEqual(['race-1', 'race-3']);
|
|
|
|
// And otherUpcomingRaces contains the other upcoming races in those leagues
|
|
expect(vm.otherUpcomingRaces.map((r) => r.id)).toEqual(['race-2', 'race-4']);
|
|
|
|
// And nextRace is the earliest upcoming race from myUpcomingRaces
|
|
expect(vm.nextRace).not.toBeNull();
|
|
expect(vm.nextRace!.id).toBe('race-1');
|
|
});
|
|
|
|
it('builds recentResults sorted by date descending and leagueStandingsSummaries from standings', async () => {
|
|
// Given completed races with results and standings
|
|
const driverId = 'driver-2';
|
|
|
|
const driver = { id: driverId, name: 'Result Driver', country: 'DE' };
|
|
|
|
const leagues = [
|
|
{ id: 'league-A', name: 'Results League A' },
|
|
{ id: 'league-B', name: 'Results League B' },
|
|
];
|
|
|
|
const raceOld = {
|
|
id: 'race-old',
|
|
leagueId: 'league-A',
|
|
track: 'Old Circuit',
|
|
car: 'GT3',
|
|
scheduledAt: new Date('2024-01-01T10:00:00Z'),
|
|
status: 'completed' as const,
|
|
};
|
|
|
|
const raceNew = {
|
|
id: 'race-new',
|
|
leagueId: 'league-B',
|
|
track: 'New Circuit',
|
|
car: 'GT4',
|
|
scheduledAt: new Date('2024-02-01T10:00:00Z'),
|
|
status: 'completed' as const,
|
|
};
|
|
|
|
const races = [raceOld, raceNew];
|
|
|
|
const results = [
|
|
{
|
|
id: 'result-old',
|
|
raceId: raceOld.id,
|
|
driverId,
|
|
position: 5,
|
|
incidents: 3,
|
|
},
|
|
{
|
|
id: 'result-new',
|
|
raceId: raceNew.id,
|
|
driverId,
|
|
position: 2,
|
|
incidents: 1,
|
|
},
|
|
];
|
|
|
|
const memberships = [
|
|
{
|
|
leagueId: 'league-A',
|
|
driverId,
|
|
status: 'active',
|
|
},
|
|
{
|
|
leagueId: 'league-B',
|
|
driverId,
|
|
status: 'active',
|
|
},
|
|
];
|
|
|
|
const standingsByLeague = new Map<
|
|
string,
|
|
Array<{ leagueId: string; driverId: string; position: number; points: number }>
|
|
>();
|
|
standingsByLeague.set('league-A', [
|
|
{ leagueId: 'league-A', driverId, position: 3, points: 50 },
|
|
{ leagueId: 'league-A', driverId: 'other-1', position: 1, points: 80 },
|
|
]);
|
|
standingsByLeague.set('league-B', [
|
|
{ leagueId: 'league-B', driverId, position: 1, points: 100 },
|
|
{ leagueId: 'league-B', driverId: 'other-2', position: 2, points: 90 },
|
|
]);
|
|
|
|
const driverRepository: {
|
|
findById: (id: string) => Promise<{ id: string; name: string; country: string } | null>;
|
|
} = {
|
|
findById: async (id: string) => (id === driver.id ? driver : null),
|
|
};
|
|
|
|
const raceRepository: {
|
|
findAll: () => Promise<typeof races>;
|
|
} = {
|
|
findAll: async () => races,
|
|
};
|
|
|
|
const resultRepository: {
|
|
findAll: () => Promise<typeof results>;
|
|
} = {
|
|
findAll: async () => results,
|
|
};
|
|
|
|
const leagueRepository: {
|
|
findAll: () => Promise<typeof leagues>;
|
|
} = {
|
|
findAll: async () => leagues,
|
|
};
|
|
|
|
const standingRepository: {
|
|
findByLeagueId: (leagueId: string) => Promise<Array<{ leagueId: string; driverId: string; position: number; points: number }>>;
|
|
} = {
|
|
findByLeagueId: async (leagueId: string) =>
|
|
standingsByLeague.get(leagueId) ?? [],
|
|
};
|
|
|
|
const leagueMembershipRepository: {
|
|
getMembership: (
|
|
leagueId: string,
|
|
driverIdParam: string,
|
|
) => Promise<{ leagueId: string; driverId: string; status: string } | null>;
|
|
} = {
|
|
getMembership: async (leagueId: string, driverIdParam: string) => {
|
|
return (
|
|
memberships.find(
|
|
(m) => m.leagueId === leagueId && m.driverId === driverIdParam,
|
|
) ?? null
|
|
);
|
|
},
|
|
};
|
|
|
|
const raceRegistrationRepository: {
|
|
isRegistered: (raceId: string, driverIdParam: string) => Promise<boolean>;
|
|
} = {
|
|
isRegistered: async () => false,
|
|
};
|
|
|
|
const feedRepository: {
|
|
getFeedForDriver: (driverIdParam: string) => Promise<DashboardFeedItemSummaryViewModel[]>;
|
|
} = {
|
|
getFeedForDriver: async () => [],
|
|
};
|
|
|
|
const socialRepository: {
|
|
getFriends: (driverIdParam: string) => Promise<Array<{ id: string }>>;
|
|
} = {
|
|
getFriends: async () => [],
|
|
};
|
|
|
|
const imageService = createTestImageService();
|
|
|
|
const getDriverStats = (id: string) =>
|
|
id === driverId
|
|
? {
|
|
rating: 1800,
|
|
wins: 3,
|
|
podiums: 7,
|
|
totalRaces: 20,
|
|
overallRank: 10,
|
|
consistency: 92,
|
|
}
|
|
: null;
|
|
|
|
const presenter = new FakeDashboardOverviewPresenter();
|
|
|
|
const useCase = new GetDashboardOverviewUseCase(
|
|
driverRepository,
|
|
raceRepository,
|
|
resultRepository,
|
|
leagueRepository,
|
|
standingRepository,
|
|
leagueMembershipRepository,
|
|
raceRegistrationRepository,
|
|
feedRepository,
|
|
socialRepository,
|
|
imageService,
|
|
getDriverStats,
|
|
);
|
|
|
|
// When
|
|
await useCase.execute({ driverId }, presenter);
|
|
|
|
const viewModel = presenter.getViewModel();
|
|
expect(viewModel).not.toBeNull();
|
|
|
|
const vm = viewModel!;
|
|
|
|
// Then recentResults are sorted by finishedAt descending (newest first)
|
|
expect(vm.recentResults.length).toBe(2);
|
|
expect(vm.recentResults[0]!.raceId).toBe('race-new');
|
|
expect(vm.recentResults[1]!.raceId).toBe('race-old');
|
|
|
|
// And leagueStandingsSummaries reflect the driver's position and points per league
|
|
const summariesByLeague = new Map(
|
|
vm.leagueStandingsSummaries.map((s) => [s.leagueId, s]),
|
|
);
|
|
|
|
const summaryA = summariesByLeague.get('league-A');
|
|
const summaryB = summariesByLeague.get('league-B');
|
|
|
|
expect(summaryA).toBeDefined();
|
|
expect(summaryA!.position).toBe(3);
|
|
expect(summaryA!.points).toBe(50);
|
|
expect(summaryA!.totalDrivers).toBe(2);
|
|
|
|
expect(summaryB).toBeDefined();
|
|
expect(summaryB!.position).toBe(1);
|
|
expect(summaryB!.points).toBe(100);
|
|
expect(summaryB!.totalDrivers).toBe(2);
|
|
});
|
|
|
|
it('returns empty collections and safe defaults when driver has no races or standings', async () => {
|
|
// Given a driver with no related data
|
|
const driverId = 'driver-empty';
|
|
|
|
const driver = { id: driverId, name: 'New Racer', country: 'FR' };
|
|
|
|
const driverRepository: {
|
|
findById: (id: string) => Promise<{ id: string; name: string; country: string } | null>;
|
|
} = {
|
|
findById: async (id: string) => (id === driver.id ? driver : null),
|
|
};
|
|
|
|
const raceRepository: { findAll: () => Promise<never[]> } = {
|
|
findAll: async () => [],
|
|
};
|
|
|
|
const resultRepository: { findAll: () => Promise<never[]> } = {
|
|
findAll: async () => [],
|
|
};
|
|
|
|
const leagueRepository: { findAll: () => Promise<never[]> } = {
|
|
findAll: async () => [],
|
|
};
|
|
|
|
const standingRepository: {
|
|
findByLeagueId: (leagueId: string) => Promise<never[]>;
|
|
} = {
|
|
findByLeagueId: async () => [],
|
|
};
|
|
|
|
const leagueMembershipRepository: {
|
|
getMembership: (leagueId: string, driverIdParam: string) => Promise<null>;
|
|
} = {
|
|
getMembership: async () => null,
|
|
};
|
|
|
|
const raceRegistrationRepository: {
|
|
isRegistered: (raceId: string, driverIdParam: string) => Promise<boolean>;
|
|
} = {
|
|
isRegistered: async () => false,
|
|
};
|
|
|
|
const feedRepository: {
|
|
getFeedForDriver: (driverIdParam: string) => Promise<DashboardFeedItemSummaryViewModel[]>;
|
|
} = {
|
|
getFeedForDriver: async () => [],
|
|
};
|
|
|
|
const socialRepository: {
|
|
getFriends: (driverIdParam: string) => Promise<Array<{ id: string }>>;
|
|
} = {
|
|
getFriends: async () => [],
|
|
};
|
|
|
|
const imageService = createTestImageService();
|
|
|
|
const getDriverStats = () => null;
|
|
|
|
const presenter = new FakeDashboardOverviewPresenter();
|
|
|
|
const useCase = new GetDashboardOverviewUseCase(
|
|
driverRepository,
|
|
raceRepository,
|
|
resultRepository,
|
|
leagueRepository,
|
|
standingRepository,
|
|
leagueMembershipRepository,
|
|
raceRegistrationRepository,
|
|
feedRepository,
|
|
socialRepository,
|
|
imageService,
|
|
getDriverStats,
|
|
);
|
|
|
|
// When
|
|
await useCase.execute({ driverId }, presenter);
|
|
|
|
const viewModel = presenter.getViewModel();
|
|
expect(viewModel).not.toBeNull();
|
|
|
|
const vm = viewModel!;
|
|
|
|
// Then collections are empty and no errors are thrown
|
|
expect(vm.myUpcomingRaces).toEqual([]);
|
|
expect(vm.otherUpcomingRaces).toEqual([]);
|
|
expect(vm.nextRace).toBeNull();
|
|
expect(vm.recentResults).toEqual([]);
|
|
expect(vm.leagueStandingsSummaries).toEqual([]);
|
|
expect(vm.friends).toEqual([]);
|
|
expect(vm.feedSummary.notificationCount).toBe(0);
|
|
expect(vm.feedSummary.items).toEqual([]);
|
|
});
|
|
}); |