view data fixes
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* DriverRegistrationStatusDisplay
|
||||
*
|
||||
* Deterministic mapping of driver registration boolean state
|
||||
* to UI labels and variants.
|
||||
*/
|
||||
|
||||
export class DriverRegistrationStatusDisplay {
|
||||
static statusMessage(isRegistered: boolean): string {
|
||||
return isRegistered ? "Registered for this race" : "Not registered";
|
||||
}
|
||||
|
||||
static statusBadgeVariant(isRegistered: boolean): string {
|
||||
return isRegistered ? "success" : "warning";
|
||||
}
|
||||
|
||||
static registrationButtonText(isRegistered: boolean): string {
|
||||
return isRegistered ? "Withdraw" : "Register";
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,10 @@ export class ProfileDisplay {
|
||||
text: 'Owner',
|
||||
badgeClasses: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30',
|
||||
},
|
||||
manager: {
|
||||
text: 'Manager',
|
||||
badgeClasses: 'bg-blue-500/10 text-blue-400 border-blue-500/30',
|
||||
},
|
||||
admin: {
|
||||
text: 'Admin',
|
||||
badgeClasses: 'bg-purple-500/10 text-purple-400 border-purple-500/30',
|
||||
|
||||
@@ -23,7 +23,6 @@ describe('DriverRegistrationService', () => {
|
||||
const mockDto = {
|
||||
isRegistered: true,
|
||||
raceId: 'race-456',
|
||||
driverId: 'driver-123',
|
||||
};
|
||||
|
||||
mockApiClient.getRegistrationStatus.mockResolvedValue(mockDto);
|
||||
@@ -36,7 +35,6 @@ describe('DriverRegistrationService', () => {
|
||||
expect(result.raceId).toBe('race-456');
|
||||
expect(result.driverId).toBe('driver-123');
|
||||
expect(result.statusMessage).toBe('Registered for this race');
|
||||
expect(result.statusColor).toBe('green');
|
||||
expect(result.statusBadgeVariant).toBe('success');
|
||||
expect(result.registrationButtonText).toBe('Withdraw');
|
||||
expect(result.canRegister).toBe(false);
|
||||
@@ -49,7 +47,6 @@ describe('DriverRegistrationService', () => {
|
||||
const mockDto = {
|
||||
isRegistered: false,
|
||||
raceId: 'race-456',
|
||||
driverId: 'driver-123',
|
||||
};
|
||||
|
||||
mockApiClient.getRegistrationStatus.mockResolvedValue(mockDto);
|
||||
@@ -58,7 +55,6 @@ describe('DriverRegistrationService', () => {
|
||||
|
||||
expect(result.isRegistered).toBe(false);
|
||||
expect(result.statusMessage).toBe('Not registered');
|
||||
expect(result.statusColor).toBe('red');
|
||||
expect(result.statusBadgeVariant).toBe('warning');
|
||||
expect(result.registrationButtonText).toBe('Register');
|
||||
expect(result.canRegister).toBe(true);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
||||
import { Service } from '@/lib/contracts/services/Service';
|
||||
import type { DriverRegistrationStatusViewData } from '@/lib/view-data/DriverRegistrationStatusViewData';
|
||||
|
||||
@injectable()
|
||||
export class DriverRegistrationService implements Service {
|
||||
@@ -22,7 +23,15 @@ export class DriverRegistrationService implements Service {
|
||||
}
|
||||
|
||||
async getDriverRegistrationStatus(driverId: string, raceId: string): Promise<DriverRegistrationStatusViewModel> {
|
||||
const data = await this.apiClient.getRegistrationStatus(driverId, raceId);
|
||||
return new DriverRegistrationStatusViewModel(data);
|
||||
const dto = await this.apiClient.getRegistrationStatus(driverId, raceId);
|
||||
|
||||
const viewData: DriverRegistrationStatusViewData = {
|
||||
isRegistered: dto.isRegistered,
|
||||
raceId: dto.raceId,
|
||||
driverId,
|
||||
canRegister: !dto.isRegistered,
|
||||
};
|
||||
|
||||
return new DriverRegistrationStatusViewModel(viewData);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Driver Registration Status View Data
|
||||
*
|
||||
* JSON-serializable, template-ready data structure.
|
||||
*/
|
||||
|
||||
import { ViewData } from "../contracts/view-data/ViewData";
|
||||
|
||||
export interface DriverRegistrationStatusViewData extends ViewData {
|
||||
isRegistered: boolean;
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
canRegister: boolean;
|
||||
}
|
||||
9
apps/website/lib/view-data/EmailSignupViewData.ts
Normal file
9
apps/website/lib/view-data/EmailSignupViewData.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
|
||||
export type EmailSignupStatus = 'success' | 'error' | 'info';
|
||||
|
||||
export interface EmailSignupViewData extends ViewData {
|
||||
email: string;
|
||||
message: string;
|
||||
status: EmailSignupStatus;
|
||||
}
|
||||
21
apps/website/lib/view-data/HomeDiscoveryViewData.ts
Normal file
21
apps/website/lib/view-data/HomeDiscoveryViewData.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
|
||||
export interface HomeDiscoveryViewData extends ViewData {
|
||||
topLeagues: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}>;
|
||||
teams: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
logoUrl?: string;
|
||||
}>;
|
||||
upcomingRaces: Array<{
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
formattedDate: string;
|
||||
}>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
|
||||
export interface ImportRaceResultsSummaryViewData extends ViewData {
|
||||
success: boolean;
|
||||
raceId: string;
|
||||
driversProcessed: number;
|
||||
resultsRecorded: number;
|
||||
errors: string[];
|
||||
}
|
||||
@@ -1,39 +1,41 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DriverRegistrationStatusViewModel } from './DriverRegistrationStatusViewModel';
|
||||
import type { DriverRegistrationStatusDTO } from '../types/generated/DriverRegistrationStatusDTO';
|
||||
import type { DriverRegistrationStatusViewData } from '../view-data/DriverRegistrationStatusViewData';
|
||||
|
||||
const createStatusDto = (overrides: Partial<DriverRegistrationStatusDTO> = {}): DriverRegistrationStatusDTO => ({
|
||||
const createViewData = (
|
||||
overrides: Partial<DriverRegistrationStatusViewData> = {},
|
||||
): DriverRegistrationStatusViewData => ({
|
||||
isRegistered: true,
|
||||
raceId: 'race-1',
|
||||
driverId: 'driver-1',
|
||||
canRegister: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('DriverRegistrationStatusViewModel', () => {
|
||||
it('maps basic registration status fields from DTO', () => {
|
||||
const dto = createStatusDto({ isRegistered: true });
|
||||
const viewModel = new DriverRegistrationStatusViewModel(dto);
|
||||
it('exposes basic registration status fields from ViewData', () => {
|
||||
const viewModel = new DriverRegistrationStatusViewModel(createViewData({ isRegistered: true }));
|
||||
|
||||
expect(viewModel.isRegistered).toBe(true);
|
||||
expect(viewModel.raceId).toBe('race-1');
|
||||
expect(viewModel.driverId).toBe('driver-1');
|
||||
});
|
||||
|
||||
it('derives UI fields when registered', () => {
|
||||
const viewModel = new DriverRegistrationStatusViewModel(createStatusDto({ isRegistered: true }));
|
||||
|
||||
expect(viewModel.statusMessage).toBe('Registered for this race');
|
||||
expect(viewModel.statusColor).toBe('green');
|
||||
expect(viewModel.statusBadgeVariant).toBe('success');
|
||||
expect(viewModel.registrationButtonText).toBe('Withdraw');
|
||||
expect(viewModel.canRegister).toBe(false);
|
||||
});
|
||||
|
||||
it('derives UI fields when registered', () => {
|
||||
const viewModel = new DriverRegistrationStatusViewModel(createViewData({ isRegistered: true }));
|
||||
|
||||
expect(viewModel.statusMessage).toBe('Registered for this race');
|
||||
expect(viewModel.statusBadgeVariant).toBe('success');
|
||||
expect(viewModel.registrationButtonText).toBe('Withdraw');
|
||||
});
|
||||
|
||||
it('derives UI fields when not registered', () => {
|
||||
const viewModel = new DriverRegistrationStatusViewModel(createStatusDto({ isRegistered: false }));
|
||||
const viewModel = new DriverRegistrationStatusViewModel(
|
||||
createViewData({ isRegistered: false, canRegister: true }),
|
||||
);
|
||||
|
||||
expect(viewModel.statusMessage).toBe('Not registered');
|
||||
expect(viewModel.statusColor).toBe('red');
|
||||
expect(viewModel.statusBadgeVariant).toBe('warning');
|
||||
expect(viewModel.registrationButtonText).toBe('Register');
|
||||
expect(viewModel.canRegister).toBe(true);
|
||||
|
||||
@@ -1,38 +1,37 @@
|
||||
import { DriverRegistrationStatusDTO } from '@/lib/types/generated/DriverRegistrationStatusDTO';
|
||||
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
import type { DriverRegistrationStatusViewData } from "../view-data/DriverRegistrationStatusViewData";
|
||||
import { DriverRegistrationStatusDisplay } from "../display-objects/DriverRegistrationStatusDisplay";
|
||||
|
||||
export class DriverRegistrationStatusViewModel extends ViewModel {
|
||||
isRegistered!: boolean;
|
||||
raceId!: string;
|
||||
driverId!: string;
|
||||
|
||||
constructor(dto: DriverRegistrationStatusDTO) {
|
||||
Object.assign(this, dto);
|
||||
constructor(private readonly viewData: DriverRegistrationStatusViewData) {
|
||||
super();
|
||||
}
|
||||
|
||||
/** UI-specific: Status message */
|
||||
get statusMessage(): string {
|
||||
return this.isRegistered ? 'Registered for this race' : 'Not registered';
|
||||
get isRegistered(): boolean {
|
||||
return this.viewData.isRegistered;
|
||||
}
|
||||
|
||||
/** UI-specific: Status color */
|
||||
get statusColor(): string {
|
||||
return this.isRegistered ? 'green' : 'red';
|
||||
get raceId(): string {
|
||||
return this.viewData.raceId;
|
||||
}
|
||||
|
||||
/** UI-specific: Badge variant */
|
||||
get statusBadgeVariant(): string {
|
||||
return this.isRegistered ? 'success' : 'warning';
|
||||
get driverId(): string {
|
||||
return this.viewData.driverId;
|
||||
}
|
||||
|
||||
/** UI-specific: Registration button text */
|
||||
get registrationButtonText(): string {
|
||||
return this.isRegistered ? 'Withdraw' : 'Register';
|
||||
}
|
||||
|
||||
/** UI-specific: Whether can register (assuming always can if not registered) */
|
||||
get canRegister(): boolean {
|
||||
return !this.isRegistered;
|
||||
return this.viewData.canRegister;
|
||||
}
|
||||
|
||||
get statusMessage(): string {
|
||||
return DriverRegistrationStatusDisplay.statusMessage(this.isRegistered);
|
||||
}
|
||||
|
||||
get statusBadgeVariant(): string {
|
||||
return DriverRegistrationStatusDisplay.statusBadgeVariant(this.isRegistered);
|
||||
}
|
||||
|
||||
get registrationButtonText(): string {
|
||||
return DriverRegistrationStatusDisplay.registrationButtonText(this.isRegistered);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DriverSummaryViewModel } from './DriverSummaryViewModel';
|
||||
import type { GetDriverOutputDTO } from '../types/generated/GetDriverOutputDTO';
|
||||
import type { DriverSummaryData } from '../view-data/LeagueDetailViewData';
|
||||
|
||||
const driverDto: GetDriverOutputDTO = {
|
||||
id: 'driver-1',
|
||||
iracingId: 'ir-123',
|
||||
name: 'Test Driver',
|
||||
country: 'DE',
|
||||
joinedAt: '2024-01-01T00:00:00Z',
|
||||
const viewData: DriverSummaryData = {
|
||||
driverId: 'driver-1',
|
||||
driverName: 'Test Driver',
|
||||
avatarUrl: 'https://example.com/avatar.png',
|
||||
rating: 2500,
|
||||
rank: 10,
|
||||
roleBadgeText: 'Owner',
|
||||
roleBadgeClasses: 'bg-blue-50 text-blue-700',
|
||||
profileUrl: '/drivers/driver-1',
|
||||
};
|
||||
|
||||
describe('DriverSummaryViewModel', () => {
|
||||
it('maps driver and optional fields from DTO', () => {
|
||||
const viewModel = new DriverSummaryViewModel({
|
||||
driver: driverDto,
|
||||
rating: 2500,
|
||||
rank: 10,
|
||||
});
|
||||
it('exposes driver identity fields from ViewData', () => {
|
||||
const viewModel = new DriverSummaryViewModel(viewData);
|
||||
|
||||
expect(viewModel.id).toBe('driver-1');
|
||||
expect(viewModel.name).toBe('Test Driver');
|
||||
expect(viewModel.avatarUrl).toBe('https://example.com/avatar.png');
|
||||
expect(viewModel.profileUrl).toBe('/drivers/driver-1');
|
||||
expect(viewModel.roleBadgeText).toBe('Owner');
|
||||
expect(viewModel.roleBadgeClasses).toBe('bg-blue-50 text-blue-700');
|
||||
});
|
||||
|
||||
it('derives ratingLabel and rankLabel for UI rendering', () => {
|
||||
const viewModel = new DriverSummaryViewModel(viewData);
|
||||
|
||||
expect(viewModel.driver).toBe(driverDto);
|
||||
expect(viewModel.rating).toBe(2500);
|
||||
expect(viewModel.ratingLabel).toBe('2,500');
|
||||
|
||||
expect(viewModel.rank).toBe(10);
|
||||
expect(viewModel.rankLabel).toBe('10');
|
||||
});
|
||||
|
||||
it('defaults nullable rating and rank when undefined', () => {
|
||||
it('renders placeholders when rating or rank are null', () => {
|
||||
const viewModel = new DriverSummaryViewModel({
|
||||
driver: driverDto,
|
||||
});
|
||||
|
||||
expect(viewModel.rating).toBeNull();
|
||||
expect(viewModel.rank).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps explicit null rating and rank', () => {
|
||||
const viewModel = new DriverSummaryViewModel({
|
||||
driver: driverDto,
|
||||
...viewData,
|
||||
rating: null,
|
||||
rank: null,
|
||||
});
|
||||
|
||||
expect(viewModel.rating).toBeNull();
|
||||
expect(viewModel.ratingLabel).toBe('—');
|
||||
|
||||
expect(viewModel.rank).toBeNull();
|
||||
expect(viewModel.rankLabel).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,55 @@
|
||||
import type { GetDriverOutputDTO } from '@/lib/types/generated/GetDriverOutputDTO';
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
import type { DriverSummaryData } from "../view-data/LeagueDetailViewData";
|
||||
import { NumberDisplay } from "../display-objects/NumberDisplay";
|
||||
import { RatingDisplay } from "../display-objects/RatingDisplay";
|
||||
|
||||
/**
|
||||
* View Model for driver summary with rating and rank
|
||||
* Transform from DTO to ViewModel with UI fields
|
||||
*
|
||||
* Client-only UI helper built from ViewData.
|
||||
*/
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class DriverSummaryViewModel extends ViewModel {
|
||||
driver: GetDriverOutputDTO;
|
||||
rating: number | null;
|
||||
rank: number | null;
|
||||
constructor(private readonly viewData: DriverSummaryData) {
|
||||
super();
|
||||
}
|
||||
|
||||
constructor(dto: {
|
||||
driver: GetDriverOutputDTO;
|
||||
rating?: number | null;
|
||||
rank?: number | null;
|
||||
}) {
|
||||
this.driver = dto.driver;
|
||||
this.rating = dto.rating ?? null;
|
||||
this.rank = dto.rank ?? null;
|
||||
get id(): string {
|
||||
return this.viewData.driverId;
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.viewData.driverName;
|
||||
}
|
||||
|
||||
get avatarUrl(): string | null {
|
||||
return this.viewData.avatarUrl;
|
||||
}
|
||||
|
||||
get rating(): number | null {
|
||||
return this.viewData.rating;
|
||||
}
|
||||
|
||||
get ratingLabel(): string {
|
||||
return RatingDisplay.format(this.rating);
|
||||
}
|
||||
|
||||
get rank(): number | null {
|
||||
return this.viewData.rank;
|
||||
}
|
||||
|
||||
get rankLabel(): string {
|
||||
return this.rank === null ? '—' : NumberDisplay.format(this.rank);
|
||||
}
|
||||
|
||||
get roleBadgeText(): string {
|
||||
return this.viewData.roleBadgeText;
|
||||
}
|
||||
|
||||
get roleBadgeClasses(): string {
|
||||
return this.viewData.roleBadgeClasses;
|
||||
}
|
||||
|
||||
get profileUrl(): string {
|
||||
return this.viewData.profileUrl;
|
||||
}
|
||||
}
|
||||
@@ -1,68 +1,59 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DriverTeamViewModel } from './DriverTeamViewModel';
|
||||
import type { GetDriverTeamOutputDTO } from '@/lib/types/generated/GetDriverTeamOutputDTO';
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { DriverTeamViewModel } from "./DriverTeamViewModel";
|
||||
import type { TeamDetailData } from "../view-data/TeamDetailViewData";
|
||||
|
||||
const createTeamDto = (overrides: Partial<GetDriverTeamOutputDTO> = {}): GetDriverTeamOutputDTO => ({
|
||||
team: {
|
||||
id: 'team-1',
|
||||
name: 'Test Team',
|
||||
tag: 'TT',
|
||||
description: 'Test team description',
|
||||
ownerId: 'owner-1',
|
||||
leagues: ['league-1'],
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
specialization: 'mixed',
|
||||
region: 'EU',
|
||||
languages: ['en'],
|
||||
},
|
||||
const createTeamViewData = (overrides: Partial<TeamDetailData> = {}): TeamDetailData => ({
|
||||
id: "team-1",
|
||||
name: "Test Team",
|
||||
tag: "TT",
|
||||
ownerId: "owner-1",
|
||||
leagues: ["league-1"],
|
||||
canManage: true,
|
||||
membership: {
|
||||
role: 'manager',
|
||||
joinedAt: '2024-01-01T00:00:00Z',
|
||||
role: "manager",
|
||||
joinedAt: "2024-01-01T00:00:00Z",
|
||||
isActive: true,
|
||||
},
|
||||
isOwner: false,
|
||||
canManage: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('DriverTeamViewModel', () => {
|
||||
it('maps team and membership fields from DTO', () => {
|
||||
const dto = createTeamDto();
|
||||
const viewModel = new DriverTeamViewModel(dto);
|
||||
describe("DriverTeamViewModel", () => {
|
||||
it("exposes team fields from ViewData", () => {
|
||||
const viewData = createTeamViewData();
|
||||
const viewModel = new DriverTeamViewModel(viewData);
|
||||
|
||||
expect(viewModel.teamId).toBe('team-1');
|
||||
expect(viewModel.teamName).toBe('Test Team');
|
||||
expect(viewModel.tag).toBe('TT');
|
||||
expect(viewModel.role).toBe('manager');
|
||||
expect(viewModel.teamId).toBe("team-1");
|
||||
expect(viewModel.teamName).toBe("Test Team");
|
||||
expect(viewModel.tag).toBe("TT");
|
||||
expect(viewModel.role).toBe("manager");
|
||||
expect(viewModel.isOwner).toBe(false);
|
||||
expect(viewModel.canManage).toBe(true);
|
||||
});
|
||||
|
||||
it('derives displayRole with capitalized first letter', () => {
|
||||
const dto = createTeamDto({
|
||||
it("derives displayRole with capitalized first letter", () => {
|
||||
const viewData = createTeamViewData({
|
||||
membership: {
|
||||
role: 'owner',
|
||||
joinedAt: '2024-01-01T00:00:00Z',
|
||||
role: "owner",
|
||||
joinedAt: "2024-01-01T00:00:00Z",
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
const viewModel = new DriverTeamViewModel(dto);
|
||||
const viewModel = new DriverTeamViewModel(viewData);
|
||||
|
||||
expect(viewModel.displayRole).toBe('Owner');
|
||||
expect(viewModel.displayRole).toBe("Owner");
|
||||
expect(viewModel.isOwner).toBe(true);
|
||||
});
|
||||
|
||||
it('handles lower-case role strings consistently', () => {
|
||||
const dto = createTeamDto({
|
||||
membership: {
|
||||
role: 'member',
|
||||
joinedAt: '2024-01-01T00:00:00Z',
|
||||
isActive: true,
|
||||
},
|
||||
it("defaults role when membership is missing", () => {
|
||||
const viewData = createTeamViewData({
|
||||
membership: null,
|
||||
});
|
||||
|
||||
const viewModel = new DriverTeamViewModel(dto);
|
||||
const viewModel = new DriverTeamViewModel(viewData);
|
||||
|
||||
expect(viewModel.displayRole).toBe('Member');
|
||||
expect(viewModel.role).toBe("member");
|
||||
expect(viewModel.displayRole).toBe("Member");
|
||||
expect(viewModel.isOwner).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,31 +1,44 @@
|
||||
import type { GetDriverTeamOutputDTO } from '@/lib/types/generated/GetDriverTeamOutputDTO';
|
||||
|
||||
/**
|
||||
* View Model for Driver's Team
|
||||
*
|
||||
* Represents a driver's team membership in a UI-ready format.
|
||||
* Client-only UI helper built from ViewData.
|
||||
*/
|
||||
|
||||
import { ProfileDisplay } from "../display-objects/ProfileDisplay";
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
import type { TeamDetailData } from "../view-data/TeamDetailViewData";
|
||||
|
||||
export class DriverTeamViewModel extends ViewModel {
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
tag: string;
|
||||
role: string;
|
||||
isOwner: boolean;
|
||||
canManage: boolean;
|
||||
constructor(private readonly viewData: TeamDetailData) {
|
||||
super();
|
||||
}
|
||||
|
||||
constructor(dto: GetDriverTeamOutputDTO) {
|
||||
this.teamId = dto.team.id;
|
||||
this.teamName = dto.team.name;
|
||||
this.tag = dto.team.tag;
|
||||
this.role = dto.membership.role;
|
||||
this.isOwner = dto.isOwner;
|
||||
this.canManage = dto.canManage;
|
||||
get teamId(): string {
|
||||
return this.viewData.id;
|
||||
}
|
||||
|
||||
get teamName(): string {
|
||||
return this.viewData.name;
|
||||
}
|
||||
|
||||
get tag(): string {
|
||||
return this.viewData.tag;
|
||||
}
|
||||
|
||||
get role(): string {
|
||||
return this.viewData.membership?.role ?? "member";
|
||||
}
|
||||
|
||||
get canManage(): boolean {
|
||||
return this.viewData.canManage;
|
||||
}
|
||||
|
||||
get isOwner(): boolean {
|
||||
return this.viewData.membership?.role === "owner";
|
||||
}
|
||||
|
||||
/** UI-specific: Display role */
|
||||
get displayRole(): string {
|
||||
return this.role.charAt(0).toUpperCase() + this.role.slice(1);
|
||||
return ProfileDisplay.getTeamRole(this.role).text;
|
||||
}
|
||||
}
|
||||
@@ -1,111 +1,139 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DriverViewModel } from './DriverViewModel';
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { DriverViewModel } from "./DriverViewModel";
|
||||
import type { DriverData } from "../view-data/LeagueStandingsViewData";
|
||||
|
||||
describe('DriverViewModel', () => {
|
||||
it('should create instance with all properties', () => {
|
||||
const dto = {
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
iracingId: 'iracing-456',
|
||||
describe("DriverViewModel", () => {
|
||||
it("should create instance with all properties", () => {
|
||||
const viewData: DriverData = {
|
||||
id: "driver-123",
|
||||
name: "John Doe",
|
||||
avatarUrl: "https://example.com/avatar.jpg",
|
||||
iracingId: "iracing-456",
|
||||
rating: 1500,
|
||||
country: "US",
|
||||
};
|
||||
|
||||
const viewModel = new DriverViewModel(dto);
|
||||
const viewModel = new DriverViewModel(viewData);
|
||||
|
||||
expect(viewModel.id).toBe('driver-123');
|
||||
expect(viewModel.name).toBe('John Doe');
|
||||
expect(viewModel.avatarUrl).toBe('https://example.com/avatar.jpg');
|
||||
expect(viewModel.iracingId).toBe('iracing-456');
|
||||
expect(viewModel.id).toBe("driver-123");
|
||||
expect(viewModel.name).toBe("John Doe");
|
||||
expect(viewModel.avatarUrl).toBe("https://example.com/avatar.jpg");
|
||||
expect(viewModel.iracingId).toBe("iracing-456");
|
||||
expect(viewModel.rating).toBe(1500);
|
||||
expect(viewModel.country).toBe("US");
|
||||
});
|
||||
|
||||
it('should create instance with only required properties', () => {
|
||||
const dto = {
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
it("should create instance with only required properties", () => {
|
||||
const viewData: DriverData = {
|
||||
id: "driver-123",
|
||||
name: "John Doe",
|
||||
avatarUrl: null,
|
||||
};
|
||||
|
||||
const viewModel = new DriverViewModel(dto);
|
||||
const viewModel = new DriverViewModel(viewData);
|
||||
|
||||
expect(viewModel.id).toBe('driver-123');
|
||||
expect(viewModel.name).toBe('John Doe');
|
||||
expect(viewModel.id).toBe("driver-123");
|
||||
expect(viewModel.name).toBe("John Doe");
|
||||
expect(viewModel.avatarUrl).toBeNull();
|
||||
expect(viewModel.iracingId).toBeUndefined();
|
||||
expect(viewModel.rating).toBeUndefined();
|
||||
expect(viewModel.country).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return true for hasIracingId when iracingId exists', () => {
|
||||
const viewModel = new DriverViewModel({
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
iracingId: 'iracing-456',
|
||||
});
|
||||
it("should return true for hasIracingId when iracingId exists", () => {
|
||||
const viewData: DriverData = {
|
||||
id: "driver-123",
|
||||
name: "John Doe",
|
||||
avatarUrl: null,
|
||||
iracingId: "iracing-456",
|
||||
};
|
||||
|
||||
const viewModel = new DriverViewModel(viewData);
|
||||
|
||||
expect(viewModel.hasIracingId).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for hasIracingId when iracingId is undefined', () => {
|
||||
const viewModel = new DriverViewModel({
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
});
|
||||
it("should return false for hasIracingId when iracingId is undefined", () => {
|
||||
const viewData: DriverData = {
|
||||
id: "driver-123",
|
||||
name: "John Doe",
|
||||
avatarUrl: null,
|
||||
};
|
||||
|
||||
const viewModel = new DriverViewModel(viewData);
|
||||
|
||||
expect(viewModel.hasIracingId).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for hasIracingId when iracingId is empty string', () => {
|
||||
const viewModel = new DriverViewModel({
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
iracingId: '',
|
||||
});
|
||||
it("should return false for hasIracingId when iracingId is empty string", () => {
|
||||
const viewData: DriverData = {
|
||||
id: "driver-123",
|
||||
name: "John Doe",
|
||||
avatarUrl: null,
|
||||
iracingId: "",
|
||||
};
|
||||
|
||||
const viewModel = new DriverViewModel(viewData);
|
||||
|
||||
expect(viewModel.hasIracingId).toBe(false);
|
||||
});
|
||||
|
||||
it('should format rating correctly when rating exists', () => {
|
||||
const viewModel = new DriverViewModel({
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
it("should format rating correctly when rating exists", () => {
|
||||
const viewData: DriverData = {
|
||||
id: "driver-123",
|
||||
name: "John Doe",
|
||||
avatarUrl: null,
|
||||
rating: 1547.89,
|
||||
});
|
||||
};
|
||||
|
||||
expect(viewModel.formattedRating).toBe('1548');
|
||||
const viewModel = new DriverViewModel(viewData);
|
||||
|
||||
expect(viewModel.formattedRating).toBe("1548");
|
||||
});
|
||||
|
||||
it('should return "Unrated" when rating is undefined', () => {
|
||||
const viewModel = new DriverViewModel({
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
});
|
||||
it("should return \"Unrated\" when rating is undefined", () => {
|
||||
const viewData: DriverData = {
|
||||
id: "driver-123",
|
||||
name: "John Doe",
|
||||
avatarUrl: null,
|
||||
};
|
||||
|
||||
expect(viewModel.formattedRating).toBe('Unrated');
|
||||
const viewModel = new DriverViewModel(viewData);
|
||||
|
||||
expect(viewModel.formattedRating).toBe("Unrated");
|
||||
});
|
||||
|
||||
it('should handle zero rating', () => {
|
||||
const viewModel = new DriverViewModel({
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
it("should handle zero rating", () => {
|
||||
const viewData: DriverData = {
|
||||
id: "driver-123",
|
||||
name: "John Doe",
|
||||
avatarUrl: null,
|
||||
rating: 0,
|
||||
});
|
||||
};
|
||||
|
||||
expect(viewModel.formattedRating).toBe('Unrated');
|
||||
const viewModel = new DriverViewModel(viewData);
|
||||
|
||||
expect(viewModel.formattedRating).toBe("Unrated");
|
||||
});
|
||||
|
||||
it('should round rating to nearest integer', () => {
|
||||
const viewModel1 = new DriverViewModel({
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
it("should round rating to nearest integer", () => {
|
||||
const viewData1: DriverData = {
|
||||
id: "driver-123",
|
||||
name: "John Doe",
|
||||
avatarUrl: null,
|
||||
rating: 1500.4,
|
||||
});
|
||||
const viewModel2 = new DriverViewModel({
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
};
|
||||
const viewData2: DriverData = {
|
||||
id: "driver-123",
|
||||
name: "John Doe",
|
||||
avatarUrl: null,
|
||||
rating: 1500.6,
|
||||
});
|
||||
};
|
||||
|
||||
expect(viewModel1.formattedRating).toBe('1500');
|
||||
expect(viewModel2.formattedRating).toBe('1501');
|
||||
const viewModel1 = new DriverViewModel(viewData1);
|
||||
const viewModel2 = new DriverViewModel(viewData2);
|
||||
|
||||
expect(viewModel1.formattedRating).toBe("1500");
|
||||
expect(viewModel2.formattedRating).toBe("1501");
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,15 @@
|
||||
* Driver view model
|
||||
* UI representation of a driver
|
||||
*
|
||||
* Note: No matching generated DTO available yet
|
||||
* Note: client-only ViewModel created from ViewData (never DTO).
|
||||
*/
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
import type { DriverData } from "../view-data/LeagueStandingsViewData";
|
||||
|
||||
type DriverViewModelViewData = DriverData & {
|
||||
bio?: string;
|
||||
joinedAt?: string;
|
||||
};
|
||||
|
||||
export class DriverViewModel extends ViewModel {
|
||||
id: string;
|
||||
@@ -16,25 +22,16 @@ export class DriverViewModel extends ViewModel {
|
||||
bio?: string;
|
||||
joinedAt?: string;
|
||||
|
||||
constructor(dto: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string | null;
|
||||
iracingId?: string;
|
||||
rating?: number;
|
||||
country?: string;
|
||||
bio?: string;
|
||||
joinedAt?: string;
|
||||
}) {
|
||||
constructor(viewData: DriverViewModelViewData) {
|
||||
super();
|
||||
this.id = dto.id;
|
||||
this.name = dto.name;
|
||||
this.avatarUrl = dto.avatarUrl ?? null;
|
||||
if (dto.iracingId !== undefined) this.iracingId = dto.iracingId;
|
||||
if (dto.rating !== undefined) this.rating = dto.rating;
|
||||
if (dto.country !== undefined) this.country = dto.country;
|
||||
if (dto.bio !== undefined) this.bio = dto.bio;
|
||||
if (dto.joinedAt !== undefined) this.joinedAt = dto.joinedAt;
|
||||
this.id = viewData.id;
|
||||
this.name = viewData.name;
|
||||
this.avatarUrl = viewData.avatarUrl ?? null;
|
||||
if (viewData.iracingId !== undefined) this.iracingId = viewData.iracingId;
|
||||
if (viewData.rating !== undefined) this.rating = viewData.rating;
|
||||
if (viewData.country !== undefined) this.country = viewData.country;
|
||||
if (viewData.bio !== undefined) this.bio = viewData.bio;
|
||||
if (viewData.joinedAt !== undefined) this.joinedAt = viewData.joinedAt;
|
||||
}
|
||||
|
||||
/** UI-specific: Whether driver has an iRacing ID */
|
||||
@@ -44,6 +41,6 @@ export class DriverViewModel extends ViewModel {
|
||||
|
||||
/** UI-specific: Formatted rating */
|
||||
get formattedRating(): string {
|
||||
return this.rating ? this.rating.toFixed(0) : 'Unrated';
|
||||
return this.rating ? this.rating.toFixed(0) : "Unrated";
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,37 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { EmailSignupViewModel } from './EmailSignupViewModel';
|
||||
import type { EmailSignupViewData } from '../view-data/EmailSignupViewData';
|
||||
|
||||
describe('EmailSignupViewModel', () => {
|
||||
it('should be defined', () => {
|
||||
expect(EmailSignupViewModel).toBeDefined();
|
||||
it('wraps EmailSignupViewData and exposes UI helpers', () => {
|
||||
const viewData: EmailSignupViewData = {
|
||||
email: 'test@example.com',
|
||||
message: 'Thanks for signing up!',
|
||||
status: 'success',
|
||||
};
|
||||
|
||||
const viewModel = new EmailSignupViewModel(viewData);
|
||||
|
||||
expect(viewModel.email).toBe('test@example.com');
|
||||
expect(viewModel.message).toBe('Thanks for signing up!');
|
||||
expect(viewModel.status).toBe('success');
|
||||
|
||||
expect(viewModel.isSuccess).toBe(true);
|
||||
expect(viewModel.isError).toBe(false);
|
||||
expect(viewModel.isInfo).toBe(false);
|
||||
});
|
||||
|
||||
it('reflects error status helpers', () => {
|
||||
const viewData: EmailSignupViewData = {
|
||||
email: 'test@example.com',
|
||||
message: 'Something went wrong',
|
||||
status: 'error',
|
||||
};
|
||||
|
||||
const viewModel = new EmailSignupViewModel(viewData);
|
||||
|
||||
expect(viewModel.isSuccess).toBe(false);
|
||||
expect(viewModel.isError).toBe(true);
|
||||
expect(viewModel.isInfo).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,15 +4,34 @@
|
||||
* View model for email signup responses
|
||||
*/
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
import type { EmailSignupViewData } from "../view-data/EmailSignupViewData";
|
||||
|
||||
export class EmailSignupViewModel extends ViewModel {
|
||||
readonly email: string;
|
||||
readonly message: string;
|
||||
readonly status: 'success' | 'error' | 'info';
|
||||
constructor(private readonly viewData: EmailSignupViewData) {
|
||||
super();
|
||||
}
|
||||
|
||||
constructor(email: string, message: string, status: 'success' | 'error' | 'info') {
|
||||
this.email = email;
|
||||
this.message = message;
|
||||
this.status = status;
|
||||
get email(): string {
|
||||
return this.viewData.email;
|
||||
}
|
||||
|
||||
get message(): string {
|
||||
return this.viewData.message;
|
||||
}
|
||||
|
||||
get status(): EmailSignupViewData["status"] {
|
||||
return this.viewData.status;
|
||||
}
|
||||
|
||||
get isSuccess(): boolean {
|
||||
return this.status === 'success';
|
||||
}
|
||||
|
||||
get isError(): boolean {
|
||||
return this.status === 'error';
|
||||
}
|
||||
|
||||
get isInfo(): boolean {
|
||||
return this.status === 'info';
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { HomeDiscoveryViewModel } from './HomeDiscoveryViewModel';
|
||||
import { LeagueCardViewModel } from './LeagueCardViewModel';
|
||||
import { TeamCardViewModel } from './TeamCardViewModel';
|
||||
import { UpcomingRaceCardViewModel } from './UpcomingRaceCardViewModel';
|
||||
import type { HomeDiscoveryViewData } from '@/lib/view-data/HomeDiscoveryViewData';
|
||||
|
||||
describe('HomeDiscoveryViewModel', () => {
|
||||
it('exposes the top leagues, teams and upcoming races from the DTO', () => {
|
||||
const topLeagues = [
|
||||
new LeagueCardViewModel({
|
||||
id: 'league-1',
|
||||
name: 'Pro League',
|
||||
description: 'Top-tier league',
|
||||
memberCount: 100,
|
||||
isMember: false,
|
||||
} as any),
|
||||
];
|
||||
it('exposes the discovery collections from ViewData', () => {
|
||||
const viewData: HomeDiscoveryViewData = {
|
||||
topLeagues: [{ id: 'league-1', name: 'Pro League', description: 'Top-tier league' }],
|
||||
teams: [{ id: 'team-1', name: 'Team Alpha', description: 'Serious endurance', logoUrl: undefined }],
|
||||
upcomingRaces: [
|
||||
{ id: 'race-1', track: 'Spa-Francorchamps', car: 'GT3', formattedDate: 'Jan 1' },
|
||||
],
|
||||
};
|
||||
|
||||
const teams = [
|
||||
new TeamCardViewModel({
|
||||
id: 'team-1',
|
||||
name: 'Team Alpha',
|
||||
tag: 'ALPHA',
|
||||
memberCount: 10,
|
||||
isMember: true,
|
||||
} as any),
|
||||
];
|
||||
const viewModel = new HomeDiscoveryViewModel(viewData);
|
||||
|
||||
const upcomingRaces = [
|
||||
new UpcomingRaceCardViewModel({
|
||||
id: 'race-1',
|
||||
track: 'Spa-Francorchamps',
|
||||
car: 'GT3',
|
||||
scheduledAt: '2025-01-01T12:00:00Z',
|
||||
} as any),
|
||||
];
|
||||
expect(viewModel.topLeagues).toBe(viewData.topLeagues);
|
||||
expect(viewModel.teams).toBe(viewData.teams);
|
||||
expect(viewModel.upcomingRaces).toBe(viewData.upcomingRaces);
|
||||
});
|
||||
|
||||
const viewModel = new HomeDiscoveryViewModel({
|
||||
topLeagues,
|
||||
teams,
|
||||
upcomingRaces,
|
||||
});
|
||||
it('provides basic UI helper booleans', () => {
|
||||
const viewData: HomeDiscoveryViewData = {
|
||||
topLeagues: [],
|
||||
teams: [{ id: 'team-1', name: 'Team Alpha', description: 'Serious endurance' }],
|
||||
upcomingRaces: [],
|
||||
};
|
||||
|
||||
expect(viewModel.topLeagues).toBe(topLeagues);
|
||||
expect(viewModel.teams).toBe(teams);
|
||||
expect(viewModel.upcomingRaces).toBe(upcomingRaces);
|
||||
const viewModel = new HomeDiscoveryViewModel(viewData);
|
||||
|
||||
expect(viewModel.hasTopLeagues).toBe(false);
|
||||
expect(viewModel.hasTeams).toBe(true);
|
||||
expect(viewModel.hasUpcomingRaces).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +1,32 @@
|
||||
import { LeagueCardViewModel } from './LeagueCardViewModel';
|
||||
import { TeamCardViewModel } from './TeamCardViewModel';
|
||||
import { UpcomingRaceCardViewModel } from './UpcomingRaceCardViewModel';
|
||||
|
||||
interface HomeDiscoveryDTO {
|
||||
topLeagues: LeagueCardViewModel[];
|
||||
teams: TeamCardViewModel[];
|
||||
upcomingRaces: UpcomingRaceCardViewModel[];
|
||||
}
|
||||
import type { HomeDiscoveryViewData } from '@/lib/view-data/HomeDiscoveryViewData';
|
||||
|
||||
/**
|
||||
* Home discovery view model
|
||||
* Aggregates discovery data for the landing page.
|
||||
*/
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
import { ViewModel } from '../contracts/view-models/ViewModel';
|
||||
|
||||
export class HomeDiscoveryViewModel extends ViewModel {
|
||||
readonly topLeagues: LeagueCardViewModel[];
|
||||
readonly teams: TeamCardViewModel[];
|
||||
readonly upcomingRaces: UpcomingRaceCardViewModel[];
|
||||
readonly topLeagues: HomeDiscoveryViewData['topLeagues'];
|
||||
readonly teams: HomeDiscoveryViewData['teams'];
|
||||
readonly upcomingRaces: HomeDiscoveryViewData['upcomingRaces'];
|
||||
|
||||
constructor(dto: HomeDiscoveryDTO) {
|
||||
this.topLeagues = dto.topLeagues;
|
||||
this.teams = dto.teams;
|
||||
this.upcomingRaces = dto.upcomingRaces;
|
||||
constructor(viewData: HomeDiscoveryViewData) {
|
||||
super();
|
||||
this.topLeagues = viewData.topLeagues;
|
||||
this.teams = viewData.teams;
|
||||
this.upcomingRaces = viewData.upcomingRaces;
|
||||
}
|
||||
|
||||
get hasTopLeagues(): boolean {
|
||||
return this.topLeagues.length > 0;
|
||||
}
|
||||
|
||||
get hasTeams(): boolean {
|
||||
return this.teams.length > 0;
|
||||
}
|
||||
|
||||
get hasUpcomingRaces(): boolean {
|
||||
return this.upcomingRaces.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ImportRaceResultsSummaryViewData } from '../view-data/ImportRaceResultsSummaryViewData';
|
||||
import { ImportRaceResultsSummaryViewModel } from './ImportRaceResultsSummaryViewModel';
|
||||
|
||||
describe('ImportRaceResultsSummaryViewModel', () => {
|
||||
it('maps DTO fields including errors', () => {
|
||||
const dto = {
|
||||
it('exposes view data fields', () => {
|
||||
const viewData: ImportRaceResultsSummaryViewData = {
|
||||
success: true,
|
||||
raceId: 'race-1',
|
||||
driversProcessed: 10,
|
||||
@@ -11,7 +12,7 @@ describe('ImportRaceResultsSummaryViewModel', () => {
|
||||
errors: ['Driver missing', 'Invalid lap time'],
|
||||
};
|
||||
|
||||
const viewModel = new ImportRaceResultsSummaryViewModel(dto);
|
||||
const viewModel = new ImportRaceResultsSummaryViewModel(viewData);
|
||||
|
||||
expect(viewModel.success).toBe(true);
|
||||
expect(viewModel.raceId).toBe('race-1');
|
||||
@@ -20,16 +21,24 @@ describe('ImportRaceResultsSummaryViewModel', () => {
|
||||
expect(viewModel.errors).toEqual(['Driver missing', 'Invalid lap time']);
|
||||
});
|
||||
|
||||
it('defaults errors to an empty array when not provided', () => {
|
||||
const dto = {
|
||||
it('derives hasErrors UI helper', () => {
|
||||
const viewDataWithErrors: ImportRaceResultsSummaryViewData = {
|
||||
success: false,
|
||||
raceId: 'race-2',
|
||||
driversProcessed: 0,
|
||||
resultsRecorded: 0,
|
||||
errors: ['Some error'],
|
||||
};
|
||||
|
||||
const viewModel = new ImportRaceResultsSummaryViewModel(dto);
|
||||
const viewDataWithoutErrors: ImportRaceResultsSummaryViewData = {
|
||||
success: false,
|
||||
raceId: 'race-3',
|
||||
driversProcessed: 0,
|
||||
resultsRecorded: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
expect(viewModel.errors).toEqual([]);
|
||||
expect(new ImportRaceResultsSummaryViewModel(viewDataWithErrors).hasErrors).toBe(true);
|
||||
expect(new ImportRaceResultsSummaryViewModel(viewDataWithoutErrors).hasErrors).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
interface ImportRaceResultsSummaryDTO {
|
||||
success: boolean;
|
||||
raceId: string;
|
||||
driversProcessed: number;
|
||||
resultsRecorded: number;
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
import type { ImportRaceResultsSummaryViewData } from "../view-data/ImportRaceResultsSummaryViewData";
|
||||
|
||||
export class ImportRaceResultsSummaryViewModel extends ViewModel {
|
||||
success: boolean;
|
||||
raceId: string;
|
||||
driversProcessed: number;
|
||||
resultsRecorded: number;
|
||||
errors: string[];
|
||||
constructor(private readonly viewData: ImportRaceResultsSummaryViewData) {
|
||||
super();
|
||||
}
|
||||
|
||||
constructor(dto: ImportRaceResultsSummaryDTO) {
|
||||
this.success = dto.success;
|
||||
this.raceId = dto.raceId;
|
||||
this.driversProcessed = dto.driversProcessed;
|
||||
this.resultsRecorded = dto.resultsRecorded;
|
||||
this.errors = dto.errors || [];
|
||||
get success(): boolean {
|
||||
return this.viewData.success;
|
||||
}
|
||||
|
||||
get raceId(): string {
|
||||
return this.viewData.raceId;
|
||||
}
|
||||
|
||||
get driversProcessed(): number {
|
||||
return this.viewData.driversProcessed;
|
||||
}
|
||||
|
||||
get resultsRecorded(): number {
|
||||
return this.viewData.resultsRecorded;
|
||||
}
|
||||
|
||||
get errors(): string[] {
|
||||
return this.viewData.errors;
|
||||
}
|
||||
|
||||
get hasErrors(): boolean {
|
||||
return this.errors.length > 0;
|
||||
}
|
||||
}
|
||||
@@ -1,138 +1,79 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { MediaViewModel } from './MediaViewModel';
|
||||
import type { MediaViewData } from '@/lib/view-data/MediaViewData';
|
||||
|
||||
describe('MediaViewModel', () => {
|
||||
it('should create instance with all properties', () => {
|
||||
const dto = {
|
||||
it('creates instance from asset ViewData', () => {
|
||||
const asset: MediaViewData['assets'][number] = {
|
||||
id: 'media-123',
|
||||
url: 'https://example.com/image.jpg',
|
||||
type: 'image',
|
||||
src: 'https://example.com/image.jpg',
|
||||
title: 'Race Day Photo',
|
||||
category: 'avatar',
|
||||
uploadedAt: '2023-01-15T00:00:00.000Z',
|
||||
size: 2048000,
|
||||
date: '2023-01-15',
|
||||
dimensions: '1920×1080',
|
||||
};
|
||||
|
||||
const viewModel = new MediaViewModel(dto);
|
||||
const viewModel = new MediaViewModel(asset);
|
||||
|
||||
expect(viewModel.id).toBe('media-123');
|
||||
expect(viewModel.url).toBe('https://example.com/image.jpg');
|
||||
expect(viewModel.type).toBe('image');
|
||||
expect(viewModel.src).toBe('https://example.com/image.jpg');
|
||||
expect(viewModel.title).toBe('Race Day Photo');
|
||||
expect(viewModel.category).toBe('avatar');
|
||||
expect(viewModel.uploadedAt).toEqual(new Date('2023-01-15'));
|
||||
expect(viewModel.size).toBe(2048000);
|
||||
expect(viewModel.date).toBe('2023-01-15');
|
||||
expect(viewModel.dimensions).toBe('1920×1080');
|
||||
});
|
||||
|
||||
it('should create instance without optional properties', () => {
|
||||
const dto = {
|
||||
it('subtitle matches MediaCard subtitle formatting', () => {
|
||||
const asset: MediaViewData['assets'][number] = {
|
||||
id: 'media-123',
|
||||
url: 'https://example.com/image.jpg',
|
||||
type: 'image',
|
||||
uploadedAt: '2023-01-15T00:00:00.000Z',
|
||||
src: 'https://example.com/image.jpg',
|
||||
title: 'Race Day Photo',
|
||||
category: 'avatar',
|
||||
dimensions: '1920×1080',
|
||||
};
|
||||
|
||||
const viewModel = new MediaViewModel(dto);
|
||||
const viewModel = new MediaViewModel(asset);
|
||||
|
||||
expect(viewModel.category).toBeUndefined();
|
||||
expect(viewModel.size).toBeUndefined();
|
||||
expect(viewModel.subtitle).toBe('avatar • 1920×1080');
|
||||
});
|
||||
|
||||
it('should return "Unknown" for formattedSize when size is undefined', () => {
|
||||
const viewModel = new MediaViewModel({
|
||||
it('subtitle omits dimensions when missing', () => {
|
||||
const asset: MediaViewData['assets'][number] = {
|
||||
id: 'media-123',
|
||||
url: 'https://example.com/image.jpg',
|
||||
type: 'image',
|
||||
uploadedAt: new Date().toISOString(),
|
||||
});
|
||||
src: 'https://example.com/image.jpg',
|
||||
title: 'Race Day Photo',
|
||||
category: 'avatar',
|
||||
};
|
||||
|
||||
expect(viewModel.formattedSize).toBe('Unknown');
|
||||
const viewModel = new MediaViewModel(asset);
|
||||
|
||||
expect(viewModel.subtitle).toBe('avatar');
|
||||
});
|
||||
|
||||
it('should format size in KB when less than 1 MB', () => {
|
||||
const viewModel = new MediaViewModel({
|
||||
id: 'media-123',
|
||||
url: 'https://example.com/image.jpg',
|
||||
type: 'image',
|
||||
uploadedAt: new Date().toISOString(),
|
||||
size: 512000, // 500 KB
|
||||
});
|
||||
|
||||
expect(viewModel.formattedSize).toBe('500.00 KB');
|
||||
});
|
||||
|
||||
it('should format size in MB when 1 MB or larger', () => {
|
||||
const viewModel = new MediaViewModel({
|
||||
id: 'media-123',
|
||||
url: 'https://example.com/image.jpg',
|
||||
type: 'image',
|
||||
uploadedAt: new Date().toISOString(),
|
||||
size: 2048000, // 2 MB in base-10, ~1.95 MB in base-2
|
||||
});
|
||||
|
||||
expect(viewModel.formattedSize).toBe('1.95 MB');
|
||||
});
|
||||
|
||||
it('should handle very small file sizes', () => {
|
||||
const viewModel = new MediaViewModel({
|
||||
id: 'media-123',
|
||||
url: 'https://example.com/image.jpg',
|
||||
type: 'image',
|
||||
uploadedAt: new Date().toISOString(),
|
||||
size: 1024, // 1 KB
|
||||
});
|
||||
|
||||
expect(viewModel.formattedSize).toBe('1.00 KB');
|
||||
});
|
||||
|
||||
it('should handle very large file sizes', () => {
|
||||
const viewModel = new MediaViewModel({
|
||||
id: 'media-123',
|
||||
url: 'https://example.com/video.mp4',
|
||||
type: 'video',
|
||||
uploadedAt: new Date().toISOString(),
|
||||
size: 104857600, // 100 MB
|
||||
});
|
||||
|
||||
expect(viewModel.formattedSize).toBe('100.00 MB');
|
||||
});
|
||||
|
||||
it('should support all media types', () => {
|
||||
const imageVm = new MediaViewModel({
|
||||
it('hasMetadata reflects presence of date or dimensions', () => {
|
||||
const noMeta = new MediaViewModel({
|
||||
id: '1',
|
||||
url: 'image.jpg',
|
||||
type: 'image',
|
||||
uploadedAt: new Date().toISOString(),
|
||||
src: 'a.jpg',
|
||||
title: 'A',
|
||||
category: 'misc',
|
||||
});
|
||||
const videoVm = new MediaViewModel({
|
||||
const withDate = new MediaViewModel({
|
||||
id: '2',
|
||||
url: 'video.mp4',
|
||||
type: 'video',
|
||||
uploadedAt: new Date().toISOString(),
|
||||
src: 'b.jpg',
|
||||
title: 'B',
|
||||
category: 'misc',
|
||||
date: '2023-01-15',
|
||||
});
|
||||
const docVm = new MediaViewModel({
|
||||
const withDimensions = new MediaViewModel({
|
||||
id: '3',
|
||||
url: 'doc.pdf',
|
||||
type: 'document',
|
||||
uploadedAt: new Date().toISOString(),
|
||||
src: 'c.jpg',
|
||||
title: 'C',
|
||||
category: 'misc',
|
||||
dimensions: '800×600',
|
||||
});
|
||||
|
||||
expect(imageVm.type).toBe('image');
|
||||
expect(videoVm.type).toBe('video');
|
||||
expect(docVm.type).toBe('document');
|
||||
});
|
||||
|
||||
it('should support all media categories', () => {
|
||||
const categories = ['avatar', 'team-logo', 'league-cover', 'race-result'] as const;
|
||||
|
||||
categories.forEach(category => {
|
||||
const viewModel = new MediaViewModel({
|
||||
id: 'media-123',
|
||||
url: 'https://example.com/image.jpg',
|
||||
type: 'image',
|
||||
category,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expect(viewModel.category).toBe(category);
|
||||
});
|
||||
expect(noMeta.hasMetadata).toBe(false);
|
||||
expect(withDate.hasMetadata).toBe(true);
|
||||
expect(withDimensions.hasMetadata).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,37 +1,40 @@
|
||||
import type { GetMediaOutputDTO } from '@/lib/types/generated/GetMediaOutputDTO';
|
||||
import type { MediaViewData } from '@/lib/view-data/MediaViewData';
|
||||
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
type MediaAssetViewData = MediaViewData['assets'][number];
|
||||
|
||||
/**
|
||||
* Media View Model
|
||||
*
|
||||
* Represents media information for the UI layer
|
||||
* Client-only ViewModel created from ViewData (never DTO).
|
||||
* Represents a single media asset card in the UI.
|
||||
*/
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class MediaViewModel extends ViewModel {
|
||||
id: string;
|
||||
url: string;
|
||||
type: 'image' | 'video' | 'document';
|
||||
category?: 'avatar' | 'team-logo' | 'league-cover' | 'race-result';
|
||||
uploadedAt: Date;
|
||||
size?: number;
|
||||
src: string;
|
||||
title: string;
|
||||
category: string;
|
||||
date?: string;
|
||||
dimensions?: string;
|
||||
|
||||
constructor(dto: GetMediaOutputDTO) {
|
||||
this.id = dto.id;
|
||||
this.url = dto.url;
|
||||
this.type = dto.type as 'image' | 'video' | 'document';
|
||||
this.uploadedAt = new Date(dto.uploadedAt);
|
||||
if (dto.category !== undefined) this.category = dto.category as 'avatar' | 'team-logo' | 'league-cover' | 'race-result';
|
||||
if (dto.size !== undefined) this.size = dto.size;
|
||||
constructor(viewData: MediaAssetViewData) {
|
||||
super();
|
||||
this.id = viewData.id;
|
||||
this.src = viewData.src;
|
||||
this.title = viewData.title;
|
||||
this.category = viewData.category;
|
||||
if (viewData.date !== undefined) this.date = viewData.date;
|
||||
if (viewData.dimensions !== undefined) this.dimensions = viewData.dimensions;
|
||||
}
|
||||
|
||||
/** UI-specific: Formatted file size */
|
||||
get formattedSize(): string {
|
||||
if (!this.size) return 'Unknown';
|
||||
const kb = this.size / 1024;
|
||||
/** UI-specific: Combined subtitle used by MediaCard */
|
||||
get subtitle(): string {
|
||||
return `${this.category}${this.dimensions ? ` • ${this.dimensions}` : ''}`;
|
||||
}
|
||||
|
||||
if (kb < 1024) return `${kb.toFixed(2)} KB`;
|
||||
|
||||
const mb = kb / 1024;
|
||||
return `${mb.toFixed(2)} MB`;
|
||||
/** UI-specific: Whether any metadata is present */
|
||||
get hasMetadata(): boolean {
|
||||
return !!this.date || !!this.dimensions;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user