diff --git a/docs/AI decleration.md b/docs/AI decleration.md index e698592..d49fdb3 100644 --- a/docs/AI decleration.md +++ b/docs/AI decleration.md @@ -39,3 +39,5 @@ final implementation and documentation. Copilot GPT Luna 5.6 assisted with spelling, formatting, and fact-checking the sustainability documentation. The content and final wording were reviewed by the team. +- **`#57` - Rating system changes (10 November 2026):** + `Claude Opus 5.5` was consulted for rating system changes in order for logic to have fewer flaws, work with the current frontend implementations and generate tests for the new aggregate system. diff --git a/project2/server/index.ts b/project2/server/index.ts index 8eecd1d..991195b 100644 --- a/project2/server/index.ts +++ b/project2/server/index.ts @@ -62,6 +62,8 @@ const typeDefs = `#graphql speed: Int! hp: Int! imageUrl: String! + averageRating: Float + ratingCount: Int! } type PokemonPage { @@ -118,6 +120,24 @@ const SORT_COLUMNS = { SPEED: 'speed', } as const; +/** + * Aggregated community rating for one Pokémon. + * AVG returns NULL when there are no ratings, which becomes a null + * `averageRating` in GraphQL. COUNT returns 0 in that case. + */ +interface RatingStats { + average: number | null; + votes: number; +} + +function getRatingStats(pokemonId: number): RatingStats { + return db + .prepare( + 'SELECT AVG(rating) AS average, COUNT(rating) AS votes FROM ratings WHERE pokemon_id = ?' + ) + .get(pokemonId) as RatingStats; +} + const resolvers = { Mutation: { ratePokemon: ( @@ -377,6 +397,10 @@ const resolvers = { 'SELECT name, description, short_description FROM abilities WHERE pokemon_id = ?' ) .all(parent.id) as Ability[], + averageRating: (parent: Pokemon): number | null => + getRatingStats(parent.id).average, + ratingCount: (parent: Pokemon): number => + getRatingStats(parent.id).votes, }, }; diff --git a/project2/server/schema.ts b/project2/server/schema.ts index 450208f..dea176b 100644 --- a/project2/server/schema.ts +++ b/project2/server/schema.ts @@ -9,6 +9,24 @@ export function decodeCursor(cursor: string): { v: unknown; i: number } { return JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8')); } +/** + * Aggregated community rating for one Pokémon. + * AVG returns NULL when there are no ratings, which becomes a null + * `averageRating` in GraphQL. COUNT returns 0 in that case. + */ +interface RatingStats { + average: number | null; + votes: number; +} + +function getRatingStats(pokemonId: number): RatingStats { + return db + .prepare( + 'SELECT AVG(rating) AS average, COUNT(rating) AS votes FROM ratings WHERE pokemon_id = ?' + ) + .get(pokemonId) as RatingStats; +} + export const typeDefs = `#graphql type Ability { name: String! @@ -32,6 +50,8 @@ export const typeDefs = `#graphql speed: Int! hp: Int! imageUrl: String! + averageRating: Float + ratingCount: Int! } type PokemonPage { @@ -250,5 +270,9 @@ export const resolvers = { 'SELECT name, description, short_description FROM abilities WHERE pokemon_id = ?' ) .all(parent.id) as Ability[], + averageRating: (parent: Pokemon): number | null => + getRatingStats(parent.id).average, + ratingCount: (parent: Pokemon): number => + getRatingStats(parent.id).votes, }, }; diff --git a/project2/server/test/api.test.ts b/project2/server/test/api.test.ts index a956c1e..9155dc6 100644 --- a/project2/server/test/api.test.ts +++ b/project2/server/test/api.test.ts @@ -31,6 +31,7 @@ function setupDbMock(options: { totalCount?: number; filteredCount?: number; items?: typeof mockPokemons; previousCount?: number; pokemonById?: (typeof mockPokemons)[0] | null; types?: typeof mockTypes; abilities?: typeof mockAbilities; + ratingStats?: { average: number | null; votes: number }; }) { mockedDb.prepare.mockImplementation((sql: string) => { const s = sql.toLowerCase(); @@ -45,6 +46,8 @@ function setupDbMock(options: { if (s.includes('select * from pokemons where id =')) return mockStatement(options.pokemonById ?? null); if (s.includes('select name from types')) return mockStatement(options.types ?? []); if (s.includes('select name, description, short_description from abilities')) return mockStatement(options.abilities ?? []); + // Mirrors SQLite: AVG is NULL and COUNT is 0 when no ratings exist + if (s.includes('select avg(rating)')) return mockStatement(options.ratingStats ?? { average: null, votes: 0 }); return mockStatement([]); }); } @@ -221,4 +224,52 @@ describe('GraphQL API', () => { expect((result.data as any).pokemon.abilities[0].name).toBe('Overgrow'); }); }); + + describe('Pokemon rating aggregate', () => { + it('returns null average and zero count when there are no ratings', async () => { + setupDbMock({ pokemonById: mockPokemons[0] }); + const result = await executeQuery(server, '{ pokemon(id: 1) { averageRating ratingCount } }'); + expect(result.errors).toBeUndefined(); + expect((result.data as any).pokemon.averageRating).toBeNull(); + expect((result.data as any).pokemon.ratingCount).toBe(0); + }); + + it('returns the single rating as the average when there is one vote', async () => { + setupDbMock({ pokemonById: mockPokemons[0], ratingStats: { average: 4, votes: 1 } }); + const result = await executeQuery(server, '{ pokemon(id: 1) { averageRating ratingCount } }'); + expect(result.errors).toBeUndefined(); + expect((result.data as any).pokemon.averageRating).toBe(4); + expect((result.data as any).pokemon.ratingCount).toBe(1); + }); + + it('returns a fractional average for several votes', async () => { + setupDbMock({ pokemonById: mockPokemons[0], ratingStats: { average: 3.5, votes: 4 } }); + const result = await executeQuery(server, '{ pokemon(id: 1) { averageRating ratingCount } }'); + expect(result.errors).toBeUndefined(); + expect((result.data as any).pokemon.averageRating).toBe(3.5); + expect((result.data as any).pokemon.ratingCount).toBe(4); + }); + + it('aggregates ratings for the requested pokemon id', async () => { + setupDbMock({ pokemonById: mockPokemons[2], ratingStats: { average: 5, votes: 2 } }); + await executeQuery(server, '{ pokemon(id: 3) { averageRating } }'); + const ratingCall = mockedDb.prepare.mock.results.find((_, i) => + String(mockedDb.prepare.mock.calls[i][0]).toLowerCase().includes('select avg(rating)') + ); + expect(ratingCall).toBeDefined(); + expect((ratingCall!.value as ReturnType).get).toHaveBeenCalledWith(3); + }); + + it('returns the aggregate for every pokemon in a page', async () => { + setupDbMock({ totalCount: 2, items: mockPokemons.slice(0, 2), ratingStats: { average: 2.5, votes: 2 } }); + const result = await executeQuery(server, '{ pokemons(first: 2) { items { id averageRating ratingCount } } }'); + expect(result.errors).toBeUndefined(); + const items = (result.data as any).pokemons.items; + expect(items).toHaveLength(2); + for (const item of items) { + expect(item.averageRating).toBe(2.5); + expect(item.ratingCount).toBe(2); + } + }); + }); });