├── .npmrc ├── .browserslistrc ├── public ├── favicon.ico └── index.html ├── src ├── assets │ └── logo.png ├── shims-vue.d.ts ├── main.ts ├── shims-tsx.d.ts ├── App.vue ├── store │ ├── index.ts │ └── modules │ │ └── horses │ │ ├── utils.ts │ │ ├── models.ts │ │ ├── index.ts │ │ └── __tests__ │ │ ├── horses.spec.ts │ │ └── utils.spec.ts ├── views │ └── HomeView.vue ├── router │ └── index.ts ├── utils │ └── getTestAttributes.ts └── components │ ├── HorseRacingApp.vue │ ├── Header.vue │ └── Race │ └── components │ ├── AvailableHorsesArea.vue │ ├── RaceResults.vue │ ├── RacingHorse.vue │ └── RaceArea.vue ├── jest.config.js ├── cypress ├── support │ ├── component.js │ ├── e2e.js │ ├── commands.js │ └── index.d.ts ├── fixtures │ └── horses.json ├── component │ ├── AvailableHorsesArea.cy.js │ └── Header.cy.js ├── e2e │ └── horse-racing.cy.js └── README.md ├── scripts └── test-e2e.sh ├── .eslintrc.js ├── cypress.config.js ├── tsconfig.json ├── .gitignore ├── package.json └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profile/horse-racing-game/main/public/favicon.ico -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profile/horse-racing-game/main/src/assets/logo.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "@vue/cli-plugin-unit-jest/presets/typescript", 3 | }; 4 | -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.vue" { 2 | import Vue from "vue"; 3 | export default Vue; 4 | } 5 | -------------------------------------------------------------------------------- /cypress/support/component.js: -------------------------------------------------------------------------------- 1 | 2 | import './commands' 3 | 4 | 5 | Cypress.on('uncaught:exception', (err, runnable) => { 6 | return false 7 | }) 8 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import App from "./App.vue"; 3 | import router from "./router"; 4 | import store from "./store"; 5 | 6 | Vue.config.productionTip = false; 7 | 8 | new Vue({ 9 | router, 10 | store, 11 | render: (h) => h(App), 12 | }).$mount("#app"); 13 | -------------------------------------------------------------------------------- /src/shims-tsx.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VNode } from "vue"; 2 | 3 | declare global { 4 | namespace JSX { 5 | interface Element extends VNode {} 6 | interface ElementClass extends Vue {} 7 | interface IntrinsicElements { 8 | [elem: string]: any; 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 18 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import Vuex from "vuex"; 3 | import { moduleHorses } from "./modules/horses"; 4 | import { MODULE_NAME as horses } from "./modules/horses/models"; 5 | 6 | Vue.use(Vuex); 7 | 8 | export default new Vuex.Store({ 9 | state: {}, 10 | getters: {}, 11 | mutations: {}, 12 | actions: {}, 13 | modules: { 14 | [horses]: moduleHorses, 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /src/views/HomeView.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 18 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import VueRouter, { RouteConfig } from "vue-router"; 3 | 4 | import HomeView from "../views/HomeView.vue"; 5 | 6 | Vue.use(VueRouter); 7 | 8 | const routes: Array = [ 9 | { 10 | path: "/", 11 | name: "home", 12 | component: HomeView, 13 | }, 14 | ]; 15 | 16 | const router = new VueRouter({ 17 | routes, 18 | }); 19 | 20 | export default router; 21 | -------------------------------------------------------------------------------- /src/utils/getTestAttributes.ts: -------------------------------------------------------------------------------- 1 | export const getTestAttributes = ({ 2 | module, 3 | description, 4 | element, 5 | id, 6 | }: { 7 | module: string; 8 | description: string; 9 | element?: string; 10 | id?: string; 11 | }): 12 | | { 13 | ["data-test-id"]: string; 14 | } 15 | | undefined => { 16 | const parts = [module, description, element, id].filter(Boolean); 17 | return { "data-test-id": parts.join("-") }; 18 | }; 19 | -------------------------------------------------------------------------------- /scripts/test-e2e.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Start the development server in the background 4 | echo "Starting development server..." 5 | npm run serve & 6 | DEV_SERVER_PID=$! 7 | 8 | # Wait for the server to be ready 9 | echo "Waiting for server to be ready..." 10 | sleep 10 11 | 12 | # Run E2E tests 13 | echo "Running E2E tests..." 14 | npm run test:e2e 15 | 16 | # Store the exit code 17 | TEST_EXIT_CODE=$? 18 | 19 | # Stop the development server 20 | echo "Stopping development server..." 21 | kill $DEV_SERVER_PID 22 | 23 | # Exit with the test exit code 24 | exit $TEST_EXIT_CODE 25 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 12 | We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue. 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | extends: [ 7 | "plugin:vue/essential", 8 | "eslint:recommended", 9 | "@vue/typescript/recommended", 10 | "plugin:prettier/recommended", 11 | ], 12 | parserOptions: { 13 | ecmaVersion: 2020, 14 | }, 15 | rules: { 16 | "no-console": process.env.NODE_ENV === "production" ? "warn" : "off", 17 | "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off", 18 | }, 19 | overrides: [ 20 | { 21 | files: [ 22 | "**/__tests__/*.{j,t}s?(x)", 23 | "**/tests/unit/**/*.spec.{j,t}s?(x)", 24 | ], 25 | env: { 26 | jest: true, 27 | }, 28 | }, 29 | ], 30 | }; 31 | -------------------------------------------------------------------------------- /cypress.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require("cypress"); 2 | 3 | module.exports = defineConfig({ 4 | e2e: { 5 | baseUrl: "http://localhost:8080", 6 | supportFile: "cypress/support/e2e.js", 7 | specPattern: "cypress/e2e/**/*.cy.{js,jsx,ts,tsx}", 8 | viewportWidth: 1280, 9 | viewportHeight: 720, 10 | video: false, 11 | screenshotOnRunFailure: true, 12 | defaultCommandTimeout: 10000, 13 | requestTimeout: 10000, 14 | responseTimeout: 10000, 15 | }, 16 | component: { 17 | devServer: { 18 | framework: "vue-cli", 19 | bundler: "webpack", 20 | }, 21 | supportFile: "cypress/support/component.js", 22 | specPattern: "cypress/component/**/*.cy.{js,jsx,ts,tsx}", 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | 2 | import './commands' 3 | 4 | Cypress.on('uncaught:exception', () => { 5 | return false 6 | }) 7 | 8 | Cypress.Commands.add('waitForVue', () => { 9 | cy.get('[data-test-id]', { timeout: 10000 }).should('be.visible') 10 | }) 11 | 12 | 13 | Cypress.Commands.add('getByTestId', (testId) => { 14 | return cy.get(`[data-test-id="${testId}"]`) 15 | }) 16 | 17 | Cypress.Commands.add('waitForRaceComplete', () => { 18 | cy.get('[data-test-id="horse-racing-race-area-start-next-round-btn"], [data-test-id="horse-racing-race-results-div"]', { timeout: 15000 }).should('be.visible') 19 | }) 20 | 21 | Cypress.Commands.add('waitForAllRoundsComplete', () => { 22 | cy.getByTestId('horse-racing-race-results-div').should('be.visible') 23 | cy.contains('Round 6 of 6').should('be.visible') 24 | }) 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "experimentalDecorators": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "useDefineForClassFields": true, 15 | "sourceMap": true, 16 | "baseUrl": ".", 17 | "types": [ 18 | "webpack-env", 19 | "jest" 20 | ], 21 | "paths": { 22 | "@/*": [ 23 | "src/*" 24 | ] 25 | }, 26 | "lib": [ 27 | "esnext", 28 | "dom", 29 | "dom.iterable", 30 | "scripthost" 31 | ] 32 | }, 33 | "include": [ 34 | "src/**/*.ts", 35 | "src/**/*.tsx", 36 | "src/**/*.vue", 37 | "tests/**/*.ts", 38 | "tests/**/*.tsx" 39 | ], 40 | "exclude": [ 41 | "node_modules" 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /cypress/fixtures/horses.json: -------------------------------------------------------------------------------- 1 | { 2 | "horses": [ 3 | { 4 | "id": 1, 5 | "name": "Thunder Bolt", 6 | "speed": 85, 7 | "endurance": 90, 8 | "color": "#FF0000" 9 | }, 10 | { 11 | "id": 2, 12 | "name": "Silver Arrow", 13 | "speed": 92, 14 | "endurance": 78, 15 | "color": "#0000FF" 16 | }, 17 | { 18 | "id": 3, 19 | "name": "Golden Star", 20 | "speed": 88, 21 | "endurance": 85, 22 | "color": "#FFD700" 23 | }, 24 | { 25 | "id": 4, 26 | "name": "Midnight Runner", 27 | "speed": 95, 28 | "endurance": 70, 29 | "color": "#000080" 30 | }, 31 | { 32 | "id": 5, 33 | "name": "Fire Storm", 34 | "speed": 87, 35 | "endurance": 88, 36 | "color": "#FF4500" 37 | } 38 | ], 39 | "raceSchedule": [ 40 | { 41 | "id": 1, 42 | "horses": [1, 2, 3], 43 | "distance": 1000, 44 | "status": "pending" 45 | }, 46 | { 47 | "id": 2, 48 | "horses": [4, 5, 1], 49 | "distance": 1200, 50 | "status": "pending" 51 | } 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | Cypress.Commands.add('generateHorses', () => { 2 | cy.getByTestId('horse-racing-generate-horses-btn').click() 3 | cy.getByTestId('horse-racing-available-horses-area-div').should('be.visible') 4 | }) 5 | 6 | Cypress.Commands.add('generateRaceSchedule', () => { 7 | cy.getByTestId('horse-racing-generate-schedule-btn').click() 8 | cy.getByTestId('horse-racing-race-area-div').should('be.visible') 9 | }) 10 | 11 | Cypress.Commands.add('startRace', () => { 12 | cy.getByTestId('horse-racing-race-area-start-race-btn').click() 13 | cy.getByTestId('horse-racing-race-area-start-race-btn').should('be.disabled') 14 | }) 15 | 16 | Cypress.Commands.add('waitForHorsesGenerated', () => { 17 | cy.getByTestId('horse-racing-available-horses-area-div').should('be.visible') 18 | cy.get('[data-test-id*="horse-racing-all-horses-horse-"]').should('have.length.greaterThan', 0) 19 | }) 20 | 21 | Cypress.Commands.add('waitForRaceScheduleGenerated', () => { 22 | cy.getByTestId('horse-racing-race-area-div').should('be.visible') 23 | cy.get('[data-test-id*="horse-racing-participants-horse-area-item-"]').should('have.length.greaterThan', 0) 24 | }) 25 | -------------------------------------------------------------------------------- /src/store/modules/horses/utils.ts: -------------------------------------------------------------------------------- 1 | import { Horse, RaceParticipant, RaceSchedule } from "./models"; 2 | 3 | export function generateRandomHorses(count: number): Horse[] { 4 | return Array.from({ length: count }, (_, i) => { 5 | const hue = (i * (360 / count)) % 360; 6 | const saturation = 60 + (i % 3) * 15; 7 | const lightness = 40 + (i % 4) * 10; 8 | 9 | return { 10 | id: i + 1, 11 | name: `Horse ${i + 1}`, 12 | color: `hsl(${hue}, ${saturation}%, ${lightness}%)`, 13 | condition: Math.floor(Math.random() * 100) + 1, 14 | position: 0, 15 | finishedRank: 0, 16 | finishTime: 0, 17 | }; 18 | }); 19 | } 20 | 21 | export function pickRandomHorses( 22 | allHorses: Horse[], 23 | count: number 24 | ): RaceParticipant[] { 25 | const shuffled = allHorses.slice().sort(() => Math.random() - 0.5); 26 | // .map((item) => ({ 27 | // ...item, 28 | // position: 0, 29 | // finishedRank: 0, 30 | // finishTime: 0, 31 | // })); 32 | return shuffled.slice(0, count); 33 | } 34 | 35 | export function generateSchedule(horses: Horse[]): RaceSchedule[] { 36 | const distances = [1200, 1400, 1600, 1800, 2000, 2200]; 37 | return distances.map((distance, roundIndex) => ({ 38 | round: roundIndex + 1, 39 | distance, 40 | participants: pickRandomHorses(horses, 10), 41 | })); 42 | } 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | 25 | # Testing 26 | /coverage 27 | .nyc_output 28 | cypress/videos/ 29 | cypress/screenshots/ 30 | cypress/downloads/ 31 | 32 | # Build outputs 33 | /build 34 | *.tgz 35 | *.tar.gz 36 | 37 | # Environment files 38 | .env 39 | .env.production 40 | .env.staging 41 | 42 | # Logs 43 | logs 44 | *.log 45 | 46 | # Runtime data 47 | pids 48 | *.pid 49 | *.seed 50 | *.pid.lock 51 | 52 | # Dependency directories 53 | node_modules/ 54 | jspm_packages/ 55 | 56 | # Optional npm cache directory 57 | .npm 58 | 59 | # Optional eslint cache 60 | .eslintcache 61 | 62 | # Microbundle cache 63 | .rpt2_cache/ 64 | .rts2_cache_cjs/ 65 | .rts2_cache_es/ 66 | .rts2_cache_umd/ 67 | 68 | # Optional REPL history 69 | .node_repl_history 70 | 71 | # Output of 'npm pack' 72 | *.tgz 73 | 74 | # Yarn Integrity file 75 | .yarn-integrity 76 | 77 | # parcel-bundler cache (https://parceljs.org/) 78 | .cache 79 | .parcel-cache 80 | 81 | # Next.js build output 82 | .next 83 | 84 | # Nuxt.js build / generate output 85 | .nuxt 86 | dist 87 | 88 | # Storybook build outputs 89 | .out 90 | .storybook-out 91 | 92 | # Temporary folders 93 | tmp/ 94 | temp/ 95 | -------------------------------------------------------------------------------- /cypress/support/index.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare namespace Cypress { 4 | interface Chainable { 5 | /** 6 | * Custom command to wait for Vue to be ready 7 | * @example cy.waitForVue() 8 | */ 9 | waitForVue(): Chainable 10 | 11 | /** 12 | * Custom command to get element by test ID 13 | * @example cy.getByTestId('horse-racing-generate-horses-btn') 14 | */ 15 | getByTestId(testId: string): Chainable 16 | 17 | /** 18 | * Custom command to wait for race animation to complete 19 | * @example cy.waitForRaceComplete() 20 | */ 21 | waitForRaceComplete(): Chainable 22 | 23 | /** 24 | * Custom command to wait for all rounds to complete (final round) 25 | * @example cy.waitForAllRoundsComplete() 26 | */ 27 | waitForAllRoundsComplete(): Chainable 28 | 29 | /** 30 | * Custom command to generate horses 31 | * @example cy.generateHorses() 32 | */ 33 | generateHorses(): Chainable 34 | 35 | /** 36 | * Custom command to generate race schedule 37 | * @example cy.generateRaceSchedule() 38 | */ 39 | generateRaceSchedule(): Chainable 40 | 41 | /** 42 | * Custom command to start a race 43 | * @example cy.startRace() 44 | */ 45 | startRace(): Chainable 46 | 47 | /** 48 | * Custom command to wait for horses to be generated 49 | * @example cy.waitForHorsesGenerated() 50 | */ 51 | waitForHorsesGenerated(): Chainable 52 | 53 | /** 54 | * Custom command to wait for race schedule to be generated 55 | * @example cy.waitForRaceScheduleGenerated() 56 | */ 57 | waitForRaceScheduleGenerated(): Chainable 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Horse-Racing", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "test:unit": "vue-cli-service test:unit", 9 | "test:unit:coverage": "vue-cli-service test:unit --coverage", 10 | "test:e2e": "cypress run", 11 | "test:e2e:open": "cypress open", 12 | "test:e2e:headed": "cypress run --headed", 13 | "test:component": "cypress run --component", 14 | "test:component:open": "cypress open --component", 15 | "lint": "vue-cli-service lint" 16 | }, 17 | "dependencies": { 18 | "tslib": "^2.4.0", 19 | "vue": "^2.6.14", 20 | "vue-class-component": "^7.2.3", 21 | "vue-property-decorator": "^9.1.2", 22 | "vue-router": "^3.5.1", 23 | "vuex": "^3.6.2" 24 | }, 25 | "devDependencies": { 26 | "@types/jest": "^27.0.1", 27 | "@typescript-eslint/eslint-plugin": "^5.4.0", 28 | "@typescript-eslint/parser": "^5.4.0", 29 | "@vue/cli-plugin-eslint": "~5.0.0", 30 | "@vue/cli-plugin-router": "~5.0.0", 31 | "@vue/cli-plugin-typescript": "~5.0.0", 32 | "@vue/cli-plugin-unit-jest": "~5.0.0", 33 | "@vue/cli-plugin-vuex": "~5.0.0", 34 | "@vue/cli-service": "~5.0.0", 35 | "@vue/eslint-config-typescript": "^9.1.0", 36 | "@vue/test-utils": "^1.1.3", 37 | "@vue/vue2-jest": "^27.0.0-alpha.2", 38 | "babel-jest": "^27.0.6", 39 | "cypress": "^12.17.4", 40 | "eslint": "^7.32.0", 41 | "eslint-config-prettier": "^8.3.0", 42 | "eslint-plugin-prettier": "^4.0.0", 43 | "eslint-plugin-vue": "^8.0.3", 44 | "jest": "^27.0.5", 45 | "prettier": "^2.4.1", 46 | "sass": "^1.32.7", 47 | "sass-loader": "^12.0.0", 48 | "ts-jest": "^27.0.4", 49 | "typescript": "~4.5.5", 50 | "vue-template-compiler": "^2.6.14" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /cypress/component/AvailableHorsesArea.cy.js: -------------------------------------------------------------------------------- 1 | import AvailableHorsesArea from '../../src/components/Race/components/AvailableHorsesArea.vue' 2 | 3 | describe('AvailableHorsesArea Component', () => { 4 | const mockHorses = [ 5 | { 6 | id: 1, 7 | name: 'Thunder Bolt', 8 | speed: 85, 9 | endurance: 90, 10 | color: '#FF0000' 11 | }, 12 | { 13 | id: 2, 14 | name: 'Silver Arrow', 15 | speed: 92, 16 | endurance: 78, 17 | color: '#0000FF' 18 | } 19 | ] 20 | 21 | beforeEach(() => { 22 | cy.mount(AvailableHorsesArea, { 23 | props: { 24 | horses: mockHorses 25 | } 26 | }) 27 | }) 28 | 29 | it('should display all horses', () => { 30 | cy.get('[data-test-id*="horse-racing-all-horses-horse-"]').should('have.length', 2) 31 | }) 32 | 33 | it('should display horse information correctly', () => { 34 | cy.get('[data-test-id*="horse-racing-all-horses-horse-"]').first().within(() => { 35 | cy.get('[data-test-id*="horse-racing-all-horses-horse-name-"]').should('contain.text', 'Thunder Bolt') 36 | cy.get('[data-test-id*="horse-racing-all-horses-horse-condition"]').should('contain.text', 'Condition:') 37 | }) 38 | }) 39 | 40 | it('should display empty state when no horses', () => { 41 | cy.mount(AvailableHorsesArea, { 42 | props: { 43 | horses: [] 44 | } 45 | }) 46 | 47 | cy.get('[data-test-id*="horse-racing-all-horses-horse-"]').should('have.length', 0) 48 | cy.getByTestId('horse-racing-available-horses-area-div').should('be.visible') 49 | }) 50 | 51 | it('should display horses with different colors', () => { 52 | cy.get('[data-test-id*="horse-racing-all-horses-horse-"]').first().should('have.css', 'border-color', 'rgb(255, 0, 0)') 53 | cy.get('[data-test-id*="horse-racing-all-horses-horse-"]').last().should('have.css', 'border-color', 'rgb(0, 0, 255)') 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /src/components/HorseRacingApp.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 11 | 12 | 17 | 18 | 19 | 20 | 21 | 75 | 86 | -------------------------------------------------------------------------------- /src/store/modules/horses/models.ts: -------------------------------------------------------------------------------- 1 | export const MODULE_NAME = "horses"; 2 | 3 | export enum RaceAction { 4 | STOP = "STOP", 5 | START = "START", 6 | } 7 | 8 | export const HORSE_MUTATIONS = { 9 | SET_HORSES: "setHorses", 10 | SET_SCHEDULE: "setSchedule", 11 | SET_CURRENT_ROUND: "setCurrentRound", 12 | SET_RACE_FINISHED: "setRaceFinished", 13 | SET_ROUND_RESULTS: "setRoundResults", 14 | } as const; 15 | 16 | export const HORSE_ACTIONS = { 17 | GENERATE_HORSES: "generateHorses", 18 | CREATE_SCHEDULE: "createSchedule", 19 | RESET_RACE_VALUES: "resetRaceValues", 20 | } as const; 21 | 22 | export const HORSE_GETTERS = { 23 | ALL_HORSES: "allHorses", 24 | RACE_SCHEDULE_LIST: "raceScheduleList", 25 | RACE_IN_PROGRESS: "raceInProgress", 26 | CURRENT_ROUND: "currentRound", 27 | RACE_FINISHED: "raceFinished", 28 | ROUND_RESULTS: "roundResults", 29 | } as const; 30 | 31 | export type Horse = { 32 | id: number; 33 | name: string; 34 | color: string; 35 | condition: number; 36 | position: number; 37 | finishedRank: number; 38 | finishTime: number; 39 | }; 40 | 41 | export type RaceParticipant = Horse; 42 | 43 | export type RoundResult = { 44 | round: number; 45 | distance: number; 46 | participants: RaceParticipant[]; 47 | finishedHorses: RaceParticipant[]; 48 | }; 49 | 50 | export type RaceSchedule = { 51 | round: number; 52 | distance: number; 53 | participants: RaceParticipant[]; 54 | }; 55 | 56 | export type HorsesState = { 57 | roundCounts: number; 58 | horses: Horse[]; 59 | schedule: RaceSchedule[]; 60 | currentRound: number; 61 | raceFinished: boolean; 62 | roundResults: RoundResult[]; 63 | }; 64 | 65 | export type HorsesGetters = { 66 | allHorses: Horse[]; 67 | raceScheduleList: RaceSchedule[]; 68 | raceInProgress: boolean; 69 | currentRound: number; 70 | raceFinished: boolean; 71 | roundResults: RoundResult[]; 72 | }; 73 | 74 | export type HorsesMutations = { 75 | setHorses: (horses: Horse[]) => void; 76 | setSchedule: (schedule: RaceSchedule[]) => void; 77 | setCurrentRound: (round: number) => void; 78 | setRaceFinished: (finished: boolean) => void; 79 | setRoundResults: (results: RoundResult[]) => void; 80 | }; 81 | 82 | export type HorsesActions = { 83 | generateHorses: () => Promise; 84 | createSchedule: () => Promise; 85 | resetRaceValues: () => Promise; 86 | toggleRace: () => Promise; 87 | }; 88 | -------------------------------------------------------------------------------- /src/components/Header.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | Horse Racing 4 | 5 | 17 | Generate Horse List 18 | 19 | 32 | Generate Race Schedule 33 | 34 | 35 | 36 | 37 | 38 | 70 | 71 | 109 | -------------------------------------------------------------------------------- /cypress/component/Header.cy.js: -------------------------------------------------------------------------------- 1 | import Header from '../../src/components/Header.vue' 2 | 3 | describe('Header Component', () => { 4 | beforeEach(() => { 5 | cy.mount(Header, { 6 | props: { 7 | disableGenerateHorsesBtn: false, 8 | disableGenerateScheduleBtn: true, 9 | showGenerateScheduleBtn: false, 10 | onGenerateHorsesClick: cy.stub().as('generateHorsesClick'), 11 | onGenerateScheduleClick: cy.stub().as('generateScheduleClick'), 12 | }, 13 | }) 14 | }) 15 | 16 | it('should render generate horses button', () => { 17 | cy.getByTestId('horse-racing-generate-horses-btn').should('be.visible') 18 | cy.getByTestId('horse-racing-generate-horses-btn').should('contain.text', 'Generate Horses') 19 | }) 20 | 21 | it('should call onGenerateHorsesClick when generate horses button is clicked', () => { 22 | cy.getByTestId('horse-racing-generate-horses-btn').click() 23 | cy.get('@generateHorsesClick').should('have.been.called') 24 | }) 25 | 26 | it('should not show generate schedule button when showGenerateScheduleBtn is false', () => { 27 | cy.getByTestId('horse-racing-generate-schedule-btn').should('not.exist') 28 | }) 29 | 30 | it('should show generate schedule button when showGenerateScheduleBtn is true', () => { 31 | cy.mount(Header, { 32 | props: { 33 | disableGenerateHorsesBtn: false, 34 | disableGenerateScheduleBtn: false, 35 | showGenerateScheduleBtn: true, 36 | onGenerateHorsesClick: cy.stub().as('generateHorsesClick'), 37 | onGenerateScheduleClick: cy.stub().as('generateScheduleClick'), 38 | }, 39 | }) 40 | 41 | cy.getByTestId('horse-racing-generate-schedule-btn').should('be.visible') 42 | cy.getByTestId('horse-racing-generate-schedule-btn').should('contain.text', 'Generate Schedule') 43 | }) 44 | 45 | it('should disable generate horses button when disableGenerateHorsesBtn is true', () => { 46 | cy.mount(Header, { 47 | props: { 48 | disableGenerateHorsesBtn: true, 49 | disableGenerateScheduleBtn: true, 50 | showGenerateScheduleBtn: false, 51 | onGenerateHorsesClick: cy.stub().as('generateHorsesClick'), 52 | onGenerateScheduleClick: cy.stub().as('generateScheduleClick'), 53 | }, 54 | }) 55 | 56 | cy.getByTestId('horse-racing-generate-horses-btn').should('be.disabled') 57 | }) 58 | 59 | it('should disable generate schedule button when disableGenerateScheduleBtn is true', () => { 60 | cy.mount(Header, { 61 | props: { 62 | disableGenerateHorsesBtn: false, 63 | disableGenerateScheduleBtn: true, 64 | showGenerateScheduleBtn: true, 65 | onGenerateHorsesClick: cy.stub().as('generateHorsesClick'), 66 | onGenerateScheduleClick: cy.stub().as('generateScheduleClick'), 67 | }, 68 | }) 69 | 70 | cy.getByTestId('horse-racing-generate-schedule-btn').should('be.disabled') 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /src/components/Race/components/AvailableHorsesArea.vue: -------------------------------------------------------------------------------- 1 | 2 | 12 | 24 | 25 | 35 | {{ horse.name }} 36 | 37 | 47 | Condition: {{ horse.condition }} 48 | 49 | 50 | 51 | 52 | 53 | 54 | 71 | 72 | 119 | -------------------------------------------------------------------------------- /src/store/modules/horses/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Horse, 3 | RaceSchedule, 4 | HorsesState, 5 | HORSE_GETTERS, 6 | RoundResult, 7 | HORSE_MUTATIONS, 8 | HORSE_ACTIONS, 9 | } from "./models"; 10 | 11 | import { generateRandomHorses, generateSchedule } from "./utils"; 12 | 13 | import { ActionContext } from "vuex"; 14 | 15 | export const moduleHorses = { 16 | namespaced: true, 17 | state: (): HorsesState => ({ 18 | roundCounts: 6, 19 | horses: [], 20 | schedule: [], 21 | currentRound: 0, 22 | raceFinished: false, 23 | roundResults: [], 24 | }), 25 | getters: { 26 | [HORSE_GETTERS.ALL_HORSES]: (state: HorsesState): Horse[] => state.horses, 27 | [HORSE_GETTERS.RACE_SCHEDULE_LIST]: (state: HorsesState): RaceSchedule[] => 28 | state.schedule, 29 | [HORSE_GETTERS.RACE_IN_PROGRESS]: (state: HorsesState): boolean => false, 30 | [HORSE_GETTERS.CURRENT_ROUND]: (state: HorsesState): number => 31 | state.currentRound, 32 | [HORSE_GETTERS.RACE_FINISHED]: (state: HorsesState): boolean => 33 | state.raceFinished, 34 | [HORSE_GETTERS.ROUND_RESULTS]: (state: HorsesState): RoundResult[] => 35 | state.roundResults, 36 | }, 37 | mutations: { 38 | [HORSE_MUTATIONS.SET_HORSES](state: HorsesState, horses: Horse[]): void { 39 | state.horses = horses; 40 | }, 41 | [HORSE_MUTATIONS.SET_SCHEDULE]( 42 | state: HorsesState, 43 | schedule: RaceSchedule[] 44 | ): void { 45 | state.schedule = schedule; 46 | }, 47 | [HORSE_MUTATIONS.SET_CURRENT_ROUND]( 48 | state: HorsesState, 49 | round: number 50 | ): void { 51 | state.currentRound = round; 52 | }, 53 | [HORSE_MUTATIONS.SET_RACE_FINISHED]( 54 | state: HorsesState, 55 | finished: boolean 56 | ): void { 57 | state.raceFinished = finished; 58 | }, 59 | [HORSE_MUTATIONS.SET_ROUND_RESULTS]( 60 | state: HorsesState, 61 | results: RoundResult[] 62 | ): void { 63 | state.roundResults = results; 64 | }, 65 | }, 66 | actions: { 67 | async [HORSE_ACTIONS.GENERATE_HORSES]({ 68 | commit, 69 | }: ActionContext): Promise { 70 | const horses = generateRandomHorses(20); 71 | commit(HORSE_MUTATIONS.SET_HORSES, horses); 72 | commit(HORSE_MUTATIONS.SET_SCHEDULE, []); 73 | }, 74 | async [HORSE_ACTIONS.CREATE_SCHEDULE]({ 75 | commit, 76 | state, 77 | }: ActionContext): Promise { 78 | const schedule = generateSchedule(state.horses); 79 | commit(HORSE_MUTATIONS.SET_SCHEDULE, schedule); 80 | 81 | commit(HORSE_MUTATIONS.SET_CURRENT_ROUND, 0); 82 | commit(HORSE_MUTATIONS.SET_RACE_FINISHED, false); 83 | commit(HORSE_MUTATIONS.SET_ROUND_RESULTS, []); 84 | }, 85 | 86 | async [HORSE_ACTIONS.RESET_RACE_VALUES]({ 87 | commit, 88 | }: ActionContext): Promise { 89 | commit(HORSE_MUTATIONS.SET_CURRENT_ROUND, 0); 90 | commit(HORSE_MUTATIONS.SET_RACE_FINISHED, false); 91 | commit(HORSE_MUTATIONS.SET_ROUND_RESULTS, []); 92 | }, 93 | }, 94 | }; 95 | -------------------------------------------------------------------------------- /src/components/Race/components/RaceResults.vue: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | All Rounds Summary 14 | 15 | 28 | 29 | Round {{ round.round }} 30 | {{ round.distance }}m 31 | 32 | 33 | 38 | {{ horseIndex + 1 }}. {{ horse.name }} 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 67 | 68 | 141 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Horse Racing Game - Vue 2 + TypeScript 2 | 3 | A Vue 2 horse racing game built with TypeScript, featuring horse generation, race scheduling, and animated races. 4 | 5 | ## Project setup 6 | ```bash 7 | npm ci --legacy-peer-deps 8 | ``` 9 | 10 | ### Compiles and hot-reloads for development 11 | ```bash 12 | npm run serve 13 | ``` 14 | 15 | ### Compiles and minifies for production 16 | ```bash 17 | npm run build 18 | ``` 19 | 20 | ### Run your unit tests 21 | ```bash 22 | npm run test:unit 23 | ``` 24 | 25 | ### Run unit tests with coverage 26 | ```bash 27 | npm run test:unit:coverage 28 | ``` 29 | 30 | ### Run E2E tests 31 | ```bash 32 | # Start dev server first, then in another terminal: 33 | npm run test:e2e 34 | 35 | # Or use the convenience script: 36 | ./scripts/test-e2e.sh 37 | 38 | # Interactive mode: 39 | npm run test:e2e:open 40 | ``` 41 | 42 | ### Lint and fix files 43 | ```bash 44 | npm run lint 45 | ``` 46 | 47 | ### Customize configuration 48 | See [Configuration Reference](https://cli.vuejs.org/config/). 49 | 50 | ## Testing 51 | 52 | This project includes comprehensive testing: 53 | 54 | - **Unit Tests**: Jest-based tests for Vuex store and utilities 55 | - **E2E Tests**: Cypress tests for complete user workflows 56 | - **Component Tests**: Cypress component tests for individual Vue components 57 | 58 | See [cypress/README.md](cypress/README.md) for detailed E2E testing documentation. 59 | 60 | ## Unit Testing 61 | 62 | ### Test Structure 63 | ``` 64 | src/store/modules/horses/__tests__/ 65 | ├── horses.spec.ts # Vuex store module tests 66 | └── utils.spec.ts # Utility function tests 67 | ``` 68 | 69 | ### Unit Test Coverage 70 | Run tests with coverage to see how well your code is tested: 71 | 72 | ```bash 73 | # Run all unit tests 74 | npm run test:unit 75 | # Run all unit tests with coverage 76 | npm run test:unit:coverage 77 | ``` 78 | 79 | This will generate a coverage report showing: 80 | - **Statements**: Percentage of code statements executed 81 | - **Branches**: Percentage of conditional branches tested 82 | - **Functions**: Percentage of functions called 83 | - **Lines**: Percentage of lines executed 84 | 85 | ### Writing Unit Tests 86 | - **Store Tests**: Test Vuex mutations, actions, and getters 87 | - **Utility Tests**: Test pure functions with various inputs 88 | - **Component Tests**: Test individual Vue components in isolation 89 | 90 | ### ESLint Configuration 91 | This project uses ESLint with TypeScript and Vue.js rules for consistent code quality. 92 | 93 | ### Linting Rules 94 | - **TypeScript**: Strict type checking and best practices 95 | - **Vue.js**: Vue 2 specific rules and component standards 96 | - **Prettier**: Code formatting consistency 97 | - **ES6+**: Modern JavaScript features and patterns 98 | 99 | ### Pre-commit Hooks 100 | Consider setting up pre-commit hooks to automatically run: 101 | - `npm run lint` - Check code quality 102 | - `npm run test:unit` - Ensure tests pass 103 | - `npm run build` - Verify build works 104 | 105 | ## Development Workflow 106 | 107 | ### Recommended Development Process 108 | 1. **Write Code** → Implement features in components/store 109 | 2. **Write Tests** → Add unit tests for new functionality 110 | 3. **Check Quality** → Run `npm run lint` to ensure code standards 111 | 4. **Verify Tests** → Run `npm run test:unit` to ensure tests pass 112 | 5. **Check Coverage** → Run `npm run test:unit:coverage` to maintain good coverage 113 | 6. **Commit** → Only commit when all checks pass 114 | 115 | ### Quality Gates 116 | - **Linting**: All files must pass ESLint rules 117 | - **Unit Tests**: All tests must pass with >80% coverage 118 | - **Build**: Project must build successfully 119 | - **E2E Tests**: Critical user flows must pass -------------------------------------------------------------------------------- /cypress/e2e/horse-racing.cy.js: -------------------------------------------------------------------------------- 1 | describe('Horse Racing Game E2E Tests', () => { 2 | beforeEach(() => { 3 | cy.visit('/') 4 | cy.waitForVue() 5 | }) 6 | 7 | it('should display the main game interface', () => { 8 | cy.getByTestId('horse-racing-generate-horses-btn').should('be.visible') 9 | cy.getByTestId('horse-racing-generate-schedule-btn').should('not.exist') 10 | cy.getByTestId('horse-racing-available-horses-area-div').should('be.visible') 11 | cy.getByTestId('horse-racing-race-area-div').should('not.exist') 12 | }) 13 | 14 | it('should generate horses when generate horses button is clicked', () => { 15 | cy.get('[data-test-id*="horse-racing-all-horses-horse-"]').should('have.length', 0) 16 | 17 | cy.generateHorses() 18 | 19 | cy.waitForHorsesGenerated() 20 | 21 | cy.getByTestId('horse-racing-generate-schedule-btn').should('not.be.disabled') 22 | }) 23 | 24 | it('should generate race schedule when generate schedule button is clicked', () => { 25 | cy.generateHorses() 26 | cy.waitForHorsesGenerated() 27 | 28 | cy.generateRaceSchedule() 29 | 30 | cy.waitForRaceScheduleGenerated() 31 | }) 32 | 33 | it('should start and complete a race', () => { 34 | cy.generateHorses() 35 | cy.waitForHorsesGenerated() 36 | cy.generateRaceSchedule() 37 | cy.waitForRaceScheduleGenerated() 38 | 39 | cy.startRace() 40 | 41 | cy.waitForRaceComplete() 42 | 43 | cy.getByTestId('horse-racing-race-results-div').should('be.visible') 44 | }) 45 | 46 | it('should disable buttons during race animation', () => { 47 | cy.generateHorses() 48 | cy.waitForHorsesGenerated() 49 | cy.generateRaceSchedule() 50 | cy.waitForRaceScheduleGenerated() 51 | 52 | cy.startRace() 53 | 54 | cy.getByTestId('horse-racing-generate-horses-btn').should('be.disabled') 55 | cy.getByTestId('horse-racing-generate-schedule-btn').should('be.disabled') 56 | 57 | cy.waitForRaceComplete() 58 | 59 | cy.getByTestId('horse-racing-generate-horses-btn').should('not.be.disabled') 60 | cy.getByTestId('horse-racing-generate-schedule-btn').should('not.be.disabled') 61 | }) 62 | 63 | it('should display horse information correctly', () => { 64 | cy.generateHorses() 65 | cy.waitForHorsesGenerated() 66 | 67 | cy.get('[data-test-id*="horse-racing-all-horses-horse-"]').first().within(() => { 68 | cy.get('[data-test-id*="horse-racing-all-horses-horse-name-"]').should('be.visible') 69 | cy.get('[data-test-id*="horse-racing-all-horses-horse-condition-"]').should('be.visible') 70 | }) 71 | }) 72 | 73 | it('should handle race reset correctly', () => { 74 | cy.generateHorses() 75 | cy.waitForHorsesGenerated() 76 | cy.generateRaceSchedule() 77 | cy.waitForRaceScheduleGenerated() 78 | cy.startRace() 79 | cy.waitForRaceComplete() 80 | 81 | cy.generateRaceSchedule() 82 | cy.waitForRaceScheduleGenerated() 83 | 84 | cy.getByTestId('horse-racing-race-area-div').should('be.visible') 85 | cy.get('[data-test-id*="horse-racing-participants-horse-area-item-"]').should('have.length.greaterThan', 0) 86 | }) 87 | 88 | it('should complete all 6 rounds and show final results', () => { 89 | cy.generateHorses() 90 | cy.waitForHorsesGenerated() 91 | cy.generateRaceSchedule() 92 | cy.waitForRaceScheduleGenerated() 93 | 94 | for (let round = 1; round <= 6; round++) { 95 | if (round === 1) { 96 | cy.startRace() 97 | } else { 98 | cy.getByTestId('horse-racing-race-area-start-next-round-btn').click() 99 | } 100 | 101 | if (round < 6) { 102 | cy.waitForRaceComplete() 103 | } else { 104 | cy.waitForAllRoundsComplete() 105 | } 106 | } 107 | 108 | cy.getByTestId('horse-racing-race-results-div').should('be.visible') 109 | cy.contains('Round 6 of 6').should('be.visible') 110 | }) 111 | }) 112 | -------------------------------------------------------------------------------- /cypress/README.md: -------------------------------------------------------------------------------- 1 | # Cypress E2E Testing Setup 2 | 3 | This project uses Cypress for End-to-End (E2E) testing and component testing. 4 | 5 | ## Prerequisites 6 | 7 | - Node.js (version 14 or higher) 8 | - npm or yarn 9 | - Vue CLI development server running on port 8080 10 | 11 | ## Installation 12 | 13 | Cypress is already installed as a dev dependency. If you need to reinstall: 14 | 15 | ```bash 16 | npm install --save-dev cypress 17 | ``` 18 | 19 | ## Available Scripts 20 | 21 | ### E2E Testing 22 | - `npm run test:e2e` - Run all E2E tests in headless mode 23 | - `npm run test:e2e:open` - Open Cypress Test Runner for E2E tests 24 | - `npm run test:e2e:headed` - Run E2E tests in headed mode (with browser visible) 25 | 26 | ### Component Testing 27 | - `npm run test:component` - Run all component tests in headless mode 28 | - `npm run test:component:open` - Open Cypress Test Runner for component tests 29 | 30 | ## Test Structure 31 | 32 | ``` 33 | cypress/ 34 | ├── e2e/ # E2E test files 35 | │ └── horse-racing.cy.js # Main game flow tests 36 | ├── component/ # Component test files 37 | │ ├── Header.cy.js # Header component tests 38 | │ └── AvailableHorsesArea.cy.js # AvailableHorsesArea component tests 39 | ├── support/ # Support files 40 | │ ├── e2e.js # E2E support configuration 41 | │ ├── component.js # Component support configuration 42 | │ └── commands.js # Custom Cypress commands 43 | └── fixtures/ # Test data files (if needed) 44 | ``` 45 | 46 | ## Custom Commands 47 | 48 | The following custom commands are available in E2E tests: 49 | 50 | - `cy.generateHorses()` - Clicks the generate horses button 51 | - `cy.generateRaceSchedule()` - Clicks the generate race schedule button 52 | - `cy.startRace()` - Starts a race 53 | - `cy.waitForHorsesGenerated()` - Waits for horses to be generated 54 | - `cy.waitForRaceScheduleGenerated()` - Waits for race schedule to be generated 55 | - `cy.waitForRaceComplete()` - Waits for race animation to complete 56 | - `cy.getByTestId(testId)` - Gets element by test ID attribute 57 | 58 | ## Test Data Attributes 59 | 60 | The application uses `data-test-id` attributes for reliable element selection. These are generated using the `getTestAttributes` utility function. 61 | 62 | Example test IDs: 63 | - `horse-racing-generate-horses-btn` 64 | - `horse-racing-generate-schedule-btn` 65 | - `horse-racing-available-horses` 66 | - `horse-racing-race-area` 67 | 68 | ## Running Tests 69 | 70 | ### 1. Start the Development Server 71 | ```bash 72 | npm run serve 73 | ``` 74 | 75 | ### 2. Run E2E Tests 76 | In a new terminal: 77 | ```bash 78 | npm run test:e2e 79 | ``` 80 | 81 | ### 3. Open Cypress Test Runner (Interactive Mode) 82 | ```bash 83 | npm run test:e2e:open 84 | ``` 85 | 86 | ## Writing Tests 87 | 88 | ### E2E Test Example 89 | ```javascript 90 | it('should generate horses when generate horses button is clicked', () => { 91 | // Initially no horses should be available 92 | cy.get('[data-test-id*="horse-racing-horse-item"]').should('have.length', 0) 93 | 94 | // Click generate horses button 95 | cy.generateHorses() 96 | 97 | // Wait for horses to be generated and displayed 98 | cy.waitForHorsesGenerated() 99 | }) 100 | ``` 101 | 102 | ### Component Test Example 103 | ```javascript 104 | it('should render generate horses button', () => { 105 | cy.getByTestId('horse-racing-generate-horses-btn').should('be.visible') 106 | cy.getByTestId('horse-racing-generate-horses-btn').should('contain.text', 'Generate Horses') 107 | }) 108 | ``` 109 | 110 | ## Best Practices 111 | 112 | 1. **Use test IDs**: Always use `data-test-id` attributes for element selection 113 | 2. **Wait for state changes**: Use custom commands to wait for async operations 114 | 3. **Test user flows**: Focus on testing complete user journeys 115 | 4. **Keep tests independent**: Each test should be able to run independently 116 | 5. **Use descriptive test names**: Test names should clearly describe what is being tested 117 | 118 | ## Troubleshooting 119 | 120 | ### Common Issues 121 | 122 | 1. **Tests fail with "element not found"**: Make sure the development server is running on port 8080 123 | 2. **Race conditions**: Use the custom wait commands for async operations 124 | 3. **Component mounting issues**: Ensure all required props are provided in component tests 125 | 126 | ### Debug Mode 127 | 128 | Run tests with `--headed` flag to see the browser and debug visually: 129 | ```bash 130 | npm run test:e2e:headed 131 | ``` 132 | 133 | ## Configuration 134 | 135 | The Cypress configuration is in `cypress.config.js`. Key settings: 136 | - Base URL: `http://localhost:8080` 137 | - Viewport: 1280x720 138 | - Timeouts: 10 seconds for commands and requests 139 | - Screenshots: Enabled on test failure 140 | -------------------------------------------------------------------------------- /src/components/Race/components/RacingHorse.vue: -------------------------------------------------------------------------------- 1 | 2 | 12 | {{ horseOrder }} 13 | 17 | {{ horse.name }} 18 | 19 | 32 | 39 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 74 | 75 | 148 | -------------------------------------------------------------------------------- /src/store/modules/horses/__tests__/horses.spec.ts: -------------------------------------------------------------------------------- 1 | import { moduleHorses } from "@/store/modules/horses"; 2 | import { 3 | HORSE_GETTERS, 4 | HORSE_MUTATIONS, 5 | HORSE_ACTIONS, 6 | HorsesState, 7 | RoundResult, 8 | Horse, 9 | RaceSchedule, 10 | } from "@/store/modules/horses/models"; 11 | import { 12 | generateRandomHorses, 13 | generateSchedule, 14 | } from "@/store/modules/horses/utils"; 15 | import { ActionContext } from "vuex"; 16 | 17 | jest.mock("@/store/modules/horses/utils", () => ({ 18 | generateRandomHorses: jest.fn(), 19 | generateSchedule: jest.fn(), 20 | })); 21 | 22 | const mockGenerateRandomHorses = generateRandomHorses as jest.MockedFunction< 23 | typeof generateRandomHorses 24 | >; 25 | const mockGenerateSchedule = generateSchedule as jest.MockedFunction< 26 | typeof generateSchedule 27 | >; 28 | 29 | describe("Horses Store Module", () => { 30 | let state: HorsesState; 31 | 32 | const createMockContext = ( 33 | commit: jest.Mock, 34 | state?: HorsesState 35 | ): ActionContext => ({ 36 | commit, 37 | state: state || moduleHorses.state(), 38 | dispatch: jest.fn(), 39 | getters: {}, 40 | rootState: {} as HorsesState, 41 | rootGetters: {}, 42 | }); 43 | 44 | beforeEach(() => { 45 | jest.clearAllMocks(); 46 | 47 | state = moduleHorses.state(); 48 | }); 49 | 50 | describe("State", () => { 51 | it("should initialize with default values", () => { 52 | expect(state.roundCounts).toBe(6); 53 | expect(state.horses).toEqual([]); 54 | expect(state.schedule).toEqual([]); 55 | expect(state.currentRound).toBe(0); 56 | expect(state.raceFinished).toBe(false); 57 | expect(state.roundResults).toEqual([]); 58 | }); 59 | }); 60 | 61 | describe("Getters", () => { 62 | it("should return all horses", () => { 63 | const horses = [ 64 | { 65 | id: 1, 66 | name: "Test Horse", 67 | color: "red", 68 | condition: 100, 69 | position: 0, 70 | finishedRank: 0, 71 | finishTime: 0, 72 | }, 73 | ]; 74 | state.horses = horses; 75 | 76 | const result = moduleHorses.getters[HORSE_GETTERS.ALL_HORSES](state); 77 | expect(result).toEqual(horses); 78 | }); 79 | 80 | it("should return race schedule list", () => { 81 | const schedule = [{ round: 1, distance: 100, participants: [] }]; 82 | state.schedule = schedule; 83 | 84 | const result = 85 | moduleHorses.getters[HORSE_GETTERS.RACE_SCHEDULE_LIST](state); 86 | expect(result).toEqual(schedule); 87 | }); 88 | 89 | it("should return race in progress status", () => { 90 | const result = 91 | moduleHorses.getters[HORSE_GETTERS.RACE_IN_PROGRESS](state); 92 | expect(result).toBe(false); 93 | }); 94 | 95 | it("should return current round", () => { 96 | state.currentRound = 3; 97 | 98 | const result = moduleHorses.getters[HORSE_GETTERS.CURRENT_ROUND](state); 99 | expect(result).toBe(3); 100 | }); 101 | 102 | it("should return race finished status", () => { 103 | state.raceFinished = true; 104 | 105 | const result = moduleHorses.getters[HORSE_GETTERS.RACE_FINISHED](state); 106 | expect(result).toBe(true); 107 | }); 108 | 109 | it("should return round results", () => { 110 | const results = [ 111 | { round: 1, distance: 100, participants: [], finishedHorses: [] }, 112 | ]; 113 | state.roundResults = results; 114 | 115 | const result = moduleHorses.getters[HORSE_GETTERS.ROUND_RESULTS](state); 116 | expect(result).toEqual(results); 117 | }); 118 | }); 119 | 120 | describe("Mutations", () => { 121 | it("should set horses", () => { 122 | const horses = [ 123 | { 124 | id: 1, 125 | name: "Test Horse", 126 | color: "red", 127 | condition: 100, 128 | position: 0, 129 | finishedRank: 0, 130 | finishTime: 0, 131 | }, 132 | ]; 133 | 134 | moduleHorses.mutations[HORSE_MUTATIONS.SET_HORSES](state, horses); 135 | expect(state.horses).toEqual(horses); 136 | }); 137 | 138 | it("should set schedule", () => { 139 | const schedule = [{ round: 1, distance: 100, participants: [] }]; 140 | 141 | moduleHorses.mutations[HORSE_MUTATIONS.SET_SCHEDULE](state, schedule); 142 | expect(state.schedule).toEqual(schedule); 143 | }); 144 | 145 | it("should set current round", () => { 146 | moduleHorses.mutations[HORSE_MUTATIONS.SET_CURRENT_ROUND](state, 5); 147 | expect(state.currentRound).toBe(5); 148 | }); 149 | 150 | it("should set race finished status", () => { 151 | moduleHorses.mutations[HORSE_MUTATIONS.SET_RACE_FINISHED](state, true); 152 | expect(state.raceFinished).toBe(true); 153 | }); 154 | 155 | it("should set round results", () => { 156 | const results = [ 157 | { round: 1, distance: 100, participants: [], finishedHorses: [] }, 158 | ]; 159 | 160 | moduleHorses.mutations[HORSE_MUTATIONS.SET_ROUND_RESULTS](state, results); 161 | expect(state.roundResults).toEqual(results); 162 | }); 163 | }); 164 | 165 | describe("Actions", () => { 166 | let commit: jest.Mock; 167 | 168 | beforeEach(() => { 169 | jest.clearAllMocks(); 170 | 171 | commit = jest.fn(); 172 | }); 173 | 174 | describe("generateHorses", () => { 175 | it("should generate horses and reset schedule", async () => { 176 | const mockHorses = [ 177 | { 178 | id: 1, 179 | name: "Test Horse", 180 | color: "red", 181 | condition: 100, 182 | position: 0, 183 | finishedRank: 0, 184 | finishTime: 0, 185 | }, 186 | ]; 187 | mockGenerateRandomHorses.mockReturnValue(mockHorses); 188 | 189 | await moduleHorses.actions[HORSE_ACTIONS.GENERATE_HORSES]( 190 | createMockContext(commit) 191 | ); 192 | 193 | expect(mockGenerateRandomHorses).toHaveBeenCalledWith(20); 194 | expect(commit).toHaveBeenCalledWith( 195 | HORSE_MUTATIONS.SET_HORSES, 196 | mockHorses 197 | ); 198 | expect(commit).toHaveBeenCalledWith(HORSE_MUTATIONS.SET_SCHEDULE, []); 199 | }); 200 | }); 201 | 202 | describe("createSchedule", () => { 203 | it("should create schedule and reset race values", async () => { 204 | const mockSchedule = [{ round: 1, distance: 100, participants: [] }]; 205 | mockGenerateSchedule.mockReturnValue(mockSchedule); 206 | 207 | await moduleHorses.actions[HORSE_ACTIONS.CREATE_SCHEDULE]( 208 | createMockContext(commit, state) 209 | ); 210 | 211 | expect(mockGenerateSchedule).toHaveBeenCalledWith(state.horses); 212 | expect(commit).toHaveBeenCalledWith( 213 | HORSE_MUTATIONS.SET_SCHEDULE, 214 | mockSchedule 215 | ); 216 | expect(commit).toHaveBeenCalledWith( 217 | HORSE_MUTATIONS.SET_CURRENT_ROUND, 218 | 0 219 | ); 220 | expect(commit).toHaveBeenCalledWith( 221 | HORSE_MUTATIONS.SET_RACE_FINISHED, 222 | false 223 | ); 224 | expect(commit).toHaveBeenCalledWith( 225 | HORSE_MUTATIONS.SET_ROUND_RESULTS, 226 | [] 227 | ); 228 | }); 229 | }); 230 | 231 | describe("resetRaceValues", () => { 232 | it("should reset all race-related values", async () => { 233 | await moduleHorses.actions[HORSE_ACTIONS.RESET_RACE_VALUES]( 234 | createMockContext(commit) 235 | ); 236 | 237 | expect(commit).toHaveBeenCalledWith( 238 | HORSE_MUTATIONS.SET_CURRENT_ROUND, 239 | 0 240 | ); 241 | expect(commit).toHaveBeenCalledWith( 242 | HORSE_MUTATIONS.SET_RACE_FINISHED, 243 | false 244 | ); 245 | expect(commit).toHaveBeenCalledWith( 246 | HORSE_MUTATIONS.SET_ROUND_RESULTS, 247 | [] 248 | ); 249 | }); 250 | }); 251 | }); 252 | 253 | describe("Module Configuration", () => { 254 | it("should be namespaced", () => { 255 | expect(moduleHorses.namespaced).toBe(true); 256 | }); 257 | 258 | it("should have all required properties", () => { 259 | expect(moduleHorses).toHaveProperty("state"); 260 | expect(moduleHorses).toHaveProperty("getters"); 261 | expect(moduleHorses).toHaveProperty("mutations"); 262 | expect(moduleHorses).toHaveProperty("actions"); 263 | }); 264 | 265 | it("should have state as a function", () => { 266 | expect(typeof moduleHorses.state).toBe("function"); 267 | }); 268 | 269 | it("should have getters as an object", () => { 270 | expect(typeof moduleHorses.getters).toBe("object"); 271 | }); 272 | 273 | it("should have mutations as an object", () => { 274 | expect(typeof moduleHorses.mutations).toBe("object"); 275 | }); 276 | 277 | it("should have actions as an object", () => { 278 | expect(typeof moduleHorses.actions).toBe("object"); 279 | }); 280 | }); 281 | 282 | describe("Integration Tests", () => { 283 | it("should handle complete race flow", async () => { 284 | const mockHorses = [ 285 | { 286 | id: 1, 287 | name: "Test Horse", 288 | color: "red", 289 | condition: 100, 290 | position: 0, 291 | finishedRank: 0, 292 | finishTime: 0, 293 | }, 294 | ]; 295 | const mockSchedule = [ 296 | { round: 1, distance: 100, participants: mockHorses }, 297 | ]; 298 | 299 | mockGenerateRandomHorses.mockReturnValue(mockHorses); 300 | mockGenerateSchedule.mockReturnValue(mockSchedule); 301 | 302 | const mockCommit = jest.fn((mutation: string, payload: unknown) => { 303 | switch (mutation) { 304 | case HORSE_MUTATIONS.SET_HORSES: 305 | state.horses = payload as Horse[]; 306 | break; 307 | case HORSE_MUTATIONS.SET_SCHEDULE: 308 | state.schedule = payload as RaceSchedule[]; 309 | break; 310 | case HORSE_MUTATIONS.SET_CURRENT_ROUND: 311 | state.currentRound = payload as number; 312 | break; 313 | case HORSE_MUTATIONS.SET_RACE_FINISHED: 314 | state.raceFinished = payload as boolean; 315 | break; 316 | case HORSE_MUTATIONS.SET_ROUND_RESULTS: 317 | state.roundResults = payload as RoundResult[]; 318 | break; 319 | } 320 | }); 321 | 322 | await moduleHorses.actions[HORSE_ACTIONS.GENERATE_HORSES]( 323 | createMockContext(mockCommit) 324 | ); 325 | 326 | await moduleHorses.actions[HORSE_ACTIONS.CREATE_SCHEDULE]( 327 | createMockContext(mockCommit, state) 328 | ); 329 | 330 | expect(state.horses).toEqual(mockHorses); 331 | expect(state.schedule).toEqual(mockSchedule); 332 | expect(state.currentRound).toBe(0); 333 | expect(state.raceFinished).toBe(false); 334 | expect(state.roundResults).toEqual([]); 335 | }); 336 | 337 | it("should reset race values correctly", async () => { 338 | state.currentRound = 5; 339 | state.raceFinished = true; 340 | state.roundResults = [ 341 | { round: 1, distance: 100, participants: [], finishedHorses: [] }, 342 | ]; 343 | 344 | const mockCommit = jest.fn( 345 | (mutation: string, payload: number | boolean | RoundResult[]) => { 346 | switch (mutation) { 347 | case HORSE_MUTATIONS.SET_CURRENT_ROUND: 348 | state.currentRound = payload as number; 349 | break; 350 | case HORSE_MUTATIONS.SET_RACE_FINISHED: 351 | state.raceFinished = payload as boolean; 352 | break; 353 | case HORSE_MUTATIONS.SET_ROUND_RESULTS: 354 | state.roundResults = payload as RoundResult[]; 355 | break; 356 | } 357 | } 358 | ); 359 | 360 | await moduleHorses.actions[HORSE_ACTIONS.RESET_RACE_VALUES]( 361 | createMockContext(mockCommit) 362 | ); 363 | 364 | expect(state.currentRound).toBe(0); 365 | expect(state.raceFinished).toBe(false); 366 | expect(state.roundResults).toEqual([]); 367 | }); 368 | }); 369 | }); 370 | -------------------------------------------------------------------------------- /src/components/Race/components/RaceArea.vue: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | Round {{ currentRound + 1 }} of {{ totalRounds }} 16 | ✓ Completed 17 | 18 | 19 | 32 | Start Race 33 | 34 | 47 | Start Next Round 48 | 49 | 50 | 51 | 52 | 53 | 60 | 61 | 66 | 67 | 68 | 69 | 70 | 301 | 302 | 525 | -------------------------------------------------------------------------------- /src/store/modules/horses/__tests__/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | generateRandomHorses, 3 | pickRandomHorses, 4 | generateSchedule, 5 | } from "../utils"; 6 | import { Horse } from "../models"; 7 | 8 | describe("Horses Utils", () => { 9 | describe("generateRandomHorses", () => { 10 | it("should generate the correct number of horses", () => { 11 | const count = 20; 12 | const horses = generateRandomHorses(count); 13 | 14 | expect(horses).toHaveLength(count); 15 | }); 16 | 17 | it("should generate horses with unique IDs", () => { 18 | const count = 15; 19 | const horses = generateRandomHorses(count); 20 | 21 | const ids = horses.map((horse) => horse.id); 22 | const uniqueIds = new Set(ids); 23 | 24 | expect(uniqueIds.size).toBe(count); 25 | expect(ids).toEqual(Array.from({ length: count }, (_, i) => i + 1)); 26 | }); 27 | 28 | it("should generate horses with sequential names", () => { 29 | const count = 10; 30 | const horses = generateRandomHorses(count); 31 | 32 | horses.forEach((horse, index) => { 33 | expect(horse.name).toBe(`Horse ${index + 1}`); 34 | }); 35 | }); 36 | 37 | it("should generate horses with valid HSL colors", () => { 38 | const count = 8; 39 | const horses = generateRandomHorses(count); 40 | 41 | horses.forEach((horse, index) => { 42 | expect(horse.color).toMatch(/^hsl\(\d+,\s*\d+%,\s*\d+%\)$/); 43 | 44 | const hslMatch = horse.color.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/); 45 | expect(hslMatch).toBeTruthy(); 46 | 47 | if (hslMatch) { 48 | const hue = parseInt(hslMatch[1]); 49 | const saturation = parseInt(hslMatch[2]); 50 | const lightness = parseInt(hslMatch[3]); 51 | 52 | expect(hue).toBeGreaterThanOrEqual(0); 53 | expect(hue).toBeLessThan(360); 54 | expect(saturation).toBeGreaterThanOrEqual(60); 55 | expect(saturation).toBeLessThanOrEqual(90); 56 | expect(lightness).toBeGreaterThanOrEqual(40); 57 | expect(lightness).toBeLessThanOrEqual(70); 58 | } 59 | }); 60 | }); 61 | 62 | it("should generate horses with valid condition values", () => { 63 | const count = 12; 64 | const horses = generateRandomHorses(count); 65 | 66 | horses.forEach((horse) => { 67 | expect(horse.condition).toBeGreaterThanOrEqual(1); 68 | expect(horse.condition).toBeLessThanOrEqual(100); 69 | expect(Number.isInteger(horse.condition)).toBe(true); 70 | }); 71 | }); 72 | 73 | it("should initialize horses with default race values", () => { 74 | const count = 5; 75 | const horses = generateRandomHorses(count); 76 | 77 | horses.forEach((horse) => { 78 | expect(horse.position).toBe(0); 79 | expect(horse.finishedRank).toBe(0); 80 | expect(horse.finishTime).toBe(0); 81 | }); 82 | }); 83 | 84 | it("should handle edge case of 0 horses", () => { 85 | const horses = generateRandomHorses(0); 86 | 87 | expect(horses).toHaveLength(0); 88 | expect(horses).toEqual([]); 89 | }); 90 | 91 | it("should handle edge case of 1 horse", () => { 92 | const horses = generateRandomHorses(1); 93 | 94 | expect(horses).toHaveLength(1); 95 | expect(horses[0].id).toBe(1); 96 | expect(horses[0].name).toBe("Horse 1"); 97 | }); 98 | 99 | it("should generate horses with distributed colors", () => { 100 | const count = 6; 101 | const horses = generateRandomHorses(count); 102 | 103 | const colors = horses.map((horse) => horse.color); 104 | const uniqueColors = new Set(colors); 105 | 106 | expect(uniqueColors.size).toBeGreaterThan(1); 107 | }); 108 | }); 109 | 110 | describe("pickRandomHorses", () => { 111 | let sampleHorses: Horse[]; 112 | 113 | beforeEach(() => { 114 | sampleHorses = [ 115 | { 116 | id: 1, 117 | name: "Horse 1", 118 | color: "red", 119 | condition: 80, 120 | position: 0, 121 | finishedRank: 0, 122 | finishTime: 0, 123 | }, 124 | { 125 | id: 2, 126 | name: "Horse 2", 127 | color: "blue", 128 | condition: 90, 129 | position: 0, 130 | finishedRank: 0, 131 | finishTime: 0, 132 | }, 133 | { 134 | id: 3, 135 | name: "Horse 3", 136 | color: "green", 137 | condition: 70, 138 | position: 0, 139 | finishedRank: 0, 140 | finishTime: 0, 141 | }, 142 | { 143 | id: 4, 144 | name: "Horse 4", 145 | color: "yellow", 146 | condition: 85, 147 | position: 0, 148 | finishedRank: 0, 149 | finishTime: 0, 150 | }, 151 | { 152 | id: 5, 153 | name: "Horse 5", 154 | color: "purple", 155 | condition: 75, 156 | position: 0, 157 | finishedRank: 0, 158 | finishTime: 0, 159 | }, 160 | ]; 161 | }); 162 | 163 | it("should return the correct number of horses", () => { 164 | const count = 3; 165 | const selected = pickRandomHorses(sampleHorses, count); 166 | 167 | expect(selected).toHaveLength(count); 168 | }); 169 | 170 | it("should return all horses when count equals total", () => { 171 | const count = sampleHorses.length; 172 | const selected = pickRandomHorses(sampleHorses, count); 173 | 174 | expect(selected).toHaveLength(count); 175 | expect(selected).toHaveLength(sampleHorses.length); 176 | }); 177 | 178 | it("should return all horses when count exceeds total", () => { 179 | const count = sampleHorses.length + 5; 180 | const selected = pickRandomHorses(sampleHorses, count); 181 | 182 | expect(selected).toHaveLength(sampleHorses.length); 183 | }); 184 | 185 | it("should return empty array when count is 0", () => { 186 | const selected = pickRandomHorses(sampleHorses, 0); 187 | 188 | expect(selected).toHaveLength(0); 189 | expect(selected).toEqual([]); 190 | }); 191 | 192 | it("should return empty array when horses array is empty", () => { 193 | const selected = pickRandomHorses([], 3); 194 | 195 | expect(selected).toHaveLength(0); 196 | expect(selected).toEqual([]); 197 | }); 198 | 199 | it("should not modify the original horses array", () => { 200 | const originalHorses = [...sampleHorses]; 201 | pickRandomHorses(sampleHorses, 3); 202 | 203 | expect(sampleHorses).toEqual(originalHorses); 204 | }); 205 | 206 | it("should return horses with correct structure", () => { 207 | const selected = pickRandomHorses(sampleHorses, 2); 208 | 209 | selected.forEach((horse) => { 210 | expect(horse).toHaveProperty("id"); 211 | expect(horse).toHaveProperty("name"); 212 | expect(horse).toHaveProperty("color"); 213 | expect(horse).toHaveProperty("condition"); 214 | expect(horse).toHaveProperty("position"); 215 | expect(horse).toHaveProperty("finishedRank"); 216 | expect(horse).toHaveProperty("finishTime"); 217 | }); 218 | }); 219 | }); 220 | 221 | describe("generateSchedule", () => { 222 | let sampleHorses: Horse[]; 223 | 224 | beforeEach(() => { 225 | sampleHorses = [ 226 | { 227 | id: 1, 228 | name: "Horse 1", 229 | color: "red", 230 | condition: 80, 231 | position: 0, 232 | finishedRank: 0, 233 | finishTime: 0, 234 | }, 235 | { 236 | id: 2, 237 | name: "Horse 2", 238 | color: "blue", 239 | condition: 90, 240 | position: 0, 241 | finishedRank: 0, 242 | finishTime: 0, 243 | }, 244 | { 245 | id: 3, 246 | name: "Horse 3", 247 | color: "green", 248 | condition: 70, 249 | position: 0, 250 | finishedRank: 0, 251 | finishTime: 0, 252 | }, 253 | { 254 | id: 4, 255 | name: "Horse 4", 256 | color: "yellow", 257 | condition: 85, 258 | position: 0, 259 | finishedRank: 0, 260 | finishTime: 0, 261 | }, 262 | { 263 | id: 5, 264 | name: "Horse 5", 265 | color: "purple", 266 | condition: 75, 267 | position: 0, 268 | finishedRank: 0, 269 | finishTime: 0, 270 | }, 271 | { 272 | id: 6, 273 | name: "Horse 6", 274 | color: "orange", 275 | condition: 88, 276 | position: 0, 277 | finishedRank: 0, 278 | finishTime: 0, 279 | }, 280 | { 281 | id: 7, 282 | name: "Horse 7", 283 | color: "pink", 284 | condition: 92, 285 | position: 0, 286 | finishedRank: 0, 287 | finishTime: 0, 288 | }, 289 | { 290 | id: 8, 291 | name: "Horse 8", 292 | color: "brown", 293 | condition: 78, 294 | position: 0, 295 | finishedRank: 0, 296 | finishTime: 0, 297 | }, 298 | { 299 | id: 9, 300 | name: "Horse 9", 301 | color: "gray", 302 | condition: 83, 303 | position: 0, 304 | finishedRank: 0, 305 | finishTime: 0, 306 | }, 307 | { 308 | id: 10, 309 | name: "Horse 10", 310 | color: "cyan", 311 | condition: 87, 312 | position: 0, 313 | finishedRank: 0, 314 | finishTime: 0, 315 | }, 316 | { 317 | id: 11, 318 | name: "Horse 11", 319 | color: "magenta", 320 | condition: 79, 321 | position: 0, 322 | finishedRank: 0, 323 | finishTime: 0, 324 | }, 325 | { 326 | id: 12, 327 | name: "Horse 12", 328 | color: "lime", 329 | condition: 91, 330 | position: 0, 331 | finishedRank: 0, 332 | finishTime: 0, 333 | }, 334 | ]; 335 | }); 336 | 337 | it("should generate schedule with 6 rounds", () => { 338 | const schedule = generateSchedule(sampleHorses); 339 | 340 | expect(schedule).toHaveLength(6); 341 | }); 342 | 343 | it("should have correct round numbers", () => { 344 | const schedule = generateSchedule(sampleHorses); 345 | 346 | schedule.forEach((race, index) => { 347 | expect(race.round).toBe(index + 1); 348 | }); 349 | }); 350 | 351 | it("should have correct distances", () => { 352 | const expectedDistances = [1200, 1400, 1600, 1800, 2000, 2200]; 353 | const schedule = generateSchedule(sampleHorses); 354 | 355 | schedule.forEach((race, index) => { 356 | expect(race.distance).toBe(expectedDistances[index]); 357 | }); 358 | }); 359 | 360 | it("should have 10 participants per race", () => { 361 | const schedule = generateSchedule(sampleHorses); 362 | 363 | schedule.forEach((race) => { 364 | expect(race.participants).toHaveLength(10); 365 | }); 366 | }); 367 | 368 | it("should have correct structure for each race", () => { 369 | const schedule = generateSchedule(sampleHorses); 370 | 371 | schedule.forEach((race) => { 372 | expect(race).toHaveProperty("round"); 373 | expect(race).toHaveProperty("distance"); 374 | expect(race).toHaveProperty("participants"); 375 | expect(Array.isArray(race.participants)).toBe(true); 376 | }); 377 | }); 378 | 379 | it("should handle empty horses array", () => { 380 | const schedule = generateSchedule([]); 381 | 382 | expect(schedule).toHaveLength(6); 383 | schedule.forEach((race) => { 384 | expect(race.participants).toHaveLength(0); 385 | }); 386 | }); 387 | 388 | it("should handle horses array with less than 10 horses", () => { 389 | const fewHorses = sampleHorses.slice(0, 5); 390 | const schedule = generateSchedule(fewHorses); 391 | 392 | schedule.forEach((race) => { 393 | expect(race.participants).toHaveLength(5); 394 | }); 395 | }); 396 | 397 | it("should not modify the original horses array", () => { 398 | const originalHorses = [...sampleHorses]; 399 | generateSchedule(sampleHorses); 400 | 401 | expect(sampleHorses).toEqual(originalHorses); 402 | }); 403 | 404 | it("should generate different participant orders for different rounds", () => { 405 | const schedule = generateSchedule(sampleHorses); 406 | 407 | const firstRaceParticipants = schedule[0].participants.map((p) => p.id); 408 | const secondRaceParticipants = schedule[1].participants.map((p) => p.id); 409 | 410 | expect(firstRaceParticipants).toHaveLength(10); 411 | expect(secondRaceParticipants).toHaveLength(10); 412 | }); 413 | }); 414 | 415 | describe("Integration Tests", () => { 416 | it("should work together: generate horses -> pick random -> generate schedule", () => { 417 | const horses = generateRandomHorses(15); 418 | expect(horses).toHaveLength(15); 419 | 420 | const selected = pickRandomHorses(horses, 8); 421 | expect(selected).toHaveLength(8); 422 | 423 | const schedule = generateSchedule(horses); 424 | expect(schedule).toHaveLength(6); 425 | 426 | schedule.forEach((race) => { 427 | expect(race.participants).toHaveLength(10); 428 | race.participants.forEach((participant) => { 429 | expect(horses.some((horse) => horse.id === participant.id)).toBe( 430 | true 431 | ); 432 | }); 433 | }); 434 | }); 435 | 436 | it("should handle edge case: single horse", () => { 437 | const horses = generateRandomHorses(1); 438 | expect(horses).toHaveLength(1); 439 | 440 | const selected = pickRandomHorses(horses, 1); 441 | expect(selected).toHaveLength(1); 442 | 443 | const schedule = generateSchedule(horses); 444 | expect(schedule).toHaveLength(6); 445 | 446 | schedule.forEach((race) => { 447 | expect(race.participants).toHaveLength(1); 448 | expect(race.participants[0].id).toBe(1); 449 | }); 450 | }); 451 | }); 452 | }); 453 | --------------------------------------------------------------------------------