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
15 changes: 12 additions & 3 deletions src/save-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,28 @@ import { type SaveFile, validateSave } from "./saves";
/** An in-memory sql.js database produced from a `.cdb` save by `cdbToSql`. */
export type SaveDb = ReturnType<typeof cdbToSql>;

/** Smallest plausible `YYYYMMDD` value (year 1000), used to reject sentinels. */
const MIN_YMD = 10000000;

/**
* Read the current in-game date from a save as a `YYYYMMDD` integer
* (e.g. `20260605`), or `null` when it can't be found.
* (e.g. `20260605`), or `null` when it can't be found or isn't a real date.
*
* PCM stores the career's current date in `GAM_config.gene_i_date`. It is the
* reference point for any age- or season-relative computation, since the
* on-disk save advances as the career is played.
* on-disk save advances as the career is played. Fresh official releases that
* haven't started a career store `0` here; that sentinel is treated as "unknown"
* (returns `null`) so callers don't derive nonsensical ages from it.
*/
export function getGameDate(db: SaveDb): number | null {
try {
const result = db.exec("SELECT gene_i_date FROM GAM_config LIMIT 1");
const raw = result[0]?.values?.[0]?.[0];
return raw != null ? Number(raw) : null;
if (raw == null) {
return null;
}
const value = Number(raw);
return Number.isFinite(value) && value >= MIN_YMD ? value : null;
} catch {
return null;
}
Expand Down
6 changes: 5 additions & 1 deletion src/tools/get-team-roster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,12 @@ export function registerGetTeamRoster(server: McpServer): void {
stmt.bind({ ":teamId": resolvedTeamId });
while (stmt.step()) {
const row = stmt.getAsObject();
const birthdate =
const rawBirthdate =
row.birthdate != null ? Number(row.birthdate) : null;
const birthdate =
rawBirthdate != null && rawBirthdate >= 10000000
? rawBirthdate
: null;
cyclists.push({
id: Number(row.id),
firstName: String(row.firstName),
Expand Down
Binary file added test/fixtures/OfficialRelease-2014.cdb
Binary file not shown.
Binary file added test/fixtures/OfficialRelease-2018.cdb
Binary file not shown.
Binary file added test/fixtures/OfficialRelease-2019.cdb
Binary file not shown.
Binary file added test/fixtures/OfficialRelease-2021.cdb
Binary file not shown.
Binary file added test/fixtures/OfficialRelease-2025.cdb
Binary file not shown.
28 changes: 28 additions & 0 deletions test/fixtures/save.fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { fileURLToPath } from "node:url";

export const saveFixtures = [
[
"Pro cycling manager 2018",
fileURLToPath(
new URL("../fixtures/OfficialRelease-2018.cdb", import.meta.url),
),
],
[
"Pro cycling manager 2019",
fileURLToPath(
new URL("../fixtures/OfficialRelease-2019.cdb", import.meta.url),
),
],
[
"Pro cycling manager 2021",
fileURLToPath(
new URL("../fixtures/OfficialRelease-2021.cdb", import.meta.url),
),
],
[
"Pro cycling manager 2025",
fileURLToPath(
new URL("../fixtures/OfficialRelease-2025.cdb", import.meta.url),
),
],
];
75 changes: 75 additions & 0 deletions test/mocks/mock-mcp-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { MockInstance } from "vitest";
import { vi } from "vitest";
import {
McpServer,
type ToolCallback,
} from "@modelcontextprotocol/sdk/server/mcp.js";

interface RegisteredTool {
name: string;
config: any;

Check warning on line 10 in test/mocks/mock-mcp-server.ts

View workflow job for this annotation

GitHub Actions / test (24.x)

lint/suspicious/noExplicitAny

Unexpected any. Specify a different type.

Check warning on line 10 in test/mocks/mock-mcp-server.ts

View workflow job for this annotation

GitHub Actions / test (22.x)

lint/suspicious/noExplicitAny

Unexpected any. Specify a different type.
callback: ToolCallback<any>;

Check warning on line 11 in test/mocks/mock-mcp-server.ts

View workflow job for this annotation

GitHub Actions / test (24.x)

lint/suspicious/noExplicitAny

Unexpected any. Specify a different type.

Check warning on line 11 in test/mocks/mock-mcp-server.ts

View workflow job for this annotation

GitHub Actions / test (22.x)

lint/suspicious/noExplicitAny

Unexpected any. Specify a different type.
}
Comment thread
mpicciolli marked this conversation as resolved.

export interface MockMcpServer {
/** The object to pass where an `McpServer` is expected. */
server: McpServer;
/** Vitest spy behind `server.registerTool`, for call assertions. */
registerTool: MockInstance<McpServer["registerTool"]>;
/** Every tool registered so far, in registration order. */
tools: RegisteredTool[];
/** Look up a registered tool by its name. */
getTool(name: string): RegisteredTool | undefined;
/** Invoke a registered tool's callback by name. */
callTool(
name: string,
args: Record<string, unknown>,
): ReturnType<ToolCallback<any>>;

Check warning on line 27 in test/mocks/mock-mcp-server.ts

View workflow job for this annotation

GitHub Actions / test (24.x)

lint/suspicious/noExplicitAny

Unexpected any. Specify a different type.

Check warning on line 27 in test/mocks/mock-mcp-server.ts

View workflow job for this annotation

GitHub Actions / test (22.x)

lint/suspicious/noExplicitAny

Unexpected any. Specify a different type.
}
Comment thread
mpicciolli marked this conversation as resolved.

/**
* Build a fake `McpServer` for unit tests. Only `registerTool` is implemented;
* it records each registration so tests can inspect the config or invoke the
* tool callback directly.
*
* @example
* const mcp = createMockMcpServer();
* registerGetTableSchema(mcp.server);
* const result = await mcp.callTool("pcm_get_table_schema", { savePath, tableName });
*/
export function createMockMcpServer(): MockMcpServer {
const tools: RegisteredTool[] = [];

// A real `McpServer`, so no cast is needed to satisfy the tool
// registration functions. Only `registerTool` is exercised; spying on it
// records each registration and suppresses the real side effects.
const server = new McpServer({ name: "mock", version: "0.0.0" });

const registerTool = vi
.spyOn(server, "registerTool")
.mockImplementation((name, config, callback) => {
tools.push({
name,
config,
callback: callback as ToolCallback<any>,

Check warning on line 54 in test/mocks/mock-mcp-server.ts

View workflow job for this annotation

GitHub Actions / test (24.x)

lint/suspicious/noExplicitAny

Unexpected any. Specify a different type.

Check warning on line 54 in test/mocks/mock-mcp-server.ts

View workflow job for this annotation

GitHub Actions / test (22.x)

lint/suspicious/noExplicitAny

Unexpected any. Specify a different type.
});
return {} as ReturnType<McpServer["registerTool"]>;
});
Comment thread
mpicciolli marked this conversation as resolved.

const getTool = (name: string) => tools.find((t) => t.name === name);

const callTool = (name: string, args: Record<string, unknown>) => {
const tool = getTool(name);
if (!tool) {
throw new Error(
`Tool "${name}" was not registered. Registered: ${
tools.map((t) => t.name).join(", ") || "(none)"
}`,
);
}
// The second arg is the RequestHandlerExtra, unused by these tools.
return tool.callback(args as never, {} as any);

Check warning on line 71 in test/mocks/mock-mcp-server.ts

View workflow job for this annotation

GitHub Actions / test (24.x)

lint/suspicious/noExplicitAny

Unexpected any. Specify a different type.

Check warning on line 71 in test/mocks/mock-mcp-server.ts

View workflow job for this annotation

GitHub Actions / test (22.x)

lint/suspicious/noExplicitAny

Unexpected any. Specify a different type.
};

return { server, registerTool, tools, getTool, callTool };
}
8 changes: 5 additions & 3 deletions test/save-db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { withSaveDb } from "../src/save-db";
vi.mock("cdb-converter", () => ({ cdbToSql: vi.fn() }));
vi.mock("sql.js", () => ({ default: vi.fn(() => ({})) }));

const cdbToSqlMock = cdbToSql as unknown as Mock;
const cdbToSqlMock = cdbToSql as Mock;

let dir: string;
let savePath: string;
Expand Down Expand Up @@ -48,7 +48,9 @@ describe("withSaveDb", () => {
});

it("passes the open database and save metadata to the callback", async () => {
const fn = vi.fn(() => ({ ok: true }));
const fn = vi.fn((_db: unknown, _save: { name: string; path: string }) => ({
ok: true,
}));

await withSaveDb(savePath, fn);

Expand Down Expand Up @@ -76,7 +78,7 @@ describe("withSaveDb", () => {
});

expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("boom");
expect((result.content[0] as { text: string }).text).toContain("boom");
expect(fakeDb.close).toHaveBeenCalledTimes(1);
});

Expand Down
Loading
Loading