Fix demo rating average calculation and add tests

#3
by Nafris - opened
Files changed (3) hide show
  1. package.json +1 -0
  2. src/lib/tools.ts +5 -2
  3. tests/demo-ratings.test.mjs +137 -0
package.json CHANGED
@@ -7,6 +7,7 @@
7
  "build": "next build",
8
  "prestart": "node scripts/prepare-standalone.mjs",
9
  "start": "node .next/standalone/server.js",
 
10
  "lint": "eslint",
11
  "typecheck": "tsc --noEmit"
12
  },
 
7
  "build": "next build",
8
  "prestart": "node scripts/prepare-standalone.mjs",
9
  "start": "node .next/standalone/server.js",
10
+ "test": "node --test tests/*.test.mjs",
11
  "lint": "eslint",
12
  "typecheck": "tsc --noEmit"
13
  },
src/lib/tools.ts CHANGED
@@ -11,6 +11,7 @@ type NewTool = Omit<
11
  type DemoState = {
12
  tools: SupplyChainTool[];
13
  ratings: Map<string, number>;
 
14
  };
15
 
16
  declare global {
@@ -122,6 +123,7 @@ function getDemoState(): DemoState {
122
  globalThis.__chainkitDemoState ??= {
123
  tools: demoTools.map((tool) => ({ ...tool })),
124
  ratings: new Map(),
 
125
  };
126
  return globalThis.__chainkitDemoState;
127
  }
@@ -319,11 +321,12 @@ export async function rateTool(toolId: string, voterId: string, rating: number)
319
 
320
  const key = `${toolId}:${voterId}`;
321
  const previousRating = state.ratings.get(key);
322
- const total = tool.averageRating * tool.ratingCount;
323
  const nextCount = tool.ratingCount + (previousRating === undefined ? 1 : 0);
324
  const nextTotal = total - (previousRating ?? 0) + rating;
325
  state.ratings.set(key, rating);
326
- tool.averageRating = Number((nextTotal / nextCount).toFixed(1));
 
327
  tool.ratingCount = nextCount;
328
  return tool;
329
  }
 
11
  type DemoState = {
12
  tools: SupplyChainTool[];
13
  ratings: Map<string, number>;
14
+ ratingSums: Map<string, number>;
15
  };
16
 
17
  declare global {
 
123
  globalThis.__chainkitDemoState ??= {
124
  tools: demoTools.map((tool) => ({ ...tool })),
125
  ratings: new Map(),
126
+ ratingSums: new Map(),
127
  };
128
  return globalThis.__chainkitDemoState;
129
  }
 
321
 
322
  const key = `${toolId}:${voterId}`;
323
  const previousRating = state.ratings.get(key);
324
+ const total = state.ratingSums.get(toolId) ?? tool.averageRating * tool.ratingCount;
325
  const nextCount = tool.ratingCount + (previousRating === undefined ? 1 : 0);
326
  const nextTotal = total - (previousRating ?? 0) + rating;
327
  state.ratings.set(key, rating);
328
+ state.ratingSums.set(toolId, nextTotal);
329
+ tool.averageRating = nextTotal / nextCount;
330
  tool.ratingCount = nextCount;
331
  return tool;
332
  }
tests/demo-ratings.test.mjs ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import { once } from "node:events";
4
+ import { createServer } from "node:net";
5
+ import { fileURLToPath } from "node:url";
6
+ import { after, before, test } from "node:test";
7
+
8
+ const projectRoot = fileURLToPath(new URL("..", import.meta.url));
9
+ let server;
10
+ let serverOutput = "";
11
+ let baseUrl;
12
+
13
+ async function getAvailablePort() {
14
+ const socket = createServer();
15
+ socket.listen(0, "127.0.0.1");
16
+ await once(socket, "listening");
17
+ const address = socket.address();
18
+ assert(address && typeof address === "object");
19
+ const { port } = address;
20
+ socket.close();
21
+ await once(socket, "close");
22
+ return port;
23
+ }
24
+
25
+ async function waitForServer() {
26
+ const deadline = Date.now() + 30_000;
27
+ while (Date.now() < deadline) {
28
+ if (server.exitCode !== null) {
29
+ throw new Error(`Next.js exited before becoming ready.\n${serverOutput}`);
30
+ }
31
+ try {
32
+ const response = await fetch(`${baseUrl}/api/tools`);
33
+ if (response.status === 405) return;
34
+ } catch {
35
+ // The server is still starting.
36
+ }
37
+ await new Promise((resolve) => setTimeout(resolve, 100));
38
+ }
39
+ throw new Error(`Timed out waiting for Next.js.\n${serverOutput}`);
40
+ }
41
+
42
+ async function createTool(label) {
43
+ const response = await fetch(`${baseUrl}/api/tools`, {
44
+ method: "POST",
45
+ headers: { "Content-Type": "application/json" },
46
+ body: JSON.stringify({
47
+ name: `Rating test ${label}`,
48
+ summary: "Temporary tool used by the demo rating integration tests.",
49
+ category: "Planning",
50
+ repoUrl: `https://github.com/chainkit-test/${label}`,
51
+ websiteUrl: "",
52
+ submittedBy: "Automated test",
53
+ }),
54
+ });
55
+ assert.equal(response.status, 201);
56
+ const body = await response.json();
57
+ return body.tool;
58
+ }
59
+
60
+ async function rateTool(toolId, voterId, rating) {
61
+ const response = await fetch(`${baseUrl}/api/ratings`, {
62
+ method: "POST",
63
+ headers: { "Content-Type": "application/json" },
64
+ body: JSON.stringify({ toolId, voterId, rating }),
65
+ });
66
+ assert.equal(response.status, 200);
67
+ return response.json();
68
+ }
69
+
70
+ before(async () => {
71
+ const port = await getAvailablePort();
72
+ baseUrl = `http://127.0.0.1:${port}`;
73
+ server = spawn(
74
+ process.execPath,
75
+ ["node_modules/next/dist/bin/next", "dev", "--hostname", "127.0.0.1", "--port", String(port)],
76
+ {
77
+ cwd: projectRoot,
78
+ env: {
79
+ ...process.env,
80
+ BIGQUERY_PROJECT_ID: "",
81
+ BIGQUERY_DATASET: "",
82
+ NEXT_TELEMETRY_DISABLED: "1",
83
+ },
84
+ stdio: ["ignore", "pipe", "pipe"],
85
+ },
86
+ );
87
+ server.stdout.on("data", (chunk) => { serverOutput += chunk; });
88
+ server.stderr.on("data", (chunk) => { serverOutput += chunk; });
89
+ await waitForServer();
90
+ });
91
+
92
+ after(async () => {
93
+ if (!server || server.exitCode !== null) return;
94
+ server.kill("SIGTERM");
95
+ await once(server, "exit");
96
+ });
97
+
98
+ test("records a new rating", async () => {
99
+ const tool = await createTool("new-rating");
100
+ const aggregate = await rateTool(tool.id, "00000000-0000-4000-8000-000000000001", 4);
101
+
102
+ assert.deepEqual(aggregate, { averageRating: 4, ratingCount: 1 });
103
+ });
104
+
105
+ test("updates an existing voter without increasing the count", async () => {
106
+ const tool = await createTool("updated-rating");
107
+ const voterId = "00000000-0000-4000-8000-000000000002";
108
+
109
+ await rateTool(tool.id, voterId, 5);
110
+ const aggregate = await rateTool(tool.id, voterId, 2);
111
+
112
+ assert.deepEqual(aggregate, { averageRating: 2, ratingCount: 1 });
113
+ });
114
+
115
+ test("counts ratings from distinct voters", async () => {
116
+ const tool = await createTool("rating-count");
117
+
118
+ await rateTool(tool.id, "00000000-0000-4000-8000-000000000003", 5);
119
+ const aggregate = await rateTool(tool.id, "00000000-0000-4000-8000-000000000004", 3);
120
+
121
+ assert.deepEqual(aggregate, { averageRating: 4, ratingCount: 2 });
122
+ });
123
+
124
+ test("keeps the unrounded average for ratings 2, 1, 1, 1", async () => {
125
+ const tool = await createTool("rounding-regression");
126
+ const ratings = [2, 1, 1, 1];
127
+ let aggregate;
128
+
129
+ for (const [index, rating] of ratings.entries()) {
130
+ const voterId = `00000000-0000-4000-8000-${String(index + 5).padStart(12, "0")}`;
131
+ aggregate = await rateTool(tool.id, voterId, rating);
132
+ }
133
+
134
+ assert.equal(aggregate.averageRating, 1.25);
135
+ assert.equal(aggregate.averageRating.toFixed(1), "1.3");
136
+ assert.equal(aggregate.ratingCount, 4);
137
+ });