Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/AI decleration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
24 changes: 24 additions & 0 deletions project2/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ const typeDefs = `#graphql
speed: Int!
hp: Int!
imageUrl: String!
averageRating: Float
ratingCount: Int!
}
type PokemonPage {
Expand Down Expand Up @@ -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: (
Expand Down Expand Up @@ -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,
},
};

Expand Down
24 changes: 24 additions & 0 deletions project2/server/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand All @@ -32,6 +50,8 @@ export const typeDefs = `#graphql
speed: Int!
hp: Int!
imageUrl: String!
averageRating: Float
ratingCount: Int!
}
type PokemonPage {
Expand Down Expand Up @@ -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,
},
};
51 changes: 51 additions & 0 deletions project2/server/test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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([]);
});
}
Expand Down Expand Up @@ -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<typeof mockStatement>).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);
}
});
});
});