Skip to content

Commit d20d54a

Browse files
committed
fix(image-generator): make the package actually work standalone
1 parent 7ffe46c commit d20d54a

5 files changed

Lines changed: 54 additions & 36 deletions

File tree

image-generator/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"name": "image-generator",
33
"version": "0.0.1",
44
"private": true,
5+
"packageManager": "pnpm@9.15.9",
56
"description": "Standalone microservice that renders deterministic pet NFT art (SVG) and ERC-721 metadata from a pet's on-chain DNA.",
67
"license": "SEE LICENSE IN ../LICENSE",
78
"type": "module",

image-generator/src/digitPair.test.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@
1111
* `contracts/test-vectors/battle.json`. Agreeing with it makes this port correct
1212
* transitively, without importing anything at build time.
1313
*
14-
* Skipped when the monorepo is absent, so the service still tests standalone.
14+
* Skipped when the monorepo is absent, so the service still tests standalone. The
15+
* specifier is assembled at runtime rather than written as a literal: tsc resolves
16+
* a literal dynamic import, so a static path would make `pnpm typecheck` fail
17+
* wherever the monorepo is not checked out, which is exactly the isolation this
18+
* package is meant to keep.
1519
*/
1620

1721
import { existsSync } from 'node:fs';
@@ -21,13 +25,22 @@ import { digitPair } from './traits.js';
2125

2226
const SHARED_DNA = join('..', 'shared', 'src', 'utils', 'combat', 'dna.ts');
2327

28+
interface DnaPort {
29+
digitPair: (dna: bigint, pairIdx: number) => bigint;
30+
}
31+
32+
/** Resolved to an absolute file URL against this file, so the path is right at
33+
* runtime while staying a plain string to tsc; see the note above. */
34+
const loadSharedPort = async (): Promise<DnaPort> => {
35+
const specifier = new URL('../../shared/src/utils/combat/dna.ts', import.meta.url).href;
36+
return (await import(/* @vite-ignore */ specifier)) as DnaPort;
37+
};
38+
2439
const describeIfPresent = existsSync(SHARED_DNA) ? describe : describe.skip;
2540

2641
describeIfPresent('digitPair vs the golden-vector-checked port', () => {
2742
it('agrees across every pair index, for DNA of every shape', async () => {
28-
const shared = (await import('../../shared/src/utils/combat/dna.js')) as {
29-
digitPair: (dna: bigint, pairIdx: number) => bigint;
30-
};
43+
const shared = await loadSharedPort();
3144

3245
const samples = [
3346
0n,
@@ -51,9 +64,7 @@ describeIfPresent('digitPair vs the golden-vector-checked port', () => {
5164
});
5265

5366
it('agrees across a sweep, not just hand-picked values', async () => {
54-
const shared = (await import('../../shared/src/utils/combat/dna.js')) as {
55-
digitPair: (dna: bigint, pairIdx: number) => bigint;
56-
};
67+
const shared = await loadSharedPort();
5768

5869
for (let i = 0n; i < 500n; i++) {
5970
const dna = (i * 7_919_000_000_037n) % 10_000_000_000_000_000n;

image-generator/src/solanaLayout.test.ts

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -51,61 +51,71 @@ const ourFields = FIELD_SIZES.slice(1).map(([name, bytes]) => [name, bytes] as [
5151

5252
const describeIf = (path: string) => (existsSync(path) ? describe : describe.skip);
5353

54-
describeIf(IDL_PATH)('PetAccount layout vs the Anchor IDL', () => {
54+
// Read inside the tests, never in a describe body: vitest evaluates the body of a
55+
// skipped describe, so a top-level readFileSync throws where the file is absent
56+
// and the skip never gets a chance to apply.
57+
const readIdl = () => {
5558
const idl = JSON.parse(readFileSync(IDL_PATH, 'utf8')) as {
5659
accounts?: { name: string; discriminator: number[] }[];
5760
types?: { name: string; type: { fields: { name: string; type: IdlType }[] } }[];
5861
};
5962
const fields = idl.types?.find((t) => t.name === 'PetAccount')?.type.fields ?? [];
60-
const idlLayout = fields.map((f) => [f.name, widthOf(f.type)] as [string, number]);
63+
return { idl, layout: fields.map((f) => [f.name, widthOf(f.type)] as [string, number]) };
64+
};
65+
66+
describeIf(IDL_PATH)('PetAccount layout vs the Anchor IDL', () => {
6167

6268
// Guards the guard: an empty layout would agree with anything.
6369
it('found the account in the IDL', () => {
64-
expect(idlLayout.length).toBeGreaterThan(20);
70+
expect(readIdl().layout.length).toBeGreaterThan(20);
6571
});
6672

6773
it('has the same fields, in the same order, at the same widths', () => {
68-
expect(ourFields).toEqual(idlLayout);
74+
expect(ourFields).toEqual(readIdl().layout);
6975
});
7076

7177
it('totals the same account size once the discriminator is added', () => {
72-
const total = idlLayout.reduce((sum, [, bytes]) => sum + bytes, 0);
78+
const total = readIdl().layout.reduce((sum, [, bytes]) => sum + bytes, 0);
7379
expect(PET_ACCOUNT_SPACE).toBe(total + 8);
7480
});
7581

7682
// Computed as sha256("account:PetAccount")[0..8] rather than read from
7783
// anywhere. If that convention were wrong, every real account would fail the
7884
// discriminator check and Solana would never serve a single pet.
7985
it('computes the discriminator Anchor generated', () => {
80-
const expected = idl.accounts?.find((a) => a.name === 'PetAccount')?.discriminator;
86+
const expected = readIdl().idl.accounts?.find((a) => a.name === 'PetAccount')?.discriminator;
8187
expect(expected).toBeDefined();
8288
expect([...petAccountDiscriminator()]).toEqual(expected);
8389
});
8490
});
8591

86-
describeIf(PET_RS)('PetAccount layout vs pet.rs', () => {
92+
/** Same reason as readIdl: never read at describe level. */
93+
const readRustLayout = (): [string, number][] => {
8794
const toCamel = (name: string): string =>
8895
name.replace(/^_+/, '').replace(/_([a-z])/g, (_full, c: string) => c.toUpperCase());
8996

9097
const source = readFileSync(PET_RS, 'utf8');
9198
const body = source.slice(source.indexOf('pub struct PetAccount {'), source.indexOf('impl PetAccount'));
9299

93-
const rustLayout: [string, number][] = [];
100+
const layout: [string, number][] = [];
94101
for (const line of body.split('\n')) {
95102
const match = /^\s*pub\s+(\w+):\s*([^,]+),/.exec(line);
96103
if (!match) continue;
97104
const type = match[2]!.trim().replace('PetAccount::MAX_NAME_LEN', '32');
98105
const array = /^\[u8;\s*(\d+)\]$/.exec(type);
99-
rustLayout.push([toCamel(match[1]!), array ? Number(array[1]) : widthOf(type)]);
106+
layout.push([toCamel(match[1]!), array ? Number(array[1]) : widthOf(type)]);
100107
}
108+
return layout;
109+
};
101110

111+
describeIf(PET_RS)('PetAccount layout vs pet.rs', () => {
102112
it('found the struct in the source', () => {
103-
expect(rustLayout.length).toBeGreaterThan(20);
113+
expect(readRustLayout().length).toBeGreaterThan(20);
104114
});
105115

106116
// Disagreement here means the checked-in IDL no longer matches the program
107117
// source, so this service would decode for a program that is not deployed.
108118
it('matches the current Rust struct too, so the IDL is not stale', () => {
109-
expect(ourFields).toEqual(rustLayout);
119+
expect(ourFields).toEqual(readRustLayout());
110120
});
111121
});

image-generator/src/traitAlignment.test.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,37 +43,39 @@ const BODY_FOR_SKILL: readonly [skill: string, body: string][] = [
4343
['Bloodlust', 'Fanged'],
4444
];
4545

46-
describeIf(SKILLS)('body silhouettes vs the skill archetypes', () => {
47-
const source = readFileSync(SKILLS, 'utf8');
48-
const skills = [...source.matchAll(/name:\s*'([A-Za-z]+)'/g)].map((m) => m[1]!);
46+
// Read inside the tests, never in a describe body: vitest evaluates the body of a
47+
// skipped describe, so a top-level read throws where the monorepo is absent and
48+
// the skip never gets a chance to apply.
49+
const readSkills = (): string[] =>
50+
[...readFileSync(SKILLS, 'utf8').matchAll(/name:\s*'([A-Za-z]+)'/g)].map((m) => m[1]!);
51+
52+
const readElements = () => /const elements = \[([^\]]+)\]/.exec(readFileSync(PET_CARD, 'utf8'));
4953

54+
describeIf(SKILLS)('body silhouettes vs the skill archetypes', () => {
5055
it('found the archetype list', () => {
51-
expect(skills.length).toBeGreaterThan(4);
56+
expect(readSkills().length).toBeGreaterThan(4);
5257
});
5358

5459
it('has one silhouette per archetype', () => {
55-
expect(BODY_NAMES).toHaveLength(skills.length);
60+
expect(BODY_NAMES).toHaveLength(readSkills().length);
5661
});
5762

5863
// Both are indexed by speciesId % 8, so position is the whole contract.
5964
it('pairs each silhouette with the archetype at the same index', () => {
60-
expect(skills).toEqual(BODY_FOR_SKILL.map(([skill]) => skill));
65+
expect(readSkills()).toEqual(BODY_FOR_SKILL.map(([skill]) => skill));
6166
expect([...BODY_NAMES]).toEqual(BODY_FOR_SKILL.map(([, body]) => body));
6267
});
6368
});
6469

6570
describeIf(PET_CARD)('element names vs the game element wheel', () => {
66-
const source = readFileSync(PET_CARD, 'utf8');
67-
const match = /const elements = \[([^\]]+)\]/.exec(source);
68-
6971
it('found the element list', () => {
70-
expect(match).not.toBeNull();
72+
expect(readElements()).not.toBeNull();
7173
});
7274

7375
// Order is the palette: element 1 must mean the same thing in both places, or
7476
// a pet's metadata would name one element while the app names another.
7577
it('is the same list in the same order', () => {
76-
const theirs = match![1]!.split(',').map((s) => s.trim().replace(/'/g, ''));
78+
const theirs = readElements()![1]!.split(',').map((s) => s.trim().replace(/'/g, ''));
7779
const capitalised = theirs.map((name) => name[0]!.toUpperCase() + name.slice(1));
7880

7981
expect([...ELEMENT_NAMES]).toEqual(capitalised);

image-generator/tsconfig.test.json

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,7 @@
66
"extends": "./tsconfig.json",
77
"compilerOptions": {
88
"noEmit": true,
9-
"types": ["node"],
10-
// Widened past ./src because digitPair.test.ts deliberately imports a
11-
// sibling package's port to check this service's copy against it. Nothing
12-
// is emitted from this config, so rootDir only has to be wide enough to
13-
// contain the program. The build config keeps ./src, so production code
14-
// still cannot reach outside the package.
15-
"rootDir": ".."
9+
"types": ["node"]
1610
},
1711
"include": ["src/**/*"],
1812
"exclude": ["node_modules", "dist"]

0 commit comments

Comments
 (0)