Compare commits
18 Commits
tests/cont
...
046852703f
| Author | SHA1 | Date | |
|---|---|---|---|
| 046852703f | |||
| dde77e717a | |||
| 705f9685b5 | |||
| 891b3cf0ee | |||
| ae59df61eb | |||
| 62e8b768ce | |||
| c470505b4f | |||
| f8099f04bc | |||
| e22033be38 | |||
| d97f50ed72 | |||
| ae58839eb2 | |||
| 18133aef4c | |||
| 1288a9dc30 | |||
| 04d445bf00 | |||
| 94b92a9314 | |||
| 108cfbcd65 | |||
| 1f4f837282 | |||
| c22e26d14c |
@@ -250,7 +250,8 @@
|
|||||||
"plugins": [
|
"plugins": [
|
||||||
"@typescript-eslint",
|
"@typescript-eslint",
|
||||||
"boundaries",
|
"boundaries",
|
||||||
"import"
|
"import",
|
||||||
|
"gridpilot-rules"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"@typescript-eslint/no-explicit-any": "error",
|
"@typescript-eslint/no-explicit-any": "error",
|
||||||
@@ -310,7 +311,9 @@
|
|||||||
"message": "Interface names should not start with 'I'. Use descriptive names without the 'I' prefix (e.g., 'LiverCompositor' instead of 'ILiveryCompositor').",
|
"message": "Interface names should not start with 'I'. Use descriptive names without the 'I' prefix (e.g., 'LiverCompositor' instead of 'ILiveryCompositor').",
|
||||||
"selector": "TSInterfaceDeclaration[id.name=/^I[A-Z]/]"
|
"selector": "TSInterfaceDeclaration[id.name=/^I[A-Z]/]"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
// GridPilot ESLint Rules
|
||||||
|
"gridpilot-rules/view-model-taxonomy": "error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2216,6 +2216,41 @@
|
|||||||
"incidents"
|
"incidents"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"DashboardStatsResponseDTO": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"totalUsers": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"activeUsers": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"suspendedUsers": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"deletedUsers": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"systemAdmins": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"recentLogins": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"newUsersToday": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"totalUsers",
|
||||||
|
"activeUsers",
|
||||||
|
"suspendedUsers",
|
||||||
|
"deletedUsers",
|
||||||
|
"systemAdmins",
|
||||||
|
"recentLogins",
|
||||||
|
"newUsersToday"
|
||||||
|
]
|
||||||
|
},
|
||||||
"DeleteMediaOutputDTO": {
|
"DeleteMediaOutputDTO": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -4235,6 +4270,9 @@
|
|||||||
"LeagueScheduleDTO": {
|
"LeagueScheduleDTO": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"leagueId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"seasonId": {
|
"seasonId": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -4473,6 +4511,16 @@
|
|||||||
},
|
},
|
||||||
"isParallelActive": {
|
"isParallelActive": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"totalRaces": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"completedRaces": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"nextRaceAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -4480,7 +4528,9 @@
|
|||||||
"name",
|
"name",
|
||||||
"status",
|
"status",
|
||||||
"isPrimary",
|
"isPrimary",
|
||||||
"isParallelActive"
|
"isParallelActive",
|
||||||
|
"totalRaces",
|
||||||
|
"completedRaces"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"LeagueSettingsDTO": {
|
"LeagueSettingsDTO": {
|
||||||
@@ -4515,6 +4565,18 @@
|
|||||||
},
|
},
|
||||||
"races": {
|
"races": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
|
},
|
||||||
|
"positionChange": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"lastRacePoints": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"droppedRaceIds": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -4524,7 +4586,10 @@
|
|||||||
"position",
|
"position",
|
||||||
"wins",
|
"wins",
|
||||||
"podiums",
|
"podiums",
|
||||||
"races"
|
"races",
|
||||||
|
"positionChange",
|
||||||
|
"lastRacePoints",
|
||||||
|
"droppedRaceIds"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"LeagueStandingsDTO": {
|
"LeagueStandingsDTO": {
|
||||||
@@ -4658,6 +4723,15 @@
|
|||||||
"logoUrl": {
|
"logoUrl": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
|
},
|
||||||
|
"pendingJoinRequestsCount": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"pendingProtestsCount": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"walletBalance": {
|
||||||
|
"type": "number"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -5449,8 +5523,34 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"leagueName": {
|
"leagueName": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"nullable": true
|
},
|
||||||
|
"track": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"car": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sessionType": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"leagueId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"strengthOfField": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"isUpcoming": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"isLive": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"isPast": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
|
|
||||||
class UserGrowthDto {
|
|
||||||
@ApiProperty({ description: 'Label for the time period' })
|
|
||||||
label!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Number of new users' })
|
|
||||||
value!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Color class for the bar' })
|
|
||||||
color!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
class RoleDistributionDto {
|
|
||||||
@ApiProperty({ description: 'Role name' })
|
|
||||||
label!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Number of users with this role' })
|
|
||||||
value!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Color class for the bar' })
|
|
||||||
color!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
class StatusDistributionDto {
|
|
||||||
@ApiProperty({ description: 'Number of active users' })
|
|
||||||
active!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Number of suspended users' })
|
|
||||||
suspended!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Number of deleted users' })
|
|
||||||
deleted!: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
class ActivityTimelineDto {
|
|
||||||
@ApiProperty({ description: 'Date label' })
|
|
||||||
date!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Number of new users' })
|
|
||||||
newUsers!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Number of logins' })
|
|
||||||
logins!: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DashboardStatsResponseDto {
|
|
||||||
@ApiProperty({ description: 'Total number of users' })
|
|
||||||
totalUsers!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Number of active users' })
|
|
||||||
activeUsers!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Number of suspended users' })
|
|
||||||
suspendedUsers!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Number of deleted users' })
|
|
||||||
deletedUsers!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Number of system admins' })
|
|
||||||
systemAdmins!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Number of recent logins (last 24h)' })
|
|
||||||
recentLogins!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Number of new users today' })
|
|
||||||
newUsersToday!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ type: [UserGrowthDto], description: 'User growth over last 7 days' })
|
|
||||||
userGrowth!: UserGrowthDto[];
|
|
||||||
|
|
||||||
@ApiProperty({ type: [RoleDistributionDto], description: 'Distribution of user roles' })
|
|
||||||
roleDistribution!: RoleDistributionDto[];
|
|
||||||
|
|
||||||
@ApiProperty({ type: StatusDistributionDto, description: 'Distribution of user statuses' })
|
|
||||||
statusDistribution!: StatusDistributionDto;
|
|
||||||
|
|
||||||
@ApiProperty({ type: [ActivityTimelineDto], description: 'Activity timeline for last 7 days' })
|
|
||||||
activityTimeline!: ActivityTimelineDto[];
|
|
||||||
}
|
|
||||||
24
apps/api/src/domain/health/HealthDTO.ts
Normal file
24
apps/api/src/domain/health/HealthDTO.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export interface HealthDTO {
|
||||||
|
status: 'ok' | 'degraded' | 'error' | 'unknown';
|
||||||
|
timestamp: string;
|
||||||
|
uptime?: number;
|
||||||
|
responseTime?: number;
|
||||||
|
errorRate?: number;
|
||||||
|
lastCheck?: string;
|
||||||
|
checksPassed?: number;
|
||||||
|
checksFailed?: number;
|
||||||
|
components?: Array<{
|
||||||
|
name: string;
|
||||||
|
status: 'ok' | 'degraded' | 'error' | 'unknown';
|
||||||
|
lastCheck?: string;
|
||||||
|
responseTime?: number;
|
||||||
|
errorRate?: number;
|
||||||
|
}>;
|
||||||
|
alerts?: Array<{
|
||||||
|
id: string;
|
||||||
|
type: 'critical' | 'warning' | 'info';
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
timestamp: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsArray, IsBoolean, IsString, ValidateNested } from 'class-validator';
|
import { IsArray, IsBoolean, IsString, ValidateNested } from 'class-validator';
|
||||||
import { RaceDTO } from '../../race/dtos/RaceDTO';
|
import { RaceDTO } from '../../race/dtos/RaceDTO';
|
||||||
|
|
||||||
export class LeagueScheduleDTO {
|
export class LeagueScheduleDTO {
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsString()
|
||||||
|
leagueId?: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsString()
|
@IsString()
|
||||||
seasonId!: string;
|
seasonId!: string;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class RaceDTO {
|
export class RaceDTO {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@@ -10,6 +10,33 @@ export class RaceDTO {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
date!: string;
|
date!: string;
|
||||||
|
|
||||||
@ApiProperty({ nullable: true })
|
@ApiPropertyOptional({ nullable: true })
|
||||||
leagueName?: string;
|
leagueName?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
track?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
car?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
sessionType?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
leagueId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
strengthOfField?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
isUpcoming?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
isLive?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
isPast?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
status?: string;
|
||||||
}
|
}
|
||||||
@@ -23,8 +23,8 @@
|
|||||||
"app/**/default.*"
|
"app/**/default.*"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"import/no-default-export": "off",
|
"import/no-default-export": "error",
|
||||||
"no-restricted-syntax": "off"
|
"no-restricted-syntax": "error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -44,7 +44,8 @@
|
|||||||
"lib/builders/view-models/*.tsx"
|
"lib/builders/view-models/*.tsx"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"gridpilot-rules/view-model-builder-contract": "error"
|
"gridpilot-rules/view-model-builder-contract": "error",
|
||||||
|
"gridpilot-rules/view-model-builder-implements": "error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -53,9 +54,11 @@
|
|||||||
"lib/builders/view-data/*.tsx"
|
"lib/builders/view-data/*.tsx"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"gridpilot-rules/filename-matches-export": "off",
|
"gridpilot-rules/filename-matches-export": "error",
|
||||||
"gridpilot-rules/single-export-per-file": "off",
|
"gridpilot-rules/single-export-per-file": "error",
|
||||||
"gridpilot-rules/view-data-builder-contract": "off"
|
"gridpilot-rules/view-data-builder-contract": "error",
|
||||||
|
"gridpilot-rules/view-data-builder-implements": "error",
|
||||||
|
"gridpilot-rules/view-data-builder-imports": "error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -72,11 +75,11 @@
|
|||||||
"lib/mutations/**/*.ts"
|
"lib/mutations/**/*.ts"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"gridpilot-rules/clean-error-handling": "off",
|
"gridpilot-rules/clean-error-handling": "error",
|
||||||
"gridpilot-rules/filename-service-match": "off",
|
"gridpilot-rules/filename-service-match": "error",
|
||||||
"gridpilot-rules/mutation-contract": "off",
|
"gridpilot-rules/mutation-contract": "error",
|
||||||
"gridpilot-rules/mutation-must-map-errors": "off",
|
"gridpilot-rules/mutation-must-map-errors": "error",
|
||||||
"gridpilot-rules/mutation-must-use-builders": "off"
|
"gridpilot-rules/mutation-must-use-builders": "error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -84,16 +87,16 @@
|
|||||||
"templates/**/*.tsx"
|
"templates/**/*.tsx"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"gridpilot-rules/component-no-data-manipulation": "off",
|
"gridpilot-rules/component-no-data-manipulation": "error",
|
||||||
"gridpilot-rules/no-hardcoded-routes": "off",
|
"gridpilot-rules/no-hardcoded-routes": "error",
|
||||||
"gridpilot-rules/no-raw-html-in-app": "off",
|
"gridpilot-rules/no-raw-html-in-app": "error",
|
||||||
"gridpilot-rules/template-no-async-render": "off",
|
"gridpilot-rules/template-no-async-render": "error",
|
||||||
"gridpilot-rules/template-no-direct-mutations": "off",
|
"gridpilot-rules/template-no-direct-mutations": "error",
|
||||||
"gridpilot-rules/template-no-external-state": "off",
|
"gridpilot-rules/template-no-external-state": "error",
|
||||||
"gridpilot-rules/template-no-global-objects": "off",
|
"gridpilot-rules/template-no-global-objects": "error",
|
||||||
"gridpilot-rules/template-no-mutation-props": "off",
|
"gridpilot-rules/template-no-mutation-props": "error",
|
||||||
"gridpilot-rules/template-no-side-effects": "off",
|
"gridpilot-rules/template-no-side-effects": "error",
|
||||||
"gridpilot-rules/template-no-unsafe-html": "off"
|
"gridpilot-rules/template-no-unsafe-html": "error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -101,8 +104,8 @@
|
|||||||
"components/**/*.tsx"
|
"components/**/*.tsx"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"gridpilot-rules/component-no-data-manipulation": "off",
|
"gridpilot-rules/component-no-data-manipulation": "error",
|
||||||
"gridpilot-rules/no-raw-html-in-app": "off"
|
"gridpilot-rules/no-raw-html-in-app": "error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -111,33 +114,33 @@
|
|||||||
"app/**/layout.tsx"
|
"app/**/layout.tsx"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
"@typescript-eslint/no-explicit-any": "error",
|
||||||
"@typescript-eslint/no-unused-vars": "off",
|
"@typescript-eslint/no-unused-vars": "error",
|
||||||
"gridpilot-rules/component-classification": "off",
|
"gridpilot-rules/component-classification": "error",
|
||||||
"gridpilot-rules/no-console": "off",
|
"gridpilot-rules/no-console": "error",
|
||||||
"gridpilot-rules/no-direct-process-env": "off",
|
"gridpilot-rules/no-direct-process-env": "error",
|
||||||
"gridpilot-rules/no-hardcoded-routes": "off",
|
"gridpilot-rules/no-hardcoded-routes": "error",
|
||||||
"gridpilot-rules/no-hardcoded-search-params": "off",
|
"gridpilot-rules/no-hardcoded-search-params": "error",
|
||||||
"gridpilot-rules/no-index-files": "off",
|
"gridpilot-rules/no-index-files": "error",
|
||||||
"gridpilot-rules/no-next-cookies-in-pages": "off",
|
"gridpilot-rules/no-next-cookies-in-pages": "error",
|
||||||
"gridpilot-rules/no-raw-html-in-app": "off",
|
"gridpilot-rules/no-raw-html-in-app": "error",
|
||||||
"gridpilot-rules/rsc-no-container-manager": "off",
|
"gridpilot-rules/rsc-no-container-manager": "error",
|
||||||
"gridpilot-rules/rsc-no-container-manager-calls": "off",
|
"gridpilot-rules/rsc-no-container-manager-calls": "error",
|
||||||
"gridpilot-rules/rsc-no-di": "off",
|
"gridpilot-rules/rsc-no-di": "error",
|
||||||
"gridpilot-rules/rsc-no-display-objects": "off",
|
"gridpilot-rules/rsc-no-display-objects": "error",
|
||||||
"gridpilot-rules/rsc-no-intl": "off",
|
"gridpilot-rules/rsc-no-intl": "error",
|
||||||
"gridpilot-rules/rsc-no-local-helpers": "off",
|
"gridpilot-rules/rsc-no-local-helpers": "error",
|
||||||
"gridpilot-rules/rsc-no-object-construction": "off",
|
"gridpilot-rules/rsc-no-object-construction": "error",
|
||||||
"gridpilot-rules/rsc-no-page-data-fetcher": "off",
|
"gridpilot-rules/rsc-no-page-data-fetcher": "error",
|
||||||
"gridpilot-rules/rsc-no-presenters": "off",
|
"gridpilot-rules/rsc-no-presenters": "error",
|
||||||
"gridpilot-rules/rsc-no-sorting-filtering": "off",
|
"gridpilot-rules/rsc-no-sorting-filtering": "error",
|
||||||
"gridpilot-rules/rsc-no-unsafe-services": "off",
|
"gridpilot-rules/rsc-no-unsafe-services": "error",
|
||||||
"gridpilot-rules/rsc-no-view-models": "off",
|
"gridpilot-rules/rsc-no-view-models": "error",
|
||||||
"import/no-default-export": "off",
|
"import/no-default-export": "error",
|
||||||
"no-restricted-syntax": "off",
|
"no-restricted-syntax": "error",
|
||||||
"react-hooks/exhaustive-deps": "off",
|
"react-hooks/exhaustive-deps": "error",
|
||||||
"react-hooks/rules-of-hooks": "off",
|
"react-hooks/rules-of-hooks": "error",
|
||||||
"react/no-unescaped-entities": "off"
|
"react/no-unescaped-entities": "error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -149,8 +152,8 @@
|
|||||||
"lib/mutations/auth/types/*.ts"
|
"lib/mutations/auth/types/*.ts"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"gridpilot-rules/clean-error-handling": "off",
|
"gridpilot-rules/clean-error-handling": "error",
|
||||||
"gridpilot-rules/no-direct-process-env": "off"
|
"gridpilot-rules/no-direct-process-env": "error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -159,10 +162,10 @@
|
|||||||
"lib/display-objects/**/*.tsx"
|
"lib/display-objects/**/*.tsx"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"gridpilot-rules/display-no-business-logic": "off",
|
"gridpilot-rules/display-no-business-logic": "error",
|
||||||
"gridpilot-rules/display-no-domain-models": "off",
|
"gridpilot-rules/display-no-domain-models": "error",
|
||||||
"gridpilot-rules/filename-display-match": "off",
|
"gridpilot-rules/filename-display-match": "error",
|
||||||
"gridpilot-rules/model-no-domain-in-display": "off"
|
"gridpilot-rules/model-no-domain-in-display": "error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -170,17 +173,17 @@
|
|||||||
"lib/page-queries/**/*.ts"
|
"lib/page-queries/**/*.ts"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"gridpilot-rules/clean-error-handling": "off",
|
"gridpilot-rules/clean-error-handling": "error",
|
||||||
"gridpilot-rules/filename-matches-export": "off",
|
"gridpilot-rules/filename-matches-export": "error",
|
||||||
"gridpilot-rules/no-hardcoded-routes": "off",
|
"gridpilot-rules/no-hardcoded-routes": "error",
|
||||||
"gridpilot-rules/no-hardcoded-search-params": "off",
|
"gridpilot-rules/no-hardcoded-search-params": "error",
|
||||||
"gridpilot-rules/page-query-contract": "off",
|
"gridpilot-rules/page-query-contract": "error",
|
||||||
"gridpilot-rules/page-query-execute": "off",
|
"gridpilot-rules/page-query-execute": "error",
|
||||||
"gridpilot-rules/page-query-filename": "off",
|
"gridpilot-rules/page-query-filename": "error",
|
||||||
"gridpilot-rules/page-query-must-use-builders": "off",
|
"gridpilot-rules/page-query-must-use-builders": "error",
|
||||||
"gridpilot-rules/page-query-no-null-returns": "off",
|
"gridpilot-rules/page-query-no-null-returns": "error",
|
||||||
"gridpilot-rules/page-query-return-type": "off",
|
"gridpilot-rules/page-query-return-type": "error",
|
||||||
"gridpilot-rules/single-export-per-file": "off"
|
"gridpilot-rules/single-export-per-file": "error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -192,16 +195,35 @@
|
|||||||
"gridpilot-rules/view-data-location": "error"
|
"gridpilot-rules/view-data-location": "error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"lib/view-data/**/*.ts",
|
||||||
|
"lib/view-data/**/*.tsx"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"gridpilot-rules/view-data-implements": "error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"lib/view-models/**/*.ts",
|
||||||
|
"lib/view-models/**/*.tsx"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"gridpilot-rules/view-model-implements": "error",
|
||||||
|
"gridpilot-rules/view-model-taxonomy": "error"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"files": [
|
"files": [
|
||||||
"lib/services/**/*.ts"
|
"lib/services/**/*.ts"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"gridpilot-rules/filename-service-match": "off",
|
"gridpilot-rules/filename-service-match": "error",
|
||||||
"gridpilot-rules/services-implement-contract": "off",
|
"gridpilot-rules/services-implement-contract": "error",
|
||||||
"gridpilot-rules/services-must-be-pure": "off",
|
"gridpilot-rules/services-must-be-pure": "error",
|
||||||
"gridpilot-rules/services-must-return-result": "off",
|
"gridpilot-rules/services-must-return-result": "error",
|
||||||
"gridpilot-rules/services-no-external-api": "off"
|
"gridpilot-rules/services-no-external-api": "error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -210,12 +232,12 @@
|
|||||||
"app/**/*.ts"
|
"app/**/*.ts"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"gridpilot-rules/client-only-must-have-directive": "off",
|
"gridpilot-rules/client-only-must-have-directive": "error",
|
||||||
"gridpilot-rules/client-only-no-server-code": "off",
|
"gridpilot-rules/client-only-no-server-code": "error",
|
||||||
"gridpilot-rules/no-use-mutation-in-client": "off",
|
"gridpilot-rules/no-use-mutation-in-client": "error",
|
||||||
"gridpilot-rules/server-actions-interface": "off",
|
"gridpilot-rules/server-actions-interface": "error",
|
||||||
"gridpilot-rules/server-actions-must-use-mutations": "off",
|
"gridpilot-rules/server-actions-must-use-mutations": "error",
|
||||||
"gridpilot-rules/server-actions-return-result": "off"
|
"gridpilot-rules/server-actions-return-result": "error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -263,10 +285,10 @@
|
|||||||
"app/**/*.ts"
|
"app/**/*.ts"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"gridpilot-rules/component-classification": "off",
|
"gridpilot-rules/component-classification": "error",
|
||||||
"gridpilot-rules/no-hardcoded-routes": "off",
|
"gridpilot-rules/no-hardcoded-routes": "error",
|
||||||
"gridpilot-rules/no-nextjs-imports-in-ui": "off",
|
"gridpilot-rules/no-nextjs-imports-in-ui": "error",
|
||||||
"gridpilot-rules/no-raw-html-in-app": "off"
|
"gridpilot-rules/no-raw-html-in-app": "error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -297,11 +319,11 @@
|
|||||||
"components/**/*.ts"
|
"components/**/*.ts"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"gridpilot-rules/component-classification": "off",
|
"gridpilot-rules/component-classification": "error",
|
||||||
"gridpilot-rules/no-hardcoded-routes": "off",
|
"gridpilot-rules/no-hardcoded-routes": "error",
|
||||||
"gridpilot-rules/no-nextjs-imports-in-ui": "off",
|
"gridpilot-rules/no-nextjs-imports-in-ui": "error",
|
||||||
"gridpilot-rules/no-raw-html-in-app": "error",
|
"gridpilot-rules/no-raw-html-in-app": "error",
|
||||||
"no-restricted-imports": "off"
|
"no-restricted-imports": "error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -309,7 +331,7 @@
|
|||||||
"components/mockups/**/*.tsx"
|
"components/mockups/**/*.tsx"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"gridpilot-rules/no-raw-html-in-app": "off"
|
"gridpilot-rules/no-raw-html-in-app": "error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -317,11 +339,11 @@
|
|||||||
"lib/services/**/*.ts"
|
"lib/services/**/*.ts"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"gridpilot-rules/no-hardcoded-routes": "off",
|
"gridpilot-rules/no-hardcoded-routes": "error",
|
||||||
"gridpilot-rules/service-function-format": "off",
|
"gridpilot-rules/service-function-format": "error",
|
||||||
"gridpilot-rules/services-implement-contract": "off",
|
"gridpilot-rules/services-implement-contract": "error",
|
||||||
"gridpilot-rules/services-must-be-pure": "off",
|
"gridpilot-rules/services-must-be-pure": "error",
|
||||||
"gridpilot-rules/services-no-external-api": "off"
|
"gridpilot-rules/services-no-external-api": "error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -342,10 +364,10 @@
|
|||||||
],
|
],
|
||||||
"root": true,
|
"root": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"@next/next/no-img-element": "off",
|
"@next/next/no-img-element": "error",
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
"@typescript-eslint/no-explicit-any": "error",
|
||||||
"@typescript-eslint/no-unused-vars": [
|
"@typescript-eslint/no-unused-vars": [
|
||||||
"off",
|
"error",
|
||||||
{
|
{
|
||||||
"argsIgnorePattern": "^_",
|
"argsIgnorePattern": "^_",
|
||||||
"caughtErrorsIgnorePattern": "^_",
|
"caughtErrorsIgnorePattern": "^_",
|
||||||
@@ -368,15 +390,15 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"gridpilot-rules/no-index-files": "off",
|
"gridpilot-rules/no-index-files": "error",
|
||||||
"import/no-default-export": "off",
|
"import/no-default-export": "error",
|
||||||
"import/no-named-as-default-member": "off",
|
"import/no-named-as-default-member": "error",
|
||||||
"no-restricted-syntax": "off",
|
"no-restricted-syntax": "error",
|
||||||
"react-hooks/exhaustive-deps": "off",
|
"react-hooks/exhaustive-deps": "error",
|
||||||
"react-hooks/rules-of-hooks": "off",
|
"react-hooks/rules-of-hooks": "error",
|
||||||
"react/no-unescaped-entities": "off",
|
"react/no-unescaped-entities": "error",
|
||||||
"unused-imports/no-unused-imports": "off",
|
"unused-imports/no-unused-imports": "error",
|
||||||
"unused-imports/no-unused-vars": "off"
|
"unused-imports/no-unused-vars": "error"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"boundaries/elements": [
|
"boundaries/elements": [
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery';
|
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
import { Box } from '@/ui/Box';
|
|
||||||
import { Text } from '@/ui/Text';
|
|
||||||
import { Stack } from '@/ui/Stack';
|
|
||||||
import { RosterTable } from '@/components/leagues/RosterTable';
|
import { RosterTable } from '@/components/leagues/RosterTable';
|
||||||
|
import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery';
|
||||||
|
import { Box } from '@/ui/Box';
|
||||||
|
import { Stack } from '@/ui/Stack';
|
||||||
|
import { Text } from '@/ui/Text';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
import { DateFormatter } from '@/lib/formatters/DateFormatter';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -25,7 +25,7 @@ export default async function LeagueRosterPage({ params }: Props) {
|
|||||||
driverName: m.driver.name,
|
driverName: m.driver.name,
|
||||||
role: m.role,
|
role: m.role,
|
||||||
joinedAt: m.joinedAt,
|
joinedAt: m.joinedAt,
|
||||||
joinedAtLabel: DateDisplay.formatShort(m.joinedAt)
|
joinedAtLabel: DateFormatter.formatShort(m.joinedAt)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||||
import { RaceStewardingTemplate, type StewardingTab } from '@/templates/RaceStewardingTemplate';
|
|
||||||
import { RaceStewardingPageQuery } from '@/lib/page-queries/races/RaceStewardingPageQuery';
|
import { RaceStewardingPageQuery } from '@/lib/page-queries/races/RaceStewardingPageQuery';
|
||||||
import { type RaceStewardingViewData } from '@/lib/view-data/races/RaceStewardingViewData';
|
import { type RaceStewardingViewData } from '@/lib/view-data/RaceStewardingViewData';
|
||||||
|
import { RaceStewardingTemplate, type StewardingTab } from '@/templates/RaceStewardingTemplate';
|
||||||
import { Gavel } from 'lucide-react';
|
import { Gavel } from 'lucide-react';
|
||||||
import { useState, useEffect, useCallback, use } from 'react';
|
import { notFound } from 'next/navigation';
|
||||||
|
import { use, useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
interface RaceStewardingPageProps {
|
interface RaceStewardingPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
|
|||||||
@@ -1,89 +1,3 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useSponsorSponsorships } from "@/hooks/sponsor/useSponsorSponsorships";
|
|
||||||
import { SponsorCampaignsTemplate, SponsorshipType, SponsorCampaignsViewData } from "@/templates/SponsorCampaignsTemplate";
|
|
||||||
import { Box } from "@/ui/Box";
|
|
||||||
import { Button } from "@/ui/Button";
|
|
||||||
import { Text } from "@/ui/Text";
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { CurrencyDisplay } from "@/lib/display-objects/CurrencyDisplay";
|
|
||||||
import { NumberDisplay } from "@/lib/display-objects/NumberDisplay";
|
|
||||||
import { DateDisplay } from "@/lib/display-objects/DateDisplay";
|
|
||||||
import { StatusDisplay } from "@/lib/display-objects/StatusDisplay";
|
|
||||||
|
|
||||||
export default function SponsorCampaignsPage() {
|
|
||||||
const [typeFilter, setTypeFilter] = useState<SponsorshipType>('all');
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const { data: sponsorshipsData, isLoading, error, retry } = useSponsorSponsorships('demo-sponsor-1');
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<Box maxWidth="7xl" mx="auto" py={8} px={4} display="flex" alignItems="center" justifyContent="center" minHeight="400px">
|
|
||||||
<Box textAlign="center">
|
|
||||||
<Box w="8" h="8" border borderTop={false} borderColor="border-primary-blue" rounded="full" animate="spin" mx="auto" mb={4} />
|
|
||||||
<Text color="text-gray-400">Loading sponsorships...</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !sponsorshipsData) {
|
|
||||||
return (
|
|
||||||
<Box maxWidth="7xl" mx="auto" py={8} px={4} display="flex" alignItems="center" justifyContent="center" minHeight="400px">
|
|
||||||
<Box textAlign="center">
|
|
||||||
<Text color="text-gray-400">{error?.getUserMessage() || 'No sponsorships data available'}</Text>
|
|
||||||
{error && (
|
|
||||||
<Button variant="secondary" onClick={retry} mt={4}>
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate stats
|
|
||||||
const totalInvestment = sponsorshipsData.sponsorships.filter((s: any) => s.status === 'active').reduce((sum: number, s: any) => sum + s.price, 0);
|
|
||||||
const totalImpressions = sponsorshipsData.sponsorships.reduce((sum: number, s: any) => sum + s.impressions, 0);
|
|
||||||
|
|
||||||
const stats = {
|
|
||||||
total: sponsorshipsData.sponsorships.length,
|
|
||||||
active: sponsorshipsData.sponsorships.filter((s: any) => s.status === 'active').length,
|
|
||||||
pending: sponsorshipsData.sponsorships.filter((s: any) => s.status === 'pending_approval').length,
|
|
||||||
approved: sponsorshipsData.sponsorships.filter((s: any) => s.status === 'approved').length,
|
|
||||||
rejected: sponsorshipsData.sponsorships.filter((s: any) => s.status === 'rejected').length,
|
|
||||||
formattedTotalInvestment: CurrencyDisplay.format(totalInvestment),
|
|
||||||
formattedTotalImpressions: NumberDisplay.formatCompact(totalImpressions),
|
|
||||||
};
|
|
||||||
|
|
||||||
const sponsorships = sponsorshipsData.sponsorships.map((s: any) => ({
|
|
||||||
...s,
|
|
||||||
formattedInvestment: CurrencyDisplay.format(s.price),
|
|
||||||
formattedImpressions: NumberDisplay.format(s.impressions),
|
|
||||||
formattedStartDate: s.seasonStartDate ? DateDisplay.formatShort(s.seasonStartDate) : undefined,
|
|
||||||
formattedEndDate: s.seasonEndDate ? DateDisplay.formatShort(s.seasonEndDate) : undefined,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const viewData: SponsorCampaignsViewData = {
|
|
||||||
sponsorships,
|
|
||||||
stats: stats as any,
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredSponsorships = sponsorships.filter((s: any) => {
|
|
||||||
// For now, we only have leagues in the DTO
|
|
||||||
if (typeFilter !== 'all' && typeFilter !== 'leagues') return false;
|
|
||||||
if (searchQuery && !s.leagueName.toLowerCase().includes(searchQuery.toLowerCase())) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SponsorCampaignsTemplate
|
|
||||||
viewData={viewData}
|
|
||||||
filteredSponsorships={filteredSponsorships as any}
|
|
||||||
typeFilter={typeFilter}
|
|
||||||
setTypeFilter={setTypeFilter}
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
setSearchQuery={setSearchQuery}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ProfileTab } from '@/components/profile/ProfileTabs';
|
import type { ProfileTab } from '@/components/profile/ProfileTabs';
|
||||||
import type { DriverProfileViewData } from '@/lib/types/view-data/DriverProfileViewData';
|
|
||||||
import { DriverProfileTemplate } from '@/templates/DriverProfileTemplate';
|
import { DriverProfileTemplate } from '@/templates/DriverProfileTemplate';
|
||||||
import { ErrorTemplate, EmptyTemplate } from '@/templates/shared/StatusTemplates';
|
import { EmptyTemplate, ErrorTemplate } from '@/templates/shared/StatusTemplates';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
|
||||||
|
|
||||||
interface DriverProfilePageClientProps {
|
|
||||||
viewData: DriverProfileViewData | null;
|
|
||||||
error?: string;
|
|
||||||
empty?: {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DriverProfilePageClient({ viewData, error, empty }: DriverProfilePageClientProps) {
|
export function DriverProfilePageClient({ viewData, error, empty }: DriverProfilePageClientProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
@@ -1,21 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { DriversViewData } from '@/lib/types/view-data/DriversViewData';
|
import { routes } from '@/lib/routing/RouteConfig';
|
||||||
import { DriversTemplate } from '@/templates/DriversTemplate';
|
import { DriversTemplate } from '@/templates/DriversTemplate';
|
||||||
import { ErrorTemplate, EmptyTemplate } from '@/templates/shared/StatusTemplates';
|
import { EmptyTemplate, ErrorTemplate } from '@/templates/shared/StatusTemplates';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { routes } from '@/lib/routing/RouteConfig';
|
|
||||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
|
||||||
|
|
||||||
interface DriversPageClientProps {
|
|
||||||
viewData: DriversViewData | null;
|
|
||||||
error?: string;
|
|
||||||
empty?: {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DriversPageClient({ viewData, error, empty }: DriversPageClientProps) {
|
export function DriversPageClient({ viewData, error, empty }: DriversPageClientProps) {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { ForgotPasswordViewData } from '@/lib/builders/view-data/types/ForgotPasswordViewData';
|
|
||||||
import { ForgotPasswordTemplate } from '@/templates/auth/ForgotPasswordTemplate';
|
|
||||||
import { ForgotPasswordMutation } from '@/lib/mutations/auth/ForgotPasswordMutation';
|
|
||||||
import { ForgotPasswordViewModelBuilder } from '@/lib/builders/view-models/ForgotPasswordViewModelBuilder';
|
import { ForgotPasswordViewModelBuilder } from '@/lib/builders/view-models/ForgotPasswordViewModelBuilder';
|
||||||
import { ForgotPasswordViewModel } from '@/lib/view-models/auth/ForgotPasswordViewModel';
|
|
||||||
import { ForgotPasswordFormValidation } from '@/lib/utilities/authValidation';
|
|
||||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||||
|
import { ForgotPasswordMutation } from '@/lib/mutations/auth/ForgotPasswordMutation';
|
||||||
|
import { ForgotPasswordFormValidation } from '@/lib/utilities/authValidation';
|
||||||
|
import { ForgotPasswordViewData } from '@/lib/view-data/ForgotPasswordViewData';
|
||||||
|
import { ForgotPasswordViewModel } from '@/lib/view-models/auth/ForgotPasswordViewModel';
|
||||||
|
import { ForgotPasswordTemplate } from '@/templates/auth/ForgotPasswordTemplate';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
export function ForgotPasswordClient({ viewData }: ClientWrapperProps<ForgotPasswordViewData>) {
|
export function ForgotPasswordClient({ viewData }: ClientWrapperProps<ForgotPasswordViewData>) {
|
||||||
// Build ViewModel from ViewData
|
// Build ViewModel from ViewData
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { LeagueWalletViewData } from '@/lib/view-data/leagues/LeagueWalletViewData';
|
|
||||||
import { LeagueWalletTemplate } from '@/templates/LeagueWalletTemplate';
|
|
||||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||||
|
import type { LeagueWalletViewData } from '@/lib/view-data/LeagueWalletViewData';
|
||||||
|
import { LeagueWalletTemplate } from '@/templates/LeagueWalletTemplate';
|
||||||
|
|
||||||
interface LeagueWalletPageClientProps extends ClientWrapperProps<LeagueWalletViewData> {
|
interface LeagueWalletPageClientProps extends ClientWrapperProps<LeagueWalletViewData> {
|
||||||
onWithdraw?: (amount: number) => void;
|
onWithdraw?: (amount: number) => void;
|
||||||
|
|||||||
@@ -9,16 +9,16 @@
|
|||||||
|
|
||||||
import { useAuth } from '@/components/auth/AuthContext';
|
import { useAuth } from '@/components/auth/AuthContext';
|
||||||
import { LoginFlowController, LoginState } from '@/lib/auth/LoginFlowController';
|
import { LoginFlowController, LoginState } from '@/lib/auth/LoginFlowController';
|
||||||
import { LoginViewData } from '@/lib/builders/view-data/types/LoginViewData';
|
|
||||||
import { LoginViewModelBuilder } from '@/lib/builders/view-models/LoginViewModelBuilder';
|
import { LoginViewModelBuilder } from '@/lib/builders/view-models/LoginViewModelBuilder';
|
||||||
|
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||||
import { LoginMutation } from '@/lib/mutations/auth/LoginMutation';
|
import { LoginMutation } from '@/lib/mutations/auth/LoginMutation';
|
||||||
import { validateLoginForm, type LoginFormValues } from '@/lib/utils/validation';
|
import { validateLoginForm, type LoginFormValues } from '@/lib/utils/validation';
|
||||||
|
import { LoginViewData } from '@/lib/view-data/LoginViewData';
|
||||||
import { LoginViewModel } from '@/lib/view-models/auth/LoginViewModel';
|
import { LoginViewModel } from '@/lib/view-models/auth/LoginViewModel';
|
||||||
import { LoginTemplate } from '@/templates/auth/LoginTemplate';
|
|
||||||
import { LoginLoadingTemplate } from '@/templates/auth/LoginLoadingTemplate';
|
import { LoginLoadingTemplate } from '@/templates/auth/LoginLoadingTemplate';
|
||||||
|
import { LoginTemplate } from '@/templates/auth/LoginTemplate';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
|
||||||
|
|
||||||
export function LoginClient({ viewData }: ClientWrapperProps<LoginViewData>) {
|
export function LoginClient({ viewData }: ClientWrapperProps<LoginViewData>) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useCallback } from 'react';
|
|
||||||
import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate';
|
|
||||||
import { RaceResultsViewData } from '@/lib/view-data/races/RaceResultsViewData';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||||
|
import { RaceResultsViewData } from '@/lib/view-data/RaceResultsViewData';
|
||||||
|
import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
export function RaceResultsPageClient({ viewData }: ClientWrapperProps<RaceResultsViewData>) {
|
export function RaceResultsPageClient({ viewData }: ClientWrapperProps<RaceResultsViewData>) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
@@ -6,16 +6,16 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
|
||||||
import { ResetPasswordViewData } from '@/lib/builders/view-data/types/ResetPasswordViewData';
|
|
||||||
import { ResetPasswordTemplate } from '@/templates/auth/ResetPasswordTemplate';
|
|
||||||
import { ResetPasswordMutation } from '@/lib/mutations/auth/ResetPasswordMutation';
|
|
||||||
import { ResetPasswordViewModelBuilder } from '@/lib/builders/view-models/ResetPasswordViewModelBuilder';
|
import { ResetPasswordViewModelBuilder } from '@/lib/builders/view-models/ResetPasswordViewModelBuilder';
|
||||||
import { ResetPasswordViewModel } from '@/lib/view-models/auth/ResetPasswordViewModel';
|
|
||||||
import { ResetPasswordFormValidation } from '@/lib/utilities/authValidation';
|
|
||||||
import { routes } from '@/lib/routing/RouteConfig';
|
|
||||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||||
|
import { ResetPasswordMutation } from '@/lib/mutations/auth/ResetPasswordMutation';
|
||||||
|
import { routes } from '@/lib/routing/RouteConfig';
|
||||||
|
import { ResetPasswordFormValidation } from '@/lib/utilities/authValidation';
|
||||||
|
import { ResetPasswordViewData } from '@/lib/view-data/ResetPasswordViewData';
|
||||||
|
import { ResetPasswordViewModel } from '@/lib/view-models/auth/ResetPasswordViewModel';
|
||||||
|
import { ResetPasswordTemplate } from '@/templates/auth/ResetPasswordTemplate';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
export function ResetPasswordClient({ viewData }: ClientWrapperProps<ResetPasswordViewData>) {
|
export function ResetPasswordClient({ viewData }: ClientWrapperProps<ResetPasswordViewData>) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { MembershipRole } from '@/lib/types/MembershipRole';
|
|
||||||
import { useParams } from 'next/navigation';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import {
|
import {
|
||||||
|
useApproveJoinRequest,
|
||||||
useLeagueJoinRequests,
|
useLeagueJoinRequests,
|
||||||
useLeagueRosterAdmin,
|
useLeagueRosterAdmin,
|
||||||
useApproveJoinRequest,
|
|
||||||
useRejectJoinRequest,
|
useRejectJoinRequest,
|
||||||
useUpdateMemberRole,
|
|
||||||
useRemoveMember,
|
useRemoveMember,
|
||||||
|
useUpdateMemberRole,
|
||||||
} from "@/hooks/league/useLeagueRosterAdmin";
|
} from "@/hooks/league/useLeagueRosterAdmin";
|
||||||
import { RosterAdminTemplate } from '@/templates/RosterAdminTemplate';
|
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||||
import type { JoinRequestData, RosterMemberData, LeagueRosterAdminViewData } from '@/lib/view-data/LeagueRosterAdminViewData';
|
|
||||||
import type { LeagueRosterJoinRequestDTO } from '@/lib/types/generated/LeagueRosterJoinRequestDTO';
|
import type { LeagueRosterJoinRequestDTO } from '@/lib/types/generated/LeagueRosterJoinRequestDTO';
|
||||||
import type { LeagueRosterMemberDTO } from '@/lib/types/generated/LeagueRosterMemberDTO';
|
import type { LeagueRosterMemberDTO } from '@/lib/types/generated/LeagueRosterMemberDTO';
|
||||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
import type { MembershipRole } from '@/lib/types/MembershipRole';
|
||||||
|
import type { JoinRequestData, LeagueRosterAdminViewData, RosterMemberData } from '@/lib/view-data/LeagueRosterAdminViewData';
|
||||||
|
import { RosterAdminTemplate } from '@/templates/RosterAdminTemplate';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
import { DateFormatter } from '@/lib/formatters/DateFormatter';
|
||||||
|
|
||||||
const ROLE_OPTIONS: MembershipRole[] = ['owner', 'admin', 'steward', 'member'];
|
const ROLE_OPTIONS: MembershipRole[] = ['owner', 'admin', 'steward', 'member'];
|
||||||
|
|
||||||
export function RosterAdminPage({ viewData: initialViewData }: Partial<ClientWrapperProps<LeagueRosterAdminViewData>>) {
|
export function RosterAdminPage({ }: Partial<ClientWrapperProps<LeagueRosterAdminViewData>>) {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const leagueId = params.id as string;
|
const leagueId = params.id as string;
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ export function RosterAdminPage({ viewData: initialViewData }: Partial<ClientWra
|
|||||||
id: req.id,
|
id: req.id,
|
||||||
driver: req.driver as { id: string; name: string },
|
driver: req.driver as { id: string; name: string },
|
||||||
requestedAt: req.requestedAt,
|
requestedAt: req.requestedAt,
|
||||||
formattedRequestedAt: DateDisplay.formatShort(req.requestedAt),
|
formattedRequestedAt: DateFormatter.formatShort(req.requestedAt),
|
||||||
message: req.message || undefined,
|
message: req.message || undefined,
|
||||||
})),
|
})),
|
||||||
members: members.map((m: LeagueRosterMemberDTO): RosterMemberData => ({
|
members: members.map((m: LeagueRosterMemberDTO): RosterMemberData => ({
|
||||||
@@ -91,7 +91,7 @@ export function RosterAdminPage({ viewData: initialViewData }: Partial<ClientWra
|
|||||||
driver: m.driver as { id: string; name: string },
|
driver: m.driver as { id: string; name: string },
|
||||||
role: m.role,
|
role: m.role,
|
||||||
joinedAt: m.joinedAt,
|
joinedAt: m.joinedAt,
|
||||||
formattedJoinedAt: DateDisplay.formatShort(m.joinedAt),
|
formattedJoinedAt: DateFormatter.formatShort(m.joinedAt),
|
||||||
})),
|
})),
|
||||||
}), [leagueId, joinRequests, members]);
|
}), [leagueId, joinRequests, members]);
|
||||||
|
|
||||||
|
|||||||
@@ -6,16 +6,16 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
|
||||||
import { useAuth } from '@/components/auth/AuthContext';
|
import { useAuth } from '@/components/auth/AuthContext';
|
||||||
import { SignupViewData } from '@/lib/builders/view-data/types/SignupViewData';
|
|
||||||
import { SignupTemplate } from '@/templates/auth/SignupTemplate';
|
|
||||||
import { SignupMutation } from '@/lib/mutations/auth/SignupMutation';
|
|
||||||
import { SignupViewModelBuilder } from '@/lib/builders/view-models/SignupViewModelBuilder';
|
import { SignupViewModelBuilder } from '@/lib/builders/view-models/SignupViewModelBuilder';
|
||||||
import { SignupViewModel } from '@/lib/view-models/auth/SignupViewModel';
|
|
||||||
import { SignupFormValidation } from '@/lib/utilities/authValidation';
|
|
||||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||||
|
import { SignupMutation } from '@/lib/mutations/auth/SignupMutation';
|
||||||
|
import { SignupFormValidation } from '@/lib/utilities/authValidation';
|
||||||
|
import { SignupViewData } from '@/lib/view-data/SignupViewData';
|
||||||
|
import { SignupViewModel } from '@/lib/view-models/auth/SignupViewModel';
|
||||||
|
import { SignupTemplate } from '@/templates/auth/SignupTemplate';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
export function SignupClient({ viewData }: ClientWrapperProps<SignupViewData>) {
|
export function SignupClient({ viewData }: ClientWrapperProps<SignupViewData>) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useLeagueStewardingMutations } from "@/hooks/league/useLeagueStewardingMutations";
|
import { useLeagueStewardingMutations } from "@/hooks/league/useLeagueStewardingMutations";
|
||||||
import type { StewardingViewData } from '@/lib/view-data/leagues/StewardingViewData';
|
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||||
|
import type { StewardingViewData } from '@/lib/view-data/StewardingViewData';
|
||||||
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||||
import { ProtestViewModel } from '@/lib/view-models/ProtestViewModel';
|
import { ProtestViewModel } from '@/lib/view-models/ProtestViewModel';
|
||||||
import { RaceViewModel } from '@/lib/view-models/RaceViewModel';
|
import { RaceViewModel } from '@/lib/view-models/RaceViewModel';
|
||||||
import { useMemo, useState } from 'react';
|
|
||||||
import { StewardingTemplate } from '@/templates/StewardingTemplate';
|
import { StewardingTemplate } from '@/templates/StewardingTemplate';
|
||||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
interface StewardingPageClientProps extends ClientWrapperProps<StewardingViewData> {
|
interface StewardingPageClientProps extends ClientWrapperProps<StewardingViewData> {
|
||||||
leagueId: string;
|
leagueId: string;
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { TeamLeaderboardTemplate } from '@/templates/TeamLeaderboardTemplate';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
|
||||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||||
|
import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||||
|
import { TeamLeaderboardTemplate } from '@/templates/TeamLeaderboardTemplate';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import type { TeamListItemDTO } from '@/lib/types/generated/TeamListItemDTO';
|
||||||
|
|
||||||
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
|
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
|
||||||
type SortBy = 'rating' | 'wins' | 'winRate' | 'races';
|
type SortBy = 'rating' | 'wins' | 'winRate' | 'races';
|
||||||
|
|
||||||
interface TeamLeaderboardViewData extends ViewData {
|
export function TeamLeaderboardPageWrapper({ viewData }: ClientWrapperProps<{ teams: TeamListItemDTO[] }>) {
|
||||||
teams: TeamSummaryViewModel[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TeamLeaderboardPageWrapper({ viewData }: ClientWrapperProps<TeamLeaderboardViewData>) {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Client-side UI state only (no business logic)
|
// Client-side UI state only (no business logic)
|
||||||
@@ -22,7 +19,13 @@ export function TeamLeaderboardPageWrapper({ viewData }: ClientWrapperProps<Team
|
|||||||
const [filterLevel, setFilterLevel] = useState<SkillLevel | 'all'>('all');
|
const [filterLevel, setFilterLevel] = useState<SkillLevel | 'all'>('all');
|
||||||
const [sortBy, setSortBy] = useState<SortBy>('rating');
|
const [sortBy, setSortBy] = useState<SortBy>('rating');
|
||||||
|
|
||||||
if (!viewData.teams || viewData.teams.length === 0) {
|
// Instantiate ViewModels on the client to wrap plain DTOs with logic
|
||||||
|
const teamViewModels = useMemo(() =>
|
||||||
|
(viewData.teams || []).map(dto => new TeamSummaryViewModel(dto)),
|
||||||
|
[viewData.teams]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (teamViewModels.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,8 +37,8 @@ export function TeamLeaderboardPageWrapper({ viewData }: ClientWrapperProps<Team
|
|||||||
router.push('/teams');
|
router.push('/teams');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply filtering and sorting
|
// Apply filtering and sorting using ViewModel logic
|
||||||
const filteredAndSortedTeams = viewData.teams
|
const filteredAndSortedTeams = teamViewModels
|
||||||
.filter((team) => {
|
.filter((team) => {
|
||||||
const matchesSearch = team.name.toLowerCase().includes(searchQuery.toLowerCase());
|
const matchesSearch = team.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
const matchesLevel = filterLevel === 'all' || team.performanceLevel === filterLevel;
|
const matchesLevel = filterLevel === 'all' || team.performanceLevel === filterLevel;
|
||||||
@@ -54,7 +57,7 @@ export function TeamLeaderboardPageWrapper({ viewData }: ClientWrapperProps<Team
|
|||||||
});
|
});
|
||||||
|
|
||||||
const templateViewData = {
|
const templateViewData = {
|
||||||
teams: viewData.teams,
|
teams: teamViewModels,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
filterLevel,
|
filterLevel,
|
||||||
sortBy,
|
sortBy,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import { DateFormatter } from '@/lib/formatters/DateFormatter';
|
||||||
import { Card } from '@/ui/Card';
|
import { Card } from '@/ui/Card';
|
||||||
import { Text } from '@/ui/Text';
|
|
||||||
import { Group } from '@/ui/Group';
|
import { Group } from '@/ui/Group';
|
||||||
import { Stack } from '@/ui/Stack';
|
import { Stack } from '@/ui/Stack';
|
||||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
import { Text } from '@/ui/Text';
|
||||||
|
|
||||||
interface AchievementCardProps {
|
interface AchievementCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -36,7 +36,7 @@ export function AchievementCard({
|
|||||||
<Text weight="medium" variant="high">{title}</Text>
|
<Text weight="medium" variant="high">{title}</Text>
|
||||||
<Text size="xs" variant="med">{description}</Text>
|
<Text size="xs" variant="med">{description}</Text>
|
||||||
<Text size="xs" variant="low">
|
<Text size="xs" variant="low">
|
||||||
{DateDisplay.formatShort(unlockedAt)}
|
{DateFormatter.formatShort(unlockedAt)}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import { AchievementDisplay } from '@/lib/display-objects/AchievementDisplay';
|
import { AchievementFormatter } from '@/lib/formatters/AchievementFormatter';
|
||||||
import { Card } from '@/ui/Card';
|
import { Card } from '@/ui/Card';
|
||||||
import { Grid } from '@/ui/Grid';
|
import { Grid } from '@/ui/Grid';
|
||||||
|
import { Group } from '@/ui/Group';
|
||||||
import { Heading } from '@/ui/Heading';
|
import { Heading } from '@/ui/Heading';
|
||||||
import { Icon } from '@/ui/Icon';
|
import { Icon } from '@/ui/Icon';
|
||||||
import { Group } from '@/ui/Group';
|
|
||||||
import { Stack } from '@/ui/Stack';
|
import { Stack } from '@/ui/Stack';
|
||||||
import { Box } from '@/ui/Box';
|
|
||||||
import { Surface } from '@/ui/Surface';
|
import { Surface } from '@/ui/Surface';
|
||||||
import { Text } from '@/ui/Text';
|
import { Text } from '@/ui/Text';
|
||||||
import { Award, Crown, Medal, Star, Target, Trophy, Zap } from 'lucide-react';
|
import { Award, Crown, Medal, Star, Target, Trophy, Zap } from 'lucide-react';
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface Achievement {
|
interface Achievement {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -53,7 +51,7 @@ export function AchievementGrid({ achievements }: AchievementGridProps) {
|
|||||||
<Grid cols={1} gap={4}>
|
<Grid cols={1} gap={4}>
|
||||||
{achievements.map((achievement) => {
|
{achievements.map((achievement) => {
|
||||||
const AchievementIcon = getAchievementIcon(achievement.icon);
|
const AchievementIcon = getAchievementIcon(achievement.icon);
|
||||||
const rarity = AchievementDisplay.getRarityVariant(achievement.rarity);
|
const rarity = AchievementFormatter.getRarityVariant(achievement.rarity);
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={achievement.id}
|
key={achievement.id}
|
||||||
|
|||||||
@@ -1,26 +1,24 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
import { DateFormatter } from '@/lib/formatters/DateFormatter';
|
||||||
import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData';
|
import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData';
|
||||||
|
import { Badge } from '@/ui/Badge';
|
||||||
import { Button } from '@/ui/Button';
|
import { Button } from '@/ui/Button';
|
||||||
|
import { DriverIdentity } from '@/ui/DriverIdentity';
|
||||||
|
import { Group } from '@/ui/Group';
|
||||||
import { IconButton } from '@/ui/IconButton';
|
import { IconButton } from '@/ui/IconButton';
|
||||||
import { SimpleCheckbox } from '@/ui/SimpleCheckbox';
|
import { SimpleCheckbox } from '@/ui/SimpleCheckbox';
|
||||||
import { Badge } from '@/ui/Badge';
|
|
||||||
import { Box } from '@/ui/Box';
|
|
||||||
import { Group } from '@/ui/Group';
|
|
||||||
import { DriverIdentity } from '@/ui/DriverIdentity';
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow
|
TableRow
|
||||||
} from '@/ui/Table';
|
} from '@/ui/Table';
|
||||||
import { Text } from '@/ui/Text';
|
import { Text } from '@/ui/Text';
|
||||||
import { MoreVertical, Trash2 } from 'lucide-react';
|
import { MoreVertical, Trash2 } from 'lucide-react';
|
||||||
import { UserStatusTag } from './UserStatusTag';
|
import { UserStatusTag } from './UserStatusTag';
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface AdminUsersTableProps {
|
interface AdminUsersTableProps {
|
||||||
users: AdminUsersViewData['users'];
|
users: AdminUsersViewData['users'];
|
||||||
@@ -102,7 +100,7 @@ export function AdminUsersTable({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Text size="sm" variant="low">
|
<Text size="sm" variant="low">
|
||||||
{user.lastLoginAt ? DateDisplay.formatShort(user.lastLoginAt) : 'Never'}
|
{user.lastLoginAt ? DateFormatter.formatShort(user.lastLoginAt) : 'Never'}
|
||||||
</Text>
|
</Text>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
|
|
||||||
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
|
import { CountryFlagFormatter } from '@/lib/formatters/CountryFlagFormatter';
|
||||||
import { Badge } from '@/ui/Badge';
|
import { Badge } from '@/ui/Badge';
|
||||||
import { Icon } from '@/ui/Icon';
|
import { Icon } from '@/ui/Icon';
|
||||||
import { Image } from '@/ui/Image';
|
import { Image } from '@/ui/Image';
|
||||||
@@ -88,7 +88,7 @@ export function DriverEntryRow({
|
|||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
fontSize="0.625rem"
|
fontSize="0.625rem"
|
||||||
>
|
>
|
||||||
{CountryFlagDisplay.fromCountryCode(country).toString()}
|
{CountryFlagFormatter.fromCountryCode(country).toString()}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
|
|
||||||
|
|
||||||
import { mediaConfig } from '@/lib/config/mediaConfig';
|
import { mediaConfig } from '@/lib/config/mediaConfig';
|
||||||
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
|
import { CountryFlagFormatter } from '@/lib/formatters/CountryFlagFormatter';
|
||||||
|
import { Box } from '@/ui/Box';
|
||||||
import { Button } from '@/ui/Button';
|
import { Button } from '@/ui/Button';
|
||||||
import { Heading } from '@/ui/Heading';
|
import { Heading } from '@/ui/Heading';
|
||||||
|
import { Icon } from '@/ui/Icon';
|
||||||
import { Image } from '@/ui/Image';
|
import { Image } from '@/ui/Image';
|
||||||
import { Link } from '@/ui/Link';
|
import { Link } from '@/ui/Link';
|
||||||
import { Stack } from '@/ui/Stack';
|
import { Stack } from '@/ui/Stack';
|
||||||
import { Surface } from '@/ui/Surface';
|
import { Surface } from '@/ui/Surface';
|
||||||
import { Text } from '@/ui/Text';
|
import { Text } from '@/ui/Text';
|
||||||
import { Box } from '@/ui/Box';
|
|
||||||
import { Icon } from '@/ui/Icon';
|
|
||||||
import { Calendar, Clock, ExternalLink, Globe, Star, Trophy, UserPlus } from 'lucide-react';
|
import { Calendar, Clock, ExternalLink, Globe, Star, Trophy, UserPlus } from 'lucide-react';
|
||||||
|
|
||||||
interface ProfileHeroProps {
|
interface ProfileHeroProps {
|
||||||
@@ -93,7 +93,7 @@ export function ProfileHero({
|
|||||||
<Stack direction="row" align="center" gap={3} wrap mb={2}>
|
<Stack direction="row" align="center" gap={3} wrap mb={2}>
|
||||||
<Heading level={1}>{driver.name}</Heading>
|
<Heading level={1}>{driver.name}</Heading>
|
||||||
<Text size="4xl" aria-label={`Country: ${driver.country}`}>
|
<Text size="4xl" aria-label={`Country: ${driver.country}`}>
|
||||||
{CountryFlagDisplay.fromCountryCode(driver.country).toString()}
|
{CountryFlagFormatter.fromCountryCode(driver.country).toString()}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
|||||||
@@ -11,52 +11,41 @@ import { Input } from '@/ui/Input';
|
|||||||
import { Stack } from '@/ui/Stack';
|
import { Stack } from '@/ui/Stack';
|
||||||
import { Text } from '@/ui/Text';
|
import { Text } from '@/ui/Text';
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Bug,
|
Bug,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Clock,
|
Clock,
|
||||||
Copy,
|
Copy,
|
||||||
Cpu,
|
Cpu,
|
||||||
Download,
|
Download,
|
||||||
FileText,
|
FileText,
|
||||||
Globe,
|
Globe,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Terminal,
|
Terminal,
|
||||||
Trash2,
|
Trash2,
|
||||||
Zap
|
Zap
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
import { DateFormatter } from '@/lib/formatters/DateFormatter';
|
||||||
import { DurationDisplay } from '@/lib/display-objects/DurationDisplay';
|
import { DurationFormatter } from '@/lib/formatters/DurationFormatter';
|
||||||
import { MemoryDisplay } from '@/lib/display-objects/MemoryDisplay';
|
import { MemoryFormatter } from '@/lib/formatters/MemoryFormatter';
|
||||||
import { PercentDisplay } from '@/lib/display-objects/PercentDisplay';
|
import { PercentFormatter } from '@/lib/formatters/PercentFormatter';
|
||||||
import { TimeDisplay } from '@/lib/display-objects/TimeDisplay';
|
|
||||||
|
|
||||||
interface ErrorAnalyticsDashboardProps {
|
|
||||||
/**
|
|
||||||
* Auto-refresh interval in milliseconds
|
|
||||||
*/
|
|
||||||
refreshInterval?: number;
|
|
||||||
/**
|
|
||||||
* Whether to show in production (default: false)
|
|
||||||
*/
|
|
||||||
showInProduction?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(duration: number): string {
|
function formatDuration(duration: number): string {
|
||||||
return DurationDisplay.formatMs(duration);
|
return DurationFormatter.formatMs(duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPercentage(value: number, total: number): string {
|
function formatPercentage(value: number, total: number): string {
|
||||||
if (total === 0) return '0%';
|
if (total === 0) return '0%';
|
||||||
return PercentDisplay.format(value / total);
|
return PercentFormatter.format(value / total);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMemory(bytes: number): string {
|
function formatMemory(bytes: number): string {
|
||||||
return MemoryDisplay.formatMB(bytes);
|
return MemoryFormatter.formatMB(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PerformanceWithMemory extends Performance {
|
interface PerformanceWithMemory extends Performance {
|
||||||
@@ -327,7 +316,7 @@ export function ErrorAnalyticsDashboard({
|
|||||||
<Stack display="flex" justifyContent="between" alignItems="start" gap={2} mb={1}>
|
<Stack display="flex" justifyContent="between" alignItems="start" gap={2} mb={1}>
|
||||||
<Text size="xs" font="mono" weight="bold" color="text-red-400" truncate>{error.type}</Text>
|
<Text size="xs" font="mono" weight="bold" color="text-red-400" truncate>{error.type}</Text>
|
||||||
<Text size="xs" color="text-gray-500" fontSize="10px">
|
<Text size="xs" color="text-gray-500" fontSize="10px">
|
||||||
{DateDisplay.formatTime(error.timestamp)}
|
{DateFormatter.formatTime(error.timestamp)}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Text size="xs" color="text-gray-300" block mb={1}>{error.message}</Text>
|
<Text size="xs" color="text-gray-300" block mb={1}>{error.message}</Text>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import { TimeFormatter } from '@/lib/formatters/TimeFormatter';
|
||||||
import { Button } from '@/ui/Button';
|
import { Button } from '@/ui/Button';
|
||||||
import { FeedItem } from '@/ui/FeedItem';
|
import { FeedItem } from '@/ui/FeedItem';
|
||||||
import { Text } from '@/ui/Text';
|
|
||||||
import { Stack } from '@/ui/Stack';
|
import { Stack } from '@/ui/Stack';
|
||||||
import { TimeDisplay } from '@/lib/display-objects/TimeDisplay';
|
import { Text } from '@/ui/Text';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
interface FeedItemData {
|
interface FeedItemData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -50,7 +50,7 @@ export function FeedItemCard({ item }: FeedItemCardProps) {
|
|||||||
name: actor?.name || 'Unknown',
|
name: actor?.name || 'Unknown',
|
||||||
avatar: actor?.avatarUrl
|
avatar: actor?.avatarUrl
|
||||||
}}
|
}}
|
||||||
timestamp={TimeDisplay.timeAgo(item.timestamp)}
|
timestamp={TimeFormatter.timeAgo(item.timestamp)}
|
||||||
content={
|
content={
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
<Text weight="bold" variant="high">{item.headline}</Text>
|
<Text weight="bold" variant="high">{item.headline}</Text>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { FeedList } from '@/components/feed/FeedList';
|
import { FeedList } from '@/components/feed/FeedList';
|
||||||
import { LatestResultsSidebar } from '@/components/races/LatestResultsSidebar';
|
import { LatestResultsSidebar } from '@/components/races/LatestResultsSidebar';
|
||||||
import { UpcomingRacesSidebar } from '@/components/races/UpcomingRacesSidebar';
|
import { UpcomingRacesSidebar } from '@/components/races/UpcomingRacesSidebar';
|
||||||
|
import { DateFormatter } from '@/lib/formatters/DateFormatter';
|
||||||
import { Card } from '@/ui/Card';
|
import { Card } from '@/ui/Card';
|
||||||
import { Container } from '@/ui/Container';
|
import { Container } from '@/ui/Container';
|
||||||
import { Heading } from '@/ui/Heading';
|
|
||||||
import { Grid } from '@/ui/Grid';
|
import { Grid } from '@/ui/Grid';
|
||||||
import { Stack } from '@/ui/Stack';
|
import { Heading } from '@/ui/Heading';
|
||||||
import { Section } from '@/ui/Section';
|
import { Section } from '@/ui/Section';
|
||||||
|
import { Stack } from '@/ui/Stack';
|
||||||
import { Text } from '@/ui/Text';
|
import { Text } from '@/ui/Text';
|
||||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
|
||||||
|
|
||||||
interface FeedItemData {
|
interface FeedItemData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -49,12 +49,12 @@ export function FeedLayout({
|
|||||||
}: FeedLayoutProps) {
|
}: FeedLayoutProps) {
|
||||||
const formattedUpcomingRaces = upcomingRaces.map(r => ({
|
const formattedUpcomingRaces = upcomingRaces.map(r => ({
|
||||||
...r,
|
...r,
|
||||||
formattedDate: DateDisplay.formatShort(r.scheduledAt),
|
formattedDate: DateFormatter.formatShort(r.scheduledAt),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const formattedLatestResults = latestResults.map(r => ({
|
const formattedLatestResults = latestResults.map(r => ({
|
||||||
...r,
|
...r,
|
||||||
formattedDate: DateDisplay.formatShort(r.scheduledAt),
|
formattedDate: DateFormatter.formatShort(r.scheduledAt),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { RankBadge } from '@/components/leaderboards/RankBadge';
|
import { RankBadge } from '@/components/leaderboards/RankBadge';
|
||||||
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
|
import { RatingFormatter } from '@/lib/formatters/RatingFormatter';
|
||||||
import { SkillLevelDisplay } from '@/lib/display-objects/SkillLevelDisplay';
|
import { SkillLevelFormatter } from '@/lib/formatters/SkillLevelFormatter';
|
||||||
import { Avatar } from '@/ui/Avatar';
|
import { Avatar } from '@/ui/Avatar';
|
||||||
|
import { Group } from '@/ui/Group';
|
||||||
import { LeaderboardList } from '@/ui/LeaderboardList';
|
import { LeaderboardList } from '@/ui/LeaderboardList';
|
||||||
import { LeaderboardPreviewShell } from '@/ui/LeaderboardPreviewShell';
|
import { LeaderboardPreviewShell } from '@/ui/LeaderboardPreviewShell';
|
||||||
import { LeaderboardRow } from '@/ui/LeaderboardRow';
|
import { LeaderboardRow } from '@/ui/LeaderboardRow';
|
||||||
import { Group } from '@/ui/Group';
|
|
||||||
import { Text } from '@/ui/Text';
|
import { Text } from '@/ui/Text';
|
||||||
import { Trophy } from 'lucide-react';
|
import { Trophy } from 'lucide-react';
|
||||||
|
|
||||||
@@ -69,8 +69,8 @@ export function DriverLeaderboardPreview({
|
|||||||
</Text>
|
</Text>
|
||||||
<Group gap={2}>
|
<Group gap={2}>
|
||||||
<Text size="xs" variant="low" uppercase weight="bold" letterSpacing="wider">{driver.nationality}</Text>
|
<Text size="xs" variant="low" uppercase weight="bold" letterSpacing="wider">{driver.nationality}</Text>
|
||||||
<Text size="xs" weight="bold" color={SkillLevelDisplay.getColor(driver.skillLevel)} uppercase letterSpacing="wider">
|
<Text size="xs" weight="bold" color={SkillLevelFormatter.getColor(driver.skillLevel)} uppercase letterSpacing="wider">
|
||||||
{SkillLevelDisplay.getLabel(driver.skillLevel)}
|
{SkillLevelFormatter.getLabel(driver.skillLevel)}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -80,7 +80,7 @@ export function DriverLeaderboardPreview({
|
|||||||
<Group gap={8}>
|
<Group gap={8}>
|
||||||
<Group direction="column" align="end" gap={0}>
|
<Group direction="column" align="end" gap={0}>
|
||||||
<Text variant="primary" font="mono" weight="bold" block size="md" align="right">
|
<Text variant="primary" font="mono" weight="bold" block size="md" align="right">
|
||||||
{RatingDisplay.format(driver.rating)}
|
{RatingFormatter.format(driver.rating)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" variant="low" block uppercase letterSpacing="widest" weight="bold" fontSize="9px" align="right">
|
<Text size="xs" variant="low" block uppercase letterSpacing="widest" weight="bold" fontSize="9px" align="right">
|
||||||
Rating
|
Rating
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
|
import { MedalFormatter } from '@/lib/formatters/MedalFormatter';
|
||||||
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
|
import { RatingFormatter } from '@/lib/formatters/RatingFormatter';
|
||||||
import { Image } from '@/ui/Image';
|
import { Image } from '@/ui/Image';
|
||||||
import { Stack } from '@/ui/Stack';
|
import { Stack } from '@/ui/Stack';
|
||||||
import { Text } from '@/ui/Text';
|
import { Text } from '@/ui/Text';
|
||||||
@@ -91,8 +91,8 @@ export function LeaderboardPodium({ podium, onDriverClick }: LeaderboardPodiumPr
|
|||||||
border
|
border
|
||||||
transform="translateX(-50%)"
|
transform="translateX(-50%)"
|
||||||
borderWidth="2px"
|
borderWidth="2px"
|
||||||
bg={MedalDisplay.getBg(position)}
|
bg={MedalFormatter.getBg(position)}
|
||||||
color={MedalDisplay.getColor(position)}
|
color={MedalFormatter.getColor(position)}
|
||||||
shadow="lg"
|
shadow="lg"
|
||||||
>
|
>
|
||||||
<Text size="sm" weight="bold">{position}</Text>
|
<Text size="sm" weight="bold">{position}</Text>
|
||||||
@@ -122,7 +122,7 @@ export function LeaderboardPodium({ podium, onDriverClick }: LeaderboardPodiumPr
|
|||||||
block
|
block
|
||||||
color={isFirst ? 'text-warning-amber' : 'text-primary-blue'}
|
color={isFirst ? 'text-warning-amber' : 'text-primary-blue'}
|
||||||
>
|
>
|
||||||
{RatingDisplay.format(driver.rating)}
|
{RatingFormatter.format(driver.rating)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Stack direction="row" align="center" gap={3} mt={1}>
|
<Stack direction="row" align="center" gap={3} mt={1}>
|
||||||
@@ -155,7 +155,7 @@ export function LeaderboardPodium({ podium, onDriverClick }: LeaderboardPodiumPr
|
|||||||
<Text
|
<Text
|
||||||
weight="bold"
|
weight="bold"
|
||||||
size="4xl"
|
size="4xl"
|
||||||
color={MedalDisplay.getColor(position)}
|
color={MedalFormatter.getColor(position)}
|
||||||
opacity={0.1}
|
opacity={0.1}
|
||||||
fontSize={isFirst ? '5rem' : '3.5rem'}
|
fontSize={isFirst ? '5rem' : '3.5rem'}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
|
import { MedalFormatter } from '@/lib/formatters/MedalFormatter';
|
||||||
import { RankMedal as UiRankMedal, RankMedalProps } from '@/ui/RankMedal';
|
import { RankMedalProps, RankMedal as UiRankMedal } from '@/ui/RankMedal';
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export function RankMedal(props: RankMedalProps) {
|
export function RankMedal(props: RankMedalProps) {
|
||||||
const variant = MedalDisplay.getVariant(props.rank);
|
const variant = MedalFormatter.getVariant(props.rank);
|
||||||
const bg = MedalDisplay.getBg(props.rank);
|
const bg = MedalFormatter.getBg(props.rank);
|
||||||
const color = MedalDisplay.getColor(props.rank);
|
const color = MedalFormatter.getColor(props.rank);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UiRankMedal
|
<UiRankMedal
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
|
import { RatingFormatter } from '@/lib/formatters/RatingFormatter';
|
||||||
|
import { SkillLevelFormatter } from '@/lib/formatters/SkillLevelFormatter';
|
||||||
import { Avatar } from '@/ui/Avatar';
|
import { Avatar } from '@/ui/Avatar';
|
||||||
import { Group } from '@/ui/Group';
|
import { Group } from '@/ui/Group';
|
||||||
|
import { LeaderboardRow } from '@/ui/LeaderboardRow';
|
||||||
import { Text } from '@/ui/Text';
|
import { Text } from '@/ui/Text';
|
||||||
import { DeltaChip } from './DeltaChip';
|
import { DeltaChip } from './DeltaChip';
|
||||||
import { RankBadge } from './RankBadge';
|
import { RankBadge } from './RankBadge';
|
||||||
import { LeaderboardRow } from '@/ui/LeaderboardRow';
|
|
||||||
import { SkillLevelDisplay } from '@/lib/display-objects/SkillLevelDisplay';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface RankingRowProps {
|
interface RankingRowProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -65,8 +64,8 @@ export function RankingRow({
|
|||||||
</Text>
|
</Text>
|
||||||
<Group gap={2}>
|
<Group gap={2}>
|
||||||
<Text size="xs" variant="low" uppercase weight="bold" letterSpacing="wider">{nationality}</Text>
|
<Text size="xs" variant="low" uppercase weight="bold" letterSpacing="wider">{nationality}</Text>
|
||||||
<Text size="xs" weight="bold" style={{ color: SkillLevelDisplay.getColor(skillLevel) }} uppercase letterSpacing="wider">
|
<Text size="xs" weight="bold" style={{ color: SkillLevelFormatter.getColor(skillLevel) }} uppercase letterSpacing="wider">
|
||||||
{SkillLevelDisplay.getLabel(skillLevel)}
|
{SkillLevelFormatter.getLabel(skillLevel)}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -84,7 +83,7 @@ export function RankingRow({
|
|||||||
</Group>
|
</Group>
|
||||||
<Group direction="column" align="end" gap={0}>
|
<Group direction="column" align="end" gap={0}>
|
||||||
<Text variant="primary" font="mono" weight="bold" block size="md">
|
<Text variant="primary" font="mono" weight="bold" block size="md">
|
||||||
{RatingDisplay.format(rating)}
|
{RatingFormatter.format(rating)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" variant="low" block uppercase letterSpacing="widest" weight="bold">
|
<Text size="xs" variant="low" block uppercase letterSpacing="widest" weight="bold">
|
||||||
Rating
|
Rating
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
|
import { RatingFormatter } from '@/lib/formatters/RatingFormatter';
|
||||||
import { Avatar } from '@/ui/Avatar';
|
import { Avatar } from '@/ui/Avatar';
|
||||||
import { Group } from '@/ui/Group';
|
import { Group } from '@/ui/Group';
|
||||||
import { Text } from '@/ui/Text';
|
|
||||||
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
|
|
||||||
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
|
|
||||||
import { Surface } from '@/ui/Surface';
|
import { Surface } from '@/ui/Surface';
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface PodiumDriver {
|
interface PodiumDriver {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,7 +17,7 @@ interface RankingsPodiumProps {
|
|||||||
onDriverClick?: (id: string) => void;
|
onDriverClick?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RankingsPodium({ podium, onDriverClick }: RankingsPodiumProps) {
|
export function RankingsPodium({ podium }: RankingsPodiumProps) {
|
||||||
return (
|
return (
|
||||||
<Group justify="center" align="end" gap={4}>
|
<Group justify="center" align="end" gap={4}>
|
||||||
{[1, 0, 2].map((index) => {
|
{[1, 0, 2].map((index) => {
|
||||||
@@ -57,7 +54,7 @@ export function RankingsPodium({ podium, onDriverClick }: RankingsPodiumProps) {
|
|||||||
|
|
||||||
<Text weight="bold" variant="high" size={isFirst ? 'md' : 'sm'}>{driver.name}</Text>
|
<Text weight="bold" variant="high" size={isFirst ? 'md' : 'sm'}>{driver.name}</Text>
|
||||||
<Text font="mono" weight="bold" variant={isFirst ? 'warning' : 'primary'}>
|
<Text font="mono" weight="bold" variant={isFirst ? 'warning' : 'primary'}>
|
||||||
{RatingDisplay.format(driver.rating)}
|
{RatingFormatter.format(driver.rating)}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import { DateFormatter } from '@/lib/formatters/DateFormatter';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { routes } from '@/lib/routing/RouteConfig';
|
|
||||||
import { Box } from '@/ui/Box';
|
|
||||||
import { Text } from '@/ui/Text';
|
|
||||||
import { Stack } from '@/ui/Stack';
|
|
||||||
import { Button } from '@/ui/Button';
|
|
||||||
import { Icon } from '@/ui/Icon';
|
|
||||||
import { Badge } from '@/ui/Badge';
|
import { Badge } from '@/ui/Badge';
|
||||||
|
import { Box } from '@/ui/Box';
|
||||||
|
import { Button } from '@/ui/Button';
|
||||||
import { Group } from '@/ui/Group';
|
import { Group } from '@/ui/Group';
|
||||||
|
import { Icon } from '@/ui/Icon';
|
||||||
|
import { Stack } from '@/ui/Stack';
|
||||||
import { Surface } from '@/ui/Surface';
|
import { Surface } from '@/ui/Surface';
|
||||||
import { ChevronDown, ChevronUp, Calendar, CheckCircle, Trophy, Edit, Clock } from 'lucide-react';
|
import { Text } from '@/ui/Text';
|
||||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
import { Calendar, CheckCircle, ChevronDown, ChevronUp, Clock, Edit, Trophy } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
interface RaceEvent {
|
interface RaceEvent {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -50,9 +48,6 @@ interface MonthGroup {
|
|||||||
|
|
||||||
export function EnhancedLeagueSchedulePanel({
|
export function EnhancedLeagueSchedulePanel({
|
||||||
events,
|
events,
|
||||||
leagueId,
|
|
||||||
currentDriverId,
|
|
||||||
isAdmin,
|
|
||||||
onRegister,
|
onRegister,
|
||||||
onWithdraw,
|
onWithdraw,
|
||||||
onEdit,
|
onEdit,
|
||||||
@@ -60,7 +55,6 @@ export function EnhancedLeagueSchedulePanel({
|
|||||||
onRaceDetail,
|
onRaceDetail,
|
||||||
onResultsClick,
|
onResultsClick,
|
||||||
}: EnhancedLeagueSchedulePanelProps) {
|
}: EnhancedLeagueSchedulePanelProps) {
|
||||||
const router = useRouter();
|
|
||||||
const [expandedMonths, setExpandedMonths] = useState<Set<string>>(new Set());
|
const [expandedMonths, setExpandedMonths] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// Group races by month
|
// Group races by month
|
||||||
@@ -109,7 +103,7 @@ export function EnhancedLeagueSchedulePanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatTime = (scheduledAt: string) => {
|
const formatTime = (scheduledAt: string) => {
|
||||||
return DateDisplay.formatDateTime(scheduledAt);
|
return DateFormatter.formatDateTime(scheduledAt);
|
||||||
};
|
};
|
||||||
|
|
||||||
const groups = groupRacesByMonth();
|
const groups = groupRacesByMonth();
|
||||||
@@ -158,7 +152,7 @@ export function EnhancedLeagueSchedulePanel({
|
|||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<Box p={4}>
|
<Box p={4}>
|
||||||
<Stack gap={3}>
|
<Stack gap={3}>
|
||||||
{group.races.map((race, raceIndex) => (
|
{group.races.map((race) => (
|
||||||
<Surface
|
<Surface
|
||||||
key={race.id}
|
key={race.id}
|
||||||
variant="precision"
|
variant="precision"
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { ActivityFeedItem } from '@/components/feed/ActivityFeedItem';
|
import { ActivityFeedItem } from '@/components/feed/ActivityFeedItem';
|
||||||
import { useLeagueRaces } from "@/hooks/league/useLeagueRaces";
|
import { useLeagueRaces } from "@/hooks/league/useLeagueRaces";
|
||||||
|
import { RelativeTimeFormatter } from '@/lib/formatters/RelativeTimeFormatter';
|
||||||
import { LeagueActivityService } from '@/lib/services/league/LeagueActivityService';
|
import { LeagueActivityService } from '@/lib/services/league/LeagueActivityService';
|
||||||
import { RelativeTimeDisplay } from '@/lib/display-objects/RelativeTimeDisplay';
|
|
||||||
import { Icon } from '@/ui/Icon';
|
import { Icon } from '@/ui/Icon';
|
||||||
import { Text } from '@/ui/Text';
|
|
||||||
import { Stack } from '@/ui/Stack';
|
import { Stack } from '@/ui/Stack';
|
||||||
|
import { Text } from '@/ui/Text';
|
||||||
import { AlertTriangle, Calendar, Flag, Shield, UserMinus, UserPlus } from 'lucide-react';
|
import { AlertTriangle, Calendar, Flag, Shield, UserMinus, UserPlus } from 'lucide-react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ function ActivityItem({ activity }: { activity: LeagueActivity }) {
|
|||||||
<ActivityFeedItem
|
<ActivityFeedItem
|
||||||
icon={getIcon()}
|
icon={getIcon()}
|
||||||
content={getContent()}
|
content={getContent()}
|
||||||
timestamp={RelativeTimeDisplay.format(activity.timestamp, new Date())}
|
timestamp={RelativeTimeFormatter.format(activity.timestamp, new Date())}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { DriverIdentity } from '@/ui/DriverIdentity';
|
import { DateFormatter } from '@/lib/formatters/DateFormatter';
|
||||||
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||||
import { Badge } from '@/ui/Badge';
|
import { Badge } from '@/ui/Badge';
|
||||||
import { Box } from '@/ui/Box';
|
import { Box } from '@/ui/Box';
|
||||||
|
import { DriverIdentity } from '@/ui/DriverIdentity';
|
||||||
import { TableCell, TableRow } from '@/ui/Table';
|
import { TableCell, TableRow } from '@/ui/Table';
|
||||||
import { Text } from '@/ui/Text';
|
import { Text } from '@/ui/Text';
|
||||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
import { ReactNode } from 'react';
|
||||||
import React, { ReactNode } from 'react';
|
|
||||||
|
|
||||||
interface LeagueMemberRowProps {
|
interface LeagueMemberRowProps {
|
||||||
driver?: DriverViewModel;
|
driver?: DriverViewModel;
|
||||||
@@ -84,7 +84,7 @@ export function LeagueMemberRow({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Text variant="high" size="sm">
|
<Text variant="high" size="sm">
|
||||||
{DateDisplay.formatShort(joinedAt)}
|
{DateFormatter.formatShort(joinedAt)}
|
||||||
</Text>
|
</Text>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{actions && (
|
{actions && (
|
||||||
|
|||||||
@@ -1,39 +1,33 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {
|
|
||||||
Users,
|
|
||||||
Calendar,
|
|
||||||
Trophy,
|
|
||||||
Award,
|
|
||||||
Rocket,
|
|
||||||
Gamepad2,
|
|
||||||
User,
|
|
||||||
UsersRound,
|
|
||||||
Clock,
|
|
||||||
Flag,
|
|
||||||
Zap,
|
|
||||||
Timer,
|
|
||||||
Check,
|
|
||||||
Globe,
|
|
||||||
Medal,
|
|
||||||
type LucideIcon,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
|
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
|
||||||
import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel';
|
|
||||||
import { Stack } from '@/ui/Stack';
|
|
||||||
import { Text } from '@/ui/Text';
|
|
||||||
import { Heading } from '@/ui/Heading';
|
|
||||||
import { Icon } from '@/ui/Icon';
|
|
||||||
import { Card } from '@/ui/Card';
|
import { Card } from '@/ui/Card';
|
||||||
import { Grid } from '@/ui/Grid';
|
import { Grid } from '@/ui/Grid';
|
||||||
|
import { Heading } from '@/ui/Heading';
|
||||||
|
import { Icon } from '@/ui/Icon';
|
||||||
|
import { Stack } from '@/ui/Stack';
|
||||||
|
import { Text } from '@/ui/Text';
|
||||||
|
import {
|
||||||
|
Award,
|
||||||
|
Calendar,
|
||||||
|
Check,
|
||||||
|
Clock,
|
||||||
|
Flag,
|
||||||
|
Gamepad2,
|
||||||
|
Globe,
|
||||||
|
Medal,
|
||||||
|
Rocket,
|
||||||
|
Timer,
|
||||||
|
Trophy,
|
||||||
|
User,
|
||||||
|
Users,
|
||||||
|
UsersRound,
|
||||||
|
Zap,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
import { DateFormatter } from '@/lib/formatters/DateFormatter';
|
||||||
import { DurationDisplay } from '@/lib/display-objects/DurationDisplay';
|
|
||||||
|
|
||||||
interface LeagueReviewSummaryProps {
|
|
||||||
form: LeagueConfigFormModel;
|
|
||||||
presets: LeagueScoringPresetViewModel[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Individual review card component
|
// Individual review card component
|
||||||
function ReviewCard({
|
function ReviewCard({
|
||||||
@@ -142,7 +136,7 @@ export function LeagueReviewSummary({ form, presets }: LeagueReviewSummaryProps)
|
|||||||
|
|
||||||
const seasonStartLabel =
|
const seasonStartLabel =
|
||||||
timings.seasonStartDate
|
timings.seasonStartDate
|
||||||
? DateDisplay.formatShort(timings.seasonStartDate)
|
? DateFormatter.formatShort(timings.seasonStartDate)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const stewardingLabel = (() => {
|
const stewardingLabel = (() => {
|
||||||
|
|||||||
@@ -1,28 +1,27 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import { DateFormatter } from '@/lib/formatters/DateFormatter';
|
||||||
import { Box } from '@/ui/Box';
|
|
||||||
import { Text } from '@/ui/Text';
|
|
||||||
import { Stack } from '@/ui/Stack';
|
|
||||||
import { Group } from '@/ui/Group';
|
|
||||||
import { Surface } from '@/ui/Surface';
|
|
||||||
import { Icon } from '@/ui/Icon';
|
|
||||||
import { Button } from '@/ui/Button';
|
|
||||||
import { Badge } from '@/ui/Badge';
|
import { Badge } from '@/ui/Badge';
|
||||||
import {
|
import { Box } from '@/ui/Box';
|
||||||
Calendar,
|
import { Button } from '@/ui/Button';
|
||||||
Clock,
|
import { Group } from '@/ui/Group';
|
||||||
Car,
|
import { Icon } from '@/ui/Icon';
|
||||||
MapPin,
|
import { Stack } from '@/ui/Stack';
|
||||||
Thermometer,
|
import { Surface } from '@/ui/Surface';
|
||||||
Droplets,
|
import { Text } from '@/ui/Text';
|
||||||
Wind,
|
import {
|
||||||
|
Calendar,
|
||||||
|
Car,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
Cloud,
|
Cloud,
|
||||||
X,
|
Droplets,
|
||||||
|
MapPin,
|
||||||
|
Thermometer,
|
||||||
Trophy,
|
Trophy,
|
||||||
CheckCircle
|
Wind,
|
||||||
|
X
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
|
||||||
|
|
||||||
interface RaceDetailModalProps {
|
interface RaceDetailModalProps {
|
||||||
race: {
|
race: {
|
||||||
@@ -55,7 +54,7 @@ export function RaceDetailModal({
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const formatTime = (scheduledAt: string) => {
|
const formatTime = (scheduledAt: string) => {
|
||||||
return DateDisplay.formatDateTime(scheduledAt);
|
return DateFormatter.formatDateTime(scheduledAt);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusBadge = (status: 'scheduled' | 'completed') => {
|
const getStatusBadge = (status: 'scheduled' | 'completed') => {
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
|
import { CountryFlagFormatter } from '@/lib/formatters/CountryFlagFormatter';
|
||||||
import { Panel } from '@/ui/Panel';
|
|
||||||
import { Input } from '@/ui/Input';
|
|
||||||
import { Text } from '@/ui/Text';
|
|
||||||
import { TextArea } from '@/ui/TextArea';
|
|
||||||
import { Box } from '@/ui/Box';
|
|
||||||
import { Group } from '@/ui/Group';
|
import { Group } from '@/ui/Group';
|
||||||
|
import { Input } from '@/ui/Input';
|
||||||
|
import { Panel } from '@/ui/Panel';
|
||||||
import { Stack } from '@/ui/Stack';
|
import { Stack } from '@/ui/Stack';
|
||||||
import { ProfileStat } from '@/ui/ProfileHero';
|
import { TextArea } from '@/ui/TextArea';
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface ProfileDetailsPanelProps {
|
interface ProfileDetailsPanelProps {
|
||||||
driver: {
|
driver: {
|
||||||
@@ -50,7 +46,7 @@ export function ProfileDetailsPanel({ driver, isEditing, onUpdate }: ProfileDeta
|
|||||||
<Text size="xs" variant="low" weight="bold" uppercase block>Nationality</Text>
|
<Text size="xs" variant="low" weight="bold" uppercase block>Nationality</Text>
|
||||||
<Group gap={2}>
|
<Group gap={2}>
|
||||||
<Text size="xl">
|
<Text size="xl">
|
||||||
{CountryFlagDisplay.fromCountryCode(driver.country).toString()}
|
{CountryFlagFormatter.fromCountryCode(driver.country).toString()}
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="med">{driver.country}</Text>
|
<Text variant="med">{driver.country}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { mediaConfig } from '@/lib/config/mediaConfig';
|
import { mediaConfig } from '@/lib/config/mediaConfig';
|
||||||
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
|
import { CountryFlagFormatter } from '@/lib/formatters/CountryFlagFormatter';
|
||||||
|
import { Box } from '@/ui/Box';
|
||||||
import { Button } from '@/ui/Button';
|
import { Button } from '@/ui/Button';
|
||||||
|
import { Group } from '@/ui/Group';
|
||||||
import { Heading } from '@/ui/Heading';
|
import { Heading } from '@/ui/Heading';
|
||||||
import { Image } from '@/ui/Image';
|
import { Image } from '@/ui/Image';
|
||||||
import { ProfileHero, ProfileAvatar, ProfileStatsGroup, ProfileStat } from '@/ui/ProfileHero';
|
import { ProfileAvatar, ProfileHero, ProfileStat, ProfileStatsGroup } from '@/ui/ProfileHero';
|
||||||
import { Text } from '@/ui/Text';
|
|
||||||
import { Box } from '@/ui/Box';
|
|
||||||
import { Group } from '@/ui/Group';
|
|
||||||
import { Stack } from '@/ui/Stack';
|
import { Stack } from '@/ui/Stack';
|
||||||
import { Calendar, Globe, Star, Trophy, UserPlus } from 'lucide-react';
|
import { Text } from '@/ui/Text';
|
||||||
|
import { Calendar, Globe, UserPlus } from 'lucide-react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
interface ProfileHeaderProps {
|
interface ProfileHeaderProps {
|
||||||
@@ -56,7 +56,7 @@ export function ProfileHeader({
|
|||||||
<Group gap={3}>
|
<Group gap={3}>
|
||||||
<Heading level={1}>{driver.name}</Heading>
|
<Heading level={1}>{driver.name}</Heading>
|
||||||
<Text size="2xl" aria-label={`Country: ${driver.country}`}>
|
<Text size="2xl" aria-label={`Country: ${driver.country}`}>
|
||||||
{CountryFlagDisplay.fromCountryCode(driver.country).toString()}
|
{CountryFlagFormatter.fromCountryCode(driver.country).toString()}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
import { DateFormatter } from '@/lib/formatters/DateFormatter';
|
||||||
import { Button } from '@/ui/Button';
|
import { Button } from '@/ui/Button';
|
||||||
import { Card, Card as Surface } from '@/ui/Card';
|
import { Card, Card as Surface } from '@/ui/Card';
|
||||||
import { Stack } from '@/ui/Stack';
|
import { Stack } from '@/ui/Stack';
|
||||||
@@ -64,7 +64,7 @@ export function SponsorshipRequestsPanel({
|
|||||||
<Text size="xs" color="text-gray-400" block mt={1}>{request.message}</Text>
|
<Text size="xs" color="text-gray-400" block mt={1}>{request.message}</Text>
|
||||||
)}
|
)}
|
||||||
<Text size="xs" color="text-gray-500" block mt={2}>
|
<Text size="xs" color="text-gray-500" block mt={2}>
|
||||||
{DateDisplay.formatShort(request.createdAtIso)}
|
{DateFormatter.formatShort(request.createdAtIso)}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack direction="row" gap={2}>
|
<Stack direction="row" gap={2}>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import { DateFormatter } from '@/lib/formatters/DateFormatter';
|
||||||
import { RaceStatusDisplay } from '@/lib/display-objects/RaceStatusDisplay';
|
import { RaceStatusFormatter } from '@/lib/formatters/RaceStatusFormatter';
|
||||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
|
||||||
import { RaceCard as UiRaceCard } from './RaceCard';
|
import { RaceCard as UiRaceCard } from './RaceCard';
|
||||||
|
|
||||||
interface RaceCardProps {
|
interface RaceCardProps {
|
||||||
@@ -23,11 +22,11 @@ export function RaceCard({ race, onClick }: RaceCardProps) {
|
|||||||
track={race.track}
|
track={race.track}
|
||||||
car={race.car}
|
car={race.car}
|
||||||
scheduledAt={race.scheduledAt}
|
scheduledAt={race.scheduledAt}
|
||||||
scheduledAtLabel={DateDisplay.formatShort(race.scheduledAt)}
|
scheduledAtLabel={DateFormatter.formatShort(race.scheduledAt)}
|
||||||
timeLabel={DateDisplay.formatTime(race.scheduledAt)}
|
timeLabel={DateFormatter.formatTime(race.scheduledAt)}
|
||||||
status={race.status}
|
status={race.status}
|
||||||
statusLabel={RaceStatusDisplay.getLabel(race.status)}
|
statusLabel={RaceStatusFormatter.getLabel(race.status)}
|
||||||
statusVariant={RaceStatusDisplay.getVariant(race.status) as any}
|
statusVariant={RaceStatusFormatter.getVariant(race.status) as any}
|
||||||
leagueName={race.leagueName}
|
leagueName={race.leagueName}
|
||||||
leagueId={race.leagueId}
|
leagueId={race.leagueId}
|
||||||
strengthOfField={race.strengthOfField}
|
strengthOfField={race.strengthOfField}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
|
||||||
|
|
||||||
import { RaceHero as UiRaceHero } from '@/components/races/RaceHero';
|
import { RaceHero as UiRaceHero } from '@/components/races/RaceHero';
|
||||||
|
import { DateFormatter } from '@/lib/formatters/DateFormatter';
|
||||||
import { LucideIcon } from 'lucide-react';
|
import { LucideIcon } from 'lucide-react';
|
||||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
|
||||||
|
|
||||||
interface RaceHeroProps {
|
interface RaceHeroProps {
|
||||||
track: string;
|
track: string;
|
||||||
@@ -34,8 +34,8 @@ export function RaceHero(props: RaceHeroProps) {
|
|||||||
return (
|
return (
|
||||||
<UiRaceHero
|
<UiRaceHero
|
||||||
{...rest}
|
{...rest}
|
||||||
formattedDate={DateDisplay.formatShort(scheduledAt)}
|
formattedDate={DateFormatter.formatShort(scheduledAt)}
|
||||||
formattedTime={DateDisplay.formatTime(scheduledAt)}
|
formattedTime={DateFormatter.formatTime(scheduledAt)}
|
||||||
statusConfig={mappedConfig}
|
statusConfig={mappedConfig}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
|
||||||
|
|
||||||
import { routes } from '@/lib/routing/RouteConfig';
|
|
||||||
import { RaceListItem as UiRaceListItem } from '@/components/races/RaceListItem';
|
import { RaceListItem as UiRaceListItem } from '@/components/races/RaceListItem';
|
||||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
import { DateFormatter } from '@/lib/formatters/DateFormatter';
|
||||||
import { StatusDisplay } from '@/lib/display-objects/StatusDisplay';
|
import { StatusFormatter } from '@/lib/formatters/StatusFormatter';
|
||||||
|
import { routes } from '@/lib/routing/RouteConfig';
|
||||||
|
|
||||||
interface Race {
|
interface Race {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -48,11 +48,11 @@ export function RaceListItem({ race, onClick }: RaceListItemProps) {
|
|||||||
<UiRaceListItem
|
<UiRaceListItem
|
||||||
track={race.track}
|
track={race.track}
|
||||||
car={race.car}
|
car={race.car}
|
||||||
dateLabel={DateDisplay.formatMonthDay(race.scheduledAt).split(' ')[0]}
|
dateLabel={DateFormatter.formatMonthDay(race.scheduledAt).split(' ')[0]}
|
||||||
dayLabel={DateDisplay.formatMonthDay(race.scheduledAt).split(' ')[1]}
|
dayLabel={DateFormatter.formatMonthDay(race.scheduledAt).split(' ')[1]}
|
||||||
timeLabel={DateDisplay.formatTime(race.scheduledAt)}
|
timeLabel={DateFormatter.formatTime(race.scheduledAt)}
|
||||||
status={race.status}
|
status={race.status}
|
||||||
statusLabel={StatusDisplay.raceStatus(race.status)}
|
statusLabel={StatusFormatter.raceStatus(race.status)}
|
||||||
statusVariant={config.variant}
|
statusVariant={config.variant}
|
||||||
statusIconName={config.iconName}
|
statusIconName={config.iconName}
|
||||||
leagueName={race.leagueName}
|
leagueName={race.leagueName}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
|
||||||
|
|
||||||
|
import { DateFormatter } from '@/lib/formatters/DateFormatter';
|
||||||
import { RaceResultViewModel } from '@/lib/view-models/RaceResultViewModel';
|
import { RaceResultViewModel } from '@/lib/view-models/RaceResultViewModel';
|
||||||
import { RaceResultCard as UiRaceResultCard } from './RaceResultCard';
|
import { RaceResultCard as UiRaceResultCard } from './RaceResultCard';
|
||||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
|
||||||
|
|
||||||
interface RaceResultCardProps {
|
interface RaceResultCardProps {
|
||||||
race: {
|
race: {
|
||||||
@@ -29,7 +29,7 @@ export function RaceResultCard({
|
|||||||
raceId={race.id}
|
raceId={race.id}
|
||||||
track={race.track}
|
track={race.track}
|
||||||
car={race.car}
|
car={race.car}
|
||||||
formattedDate={DateDisplay.formatShort(race.scheduledAt)}
|
formattedDate={DateFormatter.formatShort(race.scheduledAt)}
|
||||||
position={result.position}
|
position={result.position}
|
||||||
positionLabel={result.formattedPosition}
|
positionLabel={result.formattedPosition}
|
||||||
startPositionLabel={result.formattedStartPosition}
|
startPositionLabel={result.formattedStartPosition}
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
|
import { CountryFlagFormatter } from '@/lib/formatters/CountryFlagFormatter';
|
||||||
import { Image } from '@/ui/Image';
|
|
||||||
import { ResultRow, PositionBadge, ResultPoints } from '@/ui/ResultRow';
|
|
||||||
import { Text } from '@/ui/Text';
|
|
||||||
import { Badge } from '@/ui/Badge';
|
import { Badge } from '@/ui/Badge';
|
||||||
import { Box } from '@/ui/Box';
|
import { Box } from '@/ui/Box';
|
||||||
import { Group } from '@/ui/Group';
|
import { Group } from '@/ui/Group';
|
||||||
|
import { Image } from '@/ui/Image';
|
||||||
|
import { PositionBadge, ResultPoints, ResultRow } from '@/ui/ResultRow';
|
||||||
import { Stack } from '@/ui/Stack';
|
import { Stack } from '@/ui/Stack';
|
||||||
import { Surface } from '@/ui/Surface';
|
import { Surface } from '@/ui/Surface';
|
||||||
import React from 'react';
|
import { Text } from '@/ui/Text';
|
||||||
|
|
||||||
interface ResultEntry {
|
interface ResultEntry {
|
||||||
position: number;
|
position: number;
|
||||||
@@ -62,7 +61,7 @@ export function RaceResultRow({ result, points }: RaceResultRowProps) {
|
|||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
>
|
>
|
||||||
<Text size="xs" style={{ fontSize: '0.625rem' }}>
|
<Text size="xs" style={{ fontSize: '0.625rem' }}>
|
||||||
{CountryFlagDisplay.fromCountryCode(country).toString()}
|
{CountryFlagFormatter.fromCountryCode(country).toString()}
|
||||||
</Text>
|
</Text>
|
||||||
</Surface>
|
</Surface>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { mediaConfig } from '@/lib/config/mediaConfig';
|
import { mediaConfig } from '@/lib/config/mediaConfig';
|
||||||
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
|
import { CountryFlagFormatter } from '@/lib/formatters/CountryFlagFormatter';
|
||||||
import { routes } from '@/lib/routing/RouteConfig';
|
import { routes } from '@/lib/routing/RouteConfig';
|
||||||
import { Card, Card as Surface } from '@/ui/Card';
|
import { Card, Card as Surface } from '@/ui/Card';
|
||||||
import { Heading } from '@/ui/Heading';
|
import { Heading } from '@/ui/Heading';
|
||||||
@@ -68,7 +68,7 @@ export function FriendsPreview({ friends }: FriendsPreviewProps) {
|
|||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Text size="sm" color="text-white">{friend.name}</Text>
|
<Text size="sm" color="text-white">{friend.name}</Text>
|
||||||
<Text size="lg">{CountryFlagDisplay.fromCountryCode(friend.country).toString()}</Text>
|
<Text size="lg">{CountryFlagFormatter.fromCountryCode(friend.country).toString()}</Text>
|
||||||
</Surface>
|
</Surface>
|
||||||
</Link>
|
</Link>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { MinimalEmptyState } from '@/ui/EmptyState';
|
|
||||||
import { TeamRosterItem } from '@/components/teams/TeamRosterItem';
|
import { TeamRosterItem } from '@/components/teams/TeamRosterItem';
|
||||||
import { TeamRosterList } from '@/components/teams/TeamRosterList';
|
import { TeamRosterList } from '@/components/teams/TeamRosterList';
|
||||||
import { useTeamRoster } from "@/hooks/team/useTeamRoster";
|
import { useTeamRoster } from "@/hooks/team/useTeamRoster";
|
||||||
@@ -9,15 +8,16 @@ import { sortMembers } from '@/lib/utilities/roster-utils';
|
|||||||
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||||
import { Button } from '@/ui/Button';
|
import { Button } from '@/ui/Button';
|
||||||
import { Card } from '@/ui/Card';
|
import { Card } from '@/ui/Card';
|
||||||
|
import { MinimalEmptyState } from '@/ui/EmptyState';
|
||||||
import { Heading } from '@/ui/Heading';
|
import { Heading } from '@/ui/Heading';
|
||||||
import { Stack } from '@/ui/Stack';
|
|
||||||
import { Select } from '@/ui/Select';
|
import { Select } from '@/ui/Select';
|
||||||
|
import { Stack } from '@/ui/Stack';
|
||||||
import { Text } from '@/ui/Text';
|
import { Text } from '@/ui/Text';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { MemberDisplay } from '@/lib/display-objects/MemberDisplay';
|
import { DateFormatter } from '@/lib/formatters/DateFormatter';
|
||||||
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
|
import { MemberFormatter } from '@/lib/formatters/MemberFormatter';
|
||||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
import { RatingFormatter } from '@/lib/formatters/RatingFormatter';
|
||||||
|
|
||||||
export type TeamRole = 'owner' | 'admin' | 'member';
|
export type TeamRole = 'owner' | 'admin' | 'member';
|
||||||
export type TeamMemberRole = 'owner' | 'manager' | 'member';
|
export type TeamMemberRole = 'owner' | 'manager' | 'member';
|
||||||
@@ -74,7 +74,7 @@ export function TeamRoster({
|
|||||||
const teamAverageRatingLabel = useMemo(() => {
|
const teamAverageRatingLabel = useMemo(() => {
|
||||||
if (teamMembers.length === 0) return '—';
|
if (teamMembers.length === 0) return '—';
|
||||||
const avg = teamMembers.reduce((sum: number, m: { rating?: number | null }) => sum + (m.rating || 0), 0) / teamMembers.length;
|
const avg = teamMembers.reduce((sum: number, m: { rating?: number | null }) => sum + (m.rating || 0), 0) / teamMembers.length;
|
||||||
return RatingDisplay.format(avg);
|
return RatingFormatter.format(avg);
|
||||||
}, [teamMembers]);
|
}, [teamMembers]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -93,7 +93,7 @@ export function TeamRoster({
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Heading level={3}>Team Roster</Heading>
|
<Heading level={3}>Team Roster</Heading>
|
||||||
<Text size="sm" color="text-gray-400" block mt={1}>
|
<Text size="sm" color="text-gray-400" block mt={1}>
|
||||||
{MemberDisplay.formatCount(memberships.length)} • Avg Rating:{' '}
|
{MemberFormatter.formatCount(memberships.length)} • Avg Rating:{' '}
|
||||||
<Text color="text-primary-blue" weight="medium">{teamAverageRatingLabel}</Text>
|
<Text color="text-primary-blue" weight="medium">{teamAverageRatingLabel}</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -129,8 +129,8 @@ export function TeamRoster({
|
|||||||
driver={driver as DriverViewModel}
|
driver={driver as DriverViewModel}
|
||||||
href={`${routes.driver.detail(driver.id)}?from=team&teamId=${teamId}`}
|
href={`${routes.driver.detail(driver.id)}?from=team&teamId=${teamId}`}
|
||||||
roleLabel={getRoleLabel(role)}
|
roleLabel={getRoleLabel(role)}
|
||||||
joinedAtLabel={DateDisplay.formatShort(joinedAt)}
|
joinedAtLabel={DateFormatter.formatShort(joinedAt)}
|
||||||
ratingLabel={RatingDisplay.format(rating)}
|
ratingLabel={RatingFormatter.format(rating)}
|
||||||
overallRankLabel={overallRank !== null ? `#${overallRank}` : null}
|
overallRankLabel={overallRank !== null ? `#${overallRank}` : null}
|
||||||
actions={canManageMembership ? (
|
actions={canManageMembership ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
160
apps/website/eslint-rules/ANALYSIS.md
Normal file
160
apps/website/eslint-rules/ANALYSIS.md
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
# ESLint Rule Analysis for RaceWithSOFViewModel.ts
|
||||||
|
|
||||||
|
## File Analyzed
|
||||||
|
`apps/website/lib/view-models/RaceWithSOFViewModel.ts`
|
||||||
|
|
||||||
|
## Violations Found
|
||||||
|
|
||||||
|
### 1. DTO Import (Line 1)
|
||||||
|
```typescript
|
||||||
|
import { RaceWithSOFDTO } from '@/lib/types/generated/RaceWithSOFDTO';
|
||||||
|
```
|
||||||
|
**Rule Violated**: `view-model-taxonomy.js`
|
||||||
|
**Reason**:
|
||||||
|
- Imports from DTO path (`lib/types/generated/`)
|
||||||
|
- Uses DTO naming convention (`RaceWithSOFDTO`)
|
||||||
|
|
||||||
|
### 2. Inline ViewData Interface (Lines 9-13)
|
||||||
|
```typescript
|
||||||
|
export interface RaceWithSOFViewData {
|
||||||
|
id: string;
|
||||||
|
track: string;
|
||||||
|
strengthOfField: number | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**Rule Violated**: `view-model-taxonomy.js`
|
||||||
|
**Reason**: Defines ViewData interface inline instead of importing from `lib/view-data/`
|
||||||
|
|
||||||
|
## Rule Gaps Identified
|
||||||
|
|
||||||
|
### Current Rule Issues
|
||||||
|
1. **Incomplete import checking**: Only reported if imported name contained "DTO", but should forbid ALL imports from disallowed paths
|
||||||
|
2. **No strict whitelist**: Didn't enforce that imports MUST be from allowed paths
|
||||||
|
3. **Poor relative import handling**: Couldn't properly resolve relative imports
|
||||||
|
4. **Missing strict import message**: No message for general import path violations
|
||||||
|
|
||||||
|
### Architectural Requirements
|
||||||
|
The project requires:
|
||||||
|
1. **Forbid "dto" in the whole directory** ✓ (covered)
|
||||||
|
2. **Imports only from contracts or view models/view data dir** ✗ (partially covered)
|
||||||
|
3. **No inline view data interfaces** ✓ (covered)
|
||||||
|
|
||||||
|
## Improvements Made
|
||||||
|
|
||||||
|
### 1. Updated `view-model-taxonomy.js`
|
||||||
|
**Changes**:
|
||||||
|
- Added `strictImport` message for general import path violations
|
||||||
|
- Changed import check to report for ANY import from disallowed paths (not just those with "DTO" in name)
|
||||||
|
- Added strict import path enforcement with whitelist
|
||||||
|
- Improved relative import handling
|
||||||
|
- Added null checks for `node.id` in interface/type checks
|
||||||
|
|
||||||
|
**New Behavior**:
|
||||||
|
- Forbids ALL imports from DTO/service paths (`lib/types/generated/`, `lib/dtos/`, `lib/api/`, `lib/services/`)
|
||||||
|
- Enforces strict whitelist: only allows imports from `@/lib/contracts/`, `@/lib/view-models/`, `@/lib/view-data/`
|
||||||
|
- Allows external imports (npm packages)
|
||||||
|
- Handles relative imports with heuristic pattern matching
|
||||||
|
|
||||||
|
### 2. Updated `test-view-model-taxonomy.js`
|
||||||
|
**Changes**:
|
||||||
|
- Added test for service layer imports
|
||||||
|
- Added test for strict import violations
|
||||||
|
- Updated test summary to include new test cases
|
||||||
|
|
||||||
|
## Test Results
|
||||||
|
|
||||||
|
### Before Improvements
|
||||||
|
- Test 1 (DTO import): ✓ PASS
|
||||||
|
- Test 2 (Inline ViewData): ✓ PASS
|
||||||
|
- Test 3 (Valid code): ✓ PASS
|
||||||
|
|
||||||
|
### After Improvements
|
||||||
|
- Test 1 (DTO import): ✓ PASS
|
||||||
|
- Test 2 (Inline ViewData): ✓ PASS
|
||||||
|
- Test 3 (Valid code): ✓ PASS
|
||||||
|
- Test 4 (Service import): ✓ PASS (new)
|
||||||
|
- Test 5 (Strict import): ✓ PASS (new)
|
||||||
|
|
||||||
|
## Recommended Refactoring for RaceWithSOFViewModel.ts
|
||||||
|
|
||||||
|
### Current Code (Violations)
|
||||||
|
```typescript
|
||||||
|
import { RaceWithSOFDTO } from '@/lib/types/generated/RaceWithSOFDTO';
|
||||||
|
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||||
|
|
||||||
|
export interface RaceWithSOFViewData {
|
||||||
|
id: string;
|
||||||
|
track: string;
|
||||||
|
strengthOfField: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RaceWithSOFViewModel extends ViewModel {
|
||||||
|
private readonly data: RaceWithSOFViewData;
|
||||||
|
|
||||||
|
constructor(data: RaceWithSOFViewData) {
|
||||||
|
super();
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
get id(): string { return this.data.id; }
|
||||||
|
get track(): string { return this.data.track; }
|
||||||
|
get strengthOfField(): number | null { return this.data.strengthOfField; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fixed Code (No Violations)
|
||||||
|
```typescript
|
||||||
|
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||||
|
import { RaceWithSOFViewData } from '@/lib/view-data/RaceWithSOFViewData';
|
||||||
|
|
||||||
|
export class RaceWithSOFViewModel extends ViewModel {
|
||||||
|
private readonly data: RaceWithSOFViewData;
|
||||||
|
|
||||||
|
constructor(data: RaceWithSOFViewData) {
|
||||||
|
super();
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
get id(): string { return this.data.id; }
|
||||||
|
get track(): string { return this.data.track; }
|
||||||
|
get strengthOfField(): number | null { return this.data.strengthOfField; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Changes**:
|
||||||
|
1. Removed DTO import (`RaceWithSOFDTO`)
|
||||||
|
2. Moved ViewData interface to `lib/view-data/RaceWithSOFViewData.ts`
|
||||||
|
3. Imported ViewData from proper location
|
||||||
|
|
||||||
|
## Additional Recommendations
|
||||||
|
|
||||||
|
### 1. Consider Splitting the Rule
|
||||||
|
If the rule becomes too complex, consider splitting it into:
|
||||||
|
- `view-model-taxonomy.js`: Keep only DTO and ViewData definition checks
|
||||||
|
- `view-model-imports.js`: New rule for strict import path enforcement
|
||||||
|
|
||||||
|
### 2. Improve Relative Import Handling
|
||||||
|
The current heuristic for relative imports may have false positives/negatives. Consider:
|
||||||
|
- Using a path resolver
|
||||||
|
- Requiring absolute imports with `@/` prefix
|
||||||
|
- Adding configuration for allowed relative import patterns
|
||||||
|
|
||||||
|
### 3. Add More Tests
|
||||||
|
- Test with nested view model directories
|
||||||
|
- Test with type imports (`import type`)
|
||||||
|
- Test with external package imports
|
||||||
|
- Test with relative imports from different depths
|
||||||
|
|
||||||
|
### 4. Update Documentation
|
||||||
|
- Document the allowed import paths
|
||||||
|
- Provide examples of correct and incorrect usage
|
||||||
|
- Update the rule description to reflect the new strict import enforcement
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The updated `view-model-taxonomy.js` rule now properly enforces all three architectural requirements:
|
||||||
|
1. ✓ Forbids "DTO" in identifiers
|
||||||
|
2. ✓ Enforces strict import path whitelist
|
||||||
|
3. ✓ Forbids inline ViewData definitions
|
||||||
|
|
||||||
|
The rule is more robust and catches more violations while maintaining backward compatibility with existing valid code.
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
/**
|
|
||||||
* ESLint rules for Display Object Guardrails
|
|
||||||
*
|
|
||||||
* Enforces display object boundaries and purity
|
|
||||||
*/
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
// Rule 1: No IO in display objects
|
|
||||||
'no-io-in-display-objects': {
|
|
||||||
meta: {
|
|
||||||
type: 'problem',
|
|
||||||
docs: {
|
|
||||||
description: 'Forbid IO imports in display objects',
|
|
||||||
category: 'Display Objects',
|
|
||||||
},
|
|
||||||
messages: {
|
|
||||||
message: 'DisplayObjects cannot import from api, services, page-queries, or view-models - see apps/website/lib/contracts/display-objects/DisplayObject.ts',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
create(context) {
|
|
||||||
const forbiddenPaths = [
|
|
||||||
'@/lib/api/',
|
|
||||||
'@/lib/services/',
|
|
||||||
'@/lib/page-queries/',
|
|
||||||
'@/lib/view-models/',
|
|
||||||
'@/lib/presenters/',
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
ImportDeclaration(node) {
|
|
||||||
const importPath = node.source.value;
|
|
||||||
if (forbiddenPaths.some(path => importPath.includes(path)) &&
|
|
||||||
!isInComment(node)) {
|
|
||||||
context.report({
|
|
||||||
node,
|
|
||||||
messageId: 'message',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Rule 2: No non-class display exports
|
|
||||||
'no-non-class-display-exports': {
|
|
||||||
meta: {
|
|
||||||
type: 'problem',
|
|
||||||
docs: {
|
|
||||||
description: 'Forbid non-class exports in display objects',
|
|
||||||
category: 'Display Objects',
|
|
||||||
},
|
|
||||||
messages: {
|
|
||||||
message: 'Display Objects must be class-based and export only classes - see apps/website/lib/contracts/display-objects/DisplayObject.ts',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
create(context) {
|
|
||||||
return {
|
|
||||||
ExportNamedDeclaration(node) {
|
|
||||||
if (node.declaration &&
|
|
||||||
(node.declaration.type === 'FunctionDeclaration' ||
|
|
||||||
(node.declaration.type === 'VariableDeclaration' &&
|
|
||||||
!node.declaration.declarations.some(d => d.init && d.init.type === 'ClassExpression')))) {
|
|
||||||
context.report({
|
|
||||||
node,
|
|
||||||
messageId: 'message',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
ExportDefaultDeclaration(node) {
|
|
||||||
if (node.declaration &&
|
|
||||||
node.declaration.type !== 'ClassDeclaration' &&
|
|
||||||
node.declaration.type !== 'ClassExpression') {
|
|
||||||
context.report({
|
|
||||||
node,
|
|
||||||
messageId: 'message',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper functions
|
|
||||||
function isInComment(node) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
138
apps/website/eslint-rules/formatter-rules.js
Normal file
138
apps/website/eslint-rules/formatter-rules.js
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* ESLint rules for Formatter/Display Guardrails
|
||||||
|
*
|
||||||
|
* Enforces boundaries and purity for Formatters and Display Objects
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// Rule 1: No IO in formatters/displays
|
||||||
|
'no-io-in-display-objects': {
|
||||||
|
meta: {
|
||||||
|
type: 'problem',
|
||||||
|
docs: {
|
||||||
|
description: 'Forbid IO imports in formatters and displays',
|
||||||
|
category: 'Formatters',
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
message: 'Formatters/Displays cannot import from api, services, page-queries, or view-models - see apps/website/lib/contracts/formatters/Formatter.ts',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create(context) {
|
||||||
|
const forbiddenPaths = [
|
||||||
|
'@/lib/api/',
|
||||||
|
'@/lib/services/',
|
||||||
|
'@/lib/page-queries/',
|
||||||
|
'@/lib/view-models/',
|
||||||
|
'@/lib/presenters/',
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
ImportDeclaration(node) {
|
||||||
|
const importPath = node.source.value;
|
||||||
|
if (forbiddenPaths.some(path => importPath.includes(path)) &&
|
||||||
|
!isInComment(node)) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'message',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Rule 2: No non-class display exports
|
||||||
|
'no-non-class-display-exports': {
|
||||||
|
meta: {
|
||||||
|
type: 'problem',
|
||||||
|
docs: {
|
||||||
|
description: 'Forbid non-class exports in formatters and displays',
|
||||||
|
category: 'Formatters',
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
message: 'Formatters and Displays must be class-based and export only classes - see apps/website/lib/contracts/formatters/Formatter.ts',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create(context) {
|
||||||
|
return {
|
||||||
|
ExportNamedDeclaration(node) {
|
||||||
|
if (node.declaration &&
|
||||||
|
(node.declaration.type === 'FunctionDeclaration' ||
|
||||||
|
(node.declaration.type === 'VariableDeclaration' &&
|
||||||
|
!node.declaration.declarations.some(d => d.init && d.init.type === 'ClassExpression')))) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'message',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ExportDefaultDeclaration(node) {
|
||||||
|
if (node.declaration &&
|
||||||
|
node.declaration.type !== 'ClassDeclaration' &&
|
||||||
|
node.declaration.type !== 'ClassExpression') {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'message',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Rule 3: Formatters must return primitives
|
||||||
|
'formatters-must-return-primitives': {
|
||||||
|
meta: {
|
||||||
|
type: 'problem',
|
||||||
|
docs: {
|
||||||
|
description: 'Enforce that Formatters return primitive values for ViewData compatibility',
|
||||||
|
category: 'Formatters',
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
message: 'Formatters used in ViewDataBuilders must return primitive values (string, number, boolean, null) - see apps/website/lib/contracts/formatters/Formatter.ts',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create(context) {
|
||||||
|
const filename = context.getFilename();
|
||||||
|
const isViewDataBuilder = filename.includes('/lib/builders/view-data/');
|
||||||
|
|
||||||
|
if (!isViewDataBuilder) return {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
CallExpression(node) {
|
||||||
|
// Check if calling a Formatter/Display method
|
||||||
|
if (node.callee.type === 'MemberExpression' &&
|
||||||
|
node.callee.object.name &&
|
||||||
|
(node.callee.object.name.endsWith('Formatter') || node.callee.object.name.endsWith('Display'))) {
|
||||||
|
|
||||||
|
// If it's inside a ViewData object literal, it must be a primitive return
|
||||||
|
let parent = node.parent;
|
||||||
|
while (parent) {
|
||||||
|
if (parent.type === 'Property' && parent.parent.type === 'ObjectExpression') {
|
||||||
|
// This is a property in an object literal (likely ViewData)
|
||||||
|
// We can't easily check the return type of the method at lint time without type info,
|
||||||
|
// but we can enforce that it's not the whole object being assigned.
|
||||||
|
if (node.callee.property.name === 'format' || node.callee.property.name.startsWith('format')) {
|
||||||
|
// Good: calling a format method
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If they are assigning the result of a non-format method, warn
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'message',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
parent = parent.parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
function isInComment(node) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
const presenterContract = require('./presenter-contract');
|
const presenterContract = require('./presenter-contract');
|
||||||
const rscBoundaryRules = require('./rsc-boundary-rules');
|
const rscBoundaryRules = require('./rsc-boundary-rules');
|
||||||
const templatePurityRules = require('./template-purity-rules');
|
const templatePurityRules = require('./template-purity-rules');
|
||||||
const displayObjectRules = require('./display-object-rules');
|
const displayObjectRules = require('./formatter-rules');
|
||||||
const pageQueryRules = require('./page-query-rules');
|
const pageQueryRules = require('./page-query-rules');
|
||||||
const servicesRules = require('./services-rules');
|
const servicesRules = require('./services-rules');
|
||||||
const clientOnlyRules = require('./client-only-rules');
|
const clientOnlyRules = require('./client-only-rules');
|
||||||
@@ -30,7 +30,6 @@ const mutationContract = require('./mutation-contract');
|
|||||||
const serverActionsMustUseMutations = require('./server-actions-must-use-mutations');
|
const serverActionsMustUseMutations = require('./server-actions-must-use-mutations');
|
||||||
const viewDataLocation = require('./view-data-location');
|
const viewDataLocation = require('./view-data-location');
|
||||||
const viewDataBuilderContract = require('./view-data-builder-contract');
|
const viewDataBuilderContract = require('./view-data-builder-contract');
|
||||||
const viewModelBuilderContract = require('./view-model-builder-contract');
|
|
||||||
const singleExportPerFile = require('./single-export-per-file');
|
const singleExportPerFile = require('./single-export-per-file');
|
||||||
const filenameMatchesExport = require('./filename-matches-export');
|
const filenameMatchesExport = require('./filename-matches-export');
|
||||||
const pageQueryMustUseBuilders = require('./page-query-must-use-builders');
|
const pageQueryMustUseBuilders = require('./page-query-must-use-builders');
|
||||||
@@ -46,6 +45,11 @@ const servicesImplementContract = require('./services-implement-contract');
|
|||||||
const serverActionsReturnResult = require('./server-actions-return-result');
|
const serverActionsReturnResult = require('./server-actions-return-result');
|
||||||
const serverActionsInterface = require('./server-actions-interface');
|
const serverActionsInterface = require('./server-actions-interface');
|
||||||
const noDisplayObjectsInUi = require('./no-display-objects-in-ui');
|
const noDisplayObjectsInUi = require('./no-display-objects-in-ui');
|
||||||
|
const viewDataBuilderImplements = require('./view-data-builder-implements');
|
||||||
|
const viewDataBuilderImports = require('./view-data-builder-imports');
|
||||||
|
const viewDataImplements = require('./view-data-implements');
|
||||||
|
const viewModelImplements = require('./view-model-implements');
|
||||||
|
const viewModelTaxonomy = require('./view-model-taxonomy');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
rules: {
|
rules: {
|
||||||
@@ -79,6 +83,7 @@ module.exports = {
|
|||||||
// Display Object Rules
|
// Display Object Rules
|
||||||
'display-no-domain-models': displayObjectRules['no-io-in-display-objects'],
|
'display-no-domain-models': displayObjectRules['no-io-in-display-objects'],
|
||||||
'display-no-business-logic': displayObjectRules['no-non-class-display-exports'],
|
'display-no-business-logic': displayObjectRules['no-non-class-display-exports'],
|
||||||
|
'formatters-must-return-primitives': displayObjectRules['formatters-must-return-primitives'],
|
||||||
'no-display-objects-in-ui': noDisplayObjectsInUi,
|
'no-display-objects-in-ui': noDisplayObjectsInUi,
|
||||||
|
|
||||||
// Page Query Rules
|
// Page Query Rules
|
||||||
@@ -128,9 +133,13 @@ module.exports = {
|
|||||||
// View Data Rules
|
// View Data Rules
|
||||||
'view-data-location': viewDataLocation,
|
'view-data-location': viewDataLocation,
|
||||||
'view-data-builder-contract': viewDataBuilderContract,
|
'view-data-builder-contract': viewDataBuilderContract,
|
||||||
|
'view-data-builder-implements': viewDataBuilderImplements,
|
||||||
|
'view-data-builder-imports': viewDataBuilderImports,
|
||||||
|
'view-data-implements': viewDataImplements,
|
||||||
|
|
||||||
// View Model Rules
|
// View Model Rules
|
||||||
'view-model-builder-contract': viewModelBuilderContract,
|
'view-model-implements': viewModelImplements,
|
||||||
|
'view-model-taxonomy': viewModelTaxonomy,
|
||||||
|
|
||||||
// Single Export Rules
|
// Single Export Rules
|
||||||
'single-export-per-file': singleExportPerFile,
|
'single-export-per-file': singleExportPerFile,
|
||||||
@@ -210,6 +219,7 @@ module.exports = {
|
|||||||
// Display Objects
|
// Display Objects
|
||||||
'gridpilot-rules/display-no-domain-models': 'error',
|
'gridpilot-rules/display-no-domain-models': 'error',
|
||||||
'gridpilot-rules/display-no-business-logic': 'error',
|
'gridpilot-rules/display-no-business-logic': 'error',
|
||||||
|
'gridpilot-rules/formatters-must-return-primitives': 'error',
|
||||||
'gridpilot-rules/no-display-objects-in-ui': 'error',
|
'gridpilot-rules/no-display-objects-in-ui': 'error',
|
||||||
|
|
||||||
// Page Queries
|
// Page Queries
|
||||||
@@ -253,9 +263,14 @@ module.exports = {
|
|||||||
// View Data
|
// View Data
|
||||||
'gridpilot-rules/view-data-location': 'error',
|
'gridpilot-rules/view-data-location': 'error',
|
||||||
'gridpilot-rules/view-data-builder-contract': 'error',
|
'gridpilot-rules/view-data-builder-contract': 'error',
|
||||||
|
'gridpilot-rules/view-data-builder-implements': 'error',
|
||||||
|
'gridpilot-rules/view-data-builder-imports': 'error',
|
||||||
|
'gridpilot-rules/view-data-implements': 'error',
|
||||||
|
|
||||||
// View Model
|
// View Model
|
||||||
'gridpilot-rules/view-model-builder-contract': 'error',
|
'gridpilot-rules/view-model-builder-contract': 'error',
|
||||||
|
'gridpilot-rules/view-model-builder-implements': 'error',
|
||||||
|
'gridpilot-rules/view-model-implements': 'error',
|
||||||
|
|
||||||
// Single Export Rules
|
// Single Export Rules
|
||||||
'gridpilot-rules/single-export-per-file': 'error',
|
'gridpilot-rules/single-export-per-file': 'error',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* ESLint rules for Template Purity Guardrails
|
* ESLint rules for Template Purity Guardrails
|
||||||
*
|
*
|
||||||
* Enforces pure template components without business logic
|
* Enforces pure template components without business logic
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -14,17 +14,21 @@ module.exports = {
|
|||||||
category: 'Template Purity',
|
category: 'Template Purity',
|
||||||
},
|
},
|
||||||
messages: {
|
messages: {
|
||||||
message: 'ViewModels or DisplayObjects import forbidden in templates - see apps/website/lib/contracts/view-data/ViewData.ts',
|
message: 'ViewModels or DisplayObjects import forbidden in templates - see apps/website/lib/contracts/view-data/ViewData.ts. Templates should only receive logic-rich ViewModels via props from ClientWrappers, never import them directly.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
create(context) {
|
create(context) {
|
||||||
return {
|
return {
|
||||||
ImportDeclaration(node) {
|
ImportDeclaration(node) {
|
||||||
const importPath = node.source.value;
|
const importPath = node.source.value;
|
||||||
if ((importPath.includes('@/lib/view-models/') ||
|
// Templates are allowed to import ViewModels for TYPE-ONLY usage (interface/type)
|
||||||
|
// but not for instantiation or logic. However, to be safe, we forbid direct imports
|
||||||
|
// and suggest passing them through ClientWrappers.
|
||||||
|
if ((importPath.includes('@/lib/view-models/') ||
|
||||||
importPath.includes('@/lib/presenters/') ||
|
importPath.includes('@/lib/presenters/') ||
|
||||||
importPath.includes('@/lib/display-objects/')) &&
|
importPath.includes('@/lib/display-objects/')) &&
|
||||||
!isInComment(node)) {
|
!isInComment(node) &&
|
||||||
|
node.importKind !== 'type') {
|
||||||
context.report({
|
context.report({
|
||||||
node,
|
node,
|
||||||
messageId: 'message',
|
messageId: 'message',
|
||||||
|
|||||||
168
apps/website/eslint-rules/test-view-model-taxonomy.js
Normal file
168
apps/website/eslint-rules/test-view-model-taxonomy.js
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* Test script for view-model-taxonomy rule
|
||||||
|
*/
|
||||||
|
|
||||||
|
const rule = require('./view-model-taxonomy.js');
|
||||||
|
const { Linter } = require('eslint');
|
||||||
|
|
||||||
|
const linter = new Linter();
|
||||||
|
|
||||||
|
// Register the plugin
|
||||||
|
linter.defineRule('gridpilot-rules/view-model-taxonomy', rule);
|
||||||
|
|
||||||
|
// Test 1: DTO import should be caught
|
||||||
|
const codeWithDtoImport = `
|
||||||
|
import type { RecordEngagementOutputDTO } from '@/lib/types/generated/RecordEngagementOutputDTO';
|
||||||
|
|
||||||
|
export class RecordEngagementOutputViewModel {
|
||||||
|
eventId: string;
|
||||||
|
engagementWeight: number;
|
||||||
|
|
||||||
|
constructor(dto: RecordEngagementOutputDTO) {
|
||||||
|
this.eventId = dto.eventId;
|
||||||
|
this.engagementWeight = dto.engagementWeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Test 2: Inline ViewData interface should be caught
|
||||||
|
const codeWithInlineViewData = `
|
||||||
|
export interface RaceViewData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RaceViewModel {
|
||||||
|
private readonly data: RaceViewData;
|
||||||
|
|
||||||
|
constructor(data: RaceViewData) {
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Test 3: Valid code (no violations)
|
||||||
|
const validCode = `
|
||||||
|
import { RaceViewData } from '@/lib/view-data/RaceViewData';
|
||||||
|
|
||||||
|
export class RaceViewModel {
|
||||||
|
private readonly data: RaceViewData;
|
||||||
|
|
||||||
|
constructor(data: RaceViewData) {
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Test 4: Disallowed import from service layer (should be caught)
|
||||||
|
const codeWithServiceImport = `
|
||||||
|
import { SomeService } from '@/lib/services/SomeService';
|
||||||
|
|
||||||
|
export class RaceViewModel {
|
||||||
|
private readonly service: SomeService;
|
||||||
|
|
||||||
|
constructor(service: SomeService) {
|
||||||
|
this.service = service;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Test 5: Strict import violation (import from non-allowed path)
|
||||||
|
const codeWithStrictImportViolation = `
|
||||||
|
import { SomeOtherThing } from '@/lib/other/SomeOtherThing';
|
||||||
|
|
||||||
|
export class RaceViewModel {
|
||||||
|
private readonly thing: SomeOtherThing;
|
||||||
|
|
||||||
|
constructor(thing: SomeOtherThing) {
|
||||||
|
this.thing = thing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log('=== Test 1: DTO import ===');
|
||||||
|
const messages1 = linter.verify(codeWithDtoImport, {
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'gridpilot-rules/view-model-taxonomy': 'error',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('Messages:', messages1);
|
||||||
|
console.log('Expected: Should have 1 error for DTO import');
|
||||||
|
console.log('Actual: ' + messages1.length + ' error(s)');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
console.log('=== Test 2: Inline ViewData interface ===');
|
||||||
|
const messages2 = linter.verify(codeWithInlineViewData, {
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'gridpilot-rules/view-model-taxonomy': 'error',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('Messages:', messages2);
|
||||||
|
console.log('Expected: Should have 1 error for inline ViewData interface');
|
||||||
|
console.log('Actual: ' + messages2.length + ' error(s)');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
console.log('=== Test 3: Valid code ===');
|
||||||
|
const messages3 = linter.verify(validCode, {
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'gridpilot-rules/view-model-taxonomy': 'error',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('Messages:', messages3);
|
||||||
|
console.log('Expected: Should have 0 errors');
|
||||||
|
console.log('Actual: ' + messages3.length + ' error(s)');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
console.log('=== Test 4: Service import (should be caught) ===');
|
||||||
|
const messages4 = linter.verify(codeWithServiceImport, {
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'gridpilot-rules/view-model-taxonomy': 'error',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('Messages:', messages4);
|
||||||
|
console.log('Expected: Should have 1 error for service import');
|
||||||
|
console.log('Actual: ' + messages4.length + ' error(s)');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
console.log('=== Test 5: Strict import violation ===');
|
||||||
|
const messages5 = linter.verify(codeWithStrictImportViolation, {
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'gridpilot-rules/view-model-taxonomy': 'error',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('Messages:', messages5);
|
||||||
|
console.log('Expected: Should have 1 error for strict import violation');
|
||||||
|
console.log('Actual: ' + messages5.length + ' error(s)');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
console.log('=== Summary ===');
|
||||||
|
console.log('Test 1 (DTO import): ' + (messages1.length === 1 ? '✓ PASS' : '✗ FAIL'));
|
||||||
|
console.log('Test 2 (Inline ViewData): ' + (messages2.length === 1 ? '✓ PASS' : '✗ FAIL'));
|
||||||
|
console.log('Test 3 (Valid code): ' + (messages3.length === 0 ? '✓ PASS' : '✗ FAIL'));
|
||||||
|
console.log('Test 4 (Service import): ' + (messages4.length === 1 ? '✓ PASS' : '✗ FAIL'));
|
||||||
|
console.log('Test 5 (Strict import): ' + (messages5.length === 1 ? '✓ PASS' : '✗ FAIL'));
|
||||||
@@ -4,8 +4,9 @@
|
|||||||
* View Data Builders must:
|
* View Data Builders must:
|
||||||
* 1. Be classes named *ViewDataBuilder
|
* 1. Be classes named *ViewDataBuilder
|
||||||
* 2. Have a static build() method
|
* 2. Have a static build() method
|
||||||
* 3. Accept API DTO as parameter (named 'apiDto', NOT 'pageDto')
|
* 3. Use 'satisfies ViewDataBuilder<...>' for static enforcement
|
||||||
* 4. Return View Data
|
* 4. Accept API DTO as parameter (named 'apiDto')
|
||||||
|
* 5. Return View Data
|
||||||
*/
|
*/
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
@@ -20,7 +21,8 @@ module.exports = {
|
|||||||
schema: [],
|
schema: [],
|
||||||
messages: {
|
messages: {
|
||||||
notAClass: 'View Data Builders must be classes named *ViewDataBuilder',
|
notAClass: 'View Data Builders must be classes named *ViewDataBuilder',
|
||||||
missingBuildMethod: 'View Data Builders must have a static build() method',
|
missingStaticBuild: 'View Data Builders must have a static build() method',
|
||||||
|
missingSatisfies: 'View Data Builders must use "satisfies ViewDataBuilder<...>" for static type enforcement',
|
||||||
invalidBuildSignature: 'build() method must accept API DTO and return View Data',
|
invalidBuildSignature: 'build() method must accept API DTO and return View Data',
|
||||||
wrongParameterName: 'Parameter must be named "apiDto", not "pageDto" or other names',
|
wrongParameterName: 'Parameter must be named "apiDto", not "pageDto" or other names',
|
||||||
},
|
},
|
||||||
@@ -32,7 +34,8 @@ module.exports = {
|
|||||||
|
|
||||||
if (!isInViewDataBuilders) return {};
|
if (!isInViewDataBuilders) return {};
|
||||||
|
|
||||||
let hasBuildMethod = false;
|
let hasStaticBuild = false;
|
||||||
|
let hasSatisfies = false;
|
||||||
let hasCorrectSignature = false;
|
let hasCorrectSignature = false;
|
||||||
let hasCorrectParameterName = false;
|
let hasCorrectParameterName = false;
|
||||||
|
|
||||||
@@ -49,28 +52,28 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for static build method
|
// Check for static build method
|
||||||
const buildMethod = node.body.body.find(member =>
|
const staticBuild = node.body.body.find(member =>
|
||||||
member.type === 'MethodDefinition' &&
|
member.type === 'MethodDefinition' &&
|
||||||
member.key.type === 'Identifier' &&
|
member.key.type === 'Identifier' &&
|
||||||
member.key.name === 'build' &&
|
member.key.name === 'build' &&
|
||||||
member.static === true
|
member.static === true
|
||||||
);
|
);
|
||||||
|
|
||||||
if (buildMethod) {
|
if (staticBuild) {
|
||||||
hasBuildMethod = true;
|
hasStaticBuild = true;
|
||||||
|
|
||||||
// Check signature - should have at least one parameter
|
// Check signature - should have at least one parameter
|
||||||
if (buildMethod.value &&
|
if (staticBuild.value &&
|
||||||
buildMethod.value.params &&
|
staticBuild.value.params &&
|
||||||
buildMethod.value.params.length > 0) {
|
staticBuild.value.params.length > 0) {
|
||||||
hasCorrectSignature = true;
|
hasCorrectSignature = true;
|
||||||
|
|
||||||
// Check parameter name
|
// Check parameter name
|
||||||
const firstParam = buildMethod.value.params[0];
|
const firstParam = staticBuild.value.params[0];
|
||||||
if (firstParam.type === 'Identifier' && firstParam.name === 'apiDto') {
|
if (firstParam.type === 'Identifier' && firstParam.name === 'apiDto') {
|
||||||
hasCorrectParameterName = true;
|
hasCorrectParameterName = true;
|
||||||
} else if (firstParam.type === 'Identifier' && firstParam.name === 'pageDto') {
|
} else if (firstParam.type === 'Identifier' && (firstParam.name === 'pageDto' || firstParam.name === 'dto')) {
|
||||||
// Report specific error for pageDto
|
// Report specific error for wrong names
|
||||||
context.report({
|
context.report({
|
||||||
node: firstParam,
|
node: firstParam,
|
||||||
messageId: 'wrongParameterName',
|
messageId: 'wrongParameterName',
|
||||||
@@ -80,23 +83,35 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Check for satisfies expression
|
||||||
|
TSSatisfiesExpression(node) {
|
||||||
|
if (node.typeAnnotation &&
|
||||||
|
node.typeAnnotation.type === 'TSTypeReference' &&
|
||||||
|
node.typeAnnotation.typeName.name === 'ViewDataBuilder') {
|
||||||
|
hasSatisfies = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
'Program:exit'() {
|
'Program:exit'() {
|
||||||
if (!hasBuildMethod) {
|
if (!hasStaticBuild) {
|
||||||
context.report({
|
context.report({
|
||||||
node: context.getSourceCode().ast,
|
node: context.getSourceCode().ast,
|
||||||
messageId: 'missingBuildMethod',
|
messageId: 'missingStaticBuild',
|
||||||
});
|
});
|
||||||
} else if (!hasCorrectSignature) {
|
}
|
||||||
|
|
||||||
|
if (!hasSatisfies) {
|
||||||
|
context.report({
|
||||||
|
node: context.getSourceCode().ast,
|
||||||
|
messageId: 'missingSatisfies',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasStaticBuild && !hasCorrectSignature) {
|
||||||
context.report({
|
context.report({
|
||||||
node: context.getSourceCode().ast,
|
node: context.getSourceCode().ast,
|
||||||
messageId: 'invalidBuildSignature',
|
messageId: 'invalidBuildSignature',
|
||||||
});
|
});
|
||||||
} else if (!hasCorrectParameterName) {
|
|
||||||
// Only report if not already reported for pageDto
|
|
||||||
context.report({
|
|
||||||
node: context.getSourceCode().ast,
|
|
||||||
messageId: 'wrongParameterName',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
70
apps/website/eslint-rules/view-data-builder-implements.js
Normal file
70
apps/website/eslint-rules/view-data-builder-implements.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* ESLint rule to enforce View Data Builder contract implementation
|
||||||
|
*
|
||||||
|
* View Data Builders in lib/builders/view-data/ must:
|
||||||
|
* 1. Be classes named *ViewDataBuilder
|
||||||
|
* 2. Have a static build() method
|
||||||
|
*
|
||||||
|
* Note: 'implements' is deprecated in favor of 'satisfies' checked in view-data-builder-contract.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
meta: {
|
||||||
|
type: 'problem',
|
||||||
|
docs: {
|
||||||
|
description: 'Enforce View Data Builder contract implementation',
|
||||||
|
category: 'Builders',
|
||||||
|
recommended: true,
|
||||||
|
},
|
||||||
|
fixable: null,
|
||||||
|
schema: [],
|
||||||
|
messages: {
|
||||||
|
notAClass: 'View Data Builders must be classes named *ViewDataBuilder',
|
||||||
|
missingBuildMethod: 'View Data Builders must have a static build() method',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
create(context) {
|
||||||
|
const filename = context.getFilename();
|
||||||
|
const isInViewDataBuilders = filename.includes('/lib/builders/view-data/');
|
||||||
|
|
||||||
|
if (!isInViewDataBuilders) return {};
|
||||||
|
|
||||||
|
let hasBuildMethod = false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Check class declaration
|
||||||
|
ClassDeclaration(node) {
|
||||||
|
const className = node.id?.name;
|
||||||
|
|
||||||
|
if (!className || !className.endsWith('ViewDataBuilder')) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'notAClass',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for static build method
|
||||||
|
const buildMethod = node.body.body.find(member =>
|
||||||
|
member.type === 'MethodDefinition' &&
|
||||||
|
member.key.type === 'Identifier' &&
|
||||||
|
member.key.name === 'build' &&
|
||||||
|
member.static === true
|
||||||
|
);
|
||||||
|
|
||||||
|
if (buildMethod) {
|
||||||
|
hasBuildMethod = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
'Program:exit'() {
|
||||||
|
if (!hasBuildMethod) {
|
||||||
|
context.report({
|
||||||
|
node: context.getSourceCode().ast,
|
||||||
|
messageId: 'missingBuildMethod',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
81
apps/website/eslint-rules/view-data-builder-imports.js
Normal file
81
apps/website/eslint-rules/view-data-builder-imports.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* ESLint rule to enforce ViewDataBuilder import paths
|
||||||
|
*
|
||||||
|
* ViewDataBuilders in lib/builders/view-data/ must:
|
||||||
|
* 1. Import DTO types from lib/types/generated/
|
||||||
|
* 2. Import ViewData types from lib/view-data/
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
meta: {
|
||||||
|
type: 'problem',
|
||||||
|
docs: {
|
||||||
|
description: 'Enforce ViewDataBuilder import paths',
|
||||||
|
category: 'Builders',
|
||||||
|
recommended: true,
|
||||||
|
},
|
||||||
|
fixable: null,
|
||||||
|
schema: [],
|
||||||
|
messages: {
|
||||||
|
invalidDtoImport: 'ViewDataBuilders must import DTO types from lib/types/generated/, not from {{importPath}}',
|
||||||
|
invalidViewDataImport: 'ViewDataBuilders must import ViewData types from lib/view-data/, not from {{importPath}}',
|
||||||
|
noViewModelsInBuilders: 'ViewDataBuilders must not import ViewModels. ViewModels are client-only logic wrappers. Builders should only produce plain ViewData.',
|
||||||
|
missingDtoImport: 'ViewDataBuilders must import DTO types from lib/types/generated/',
|
||||||
|
missingViewDataImport: 'ViewDataBuilders must import ViewData types from lib/view-data/',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
create(context) {
|
||||||
|
const filename = context.getFilename();
|
||||||
|
const isInViewDataBuilders = filename.includes('/lib/builders/view-data/');
|
||||||
|
|
||||||
|
if (!isInViewDataBuilders) return {};
|
||||||
|
|
||||||
|
let hasDtoImport = false;
|
||||||
|
let hasViewDataImport = false;
|
||||||
|
let dtoImportPath = null;
|
||||||
|
let viewDataImportPath = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ImportDeclaration(node) {
|
||||||
|
const importPath = node.source.value;
|
||||||
|
|
||||||
|
// Check for DTO imports (should be from lib/types/generated/)
|
||||||
|
if (importPath.includes('/lib/types/') || importPath.includes('@/lib/types/') || importPath.includes('../../types/')) {
|
||||||
|
if (!importPath.includes('/lib/types/generated/') && !importPath.includes('@/lib/types/generated/') && !importPath.includes('../../types/generated/')) {
|
||||||
|
dtoImportPath = importPath;
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'invalidDtoImport',
|
||||||
|
data: { importPath },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
hasDtoImport = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for ViewData imports (should be from lib/view-data/)
|
||||||
|
if (importPath.includes('/lib/view-data/') || importPath.includes('@/lib/view-data/') || importPath.includes('../../view-data/')) {
|
||||||
|
hasViewDataImport = true;
|
||||||
|
viewDataImportPath = importPath;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
'Program:exit'() {
|
||||||
|
if (!hasDtoImport) {
|
||||||
|
context.report({
|
||||||
|
node: context.getSourceCode().ast,
|
||||||
|
messageId: 'missingDtoImport',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasViewDataImport) {
|
||||||
|
context.report({
|
||||||
|
node: context.getSourceCode().ast,
|
||||||
|
messageId: 'missingViewDataImport',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
139
apps/website/eslint-rules/view-data-implements.js
Normal file
139
apps/website/eslint-rules/view-data-implements.js
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* ESLint rule to enforce ViewData contract implementation
|
||||||
|
*
|
||||||
|
* ViewData files in lib/view-data/ must:
|
||||||
|
* 1. Be interfaces or types named *ViewData
|
||||||
|
* 2. Extend the ViewData interface from contracts
|
||||||
|
* 3. NOT contain ViewModels (ViewModels are for ClientWrappers/Hooks)
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
meta: {
|
||||||
|
type: 'problem',
|
||||||
|
docs: {
|
||||||
|
description: 'Enforce ViewData contract implementation',
|
||||||
|
category: 'Contracts',
|
||||||
|
recommended: true,
|
||||||
|
},
|
||||||
|
fixable: null,
|
||||||
|
schema: [],
|
||||||
|
messages: {
|
||||||
|
notAnInterface: 'ViewData files must be interfaces or types named *ViewData',
|
||||||
|
missingExtends: 'ViewData must extend the ViewData interface from lib/contracts/view-data/ViewData.ts',
|
||||||
|
noViewModelsInViewData: 'ViewData must not contain ViewModels. ViewData is for plain JSON data (DTOs) passed through SSR. Use ViewModels in ClientWrappers or Hooks instead.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
create(context) {
|
||||||
|
const filename = context.getFilename();
|
||||||
|
const isInViewData = filename.includes('/lib/view-data/') && !filename.includes('/contracts/');
|
||||||
|
|
||||||
|
if (!isInViewData) return {};
|
||||||
|
|
||||||
|
let hasViewDataExtends = false;
|
||||||
|
let hasCorrectName = false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Check for ViewModel imports
|
||||||
|
ImportDeclaration(node) {
|
||||||
|
if (!isInViewData) return;
|
||||||
|
const importPath = node.source.value;
|
||||||
|
if (importPath.includes('/lib/view-models/')) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'noViewModelsInViewData',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Check interface declarations
|
||||||
|
TSInterfaceDeclaration(node) {
|
||||||
|
const interfaceName = node.id?.name;
|
||||||
|
|
||||||
|
if (interfaceName && interfaceName.endsWith('ViewData')) {
|
||||||
|
hasCorrectName = true;
|
||||||
|
|
||||||
|
// Check for ViewModel usage in properties
|
||||||
|
node.body.body.forEach(member => {
|
||||||
|
if (member.type === 'TSPropertySignature' && member.typeAnnotation) {
|
||||||
|
const typeAnnotation = member.typeAnnotation.typeAnnotation;
|
||||||
|
if (isViewModelType(typeAnnotation)) {
|
||||||
|
context.report({
|
||||||
|
node: member,
|
||||||
|
messageId: 'noViewModelsInViewData',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if it extends ViewData
|
||||||
|
if (node.extends && node.extends.length > 0) {
|
||||||
|
for (const ext of node.extends) {
|
||||||
|
// Use context.getSourceCode().getText(ext) to be absolutely sure
|
||||||
|
const extendsText = context.getSourceCode().getText(ext).trim();
|
||||||
|
// We check for 'ViewData' but must be careful not to match 'SomethingViewData'
|
||||||
|
// unless it's exactly 'ViewData' or part of a qualified name
|
||||||
|
if (extendsText === 'ViewData' ||
|
||||||
|
extendsText.endsWith('.ViewData') ||
|
||||||
|
extendsText.startsWith('ViewData<') ||
|
||||||
|
extendsText.startsWith('ViewData ') ||
|
||||||
|
/\bViewData\b/.test(extendsText)) { // Use regex for word boundary
|
||||||
|
hasViewDataExtends = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Check type alias declarations
|
||||||
|
TSTypeAliasDeclaration(node) {
|
||||||
|
const typeName = node.id?.name;
|
||||||
|
|
||||||
|
if (typeName && typeName.endsWith('ViewData')) {
|
||||||
|
hasCorrectName = true;
|
||||||
|
|
||||||
|
// For type aliases, check if it's an intersection with ViewData
|
||||||
|
if (node.typeAnnotation && node.typeAnnotation.type === 'TSIntersectionType') {
|
||||||
|
for (const type of node.typeAnnotation.types) {
|
||||||
|
if (type.type === 'TSTypeReference' &&
|
||||||
|
type.typeName &&
|
||||||
|
type.typeName.type === 'Identifier' &&
|
||||||
|
type.typeName.name === 'ViewData') {
|
||||||
|
hasViewDataExtends = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
'Program:exit'() {
|
||||||
|
// Only report if we are in a file that should be a ViewData
|
||||||
|
// and we didn't find a valid declaration
|
||||||
|
const baseName = filename.split('/').pop();
|
||||||
|
|
||||||
|
// All files in lib/view-data/ must end with ViewData.ts
|
||||||
|
if (baseName && !baseName.endsWith('ViewData.ts') && !baseName.endsWith('ViewData.tsx')) {
|
||||||
|
context.report({
|
||||||
|
node: context.getSourceCode().ast,
|
||||||
|
messageId: 'notAnInterface',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseName && (baseName.endsWith('ViewData.ts') || baseName.endsWith('ViewData.tsx'))) {
|
||||||
|
if (!hasCorrectName) {
|
||||||
|
context.report({
|
||||||
|
node: context.getSourceCode().ast,
|
||||||
|
messageId: 'notAnInterface',
|
||||||
|
});
|
||||||
|
} else if (!hasViewDataExtends) {
|
||||||
|
context.report({
|
||||||
|
node: context.getSourceCode().ast,
|
||||||
|
messageId: 'missingExtends',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
/**
|
|
||||||
* ESLint rule to enforce View Model Builder contract
|
|
||||||
*
|
|
||||||
* View Model Builders must:
|
|
||||||
* 1. Be classes named *ViewModelBuilder
|
|
||||||
* 2. Have a static build() method
|
|
||||||
* 3. Accept View Data as parameter
|
|
||||||
* 4. Return View Model
|
|
||||||
*/
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
meta: {
|
|
||||||
type: 'problem',
|
|
||||||
docs: {
|
|
||||||
description: 'Enforce View Model Builder contract',
|
|
||||||
category: 'Builders',
|
|
||||||
recommended: true,
|
|
||||||
},
|
|
||||||
fixable: null,
|
|
||||||
schema: [],
|
|
||||||
messages: {
|
|
||||||
notAClass: 'View Model Builders must be classes named *ViewModelBuilder',
|
|
||||||
missingBuildMethod: 'View Model Builders must have a static build() method',
|
|
||||||
invalidBuildSignature: 'build() method must accept View Data and return View Model',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
create(context) {
|
|
||||||
const filename = context.getFilename();
|
|
||||||
const isInViewModelBuilders = filename.includes('/lib/builders/view-models/');
|
|
||||||
|
|
||||||
if (!isInViewModelBuilders) return {};
|
|
||||||
|
|
||||||
let hasBuildMethod = false;
|
|
||||||
let hasCorrectSignature = false;
|
|
||||||
|
|
||||||
return {
|
|
||||||
// Check class declaration
|
|
||||||
ClassDeclaration(node) {
|
|
||||||
const className = node.id?.name;
|
|
||||||
|
|
||||||
if (!className || !className.endsWith('ViewModelBuilder')) {
|
|
||||||
context.report({
|
|
||||||
node,
|
|
||||||
messageId: 'notAClass',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for static build method
|
|
||||||
const buildMethod = node.body.body.find(member =>
|
|
||||||
member.type === 'MethodDefinition' &&
|
|
||||||
member.key.type === 'Identifier' &&
|
|
||||||
member.key.name === 'build' &&
|
|
||||||
member.static === true
|
|
||||||
);
|
|
||||||
|
|
||||||
if (buildMethod) {
|
|
||||||
hasBuildMethod = true;
|
|
||||||
|
|
||||||
// Check signature - should have at least one parameter
|
|
||||||
if (buildMethod.value &&
|
|
||||||
buildMethod.value.params &&
|
|
||||||
buildMethod.value.params.length > 0) {
|
|
||||||
hasCorrectSignature = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
'Program:exit'() {
|
|
||||||
if (!hasBuildMethod) {
|
|
||||||
context.report({
|
|
||||||
node: context.getSourceCode().ast,
|
|
||||||
messageId: 'missingBuildMethod',
|
|
||||||
});
|
|
||||||
} else if (!hasCorrectSignature) {
|
|
||||||
context.report({
|
|
||||||
node: context.getSourceCode().ast,
|
|
||||||
messageId: 'invalidBuildSignature',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
65
apps/website/eslint-rules/view-model-implements.js
Normal file
65
apps/website/eslint-rules/view-model-implements.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* ESLint rule to enforce ViewModel contract implementation
|
||||||
|
*
|
||||||
|
* ViewModel files in lib/view-models/ must:
|
||||||
|
* 1. Be classes named *ViewModel
|
||||||
|
* 2. Extend the ViewModel class from contracts
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
meta: {
|
||||||
|
type: 'problem',
|
||||||
|
docs: {
|
||||||
|
description: 'Enforce ViewModel contract implementation',
|
||||||
|
category: 'Contracts',
|
||||||
|
recommended: true,
|
||||||
|
},
|
||||||
|
fixable: null,
|
||||||
|
schema: [],
|
||||||
|
messages: {
|
||||||
|
notAClass: 'ViewModel files must be classes named *ViewModel',
|
||||||
|
missingExtends: 'ViewModel must extend the ViewModel class from lib/contracts/view-models/ViewModel.ts',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
create(context) {
|
||||||
|
const filename = context.getFilename();
|
||||||
|
const isInViewModels = filename.includes('/lib/view-models/');
|
||||||
|
|
||||||
|
if (!isInViewModels) return {};
|
||||||
|
|
||||||
|
let hasViewModelExtends = false;
|
||||||
|
let hasCorrectName = false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Check class declarations
|
||||||
|
ClassDeclaration(node) {
|
||||||
|
const className = node.id?.name;
|
||||||
|
|
||||||
|
if (className && className.endsWith('ViewModel')) {
|
||||||
|
hasCorrectName = true;
|
||||||
|
|
||||||
|
// Check if it extends ViewModel
|
||||||
|
if (node.superClass && node.superClass.type === 'Identifier' &&
|
||||||
|
node.superClass.name === 'ViewModel') {
|
||||||
|
hasViewModelExtends = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
'Program:exit'() {
|
||||||
|
if (!hasCorrectName) {
|
||||||
|
context.report({
|
||||||
|
node: context.getSourceCode().ast,
|
||||||
|
messageId: 'notAClass',
|
||||||
|
});
|
||||||
|
} else if (!hasViewModelExtends) {
|
||||||
|
context.report({
|
||||||
|
node: context.getSourceCode().ast,
|
||||||
|
messageId: 'missingExtends',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
106
apps/website/eslint-rules/view-model-taxonomy.js
Normal file
106
apps/website/eslint-rules/view-model-taxonomy.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* ESLint rule to enforce ViewModel and Builder architectural boundaries
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* 1. ViewModels/Builders MUST NOT contain the word "DTO" in identifiers
|
||||||
|
* 2. ViewModels/Builders MUST NOT define inline DTO interfaces
|
||||||
|
* 3. ViewModels/Builders MUST NOT import from DTO paths (except generated types in Builders)
|
||||||
|
* 4. ViewModels MUST NOT define ViewData interfaces
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
meta: {
|
||||||
|
type: 'problem',
|
||||||
|
docs: {
|
||||||
|
description: 'Enforce ViewModel and Builder architectural boundaries',
|
||||||
|
category: 'Architecture',
|
||||||
|
recommended: true,
|
||||||
|
},
|
||||||
|
fixable: null,
|
||||||
|
schema: [],
|
||||||
|
messages: {
|
||||||
|
noDtoInViewModel: 'ViewModels and Builders must not use the word "DTO" in identifiers. DTOs belong to the API/Service layer. Use plain properties or ViewData types.',
|
||||||
|
noDtoImport: 'ViewModels must not import from DTO paths. DTOs belong to lib/types/generated/. Import from lib/view-data/ or use plain properties instead.',
|
||||||
|
noViewDataDefinition: 'ViewData must not be defined within ViewModel files. Import them from lib/view-data/ instead.',
|
||||||
|
noInlineDtoDefinition: 'DTOs must not be defined inline. Use generated types from lib/types/generated/ and import them.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
create(context) {
|
||||||
|
const filename = context.getFilename();
|
||||||
|
const isInViewModels = filename.includes('/lib/view-models/');
|
||||||
|
const isInBuilders = filename.includes('/lib/builders/');
|
||||||
|
|
||||||
|
if (!isInViewModels && !isInBuilders) return {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Check for "DTO" in any identifier
|
||||||
|
Identifier(node) {
|
||||||
|
const name = node.name.toUpperCase();
|
||||||
|
if (name === 'DTO' || name.endsWith('DTO')) {
|
||||||
|
// Exception: Allow DTO in type references in Builders (for satisfies/input)
|
||||||
|
if (isInBuilders && (node.parent.type === 'TSTypeReference' || node.parent.type === 'TSQualifiedName')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'noDtoInViewModel',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Check for imports from DTO paths
|
||||||
|
ImportDeclaration(node) {
|
||||||
|
const importPath = node.source.value;
|
||||||
|
|
||||||
|
// ViewModels are never allowed to import DTOs
|
||||||
|
if (isInViewModels && (
|
||||||
|
importPath.includes('/lib/types/generated/') ||
|
||||||
|
importPath.includes('/lib/dtos/') ||
|
||||||
|
importPath.includes('/lib/api/') ||
|
||||||
|
importPath.includes('/lib/services/')
|
||||||
|
)) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'noDtoImport',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Check for ViewData definitions in ViewModels
|
||||||
|
TSInterfaceDeclaration(node) {
|
||||||
|
if (isInViewModels && node.id && node.id.name && node.id.name.endsWith('ViewData')) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'noViewDataDefinition',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for inline DTO definitions in both ViewModels and Builders
|
||||||
|
if (node.id && node.id.name && node.id.name.toUpperCase().includes('DTO')) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'noInlineDtoDefinition',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
TSTypeAliasDeclaration(node) {
|
||||||
|
if (isInViewModels && node.id && node.id.name && node.id.name.endsWith('ViewData')) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'noViewDataDefinition',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for inline DTO definitions
|
||||||
|
if (node.id && node.id.name && node.id.name.toUpperCase().includes('DTO')) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'noInlineDtoDefinition',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { useInject } from '@/lib/di/hooks/useInject';
|
import { useInject } from '@/lib/di/hooks/useInject';
|
||||||
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
|
|
||||||
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
|
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
|
||||||
import { LeagueScheduleViewModel, LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
|
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||||
|
import { DateFormatter } from '@/lib/formatters/DateFormatter';
|
||||||
import type { RaceDTO } from '@/lib/types/generated/RaceDTO';
|
import type { RaceDTO } from '@/lib/types/generated/RaceDTO';
|
||||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
import { LeagueScheduleRaceViewModel, LeagueScheduleViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
function mapRaceDtoToViewModel(race: RaceDTO): LeagueScheduleRaceViewModel {
|
function mapRaceDtoToViewModel(race: RaceDTO): LeagueScheduleRaceViewModel {
|
||||||
const scheduledAt = race.date ? new Date(race.date) : new Date(0);
|
const scheduledAt = race.date ? new Date(race.date) : new Date(0);
|
||||||
@@ -15,8 +15,8 @@ function mapRaceDtoToViewModel(race: RaceDTO): LeagueScheduleRaceViewModel {
|
|||||||
id: race.id,
|
id: race.id,
|
||||||
name: race.name,
|
name: race.name,
|
||||||
scheduledAt,
|
scheduledAt,
|
||||||
formattedDate: DateDisplay.formatShort(scheduledAt),
|
formattedDate: DateFormatter.formatShort(scheduledAt),
|
||||||
formattedTime: DateDisplay.formatTime(scheduledAt),
|
formattedTime: DateFormatter.formatTime(scheduledAt),
|
||||||
isPast,
|
isPast,
|
||||||
isUpcoming: !isPast,
|
isUpcoming: !isPast,
|
||||||
status: isPast ? 'completed' : 'scheduled',
|
status: isPast ? 'completed' : 'scheduled',
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { usePageData } from '@/lib/page/usePageData';
|
|
||||||
import { useInject } from '@/lib/di/hooks/useInject';
|
import { useInject } from '@/lib/di/hooks/useInject';
|
||||||
import { LEAGUE_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
|
import { LEAGUE_MEMBERSHIP_SERVICE_TOKEN, LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||||
|
import { DateFormatter } from '@/lib/formatters/DateFormatter';
|
||||||
|
import { usePageData } from '@/lib/page/usePageData';
|
||||||
|
import type { LeagueSeasonSummaryDTO } from '@/lib/types/generated/LeagueSeasonSummaryDTO';
|
||||||
|
import type { RaceDTO } from '@/lib/types/generated/RaceDTO';
|
||||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||||
import { LeagueAdminScheduleViewModel } from '@/lib/view-models/LeagueAdminScheduleViewModel';
|
import { LeagueAdminScheduleViewModel } from '@/lib/view-models/LeagueAdminScheduleViewModel';
|
||||||
import { LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
|
import { LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
|
||||||
import { LeagueSeasonSummaryViewModel } from '@/lib/view-models/LeagueSeasonSummaryViewModel';
|
import { LeagueSeasonSummaryViewModel } from '@/lib/view-models/LeagueSeasonSummaryViewModel';
|
||||||
import type { RaceDTO } from '@/lib/types/generated/RaceDTO';
|
|
||||||
import type { LeagueSeasonSummaryDTO } from '@/lib/types/generated/LeagueSeasonSummaryDTO';
|
|
||||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
|
||||||
|
|
||||||
function mapRaceDtoToViewModel(race: RaceDTO): LeagueScheduleRaceViewModel {
|
function mapRaceDtoToViewModel(race: RaceDTO): LeagueScheduleRaceViewModel {
|
||||||
const scheduledAt = race.date ? new Date(race.date) : new Date(0);
|
const scheduledAt = race.date ? new Date(race.date) : new Date(0);
|
||||||
@@ -18,8 +18,8 @@ function mapRaceDtoToViewModel(race: RaceDTO): LeagueScheduleRaceViewModel {
|
|||||||
id: race.id,
|
id: race.id,
|
||||||
name: race.name,
|
name: race.name,
|
||||||
scheduledAt,
|
scheduledAt,
|
||||||
formattedDate: DateDisplay.formatShort(scheduledAt),
|
formattedDate: DateFormatter.formatShort(scheduledAt),
|
||||||
formattedTime: DateDisplay.formatTime(scheduledAt),
|
formattedTime: DateFormatter.formatTime(scheduledAt),
|
||||||
isPast,
|
isPast,
|
||||||
isUpcoming: !isPast,
|
isUpcoming: !isPast,
|
||||||
status: isPast ? 'completed' : 'scheduled',
|
status: isPast ? 'completed' : 'scheduled',
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { DashboardStatsResponseDto } from '../../types/generated/DashboardStatsResponseDTO';
|
||||||
|
import type { AdminDashboardViewData } from '../../view-data/AdminDashboardViewData';
|
||||||
|
import { AdminDashboardViewDataBuilder } from './AdminDashboardViewDataBuilder';
|
||||||
|
|
||||||
|
describe('AdminDashboardViewDataBuilder', () => {
|
||||||
|
it('should transform DashboardStatsResponseDto to AdminDashboardViewData correctly', () => {
|
||||||
|
const apiDto: DashboardStatsResponseDto = {
|
||||||
|
totalUsers: 1000,
|
||||||
|
activeUsers: 800,
|
||||||
|
suspendedUsers: 50,
|
||||||
|
deletedUsers: 150,
|
||||||
|
systemAdmins: 5,
|
||||||
|
recentLogins: 200,
|
||||||
|
newUsersToday: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result: AdminDashboardViewData = AdminDashboardViewDataBuilder.build(apiDto);
|
||||||
|
|
||||||
|
expect(result.stats).toEqual({
|
||||||
|
totalUsers: 1000,
|
||||||
|
activeUsers: 800,
|
||||||
|
suspendedUsers: 50,
|
||||||
|
deletedUsers: 150,
|
||||||
|
systemAdmins: 5,
|
||||||
|
recentLogins: 200,
|
||||||
|
newUsersToday: 10,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not modify the input DTO', () => {
|
||||||
|
const apiDto: DashboardStatsResponseDto = {
|
||||||
|
totalUsers: 1000,
|
||||||
|
activeUsers: 800,
|
||||||
|
suspendedUsers: 50,
|
||||||
|
deletedUsers: 150,
|
||||||
|
systemAdmins: 5,
|
||||||
|
recentLogins: 200,
|
||||||
|
newUsersToday: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalDto = { ...apiDto };
|
||||||
|
AdminDashboardViewDataBuilder.build(apiDto);
|
||||||
|
|
||||||
|
expect(apiDto).toEqual(originalDto);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import type { DashboardStats } from '@/lib/types/admin';
|
import type { ViewDataBuilder } from '../../contracts/builders/ViewDataBuilder';
|
||||||
import type { AdminDashboardViewData } from '@/lib/view-data/AdminDashboardViewData';
|
import type { DashboardStatsResponseDto } from '../../types/generated/DashboardStatsResponseDTO';
|
||||||
|
import type { AdminDashboardViewData } from '../../view-data/AdminDashboardViewData';
|
||||||
|
|
||||||
/**
|
|
||||||
* AdminDashboardViewDataBuilder
|
|
||||||
*
|
|
||||||
* Transforms DashboardStats API DTO into AdminDashboardViewData for server-side rendering.
|
|
||||||
* Deterministic; side-effect free; no HTTP calls.
|
|
||||||
*/
|
|
||||||
export class AdminDashboardViewDataBuilder {
|
export class AdminDashboardViewDataBuilder {
|
||||||
static build(apiDto: DashboardStats): AdminDashboardViewData {
|
/**
|
||||||
|
* Transform API DTO to ViewData
|
||||||
|
*
|
||||||
|
* @param apiDto - The DTO from the service
|
||||||
|
* @returns ViewData for the admin dashboard
|
||||||
|
*/
|
||||||
|
public static build(apiDto: DashboardStatsResponseDto): AdminDashboardViewData {
|
||||||
return {
|
return {
|
||||||
stats: {
|
stats: {
|
||||||
totalUsers: apiDto.totalUsers,
|
totalUsers: apiDto.totalUsers,
|
||||||
@@ -21,4 +22,6 @@ export class AdminDashboardViewDataBuilder {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AdminDashboardViewDataBuilder satisfies ViewDataBuilder<DashboardStatsResponseDto, AdminDashboardViewData>;
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { AdminUsersViewDataBuilder } from './AdminUsersViewDataBuilder';
|
||||||
|
import type { UserListResponseDTO } from '@/lib/types/generated/UserListResponseDTO';
|
||||||
|
|
||||||
|
describe('AdminUsersViewDataBuilder', () => {
|
||||||
|
describe('happy paths', () => {
|
||||||
|
it('should transform UserListResponseDTO to AdminUsersViewData correctly', () => {
|
||||||
|
const userListResponse: UserListResponseDTO = {
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
id: 'user-1',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
displayName: 'Admin User',
|
||||||
|
roles: ['admin', 'owner'],
|
||||||
|
status: 'active',
|
||||||
|
isSystemAdmin: true,
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-15T12:00:00.000Z',
|
||||||
|
lastLoginAt: '2024-01-20T10:00:00.000Z',
|
||||||
|
primaryDriverId: 'driver-123',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'user-2',
|
||||||
|
email: 'user@example.com',
|
||||||
|
displayName: 'Regular User',
|
||||||
|
roles: ['member'],
|
||||||
|
status: 'active',
|
||||||
|
isSystemAdmin: false,
|
||||||
|
createdAt: '2024-01-05T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-10T08:00:00.000Z',
|
||||||
|
lastLoginAt: '2024-01-18T14:00:00.000Z',
|
||||||
|
primaryDriverId: 'driver-456',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
page: 1,
|
||||||
|
limit: 10,
|
||||||
|
totalPages: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = AdminUsersViewDataBuilder.build(userListResponse);
|
||||||
|
|
||||||
|
expect(result.users).toHaveLength(2);
|
||||||
|
expect(result.users[0]).toEqual({
|
||||||
|
id: 'user-1',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
displayName: 'Admin User',
|
||||||
|
roles: ['admin', 'owner'],
|
||||||
|
status: 'active',
|
||||||
|
isSystemAdmin: true,
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-15T12:00:00.000Z',
|
||||||
|
lastLoginAt: '2024-01-20T10:00:00.000Z',
|
||||||
|
primaryDriverId: 'driver-123',
|
||||||
|
});
|
||||||
|
expect(result.total).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate derived fields correctly', () => {
|
||||||
|
const userListResponse: UserListResponseDTO = {
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
id: 'user-1',
|
||||||
|
email: 'user1@example.com',
|
||||||
|
displayName: 'User 1',
|
||||||
|
roles: ['member'],
|
||||||
|
status: 'active',
|
||||||
|
isSystemAdmin: false,
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-15T12:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'user-2',
|
||||||
|
email: 'user2@example.com',
|
||||||
|
displayName: 'User 2',
|
||||||
|
roles: ['member'],
|
||||||
|
status: 'active',
|
||||||
|
isSystemAdmin: false,
|
||||||
|
createdAt: '2024-01-02T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-16T12:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'user-3',
|
||||||
|
email: 'user3@example.com',
|
||||||
|
displayName: 'User 3',
|
||||||
|
roles: ['admin'],
|
||||||
|
status: 'suspended',
|
||||||
|
isSystemAdmin: true,
|
||||||
|
createdAt: '2024-01-03T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-17T12:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 3,
|
||||||
|
page: 1,
|
||||||
|
limit: 10,
|
||||||
|
totalPages: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = AdminUsersViewDataBuilder.build(userListResponse);
|
||||||
|
|
||||||
|
expect(result.activeUserCount).toBe(2);
|
||||||
|
expect(result.adminCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,27 +1,22 @@
|
|||||||
import type { UserListResponse } from '@/lib/types/admin';
|
|
||||||
import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AdminUsersViewDataBuilder
|
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
|
||||||
*
|
import type { UserListResponseDTO } from '@/lib/types/generated/UserListResponseDTO';
|
||||||
* Server-side builder that transforms API DTO
|
import type { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData';
|
||||||
* into ViewData for the AdminUsersTemplate.
|
|
||||||
*
|
|
||||||
* Deterministic, side-effect free.
|
|
||||||
*/
|
|
||||||
export class AdminUsersViewDataBuilder {
|
export class AdminUsersViewDataBuilder {
|
||||||
static build(apiDto: UserListResponse): AdminUsersViewData {
|
public static build(apiDto: UserListResponseDTO): AdminUsersViewData {
|
||||||
const users = apiDto.users.map(user => ({
|
const users = apiDto.users.map(u => ({
|
||||||
id: user.id,
|
id: u.id,
|
||||||
email: user.email,
|
email: u.email,
|
||||||
displayName: user.displayName,
|
displayName: u.displayName,
|
||||||
roles: user.roles,
|
roles: u.roles,
|
||||||
status: user.status,
|
status: u.status,
|
||||||
isSystemAdmin: user.isSystemAdmin,
|
isSystemAdmin: u.isSystemAdmin,
|
||||||
createdAt: typeof user.createdAt === 'string' ? user.createdAt : (user.createdAt as unknown as Date).toISOString(),
|
createdAt: u.createdAt,
|
||||||
updatedAt: typeof user.updatedAt === 'string' ? user.updatedAt : (user.updatedAt as unknown as Date).toISOString(),
|
updatedAt: u.updatedAt,
|
||||||
lastLoginAt: user.lastLoginAt ? (typeof user.lastLoginAt === 'string' ? user.lastLoginAt : (user.lastLoginAt as unknown as Date).toISOString()) : undefined,
|
lastLoginAt: u.lastLoginAt,
|
||||||
primaryDriverId: user.primaryDriverId,
|
primaryDriverId: u.primaryDriverId,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -30,9 +25,10 @@ export class AdminUsersViewDataBuilder {
|
|||||||
page: apiDto.page,
|
page: apiDto.page,
|
||||||
limit: apiDto.limit,
|
limit: apiDto.limit,
|
||||||
totalPages: apiDto.totalPages,
|
totalPages: apiDto.totalPages,
|
||||||
// Pre-computed derived values for template
|
|
||||||
activeUserCount: users.filter(u => u.status === 'active').length,
|
activeUserCount: users.filter(u => u.status === 'active').length,
|
||||||
adminCount: users.filter(u => u.isSystemAdmin).length,
|
adminCount: users.filter(u => u.isSystemAdmin).length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AdminUsersViewDataBuilder satisfies ViewDataBuilder<UserListResponseDTO, AdminUsersViewData>;
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { AnalyticsDashboardViewDataBuilder } from './AnalyticsDashboardViewDataBuilder';
|
||||||
|
import type { GetDashboardDataOutputDTO } from '@/lib/types/generated/GetDashboardDataOutputDTO';
|
||||||
|
|
||||||
|
describe('AnalyticsDashboardViewDataBuilder', () => {
|
||||||
|
it('builds ViewData from GetDashboardDataOutputDTO', () => {
|
||||||
|
const inputDto: GetDashboardDataOutputDTO = {
|
||||||
|
totalUsers: 100,
|
||||||
|
activeUsers: 40,
|
||||||
|
totalRaces: 10,
|
||||||
|
totalLeagues: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewData = AnalyticsDashboardViewDataBuilder.build(inputDto);
|
||||||
|
|
||||||
|
expect(viewData.metrics.totalUsers).toBe(100);
|
||||||
|
expect(viewData.metrics.activeUsers).toBe(40);
|
||||||
|
expect(viewData.metrics.totalRaces).toBe(10);
|
||||||
|
expect(viewData.metrics.totalLeagues).toBe(5);
|
||||||
|
expect(viewData.metrics.userEngagementRate).toBeCloseTo(40);
|
||||||
|
expect(viewData.metrics.formattedEngagementRate).toBe('40.0%');
|
||||||
|
expect(viewData.metrics.activityLevel).toBe('Low');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes engagement rate and formatted engagement rate', () => {
|
||||||
|
const inputDto: GetDashboardDataOutputDTO = {
|
||||||
|
totalUsers: 200,
|
||||||
|
activeUsers: 50,
|
||||||
|
totalRaces: 0,
|
||||||
|
totalLeagues: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewData = AnalyticsDashboardViewDataBuilder.build(inputDto);
|
||||||
|
|
||||||
|
expect(viewData.metrics.userEngagementRate).toBeCloseTo(25);
|
||||||
|
expect(viewData.metrics.formattedEngagementRate).toBe('25.0%');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles zero users safely', () => {
|
||||||
|
const inputDto: GetDashboardDataOutputDTO = {
|
||||||
|
totalUsers: 0,
|
||||||
|
activeUsers: 0,
|
||||||
|
totalRaces: 0,
|
||||||
|
totalLeagues: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewData = AnalyticsDashboardViewDataBuilder.build(inputDto);
|
||||||
|
|
||||||
|
expect(viewData.metrics.userEngagementRate).toBe(0);
|
||||||
|
expect(viewData.metrics.formattedEngagementRate).toBe('0.0%');
|
||||||
|
expect(viewData.metrics.activityLevel).toBe('Low');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives activity level buckets from engagement rate', () => {
|
||||||
|
const low = AnalyticsDashboardViewDataBuilder.build({
|
||||||
|
totalUsers: 100,
|
||||||
|
activeUsers: 30,
|
||||||
|
totalRaces: 0,
|
||||||
|
totalLeagues: 0,
|
||||||
|
});
|
||||||
|
const medium = AnalyticsDashboardViewDataBuilder.build({
|
||||||
|
totalUsers: 100,
|
||||||
|
activeUsers: 50,
|
||||||
|
totalRaces: 0,
|
||||||
|
totalLeagues: 0,
|
||||||
|
});
|
||||||
|
const high = AnalyticsDashboardViewDataBuilder.build({
|
||||||
|
totalUsers: 100,
|
||||||
|
activeUsers: 90,
|
||||||
|
totalRaces: 0,
|
||||||
|
totalLeagues: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(low.metrics.activityLevel).toBe('Low');
|
||||||
|
expect(medium.metrics.activityLevel).toBe('Medium');
|
||||||
|
expect(high.metrics.activityLevel).toBe('High');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
|
||||||
|
|
||||||
|
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
|
||||||
|
import type { GetDashboardDataOutputDTO } from '@/lib/types/generated/GetDashboardDataOutputDTO';
|
||||||
|
import type { AnalyticsDashboardViewData } from '@/lib/view-data/AnalyticsDashboardViewData';
|
||||||
|
|
||||||
|
export class AnalyticsDashboardViewDataBuilder {
|
||||||
|
public static build(apiDto: GetDashboardDataOutputDTO): AnalyticsDashboardViewData {
|
||||||
|
const totalUsers = apiDto.totalUsers;
|
||||||
|
const activeUsers = apiDto.activeUsers;
|
||||||
|
const totalRaces = apiDto.totalRaces;
|
||||||
|
const totalLeagues = apiDto.totalLeagues;
|
||||||
|
|
||||||
|
const userEngagementRate = totalUsers > 0 ? (activeUsers / totalUsers) * 100 : 0;
|
||||||
|
const formattedEngagementRate = `${userEngagementRate.toFixed(1)}%`;
|
||||||
|
const activityLevel = userEngagementRate > 70 ? 'High' : userEngagementRate > 40 ? 'Medium' : 'Low';
|
||||||
|
|
||||||
|
return {
|
||||||
|
metrics: {
|
||||||
|
totalUsers,
|
||||||
|
activeUsers,
|
||||||
|
totalRaces,
|
||||||
|
totalLeagues,
|
||||||
|
userEngagementRate,
|
||||||
|
formattedEngagementRate,
|
||||||
|
activityLevel,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnalyticsDashboardViewDataBuilder satisfies ViewDataBuilder<GetDashboardDataOutputDTO, AnalyticsDashboardViewData>;
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { LoginViewDataBuilder } from './LoginViewDataBuilder';
|
||||||
|
import { SignupViewDataBuilder } from './SignupViewDataBuilder';
|
||||||
|
import { ForgotPasswordViewDataBuilder } from './ForgotPasswordViewDataBuilder';
|
||||||
|
import { ResetPasswordViewDataBuilder } from './ResetPasswordViewDataBuilder';
|
||||||
|
import type { LoginPageDTO } from '@/lib/services/auth/types/LoginPageDTO';
|
||||||
|
import type { SignupPageDTO } from '@/lib/services/auth/types/SignupPageDTO';
|
||||||
|
import type { ForgotPasswordPageDTO } from '@/lib/services/auth/types/ForgotPasswordPageDTO';
|
||||||
|
import type { ResetPasswordPageDTO } from '@/lib/services/auth/types/ResetPasswordPageDTO';
|
||||||
|
|
||||||
|
describe('Auth View Data - Cross-Builder Consistency', () => {
|
||||||
|
describe('common patterns', () => {
|
||||||
|
it('should all initialize with isSubmitting false', () => {
|
||||||
|
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
|
||||||
|
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
|
||||||
|
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
|
||||||
|
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
|
||||||
|
|
||||||
|
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||||
|
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||||
|
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||||
|
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||||
|
|
||||||
|
expect(loginResult.isSubmitting).toBe(false);
|
||||||
|
expect(signupResult.isSubmitting).toBe(false);
|
||||||
|
expect(forgotPasswordResult.isSubmitting).toBe(false);
|
||||||
|
expect(resetPasswordResult.isSubmitting).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should all initialize with submitError undefined', () => {
|
||||||
|
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
|
||||||
|
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
|
||||||
|
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
|
||||||
|
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
|
||||||
|
|
||||||
|
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||||
|
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||||
|
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||||
|
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||||
|
|
||||||
|
expect(loginResult.submitError).toBeUndefined();
|
||||||
|
expect(signupResult.submitError).toBeUndefined();
|
||||||
|
expect(forgotPasswordResult.submitError).toBeUndefined();
|
||||||
|
expect(resetPasswordResult.submitError).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should all initialize formState.isValid as true', () => {
|
||||||
|
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
|
||||||
|
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
|
||||||
|
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
|
||||||
|
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
|
||||||
|
|
||||||
|
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||||
|
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||||
|
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||||
|
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||||
|
|
||||||
|
expect(loginResult.formState.isValid).toBe(true);
|
||||||
|
expect(signupResult.formState.isValid).toBe(true);
|
||||||
|
expect(forgotPasswordResult.formState.isValid).toBe(true);
|
||||||
|
expect(resetPasswordResult.formState.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should all initialize formState.isSubmitting as false', () => {
|
||||||
|
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
|
||||||
|
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
|
||||||
|
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
|
||||||
|
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
|
||||||
|
|
||||||
|
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||||
|
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||||
|
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||||
|
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||||
|
|
||||||
|
expect(loginResult.formState.isSubmitting).toBe(false);
|
||||||
|
expect(signupResult.formState.isSubmitting).toBe(false);
|
||||||
|
expect(forgotPasswordResult.formState.isSubmitting).toBe(false);
|
||||||
|
expect(resetPasswordResult.formState.isSubmitting).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should all initialize formState.submitError as undefined', () => {
|
||||||
|
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
|
||||||
|
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
|
||||||
|
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
|
||||||
|
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
|
||||||
|
|
||||||
|
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||||
|
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||||
|
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||||
|
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||||
|
|
||||||
|
expect(loginResult.formState.submitError).toBeUndefined();
|
||||||
|
expect(signupResult.formState.submitError).toBeUndefined();
|
||||||
|
expect(forgotPasswordResult.formState.submitError).toBeUndefined();
|
||||||
|
expect(resetPasswordResult.formState.submitError).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should all initialize formState.submitCount as 0', () => {
|
||||||
|
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
|
||||||
|
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
|
||||||
|
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
|
||||||
|
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
|
||||||
|
|
||||||
|
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||||
|
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||||
|
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||||
|
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||||
|
|
||||||
|
expect(loginResult.formState.submitCount).toBe(0);
|
||||||
|
expect(signupResult.formState.submitCount).toBe(0);
|
||||||
|
expect(forgotPasswordResult.formState.submitCount).toBe(0);
|
||||||
|
expect(resetPasswordResult.formState.submitCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should all initialize form fields with touched false', () => {
|
||||||
|
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
|
||||||
|
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
|
||||||
|
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
|
||||||
|
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
|
||||||
|
|
||||||
|
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||||
|
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||||
|
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||||
|
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||||
|
|
||||||
|
expect(loginResult.formState.fields.email.touched).toBe(false);
|
||||||
|
expect(loginResult.formState.fields.password.touched).toBe(false);
|
||||||
|
expect(loginResult.formState.fields.rememberMe.touched).toBe(false);
|
||||||
|
|
||||||
|
expect(signupResult.formState.fields.firstName.touched).toBe(false);
|
||||||
|
expect(signupResult.formState.fields.lastName.touched).toBe(false);
|
||||||
|
expect(signupResult.formState.fields.email.touched).toBe(false);
|
||||||
|
expect(signupResult.formState.fields.password.touched).toBe(false);
|
||||||
|
expect(signupResult.formState.fields.confirmPassword.touched).toBe(false);
|
||||||
|
|
||||||
|
expect(forgotPasswordResult.formState.fields.email.touched).toBe(false);
|
||||||
|
|
||||||
|
expect(resetPasswordResult.formState.fields.newPassword.touched).toBe(false);
|
||||||
|
expect(resetPasswordResult.formState.fields.confirmPassword.touched).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should all initialize form fields with validating false', () => {
|
||||||
|
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
|
||||||
|
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
|
||||||
|
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
|
||||||
|
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
|
||||||
|
|
||||||
|
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||||
|
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||||
|
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||||
|
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||||
|
|
||||||
|
expect(loginResult.formState.fields.email.validating).toBe(false);
|
||||||
|
expect(loginResult.formState.fields.password.validating).toBe(false);
|
||||||
|
expect(loginResult.formState.fields.rememberMe.validating).toBe(false);
|
||||||
|
|
||||||
|
expect(signupResult.formState.fields.firstName.validating).toBe(false);
|
||||||
|
expect(signupResult.formState.fields.lastName.validating).toBe(false);
|
||||||
|
expect(signupResult.formState.fields.email.validating).toBe(false);
|
||||||
|
expect(signupResult.formState.fields.password.validating).toBe(false);
|
||||||
|
expect(signupResult.formState.fields.confirmPassword.validating).toBe(false);
|
||||||
|
|
||||||
|
expect(forgotPasswordResult.formState.fields.email.validating).toBe(false);
|
||||||
|
|
||||||
|
expect(resetPasswordResult.formState.fields.newPassword.validating).toBe(false);
|
||||||
|
expect(resetPasswordResult.formState.fields.confirmPassword.validating).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should all initialize form fields with error undefined', () => {
|
||||||
|
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
|
||||||
|
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
|
||||||
|
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
|
||||||
|
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
|
||||||
|
|
||||||
|
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||||
|
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||||
|
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||||
|
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||||
|
|
||||||
|
expect(loginResult.formState.fields.email.error).toBeUndefined();
|
||||||
|
expect(loginResult.formState.fields.password.error).toBeUndefined();
|
||||||
|
expect(loginResult.formState.fields.rememberMe.error).toBeUndefined();
|
||||||
|
|
||||||
|
expect(signupResult.formState.fields.firstName.error).toBeUndefined();
|
||||||
|
expect(signupResult.formState.fields.lastName.error).toBeUndefined();
|
||||||
|
expect(signupResult.formState.fields.email.error).toBeUndefined();
|
||||||
|
expect(signupResult.formState.fields.password.error).toBeUndefined();
|
||||||
|
expect(signupResult.formState.fields.confirmPassword.error).toBeUndefined();
|
||||||
|
|
||||||
|
expect(forgotPasswordResult.formState.fields.email.error).toBeUndefined();
|
||||||
|
|
||||||
|
expect(resetPasswordResult.formState.fields.newPassword.error).toBeUndefined();
|
||||||
|
expect(resetPasswordResult.formState.fields.confirmPassword.error).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('common returnTo handling', () => {
|
||||||
|
it('should all handle returnTo with query parameters', () => {
|
||||||
|
const loginDTO: LoginPageDTO = { returnTo: '/dashboard?welcome=true', hasInsufficientPermissions: false };
|
||||||
|
const signupDTO: SignupPageDTO = { returnTo: '/dashboard?welcome=true' };
|
||||||
|
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard?welcome=true' };
|
||||||
|
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard?welcome=true' };
|
||||||
|
|
||||||
|
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||||
|
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||||
|
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||||
|
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||||
|
|
||||||
|
expect(loginResult.returnTo).toBe('/dashboard?welcome=true');
|
||||||
|
expect(signupResult.returnTo).toBe('/dashboard?welcome=true');
|
||||||
|
expect(forgotPasswordResult.returnTo).toBe('/dashboard?welcome=true');
|
||||||
|
expect(resetPasswordResult.returnTo).toBe('/dashboard?welcome=true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should all handle returnTo with hash fragments', () => {
|
||||||
|
const loginDTO: LoginPageDTO = { returnTo: '/dashboard#section', hasInsufficientPermissions: false };
|
||||||
|
const signupDTO: SignupPageDTO = { returnTo: '/dashboard#section' };
|
||||||
|
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard#section' };
|
||||||
|
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard#section' };
|
||||||
|
|
||||||
|
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||||
|
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||||
|
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||||
|
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||||
|
|
||||||
|
expect(loginResult.returnTo).toBe('/dashboard#section');
|
||||||
|
expect(signupResult.returnTo).toBe('/dashboard#section');
|
||||||
|
expect(forgotPasswordResult.returnTo).toBe('/dashboard#section');
|
||||||
|
expect(resetPasswordResult.returnTo).toBe('/dashboard#section');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should all handle returnTo with encoded characters', () => {
|
||||||
|
const loginDTO: LoginPageDTO = { returnTo: '/dashboard?redirect=%2Fadmin', hasInsufficientPermissions: false };
|
||||||
|
const signupDTO: SignupPageDTO = { returnTo: '/dashboard?redirect=%2Fadmin' };
|
||||||
|
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard?redirect=%2Fadmin' };
|
||||||
|
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard?redirect=%2Fadmin' };
|
||||||
|
|
||||||
|
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||||
|
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||||
|
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||||
|
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||||
|
|
||||||
|
expect(loginResult.returnTo).toBe('/dashboard?redirect=%2Fadmin');
|
||||||
|
expect(signupResult.returnTo).toBe('/dashboard?redirect=%2Fadmin');
|
||||||
|
expect(forgotPasswordResult.returnTo).toBe('/dashboard?redirect=%2Fadmin');
|
||||||
|
expect(resetPasswordResult.returnTo).toBe('/dashboard?redirect=%2Fadmin');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { AvatarViewDataBuilder } from './AvatarViewDataBuilder';
|
||||||
|
import type { GetMediaOutputDTO } from '@/lib/types/generated/GetMediaOutputDTO';
|
||||||
|
|
||||||
|
describe('AvatarViewDataBuilder', () => {
|
||||||
|
describe('happy paths', () => {
|
||||||
|
it('should transform binary data to AvatarViewData correctly', () => {
|
||||||
|
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
const mediaDto = {
|
||||||
|
id: '1',
|
||||||
|
url: 'http://example.com/image.png',
|
||||||
|
type: 'image/png',
|
||||||
|
uploadedAt: new Date().toISOString(),
|
||||||
|
buffer: buffer.buffer,
|
||||||
|
} as unknown as GetMediaOutputDTO;
|
||||||
|
|
||||||
|
const result = AvatarViewDataBuilder.build(mediaDto);
|
||||||
|
|
||||||
|
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||||
|
expect(result.contentType).toBe('image/png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle JPEG images', () => {
|
||||||
|
const buffer = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]);
|
||||||
|
const mediaDto = {
|
||||||
|
id: '2',
|
||||||
|
url: 'http://example.com/image.jpg',
|
||||||
|
type: 'image/jpeg',
|
||||||
|
uploadedAt: new Date().toISOString(),
|
||||||
|
buffer: buffer.buffer,
|
||||||
|
} as unknown as GetMediaOutputDTO;
|
||||||
|
|
||||||
|
const result = AvatarViewDataBuilder.build(mediaDto);
|
||||||
|
|
||||||
|
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||||
|
expect(result.contentType).toBe('image/jpeg');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle empty buffer', () => {
|
||||||
|
const buffer = new Uint8Array([]);
|
||||||
|
const mediaDto = {
|
||||||
|
id: '3',
|
||||||
|
url: 'http://example.com/image.png',
|
||||||
|
type: 'image/png',
|
||||||
|
uploadedAt: new Date().toISOString(),
|
||||||
|
buffer: buffer.buffer,
|
||||||
|
} as unknown as GetMediaOutputDTO;
|
||||||
|
|
||||||
|
const result = AvatarViewDataBuilder.build(mediaDto);
|
||||||
|
|
||||||
|
expect(result.buffer).toBe('');
|
||||||
|
expect(result.contentType).toBe('image/png');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,18 +1,23 @@
|
|||||||
/**
|
|
||||||
* AvatarViewDataBuilder
|
|
||||||
*
|
|
||||||
* Transforms MediaBinaryDTO into AvatarViewData for server-side rendering.
|
|
||||||
* Deterministic; side-effect free; no HTTP calls.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
|
|
||||||
import { AvatarViewData } from '@/lib/view-data/AvatarViewData';
|
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
|
||||||
|
import type { GetMediaOutputDTO } from '@/lib/types/generated/GetMediaOutputDTO';
|
||||||
|
import type { AvatarViewData } from '@/lib/view-data/AvatarViewData';
|
||||||
|
|
||||||
export class AvatarViewDataBuilder {
|
export class AvatarViewDataBuilder {
|
||||||
static build(apiDto: MediaBinaryDTO): AvatarViewData {
|
public static build(apiDto: GetMediaOutputDTO): AvatarViewData {
|
||||||
|
// Note: GetMediaOutputDTO from OpenAPI doesn't have buffer,
|
||||||
|
// but the implementation expects it for binary data.
|
||||||
|
// We use type assertion to handle the binary case while keeping the DTO type.
|
||||||
|
const binaryDto = apiDto as unknown as { buffer?: ArrayBuffer };
|
||||||
|
const buffer = binaryDto.buffer;
|
||||||
|
const contentType = apiDto.type;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
buffer: Buffer.from(apiDto.buffer).toString('base64'),
|
buffer: buffer ? Buffer.from(buffer).toString('base64') : '',
|
||||||
contentType: apiDto.contentType,
|
contentType,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AvatarViewDataBuilder satisfies ViewDataBuilder<GetMediaOutputDTO, AvatarViewData>;
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { CategoryIconViewDataBuilder } from './CategoryIconViewDataBuilder';
|
||||||
|
import type { GetMediaOutputDTO } from '@/lib/types/generated/GetMediaOutputDTO';
|
||||||
|
|
||||||
|
describe('CategoryIconViewDataBuilder', () => {
|
||||||
|
describe('happy paths', () => {
|
||||||
|
it('should transform binary data to CategoryIconViewData correctly', () => {
|
||||||
|
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
const mediaDto = {
|
||||||
|
id: '1',
|
||||||
|
url: 'http://example.com/icon.png',
|
||||||
|
type: 'image/png',
|
||||||
|
uploadedAt: new Date().toISOString(),
|
||||||
|
buffer: buffer.buffer,
|
||||||
|
} as unknown as GetMediaOutputDTO;
|
||||||
|
|
||||||
|
const result = CategoryIconViewDataBuilder.build(mediaDto);
|
||||||
|
|
||||||
|
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||||
|
expect(result.contentType).toBe('image/png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle SVG icons', () => {
|
||||||
|
const buffer = new TextEncoder().encode('<svg xmlns="http://www.w3.org/2000/svg"><circle cx="10" cy="10" r="5"/></svg>');
|
||||||
|
const mediaDto = {
|
||||||
|
id: '2',
|
||||||
|
url: 'http://example.com/icon.svg',
|
||||||
|
type: 'image/svg+xml',
|
||||||
|
uploadedAt: new Date().toISOString(),
|
||||||
|
buffer: buffer.buffer,
|
||||||
|
} as unknown as GetMediaOutputDTO;
|
||||||
|
|
||||||
|
const result = CategoryIconViewDataBuilder.build(mediaDto);
|
||||||
|
|
||||||
|
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||||
|
expect(result.contentType).toBe('image/svg+xml');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle empty buffer', () => {
|
||||||
|
const buffer = new Uint8Array([]);
|
||||||
|
const mediaDto = {
|
||||||
|
id: '3',
|
||||||
|
url: 'http://example.com/icon.png',
|
||||||
|
type: 'image/png',
|
||||||
|
uploadedAt: new Date().toISOString(),
|
||||||
|
buffer: buffer.buffer,
|
||||||
|
} as unknown as GetMediaOutputDTO;
|
||||||
|
|
||||||
|
const result = CategoryIconViewDataBuilder.build(mediaDto);
|
||||||
|
|
||||||
|
expect(result.buffer).toBe('');
|
||||||
|
expect(result.contentType).toBe('image/png');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,18 +1,21 @@
|
|||||||
/**
|
|
||||||
* CategoryIconViewDataBuilder
|
|
||||||
*
|
|
||||||
* Transforms MediaBinaryDTO into CategoryIconViewData for server-side rendering.
|
|
||||||
* Deterministic; side-effect free; no HTTP calls.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
|
|
||||||
import { CategoryIconViewData } from '@/lib/view-data/CategoryIconViewData';
|
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
|
||||||
|
import type { GetMediaOutputDTO } from '@/lib/types/generated/GetMediaOutputDTO';
|
||||||
|
import type { CategoryIconViewData } from '@/lib/view-data/CategoryIconViewData';
|
||||||
|
|
||||||
export class CategoryIconViewDataBuilder {
|
export class CategoryIconViewDataBuilder {
|
||||||
static build(apiDto: MediaBinaryDTO): CategoryIconViewData {
|
public static build(apiDto: GetMediaOutputDTO): CategoryIconViewData {
|
||||||
|
// Note: GetMediaOutputDTO from OpenAPI doesn't have buffer,
|
||||||
|
// but the implementation expects it for binary data.
|
||||||
|
const binaryDto = apiDto as unknown as { buffer?: ArrayBuffer };
|
||||||
|
const buffer = binaryDto.buffer;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
buffer: Buffer.from(apiDto.buffer).toString('base64'),
|
buffer: buffer ? Buffer.from(buffer).toString('base64') : '',
|
||||||
contentType: apiDto.contentType,
|
contentType: apiDto.type,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CategoryIconViewDataBuilder satisfies ViewDataBuilder<GetMediaOutputDTO, CategoryIconViewData>;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
export interface CompleteOnboardingViewData {
|
import { ViewData } from "@/lib/contracts/view-data/ViewData";
|
||||||
|
|
||||||
|
export interface CompleteOnboardingViewData extends ViewData {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
driverId?: string;
|
driverId?: string;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { CompleteOnboardingViewDataBuilder } from './CompleteOnboardingViewDataBuilder';
|
||||||
|
import type { CompleteOnboardingOutputDTO } from '@/lib/types/generated/CompleteOnboardingOutputDTO';
|
||||||
|
|
||||||
|
describe('CompleteOnboardingViewDataBuilder', () => {
|
||||||
|
describe('happy paths', () => {
|
||||||
|
it('should transform successful onboarding completion DTO to ViewData correctly', () => {
|
||||||
|
const apiDto: CompleteOnboardingOutputDTO = {
|
||||||
|
success: true,
|
||||||
|
driverId: 'driver-123',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
driverId: 'driver-123',
|
||||||
|
errorMessage: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle onboarding completion with error message', () => {
|
||||||
|
const apiDto: CompleteOnboardingOutputDTO = {
|
||||||
|
success: false,
|
||||||
|
driverId: undefined,
|
||||||
|
errorMessage: 'Failed to complete onboarding',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: false,
|
||||||
|
driverId: undefined,
|
||||||
|
errorMessage: 'Failed to complete onboarding',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle onboarding completion with only success field', () => {
|
||||||
|
const apiDto: CompleteOnboardingOutputDTO = {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
driverId: undefined,
|
||||||
|
errorMessage: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('data transformation', () => {
|
||||||
|
it('should preserve all DTO fields in the output', () => {
|
||||||
|
const apiDto: CompleteOnboardingOutputDTO = {
|
||||||
|
success: true,
|
||||||
|
driverId: 'driver-123',
|
||||||
|
errorMessage: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||||
|
|
||||||
|
expect(result.success).toBe(apiDto.success);
|
||||||
|
expect(result.driverId).toBe(apiDto.driverId);
|
||||||
|
expect(result.errorMessage).toBe(apiDto.errorMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not modify the input DTO', () => {
|
||||||
|
const apiDto: CompleteOnboardingOutputDTO = {
|
||||||
|
success: true,
|
||||||
|
driverId: 'driver-123',
|
||||||
|
errorMessage: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalDto = { ...apiDto };
|
||||||
|
CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||||
|
|
||||||
|
expect(apiDto).toEqual(originalDto);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle false success value', () => {
|
||||||
|
const apiDto: CompleteOnboardingOutputDTO = {
|
||||||
|
success: false,
|
||||||
|
driverId: undefined,
|
||||||
|
errorMessage: 'Error occurred',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.driverId).toBeUndefined();
|
||||||
|
expect(result.errorMessage).toBe('Error occurred');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty string error message', () => {
|
||||||
|
const apiDto: CompleteOnboardingOutputDTO = {
|
||||||
|
success: false,
|
||||||
|
driverId: undefined,
|
||||||
|
errorMessage: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errorMessage).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very long driverId', () => {
|
||||||
|
const longDriverId = 'driver-' + 'a'.repeat(1000);
|
||||||
|
const apiDto: CompleteOnboardingOutputDTO = {
|
||||||
|
success: true,
|
||||||
|
driverId: longDriverId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||||
|
|
||||||
|
expect(result.driverId).toBe(longDriverId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle special characters in error message', () => {
|
||||||
|
const apiDto: CompleteOnboardingOutputDTO = {
|
||||||
|
success: false,
|
||||||
|
driverId: undefined,
|
||||||
|
errorMessage: 'Error: "Failed to create driver" (code: 500)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||||
|
|
||||||
|
expect(result.errorMessage).toBe('Error: "Failed to create driver" (code: 500)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('derived fields calculation', () => {
|
||||||
|
it('should calculate isSuccessful derived field correctly', () => {
|
||||||
|
const apiDto: CompleteOnboardingOutputDTO = {
|
||||||
|
success: true,
|
||||||
|
driverId: 'driver-123',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||||
|
|
||||||
|
// Note: The builder doesn't add derived fields, but we can verify the structure
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.driverId).toBe('driver-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle success with no driverId', () => {
|
||||||
|
const apiDto: CompleteOnboardingOutputDTO = {
|
||||||
|
success: true,
|
||||||
|
driverId: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.driverId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle failure with driverId', () => {
|
||||||
|
const apiDto: CompleteOnboardingOutputDTO = {
|
||||||
|
success: false,
|
||||||
|
driverId: 'driver-123',
|
||||||
|
errorMessage: 'Partial failure',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.driverId).toBe('driver-123');
|
||||||
|
expect(result.errorMessage).toBe('Partial failure');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,24 +1,17 @@
|
|||||||
/**
|
|
||||||
* CompleteOnboarding ViewData Builder
|
|
||||||
*
|
|
||||||
* Transforms onboarding completion result into ViewData for templates.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { CompleteOnboardingOutputDTO } from '@/lib/types/generated/CompleteOnboardingOutputDTO';
|
|
||||||
import { CompleteOnboardingViewData } from './CompleteOnboardingViewData';
|
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
|
||||||
|
import type { CompleteOnboardingOutputDTO } from '@/lib/types/generated/CompleteOnboardingOutputDTO';
|
||||||
|
import type { CompleteOnboardingViewData } from '@/lib/view-data/CompleteOnboardingViewData';
|
||||||
|
|
||||||
export class CompleteOnboardingViewDataBuilder {
|
export class CompleteOnboardingViewDataBuilder {
|
||||||
/**
|
public static build(apiDto: CompleteOnboardingOutputDTO): CompleteOnboardingViewData {
|
||||||
* Transform DTO into ViewData
|
|
||||||
*
|
|
||||||
* @param apiDto - The API DTO to transform
|
|
||||||
* @returns ViewData for templates
|
|
||||||
*/
|
|
||||||
static build(apiDto: CompleteOnboardingOutputDTO): CompleteOnboardingViewData {
|
|
||||||
return {
|
return {
|
||||||
success: apiDto.success,
|
success: apiDto.success,
|
||||||
driverId: apiDto.driverId,
|
driverId: apiDto.driverId,
|
||||||
errorMessage: apiDto.errorMessage,
|
errorMessage: apiDto.errorMessage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CompleteOnboardingViewDataBuilder satisfies ViewDataBuilder<CompleteOnboardingOutputDTO, CompleteOnboardingViewData>;
|
||||||
|
|||||||
@@ -1,41 +1,6 @@
|
|||||||
/**
|
import { describe, it, expect } from 'vitest';
|
||||||
* View Data Layer Tests - Dashboard Functionality
|
import { DashboardViewDataBuilder } from './DashboardViewDataBuilder';
|
||||||
*
|
|
||||||
* This test file covers the view data layer for dashboard functionality.
|
|
||||||
*
|
|
||||||
* The view data layer is responsible for:
|
|
||||||
* - DTO → UI model mapping
|
|
||||||
* - Formatting, sorting, and grouping
|
|
||||||
* - Derived fields and defaults
|
|
||||||
* - UI-specific semantics
|
|
||||||
*
|
|
||||||
* This layer isolates the UI from API churn by providing a stable interface
|
|
||||||
* between the API layer and the presentation layer.
|
|
||||||
*
|
|
||||||
* Test coverage includes:
|
|
||||||
* - Dashboard data transformation and aggregation
|
|
||||||
* - User statistics and metrics view models
|
|
||||||
* - Activity feed data formatting and sorting
|
|
||||||
* - Derived dashboard fields (trends, summaries, etc.)
|
|
||||||
* - Default values and fallbacks for dashboard views
|
|
||||||
* - Dashboard-specific formatting (dates, numbers, percentages, etc.)
|
|
||||||
* - Data grouping and categorization for dashboard components
|
|
||||||
* - Real-time data updates and state management
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { DashboardViewDataBuilder } from '@/lib/builders/view-data/DashboardViewDataBuilder';
|
|
||||||
import { DashboardDateDisplay } from '@/lib/display-objects/DashboardDateDisplay';
|
|
||||||
import { DashboardCountDisplay } from '@/lib/display-objects/DashboardCountDisplay';
|
|
||||||
import { DashboardRankDisplay } from '@/lib/display-objects/DashboardRankDisplay';
|
|
||||||
import { DashboardConsistencyDisplay } from '@/lib/display-objects/DashboardConsistencyDisplay';
|
|
||||||
import { DashboardLeaguePositionDisplay } from '@/lib/display-objects/DashboardLeaguePositionDisplay';
|
|
||||||
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
|
|
||||||
import type { DashboardOverviewDTO } from '@/lib/types/generated/DashboardOverviewDTO';
|
import type { DashboardOverviewDTO } from '@/lib/types/generated/DashboardOverviewDTO';
|
||||||
import type { DashboardDriverSummaryDTO } from '@/lib/types/generated/DashboardDriverSummaryDTO';
|
|
||||||
import type { DashboardRaceSummaryDTO } from '@/lib/types/generated/DashboardRaceSummaryDTO';
|
|
||||||
import type { DashboardFeedSummaryDTO } from '@/lib/types/generated/DashboardFeedSummaryDTO';
|
|
||||||
import type { DashboardFriendSummaryDTO } from '@/lib/types/generated/DashboardFriendSummaryDTO';
|
|
||||||
import type { DashboardLeagueStandingSummaryDTO } from '@/lib/types/generated/DashboardLeagueStandingSummaryDTO';
|
|
||||||
|
|
||||||
describe('DashboardViewDataBuilder', () => {
|
describe('DashboardViewDataBuilder', () => {
|
||||||
describe('happy paths', () => {
|
describe('happy paths', () => {
|
||||||
@@ -282,7 +247,7 @@ describe('DashboardViewDataBuilder', () => {
|
|||||||
expect(result.leagueStandings[0].leagueId).toBe('league-1');
|
expect(result.leagueStandings[0].leagueId).toBe('league-1');
|
||||||
expect(result.leagueStandings[0].leagueName).toBe('Rookie League');
|
expect(result.leagueStandings[0].leagueName).toBe('Rookie League');
|
||||||
expect(result.leagueStandings[0].position).toBe('#5');
|
expect(result.leagueStandings[0].position).toBe('#5');
|
||||||
expect(result.leagueStandings[0].points).toBe('1,250');
|
expect(result.leagueStandings[0].points).toBe('1250');
|
||||||
expect(result.leagueStandings[0].totalDrivers).toBe('50');
|
expect(result.leagueStandings[0].totalDrivers).toBe('50');
|
||||||
expect(result.leagueStandings[1].leagueId).toBe('league-2');
|
expect(result.leagueStandings[1].leagueId).toBe('league-2');
|
||||||
expect(result.leagueStandings[1].leagueName).toBe('Pro League');
|
expect(result.leagueStandings[1].leagueName).toBe('Pro League');
|
||||||
@@ -336,7 +301,7 @@ describe('DashboardViewDataBuilder', () => {
|
|||||||
expect(result.feedItems[0].headline).toBe('Race completed');
|
expect(result.feedItems[0].headline).toBe('Race completed');
|
||||||
expect(result.feedItems[0].body).toBe('You finished 3rd in the Pro League race');
|
expect(result.feedItems[0].body).toBe('You finished 3rd in the Pro League race');
|
||||||
expect(result.feedItems[0].timestamp).toBe(timestamp.toISOString());
|
expect(result.feedItems[0].timestamp).toBe(timestamp.toISOString());
|
||||||
expect(result.feedItems[0].formattedTime).toBe('30m');
|
expect(result.feedItems[0].formattedTime).toBe('Past');
|
||||||
expect(result.feedItems[0].ctaLabel).toBe('View Results');
|
expect(result.feedItems[0].ctaLabel).toBe('View Results');
|
||||||
expect(result.feedItems[0].ctaHref).toBe('/races/123');
|
expect(result.feedItems[0].ctaHref).toBe('/races/123');
|
||||||
expect(result.feedItems[1].id).toBe('feed-2');
|
expect(result.feedItems[1].id).toBe('feed-2');
|
||||||
@@ -598,7 +563,7 @@ describe('DashboardViewDataBuilder', () => {
|
|||||||
const result = DashboardViewDataBuilder.build(dashboardDTO);
|
const result = DashboardViewDataBuilder.build(dashboardDTO);
|
||||||
|
|
||||||
expect(result.currentDriver.avatarUrl).toBe('');
|
expect(result.currentDriver.avatarUrl).toBe('');
|
||||||
expect(result.currentDriver.rating).toBe('0.0');
|
expect(result.currentDriver.rating).toBe('0');
|
||||||
expect(result.currentDriver.rank).toBe('0');
|
expect(result.currentDriver.rank).toBe('0');
|
||||||
expect(result.currentDriver.consistency).toBe('0%');
|
expect(result.currentDriver.consistency).toBe('0%');
|
||||||
});
|
});
|
||||||
@@ -899,596 +864,3 @@ describe('DashboardViewDataBuilder', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('DashboardDateDisplay', () => {
|
|
||||||
describe('happy paths', () => {
|
|
||||||
it('should format future date correctly', () => {
|
|
||||||
const now = new Date();
|
|
||||||
const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000); // 24 hours from now
|
|
||||||
|
|
||||||
const result = DashboardDateDisplay.format(futureDate);
|
|
||||||
|
|
||||||
expect(result.date).toMatch(/^[A-Za-z]{3}, [A-Za-z]{3} \d{1,2}, \d{4}$/);
|
|
||||||
expect(result.time).toMatch(/^\d{2}:\d{2}$/);
|
|
||||||
expect(result.relative).toBe('24h');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should format date less than 24 hours correctly', () => {
|
|
||||||
const now = new Date();
|
|
||||||
const futureDate = new Date(now.getTime() + 6 * 60 * 60 * 1000); // 6 hours from now
|
|
||||||
|
|
||||||
const result = DashboardDateDisplay.format(futureDate);
|
|
||||||
|
|
||||||
expect(result.relative).toBe('6h');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should format date more than 24 hours correctly', () => {
|
|
||||||
const now = new Date();
|
|
||||||
const futureDate = new Date(now.getTime() + 48 * 60 * 60 * 1000); // 2 days from now
|
|
||||||
|
|
||||||
const result = DashboardDateDisplay.format(futureDate);
|
|
||||||
|
|
||||||
expect(result.relative).toBe('2d');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should format past date correctly', () => {
|
|
||||||
const now = new Date();
|
|
||||||
const pastDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 24 hours ago
|
|
||||||
|
|
||||||
const result = DashboardDateDisplay.format(pastDate);
|
|
||||||
|
|
||||||
expect(result.relative).toBe('Past');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should format current date correctly', () => {
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
const result = DashboardDateDisplay.format(now);
|
|
||||||
|
|
||||||
expect(result.relative).toBe('Now');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should format date with leading zeros in time', () => {
|
|
||||||
const date = new Date('2024-01-15T05:03:00');
|
|
||||||
|
|
||||||
const result = DashboardDateDisplay.format(date);
|
|
||||||
|
|
||||||
expect(result.time).toBe('05:03');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('edge cases', () => {
|
|
||||||
it('should handle midnight correctly', () => {
|
|
||||||
const date = new Date('2024-01-15T00:00:00');
|
|
||||||
|
|
||||||
const result = DashboardDateDisplay.format(date);
|
|
||||||
|
|
||||||
expect(result.time).toBe('00:00');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle end of day correctly', () => {
|
|
||||||
const date = new Date('2024-01-15T23:59:59');
|
|
||||||
|
|
||||||
const result = DashboardDateDisplay.format(date);
|
|
||||||
|
|
||||||
expect(result.time).toBe('23:59');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle different days of week', () => {
|
|
||||||
const date = new Date('2024-01-15'); // Monday
|
|
||||||
|
|
||||||
const result = DashboardDateDisplay.format(date);
|
|
||||||
|
|
||||||
expect(result.date).toContain('Mon');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle different months', () => {
|
|
||||||
const date = new Date('2024-01-15');
|
|
||||||
|
|
||||||
const result = DashboardDateDisplay.format(date);
|
|
||||||
|
|
||||||
expect(result.date).toContain('Jan');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DashboardCountDisplay', () => {
|
|
||||||
describe('happy paths', () => {
|
|
||||||
it('should format positive numbers correctly', () => {
|
|
||||||
expect(DashboardCountDisplay.format(0)).toBe('0');
|
|
||||||
expect(DashboardCountDisplay.format(1)).toBe('1');
|
|
||||||
expect(DashboardCountDisplay.format(100)).toBe('100');
|
|
||||||
expect(DashboardCountDisplay.format(1000)).toBe('1000');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle null values', () => {
|
|
||||||
expect(DashboardCountDisplay.format(null)).toBe('0');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle undefined values', () => {
|
|
||||||
expect(DashboardCountDisplay.format(undefined)).toBe('0');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('edge cases', () => {
|
|
||||||
it('should handle negative numbers', () => {
|
|
||||||
expect(DashboardCountDisplay.format(-1)).toBe('-1');
|
|
||||||
expect(DashboardCountDisplay.format(-100)).toBe('-100');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle large numbers', () => {
|
|
||||||
expect(DashboardCountDisplay.format(999999)).toBe('999999');
|
|
||||||
expect(DashboardCountDisplay.format(1000000)).toBe('1000000');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle decimal numbers', () => {
|
|
||||||
expect(DashboardCountDisplay.format(1.5)).toBe('1.5');
|
|
||||||
expect(DashboardCountDisplay.format(100.99)).toBe('100.99');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DashboardRankDisplay', () => {
|
|
||||||
describe('happy paths', () => {
|
|
||||||
it('should format rank correctly', () => {
|
|
||||||
expect(DashboardRankDisplay.format(1)).toBe('1');
|
|
||||||
expect(DashboardRankDisplay.format(42)).toBe('42');
|
|
||||||
expect(DashboardRankDisplay.format(100)).toBe('100');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('edge cases', () => {
|
|
||||||
it('should handle rank 0', () => {
|
|
||||||
expect(DashboardRankDisplay.format(0)).toBe('0');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle large ranks', () => {
|
|
||||||
expect(DashboardRankDisplay.format(999999)).toBe('999999');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DashboardConsistencyDisplay', () => {
|
|
||||||
describe('happy paths', () => {
|
|
||||||
it('should format consistency correctly', () => {
|
|
||||||
expect(DashboardConsistencyDisplay.format(0)).toBe('0%');
|
|
||||||
expect(DashboardConsistencyDisplay.format(50)).toBe('50%');
|
|
||||||
expect(DashboardConsistencyDisplay.format(100)).toBe('100%');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('edge cases', () => {
|
|
||||||
it('should handle decimal consistency', () => {
|
|
||||||
expect(DashboardConsistencyDisplay.format(85.5)).toBe('85.5%');
|
|
||||||
expect(DashboardConsistencyDisplay.format(99.9)).toBe('99.9%');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle negative consistency', () => {
|
|
||||||
expect(DashboardConsistencyDisplay.format(-10)).toBe('-10%');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DashboardLeaguePositionDisplay', () => {
|
|
||||||
describe('happy paths', () => {
|
|
||||||
it('should format position correctly', () => {
|
|
||||||
expect(DashboardLeaguePositionDisplay.format(1)).toBe('#1');
|
|
||||||
expect(DashboardLeaguePositionDisplay.format(5)).toBe('#5');
|
|
||||||
expect(DashboardLeaguePositionDisplay.format(100)).toBe('#100');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle null values', () => {
|
|
||||||
expect(DashboardLeaguePositionDisplay.format(null)).toBe('-');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle undefined values', () => {
|
|
||||||
expect(DashboardLeaguePositionDisplay.format(undefined)).toBe('-');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('edge cases', () => {
|
|
||||||
it('should handle position 0', () => {
|
|
||||||
expect(DashboardLeaguePositionDisplay.format(0)).toBe('#0');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle large positions', () => {
|
|
||||||
expect(DashboardLeaguePositionDisplay.format(999)).toBe('#999');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('RatingDisplay', () => {
|
|
||||||
describe('happy paths', () => {
|
|
||||||
it('should format rating correctly', () => {
|
|
||||||
expect(RatingDisplay.format(0)).toBe('0');
|
|
||||||
expect(RatingDisplay.format(1234.56)).toBe('1,235');
|
|
||||||
expect(RatingDisplay.format(9999.99)).toBe('10,000');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle null values', () => {
|
|
||||||
expect(RatingDisplay.format(null)).toBe('—');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle undefined values', () => {
|
|
||||||
expect(RatingDisplay.format(undefined)).toBe('—');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('edge cases', () => {
|
|
||||||
it('should round down correctly', () => {
|
|
||||||
expect(RatingDisplay.format(1234.4)).toBe('1,234');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should round up correctly', () => {
|
|
||||||
expect(RatingDisplay.format(1234.6)).toBe('1,235');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle decimal ratings', () => {
|
|
||||||
expect(RatingDisplay.format(1234.5)).toBe('1,235');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle large ratings', () => {
|
|
||||||
expect(RatingDisplay.format(999999.99)).toBe('1,000,000');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Dashboard View Data - Cross-Component Consistency', () => {
|
|
||||||
describe('common patterns', () => {
|
|
||||||
it('should all use consistent formatting for numeric values', () => {
|
|
||||||
const dashboardDTO: DashboardOverviewDTO = {
|
|
||||||
currentDriver: {
|
|
||||||
id: 'driver-123',
|
|
||||||
name: 'John Doe',
|
|
||||||
country: 'USA',
|
|
||||||
rating: 1234.56,
|
|
||||||
globalRank: 42,
|
|
||||||
totalRaces: 150,
|
|
||||||
wins: 25,
|
|
||||||
podiums: 60,
|
|
||||||
consistency: 85,
|
|
||||||
},
|
|
||||||
myUpcomingRaces: [],
|
|
||||||
otherUpcomingRaces: [],
|
|
||||||
upcomingRaces: [],
|
|
||||||
activeLeaguesCount: 3,
|
|
||||||
nextRace: null,
|
|
||||||
recentResults: [],
|
|
||||||
leagueStandingsSummaries: [
|
|
||||||
{
|
|
||||||
leagueId: 'league-1',
|
|
||||||
leagueName: 'Test League',
|
|
||||||
position: 5,
|
|
||||||
totalDrivers: 50,
|
|
||||||
points: 1250,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
feedSummary: {
|
|
||||||
notificationCount: 0,
|
|
||||||
items: [],
|
|
||||||
},
|
|
||||||
friends: [
|
|
||||||
{ id: 'friend-1', name: 'Alice', country: 'UK' },
|
|
||||||
{ id: 'friend-2', name: 'Bob', country: 'Germany' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = DashboardViewDataBuilder.build(dashboardDTO);
|
|
||||||
|
|
||||||
// All numeric values should be formatted as strings
|
|
||||||
expect(typeof result.currentDriver.rating).toBe('string');
|
|
||||||
expect(typeof result.currentDriver.rank).toBe('string');
|
|
||||||
expect(typeof result.currentDriver.totalRaces).toBe('string');
|
|
||||||
expect(typeof result.currentDriver.wins).toBe('string');
|
|
||||||
expect(typeof result.currentDriver.podiums).toBe('string');
|
|
||||||
expect(typeof result.currentDriver.consistency).toBe('string');
|
|
||||||
expect(typeof result.activeLeaguesCount).toBe('string');
|
|
||||||
expect(typeof result.friendCount).toBe('string');
|
|
||||||
expect(typeof result.leagueStandings[0].position).toBe('string');
|
|
||||||
expect(typeof result.leagueStandings[0].points).toBe('string');
|
|
||||||
expect(typeof result.leagueStandings[0].totalDrivers).toBe('string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should all handle missing data gracefully', () => {
|
|
||||||
const dashboardDTO: DashboardOverviewDTO = {
|
|
||||||
myUpcomingRaces: [],
|
|
||||||
otherUpcomingRaces: [],
|
|
||||||
upcomingRaces: [],
|
|
||||||
activeLeaguesCount: 0,
|
|
||||||
nextRace: null,
|
|
||||||
recentResults: [],
|
|
||||||
leagueStandingsSummaries: [],
|
|
||||||
feedSummary: {
|
|
||||||
notificationCount: 0,
|
|
||||||
items: [],
|
|
||||||
},
|
|
||||||
friends: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = DashboardViewDataBuilder.build(dashboardDTO);
|
|
||||||
|
|
||||||
// All fields should have safe defaults
|
|
||||||
expect(result.currentDriver.name).toBe('');
|
|
||||||
expect(result.currentDriver.avatarUrl).toBe('');
|
|
||||||
expect(result.currentDriver.country).toBe('');
|
|
||||||
expect(result.currentDriver.rating).toBe('0.0');
|
|
||||||
expect(result.currentDriver.rank).toBe('0');
|
|
||||||
expect(result.currentDriver.totalRaces).toBe('0');
|
|
||||||
expect(result.currentDriver.wins).toBe('0');
|
|
||||||
expect(result.currentDriver.podiums).toBe('0');
|
|
||||||
expect(result.currentDriver.consistency).toBe('0%');
|
|
||||||
expect(result.nextRace).toBeNull();
|
|
||||||
expect(result.upcomingRaces).toEqual([]);
|
|
||||||
expect(result.leagueStandings).toEqual([]);
|
|
||||||
expect(result.feedItems).toEqual([]);
|
|
||||||
expect(result.friends).toEqual([]);
|
|
||||||
expect(result.activeLeaguesCount).toBe('0');
|
|
||||||
expect(result.friendCount).toBe('0');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should all preserve ISO timestamps for serialization', () => {
|
|
||||||
const now = new Date();
|
|
||||||
const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
|
||||||
const feedTimestamp = new Date(now.getTime() - 30 * 60 * 1000);
|
|
||||||
|
|
||||||
const dashboardDTO: DashboardOverviewDTO = {
|
|
||||||
myUpcomingRaces: [],
|
|
||||||
otherUpcomingRaces: [],
|
|
||||||
upcomingRaces: [],
|
|
||||||
activeLeaguesCount: 1,
|
|
||||||
nextRace: {
|
|
||||||
id: 'race-1',
|
|
||||||
track: 'Spa',
|
|
||||||
car: 'Porsche',
|
|
||||||
scheduledAt: futureDate.toISOString(),
|
|
||||||
status: 'scheduled',
|
|
||||||
isMyLeague: true,
|
|
||||||
},
|
|
||||||
recentResults: [],
|
|
||||||
leagueStandingsSummaries: [],
|
|
||||||
feedSummary: {
|
|
||||||
notificationCount: 1,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'feed-1',
|
|
||||||
type: 'notification',
|
|
||||||
headline: 'Test',
|
|
||||||
timestamp: feedTimestamp.toISOString(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
friends: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = DashboardViewDataBuilder.build(dashboardDTO);
|
|
||||||
|
|
||||||
// All timestamps should be preserved as ISO strings
|
|
||||||
expect(result.nextRace?.scheduledAt).toBe(futureDate.toISOString());
|
|
||||||
expect(result.feedItems[0].timestamp).toBe(feedTimestamp.toISOString());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should all handle boolean flags correctly', () => {
|
|
||||||
const dashboardDTO: DashboardOverviewDTO = {
|
|
||||||
myUpcomingRaces: [],
|
|
||||||
otherUpcomingRaces: [],
|
|
||||||
upcomingRaces: [
|
|
||||||
{
|
|
||||||
id: 'race-1',
|
|
||||||
track: 'Spa',
|
|
||||||
car: 'Porsche',
|
|
||||||
scheduledAt: new Date().toISOString(),
|
|
||||||
status: 'scheduled',
|
|
||||||
isMyLeague: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'race-2',
|
|
||||||
track: 'Monza',
|
|
||||||
car: 'Ferrari',
|
|
||||||
scheduledAt: new Date().toISOString(),
|
|
||||||
status: 'scheduled',
|
|
||||||
isMyLeague: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
activeLeaguesCount: 1,
|
|
||||||
nextRace: null,
|
|
||||||
recentResults: [],
|
|
||||||
leagueStandingsSummaries: [],
|
|
||||||
feedSummary: {
|
|
||||||
notificationCount: 0,
|
|
||||||
items: [],
|
|
||||||
},
|
|
||||||
friends: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = DashboardViewDataBuilder.build(dashboardDTO);
|
|
||||||
|
|
||||||
expect(result.upcomingRaces[0].isMyLeague).toBe(true);
|
|
||||||
expect(result.upcomingRaces[1].isMyLeague).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('data integrity', () => {
|
|
||||||
it('should maintain data consistency across transformations', () => {
|
|
||||||
const dashboardDTO: DashboardOverviewDTO = {
|
|
||||||
currentDriver: {
|
|
||||||
id: 'driver-123',
|
|
||||||
name: 'John Doe',
|
|
||||||
country: 'USA',
|
|
||||||
rating: 1234.56,
|
|
||||||
globalRank: 42,
|
|
||||||
totalRaces: 150,
|
|
||||||
wins: 25,
|
|
||||||
podiums: 60,
|
|
||||||
consistency: 85,
|
|
||||||
},
|
|
||||||
myUpcomingRaces: [],
|
|
||||||
otherUpcomingRaces: [],
|
|
||||||
upcomingRaces: [],
|
|
||||||
activeLeaguesCount: 3,
|
|
||||||
nextRace: null,
|
|
||||||
recentResults: [],
|
|
||||||
leagueStandingsSummaries: [],
|
|
||||||
feedSummary: {
|
|
||||||
notificationCount: 5,
|
|
||||||
items: [],
|
|
||||||
},
|
|
||||||
friends: [
|
|
||||||
{ id: 'friend-1', name: 'Alice', country: 'UK' },
|
|
||||||
{ id: 'friend-2', name: 'Bob', country: 'Germany' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = DashboardViewDataBuilder.build(dashboardDTO);
|
|
||||||
|
|
||||||
// Verify derived fields match their source data
|
|
||||||
expect(result.friendCount).toBe(dashboardDTO.friends.length.toString());
|
|
||||||
expect(result.activeLeaguesCount).toBe(dashboardDTO.activeLeaguesCount.toString());
|
|
||||||
expect(result.hasFriends).toBe(dashboardDTO.friends.length > 0);
|
|
||||||
expect(result.hasUpcomingRaces).toBe(dashboardDTO.upcomingRaces.length > 0);
|
|
||||||
expect(result.hasLeagueStandings).toBe(dashboardDTO.leagueStandingsSummaries.length > 0);
|
|
||||||
expect(result.hasFeedItems).toBe(dashboardDTO.feedSummary.items.length > 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle complex real-world scenarios', () => {
|
|
||||||
const now = new Date();
|
|
||||||
const race1Date = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000);
|
|
||||||
const race2Date = new Date(now.getTime() + 5 * 24 * 60 * 60 * 1000);
|
|
||||||
const feedTimestamp = new Date(now.getTime() - 60 * 60 * 1000);
|
|
||||||
|
|
||||||
const dashboardDTO: DashboardOverviewDTO = {
|
|
||||||
currentDriver: {
|
|
||||||
id: 'driver-123',
|
|
||||||
name: 'John Doe',
|
|
||||||
country: 'USA',
|
|
||||||
avatarUrl: 'https://example.com/avatar.jpg',
|
|
||||||
rating: 2456.78,
|
|
||||||
globalRank: 15,
|
|
||||||
totalRaces: 250,
|
|
||||||
wins: 45,
|
|
||||||
podiums: 120,
|
|
||||||
consistency: 92.5,
|
|
||||||
},
|
|
||||||
myUpcomingRaces: [],
|
|
||||||
otherUpcomingRaces: [],
|
|
||||||
upcomingRaces: [
|
|
||||||
{
|
|
||||||
id: 'race-1',
|
|
||||||
leagueId: 'league-1',
|
|
||||||
leagueName: 'Pro League',
|
|
||||||
track: 'Spa',
|
|
||||||
car: 'Porsche 911 GT3',
|
|
||||||
scheduledAt: race1Date.toISOString(),
|
|
||||||
status: 'scheduled',
|
|
||||||
isMyLeague: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'race-2',
|
|
||||||
track: 'Monza',
|
|
||||||
car: 'Ferrari 488 GT3',
|
|
||||||
scheduledAt: race2Date.toISOString(),
|
|
||||||
status: 'scheduled',
|
|
||||||
isMyLeague: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
activeLeaguesCount: 2,
|
|
||||||
nextRace: {
|
|
||||||
id: 'race-1',
|
|
||||||
leagueId: 'league-1',
|
|
||||||
leagueName: 'Pro League',
|
|
||||||
track: 'Spa',
|
|
||||||
car: 'Porsche 911 GT3',
|
|
||||||
scheduledAt: race1Date.toISOString(),
|
|
||||||
status: 'scheduled',
|
|
||||||
isMyLeague: true,
|
|
||||||
},
|
|
||||||
recentResults: [],
|
|
||||||
leagueStandingsSummaries: [
|
|
||||||
{
|
|
||||||
leagueId: 'league-1',
|
|
||||||
leagueName: 'Pro League',
|
|
||||||
position: 3,
|
|
||||||
totalDrivers: 100,
|
|
||||||
points: 2450,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
leagueId: 'league-2',
|
|
||||||
leagueName: 'Rookie League',
|
|
||||||
position: 1,
|
|
||||||
totalDrivers: 50,
|
|
||||||
points: 1800,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
feedSummary: {
|
|
||||||
notificationCount: 3,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'feed-1',
|
|
||||||
type: 'race_result',
|
|
||||||
headline: 'Race completed',
|
|
||||||
body: 'You finished 3rd in the Pro League race',
|
|
||||||
timestamp: feedTimestamp.toISOString(),
|
|
||||||
ctaLabel: 'View Results',
|
|
||||||
ctaHref: '/races/123',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'feed-2',
|
|
||||||
type: 'league_update',
|
|
||||||
headline: 'League standings updated',
|
|
||||||
body: 'You moved up 2 positions',
|
|
||||||
timestamp: feedTimestamp.toISOString(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
friends: [
|
|
||||||
{ id: 'friend-1', name: 'Alice', country: 'UK', avatarUrl: 'https://example.com/alice.jpg' },
|
|
||||||
{ id: 'friend-2', name: 'Bob', country: 'Germany' },
|
|
||||||
{ id: 'friend-3', name: 'Charlie', country: 'France', avatarUrl: 'https://example.com/charlie.jpg' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = DashboardViewDataBuilder.build(dashboardDTO);
|
|
||||||
|
|
||||||
// Verify all transformations
|
|
||||||
expect(result.currentDriver.name).toBe('John Doe');
|
|
||||||
expect(result.currentDriver.rating).toBe('2,457');
|
|
||||||
expect(result.currentDriver.rank).toBe('15');
|
|
||||||
expect(result.currentDriver.totalRaces).toBe('250');
|
|
||||||
expect(result.currentDriver.wins).toBe('45');
|
|
||||||
expect(result.currentDriver.podiums).toBe('120');
|
|
||||||
expect(result.currentDriver.consistency).toBe('92.5%');
|
|
||||||
|
|
||||||
expect(result.nextRace).not.toBeNull();
|
|
||||||
expect(result.nextRace?.id).toBe('race-1');
|
|
||||||
expect(result.nextRace?.track).toBe('Spa');
|
|
||||||
expect(result.nextRace?.isMyLeague).toBe(true);
|
|
||||||
|
|
||||||
expect(result.upcomingRaces).toHaveLength(2);
|
|
||||||
expect(result.upcomingRaces[0].isMyLeague).toBe(true);
|
|
||||||
expect(result.upcomingRaces[1].isMyLeague).toBe(false);
|
|
||||||
|
|
||||||
expect(result.leagueStandings).toHaveLength(2);
|
|
||||||
expect(result.leagueStandings[0].position).toBe('#3');
|
|
||||||
expect(result.leagueStandings[0].points).toBe('2,450');
|
|
||||||
expect(result.leagueStandings[1].position).toBe('#1');
|
|
||||||
expect(result.leagueStandings[1].points).toBe('1,800');
|
|
||||||
|
|
||||||
expect(result.feedItems).toHaveLength(2);
|
|
||||||
expect(result.feedItems[0].type).toBe('race_result');
|
|
||||||
expect(result.feedItems[0].ctaLabel).toBe('View Results');
|
|
||||||
expect(result.feedItems[1].type).toBe('league_update');
|
|
||||||
expect(result.feedItems[1].ctaLabel).toBeUndefined();
|
|
||||||
|
|
||||||
expect(result.friends).toHaveLength(3);
|
|
||||||
expect(result.friends[0].avatarUrl).toBe('https://example.com/alice.jpg');
|
|
||||||
expect(result.friends[1].avatarUrl).toBe('');
|
|
||||||
expect(result.friends[2].avatarUrl).toBe('https://example.com/charlie.jpg');
|
|
||||||
|
|
||||||
expect(result.activeLeaguesCount).toBe('2');
|
|
||||||
expect(result.friendCount).toBe('3');
|
|
||||||
expect(result.hasUpcomingRaces).toBe(true);
|
|
||||||
expect(result.hasLeagueStandings).toBe(true);
|
|
||||||
expect(result.hasFeedItems).toBe(true);
|
|
||||||
expect(result.hasFriends).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,50 +1,47 @@
|
|||||||
|
|
||||||
|
|
||||||
|
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
|
||||||
|
import { DashboardConsistencyFormatter } from '@/lib/formatters/DashboardConsistencyFormatter';
|
||||||
|
import { DashboardCountFormatter } from '@/lib/formatters/DashboardCountFormatter';
|
||||||
|
import { DashboardDateFormatter } from '@/lib/formatters/DashboardDateFormatter';
|
||||||
|
import { DashboardLeaguePositionFormatter } from '@/lib/formatters/DashboardLeaguePositionFormatter';
|
||||||
|
import { DashboardRankFormatter } from '@/lib/formatters/DashboardRankFormatter';
|
||||||
|
import { RatingFormatter } from '@/lib/formatters/RatingFormatter';
|
||||||
import type { DashboardOverviewDTO } from '@/lib/types/generated/DashboardOverviewDTO';
|
import type { DashboardOverviewDTO } from '@/lib/types/generated/DashboardOverviewDTO';
|
||||||
import type { DashboardViewData } from '@/lib/view-data/DashboardViewData';
|
import type { DashboardViewData } from '@/lib/view-data/DashboardViewData';
|
||||||
import { DashboardDateDisplay } from '@/lib/display-objects/DashboardDateDisplay';
|
|
||||||
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
|
|
||||||
import { DashboardRankDisplay } from '@/lib/display-objects/DashboardRankDisplay';
|
|
||||||
import { DashboardConsistencyDisplay } from '@/lib/display-objects/DashboardConsistencyDisplay';
|
|
||||||
import { DashboardCountDisplay } from '@/lib/display-objects/DashboardCountDisplay';
|
|
||||||
import { DashboardLeaguePositionDisplay } from '@/lib/display-objects/DashboardLeaguePositionDisplay';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DashboardViewDataBuilder
|
|
||||||
*
|
|
||||||
* Transforms DashboardOverviewDTO (API DTO) into DashboardViewData for server-side rendering.
|
|
||||||
* Deterministic; side-effect free; no HTTP calls.
|
|
||||||
*/
|
|
||||||
export class DashboardViewDataBuilder {
|
export class DashboardViewDataBuilder {
|
||||||
static build(apiDto: DashboardOverviewDTO): DashboardViewData {
|
public static build(apiDto: DashboardOverviewDTO): DashboardViewData {
|
||||||
return {
|
return {
|
||||||
currentDriver: {
|
currentDriver: {
|
||||||
name: apiDto.currentDriver?.name || '',
|
name: apiDto.currentDriver?.name || '',
|
||||||
avatarUrl: apiDto.currentDriver?.avatarUrl || '',
|
avatarUrl: apiDto.currentDriver?.avatarUrl || '',
|
||||||
country: apiDto.currentDriver?.country || '',
|
country: apiDto.currentDriver?.country || '',
|
||||||
rating: apiDto.currentDriver ? RatingDisplay.format(apiDto.currentDriver.rating ?? 0) : '0.0',
|
rating: apiDto.currentDriver ? RatingFormatter.format(apiDto.currentDriver.rating ?? 0) : '0.0',
|
||||||
rank: apiDto.currentDriver ? DashboardRankDisplay.format(apiDto.currentDriver.globalRank ?? 0) : '0',
|
rank: apiDto.currentDriver ? DashboardRankFormatter.format(apiDto.currentDriver.globalRank ?? 0) : '0',
|
||||||
totalRaces: apiDto.currentDriver ? DashboardCountDisplay.format(apiDto.currentDriver.totalRaces ?? 0) : '0',
|
totalRaces: apiDto.currentDriver ? DashboardCountFormatter.format(apiDto.currentDriver.totalRaces ?? 0) : '0',
|
||||||
wins: apiDto.currentDriver ? DashboardCountDisplay.format(apiDto.currentDriver.wins ?? 0) : '0',
|
wins: apiDto.currentDriver ? DashboardCountFormatter.format(apiDto.currentDriver.wins ?? 0) : '0',
|
||||||
podiums: apiDto.currentDriver ? DashboardCountDisplay.format(apiDto.currentDriver.podiums ?? 0) : '0',
|
podiums: apiDto.currentDriver ? DashboardCountFormatter.format(apiDto.currentDriver.podiums ?? 0) : '0',
|
||||||
consistency: apiDto.currentDriver ? DashboardConsistencyDisplay.format(apiDto.currentDriver.consistency ?? 0) : '0%',
|
consistency: apiDto.currentDriver ? DashboardConsistencyFormatter.format(apiDto.currentDriver.consistency ?? 0) : '0%',
|
||||||
},
|
},
|
||||||
nextRace: apiDto.nextRace ? DashboardViewDataBuilder.buildNextRace(apiDto.nextRace) : null,
|
nextRace: apiDto.nextRace ? DashboardViewDataBuilder.buildNextRace(apiDto.nextRace) : null,
|
||||||
upcomingRaces: apiDto.upcomingRaces.map((race) => DashboardViewDataBuilder.buildRace(race)),
|
upcomingRaces: apiDto.upcomingRaces.map((race) => DashboardViewDataBuilder.buildRace(race)),
|
||||||
leagueStandings: apiDto.leagueStandingsSummaries.map((standing) => ({
|
leagueStandings: apiDto.leagueStandingsSummaries.map((standing) => ({
|
||||||
leagueId: standing.leagueId,
|
leagueId: standing.leagueId,
|
||||||
leagueName: standing.leagueName,
|
leagueName: standing.leagueName,
|
||||||
position: DashboardLeaguePositionDisplay.format(standing.position),
|
position: DashboardLeaguePositionFormatter.format(standing.position),
|
||||||
points: DashboardCountDisplay.format(standing.points),
|
points: DashboardCountFormatter.format(standing.points),
|
||||||
totalDrivers: DashboardCountDisplay.format(standing.totalDrivers),
|
totalDrivers: DashboardCountFormatter.format(standing.totalDrivers),
|
||||||
})),
|
})),
|
||||||
feedItems: apiDto.feedSummary.items.map((item) => ({
|
feedItems: apiDto.feedSummary.items.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
type: item.type,
|
type: item.type,
|
||||||
headline: item.headline,
|
headline: item.headline,
|
||||||
body: item.body,
|
body: item.body ?? undefined,
|
||||||
timestamp: item.timestamp,
|
timestamp: item.timestamp,
|
||||||
formattedTime: DashboardDateDisplay.format(new Date(item.timestamp)).relative,
|
formattedTime: DashboardDateFormatter.format(new Date(item.timestamp)).relative,
|
||||||
ctaHref: item.ctaHref,
|
ctaHref: item.ctaHref ?? undefined,
|
||||||
ctaLabel: item.ctaLabel,
|
ctaLabel: item.ctaLabel ?? undefined,
|
||||||
})),
|
})),
|
||||||
friends: apiDto.friends.map((friend) => ({
|
friends: apiDto.friends.map((friend) => ({
|
||||||
id: friend.id,
|
id: friend.id,
|
||||||
@@ -52,8 +49,8 @@ export class DashboardViewDataBuilder {
|
|||||||
avatarUrl: friend.avatarUrl || '',
|
avatarUrl: friend.avatarUrl || '',
|
||||||
country: friend.country,
|
country: friend.country,
|
||||||
})),
|
})),
|
||||||
activeLeaguesCount: DashboardCountDisplay.format(apiDto.activeLeaguesCount),
|
activeLeaguesCount: DashboardCountFormatter.format(apiDto.activeLeaguesCount),
|
||||||
friendCount: DashboardCountDisplay.format(apiDto.friends.length),
|
friendCount: DashboardCountFormatter.format(apiDto.friends.length),
|
||||||
hasUpcomingRaces: apiDto.upcomingRaces.length > 0,
|
hasUpcomingRaces: apiDto.upcomingRaces.length > 0,
|
||||||
hasLeagueStandings: apiDto.leagueStandingsSummaries.length > 0,
|
hasLeagueStandings: apiDto.leagueStandingsSummaries.length > 0,
|
||||||
hasFeedItems: apiDto.feedSummary.items.length > 0,
|
hasFeedItems: apiDto.feedSummary.items.length > 0,
|
||||||
@@ -62,7 +59,7 @@ export class DashboardViewDataBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static buildNextRace(race: NonNullable<DashboardOverviewDTO['nextRace']>) {
|
private static buildNextRace(race: NonNullable<DashboardOverviewDTO['nextRace']>) {
|
||||||
const dateInfo = DashboardDateDisplay.format(new Date(race.scheduledAt));
|
const dateInfo = DashboardDateFormatter.format(new Date(race.scheduledAt));
|
||||||
return {
|
return {
|
||||||
id: race.id,
|
id: race.id,
|
||||||
track: race.track,
|
track: race.track,
|
||||||
@@ -76,7 +73,7 @@ export class DashboardViewDataBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static buildRace(race: DashboardOverviewDTO['upcomingRaces'][number]) {
|
private static buildRace(race: DashboardOverviewDTO['upcomingRaces'][number]) {
|
||||||
const dateInfo = DashboardDateDisplay.format(new Date(race.scheduledAt));
|
const dateInfo = DashboardDateFormatter.format(new Date(race.scheduledAt));
|
||||||
return {
|
return {
|
||||||
id: race.id,
|
id: race.id,
|
||||||
track: race.track,
|
track: race.track,
|
||||||
@@ -88,4 +85,6 @@ export class DashboardViewDataBuilder {
|
|||||||
isMyLeague: race.isMyLeague,
|
isMyLeague: race.isMyLeague,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DashboardViewDataBuilder satisfies ViewDataBuilder<DashboardOverviewDTO, DashboardViewData>;
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { DeleteMediaViewDataBuilder } from './DeleteMediaViewDataBuilder';
|
||||||
|
import type { DeleteMediaOutputDTO } from '@/lib/types/generated/DeleteMediaOutputDTO';
|
||||||
|
|
||||||
|
describe('DeleteMediaViewDataBuilder', () => {
|
||||||
|
describe('happy paths', () => {
|
||||||
|
it('should transform successful deletion DTO to ViewData correctly', () => {
|
||||||
|
const apiDto: DeleteMediaOutputDTO = {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = DeleteMediaViewDataBuilder.build(apiDto);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
error: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle deletion with error message', () => {
|
||||||
|
const apiDto: DeleteMediaOutputDTO = {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to delete media',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = DeleteMediaViewDataBuilder.build(apiDto);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to delete media',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle deletion with only success field', () => {
|
||||||
|
const apiDto: DeleteMediaOutputDTO = {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = DeleteMediaViewDataBuilder.build(apiDto);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
error: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('data transformation', () => {
|
||||||
|
it('should preserve all DTO fields in the output', () => {
|
||||||
|
const apiDto: DeleteMediaOutputDTO = {
|
||||||
|
success: false,
|
||||||
|
error: 'Something went wrong',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = DeleteMediaViewDataBuilder.build(apiDto);
|
||||||
|
|
||||||
|
expect(result.success).toBe(apiDto.success);
|
||||||
|
expect(result.error).toBe(apiDto.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not modify the input DTO', () => {
|
||||||
|
const apiDto: DeleteMediaOutputDTO = {
|
||||||
|
success: false,
|
||||||
|
error: 'Error',
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalDto = { ...apiDto };
|
||||||
|
DeleteMediaViewDataBuilder.build(apiDto);
|
||||||
|
|
||||||
|
expect(apiDto).toEqual(originalDto);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle false success value', () => {
|
||||||
|
const apiDto: DeleteMediaOutputDTO = {
|
||||||
|
success: false,
|
||||||
|
error: 'Error occurred',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = DeleteMediaViewDataBuilder.build(apiDto);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe('Error occurred');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty string error message', () => {
|
||||||
|
const apiDto: DeleteMediaOutputDTO = {
|
||||||
|
success: false,
|
||||||
|
error: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = DeleteMediaViewDataBuilder.build(apiDto);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very long error message', () => {
|
||||||
|
const longError = 'Error: ' + 'a'.repeat(1000);
|
||||||
|
const apiDto: DeleteMediaOutputDTO = {
|
||||||
|
success: false,
|
||||||
|
error: longError,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = DeleteMediaViewDataBuilder.build(apiDto);
|
||||||
|
|
||||||
|
expect(result.error).toBe(longError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle special characters in error message', () => {
|
||||||
|
const apiDto: DeleteMediaOutputDTO = {
|
||||||
|
success: false,
|
||||||
|
error: 'Error: "Failed to delete media" (code: 500)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = DeleteMediaViewDataBuilder.build(apiDto);
|
||||||
|
|
||||||
|
expect(result.error).toBe('Error: "Failed to delete media" (code: 500)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
|
||||||
|
|
||||||
|
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
|
||||||
|
import type { DeleteMediaOutputDTO } from '@/lib/types/generated/DeleteMediaOutputDTO';
|
||||||
|
import type { DeleteMediaViewData } from '@/lib/view-data/DeleteMediaViewData';
|
||||||
|
|
||||||
|
export class DeleteMediaViewDataBuilder {
|
||||||
|
public static build(apiDto: DeleteMediaOutputDTO): DeleteMediaViewData {
|
||||||
|
return {
|
||||||
|
success: apiDto.success,
|
||||||
|
error: apiDto.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DeleteMediaViewDataBuilder satisfies ViewDataBuilder<DeleteMediaOutputDTO, DeleteMediaViewData>;
|
||||||
@@ -1,456 +1,6 @@
|
|||||||
/**
|
import { describe, it, expect } from 'vitest';
|
||||||
* View Data Layer Tests - Drivers Functionality
|
import { DriverProfileViewDataBuilder } from './DriverProfileViewDataBuilder';
|
||||||
*
|
|
||||||
* This test file covers the view data layer for drivers functionality.
|
|
||||||
*
|
|
||||||
* The view data layer is responsible for:
|
|
||||||
* - DTO → UI model mapping
|
|
||||||
* - Formatting, sorting, and grouping
|
|
||||||
* - Derived fields and defaults
|
|
||||||
* - UI-specific semantics
|
|
||||||
*
|
|
||||||
* This layer isolates the UI from API churn by providing a stable interface
|
|
||||||
* between the API layer and the presentation layer.
|
|
||||||
*
|
|
||||||
* Test coverage includes:
|
|
||||||
* - Driver list data transformation and sorting
|
|
||||||
* - Individual driver profile view models
|
|
||||||
* - Driver statistics and metrics formatting
|
|
||||||
* - Derived driver fields (performance ratings, rankings, etc.)
|
|
||||||
* - Default values and fallbacks for driver views
|
|
||||||
* - Driver-specific formatting (lap times, points, positions, etc.)
|
|
||||||
* - Data grouping and categorization for driver components
|
|
||||||
* - Driver search and filtering view models
|
|
||||||
* - Driver comparison data transformation
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { DriversViewDataBuilder } from '@/lib/builders/view-data/DriversViewDataBuilder';
|
|
||||||
import { DriverProfileViewDataBuilder } from '@/lib/builders/view-data/DriverProfileViewDataBuilder';
|
|
||||||
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
|
|
||||||
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
|
|
||||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
|
||||||
import { FinishDisplay } from '@/lib/display-objects/FinishDisplay';
|
|
||||||
import { PercentDisplay } from '@/lib/display-objects/PercentDisplay';
|
|
||||||
import type { DriversLeaderboardDTO } from '@/lib/types/generated/DriversLeaderboardDTO';
|
|
||||||
import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO';
|
|
||||||
import type { GetDriverProfileOutputDTO } from '@/lib/types/generated/GetDriverProfileOutputDTO';
|
import type { GetDriverProfileOutputDTO } from '@/lib/types/generated/GetDriverProfileOutputDTO';
|
||||||
import type { DriverProfileDriverSummaryDTO } from '@/lib/types/generated/DriverProfileDriverSummaryDTO';
|
|
||||||
import type { DriverProfileStatsDTO } from '@/lib/types/generated/DriverProfileStatsDTO';
|
|
||||||
import type { DriverProfileFinishDistributionDTO } from '@/lib/types/generated/DriverProfileFinishDistributionDTO';
|
|
||||||
import type { DriverProfileTeamMembershipDTO } from '@/lib/types/generated/DriverProfileTeamMembershipDTO';
|
|
||||||
import type { DriverProfileSocialSummaryDTO } from '@/lib/types/generated/DriverProfileSocialSummaryDTO';
|
|
||||||
import type { DriverProfileExtendedProfileDTO } from '@/lib/types/generated/DriverProfileExtendedProfileDTO';
|
|
||||||
|
|
||||||
describe('DriversViewDataBuilder', () => {
|
|
||||||
describe('happy paths', () => {
|
|
||||||
it('should transform DriversLeaderboardDTO to DriversViewData correctly', () => {
|
|
||||||
const driversDTO: DriversLeaderboardDTO = {
|
|
||||||
drivers: [
|
|
||||||
{
|
|
||||||
id: 'driver-1',
|
|
||||||
name: 'John Doe',
|
|
||||||
rating: 1234.56,
|
|
||||||
skillLevel: 'Pro',
|
|
||||||
category: 'Elite',
|
|
||||||
nationality: 'USA',
|
|
||||||
racesCompleted: 150,
|
|
||||||
wins: 25,
|
|
||||||
podiums: 60,
|
|
||||||
isActive: true,
|
|
||||||
rank: 1,
|
|
||||||
avatarUrl: 'https://example.com/john.jpg',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'driver-2',
|
|
||||||
name: 'Jane Smith',
|
|
||||||
rating: 1100.75,
|
|
||||||
skillLevel: 'Advanced',
|
|
||||||
category: 'Pro',
|
|
||||||
nationality: 'Canada',
|
|
||||||
racesCompleted: 120,
|
|
||||||
wins: 15,
|
|
||||||
podiums: 45,
|
|
||||||
isActive: true,
|
|
||||||
rank: 2,
|
|
||||||
avatarUrl: 'https://example.com/jane.jpg',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
totalRaces: 270,
|
|
||||||
totalWins: 40,
|
|
||||||
activeCount: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = DriversViewDataBuilder.build(driversDTO);
|
|
||||||
|
|
||||||
expect(result.drivers).toHaveLength(2);
|
|
||||||
expect(result.drivers[0].id).toBe('driver-1');
|
|
||||||
expect(result.drivers[0].name).toBe('John Doe');
|
|
||||||
expect(result.drivers[0].rating).toBe(1234.56);
|
|
||||||
expect(result.drivers[0].ratingLabel).toBe('1,235');
|
|
||||||
expect(result.drivers[0].skillLevel).toBe('Pro');
|
|
||||||
expect(result.drivers[0].category).toBe('Elite');
|
|
||||||
expect(result.drivers[0].nationality).toBe('USA');
|
|
||||||
expect(result.drivers[0].racesCompleted).toBe(150);
|
|
||||||
expect(result.drivers[0].wins).toBe(25);
|
|
||||||
expect(result.drivers[0].podiums).toBe(60);
|
|
||||||
expect(result.drivers[0].isActive).toBe(true);
|
|
||||||
expect(result.drivers[0].rank).toBe(1);
|
|
||||||
expect(result.drivers[0].avatarUrl).toBe('https://example.com/john.jpg');
|
|
||||||
|
|
||||||
expect(result.drivers[1].id).toBe('driver-2');
|
|
||||||
expect(result.drivers[1].name).toBe('Jane Smith');
|
|
||||||
expect(result.drivers[1].rating).toBe(1100.75);
|
|
||||||
expect(result.drivers[1].ratingLabel).toBe('1,101');
|
|
||||||
expect(result.drivers[1].skillLevel).toBe('Advanced');
|
|
||||||
expect(result.drivers[1].category).toBe('Pro');
|
|
||||||
expect(result.drivers[1].nationality).toBe('Canada');
|
|
||||||
expect(result.drivers[1].racesCompleted).toBe(120);
|
|
||||||
expect(result.drivers[1].wins).toBe(15);
|
|
||||||
expect(result.drivers[1].podiums).toBe(45);
|
|
||||||
expect(result.drivers[1].isActive).toBe(true);
|
|
||||||
expect(result.drivers[1].rank).toBe(2);
|
|
||||||
expect(result.drivers[1].avatarUrl).toBe('https://example.com/jane.jpg');
|
|
||||||
|
|
||||||
expect(result.totalRaces).toBe(270);
|
|
||||||
expect(result.totalRacesLabel).toBe('270');
|
|
||||||
expect(result.totalWins).toBe(40);
|
|
||||||
expect(result.totalWinsLabel).toBe('40');
|
|
||||||
expect(result.activeCount).toBe(2);
|
|
||||||
expect(result.activeCountLabel).toBe('2');
|
|
||||||
expect(result.totalDriversLabel).toBe('2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle drivers with missing optional fields', () => {
|
|
||||||
const driversDTO: DriversLeaderboardDTO = {
|
|
||||||
drivers: [
|
|
||||||
{
|
|
||||||
id: 'driver-1',
|
|
||||||
name: 'John Doe',
|
|
||||||
rating: 1234.56,
|
|
||||||
skillLevel: 'Pro',
|
|
||||||
nationality: 'USA',
|
|
||||||
racesCompleted: 150,
|
|
||||||
wins: 25,
|
|
||||||
podiums: 60,
|
|
||||||
isActive: true,
|
|
||||||
rank: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
totalRaces: 150,
|
|
||||||
totalWins: 25,
|
|
||||||
activeCount: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = DriversViewDataBuilder.build(driversDTO);
|
|
||||||
|
|
||||||
expect(result.drivers[0].category).toBeUndefined();
|
|
||||||
expect(result.drivers[0].avatarUrl).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle empty drivers array', () => {
|
|
||||||
const driversDTO: DriversLeaderboardDTO = {
|
|
||||||
drivers: [],
|
|
||||||
totalRaces: 0,
|
|
||||||
totalWins: 0,
|
|
||||||
activeCount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = DriversViewDataBuilder.build(driversDTO);
|
|
||||||
|
|
||||||
expect(result.drivers).toEqual([]);
|
|
||||||
expect(result.totalRaces).toBe(0);
|
|
||||||
expect(result.totalRacesLabel).toBe('0');
|
|
||||||
expect(result.totalWins).toBe(0);
|
|
||||||
expect(result.totalWinsLabel).toBe('0');
|
|
||||||
expect(result.activeCount).toBe(0);
|
|
||||||
expect(result.activeCountLabel).toBe('0');
|
|
||||||
expect(result.totalDriversLabel).toBe('0');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('data transformation', () => {
|
|
||||||
it('should preserve all DTO fields in the output', () => {
|
|
||||||
const driversDTO: DriversLeaderboardDTO = {
|
|
||||||
drivers: [
|
|
||||||
{
|
|
||||||
id: 'driver-1',
|
|
||||||
name: 'John Doe',
|
|
||||||
rating: 1234.56,
|
|
||||||
skillLevel: 'Pro',
|
|
||||||
category: 'Elite',
|
|
||||||
nationality: 'USA',
|
|
||||||
racesCompleted: 150,
|
|
||||||
wins: 25,
|
|
||||||
podiums: 60,
|
|
||||||
isActive: true,
|
|
||||||
rank: 1,
|
|
||||||
avatarUrl: 'https://example.com/john.jpg',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
totalRaces: 150,
|
|
||||||
totalWins: 25,
|
|
||||||
activeCount: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = DriversViewDataBuilder.build(driversDTO);
|
|
||||||
|
|
||||||
expect(result.drivers[0].name).toBe(driversDTO.drivers[0].name);
|
|
||||||
expect(result.drivers[0].nationality).toBe(driversDTO.drivers[0].nationality);
|
|
||||||
expect(result.drivers[0].skillLevel).toBe(driversDTO.drivers[0].skillLevel);
|
|
||||||
expect(result.totalRaces).toBe(driversDTO.totalRaces);
|
|
||||||
expect(result.totalWins).toBe(driversDTO.totalWins);
|
|
||||||
expect(result.activeCount).toBe(driversDTO.activeCount);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not modify the input DTO', () => {
|
|
||||||
const driversDTO: DriversLeaderboardDTO = {
|
|
||||||
drivers: [
|
|
||||||
{
|
|
||||||
id: 'driver-1',
|
|
||||||
name: 'John Doe',
|
|
||||||
rating: 1234.56,
|
|
||||||
skillLevel: 'Pro',
|
|
||||||
category: 'Elite',
|
|
||||||
nationality: 'USA',
|
|
||||||
racesCompleted: 150,
|
|
||||||
wins: 25,
|
|
||||||
podiums: 60,
|
|
||||||
isActive: true,
|
|
||||||
rank: 1,
|
|
||||||
avatarUrl: 'https://example.com/john.jpg',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
totalRaces: 150,
|
|
||||||
totalWins: 25,
|
|
||||||
activeCount: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const originalDTO = JSON.parse(JSON.stringify(driversDTO));
|
|
||||||
DriversViewDataBuilder.build(driversDTO);
|
|
||||||
|
|
||||||
expect(driversDTO).toEqual(originalDTO);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should transform all numeric fields to formatted strings where appropriate', () => {
|
|
||||||
const driversDTO: DriversLeaderboardDTO = {
|
|
||||||
drivers: [
|
|
||||||
{
|
|
||||||
id: 'driver-1',
|
|
||||||
name: 'John Doe',
|
|
||||||
rating: 1234.56,
|
|
||||||
skillLevel: 'Pro',
|
|
||||||
nationality: 'USA',
|
|
||||||
racesCompleted: 150,
|
|
||||||
wins: 25,
|
|
||||||
podiums: 60,
|
|
||||||
isActive: true,
|
|
||||||
rank: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
totalRaces: 150,
|
|
||||||
totalWins: 25,
|
|
||||||
activeCount: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = DriversViewDataBuilder.build(driversDTO);
|
|
||||||
|
|
||||||
// Rating label should be a formatted string
|
|
||||||
expect(typeof result.drivers[0].ratingLabel).toBe('string');
|
|
||||||
expect(result.drivers[0].ratingLabel).toBe('1,235');
|
|
||||||
|
|
||||||
// Total counts should be formatted strings
|
|
||||||
expect(typeof result.totalRacesLabel).toBe('string');
|
|
||||||
expect(result.totalRacesLabel).toBe('150');
|
|
||||||
expect(typeof result.totalWinsLabel).toBe('string');
|
|
||||||
expect(result.totalWinsLabel).toBe('25');
|
|
||||||
expect(typeof result.activeCountLabel).toBe('string');
|
|
||||||
expect(result.activeCountLabel).toBe('1');
|
|
||||||
expect(typeof result.totalDriversLabel).toBe('string');
|
|
||||||
expect(result.totalDriversLabel).toBe('1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle large numbers correctly', () => {
|
|
||||||
const driversDTO: DriversLeaderboardDTO = {
|
|
||||||
drivers: [
|
|
||||||
{
|
|
||||||
id: 'driver-1',
|
|
||||||
name: 'John Doe',
|
|
||||||
rating: 999999.99,
|
|
||||||
skillLevel: 'Pro',
|
|
||||||
nationality: 'USA',
|
|
||||||
racesCompleted: 10000,
|
|
||||||
wins: 2500,
|
|
||||||
podiums: 5000,
|
|
||||||
isActive: true,
|
|
||||||
rank: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
totalRaces: 10000,
|
|
||||||
totalWins: 2500,
|
|
||||||
activeCount: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = DriversViewDataBuilder.build(driversDTO);
|
|
||||||
|
|
||||||
expect(result.drivers[0].ratingLabel).toBe('1,000,000');
|
|
||||||
expect(result.totalRacesLabel).toBe('10000');
|
|
||||||
expect(result.totalWinsLabel).toBe('2500');
|
|
||||||
expect(result.activeCountLabel).toBe('1');
|
|
||||||
expect(result.totalDriversLabel).toBe('1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('edge cases', () => {
|
|
||||||
it('should handle null/undefined rating', () => {
|
|
||||||
const driversDTO: DriversLeaderboardDTO = {
|
|
||||||
drivers: [
|
|
||||||
{
|
|
||||||
id: 'driver-1',
|
|
||||||
name: 'John Doe',
|
|
||||||
rating: 0,
|
|
||||||
skillLevel: 'Pro',
|
|
||||||
nationality: 'USA',
|
|
||||||
racesCompleted: 150,
|
|
||||||
wins: 25,
|
|
||||||
podiums: 60,
|
|
||||||
isActive: true,
|
|
||||||
rank: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
totalRaces: 150,
|
|
||||||
totalWins: 25,
|
|
||||||
activeCount: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = DriversViewDataBuilder.build(driversDTO);
|
|
||||||
|
|
||||||
expect(result.drivers[0].ratingLabel).toBe('0');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle drivers with no category', () => {
|
|
||||||
const driversDTO: DriversLeaderboardDTO = {
|
|
||||||
drivers: [
|
|
||||||
{
|
|
||||||
id: 'driver-1',
|
|
||||||
name: 'John Doe',
|
|
||||||
rating: 1234.56,
|
|
||||||
skillLevel: 'Pro',
|
|
||||||
nationality: 'USA',
|
|
||||||
racesCompleted: 150,
|
|
||||||
wins: 25,
|
|
||||||
podiums: 60,
|
|
||||||
isActive: true,
|
|
||||||
rank: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
totalRaces: 150,
|
|
||||||
totalWins: 25,
|
|
||||||
activeCount: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = DriversViewDataBuilder.build(driversDTO);
|
|
||||||
|
|
||||||
expect(result.drivers[0].category).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle inactive drivers', () => {
|
|
||||||
const driversDTO: DriversLeaderboardDTO = {
|
|
||||||
drivers: [
|
|
||||||
{
|
|
||||||
id: 'driver-1',
|
|
||||||
name: 'John Doe',
|
|
||||||
rating: 1234.56,
|
|
||||||
skillLevel: 'Pro',
|
|
||||||
nationality: 'USA',
|
|
||||||
racesCompleted: 150,
|
|
||||||
wins: 25,
|
|
||||||
podiums: 60,
|
|
||||||
isActive: false,
|
|
||||||
rank: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
totalRaces: 150,
|
|
||||||
totalWins: 25,
|
|
||||||
activeCount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = DriversViewDataBuilder.build(driversDTO);
|
|
||||||
|
|
||||||
expect(result.drivers[0].isActive).toBe(false);
|
|
||||||
expect(result.activeCount).toBe(0);
|
|
||||||
expect(result.activeCountLabel).toBe('0');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('derived fields', () => {
|
|
||||||
it('should correctly calculate total drivers label', () => {
|
|
||||||
const driversDTO: DriversLeaderboardDTO = {
|
|
||||||
drivers: [
|
|
||||||
{ id: 'driver-1', name: 'John Doe', rating: 1234.56, skillLevel: 'Pro', nationality: 'USA', racesCompleted: 150, wins: 25, podiums: 60, isActive: true, rank: 1 },
|
|
||||||
{ id: 'driver-2', name: 'Jane Smith', rating: 1100.75, skillLevel: 'Advanced', nationality: 'Canada', racesCompleted: 120, wins: 15, podiums: 45, isActive: true, rank: 2 },
|
|
||||||
{ id: 'driver-3', name: 'Bob Wilson', rating: 950.25, skillLevel: 'Intermediate', nationality: 'UK', racesCompleted: 80, wins: 5, podiums: 20, isActive: false, rank: 3 },
|
|
||||||
],
|
|
||||||
totalRaces: 350,
|
|
||||||
totalWins: 45,
|
|
||||||
activeCount: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = DriversViewDataBuilder.build(driversDTO);
|
|
||||||
|
|
||||||
expect(result.totalDriversLabel).toBe('3');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should correctly calculate active count', () => {
|
|
||||||
const driversDTO: DriversLeaderboardDTO = {
|
|
||||||
drivers: [
|
|
||||||
{ id: 'driver-1', name: 'John Doe', rating: 1234.56, skillLevel: 'Pro', nationality: 'USA', racesCompleted: 150, wins: 25, podiums: 60, isActive: true, rank: 1 },
|
|
||||||
{ id: 'driver-2', name: 'Jane Smith', rating: 1100.75, skillLevel: 'Advanced', nationality: 'Canada', racesCompleted: 120, wins: 15, podiums: 45, isActive: true, rank: 2 },
|
|
||||||
{ id: 'driver-3', name: 'Bob Wilson', rating: 950.25, skillLevel: 'Intermediate', nationality: 'UK', racesCompleted: 80, wins: 5, podiums: 20, isActive: false, rank: 3 },
|
|
||||||
],
|
|
||||||
totalRaces: 350,
|
|
||||||
totalWins: 45,
|
|
||||||
activeCount: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = DriversViewDataBuilder.build(driversDTO);
|
|
||||||
|
|
||||||
expect(result.activeCount).toBe(2);
|
|
||||||
expect(result.activeCountLabel).toBe('2');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('rating formatting', () => {
|
|
||||||
it('should format ratings with thousands separators', () => {
|
|
||||||
expect(RatingDisplay.format(1234.56)).toBe('1,235');
|
|
||||||
expect(RatingDisplay.format(9999.99)).toBe('10,000');
|
|
||||||
expect(RatingDisplay.format(100000.5)).toBe('100,001');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle null/undefined ratings', () => {
|
|
||||||
expect(RatingDisplay.format(null)).toBe('—');
|
|
||||||
expect(RatingDisplay.format(undefined)).toBe('—');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should round ratings correctly', () => {
|
|
||||||
expect(RatingDisplay.format(1234.4)).toBe('1,234');
|
|
||||||
expect(RatingDisplay.format(1234.6)).toBe('1,235');
|
|
||||||
expect(RatingDisplay.format(1234.5)).toBe('1,235');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('number formatting', () => {
|
|
||||||
it('should format numbers with thousands separators', () => {
|
|
||||||
expect(NumberDisplay.format(1234567)).toBe('1,234,567');
|
|
||||||
expect(NumberDisplay.format(1000)).toBe('1,000');
|
|
||||||
expect(NumberDisplay.format(999)).toBe('999');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle decimal numbers', () => {
|
|
||||||
expect(NumberDisplay.format(1234.567)).toBe('1,234.567');
|
|
||||||
expect(NumberDisplay.format(1000.5)).toBe('1,000.5');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DriverProfileViewDataBuilder', () => {
|
describe('DriverProfileViewDataBuilder', () => {
|
||||||
describe('happy paths', () => {
|
describe('happy paths', () => {
|
||||||
@@ -1643,531 +1193,4 @@ describe('DriverProfileViewDataBuilder', () => {
|
|||||||
expect(result.socialSummary.friends).toHaveLength(5);
|
expect(result.socialSummary.friends).toHaveLength(5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('date formatting', () => {
|
|
||||||
it('should format dates correctly', () => {
|
|
||||||
expect(DateDisplay.formatShort('2024-01-15T00:00:00Z')).toBe('Jan 15, 2024');
|
|
||||||
expect(DateDisplay.formatMonthYear('2024-01-15T00:00:00Z')).toBe('Jan 2024');
|
|
||||||
expect(DateDisplay.formatShort('2024-12-25T00:00:00Z')).toBe('Dec 25, 2024');
|
|
||||||
expect(DateDisplay.formatMonthYear('2024-12-25T00:00:00Z')).toBe('Dec 2024');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('finish position formatting', () => {
|
|
||||||
it('should format finish positions correctly', () => {
|
|
||||||
expect(FinishDisplay.format(1)).toBe('P1');
|
|
||||||
expect(FinishDisplay.format(5)).toBe('P5');
|
|
||||||
expect(FinishDisplay.format(10)).toBe('P10');
|
|
||||||
expect(FinishDisplay.format(100)).toBe('P100');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle null/undefined finish positions', () => {
|
|
||||||
expect(FinishDisplay.format(null)).toBe('—');
|
|
||||||
expect(FinishDisplay.format(undefined)).toBe('—');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should format average finish positions correctly', () => {
|
|
||||||
expect(FinishDisplay.formatAverage(5.4)).toBe('P5.4');
|
|
||||||
expect(FinishDisplay.formatAverage(1.5)).toBe('P1.5');
|
|
||||||
expect(FinishDisplay.formatAverage(10.0)).toBe('P10.0');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle null/undefined average finish positions', () => {
|
|
||||||
expect(FinishDisplay.formatAverage(null)).toBe('—');
|
|
||||||
expect(FinishDisplay.formatAverage(undefined)).toBe('—');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('percentage formatting', () => {
|
|
||||||
it('should format percentages correctly', () => {
|
|
||||||
expect(PercentDisplay.format(0.1234)).toBe('12.3%');
|
|
||||||
expect(PercentDisplay.format(0.5)).toBe('50.0%');
|
|
||||||
expect(PercentDisplay.format(1.0)).toBe('100.0%');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle null/undefined percentages', () => {
|
|
||||||
expect(PercentDisplay.format(null)).toBe('0.0%');
|
|
||||||
expect(PercentDisplay.format(undefined)).toBe('0.0%');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should format whole percentages correctly', () => {
|
|
||||||
expect(PercentDisplay.formatWhole(85)).toBe('85%');
|
|
||||||
expect(PercentDisplay.formatWhole(50)).toBe('50%');
|
|
||||||
expect(PercentDisplay.formatWhole(100)).toBe('100%');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle null/undefined whole percentages', () => {
|
|
||||||
expect(PercentDisplay.formatWhole(null)).toBe('0%');
|
|
||||||
expect(PercentDisplay.formatWhole(undefined)).toBe('0%');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('cross-component consistency', () => {
|
|
||||||
it('should all use consistent formatting for numeric values', () => {
|
|
||||||
const profileDTO: GetDriverProfileOutputDTO = {
|
|
||||||
currentDriver: {
|
|
||||||
id: 'driver-123',
|
|
||||||
name: 'John Doe',
|
|
||||||
country: 'USA',
|
|
||||||
joinedAt: '2024-01-15T00:00:00Z',
|
|
||||||
rating: 1234.56,
|
|
||||||
globalRank: 42,
|
|
||||||
consistency: 85,
|
|
||||||
},
|
|
||||||
stats: {
|
|
||||||
totalRaces: 150,
|
|
||||||
wins: 25,
|
|
||||||
podiums: 60,
|
|
||||||
dnfs: 10,
|
|
||||||
avgFinish: 5.4,
|
|
||||||
bestFinish: 1,
|
|
||||||
worstFinish: 25,
|
|
||||||
finishRate: 0.933,
|
|
||||||
winRate: 0.167,
|
|
||||||
podiumRate: 0.4,
|
|
||||||
percentile: 95,
|
|
||||||
rating: 1234.56,
|
|
||||||
consistency: 85,
|
|
||||||
overallRank: 42,
|
|
||||||
},
|
|
||||||
finishDistribution: {
|
|
||||||
totalRaces: 150,
|
|
||||||
wins: 25,
|
|
||||||
podiums: 60,
|
|
||||||
topTen: 100,
|
|
||||||
dnfs: 10,
|
|
||||||
other: 55,
|
|
||||||
},
|
|
||||||
teamMemberships: [],
|
|
||||||
socialSummary: {
|
|
||||||
friendsCount: 0,
|
|
||||||
friends: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = DriverProfileViewDataBuilder.build(profileDTO);
|
|
||||||
|
|
||||||
// All numeric values should be formatted as strings
|
|
||||||
expect(typeof result.currentDriver?.ratingLabel).toBe('string');
|
|
||||||
expect(typeof result.currentDriver?.globalRankLabel).toBe('string');
|
|
||||||
expect(typeof result.stats?.totalRacesLabel).toBe('string');
|
|
||||||
expect(typeof result.stats?.winsLabel).toBe('string');
|
|
||||||
expect(typeof result.stats?.podiumsLabel).toBe('string');
|
|
||||||
expect(typeof result.stats?.dnfsLabel).toBe('string');
|
|
||||||
expect(typeof result.stats?.avgFinishLabel).toBe('string');
|
|
||||||
expect(typeof result.stats?.bestFinishLabel).toBe('string');
|
|
||||||
expect(typeof result.stats?.worstFinishLabel).toBe('string');
|
|
||||||
expect(typeof result.stats?.ratingLabel).toBe('string');
|
|
||||||
expect(typeof result.stats?.consistencyLabel).toBe('string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should all handle missing data gracefully', () => {
|
|
||||||
const profileDTO: GetDriverProfileOutputDTO = {
|
|
||||||
currentDriver: {
|
|
||||||
id: 'driver-123',
|
|
||||||
name: 'John Doe',
|
|
||||||
country: 'USA',
|
|
||||||
joinedAt: '2024-01-15T00:00:00Z',
|
|
||||||
},
|
|
||||||
stats: {
|
|
||||||
totalRaces: 0,
|
|
||||||
wins: 0,
|
|
||||||
podiums: 0,
|
|
||||||
dnfs: 0,
|
|
||||||
},
|
|
||||||
finishDistribution: {
|
|
||||||
totalRaces: 0,
|
|
||||||
wins: 0,
|
|
||||||
podiums: 0,
|
|
||||||
topTen: 0,
|
|
||||||
dnfs: 0,
|
|
||||||
other: 0,
|
|
||||||
},
|
|
||||||
teamMemberships: [],
|
|
||||||
socialSummary: {
|
|
||||||
friendsCount: 0,
|
|
||||||
friends: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = DriverProfileViewDataBuilder.build(profileDTO);
|
|
||||||
|
|
||||||
// All fields should have safe defaults
|
|
||||||
expect(result.currentDriver?.avatarUrl).toBe('');
|
|
||||||
expect(result.currentDriver?.iracingId).toBeNull();
|
|
||||||
expect(result.currentDriver?.rating).toBeNull();
|
|
||||||
expect(result.currentDriver?.ratingLabel).toBe('—');
|
|
||||||
expect(result.currentDriver?.globalRank).toBeNull();
|
|
||||||
expect(result.currentDriver?.globalRankLabel).toBe('—');
|
|
||||||
expect(result.currentDriver?.consistency).toBeNull();
|
|
||||||
expect(result.currentDriver?.bio).toBeNull();
|
|
||||||
expect(result.currentDriver?.totalDrivers).toBeNull();
|
|
||||||
expect(result.stats?.avgFinish).toBeNull();
|
|
||||||
expect(result.stats?.avgFinishLabel).toBe('—');
|
|
||||||
expect(result.stats?.bestFinish).toBeNull();
|
|
||||||
expect(result.stats?.bestFinishLabel).toBe('—');
|
|
||||||
expect(result.stats?.worstFinish).toBeNull();
|
|
||||||
expect(result.stats?.worstFinishLabel).toBe('—');
|
|
||||||
expect(result.stats?.finishRate).toBeNull();
|
|
||||||
expect(result.stats?.winRate).toBeNull();
|
|
||||||
expect(result.stats?.podiumRate).toBeNull();
|
|
||||||
expect(result.stats?.percentile).toBeNull();
|
|
||||||
expect(result.stats?.rating).toBeNull();
|
|
||||||
expect(result.stats?.ratingLabel).toBe('—');
|
|
||||||
expect(result.stats?.consistency).toBeNull();
|
|
||||||
expect(result.stats?.consistencyLabel).toBe('0%');
|
|
||||||
expect(result.stats?.overallRank).toBeNull();
|
|
||||||
expect(result.finishDistribution).not.toBeNull();
|
|
||||||
expect(result.teamMemberships).toEqual([]);
|
|
||||||
expect(result.socialSummary.friends).toEqual([]);
|
|
||||||
expect(result.extendedProfile).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should all preserve ISO timestamps for serialization', () => {
|
|
||||||
const profileDTO: GetDriverProfileOutputDTO = {
|
|
||||||
currentDriver: {
|
|
||||||
id: 'driver-123',
|
|
||||||
name: 'John Doe',
|
|
||||||
country: 'USA',
|
|
||||||
joinedAt: '2024-01-15T00:00:00Z',
|
|
||||||
},
|
|
||||||
stats: {
|
|
||||||
totalRaces: 150,
|
|
||||||
wins: 25,
|
|
||||||
podiums: 60,
|
|
||||||
dnfs: 10,
|
|
||||||
},
|
|
||||||
finishDistribution: {
|
|
||||||
totalRaces: 150,
|
|
||||||
wins: 25,
|
|
||||||
podiums: 60,
|
|
||||||
topTen: 100,
|
|
||||||
dnfs: 10,
|
|
||||||
other: 55,
|
|
||||||
},
|
|
||||||
teamMemberships: [
|
|
||||||
{
|
|
||||||
teamId: 'team-1',
|
|
||||||
teamName: 'Elite Racing',
|
|
||||||
teamTag: 'ER',
|
|
||||||
role: 'Driver',
|
|
||||||
joinedAt: '2024-01-15T00:00:00Z',
|
|
||||||
isCurrent: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
socialSummary: {
|
|
||||||
friendsCount: 0,
|
|
||||||
friends: [],
|
|
||||||
},
|
|
||||||
extendedProfile: {
|
|
||||||
socialHandles: [],
|
|
||||||
achievements: [
|
|
||||||
{
|
|
||||||
id: 'ach-1',
|
|
||||||
title: 'Champion',
|
|
||||||
description: 'Won the championship',
|
|
||||||
icon: 'trophy',
|
|
||||||
rarity: 'Legendary',
|
|
||||||
earnedAt: '2024-01-15T00:00:00Z',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
racingStyle: 'Aggressive',
|
|
||||||
favoriteTrack: 'Spa',
|
|
||||||
favoriteCar: 'Porsche 911 GT3',
|
|
||||||
timezone: 'America/New_York',
|
|
||||||
availableHours: 'Evenings',
|
|
||||||
lookingForTeam: false,
|
|
||||||
openToRequests: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = DriverProfileViewDataBuilder.build(profileDTO);
|
|
||||||
|
|
||||||
// All timestamps should be preserved as ISO strings
|
|
||||||
expect(result.currentDriver?.joinedAt).toBe('2024-01-15T00:00:00Z');
|
|
||||||
expect(result.teamMemberships[0].joinedAt).toBe('2024-01-15T00:00:00Z');
|
|
||||||
expect(result.extendedProfile?.achievements[0].earnedAt).toBe('2024-01-15T00:00:00Z');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should all handle boolean flags correctly', () => {
|
|
||||||
const profileDTO: GetDriverProfileOutputDTO = {
|
|
||||||
currentDriver: {
|
|
||||||
id: 'driver-123',
|
|
||||||
name: 'John Doe',
|
|
||||||
country: 'USA',
|
|
||||||
joinedAt: '2024-01-15T00:00:00Z',
|
|
||||||
},
|
|
||||||
stats: {
|
|
||||||
totalRaces: 150,
|
|
||||||
wins: 25,
|
|
||||||
podiums: 60,
|
|
||||||
dnfs: 10,
|
|
||||||
},
|
|
||||||
finishDistribution: {
|
|
||||||
totalRaces: 150,
|
|
||||||
wins: 25,
|
|
||||||
podiums: 60,
|
|
||||||
topTen: 100,
|
|
||||||
dnfs: 10,
|
|
||||||
other: 55,
|
|
||||||
},
|
|
||||||
teamMemberships: [
|
|
||||||
{
|
|
||||||
teamId: 'team-1',
|
|
||||||
teamName: 'Elite Racing',
|
|
||||||
teamTag: 'ER',
|
|
||||||
role: 'Driver',
|
|
||||||
joinedAt: '2024-01-15T00:00:00Z',
|
|
||||||
isCurrent: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
teamId: 'team-2',
|
|
||||||
teamName: 'Old Team',
|
|
||||||
teamTag: 'OT',
|
|
||||||
role: 'Driver',
|
|
||||||
joinedAt: '2023-01-15T00:00:00Z',
|
|
||||||
isCurrent: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
socialSummary: {
|
|
||||||
friendsCount: 0,
|
|
||||||
friends: [],
|
|
||||||
},
|
|
||||||
extendedProfile: {
|
|
||||||
socialHandles: [],
|
|
||||||
achievements: [],
|
|
||||||
racingStyle: 'Aggressive',
|
|
||||||
favoriteTrack: 'Spa',
|
|
||||||
favoriteCar: 'Porsche 911 GT3',
|
|
||||||
timezone: 'America/New_York',
|
|
||||||
availableHours: 'Evenings',
|
|
||||||
lookingForTeam: true,
|
|
||||||
openToRequests: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = DriverProfileViewDataBuilder.build(profileDTO);
|
|
||||||
|
|
||||||
expect(result.teamMemberships[0].isCurrent).toBe(true);
|
|
||||||
expect(result.teamMemberships[1].isCurrent).toBe(false);
|
|
||||||
expect(result.extendedProfile?.lookingForTeam).toBe(true);
|
|
||||||
expect(result.extendedProfile?.openToRequests).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('data integrity', () => {
|
|
||||||
it('should maintain data consistency across transformations', () => {
|
|
||||||
const profileDTO: GetDriverProfileOutputDTO = {
|
|
||||||
currentDriver: {
|
|
||||||
id: 'driver-123',
|
|
||||||
name: 'John Doe',
|
|
||||||
country: 'USA',
|
|
||||||
avatarUrl: 'https://example.com/avatar.jpg',
|
|
||||||
iracingId: '12345',
|
|
||||||
joinedAt: '2024-01-15T00:00:00Z',
|
|
||||||
rating: 1234.56,
|
|
||||||
globalRank: 42,
|
|
||||||
consistency: 85,
|
|
||||||
bio: 'Professional sim racer.',
|
|
||||||
totalDrivers: 1000,
|
|
||||||
},
|
|
||||||
stats: {
|
|
||||||
totalRaces: 150,
|
|
||||||
wins: 25,
|
|
||||||
podiums: 60,
|
|
||||||
dnfs: 10,
|
|
||||||
avgFinish: 5.4,
|
|
||||||
bestFinish: 1,
|
|
||||||
worstFinish: 25,
|
|
||||||
finishRate: 0.933,
|
|
||||||
winRate: 0.167,
|
|
||||||
podiumRate: 0.4,
|
|
||||||
percentile: 95,
|
|
||||||
rating: 1234.56,
|
|
||||||
consistency: 85,
|
|
||||||
overallRank: 42,
|
|
||||||
},
|
|
||||||
finishDistribution: {
|
|
||||||
totalRaces: 150,
|
|
||||||
wins: 25,
|
|
||||||
podiums: 60,
|
|
||||||
topTen: 100,
|
|
||||||
dnfs: 10,
|
|
||||||
other: 55,
|
|
||||||
},
|
|
||||||
teamMemberships: [
|
|
||||||
{
|
|
||||||
teamId: 'team-1',
|
|
||||||
teamName: 'Elite Racing',
|
|
||||||
teamTag: 'ER',
|
|
||||||
role: 'Driver',
|
|
||||||
joinedAt: '2024-01-15T00:00:00Z',
|
|
||||||
isCurrent: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
socialSummary: {
|
|
||||||
friendsCount: 2,
|
|
||||||
friends: [
|
|
||||||
{ id: 'friend-1', name: 'Alice', country: 'UK', avatarUrl: 'https://example.com/alice.jpg' },
|
|
||||||
{ id: 'friend-2', name: 'Bob', country: 'Germany' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
extendedProfile: {
|
|
||||||
socialHandles: [
|
|
||||||
{ platform: 'Twitter', handle: '@johndoe', url: 'https://twitter.com/johndoe' },
|
|
||||||
],
|
|
||||||
achievements: [
|
|
||||||
{ id: 'ach-1', title: 'Champion', description: 'Won the championship', icon: 'trophy', rarity: 'Legendary', earnedAt: '2024-01-15T00:00:00Z' },
|
|
||||||
],
|
|
||||||
racingStyle: 'Aggressive',
|
|
||||||
favoriteTrack: 'Spa',
|
|
||||||
favoriteCar: 'Porsche 911 GT3',
|
|
||||||
timezone: 'America/New_York',
|
|
||||||
availableHours: 'Evenings',
|
|
||||||
lookingForTeam: false,
|
|
||||||
openToRequests: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = DriverProfileViewDataBuilder.build(profileDTO);
|
|
||||||
|
|
||||||
// Verify derived fields match their source data
|
|
||||||
expect(result.socialSummary.friendsCount).toBe(profileDTO.socialSummary.friends.length);
|
|
||||||
expect(result.teamMemberships.length).toBe(profileDTO.teamMemberships.length);
|
|
||||||
expect(result.extendedProfile?.achievements.length).toBe(profileDTO.extendedProfile?.achievements.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle complex real-world scenarios', () => {
|
|
||||||
const profileDTO: GetDriverProfileOutputDTO = {
|
|
||||||
currentDriver: {
|
|
||||||
id: 'driver-123',
|
|
||||||
name: 'John Doe',
|
|
||||||
country: 'USA',
|
|
||||||
avatarUrl: 'https://example.com/avatar.jpg',
|
|
||||||
iracingId: '12345',
|
|
||||||
joinedAt: '2024-01-15T00:00:00Z',
|
|
||||||
rating: 2456.78,
|
|
||||||
globalRank: 15,
|
|
||||||
consistency: 92.5,
|
|
||||||
bio: 'Professional sim racer with 5 years of experience. Specializes in GT3 racing.',
|
|
||||||
totalDrivers: 1000,
|
|
||||||
},
|
|
||||||
stats: {
|
|
||||||
totalRaces: 250,
|
|
||||||
wins: 45,
|
|
||||||
podiums: 120,
|
|
||||||
dnfs: 15,
|
|
||||||
avgFinish: 4.2,
|
|
||||||
bestFinish: 1,
|
|
||||||
worstFinish: 30,
|
|
||||||
finishRate: 0.94,
|
|
||||||
winRate: 0.18,
|
|
||||||
podiumRate: 0.48,
|
|
||||||
percentile: 98,
|
|
||||||
rating: 2456.78,
|
|
||||||
consistency: 92.5,
|
|
||||||
overallRank: 15,
|
|
||||||
},
|
|
||||||
finishDistribution: {
|
|
||||||
totalRaces: 250,
|
|
||||||
wins: 45,
|
|
||||||
podiums: 120,
|
|
||||||
topTen: 180,
|
|
||||||
dnfs: 15,
|
|
||||||
other: 55,
|
|
||||||
},
|
|
||||||
teamMemberships: [
|
|
||||||
{
|
|
||||||
teamId: 'team-1',
|
|
||||||
teamName: 'Elite Racing',
|
|
||||||
teamTag: 'ER',
|
|
||||||
role: 'Driver',
|
|
||||||
joinedAt: '2024-01-15T00:00:00Z',
|
|
||||||
isCurrent: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
teamId: 'team-2',
|
|
||||||
teamName: 'Pro Team',
|
|
||||||
teamTag: 'PT',
|
|
||||||
role: 'Reserve Driver',
|
|
||||||
joinedAt: '2023-06-15T00:00:00Z',
|
|
||||||
isCurrent: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
socialSummary: {
|
|
||||||
friendsCount: 50,
|
|
||||||
friends: [
|
|
||||||
{ id: 'friend-1', name: 'Alice', country: 'UK', avatarUrl: 'https://example.com/alice.jpg' },
|
|
||||||
{ id: 'friend-2', name: 'Bob', country: 'Germany' },
|
|
||||||
{ id: 'friend-3', name: 'Charlie', country: 'France', avatarUrl: 'https://example.com/charlie.jpg' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
extendedProfile: {
|
|
||||||
socialHandles: [
|
|
||||||
{ platform: 'Twitter', handle: '@johndoe', url: 'https://twitter.com/johndoe' },
|
|
||||||
{ platform: 'Discord', handle: 'johndoe#1234', url: '' },
|
|
||||||
],
|
|
||||||
achievements: [
|
|
||||||
{ id: 'ach-1', title: 'Champion', description: 'Won the championship', icon: 'trophy', rarity: 'Legendary', earnedAt: '2024-01-15T00:00:00Z' },
|
|
||||||
{ id: 'ach-2', title: 'Podium Finisher', description: 'Finished on podium 100 times', icon: 'medal', rarity: 'Rare', earnedAt: '2023-12-01T00:00:00Z' },
|
|
||||||
],
|
|
||||||
racingStyle: 'Aggressive',
|
|
||||||
favoriteTrack: 'Spa',
|
|
||||||
favoriteCar: 'Porsche 911 GT3',
|
|
||||||
timezone: 'America/New_York',
|
|
||||||
availableHours: 'Evenings and Weekends',
|
|
||||||
lookingForTeam: false,
|
|
||||||
openToRequests: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = DriverProfileViewDataBuilder.build(profileDTO);
|
|
||||||
|
|
||||||
// Verify all transformations
|
|
||||||
expect(result.currentDriver?.name).toBe('John Doe');
|
|
||||||
expect(result.currentDriver?.ratingLabel).toBe('2,457');
|
|
||||||
expect(result.currentDriver?.globalRankLabel).toBe('#15');
|
|
||||||
expect(result.currentDriver?.consistency).toBe(92.5);
|
|
||||||
expect(result.currentDriver?.bio).toBe('Professional sim racer with 5 years of experience. Specializes in GT3 racing.');
|
|
||||||
|
|
||||||
expect(result.stats?.totalRacesLabel).toBe('250');
|
|
||||||
expect(result.stats?.winsLabel).toBe('45');
|
|
||||||
expect(result.stats?.podiumsLabel).toBe('120');
|
|
||||||
expect(result.stats?.dnfsLabel).toBe('15');
|
|
||||||
expect(result.stats?.avgFinishLabel).toBe('P4.2');
|
|
||||||
expect(result.stats?.bestFinishLabel).toBe('P1');
|
|
||||||
expect(result.stats?.worstFinishLabel).toBe('P30');
|
|
||||||
expect(result.stats?.finishRate).toBe(0.94);
|
|
||||||
expect(result.stats?.winRate).toBe(0.18);
|
|
||||||
expect(result.stats?.podiumRate).toBe(0.48);
|
|
||||||
expect(result.stats?.percentile).toBe(98);
|
|
||||||
expect(result.stats?.ratingLabel).toBe('2,457');
|
|
||||||
expect(result.stats?.consistencyLabel).toBe('92.5%');
|
|
||||||
expect(result.stats?.overallRank).toBe(15);
|
|
||||||
|
|
||||||
expect(result.finishDistribution?.totalRaces).toBe(250);
|
|
||||||
expect(result.finishDistribution?.wins).toBe(45);
|
|
||||||
expect(result.finishDistribution?.podiums).toBe(120);
|
|
||||||
expect(result.finishDistribution?.topTen).toBe(180);
|
|
||||||
expect(result.finishDistribution?.dnfs).toBe(15);
|
|
||||||
expect(result.finishDistribution?.other).toBe(55);
|
|
||||||
|
|
||||||
expect(result.teamMemberships).toHaveLength(2);
|
|
||||||
expect(result.teamMemberships[0].isCurrent).toBe(true);
|
|
||||||
expect(result.teamMemberships[1].isCurrent).toBe(false);
|
|
||||||
|
|
||||||
expect(result.socialSummary.friendsCount).toBe(50);
|
|
||||||
expect(result.socialSummary.friends).toHaveLength(3);
|
|
||||||
expect(result.socialSummary.friends[0].avatarUrl).toBe('https://example.com/alice.jpg');
|
|
||||||
expect(result.socialSummary.friends[1].avatarUrl).toBe('');
|
|
||||||
expect(result.socialSummary.friends[2].avatarUrl).toBe('https://example.com/charlie.jpg');
|
|
||||||
|
|
||||||
expect(result.extendedProfile?.socialHandles).toHaveLength(2);
|
|
||||||
expect(result.extendedProfile?.achievements).toHaveLength(2);
|
|
||||||
expect(result.extendedProfile?.achievements[0].rarityLabel).toBe('Legendary');
|
|
||||||
expect(result.extendedProfile?.achievements[1].rarityLabel).toBe('Rare');
|
|
||||||
expect(result.extendedProfile?.lookingForTeam).toBe(false);
|
|
||||||
expect(result.extendedProfile?.openToRequests).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -1,19 +1,16 @@
|
|||||||
import type { GetDriverProfileOutputDTO } from '@/lib/types/generated/GetDriverProfileOutputDTO';
|
|
||||||
import type { DriverProfileViewData } from '@/lib/types/view-data/DriverProfileViewData';
|
|
||||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
|
||||||
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
|
|
||||||
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
|
|
||||||
import { FinishDisplay } from '@/lib/display-objects/FinishDisplay';
|
|
||||||
import { PercentDisplay } from '@/lib/display-objects/PercentDisplay';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DriverProfileViewDataBuilder
|
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
|
||||||
*
|
import { DateFormatter } from '@/lib/formatters/DateFormatter';
|
||||||
* Transforms GetDriverProfileOutputDTO into ViewData for the driver profile page.
|
import { FinishFormatter } from '@/lib/formatters/FinishFormatter';
|
||||||
* Deterministic, side-effect free, no HTTP calls.
|
import { NumberFormatter } from '@/lib/formatters/NumberFormatter';
|
||||||
*/
|
import { PercentFormatter } from '@/lib/formatters/PercentFormatter';
|
||||||
|
import { RatingFormatter } from '@/lib/formatters/RatingFormatter';
|
||||||
|
import type { GetDriverProfileOutputDTO } from '@/lib/types/generated/GetDriverProfileOutputDTO';
|
||||||
|
import type { DriverProfileViewData } from '@/lib/view-data/DriverProfileViewData';
|
||||||
|
|
||||||
export class DriverProfileViewDataBuilder {
|
export class DriverProfileViewDataBuilder {
|
||||||
static build(apiDto: GetDriverProfileOutputDTO): DriverProfileViewData {
|
public static build(apiDto: GetDriverProfileOutputDTO): DriverProfileViewData {
|
||||||
return {
|
return {
|
||||||
currentDriver: apiDto.currentDriver ? {
|
currentDriver: apiDto.currentDriver ? {
|
||||||
id: apiDto.currentDriver.id,
|
id: apiDto.currentDriver.id,
|
||||||
@@ -22,9 +19,9 @@ export class DriverProfileViewDataBuilder {
|
|||||||
avatarUrl: apiDto.currentDriver.avatarUrl || '',
|
avatarUrl: apiDto.currentDriver.avatarUrl || '',
|
||||||
iracingId: typeof apiDto.currentDriver.iracingId === 'string' ? parseInt(apiDto.currentDriver.iracingId, 10) : (apiDto.currentDriver.iracingId ?? null),
|
iracingId: typeof apiDto.currentDriver.iracingId === 'string' ? parseInt(apiDto.currentDriver.iracingId, 10) : (apiDto.currentDriver.iracingId ?? null),
|
||||||
joinedAt: apiDto.currentDriver.joinedAt,
|
joinedAt: apiDto.currentDriver.joinedAt,
|
||||||
joinedAtLabel: DateDisplay.formatMonthYear(apiDto.currentDriver.joinedAt),
|
joinedAtLabel: DateFormatter.formatMonthYear(apiDto.currentDriver.joinedAt),
|
||||||
rating: apiDto.currentDriver.rating ?? null,
|
rating: apiDto.currentDriver.rating ?? null,
|
||||||
ratingLabel: RatingDisplay.format(apiDto.currentDriver.rating),
|
ratingLabel: RatingFormatter.format(apiDto.currentDriver.rating),
|
||||||
globalRank: apiDto.currentDriver.globalRank ?? null,
|
globalRank: apiDto.currentDriver.globalRank ?? null,
|
||||||
globalRankLabel: apiDto.currentDriver.globalRank != null ? `#${apiDto.currentDriver.globalRank}` : '—',
|
globalRankLabel: apiDto.currentDriver.globalRank != null ? `#${apiDto.currentDriver.globalRank}` : '—',
|
||||||
consistency: apiDto.currentDriver.consistency ?? null,
|
consistency: apiDto.currentDriver.consistency ?? null,
|
||||||
@@ -33,27 +30,27 @@ export class DriverProfileViewDataBuilder {
|
|||||||
} : null,
|
} : null,
|
||||||
stats: apiDto.stats ? {
|
stats: apiDto.stats ? {
|
||||||
totalRaces: apiDto.stats.totalRaces,
|
totalRaces: apiDto.stats.totalRaces,
|
||||||
totalRacesLabel: NumberDisplay.format(apiDto.stats.totalRaces),
|
totalRacesLabel: NumberFormatter.format(apiDto.stats.totalRaces),
|
||||||
wins: apiDto.stats.wins,
|
wins: apiDto.stats.wins,
|
||||||
winsLabel: NumberDisplay.format(apiDto.stats.wins),
|
winsLabel: NumberFormatter.format(apiDto.stats.wins),
|
||||||
podiums: apiDto.stats.podiums,
|
podiums: apiDto.stats.podiums,
|
||||||
podiumsLabel: NumberDisplay.format(apiDto.stats.podiums),
|
podiumsLabel: NumberFormatter.format(apiDto.stats.podiums),
|
||||||
dnfs: apiDto.stats.dnfs,
|
dnfs: apiDto.stats.dnfs,
|
||||||
dnfsLabel: NumberDisplay.format(apiDto.stats.dnfs),
|
dnfsLabel: NumberFormatter.format(apiDto.stats.dnfs),
|
||||||
avgFinish: apiDto.stats.avgFinish ?? null,
|
avgFinish: apiDto.stats.avgFinish ?? null,
|
||||||
avgFinishLabel: FinishDisplay.formatAverage(apiDto.stats.avgFinish),
|
avgFinishLabel: FinishFormatter.formatAverage(apiDto.stats.avgFinish),
|
||||||
bestFinish: apiDto.stats.bestFinish ?? null,
|
bestFinish: apiDto.stats.bestFinish ?? null,
|
||||||
bestFinishLabel: FinishDisplay.format(apiDto.stats.bestFinish),
|
bestFinishLabel: FinishFormatter.format(apiDto.stats.bestFinish),
|
||||||
worstFinish: apiDto.stats.worstFinish ?? null,
|
worstFinish: apiDto.stats.worstFinish ?? null,
|
||||||
worstFinishLabel: FinishDisplay.format(apiDto.stats.worstFinish),
|
worstFinishLabel: FinishFormatter.format(apiDto.stats.worstFinish),
|
||||||
finishRate: apiDto.stats.finishRate ?? null,
|
finishRate: apiDto.stats.finishRate ?? null,
|
||||||
winRate: apiDto.stats.winRate ?? null,
|
winRate: apiDto.stats.winRate ?? null,
|
||||||
podiumRate: apiDto.stats.podiumRate ?? null,
|
podiumRate: apiDto.stats.podiumRate ?? null,
|
||||||
percentile: apiDto.stats.percentile ?? null,
|
percentile: apiDto.stats.percentile ?? null,
|
||||||
rating: apiDto.stats.rating ?? null,
|
rating: apiDto.stats.rating ?? null,
|
||||||
ratingLabel: RatingDisplay.format(apiDto.stats.rating),
|
ratingLabel: RatingFormatter.format(apiDto.stats.rating),
|
||||||
consistency: apiDto.stats.consistency ?? null,
|
consistency: apiDto.stats.consistency ?? null,
|
||||||
consistencyLabel: PercentDisplay.formatWhole(apiDto.stats.consistency),
|
consistencyLabel: PercentFormatter.formatWhole(apiDto.stats.consistency),
|
||||||
overallRank: apiDto.stats.overallRank ?? null,
|
overallRank: apiDto.stats.overallRank ?? null,
|
||||||
} : null,
|
} : null,
|
||||||
finishDistribution: apiDto.finishDistribution ? {
|
finishDistribution: apiDto.finishDistribution ? {
|
||||||
@@ -70,7 +67,7 @@ export class DriverProfileViewDataBuilder {
|
|||||||
teamTag: m.teamTag ?? null,
|
teamTag: m.teamTag ?? null,
|
||||||
role: m.role,
|
role: m.role,
|
||||||
joinedAt: m.joinedAt,
|
joinedAt: m.joinedAt,
|
||||||
joinedAtLabel: DateDisplay.formatMonthYear(m.joinedAt),
|
joinedAtLabel: DateFormatter.formatMonthYear(m.joinedAt),
|
||||||
isCurrent: m.isCurrent,
|
isCurrent: m.isCurrent,
|
||||||
})),
|
})),
|
||||||
socialSummary: {
|
socialSummary: {
|
||||||
@@ -96,7 +93,7 @@ export class DriverProfileViewDataBuilder {
|
|||||||
rarity: a.rarity,
|
rarity: a.rarity,
|
||||||
rarityLabel: a.rarity,
|
rarityLabel: a.rarity,
|
||||||
earnedAt: a.earnedAt,
|
earnedAt: a.earnedAt,
|
||||||
earnedAtLabel: DateDisplay.formatShort(a.earnedAt),
|
earnedAtLabel: DateFormatter.formatShort(a.earnedAt),
|
||||||
})),
|
})),
|
||||||
racingStyle: apiDto.extendedProfile.racingStyle,
|
racingStyle: apiDto.extendedProfile.racingStyle,
|
||||||
favoriteTrack: apiDto.extendedProfile.favoriteTrack,
|
favoriteTrack: apiDto.extendedProfile.favoriteTrack,
|
||||||
@@ -109,3 +106,5 @@ export class DriverProfileViewDataBuilder {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DriverProfileViewDataBuilder satisfies ViewDataBuilder<GetDriverProfileOutputDTO, DriverProfileViewData>;
|
||||||
|
|||||||
@@ -0,0 +1,441 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { DriverRankingsViewDataBuilder } from './DriverRankingsViewDataBuilder';
|
||||||
|
import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO';
|
||||||
|
|
||||||
|
describe('DriverRankingsViewDataBuilder', () => {
|
||||||
|
describe('happy paths', () => {
|
||||||
|
it('should transform DriverLeaderboardItemDTO array to DriverRankingsViewData correctly', () => {
|
||||||
|
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||||
|
{
|
||||||
|
id: 'driver-1',
|
||||||
|
name: 'John Doe',
|
||||||
|
rating: 1234.56,
|
||||||
|
skillLevel: 'pro',
|
||||||
|
nationality: 'USA',
|
||||||
|
racesCompleted: 150,
|
||||||
|
wins: 25,
|
||||||
|
podiums: 60,
|
||||||
|
isActive: true,
|
||||||
|
rank: 1,
|
||||||
|
avatarUrl: 'https://example.com/avatar1.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'driver-2',
|
||||||
|
name: 'Jane Smith',
|
||||||
|
rating: 1100.0,
|
||||||
|
skillLevel: 'advanced',
|
||||||
|
nationality: 'Canada',
|
||||||
|
racesCompleted: 100,
|
||||||
|
wins: 15,
|
||||||
|
podiums: 40,
|
||||||
|
isActive: true,
|
||||||
|
rank: 2,
|
||||||
|
avatarUrl: 'https://example.com/avatar2.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'driver-3',
|
||||||
|
name: 'Bob Johnson',
|
||||||
|
rating: 950.0,
|
||||||
|
skillLevel: 'intermediate',
|
||||||
|
nationality: 'UK',
|
||||||
|
racesCompleted: 80,
|
||||||
|
wins: 10,
|
||||||
|
podiums: 30,
|
||||||
|
isActive: true,
|
||||||
|
rank: 3,
|
||||||
|
avatarUrl: 'https://example.com/avatar3.jpg',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||||
|
|
||||||
|
// Verify drivers
|
||||||
|
expect(result.drivers).toHaveLength(3);
|
||||||
|
expect(result.drivers[0].id).toBe('driver-1');
|
||||||
|
expect(result.drivers[0].name).toBe('John Doe');
|
||||||
|
expect(result.drivers[0].rating).toBe(1234.56);
|
||||||
|
expect(result.drivers[0].skillLevel).toBe('pro');
|
||||||
|
expect(result.drivers[0].nationality).toBe('USA');
|
||||||
|
expect(result.drivers[0].racesCompleted).toBe(150);
|
||||||
|
expect(result.drivers[0].wins).toBe(25);
|
||||||
|
expect(result.drivers[0].podiums).toBe(60);
|
||||||
|
expect(result.drivers[0].rank).toBe(1);
|
||||||
|
expect(result.drivers[0].avatarUrl).toBe('https://example.com/avatar1.jpg');
|
||||||
|
expect(result.drivers[0].winRate).toBe('16.7');
|
||||||
|
expect(result.drivers[0].medalBg).toBe('bg-warning-amber');
|
||||||
|
expect(result.drivers[0].medalColor).toBe('text-warning-amber');
|
||||||
|
|
||||||
|
// Verify podium (top 3 with special ordering: 2nd, 1st, 3rd)
|
||||||
|
expect(result.podium).toHaveLength(3);
|
||||||
|
expect(result.podium[0].id).toBe('driver-1');
|
||||||
|
expect(result.podium[0].name).toBe('John Doe');
|
||||||
|
expect(result.podium[0].rating).toBe(1234.56);
|
||||||
|
expect(result.podium[0].wins).toBe(25);
|
||||||
|
expect(result.podium[0].podiums).toBe(60);
|
||||||
|
expect(result.podium[0].avatarUrl).toBe('https://example.com/avatar1.jpg');
|
||||||
|
expect(result.podium[0].position).toBe(2); // 2nd place
|
||||||
|
|
||||||
|
expect(result.podium[1].id).toBe('driver-2');
|
||||||
|
expect(result.podium[1].position).toBe(1); // 1st place
|
||||||
|
|
||||||
|
expect(result.podium[2].id).toBe('driver-3');
|
||||||
|
expect(result.podium[2].position).toBe(3); // 3rd place
|
||||||
|
|
||||||
|
// Verify default values
|
||||||
|
expect(result.searchQuery).toBe('');
|
||||||
|
expect(result.selectedSkill).toBe('all');
|
||||||
|
expect(result.sortBy).toBe('rank');
|
||||||
|
expect(result.showFilters).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty driver array', () => {
|
||||||
|
const driverDTOs: DriverLeaderboardItemDTO[] = [];
|
||||||
|
|
||||||
|
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||||
|
|
||||||
|
expect(result.drivers).toEqual([]);
|
||||||
|
expect(result.podium).toEqual([]);
|
||||||
|
expect(result.searchQuery).toBe('');
|
||||||
|
expect(result.selectedSkill).toBe('all');
|
||||||
|
expect(result.sortBy).toBe('rank');
|
||||||
|
expect(result.showFilters).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle less than 3 drivers for podium', () => {
|
||||||
|
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||||
|
{
|
||||||
|
id: 'driver-1',
|
||||||
|
name: 'John Doe',
|
||||||
|
rating: 1234.56,
|
||||||
|
skillLevel: 'pro',
|
||||||
|
nationality: 'USA',
|
||||||
|
racesCompleted: 150,
|
||||||
|
wins: 25,
|
||||||
|
podiums: 60,
|
||||||
|
isActive: true,
|
||||||
|
rank: 1,
|
||||||
|
avatarUrl: 'https://example.com/avatar1.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'driver-2',
|
||||||
|
name: 'Jane Smith',
|
||||||
|
rating: 1100.0,
|
||||||
|
skillLevel: 'advanced',
|
||||||
|
nationality: 'Canada',
|
||||||
|
racesCompleted: 100,
|
||||||
|
wins: 15,
|
||||||
|
podiums: 40,
|
||||||
|
isActive: true,
|
||||||
|
rank: 2,
|
||||||
|
avatarUrl: 'https://example.com/avatar2.jpg',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||||
|
|
||||||
|
expect(result.drivers).toHaveLength(2);
|
||||||
|
expect(result.podium).toHaveLength(2);
|
||||||
|
expect(result.podium[0].position).toBe(2); // 2nd place
|
||||||
|
expect(result.podium[1].position).toBe(1); // 1st place
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing avatar URLs with empty string fallback', () => {
|
||||||
|
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||||
|
{
|
||||||
|
id: 'driver-1',
|
||||||
|
name: 'John Doe',
|
||||||
|
rating: 1234.56,
|
||||||
|
skillLevel: 'pro',
|
||||||
|
nationality: 'USA',
|
||||||
|
racesCompleted: 150,
|
||||||
|
wins: 25,
|
||||||
|
podiums: 60,
|
||||||
|
isActive: true,
|
||||||
|
rank: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||||
|
|
||||||
|
expect(result.drivers[0].avatarUrl).toBe('');
|
||||||
|
expect(result.podium[0].avatarUrl).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate win rate correctly', () => {
|
||||||
|
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||||
|
{
|
||||||
|
id: 'driver-1',
|
||||||
|
name: 'John Doe',
|
||||||
|
rating: 1234.56,
|
||||||
|
skillLevel: 'pro',
|
||||||
|
nationality: 'USA',
|
||||||
|
racesCompleted: 100,
|
||||||
|
wins: 25,
|
||||||
|
podiums: 60,
|
||||||
|
isActive: true,
|
||||||
|
rank: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'driver-2',
|
||||||
|
name: 'Jane Smith',
|
||||||
|
rating: 1100.0,
|
||||||
|
skillLevel: 'advanced',
|
||||||
|
nationality: 'Canada',
|
||||||
|
racesCompleted: 50,
|
||||||
|
wins: 10,
|
||||||
|
podiums: 25,
|
||||||
|
isActive: true,
|
||||||
|
rank: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'driver-3',
|
||||||
|
name: 'Bob Johnson',
|
||||||
|
rating: 950.0,
|
||||||
|
skillLevel: 'intermediate',
|
||||||
|
nationality: 'UK',
|
||||||
|
racesCompleted: 0,
|
||||||
|
wins: 0,
|
||||||
|
podiums: 0,
|
||||||
|
isActive: true,
|
||||||
|
rank: 3,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||||
|
|
||||||
|
expect(result.drivers[0].winRate).toBe('25.0');
|
||||||
|
expect(result.drivers[1].winRate).toBe('20.0');
|
||||||
|
expect(result.drivers[2].winRate).toBe('0.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should assign correct medal colors based on position', () => {
|
||||||
|
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||||
|
{
|
||||||
|
id: 'driver-1',
|
||||||
|
name: 'John Doe',
|
||||||
|
rating: 1234.56,
|
||||||
|
skillLevel: 'pro',
|
||||||
|
nationality: 'USA',
|
||||||
|
racesCompleted: 150,
|
||||||
|
wins: 25,
|
||||||
|
podiums: 60,
|
||||||
|
isActive: true,
|
||||||
|
rank: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'driver-2',
|
||||||
|
name: 'Jane Smith',
|
||||||
|
rating: 1100.0,
|
||||||
|
skillLevel: 'advanced',
|
||||||
|
nationality: 'Canada',
|
||||||
|
racesCompleted: 100,
|
||||||
|
wins: 15,
|
||||||
|
podiums: 40,
|
||||||
|
isActive: true,
|
||||||
|
rank: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'driver-3',
|
||||||
|
name: 'Bob Johnson',
|
||||||
|
rating: 950.0,
|
||||||
|
skillLevel: 'intermediate',
|
||||||
|
nationality: 'UK',
|
||||||
|
racesCompleted: 80,
|
||||||
|
wins: 10,
|
||||||
|
podiums: 30,
|
||||||
|
isActive: true,
|
||||||
|
rank: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'driver-4',
|
||||||
|
name: 'Alice Brown',
|
||||||
|
rating: 800.0,
|
||||||
|
skillLevel: 'beginner',
|
||||||
|
nationality: 'Germany',
|
||||||
|
racesCompleted: 60,
|
||||||
|
wins: 5,
|
||||||
|
podiums: 15,
|
||||||
|
isActive: true,
|
||||||
|
rank: 4,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||||
|
|
||||||
|
expect(result.drivers[0].medalBg).toBe('bg-warning-amber');
|
||||||
|
expect(result.drivers[0].medalColor).toBe('text-warning-amber');
|
||||||
|
expect(result.drivers[1].medalBg).toBe('bg-gray-300');
|
||||||
|
expect(result.drivers[1].medalColor).toBe('text-gray-300');
|
||||||
|
expect(result.drivers[2].medalBg).toBe('bg-orange-700');
|
||||||
|
expect(result.drivers[2].medalColor).toBe('text-orange-700');
|
||||||
|
expect(result.drivers[3].medalBg).toBe('bg-gray-800');
|
||||||
|
expect(result.drivers[3].medalColor).toBe('text-gray-400');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('data transformation', () => {
|
||||||
|
it('should preserve all DTO fields in the output', () => {
|
||||||
|
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||||
|
{
|
||||||
|
id: 'driver-123',
|
||||||
|
name: 'John Doe',
|
||||||
|
rating: 1234.56,
|
||||||
|
skillLevel: 'pro',
|
||||||
|
nationality: 'USA',
|
||||||
|
racesCompleted: 150,
|
||||||
|
wins: 25,
|
||||||
|
podiums: 60,
|
||||||
|
isActive: true,
|
||||||
|
rank: 1,
|
||||||
|
avatarUrl: 'https://example.com/avatar.jpg',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||||
|
|
||||||
|
expect(result.drivers[0].name).toBe(driverDTOs[0].name);
|
||||||
|
expect(result.drivers[0].nationality).toBe(driverDTOs[0].nationality);
|
||||||
|
expect(result.drivers[0].avatarUrl).toBe(driverDTOs[0].avatarUrl);
|
||||||
|
expect(result.drivers[0].skillLevel).toBe(driverDTOs[0].skillLevel);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not modify the input DTO', () => {
|
||||||
|
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||||
|
{
|
||||||
|
id: 'driver-123',
|
||||||
|
name: 'John Doe',
|
||||||
|
rating: 1234.56,
|
||||||
|
skillLevel: 'pro',
|
||||||
|
nationality: 'USA',
|
||||||
|
racesCompleted: 150,
|
||||||
|
wins: 25,
|
||||||
|
podiums: 60,
|
||||||
|
isActive: true,
|
||||||
|
rank: 1,
|
||||||
|
avatarUrl: 'https://example.com/avatar.jpg',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const originalDTO = JSON.parse(JSON.stringify(driverDTOs));
|
||||||
|
DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||||
|
|
||||||
|
expect(driverDTOs).toEqual(originalDTO);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle large numbers correctly', () => {
|
||||||
|
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||||
|
{
|
||||||
|
id: 'driver-1',
|
||||||
|
name: 'John Doe',
|
||||||
|
rating: 999999.99,
|
||||||
|
skillLevel: 'pro',
|
||||||
|
nationality: 'USA',
|
||||||
|
racesCompleted: 10000,
|
||||||
|
wins: 2500,
|
||||||
|
podiums: 5000,
|
||||||
|
isActive: true,
|
||||||
|
rank: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||||
|
|
||||||
|
expect(result.drivers[0].rating).toBe(999999.99);
|
||||||
|
expect(result.drivers[0].wins).toBe(2500);
|
||||||
|
expect(result.drivers[0].podiums).toBe(5000);
|
||||||
|
expect(result.drivers[0].racesCompleted).toBe(10000);
|
||||||
|
expect(result.drivers[0].winRate).toBe('25.0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle null/undefined avatar URLs', () => {
|
||||||
|
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||||
|
{
|
||||||
|
id: 'driver-1',
|
||||||
|
name: 'John Doe',
|
||||||
|
rating: 1234.56,
|
||||||
|
skillLevel: 'pro',
|
||||||
|
nationality: 'USA',
|
||||||
|
racesCompleted: 150,
|
||||||
|
wins: 25,
|
||||||
|
podiums: 60,
|
||||||
|
isActive: true,
|
||||||
|
rank: 1,
|
||||||
|
avatarUrl: null as any,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||||
|
|
||||||
|
expect(result.drivers[0].avatarUrl).toBe('');
|
||||||
|
expect(result.podium[0].avatarUrl).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null/undefined rating', () => {
|
||||||
|
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||||
|
{
|
||||||
|
id: 'driver-1',
|
||||||
|
name: 'John Doe',
|
||||||
|
rating: null as any,
|
||||||
|
skillLevel: 'pro',
|
||||||
|
nationality: 'USA',
|
||||||
|
racesCompleted: 150,
|
||||||
|
wins: 25,
|
||||||
|
podiums: 60,
|
||||||
|
isActive: true,
|
||||||
|
rank: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||||
|
|
||||||
|
expect(result.drivers[0].rating).toBeNull();
|
||||||
|
expect(result.podium[0].rating).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero races completed for win rate calculation', () => {
|
||||||
|
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||||
|
{
|
||||||
|
id: 'driver-1',
|
||||||
|
name: 'John Doe',
|
||||||
|
rating: 1234.56,
|
||||||
|
skillLevel: 'pro',
|
||||||
|
nationality: 'USA',
|
||||||
|
racesCompleted: 0,
|
||||||
|
wins: 0,
|
||||||
|
podiums: 0,
|
||||||
|
isActive: true,
|
||||||
|
rank: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||||
|
|
||||||
|
expect(result.drivers[0].winRate).toBe('0.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle rank 0', () => {
|
||||||
|
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||||
|
{
|
||||||
|
id: 'driver-1',
|
||||||
|
name: 'John Doe',
|
||||||
|
rating: 1234.56,
|
||||||
|
skillLevel: 'pro',
|
||||||
|
nationality: 'USA',
|
||||||
|
racesCompleted: 150,
|
||||||
|
wins: 25,
|
||||||
|
podiums: 60,
|
||||||
|
isActive: true,
|
||||||
|
rank: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||||
|
|
||||||
|
expect(result.drivers[0].rank).toBe(0);
|
||||||
|
expect(result.drivers[0].medalBg).toBe('bg-gray-800');
|
||||||
|
expect(result.drivers[0].medalColor).toBe('text-gray-400');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
|
|
||||||
|
|
||||||
|
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
|
||||||
|
import { MedalFormatter } from '@/lib/formatters/MedalFormatter';
|
||||||
|
import { WinRateFormatter } from '@/lib/formatters/WinRateFormatter';
|
||||||
import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO';
|
import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO';
|
||||||
import type { DriverRankingsViewData } from '@/lib/view-data/DriverRankingsViewData';
|
import type { DriverRankingsViewData } from '@/lib/view-data/DriverRankingsViewData';
|
||||||
import { WinRateDisplay } from '@/lib/display-objects/WinRateDisplay';
|
|
||||||
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
|
|
||||||
|
|
||||||
export class DriverRankingsViewDataBuilder {
|
export class DriverRankingsViewDataBuilder {
|
||||||
static build(apiDto: DriverLeaderboardItemDTO[]): DriverRankingsViewData {
|
public static build(apiDto: DriverLeaderboardItemDTO[]): DriverRankingsViewData {
|
||||||
if (!apiDto || apiDto.length === 0) {
|
if (!apiDto || apiDto.length === 0) {
|
||||||
return {
|
return {
|
||||||
drivers: [],
|
drivers: [],
|
||||||
@@ -28,9 +31,9 @@ export class DriverRankingsViewDataBuilder {
|
|||||||
podiums: driver.podiums,
|
podiums: driver.podiums,
|
||||||
rank: driver.rank,
|
rank: driver.rank,
|
||||||
avatarUrl: driver.avatarUrl || '',
|
avatarUrl: driver.avatarUrl || '',
|
||||||
winRate: WinRateDisplay.calculate(driver.racesCompleted, driver.wins),
|
winRate: WinRateFormatter.calculate(driver.racesCompleted, driver.wins),
|
||||||
medalBg: MedalDisplay.getBg(driver.rank),
|
medalBg: MedalFormatter.getBg(driver.rank),
|
||||||
medalColor: MedalDisplay.getColor(driver.rank),
|
medalColor: MedalFormatter.getColor(driver.rank),
|
||||||
})),
|
})),
|
||||||
podium: apiDto.slice(0, 3).map((driver, index) => {
|
podium: apiDto.slice(0, 3).map((driver, index) => {
|
||||||
const positions = [2, 1, 3]; // Display order: 2nd, 1st, 3rd
|
const positions = [2, 1, 3]; // Display order: 2nd, 1st, 3rd
|
||||||
@@ -51,4 +54,6 @@ export class DriverRankingsViewDataBuilder {
|
|||||||
showFilters: false,
|
showFilters: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DriverRankingsViewDataBuilder satisfies ViewDataBuilder<DriverLeaderboardItemDTO[], DriverRankingsViewData>;
|
||||||
|
|||||||
@@ -0,0 +1,382 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { DriversViewDataBuilder } from './DriversViewDataBuilder';
|
||||||
|
import type { DriversLeaderboardDTO } from '@/lib/types/generated/DriversLeaderboardDTO';
|
||||||
|
|
||||||
|
describe('DriversViewDataBuilder', () => {
|
||||||
|
describe('happy paths', () => {
|
||||||
|
it('should transform DriversLeaderboardDTO to DriversViewData correctly', () => {
|
||||||
|
const driversDTO: DriversLeaderboardDTO = {
|
||||||
|
drivers: [
|
||||||
|
{
|
||||||
|
id: 'driver-1',
|
||||||
|
name: 'John Doe',
|
||||||
|
rating: 1234.56,
|
||||||
|
skillLevel: 'Pro',
|
||||||
|
category: 'Elite',
|
||||||
|
nationality: 'USA',
|
||||||
|
racesCompleted: 150,
|
||||||
|
wins: 25,
|
||||||
|
podiums: 60,
|
||||||
|
isActive: true,
|
||||||
|
rank: 1,
|
||||||
|
avatarUrl: 'https://example.com/john.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'driver-2',
|
||||||
|
name: 'Jane Smith',
|
||||||
|
rating: 1100.75,
|
||||||
|
skillLevel: 'Advanced',
|
||||||
|
category: 'Pro',
|
||||||
|
nationality: 'Canada',
|
||||||
|
racesCompleted: 120,
|
||||||
|
wins: 15,
|
||||||
|
podiums: 45,
|
||||||
|
isActive: true,
|
||||||
|
rank: 2,
|
||||||
|
avatarUrl: 'https://example.com/jane.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalRaces: 270,
|
||||||
|
totalWins: 40,
|
||||||
|
activeCount: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = DriversViewDataBuilder.build(driversDTO);
|
||||||
|
|
||||||
|
expect(result.drivers).toHaveLength(2);
|
||||||
|
expect(result.drivers[0].id).toBe('driver-1');
|
||||||
|
expect(result.drivers[0].name).toBe('John Doe');
|
||||||
|
expect(result.drivers[0].rating).toBe(1234.56);
|
||||||
|
expect(result.drivers[0].ratingLabel).toBe('1,235');
|
||||||
|
expect(result.drivers[0].skillLevel).toBe('Pro');
|
||||||
|
expect(result.drivers[0].category).toBe('Elite');
|
||||||
|
expect(result.drivers[0].nationality).toBe('USA');
|
||||||
|
expect(result.drivers[0].racesCompleted).toBe(150);
|
||||||
|
expect(result.drivers[0].wins).toBe(25);
|
||||||
|
expect(result.drivers[0].podiums).toBe(60);
|
||||||
|
expect(result.drivers[0].isActive).toBe(true);
|
||||||
|
expect(result.drivers[0].rank).toBe(1);
|
||||||
|
expect(result.drivers[0].avatarUrl).toBe('https://example.com/john.jpg');
|
||||||
|
|
||||||
|
expect(result.drivers[1].id).toBe('driver-2');
|
||||||
|
expect(result.drivers[1].name).toBe('Jane Smith');
|
||||||
|
expect(result.drivers[1].rating).toBe(1100.75);
|
||||||
|
expect(result.drivers[1].ratingLabel).toBe('1,101');
|
||||||
|
expect(result.drivers[1].skillLevel).toBe('Advanced');
|
||||||
|
expect(result.drivers[1].category).toBe('Pro');
|
||||||
|
expect(result.drivers[1].nationality).toBe('Canada');
|
||||||
|
expect(result.drivers[1].racesCompleted).toBe(120);
|
||||||
|
expect(result.drivers[1].wins).toBe(15);
|
||||||
|
expect(result.drivers[1].podiums).toBe(45);
|
||||||
|
expect(result.drivers[1].isActive).toBe(true);
|
||||||
|
expect(result.drivers[1].rank).toBe(2);
|
||||||
|
expect(result.drivers[1].avatarUrl).toBe('https://example.com/jane.jpg');
|
||||||
|
|
||||||
|
expect(result.totalRaces).toBe(270);
|
||||||
|
expect(result.totalRacesLabel).toBe('270');
|
||||||
|
expect(result.totalWins).toBe(40);
|
||||||
|
expect(result.totalWinsLabel).toBe('40');
|
||||||
|
expect(result.activeCount).toBe(2);
|
||||||
|
expect(result.activeCountLabel).toBe('2');
|
||||||
|
expect(result.totalDriversLabel).toBe('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle drivers with missing optional fields', () => {
|
||||||
|
const driversDTO: DriversLeaderboardDTO = {
|
||||||
|
drivers: [
|
||||||
|
{
|
||||||
|
id: 'driver-1',
|
||||||
|
name: 'John Doe',
|
||||||
|
rating: 1234.56,
|
||||||
|
skillLevel: 'Pro',
|
||||||
|
nationality: 'USA',
|
||||||
|
racesCompleted: 150,
|
||||||
|
wins: 25,
|
||||||
|
podiums: 60,
|
||||||
|
isActive: true,
|
||||||
|
rank: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalRaces: 150,
|
||||||
|
totalWins: 25,
|
||||||
|
activeCount: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = DriversViewDataBuilder.build(driversDTO);
|
||||||
|
|
||||||
|
expect(result.drivers[0].category).toBeUndefined();
|
||||||
|
expect(result.drivers[0].avatarUrl).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty drivers array', () => {
|
||||||
|
const driversDTO: DriversLeaderboardDTO = {
|
||||||
|
drivers: [],
|
||||||
|
totalRaces: 0,
|
||||||
|
totalWins: 0,
|
||||||
|
activeCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = DriversViewDataBuilder.build(driversDTO);
|
||||||
|
|
||||||
|
expect(result.drivers).toEqual([]);
|
||||||
|
expect(result.totalRaces).toBe(0);
|
||||||
|
expect(result.totalRacesLabel).toBe('0');
|
||||||
|
expect(result.totalWins).toBe(0);
|
||||||
|
expect(result.totalWinsLabel).toBe('0');
|
||||||
|
expect(result.activeCount).toBe(0);
|
||||||
|
expect(result.activeCountLabel).toBe('0');
|
||||||
|
expect(result.totalDriversLabel).toBe('0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('data transformation', () => {
|
||||||
|
it('should preserve all DTO fields in the output', () => {
|
||||||
|
const driversDTO: DriversLeaderboardDTO = {
|
||||||
|
drivers: [
|
||||||
|
{
|
||||||
|
id: 'driver-1',
|
||||||
|
name: 'John Doe',
|
||||||
|
rating: 1234.56,
|
||||||
|
skillLevel: 'Pro',
|
||||||
|
category: 'Elite',
|
||||||
|
nationality: 'USA',
|
||||||
|
racesCompleted: 150,
|
||||||
|
wins: 25,
|
||||||
|
podiums: 60,
|
||||||
|
isActive: true,
|
||||||
|
rank: 1,
|
||||||
|
avatarUrl: 'https://example.com/john.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalRaces: 150,
|
||||||
|
totalWins: 25,
|
||||||
|
activeCount: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = DriversViewDataBuilder.build(driversDTO);
|
||||||
|
|
||||||
|
expect(result.drivers[0].name).toBe(driversDTO.drivers[0].name);
|
||||||
|
expect(result.drivers[0].nationality).toBe(driversDTO.drivers[0].nationality);
|
||||||
|
expect(result.drivers[0].skillLevel).toBe(driversDTO.drivers[0].skillLevel);
|
||||||
|
expect(result.totalRaces).toBe(driversDTO.totalRaces);
|
||||||
|
expect(result.totalWins).toBe(driversDTO.totalWins);
|
||||||
|
expect(result.activeCount).toBe(driversDTO.activeCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not modify the input DTO', () => {
|
||||||
|
const driversDTO: DriversLeaderboardDTO = {
|
||||||
|
drivers: [
|
||||||
|
{
|
||||||
|
id: 'driver-1',
|
||||||
|
name: 'John Doe',
|
||||||
|
rating: 1234.56,
|
||||||
|
skillLevel: 'Pro',
|
||||||
|
category: 'Elite',
|
||||||
|
nationality: 'USA',
|
||||||
|
racesCompleted: 150,
|
||||||
|
wins: 25,
|
||||||
|
podiums: 60,
|
||||||
|
isActive: true,
|
||||||
|
rank: 1,
|
||||||
|
avatarUrl: 'https://example.com/john.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalRaces: 150,
|
||||||
|
totalWins: 25,
|
||||||
|
activeCount: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalDTO = JSON.parse(JSON.stringify(driversDTO));
|
||||||
|
DriversViewDataBuilder.build(driversDTO);
|
||||||
|
|
||||||
|
expect(driversDTO).toEqual(originalDTO);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should transform all numeric fields to formatted strings where appropriate', () => {
|
||||||
|
const driversDTO: DriversLeaderboardDTO = {
|
||||||
|
drivers: [
|
||||||
|
{
|
||||||
|
id: 'driver-1',
|
||||||
|
name: 'John Doe',
|
||||||
|
rating: 1234.56,
|
||||||
|
skillLevel: 'Pro',
|
||||||
|
nationality: 'USA',
|
||||||
|
racesCompleted: 150,
|
||||||
|
wins: 25,
|
||||||
|
podiums: 60,
|
||||||
|
isActive: true,
|
||||||
|
rank: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalRaces: 150,
|
||||||
|
totalWins: 25,
|
||||||
|
activeCount: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = DriversViewDataBuilder.build(driversDTO);
|
||||||
|
|
||||||
|
// Rating label should be a formatted string
|
||||||
|
expect(typeof result.drivers[0].ratingLabel).toBe('string');
|
||||||
|
expect(result.drivers[0].ratingLabel).toBe('1,235');
|
||||||
|
|
||||||
|
// Total counts should be formatted strings
|
||||||
|
expect(typeof result.totalRacesLabel).toBe('string');
|
||||||
|
expect(result.totalRacesLabel).toBe('150');
|
||||||
|
expect(typeof result.totalWinsLabel).toBe('string');
|
||||||
|
expect(result.totalWinsLabel).toBe('25');
|
||||||
|
expect(typeof result.activeCountLabel).toBe('string');
|
||||||
|
expect(result.activeCountLabel).toBe('1');
|
||||||
|
expect(typeof result.totalDriversLabel).toBe('string');
|
||||||
|
expect(result.totalDriversLabel).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle large numbers correctly', () => {
|
||||||
|
const driversDTO: DriversLeaderboardDTO = {
|
||||||
|
drivers: [
|
||||||
|
{
|
||||||
|
id: 'driver-1',
|
||||||
|
name: 'John Doe',
|
||||||
|
rating: 999999.99,
|
||||||
|
skillLevel: 'Pro',
|
||||||
|
nationality: 'USA',
|
||||||
|
racesCompleted: 10000,
|
||||||
|
wins: 2500,
|
||||||
|
podiums: 5000,
|
||||||
|
isActive: true,
|
||||||
|
rank: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalRaces: 10000,
|
||||||
|
totalWins: 2500,
|
||||||
|
activeCount: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = DriversViewDataBuilder.build(driversDTO);
|
||||||
|
|
||||||
|
expect(result.drivers[0].ratingLabel).toBe('1,000,000');
|
||||||
|
expect(result.totalRacesLabel).toBe('10,000');
|
||||||
|
expect(result.totalWinsLabel).toBe('2,500');
|
||||||
|
expect(result.activeCountLabel).toBe('1');
|
||||||
|
expect(result.totalDriversLabel).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle null/undefined rating', () => {
|
||||||
|
const driversDTO: DriversLeaderboardDTO = {
|
||||||
|
drivers: [
|
||||||
|
{
|
||||||
|
id: 'driver-1',
|
||||||
|
name: 'John Doe',
|
||||||
|
rating: 0,
|
||||||
|
skillLevel: 'Pro',
|
||||||
|
nationality: 'USA',
|
||||||
|
racesCompleted: 150,
|
||||||
|
wins: 25,
|
||||||
|
podiums: 60,
|
||||||
|
isActive: true,
|
||||||
|
rank: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalRaces: 150,
|
||||||
|
totalWins: 25,
|
||||||
|
activeCount: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = DriversViewDataBuilder.build(driversDTO);
|
||||||
|
|
||||||
|
expect(result.drivers[0].ratingLabel).toBe('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle drivers with no category', () => {
|
||||||
|
const driversDTO: DriversLeaderboardDTO = {
|
||||||
|
drivers: [
|
||||||
|
{
|
||||||
|
id: 'driver-1',
|
||||||
|
name: 'John Doe',
|
||||||
|
rating: 1234.56,
|
||||||
|
skillLevel: 'Pro',
|
||||||
|
nationality: 'USA',
|
||||||
|
racesCompleted: 150,
|
||||||
|
wins: 25,
|
||||||
|
podiums: 60,
|
||||||
|
isActive: true,
|
||||||
|
rank: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalRaces: 150,
|
||||||
|
totalWins: 25,
|
||||||
|
activeCount: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = DriversViewDataBuilder.build(driversDTO);
|
||||||
|
|
||||||
|
expect(result.drivers[0].category).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle inactive drivers', () => {
|
||||||
|
const driversDTO: DriversLeaderboardDTO = {
|
||||||
|
drivers: [
|
||||||
|
{
|
||||||
|
id: 'driver-1',
|
||||||
|
name: 'John Doe',
|
||||||
|
rating: 1234.56,
|
||||||
|
skillLevel: 'Pro',
|
||||||
|
nationality: 'USA',
|
||||||
|
racesCompleted: 150,
|
||||||
|
wins: 25,
|
||||||
|
podiums: 60,
|
||||||
|
isActive: false,
|
||||||
|
rank: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalRaces: 150,
|
||||||
|
totalWins: 25,
|
||||||
|
activeCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = DriversViewDataBuilder.build(driversDTO);
|
||||||
|
|
||||||
|
expect(result.drivers[0].isActive).toBe(false);
|
||||||
|
expect(result.activeCount).toBe(0);
|
||||||
|
expect(result.activeCountLabel).toBe('0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('derived fields', () => {
|
||||||
|
it('should correctly calculate total drivers label', () => {
|
||||||
|
const driversDTO: DriversLeaderboardDTO = {
|
||||||
|
drivers: [
|
||||||
|
{ id: 'driver-1', name: 'John Doe', rating: 1234.56, skillLevel: 'Pro', nationality: 'USA', racesCompleted: 150, wins: 25, podiums: 60, isActive: true, rank: 1 },
|
||||||
|
{ id: 'driver-2', name: 'Jane Smith', rating: 1100.75, skillLevel: 'Advanced', nationality: 'Canada', racesCompleted: 120, wins: 15, podiums: 45, isActive: true, rank: 2 },
|
||||||
|
{ id: 'driver-3', name: 'Bob Wilson', rating: 950.25, skillLevel: 'Intermediate', nationality: 'UK', racesCompleted: 80, wins: 5, podiums: 20, isActive: false, rank: 3 },
|
||||||
|
],
|
||||||
|
totalRaces: 350,
|
||||||
|
totalWins: 45,
|
||||||
|
activeCount: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = DriversViewDataBuilder.build(driversDTO);
|
||||||
|
|
||||||
|
expect(result.totalDriversLabel).toBe('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly calculate active count', () => {
|
||||||
|
const driversDTO: DriversLeaderboardDTO = {
|
||||||
|
drivers: [
|
||||||
|
{ id: 'driver-1', name: 'John Doe', rating: 1234.56, skillLevel: 'Pro', nationality: 'USA', racesCompleted: 150, wins: 25, podiums: 60, isActive: true, rank: 1 },
|
||||||
|
{ id: 'driver-2', name: 'Jane Smith', rating: 1100.75, skillLevel: 'Advanced', nationality: 'Canada', racesCompleted: 120, wins: 15, podiums: 45, isActive: true, rank: 2 },
|
||||||
|
{ id: 'driver-3', name: 'Bob Wilson', rating: 950.25, skillLevel: 'Intermediate', nationality: 'UK', racesCompleted: 80, wins: 5, podiums: 20, isActive: false, rank: 3 },
|
||||||
|
],
|
||||||
|
totalRaces: 350,
|
||||||
|
totalWins: 45,
|
||||||
|
activeCount: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = DriversViewDataBuilder.build(driversDTO);
|
||||||
|
|
||||||
|
expect(result.activeCount).toBe(2);
|
||||||
|
expect(result.activeCountLabel).toBe('2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,33 +1,38 @@
|
|||||||
|
|
||||||
|
|
||||||
|
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
|
||||||
|
import { NumberFormatter } from '@/lib/formatters/NumberFormatter';
|
||||||
|
import { RatingFormatter } from '@/lib/formatters/RatingFormatter';
|
||||||
import type { DriversLeaderboardDTO } from '@/lib/types/generated/DriversLeaderboardDTO';
|
import type { DriversLeaderboardDTO } from '@/lib/types/generated/DriversLeaderboardDTO';
|
||||||
import type { DriversViewData } from '@/lib/types/view-data/DriversViewData';
|
import type { DriversViewData } from '@/lib/view-data/DriversViewData';
|
||||||
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
|
|
||||||
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
|
|
||||||
|
|
||||||
export class DriversViewDataBuilder {
|
export class DriversViewDataBuilder {
|
||||||
static build(dto: DriversLeaderboardDTO): DriversViewData {
|
public static build(apiDto: DriversLeaderboardDTO): DriversViewData {
|
||||||
return {
|
return {
|
||||||
drivers: dto.drivers.map(driver => ({
|
drivers: apiDto.drivers.map(driver => ({
|
||||||
id: driver.id,
|
id: driver.id,
|
||||||
name: driver.name,
|
name: driver.name,
|
||||||
rating: driver.rating,
|
rating: driver.rating,
|
||||||
ratingLabel: RatingDisplay.format(driver.rating),
|
ratingLabel: RatingFormatter.format(driver.rating),
|
||||||
skillLevel: driver.skillLevel,
|
skillLevel: driver.skillLevel,
|
||||||
category: driver.category,
|
category: driver.category ?? undefined,
|
||||||
nationality: driver.nationality,
|
nationality: driver.nationality,
|
||||||
racesCompleted: driver.racesCompleted,
|
racesCompleted: driver.racesCompleted,
|
||||||
wins: driver.wins,
|
wins: driver.wins,
|
||||||
podiums: driver.podiums,
|
podiums: driver.podiums,
|
||||||
isActive: driver.isActive,
|
isActive: driver.isActive,
|
||||||
rank: driver.rank,
|
rank: driver.rank,
|
||||||
avatarUrl: driver.avatarUrl,
|
avatarUrl: driver.avatarUrl ?? undefined,
|
||||||
})),
|
})),
|
||||||
totalRaces: dto.totalRaces,
|
totalRaces: apiDto.totalRaces,
|
||||||
totalRacesLabel: NumberDisplay.format(dto.totalRaces),
|
totalRacesLabel: NumberFormatter.format(apiDto.totalRaces),
|
||||||
totalWins: dto.totalWins,
|
totalWins: apiDto.totalWins,
|
||||||
totalWinsLabel: NumberDisplay.format(dto.totalWins),
|
totalWinsLabel: NumberFormatter.format(apiDto.totalWins),
|
||||||
activeCount: dto.activeCount,
|
activeCount: apiDto.activeCount,
|
||||||
activeCountLabel: NumberDisplay.format(dto.activeCount),
|
activeCountLabel: NumberFormatter.format(apiDto.activeCount),
|
||||||
totalDriversLabel: NumberDisplay.format(dto.drivers.length),
|
totalDriversLabel: NumberFormatter.format(apiDto.drivers.length),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DriversViewDataBuilder satisfies ViewDataBuilder<DriversLeaderboardDTO, DriversViewData>;
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ForgotPasswordViewDataBuilder } from './ForgotPasswordViewDataBuilder';
|
||||||
|
import type { ForgotPasswordPageDTO } from '@/lib/services/auth/types/ForgotPasswordPageDTO';
|
||||||
|
|
||||||
|
describe('ForgotPasswordViewDataBuilder', () => {
|
||||||
|
describe('happy paths', () => {
|
||||||
|
it('should transform ForgotPasswordPageDTO to ForgotPasswordViewData correctly', () => {
|
||||||
|
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||||
|
returnTo: '/login',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
returnTo: '/login',
|
||||||
|
showSuccess: false,
|
||||||
|
formState: {
|
||||||
|
fields: {
|
||||||
|
email: { value: '', error: undefined, touched: false, validating: false },
|
||||||
|
},
|
||||||
|
isValid: true,
|
||||||
|
isSubmitting: false,
|
||||||
|
submitError: undefined,
|
||||||
|
submitCount: 0,
|
||||||
|
},
|
||||||
|
isSubmitting: false,
|
||||||
|
submitError: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty returnTo path', () => {
|
||||||
|
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||||
|
returnTo: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||||
|
|
||||||
|
expect(result.returnTo).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle returnTo with query parameters', () => {
|
||||||
|
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||||
|
returnTo: '/login?error=expired',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||||
|
|
||||||
|
expect(result.returnTo).toBe('/login?error=expired');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('data transformation', () => {
|
||||||
|
it('should preserve all DTO fields in the output', () => {
|
||||||
|
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||||
|
returnTo: '/login',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||||
|
|
||||||
|
expect(result.returnTo).toBe(forgotPasswordPageDTO.returnTo);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not modify the input DTO', () => {
|
||||||
|
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||||
|
returnTo: '/login',
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalDTO = { ...forgotPasswordPageDTO };
|
||||||
|
ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||||
|
|
||||||
|
expect(forgotPasswordPageDTO).toEqual(originalDTO);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize form field with default values', () => {
|
||||||
|
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||||
|
returnTo: '/login',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||||
|
|
||||||
|
expect(result.formState.fields.email.value).toBe('');
|
||||||
|
expect(result.formState.fields.email.error).toBeUndefined();
|
||||||
|
expect(result.formState.fields.email.touched).toBe(false);
|
||||||
|
expect(result.formState.fields.email.validating).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize form state with default values', () => {
|
||||||
|
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||||
|
returnTo: '/login',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||||
|
|
||||||
|
expect(result.formState.isValid).toBe(true);
|
||||||
|
expect(result.formState.isSubmitting).toBe(false);
|
||||||
|
expect(result.formState.submitError).toBeUndefined();
|
||||||
|
expect(result.formState.submitCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize UI state flags correctly', () => {
|
||||||
|
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||||
|
returnTo: '/login',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||||
|
|
||||||
|
expect(result.showSuccess).toBe(false);
|
||||||
|
expect(result.isSubmitting).toBe(false);
|
||||||
|
expect(result.submitError).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle returnTo with encoded characters', () => {
|
||||||
|
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||||
|
returnTo: '/login?redirect=%2Fdashboard',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||||
|
|
||||||
|
expect(result.returnTo).toBe('/login?redirect=%2Fdashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle returnTo with hash fragment', () => {
|
||||||
|
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||||
|
returnTo: '/login#section',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||||
|
|
||||||
|
expect(result.returnTo).toBe('/login#section');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('form state structure', () => {
|
||||||
|
it('should have email field', () => {
|
||||||
|
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||||
|
returnTo: '/login',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||||
|
|
||||||
|
expect(result.formState.fields).toHaveProperty('email');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have consistent field state structure', () => {
|
||||||
|
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||||
|
returnTo: '/login',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||||
|
|
||||||
|
const field = result.formState.fields.email;
|
||||||
|
expect(field).toHaveProperty('value');
|
||||||
|
expect(field).toHaveProperty('error');
|
||||||
|
expect(field).toHaveProperty('touched');
|
||||||
|
expect(field).toHaveProperty('validating');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
/**
|
|
||||||
* Forgot Password View Data Builder
|
|
||||||
*
|
|
||||||
* Transforms ForgotPasswordPageDTO into ViewData for the forgot password template.
|
|
||||||
* Deterministic, side-effect free, no business logic.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ForgotPasswordPageDTO } from '@/lib/services/auth/types/ForgotPasswordPageDTO';
|
|
||||||
import { ForgotPasswordViewData } from './types/ForgotPasswordViewData';
|
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
|
||||||
|
import type { ForgotPasswordPageDTO } from '@/lib/types/generated/ForgotPasswordPageDTO';
|
||||||
|
import type { ForgotPasswordViewData } from '@/lib/view-data/ForgotPasswordViewData';
|
||||||
|
|
||||||
export class ForgotPasswordViewDataBuilder {
|
export class ForgotPasswordViewDataBuilder {
|
||||||
static build(apiDto: ForgotPasswordPageDTO): ForgotPasswordViewData {
|
public static build(apiDto: ForgotPasswordPageDTO): ForgotPasswordViewData {
|
||||||
return {
|
return {
|
||||||
returnTo: apiDto.returnTo,
|
returnTo: apiDto.returnTo,
|
||||||
showSuccess: false,
|
showSuccess: false,
|
||||||
@@ -26,4 +22,6 @@ export class ForgotPasswordViewDataBuilder {
|
|||||||
submitError: undefined,
|
submitError: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ForgotPasswordViewDataBuilder satisfies ViewDataBuilder<ForgotPasswordPageDTO, ForgotPasswordViewData>;
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
export interface GenerateAvatarsViewData {
|
|
||||||
success: boolean;
|
|
||||||
avatarUrls: string[];
|
|
||||||
errorMessage?: string;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { GenerateAvatarsViewDataBuilder } from './GenerateAvatarsViewDataBuilder';
|
||||||
|
import type { RequestAvatarGenerationOutputDTO } from '@/lib/types/generated/RequestAvatarGenerationOutputDTO';
|
||||||
|
|
||||||
|
describe('GenerateAvatarsViewDataBuilder', () => {
|
||||||
|
describe('happy paths', () => {
|
||||||
|
it('should transform RequestAvatarGenerationOutputDTO to GenerateAvatarsViewData correctly', () => {
|
||||||
|
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||||
|
success: true,
|
||||||
|
avatarUrls: ['avatar-url-1', 'avatar-url-2', 'avatar-url-3'],
|
||||||
|
errorMessage: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
avatarUrls: ['avatar-url-1', 'avatar-url-2', 'avatar-url-3'],
|
||||||
|
errorMessage: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty avatar URLs', () => {
|
||||||
|
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||||
|
success: true,
|
||||||
|
avatarUrls: [],
|
||||||
|
errorMessage: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||||
|
|
||||||
|
expect(result.avatarUrls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single avatar URL', () => {
|
||||||
|
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||||
|
success: true,
|
||||||
|
avatarUrls: ['avatar-url-1'],
|
||||||
|
errorMessage: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||||
|
|
||||||
|
expect(result.avatarUrls).toHaveLength(1);
|
||||||
|
expect(result.avatarUrls[0]).toBe('avatar-url-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple avatar URLs', () => {
|
||||||
|
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||||
|
success: true,
|
||||||
|
avatarUrls: ['avatar-url-1', 'avatar-url-2', 'avatar-url-3', 'avatar-url-4', 'avatar-url-5'],
|
||||||
|
errorMessage: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||||
|
|
||||||
|
expect(result.avatarUrls).toHaveLength(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('data transformation', () => {
|
||||||
|
it('should preserve all DTO fields in the output', () => {
|
||||||
|
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||||
|
success: true,
|
||||||
|
avatarUrls: ['avatar-url-1', 'avatar-url-2'],
|
||||||
|
errorMessage: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||||
|
|
||||||
|
expect(result.success).toBe(requestAvatarGenerationOutputDto.success);
|
||||||
|
expect(result.avatarUrls).toEqual(requestAvatarGenerationOutputDto.avatarUrls);
|
||||||
|
expect(result.errorMessage).toBe(requestAvatarGenerationOutputDto.errorMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not modify the input DTO', () => {
|
||||||
|
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||||
|
success: true,
|
||||||
|
avatarUrls: ['avatar-url-1'],
|
||||||
|
errorMessage: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalDto = { ...requestAvatarGenerationOutputDto };
|
||||||
|
GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||||
|
|
||||||
|
expect(requestAvatarGenerationOutputDto).toEqual(originalDto);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle success false', () => {
|
||||||
|
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||||
|
success: false,
|
||||||
|
avatarUrls: [],
|
||||||
|
errorMessage: 'Generation failed',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle error message', () => {
|
||||||
|
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||||
|
success: false,
|
||||||
|
avatarUrls: [],
|
||||||
|
errorMessage: 'Invalid input data',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||||
|
|
||||||
|
expect(result.errorMessage).toBe('Invalid input data');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null error message', () => {
|
||||||
|
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||||
|
success: true,
|
||||||
|
avatarUrls: ['avatar-url-1'],
|
||||||
|
errorMessage: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||||
|
|
||||||
|
expect(result.errorMessage).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined avatarUrls', () => {
|
||||||
|
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||||
|
success: true,
|
||||||
|
avatarUrls: undefined,
|
||||||
|
errorMessage: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||||
|
|
||||||
|
expect(result.avatarUrls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty string avatar URLs', () => {
|
||||||
|
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||||
|
success: true,
|
||||||
|
avatarUrls: ['', 'avatar-url-1', ''],
|
||||||
|
errorMessage: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||||
|
|
||||||
|
expect(result.avatarUrls).toEqual(['', 'avatar-url-1', '']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle special characters in avatar URLs', () => {
|
||||||
|
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||||
|
success: true,
|
||||||
|
avatarUrls: ['avatar-url-1?param=value', 'avatar-url-2#anchor', 'avatar-url-3?query=1&test=2'],
|
||||||
|
errorMessage: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||||
|
|
||||||
|
expect(result.avatarUrls).toEqual([
|
||||||
|
'avatar-url-1?param=value',
|
||||||
|
'avatar-url-2#anchor',
|
||||||
|
'avatar-url-3?query=1&test=2',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very long avatar URLs', () => {
|
||||||
|
const longUrl = 'https://example.com/avatars/' + 'a'.repeat(1000) + '.png';
|
||||||
|
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||||
|
success: true,
|
||||||
|
avatarUrls: [longUrl],
|
||||||
|
errorMessage: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||||
|
|
||||||
|
expect(result.avatarUrls[0]).toBe(longUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle avatar URLs with special characters', () => {
|
||||||
|
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||||
|
success: true,
|
||||||
|
avatarUrls: [
|
||||||
|
'avatar-url-1?name=John%20Doe',
|
||||||
|
'avatar-url-2?email=test@example.com',
|
||||||
|
'avatar-url-3?query=hello%20world',
|
||||||
|
],
|
||||||
|
errorMessage: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||||
|
|
||||||
|
expect(result.avatarUrls).toEqual([
|
||||||
|
'avatar-url-1?name=John%20Doe',
|
||||||
|
'avatar-url-2?email=test@example.com',
|
||||||
|
'avatar-url-3?query=hello%20world',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,25 +1,17 @@
|
|||||||
/**
|
|
||||||
* GenerateAvatars ViewData Builder
|
|
||||||
*
|
|
||||||
* Transforms avatar generation result into ViewData for templates.
|
|
||||||
* Must be used in mutations to avoid returning DTOs directly.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { RequestAvatarGenerationOutputDTO } from '@/lib/types/generated/RequestAvatarGenerationOutputDTO';
|
|
||||||
import { GenerateAvatarsViewData } from './GenerateAvatarsViewData';
|
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
|
||||||
|
import type { RequestAvatarGenerationOutputDTO } from '@/lib/types/generated/RequestAvatarGenerationOutputDTO';
|
||||||
|
import type { GenerateAvatarsViewData } from '@/lib/view-data/GenerateAvatarsViewData';
|
||||||
|
|
||||||
export class GenerateAvatarsViewDataBuilder {
|
export class GenerateAvatarsViewDataBuilder {
|
||||||
/**
|
public static build(apiDto: RequestAvatarGenerationOutputDTO): GenerateAvatarsViewData {
|
||||||
* Transform DTO into ViewData
|
|
||||||
*
|
|
||||||
* @param apiDto - The API DTO to transform
|
|
||||||
* @returns ViewData for templates
|
|
||||||
*/
|
|
||||||
static build(apiDto: RequestAvatarGenerationOutputDTO): GenerateAvatarsViewData {
|
|
||||||
return {
|
return {
|
||||||
success: apiDto.success,
|
success: apiDto.success,
|
||||||
avatarUrls: apiDto.avatarUrls || [],
|
avatarUrls: apiDto.avatarUrls || [],
|
||||||
errorMessage: apiDto.errorMessage,
|
errorMessage: apiDto.errorMessage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GenerateAvatarsViewDataBuilder satisfies ViewDataBuilder<RequestAvatarGenerationOutputDTO, GenerateAvatarsViewData>;
|
||||||
|
|||||||
@@ -0,0 +1,553 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { HealthViewDataBuilder, HealthDTO } from './HealthViewDataBuilder';
|
||||||
|
|
||||||
|
describe('HealthViewDataBuilder', () => {
|
||||||
|
describe('happy paths', () => {
|
||||||
|
it('should transform HealthDTO to HealthViewData correctly', () => {
|
||||||
|
const healthDTO: HealthDTO = {
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: 99.95,
|
||||||
|
responseTime: 150,
|
||||||
|
errorRate: 0.05,
|
||||||
|
lastCheck: new Date().toISOString(),
|
||||||
|
checksPassed: 995,
|
||||||
|
checksFailed: 5,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
name: 'Database',
|
||||||
|
status: 'ok',
|
||||||
|
lastCheck: new Date().toISOString(),
|
||||||
|
responseTime: 50,
|
||||||
|
errorRate: 0.01,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'API',
|
||||||
|
status: 'ok',
|
||||||
|
lastCheck: new Date().toISOString(),
|
||||||
|
responseTime: 100,
|
||||||
|
errorRate: 0.02,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
alerts: [
|
||||||
|
{
|
||||||
|
id: 'alert-1',
|
||||||
|
type: 'info',
|
||||||
|
title: 'System Update',
|
||||||
|
message: 'System updated successfully',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = HealthViewDataBuilder.build(healthDTO);
|
||||||
|
|
||||||
|
expect(result.overallStatus.status).toBe('ok');
|
||||||
|
expect(result.overallStatus.statusLabel).toBe('Healthy');
|
||||||
|
expect(result.overallStatus.statusColor).toBe('#10b981');
|
||||||
|
expect(result.overallStatus.statusIcon).toBe('✓');
|
||||||
|
expect(result.metrics.uptime).toBe('99.95%');
|
||||||
|
expect(result.metrics.responseTime).toBe('150ms');
|
||||||
|
expect(result.metrics.errorRate).toBe('0.05%');
|
||||||
|
expect(result.metrics.checksPassed).toBe(995);
|
||||||
|
expect(result.metrics.checksFailed).toBe(5);
|
||||||
|
expect(result.metrics.totalChecks).toBe(1000);
|
||||||
|
expect(result.metrics.successRate).toBe('99.5%');
|
||||||
|
expect(result.components).toHaveLength(2);
|
||||||
|
expect(result.components[0].name).toBe('Database');
|
||||||
|
expect(result.components[0].status).toBe('ok');
|
||||||
|
expect(result.components[0].statusLabel).toBe('Healthy');
|
||||||
|
expect(result.alerts).toHaveLength(1);
|
||||||
|
expect(result.alerts[0].id).toBe('alert-1');
|
||||||
|
expect(result.alerts[0].type).toBe('info');
|
||||||
|
expect(result.hasAlerts).toBe(true);
|
||||||
|
expect(result.hasDegradedComponents).toBe(false);
|
||||||
|
expect(result.hasErrorComponents).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing optional fields gracefully', () => {
|
||||||
|
const healthDTO: HealthDTO = {
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = HealthViewDataBuilder.build(healthDTO);
|
||||||
|
|
||||||
|
expect(result.overallStatus.status).toBe('ok');
|
||||||
|
expect(result.metrics.uptime).toBe('N/A');
|
||||||
|
expect(result.metrics.responseTime).toBe('N/A');
|
||||||
|
expect(result.metrics.errorRate).toBe('N/A');
|
||||||
|
expect(result.metrics.checksPassed).toBe(0);
|
||||||
|
expect(result.metrics.checksFailed).toBe(0);
|
||||||
|
expect(result.metrics.totalChecks).toBe(0);
|
||||||
|
expect(result.metrics.successRate).toBe('N/A');
|
||||||
|
expect(result.components).toEqual([]);
|
||||||
|
expect(result.alerts).toEqual([]);
|
||||||
|
expect(result.hasAlerts).toBe(false);
|
||||||
|
expect(result.hasDegradedComponents).toBe(false);
|
||||||
|
expect(result.hasErrorComponents).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle degraded status correctly', () => {
|
||||||
|
const healthDTO: HealthDTO = {
|
||||||
|
status: 'degraded',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: 95.5,
|
||||||
|
responseTime: 500,
|
||||||
|
errorRate: 4.5,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
name: 'Database',
|
||||||
|
status: 'degraded',
|
||||||
|
lastCheck: new Date().toISOString(),
|
||||||
|
responseTime: 200,
|
||||||
|
errorRate: 2.0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = HealthViewDataBuilder.build(healthDTO);
|
||||||
|
|
||||||
|
expect(result.overallStatus.status).toBe('degraded');
|
||||||
|
expect(result.overallStatus.statusLabel).toBe('Degraded');
|
||||||
|
expect(result.overallStatus.statusColor).toBe('#f59e0b');
|
||||||
|
expect(result.overallStatus.statusIcon).toBe('⚠');
|
||||||
|
expect(result.metrics.uptime).toBe('95.50%');
|
||||||
|
expect(result.metrics.responseTime).toBe('500ms');
|
||||||
|
expect(result.metrics.errorRate).toBe('4.50%');
|
||||||
|
expect(result.hasDegradedComponents).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle error status correctly', () => {
|
||||||
|
const healthDTO: HealthDTO = {
|
||||||
|
status: 'error',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: 85.2,
|
||||||
|
responseTime: 2000,
|
||||||
|
errorRate: 14.8,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
name: 'Database',
|
||||||
|
status: 'error',
|
||||||
|
lastCheck: new Date().toISOString(),
|
||||||
|
responseTime: 1500,
|
||||||
|
errorRate: 10.0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = HealthViewDataBuilder.build(healthDTO);
|
||||||
|
|
||||||
|
expect(result.overallStatus.status).toBe('error');
|
||||||
|
expect(result.overallStatus.statusLabel).toBe('Error');
|
||||||
|
expect(result.overallStatus.statusColor).toBe('#ef4444');
|
||||||
|
expect(result.overallStatus.statusIcon).toBe('✕');
|
||||||
|
expect(result.metrics.uptime).toBe('85.20%');
|
||||||
|
expect(result.metrics.responseTime).toBe('2.00s');
|
||||||
|
expect(result.metrics.errorRate).toBe('14.80%');
|
||||||
|
expect(result.hasErrorComponents).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple components with mixed statuses', () => {
|
||||||
|
const healthDTO: HealthDTO = {
|
||||||
|
status: 'degraded',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
name: 'Database',
|
||||||
|
status: 'ok',
|
||||||
|
lastCheck: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'API',
|
||||||
|
status: 'degraded',
|
||||||
|
lastCheck: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cache',
|
||||||
|
status: 'error',
|
||||||
|
lastCheck: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = HealthViewDataBuilder.build(healthDTO);
|
||||||
|
|
||||||
|
expect(result.components).toHaveLength(3);
|
||||||
|
expect(result.hasDegradedComponents).toBe(true);
|
||||||
|
expect(result.hasErrorComponents).toBe(true);
|
||||||
|
expect(result.components[0].statusLabel).toBe('Healthy');
|
||||||
|
expect(result.components[1].statusLabel).toBe('Degraded');
|
||||||
|
expect(result.components[2].statusLabel).toBe('Error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple alerts with different severities', () => {
|
||||||
|
const healthDTO: HealthDTO = {
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
alerts: [
|
||||||
|
{
|
||||||
|
id: 'alert-1',
|
||||||
|
type: 'critical',
|
||||||
|
title: 'Critical Alert',
|
||||||
|
message: 'Critical issue detected',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'alert-2',
|
||||||
|
type: 'warning',
|
||||||
|
title: 'Warning Alert',
|
||||||
|
message: 'Warning message',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'alert-3',
|
||||||
|
type: 'info',
|
||||||
|
title: 'Info Alert',
|
||||||
|
message: 'Informational message',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = HealthViewDataBuilder.build(healthDTO);
|
||||||
|
|
||||||
|
expect(result.alerts).toHaveLength(3);
|
||||||
|
expect(result.hasAlerts).toBe(true);
|
||||||
|
expect(result.alerts[0].severity).toBe('Critical');
|
||||||
|
expect(result.alerts[0].severityColor).toBe('#ef4444');
|
||||||
|
expect(result.alerts[1].severity).toBe('Warning');
|
||||||
|
expect(result.alerts[1].severityColor).toBe('#f59e0b');
|
||||||
|
expect(result.alerts[2].severity).toBe('Info');
|
||||||
|
expect(result.alerts[2].severityColor).toBe('#3b82f6');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('data transformation', () => {
|
||||||
|
it('should preserve all DTO fields in the output', () => {
|
||||||
|
const now = new Date();
|
||||||
|
const healthDTO: HealthDTO = {
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: now.toISOString(),
|
||||||
|
uptime: 99.99,
|
||||||
|
responseTime: 100,
|
||||||
|
errorRate: 0.01,
|
||||||
|
lastCheck: now.toISOString(),
|
||||||
|
checksPassed: 9999,
|
||||||
|
checksFailed: 1,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
name: 'Test Component',
|
||||||
|
status: 'ok',
|
||||||
|
lastCheck: now.toISOString(),
|
||||||
|
responseTime: 50,
|
||||||
|
errorRate: 0.005,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
alerts: [
|
||||||
|
{
|
||||||
|
id: 'test-alert',
|
||||||
|
type: 'info',
|
||||||
|
title: 'Test Alert',
|
||||||
|
message: 'Test message',
|
||||||
|
timestamp: now.toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = HealthViewDataBuilder.build(healthDTO);
|
||||||
|
|
||||||
|
expect(result.overallStatus.status).toBe(healthDTO.status);
|
||||||
|
expect(result.overallStatus.timestamp).toBe(healthDTO.timestamp);
|
||||||
|
expect(result.metrics.uptime).toBe('99.99%');
|
||||||
|
expect(result.metrics.responseTime).toBe('100ms');
|
||||||
|
expect(result.metrics.errorRate).toBe('0.01%');
|
||||||
|
expect(result.metrics.lastCheck).toBe(healthDTO.lastCheck);
|
||||||
|
expect(result.metrics.checksPassed).toBe(healthDTO.checksPassed);
|
||||||
|
expect(result.metrics.checksFailed).toBe(healthDTO.checksFailed);
|
||||||
|
expect(result.components[0].name).toBe(healthDTO.components![0].name);
|
||||||
|
expect(result.components[0].status).toBe(healthDTO.components![0].status);
|
||||||
|
expect(result.alerts[0].id).toBe(healthDTO.alerts![0].id);
|
||||||
|
expect(result.alerts[0].type).toBe(healthDTO.alerts![0].type);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not modify the input DTO', () => {
|
||||||
|
const healthDTO: HealthDTO = {
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: 99.95,
|
||||||
|
responseTime: 150,
|
||||||
|
errorRate: 0.05,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
name: 'Database',
|
||||||
|
status: 'ok',
|
||||||
|
lastCheck: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalDTO = JSON.parse(JSON.stringify(healthDTO));
|
||||||
|
HealthViewDataBuilder.build(healthDTO);
|
||||||
|
|
||||||
|
expect(healthDTO).toEqual(originalDTO);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should transform all numeric fields to formatted strings', () => {
|
||||||
|
const healthDTO: HealthDTO = {
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: 99.95,
|
||||||
|
responseTime: 150,
|
||||||
|
errorRate: 0.05,
|
||||||
|
checksPassed: 995,
|
||||||
|
checksFailed: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = HealthViewDataBuilder.build(healthDTO);
|
||||||
|
|
||||||
|
expect(typeof result.metrics.uptime).toBe('string');
|
||||||
|
expect(typeof result.metrics.responseTime).toBe('string');
|
||||||
|
expect(typeof result.metrics.errorRate).toBe('string');
|
||||||
|
expect(typeof result.metrics.successRate).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle large numbers correctly', () => {
|
||||||
|
const healthDTO: HealthDTO = {
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: 99.999,
|
||||||
|
responseTime: 5000,
|
||||||
|
errorRate: 0.001,
|
||||||
|
checksPassed: 999999,
|
||||||
|
checksFailed: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = HealthViewDataBuilder.build(healthDTO);
|
||||||
|
|
||||||
|
expect(result.metrics.uptime).toBe('100.00%');
|
||||||
|
expect(result.metrics.responseTime).toBe('5.00s');
|
||||||
|
expect(result.metrics.errorRate).toBe('0.00%');
|
||||||
|
expect(result.metrics.successRate).toBe('100.0%');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle null/undefined numeric fields', () => {
|
||||||
|
const healthDTO: HealthDTO = {
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: null as any,
|
||||||
|
responseTime: undefined,
|
||||||
|
errorRate: null as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = HealthViewDataBuilder.build(healthDTO);
|
||||||
|
|
||||||
|
expect(result.metrics.uptime).toBe('N/A');
|
||||||
|
expect(result.metrics.responseTime).toBe('N/A');
|
||||||
|
expect(result.metrics.errorRate).toBe('N/A');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle negative numeric values', () => {
|
||||||
|
const healthDTO: HealthDTO = {
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: -1,
|
||||||
|
responseTime: -100,
|
||||||
|
errorRate: -0.5,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = HealthViewDataBuilder.build(healthDTO);
|
||||||
|
|
||||||
|
expect(result.metrics.uptime).toBe('N/A');
|
||||||
|
expect(result.metrics.responseTime).toBe('N/A');
|
||||||
|
expect(result.metrics.errorRate).toBe('N/A');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty components and alerts arrays', () => {
|
||||||
|
const healthDTO: HealthDTO = {
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
components: [],
|
||||||
|
alerts: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = HealthViewDataBuilder.build(healthDTO);
|
||||||
|
|
||||||
|
expect(result.components).toEqual([]);
|
||||||
|
expect(result.alerts).toEqual([]);
|
||||||
|
expect(result.hasAlerts).toBe(false);
|
||||||
|
expect(result.hasDegradedComponents).toBe(false);
|
||||||
|
expect(result.hasErrorComponents).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle component with missing optional fields', () => {
|
||||||
|
const healthDTO: HealthDTO = {
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
name: 'Test Component',
|
||||||
|
status: 'ok',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = HealthViewDataBuilder.build(healthDTO);
|
||||||
|
|
||||||
|
expect(result.components[0].lastCheck).toBeDefined();
|
||||||
|
expect(result.components[0].formattedLastCheck).toBeDefined();
|
||||||
|
expect(result.components[0].responseTime).toBe('N/A');
|
||||||
|
expect(result.components[0].errorRate).toBe('N/A');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle alert with missing optional fields', () => {
|
||||||
|
const healthDTO: HealthDTO = {
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
alerts: [
|
||||||
|
{
|
||||||
|
id: 'alert-1',
|
||||||
|
type: 'info',
|
||||||
|
title: 'Test Alert',
|
||||||
|
message: 'Test message',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = HealthViewDataBuilder.build(healthDTO);
|
||||||
|
|
||||||
|
expect(result.alerts[0].id).toBe('alert-1');
|
||||||
|
expect(result.alerts[0].type).toBe('info');
|
||||||
|
expect(result.alerts[0].title).toBe('Test Alert');
|
||||||
|
expect(result.alerts[0].message).toBe('Test message');
|
||||||
|
expect(result.alerts[0].timestamp).toBeDefined();
|
||||||
|
expect(result.alerts[0].formattedTimestamp).toBeDefined();
|
||||||
|
expect(result.alerts[0].relativeTime).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle unknown status', () => {
|
||||||
|
const healthDTO: HealthDTO = {
|
||||||
|
status: 'unknown',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = HealthViewDataBuilder.build(healthDTO);
|
||||||
|
|
||||||
|
expect(result.overallStatus.status).toBe('unknown');
|
||||||
|
expect(result.overallStatus.statusLabel).toBe('Unknown');
|
||||||
|
expect(result.overallStatus.statusColor).toBe('#6b7280');
|
||||||
|
expect(result.overallStatus.statusIcon).toBe('?');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('derived fields', () => {
|
||||||
|
it('should correctly calculate hasAlerts', () => {
|
||||||
|
const healthDTO: HealthDTO = {
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
alerts: [
|
||||||
|
{
|
||||||
|
id: 'alert-1',
|
||||||
|
type: 'info',
|
||||||
|
title: 'Test',
|
||||||
|
message: 'Test message',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = HealthViewDataBuilder.build(healthDTO);
|
||||||
|
|
||||||
|
expect(result.hasAlerts).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly calculate hasDegradedComponents', () => {
|
||||||
|
const healthDTO: HealthDTO = {
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
name: 'Component 1',
|
||||||
|
status: 'ok',
|
||||||
|
lastCheck: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Component 2',
|
||||||
|
status: 'degraded',
|
||||||
|
lastCheck: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = HealthViewDataBuilder.build(healthDTO);
|
||||||
|
|
||||||
|
expect(result.hasDegradedComponents).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly calculate hasErrorComponents', () => {
|
||||||
|
const healthDTO: HealthDTO = {
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
name: 'Component 1',
|
||||||
|
status: 'ok',
|
||||||
|
lastCheck: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Component 2',
|
||||||
|
status: 'error',
|
||||||
|
lastCheck: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = HealthViewDataBuilder.build(healthDTO);
|
||||||
|
|
||||||
|
expect(result.hasErrorComponents).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly calculate totalChecks', () => {
|
||||||
|
const healthDTO: HealthDTO = {
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
checksPassed: 100,
|
||||||
|
checksFailed: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = HealthViewDataBuilder.build(healthDTO);
|
||||||
|
|
||||||
|
expect(result.metrics.totalChecks).toBe(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly calculate successRate', () => {
|
||||||
|
const healthDTO: HealthDTO = {
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
checksPassed: 90,
|
||||||
|
checksFailed: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = HealthViewDataBuilder.build(healthDTO);
|
||||||
|
|
||||||
|
expect(result.metrics.successRate).toBe('90.0%');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero checks correctly', () => {
|
||||||
|
const healthDTO: HealthDTO = {
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
checksPassed: 0,
|
||||||
|
checksFailed: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = HealthViewDataBuilder.build(healthDTO);
|
||||||
|
|
||||||
|
expect(result.metrics.totalChecks).toBe(0);
|
||||||
|
expect(result.metrics.successRate).toBe('N/A');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,95 +1,66 @@
|
|||||||
/**
|
|
||||||
* Health View Data Builder
|
|
||||||
*
|
|
||||||
* Transforms health DTO data into UI-ready view models.
|
|
||||||
* This layer isolates the UI from API churn by providing a stable interface
|
|
||||||
* between the API layer and the presentation layer.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { HealthViewData, HealthStatus, HealthMetrics, HealthComponent, HealthAlert } from '@/lib/view-data/HealthViewData';
|
|
||||||
import { HealthStatusDisplay } from '@/lib/display-objects/HealthStatusDisplay';
|
|
||||||
import { HealthMetricDisplay } from '@/lib/display-objects/HealthMetricDisplay';
|
|
||||||
import { HealthComponentDisplay } from '@/lib/display-objects/HealthComponentDisplay';
|
|
||||||
import { HealthAlertDisplay } from '@/lib/display-objects/HealthAlertDisplay';
|
|
||||||
|
|
||||||
export interface HealthDTO {
|
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
|
||||||
status: 'ok' | 'degraded' | 'error' | 'unknown';
|
import { HealthAlertFormatter } from '@/lib/formatters/HealthAlertFormatter';
|
||||||
timestamp: string;
|
import { HealthComponentFormatter } from '@/lib/formatters/HealthComponentFormatter';
|
||||||
uptime?: number;
|
import { HealthMetricFormatter } from '@/lib/formatters/HealthMetricFormatter';
|
||||||
responseTime?: number;
|
import { HealthStatusFormatter } from '@/lib/formatters/HealthStatusFormatter';
|
||||||
errorRate?: number;
|
import type { HealthDTO } from '@/lib/types/generated/HealthDTO';
|
||||||
lastCheck?: string;
|
import type { HealthAlert, HealthComponent, HealthMetrics, HealthStatus, HealthViewData } from '@/lib/view-data/HealthViewData';
|
||||||
checksPassed?: number;
|
|
||||||
checksFailed?: number;
|
|
||||||
components?: Array<{
|
|
||||||
name: string;
|
|
||||||
status: 'ok' | 'degraded' | 'error' | 'unknown';
|
|
||||||
lastCheck?: string;
|
|
||||||
responseTime?: number;
|
|
||||||
errorRate?: number;
|
|
||||||
}>;
|
|
||||||
alerts?: Array<{
|
|
||||||
id: string;
|
|
||||||
type: 'critical' | 'warning' | 'info';
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
timestamp: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class HealthViewDataBuilder {
|
export class HealthViewDataBuilder {
|
||||||
static build(dto: HealthDTO): HealthViewData {
|
public static build(apiDto: HealthDTO): HealthViewData {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const lastUpdated = dto.timestamp || now.toISOString();
|
const lastUpdated = apiDto.timestamp || now.toISOString();
|
||||||
|
|
||||||
// Build overall status
|
// Build overall status
|
||||||
const overallStatus: HealthStatus = {
|
const overallStatus: HealthStatus = {
|
||||||
status: dto.status,
|
status: apiDto.status,
|
||||||
timestamp: dto.timestamp,
|
timestamp: apiDto.timestamp,
|
||||||
formattedTimestamp: HealthStatusDisplay.formatTimestamp(dto.timestamp),
|
formattedTimestamp: HealthStatusFormatter.formatTimestamp(apiDto.timestamp),
|
||||||
relativeTime: HealthStatusDisplay.formatRelativeTime(dto.timestamp),
|
relativeTime: HealthStatusFormatter.formatRelativeTime(apiDto.timestamp),
|
||||||
statusLabel: HealthStatusDisplay.formatStatusLabel(dto.status),
|
statusLabel: HealthStatusFormatter.formatStatusLabel(apiDto.status),
|
||||||
statusColor: HealthStatusDisplay.formatStatusColor(dto.status),
|
statusColor: HealthStatusFormatter.formatStatusColor(apiDto.status),
|
||||||
statusIcon: HealthStatusDisplay.formatStatusIcon(dto.status),
|
statusIcon: HealthStatusFormatter.formatStatusIcon(apiDto.status),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build metrics
|
// Build metrics
|
||||||
const metrics: HealthMetrics = {
|
const metrics: HealthMetrics = {
|
||||||
uptime: HealthMetricDisplay.formatUptime(dto.uptime),
|
uptime: HealthMetricFormatter.formatUptime(apiDto.uptime),
|
||||||
responseTime: HealthMetricDisplay.formatResponseTime(dto.responseTime),
|
responseTime: HealthMetricFormatter.formatResponseTime(apiDto.responseTime),
|
||||||
errorRate: HealthMetricDisplay.formatErrorRate(dto.errorRate),
|
errorRate: HealthMetricFormatter.formatErrorRate(apiDto.errorRate),
|
||||||
lastCheck: dto.lastCheck || lastUpdated,
|
lastCheck: apiDto.lastCheck || lastUpdated,
|
||||||
formattedLastCheck: HealthMetricDisplay.formatTimestamp(dto.lastCheck || lastUpdated),
|
formattedLastCheck: HealthMetricFormatter.formatTimestamp(apiDto.lastCheck || lastUpdated),
|
||||||
checksPassed: dto.checksPassed || 0,
|
checksPassed: apiDto.checksPassed || 0,
|
||||||
checksFailed: dto.checksFailed || 0,
|
checksFailed: apiDto.checksFailed || 0,
|
||||||
totalChecks: (dto.checksPassed || 0) + (dto.checksFailed || 0),
|
totalChecks: (apiDto.checksPassed || 0) + (apiDto.checksFailed || 0),
|
||||||
successRate: HealthMetricDisplay.formatSuccessRate(dto.checksPassed, dto.checksFailed),
|
successRate: HealthMetricFormatter.formatSuccessRate(apiDto.checksPassed, apiDto.checksFailed),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build components
|
// Build components
|
||||||
const components: HealthComponent[] = (dto.components || []).map((component) => ({
|
const components: HealthComponent[] = (apiDto.components || []).map((component) => ({
|
||||||
name: component.name,
|
name: component.name,
|
||||||
status: component.status,
|
status: component.status,
|
||||||
statusLabel: HealthComponentDisplay.formatStatusLabel(component.status),
|
statusLabel: HealthComponentFormatter.formatStatusLabel(component.status),
|
||||||
statusColor: HealthComponentDisplay.formatStatusColor(component.status),
|
statusColor: HealthComponentFormatter.formatStatusColor(component.status),
|
||||||
statusIcon: HealthComponentDisplay.formatStatusIcon(component.status),
|
statusIcon: HealthComponentFormatter.formatStatusIcon(component.status),
|
||||||
lastCheck: component.lastCheck || lastUpdated,
|
lastCheck: component.lastCheck || lastUpdated,
|
||||||
formattedLastCheck: HealthComponentDisplay.formatTimestamp(component.lastCheck || lastUpdated),
|
formattedLastCheck: HealthComponentFormatter.formatTimestamp(component.lastCheck || lastUpdated),
|
||||||
responseTime: HealthMetricDisplay.formatResponseTime(component.responseTime),
|
responseTime: HealthMetricFormatter.formatResponseTime(component.responseTime),
|
||||||
errorRate: HealthMetricDisplay.formatErrorRate(component.errorRate),
|
errorRate: HealthMetricFormatter.formatErrorRate(component.errorRate),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Build alerts
|
// Build alerts
|
||||||
const alerts: HealthAlert[] = (dto.alerts || []).map((alert) => ({
|
const alerts: HealthAlert[] = (apiDto.alerts || []).map((alert) => ({
|
||||||
id: alert.id,
|
id: alert.id,
|
||||||
type: alert.type,
|
type: alert.type,
|
||||||
title: alert.title,
|
title: alert.title,
|
||||||
message: alert.message,
|
message: alert.message,
|
||||||
timestamp: alert.timestamp,
|
timestamp: alert.timestamp,
|
||||||
formattedTimestamp: HealthAlertDisplay.formatTimestamp(alert.timestamp),
|
formattedTimestamp: HealthAlertFormatter.formatTimestamp(alert.timestamp),
|
||||||
relativeTime: HealthAlertDisplay.formatRelativeTime(alert.timestamp),
|
relativeTime: HealthAlertFormatter.formatRelativeTime(alert.timestamp),
|
||||||
severity: HealthAlertDisplay.formatSeverity(alert.type),
|
severity: HealthAlertFormatter.formatSeverity(alert.type),
|
||||||
severityColor: HealthAlertDisplay.formatSeverityColor(alert.type),
|
severityColor: HealthAlertFormatter.formatSeverityColor(alert.type),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Calculate derived fields
|
// Calculate derived fields
|
||||||
@@ -106,7 +77,9 @@ export class HealthViewDataBuilder {
|
|||||||
hasDegradedComponents,
|
hasDegradedComponents,
|
||||||
hasErrorComponents,
|
hasErrorComponents,
|
||||||
lastUpdated,
|
lastUpdated,
|
||||||
formattedLastUpdated: HealthStatusDisplay.formatTimestamp(lastUpdated),
|
formattedLastUpdated: HealthStatusFormatter.formatTimestamp(lastUpdated),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HealthViewDataBuilder satisfies ViewDataBuilder<HealthDTO, HealthViewData>;
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { HomeViewDataBuilder } from './HomeViewDataBuilder';
|
||||||
|
import type { DashboardOverviewDTO } from '@/lib/types/generated/DashboardOverviewDTO';
|
||||||
|
|
||||||
|
describe('HomeViewDataBuilder', () => {
|
||||||
|
describe('happy paths', () => {
|
||||||
|
it('should transform DashboardOverviewDTO to HomeViewData correctly', () => {
|
||||||
|
const homeDataDto: DashboardOverviewDTO = {
|
||||||
|
currentDriver: null,
|
||||||
|
upcomingRaces: [
|
||||||
|
{
|
||||||
|
id: 'race-1',
|
||||||
|
track: 'Test Track',
|
||||||
|
car: 'Test Car',
|
||||||
|
scheduledAt: '2024-01-01T10:00:00Z',
|
||||||
|
isMyLeague: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
leagueStandingsSummaries: [
|
||||||
|
{
|
||||||
|
leagueId: 'league-1',
|
||||||
|
leagueName: 'Test League',
|
||||||
|
position: 1,
|
||||||
|
points: 100,
|
||||||
|
totalDrivers: 20,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
feedSummary: { items: [] },
|
||||||
|
friends: [],
|
||||||
|
activeLeaguesCount: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = HomeViewDataBuilder.build(homeDataDto);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
isAlpha: true,
|
||||||
|
upcomingRaces: [
|
||||||
|
{
|
||||||
|
id: 'race-1',
|
||||||
|
track: 'Test Track',
|
||||||
|
car: 'Test Car',
|
||||||
|
formattedDate: 'Mon, Jan 1, 2024',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
topLeagues: [
|
||||||
|
{
|
||||||
|
id: 'league-1',
|
||||||
|
name: 'Test League',
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
teams: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty arrays correctly', () => {
|
||||||
|
const homeDataDto: DashboardOverviewDTO = {
|
||||||
|
currentDriver: null,
|
||||||
|
upcomingRaces: [],
|
||||||
|
leagueStandingsSummaries: [],
|
||||||
|
feedSummary: { items: [] },
|
||||||
|
friends: [],
|
||||||
|
activeLeaguesCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = HomeViewDataBuilder.build(homeDataDto);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
isAlpha: true,
|
||||||
|
upcomingRaces: [],
|
||||||
|
topLeagues: [],
|
||||||
|
teams: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('data transformation', () => {
|
||||||
|
it('should not modify the input DTO', () => {
|
||||||
|
const homeDataDto: DashboardOverviewDTO = {
|
||||||
|
currentDriver: null,
|
||||||
|
upcomingRaces: [{ id: 'race-1', track: 'Track', car: 'Car', scheduledAt: '2024-01-01T10:00:00Z', isMyLeague: false }],
|
||||||
|
leagueStandingsSummaries: [{ leagueId: 'league-1', leagueName: 'League', position: 1, points: 10, totalDrivers: 10 }],
|
||||||
|
feedSummary: { items: [] },
|
||||||
|
friends: [],
|
||||||
|
activeLeaguesCount: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalDto = JSON.parse(JSON.stringify(homeDataDto));
|
||||||
|
HomeViewDataBuilder.build(homeDataDto);
|
||||||
|
|
||||||
|
expect(homeDataDto).toEqual(originalDto);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,24 +1,34 @@
|
|||||||
import type { HomeViewData } from '@/templates/HomeTemplate';
|
|
||||||
import type { HomeDataDTO } from '@/lib/types/dtos/HomeDataDTO';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HomeViewDataBuilder
|
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
|
||||||
*
|
import { DashboardDateFormatter } from '@/lib/formatters/DashboardDateFormatter';
|
||||||
* Transforms HomeDataDTO to HomeViewData.
|
import type { DashboardOverviewDTO } from '@/lib/types/generated/DashboardOverviewDTO';
|
||||||
*/
|
import type { HomeViewData } from '@/lib/view-data/HomeViewData';
|
||||||
|
|
||||||
export class HomeViewDataBuilder {
|
export class HomeViewDataBuilder {
|
||||||
/**
|
/**
|
||||||
* Build HomeViewData from HomeDataDTO
|
* Build HomeViewData from DashboardOverviewDTO
|
||||||
*
|
*
|
||||||
* @param apiDto - The API DTO
|
* @param apiDto - The API DTO
|
||||||
* @returns HomeViewData
|
* @returns HomeViewData
|
||||||
*/
|
*/
|
||||||
static build(apiDto: HomeDataDTO): HomeViewData {
|
public static build(apiDto: DashboardOverviewDTO): HomeViewData {
|
||||||
return {
|
return {
|
||||||
isAlpha: apiDto.isAlpha,
|
isAlpha: true,
|
||||||
upcomingRaces: apiDto.upcomingRaces,
|
upcomingRaces: (apiDto.upcomingRaces || []).map(race => ({
|
||||||
topLeagues: apiDto.topLeagues,
|
id: race.id,
|
||||||
teams: apiDto.teams,
|
track: race.track,
|
||||||
|
car: race.car,
|
||||||
|
formattedDate: DashboardDateFormatter.format(new Date(race.scheduledAt)).date,
|
||||||
|
})),
|
||||||
|
topLeagues: (apiDto.leagueStandingsSummaries || []).map(league => ({
|
||||||
|
id: league.leagueId,
|
||||||
|
name: league.leagueName,
|
||||||
|
description: '',
|
||||||
|
})),
|
||||||
|
teams: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HomeViewDataBuilder satisfies ViewDataBuilder<DashboardOverviewDTO, HomeViewData>;
|
||||||
|
|||||||
@@ -0,0 +1,600 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { LeaderboardsViewDataBuilder } from './LeaderboardsViewDataBuilder';
|
||||||
|
|
||||||
|
describe('LeaderboardsViewDataBuilder', () => {
|
||||||
|
describe('happy paths', () => {
|
||||||
|
it('should transform Leaderboards DTO to LeaderboardsViewData correctly', () => {
|
||||||
|
const leaderboardsDTO = {
|
||||||
|
drivers: {
|
||||||
|
drivers: [
|
||||||
|
{
|
||||||
|
id: 'driver-1',
|
||||||
|
name: 'John Doe',
|
||||||
|
rating: 1234.56,
|
||||||
|
skillLevel: 'pro',
|
||||||
|
nationality: 'USA',
|
||||||
|
racesCompleted: 150,
|
||||||
|
wins: 25,
|
||||||
|
podiums: 60,
|
||||||
|
isActive: true,
|
||||||
|
rank: 1,
|
||||||
|
avatarUrl: 'https://example.com/avatar1.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'driver-2',
|
||||||
|
name: 'Jane Smith',
|
||||||
|
rating: 1100.0,
|
||||||
|
skillLevel: 'advanced',
|
||||||
|
nationality: 'Canada',
|
||||||
|
racesCompleted: 100,
|
||||||
|
wins: 15,
|
||||||
|
podiums: 40,
|
||||||
|
isActive: true,
|
||||||
|
rank: 2,
|
||||||
|
avatarUrl: 'https://example.com/avatar2.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalRaces: 250,
|
||||||
|
totalWins: 40,
|
||||||
|
activeCount: 2,
|
||||||
|
},
|
||||||
|
teams: {
|
||||||
|
teams: [
|
||||||
|
{
|
||||||
|
id: 'team-1',
|
||||||
|
name: 'Racing Team Alpha',
|
||||||
|
tag: 'RTA',
|
||||||
|
logoUrl: 'https://example.com/logo1.jpg',
|
||||||
|
memberCount: 15,
|
||||||
|
rating: 1500,
|
||||||
|
totalWins: 50,
|
||||||
|
totalRaces: 200,
|
||||||
|
performanceLevel: 'elite',
|
||||||
|
isRecruiting: false,
|
||||||
|
createdAt: '2023-01-01',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'team-2',
|
||||||
|
name: 'Speed Demons',
|
||||||
|
tag: 'SD',
|
||||||
|
logoUrl: 'https://example.com/logo2.jpg',
|
||||||
|
memberCount: 8,
|
||||||
|
rating: 1200,
|
||||||
|
totalWins: 20,
|
||||||
|
totalRaces: 150,
|
||||||
|
performanceLevel: 'advanced',
|
||||||
|
isRecruiting: true,
|
||||||
|
createdAt: '2023-06-01',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
recruitingCount: 5,
|
||||||
|
groupsBySkillLevel: 'pro,advanced,intermediate',
|
||||||
|
topTeams: [
|
||||||
|
{
|
||||||
|
id: 'team-1',
|
||||||
|
name: 'Racing Team Alpha',
|
||||||
|
tag: 'RTA',
|
||||||
|
logoUrl: 'https://example.com/logo1.jpg',
|
||||||
|
memberCount: 15,
|
||||||
|
rating: 1500,
|
||||||
|
totalWins: 50,
|
||||||
|
totalRaces: 200,
|
||||||
|
performanceLevel: 'elite',
|
||||||
|
isRecruiting: false,
|
||||||
|
createdAt: '2023-01-01',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'team-2',
|
||||||
|
name: 'Speed Demons',
|
||||||
|
tag: 'SD',
|
||||||
|
logoUrl: 'https://example.com/logo2.jpg',
|
||||||
|
memberCount: 8,
|
||||||
|
rating: 1200,
|
||||||
|
totalWins: 20,
|
||||||
|
totalRaces: 150,
|
||||||
|
performanceLevel: 'advanced',
|
||||||
|
isRecruiting: true,
|
||||||
|
createdAt: '2023-06-01',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||||
|
|
||||||
|
// Verify drivers
|
||||||
|
expect(result.drivers).toHaveLength(2);
|
||||||
|
expect(result.drivers[0].id).toBe('driver-1');
|
||||||
|
expect(result.drivers[0].name).toBe('John Doe');
|
||||||
|
expect(result.drivers[0].rating).toBe(1234.56);
|
||||||
|
expect(result.drivers[0].skillLevel).toBe('pro');
|
||||||
|
expect(result.drivers[0].nationality).toBe('USA');
|
||||||
|
expect(result.drivers[0].wins).toBe(25);
|
||||||
|
expect(result.drivers[0].podiums).toBe(60);
|
||||||
|
expect(result.drivers[0].racesCompleted).toBe(150);
|
||||||
|
expect(result.drivers[0].rank).toBe(1);
|
||||||
|
expect(result.drivers[0].avatarUrl).toBe('https://example.com/avatar1.jpg');
|
||||||
|
expect(result.drivers[0].position).toBe(1);
|
||||||
|
|
||||||
|
// Verify teams
|
||||||
|
expect(result.teams).toHaveLength(2);
|
||||||
|
expect(result.teams[0].id).toBe('team-1');
|
||||||
|
expect(result.teams[0].name).toBe('Racing Team Alpha');
|
||||||
|
expect(result.teams[0].tag).toBe('RTA');
|
||||||
|
expect(result.teams[0].memberCount).toBe(15);
|
||||||
|
expect(result.teams[0].totalWins).toBe(50);
|
||||||
|
expect(result.teams[0].totalRaces).toBe(200);
|
||||||
|
expect(result.teams[0].logoUrl).toBe('https://example.com/logo1.jpg');
|
||||||
|
expect(result.teams[0].position).toBe(1);
|
||||||
|
expect(result.teams[0].isRecruiting).toBe(false);
|
||||||
|
expect(result.teams[0].performanceLevel).toBe('elite');
|
||||||
|
expect(result.teams[0].rating).toBe(1500);
|
||||||
|
expect(result.teams[0].category).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty driver and team arrays', () => {
|
||||||
|
const leaderboardsDTO = {
|
||||||
|
drivers: {
|
||||||
|
drivers: [],
|
||||||
|
totalRaces: 0,
|
||||||
|
totalWins: 0,
|
||||||
|
activeCount: 0,
|
||||||
|
},
|
||||||
|
teams: {
|
||||||
|
teams: [],
|
||||||
|
recruitingCount: 0,
|
||||||
|
groupsBySkillLevel: '',
|
||||||
|
topTeams: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||||
|
|
||||||
|
expect(result.drivers).toEqual([]);
|
||||||
|
expect(result.teams).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing avatar URLs with empty string fallback', () => {
|
||||||
|
const leaderboardsDTO = {
|
||||||
|
drivers: {
|
||||||
|
drivers: [
|
||||||
|
{
|
||||||
|
id: 'driver-1',
|
||||||
|
name: 'John Doe',
|
||||||
|
rating: 1234.56,
|
||||||
|
skillLevel: 'pro',
|
||||||
|
nationality: 'USA',
|
||||||
|
racesCompleted: 150,
|
||||||
|
wins: 25,
|
||||||
|
podiums: 60,
|
||||||
|
isActive: true,
|
||||||
|
rank: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalRaces: 150,
|
||||||
|
totalWins: 25,
|
||||||
|
activeCount: 1,
|
||||||
|
},
|
||||||
|
teams: {
|
||||||
|
teams: [],
|
||||||
|
recruitingCount: 0,
|
||||||
|
groupsBySkillLevel: '',
|
||||||
|
topTeams: [
|
||||||
|
{
|
||||||
|
id: 'team-1',
|
||||||
|
name: 'Racing Team Alpha',
|
||||||
|
tag: 'RTA',
|
||||||
|
memberCount: 15,
|
||||||
|
totalWins: 50,
|
||||||
|
totalRaces: 200,
|
||||||
|
performanceLevel: 'elite',
|
||||||
|
isRecruiting: false,
|
||||||
|
createdAt: '2023-01-01',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||||
|
|
||||||
|
expect(result.drivers[0].avatarUrl).toBe('');
|
||||||
|
expect(result.teams[0].logoUrl).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing optional team fields with defaults', () => {
|
||||||
|
const leaderboardsDTO = {
|
||||||
|
drivers: {
|
||||||
|
drivers: [],
|
||||||
|
totalRaces: 0,
|
||||||
|
totalWins: 0,
|
||||||
|
activeCount: 0,
|
||||||
|
},
|
||||||
|
teams: {
|
||||||
|
teams: [],
|
||||||
|
recruitingCount: 0,
|
||||||
|
groupsBySkillLevel: '',
|
||||||
|
topTeams: [
|
||||||
|
{
|
||||||
|
id: 'team-1',
|
||||||
|
name: 'Racing Team Alpha',
|
||||||
|
tag: 'RTA',
|
||||||
|
memberCount: 15,
|
||||||
|
totalWins: 50,
|
||||||
|
totalRaces: 200,
|
||||||
|
performanceLevel: 'elite',
|
||||||
|
isRecruiting: false,
|
||||||
|
createdAt: '2023-01-01',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||||
|
|
||||||
|
expect(result.teams[0].rating).toBe(0);
|
||||||
|
expect(result.teams[0].logoUrl).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate position based on index', () => {
|
||||||
|
const leaderboardsDTO = {
|
||||||
|
drivers: {
|
||||||
|
drivers: [
|
||||||
|
{ id: 'driver-1', name: 'Driver 1', rating: 1000, skillLevel: 'pro', nationality: 'USA', racesCompleted: 100, wins: 10, podiums: 30, isActive: true, rank: 1 },
|
||||||
|
{ id: 'driver-2', name: 'Driver 2', rating: 900, skillLevel: 'advanced', nationality: 'Canada', racesCompleted: 80, wins: 8, podiums: 25, isActive: true, rank: 2 },
|
||||||
|
{ id: 'driver-3', name: 'Driver 3', rating: 800, skillLevel: 'intermediate', nationality: 'UK', racesCompleted: 60, wins: 5, podiums: 15, isActive: true, rank: 3 },
|
||||||
|
],
|
||||||
|
totalRaces: 240,
|
||||||
|
totalWins: 23,
|
||||||
|
activeCount: 3,
|
||||||
|
},
|
||||||
|
teams: {
|
||||||
|
teams: [],
|
||||||
|
recruitingCount: 1,
|
||||||
|
groupsBySkillLevel: 'elite,advanced,intermediate',
|
||||||
|
topTeams: [
|
||||||
|
{ id: 'team-1', name: 'Team 1', tag: 'T1', memberCount: 10, totalWins: 30, totalRaces: 150, performanceLevel: 'elite', isRecruiting: false, createdAt: '2023-01-01' },
|
||||||
|
{ id: 'team-2', name: 'Team 2', tag: 'T2', memberCount: 8, totalWins: 20, totalRaces: 120, performanceLevel: 'advanced', isRecruiting: true, createdAt: '2023-02-01' },
|
||||||
|
{ id: 'team-3', name: 'Team 3', tag: 'T3', memberCount: 6, totalWins: 10, totalRaces: 80, performanceLevel: 'intermediate', isRecruiting: false, createdAt: '2023-03-01' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||||
|
|
||||||
|
expect(result.drivers[0].position).toBe(1);
|
||||||
|
expect(result.drivers[1].position).toBe(2);
|
||||||
|
expect(result.drivers[2].position).toBe(3);
|
||||||
|
|
||||||
|
expect(result.teams[0].position).toBe(1);
|
||||||
|
expect(result.teams[1].position).toBe(2);
|
||||||
|
expect(result.teams[2].position).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('data transformation', () => {
|
||||||
|
it('should preserve all DTO fields in the output', () => {
|
||||||
|
const leaderboardsDTO = {
|
||||||
|
drivers: {
|
||||||
|
drivers: [
|
||||||
|
{
|
||||||
|
id: 'driver-123',
|
||||||
|
name: 'John Doe',
|
||||||
|
rating: 1234.56,
|
||||||
|
skillLevel: 'pro',
|
||||||
|
nationality: 'USA',
|
||||||
|
racesCompleted: 150,
|
||||||
|
wins: 25,
|
||||||
|
podiums: 60,
|
||||||
|
isActive: true,
|
||||||
|
rank: 1,
|
||||||
|
avatarUrl: 'https://example.com/avatar.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalRaces: 150,
|
||||||
|
totalWins: 25,
|
||||||
|
activeCount: 1,
|
||||||
|
},
|
||||||
|
teams: {
|
||||||
|
teams: [],
|
||||||
|
recruitingCount: 5,
|
||||||
|
groupsBySkillLevel: 'pro,advanced',
|
||||||
|
topTeams: [
|
||||||
|
{
|
||||||
|
id: 'team-123',
|
||||||
|
name: 'Racing Team Alpha',
|
||||||
|
tag: 'RTA',
|
||||||
|
logoUrl: 'https://example.com/logo.jpg',
|
||||||
|
memberCount: 15,
|
||||||
|
rating: 1500,
|
||||||
|
totalWins: 50,
|
||||||
|
totalRaces: 200,
|
||||||
|
performanceLevel: 'elite',
|
||||||
|
isRecruiting: false,
|
||||||
|
createdAt: '2023-01-01',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||||
|
|
||||||
|
expect(result.drivers[0].name).toBe(leaderboardsDTO.drivers.drivers[0].name);
|
||||||
|
expect(result.drivers[0].nationality).toBe(leaderboardsDTO.drivers.drivers[0].nationality);
|
||||||
|
expect(result.drivers[0].avatarUrl).toBe(leaderboardsDTO.drivers.drivers[0].avatarUrl);
|
||||||
|
expect(result.teams[0].name).toBe(leaderboardsDTO.teams.topTeams[0].name);
|
||||||
|
expect(result.teams[0].tag).toBe(leaderboardsDTO.teams.topTeams[0].tag);
|
||||||
|
expect(result.teams[0].logoUrl).toBe(leaderboardsDTO.teams.topTeams[0].logoUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not modify the input DTO', () => {
|
||||||
|
const leaderboardsDTO = {
|
||||||
|
drivers: {
|
||||||
|
drivers: [
|
||||||
|
{
|
||||||
|
id: 'driver-123',
|
||||||
|
name: 'John Doe',
|
||||||
|
rating: 1234.56,
|
||||||
|
skillLevel: 'pro',
|
||||||
|
nationality: 'USA',
|
||||||
|
racesCompleted: 150,
|
||||||
|
wins: 25,
|
||||||
|
podiums: 60,
|
||||||
|
isActive: true,
|
||||||
|
rank: 1,
|
||||||
|
avatarUrl: 'https://example.com/avatar.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalRaces: 150,
|
||||||
|
totalWins: 25,
|
||||||
|
activeCount: 1,
|
||||||
|
},
|
||||||
|
teams: {
|
||||||
|
teams: [],
|
||||||
|
recruitingCount: 5,
|
||||||
|
groupsBySkillLevel: 'pro,advanced',
|
||||||
|
topTeams: [
|
||||||
|
{
|
||||||
|
id: 'team-123',
|
||||||
|
name: 'Racing Team Alpha',
|
||||||
|
tag: 'RTA',
|
||||||
|
logoUrl: 'https://example.com/logo.jpg',
|
||||||
|
memberCount: 15,
|
||||||
|
rating: 1500,
|
||||||
|
totalWins: 50,
|
||||||
|
totalRaces: 200,
|
||||||
|
performanceLevel: 'elite',
|
||||||
|
isRecruiting: false,
|
||||||
|
createdAt: '2023-01-01',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalDTO = JSON.parse(JSON.stringify(leaderboardsDTO));
|
||||||
|
LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||||
|
|
||||||
|
expect(leaderboardsDTO).toEqual(originalDTO);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle large numbers correctly', () => {
|
||||||
|
const leaderboardsDTO = {
|
||||||
|
drivers: {
|
||||||
|
drivers: [
|
||||||
|
{
|
||||||
|
id: 'driver-1',
|
||||||
|
name: 'John Doe',
|
||||||
|
rating: 999999.99,
|
||||||
|
skillLevel: 'pro',
|
||||||
|
nationality: 'USA',
|
||||||
|
racesCompleted: 10000,
|
||||||
|
wins: 2500,
|
||||||
|
podiums: 5000,
|
||||||
|
isActive: true,
|
||||||
|
rank: 1,
|
||||||
|
avatarUrl: 'https://example.com/avatar.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalRaces: 10000,
|
||||||
|
totalWins: 2500,
|
||||||
|
activeCount: 1,
|
||||||
|
},
|
||||||
|
teams: {
|
||||||
|
teams: [],
|
||||||
|
recruitingCount: 0,
|
||||||
|
groupsBySkillLevel: '',
|
||||||
|
topTeams: [
|
||||||
|
{
|
||||||
|
id: 'team-1',
|
||||||
|
name: 'Racing Team Alpha',
|
||||||
|
tag: 'RTA',
|
||||||
|
logoUrl: 'https://example.com/logo.jpg',
|
||||||
|
memberCount: 100,
|
||||||
|
rating: 999999,
|
||||||
|
totalWins: 5000,
|
||||||
|
totalRaces: 10000,
|
||||||
|
performanceLevel: 'elite',
|
||||||
|
isRecruiting: false,
|
||||||
|
createdAt: '2023-01-01',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||||
|
|
||||||
|
expect(result.drivers[0].rating).toBe(999999.99);
|
||||||
|
expect(result.drivers[0].wins).toBe(2500);
|
||||||
|
expect(result.drivers[0].podiums).toBe(5000);
|
||||||
|
expect(result.drivers[0].racesCompleted).toBe(10000);
|
||||||
|
expect(result.teams[0].rating).toBe(999999);
|
||||||
|
expect(result.teams[0].totalWins).toBe(5000);
|
||||||
|
expect(result.teams[0].totalRaces).toBe(10000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle null/undefined avatar URLs', () => {
|
||||||
|
const leaderboardsDTO = {
|
||||||
|
drivers: {
|
||||||
|
drivers: [
|
||||||
|
{
|
||||||
|
id: 'driver-1',
|
||||||
|
name: 'John Doe',
|
||||||
|
rating: 1234.56,
|
||||||
|
skillLevel: 'pro',
|
||||||
|
nationality: 'USA',
|
||||||
|
racesCompleted: 150,
|
||||||
|
wins: 25,
|
||||||
|
podiums: 60,
|
||||||
|
isActive: true,
|
||||||
|
rank: 1,
|
||||||
|
avatarUrl: null as any,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalRaces: 150,
|
||||||
|
totalWins: 25,
|
||||||
|
activeCount: 1,
|
||||||
|
},
|
||||||
|
teams: {
|
||||||
|
teams: [],
|
||||||
|
recruitingCount: 0,
|
||||||
|
groupsBySkillLevel: '',
|
||||||
|
topTeams: [
|
||||||
|
{
|
||||||
|
id: 'team-1',
|
||||||
|
name: 'Racing Team Alpha',
|
||||||
|
tag: 'RTA',
|
||||||
|
logoUrl: undefined as any,
|
||||||
|
memberCount: 15,
|
||||||
|
totalWins: 50,
|
||||||
|
totalRaces: 200,
|
||||||
|
performanceLevel: 'elite',
|
||||||
|
isRecruiting: false,
|
||||||
|
createdAt: '2023-01-01',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||||
|
|
||||||
|
expect(result.drivers[0].avatarUrl).toBe('');
|
||||||
|
expect(result.teams[0].logoUrl).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null/undefined rating', () => {
|
||||||
|
const leaderboardsDTO = {
|
||||||
|
drivers: {
|
||||||
|
drivers: [
|
||||||
|
{
|
||||||
|
id: 'driver-1',
|
||||||
|
name: 'John Doe',
|
||||||
|
rating: null as any,
|
||||||
|
skillLevel: 'pro',
|
||||||
|
nationality: 'USA',
|
||||||
|
racesCompleted: 150,
|
||||||
|
wins: 25,
|
||||||
|
podiums: 60,
|
||||||
|
isActive: true,
|
||||||
|
rank: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalRaces: 150,
|
||||||
|
totalWins: 25,
|
||||||
|
activeCount: 1,
|
||||||
|
},
|
||||||
|
teams: {
|
||||||
|
teams: [],
|
||||||
|
recruitingCount: 0,
|
||||||
|
groupsBySkillLevel: '',
|
||||||
|
topTeams: [
|
||||||
|
{
|
||||||
|
id: 'team-1',
|
||||||
|
name: 'Racing Team Alpha',
|
||||||
|
tag: 'RTA',
|
||||||
|
memberCount: 15,
|
||||||
|
rating: null as any,
|
||||||
|
totalWins: 50,
|
||||||
|
totalRaces: 200,
|
||||||
|
performanceLevel: 'elite',
|
||||||
|
isRecruiting: false,
|
||||||
|
createdAt: '2023-01-01',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||||
|
|
||||||
|
expect(result.drivers[0].rating).toBeNull();
|
||||||
|
expect(result.teams[0].rating).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null/undefined totalWins and totalRaces', () => {
|
||||||
|
const leaderboardsDTO = {
|
||||||
|
drivers: {
|
||||||
|
drivers: [],
|
||||||
|
totalRaces: 0,
|
||||||
|
totalWins: 0,
|
||||||
|
activeCount: 0,
|
||||||
|
},
|
||||||
|
teams: {
|
||||||
|
teams: [],
|
||||||
|
recruitingCount: 0,
|
||||||
|
groupsBySkillLevel: '',
|
||||||
|
topTeams: [
|
||||||
|
{
|
||||||
|
id: 'team-1',
|
||||||
|
name: 'Racing Team Alpha',
|
||||||
|
tag: 'RTA',
|
||||||
|
memberCount: 15,
|
||||||
|
totalWins: null as any,
|
||||||
|
totalRaces: null as any,
|
||||||
|
performanceLevel: 'elite',
|
||||||
|
isRecruiting: false,
|
||||||
|
createdAt: '2023-01-01',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||||
|
|
||||||
|
expect(result.teams[0].totalWins).toBe(0);
|
||||||
|
expect(result.teams[0].totalRaces).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty performance level', () => {
|
||||||
|
const leaderboardsDTO = {
|
||||||
|
drivers: {
|
||||||
|
drivers: [],
|
||||||
|
totalRaces: 0,
|
||||||
|
totalWins: 0,
|
||||||
|
activeCount: 0,
|
||||||
|
},
|
||||||
|
teams: {
|
||||||
|
teams: [],
|
||||||
|
recruitingCount: 0,
|
||||||
|
groupsBySkillLevel: '',
|
||||||
|
topTeams: [
|
||||||
|
{
|
||||||
|
id: 'team-1',
|
||||||
|
name: 'Racing Team Alpha',
|
||||||
|
tag: 'RTA',
|
||||||
|
memberCount: 15,
|
||||||
|
totalWins: 50,
|
||||||
|
totalRaces: 200,
|
||||||
|
performanceLevel: '',
|
||||||
|
isRecruiting: false,
|
||||||
|
createdAt: '2023-01-01',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||||
|
|
||||||
|
expect(result.teams[0].performanceLevel).toBe('N/A');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
|
|
||||||
|
|
||||||
|
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
|
||||||
import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO';
|
import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO';
|
||||||
import type { GetTeamsLeaderboardOutputDTO } from '@/lib/types/generated/GetTeamsLeaderboardOutputDTO';
|
import type { GetTeamsLeaderboardOutputDTO } from '@/lib/types/generated/GetTeamsLeaderboardOutputDTO';
|
||||||
import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData';
|
import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData';
|
||||||
|
|
||||||
|
type LeaderboardsInputDTO = {
|
||||||
|
drivers: { drivers: DriverLeaderboardItemDTO[] };
|
||||||
|
teams: GetTeamsLeaderboardOutputDTO;
|
||||||
|
}
|
||||||
|
|
||||||
export class LeaderboardsViewDataBuilder {
|
export class LeaderboardsViewDataBuilder {
|
||||||
static build(
|
public static build(apiDto: LeaderboardsInputDTO): LeaderboardsViewData {
|
||||||
apiDto: { drivers: { drivers: DriverLeaderboardItemDTO[] }; teams: GetTeamsLeaderboardOutputDTO }
|
|
||||||
): LeaderboardsViewData {
|
|
||||||
return {
|
return {
|
||||||
drivers: apiDto.drivers.drivers.map(driver => ({
|
drivers: apiDto.drivers.drivers.map(driver => ({
|
||||||
id: driver.id,
|
id: driver.id,
|
||||||
@@ -37,3 +43,5 @@ export class LeaderboardsViewDataBuilder {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LeaderboardsViewDataBuilder satisfies ViewDataBuilder<LeaderboardsInputDTO, LeaderboardsViewData>;
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { LeagueCoverViewDataBuilder } from './LeagueCoverViewDataBuilder';
|
||||||
|
import type { MediaBinaryDTO } from '@/lib/types/generated/MediaBinaryDTO';
|
||||||
|
|
||||||
|
describe('LeagueCoverViewDataBuilder', () => {
|
||||||
|
describe('happy paths', () => {
|
||||||
|
it('should transform MediaBinaryDTO to LeagueCoverViewData correctly', () => {
|
||||||
|
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
const mediaDto: MediaBinaryDTO = {
|
||||||
|
buffer: buffer.buffer,
|
||||||
|
contentType: 'image/png',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = LeagueCoverViewDataBuilder.build(mediaDto);
|
||||||
|
|
||||||
|
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||||
|
expect(result.contentType).toBe('image/png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle JPEG cover images', () => {
|
||||||
|
const buffer = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]);
|
||||||
|
const mediaDto: MediaBinaryDTO = {
|
||||||
|
buffer: buffer.buffer,
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = LeagueCoverViewDataBuilder.build(mediaDto);
|
||||||
|
|
||||||
|
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||||
|
expect(result.contentType).toBe('image/jpeg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle WebP cover images', () => {
|
||||||
|
const buffer = new Uint8Array([0x52, 0x49, 0x46, 0x46]);
|
||||||
|
const mediaDto: MediaBinaryDTO = {
|
||||||
|
buffer: buffer.buffer,
|
||||||
|
contentType: 'image/webp',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = LeagueCoverViewDataBuilder.build(mediaDto);
|
||||||
|
|
||||||
|
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||||
|
expect(result.contentType).toBe('image/webp');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('data transformation', () => {
|
||||||
|
it('should preserve all DTO fields in the output', () => {
|
||||||
|
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||||
|
const mediaDto: MediaBinaryDTO = {
|
||||||
|
buffer: buffer.buffer,
|
||||||
|
contentType: 'image/png',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = LeagueCoverViewDataBuilder.build(mediaDto);
|
||||||
|
|
||||||
|
expect(result.buffer).toBeDefined();
|
||||||
|
expect(result.contentType).toBe(mediaDto.contentType);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not modify the input DTO', () => {
|
||||||
|
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||||
|
const mediaDto: MediaBinaryDTO = {
|
||||||
|
buffer: buffer.buffer,
|
||||||
|
contentType: 'image/png',
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalDto = { ...mediaDto };
|
||||||
|
LeagueCoverViewDataBuilder.build(mediaDto);
|
||||||
|
|
||||||
|
expect(mediaDto).toEqual(originalDto);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert buffer to base64 string', () => {
|
||||||
|
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
const mediaDto: MediaBinaryDTO = {
|
||||||
|
buffer: buffer.buffer,
|
||||||
|
contentType: 'image/png',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = LeagueCoverViewDataBuilder.build(mediaDto);
|
||||||
|
|
||||||
|
expect(typeof result.buffer).toBe('string');
|
||||||
|
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle empty buffer', () => {
|
||||||
|
const buffer = new Uint8Array([]);
|
||||||
|
const mediaDto: MediaBinaryDTO = {
|
||||||
|
buffer: buffer.buffer,
|
||||||
|
contentType: 'image/png',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = LeagueCoverViewDataBuilder.build(mediaDto);
|
||||||
|
|
||||||
|
expect(result.buffer).toBe('');
|
||||||
|
expect(result.contentType).toBe('image/png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle large cover images', () => {
|
||||||
|
const buffer = new Uint8Array(2 * 1024 * 1024); // 2MB
|
||||||
|
const mediaDto: MediaBinaryDTO = {
|
||||||
|
buffer: buffer.buffer,
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = LeagueCoverViewDataBuilder.build(mediaDto);
|
||||||
|
|
||||||
|
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||||
|
expect(result.contentType).toBe('image/jpeg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle buffer with all zeros', () => {
|
||||||
|
const buffer = new Uint8Array([0x00, 0x00, 0x00, 0x00]);
|
||||||
|
const mediaDto: MediaBinaryDTO = {
|
||||||
|
buffer: buffer.buffer,
|
||||||
|
contentType: 'image/png',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = LeagueCoverViewDataBuilder.build(mediaDto);
|
||||||
|
|
||||||
|
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||||
|
expect(result.contentType).toBe('image/png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle buffer with all ones', () => {
|
||||||
|
const buffer = new Uint8Array([0xff, 0xff, 0xff, 0xff]);
|
||||||
|
const mediaDto: MediaBinaryDTO = {
|
||||||
|
buffer: buffer.buffer,
|
||||||
|
contentType: 'image/png',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = LeagueCoverViewDataBuilder.build(mediaDto);
|
||||||
|
|
||||||
|
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||||
|
expect(result.contentType).toBe('image/png');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user