266 lines
9.2 KiB
TypeScript
266 lines
9.2 KiB
TypeScript
import { connect, type Database } from '@tursodatabase/database';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
let database: Database;
|
|
|
|
const applySchema = async (target: Database) => {
|
|
const schema = await readFile(new URL('./schema.sql', import.meta.url), 'utf8');
|
|
for (const statement of schema
|
|
.split(';')
|
|
.map((value) => value.trim())
|
|
.filter(Boolean)) {
|
|
await target.exec(statement);
|
|
}
|
|
};
|
|
|
|
const insertSource = async (
|
|
target: Database,
|
|
id: string,
|
|
ownerId: string,
|
|
fingerprint: string,
|
|
) => {
|
|
await (
|
|
await target.prepare(
|
|
`INSERT INTO mechanic_authored_sources(
|
|
id,owner_type,owner_id,template,authored_contract_version,source_fingerprint,
|
|
talent_scope,talent_has_tier_ii,created_at,updated_at
|
|
) VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
|
)
|
|
).run(
|
|
id,
|
|
'talent',
|
|
ownerId,
|
|
'Gain {1}',
|
|
1,
|
|
fingerprint,
|
|
'standard',
|
|
1,
|
|
'2026-08-27T00:00:00.000Z',
|
|
'2026-08-27T00:00:00.000Z',
|
|
);
|
|
};
|
|
|
|
describe('mechanic persistence schema', () => {
|
|
beforeEach(async () => {
|
|
database = await connect(':memory:');
|
|
await database.exec('PRAGMA foreign_keys=ON');
|
|
await applySchema(database);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await database.close();
|
|
});
|
|
|
|
it('installs the complete schema with clean foreign keys', async () => {
|
|
const rows = await (
|
|
await database.prepare(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'mechanic_%' ORDER BY name",
|
|
)
|
|
).all();
|
|
|
|
expect(rows.map((row) => row.name)).toEqual([
|
|
'mechanic_authored_slots',
|
|
'mechanic_authored_sources',
|
|
'mechanic_authored_variants',
|
|
'mechanic_model_revision_clauses',
|
|
'mechanic_model_revision_outcomes',
|
|
'mechanic_model_revision_pools',
|
|
'mechanic_model_revisions',
|
|
'mechanic_model_selections',
|
|
'mechanic_public_variants',
|
|
]);
|
|
expect(await (await database.prepare('PRAGMA foreign_key_check')).all()).toEqual([]);
|
|
});
|
|
|
|
it('enforces owner metadata, ordered authored children, and source cascades', async () => {
|
|
const fingerprint = 'a'.repeat(64);
|
|
await insertSource(database, 'source-1', 'talent-1', fingerprint);
|
|
|
|
await expect(
|
|
insertSource(database, 'source-2', 'talent-1', fingerprint),
|
|
).rejects.toThrow();
|
|
await expect(
|
|
database.exec(
|
|
`INSERT INTO mechanic_authored_sources(
|
|
id,owner_type,owner_id,template,authored_contract_version,source_fingerprint,
|
|
specialization_rank_description,specialization_effect_id,specialization_effect_template,
|
|
created_at,updated_at
|
|
) VALUES('source-3','talent','talent-3','Gain',1,'${fingerprint}',
|
|
'rank','effect','template','now','now')`,
|
|
),
|
|
).rejects.toThrow();
|
|
|
|
await (
|
|
await database.prepare(
|
|
`INSERT INTO mechanic_authored_variants(source_id,variant_key,materialized,sort_order)
|
|
VALUES('source-1','I','Gain 1',0),('source-1','II','Gain 2',1)`,
|
|
)
|
|
).run();
|
|
await (
|
|
await database.prepare(
|
|
`INSERT INTO mechanic_authored_slots(
|
|
source_id,variant_key,slot_key,display_value,sort_order
|
|
) VALUES('source-1','I','1','1',0)`,
|
|
)
|
|
).run();
|
|
|
|
await expect(
|
|
database.exec(
|
|
`INSERT INTO mechanic_authored_slots(
|
|
source_id,variant_key,slot_key,display_value,sort_order
|
|
) VALUES('source-1','missing','1','1',0)`,
|
|
),
|
|
).rejects.toThrow();
|
|
await expect(
|
|
database.exec(
|
|
`INSERT INTO mechanic_authored_slots(
|
|
source_id,variant_key,slot_key,display_value,sort_order
|
|
) VALUES('source-1','II','1','10% Weapon Damage',0)`,
|
|
),
|
|
).rejects.toThrow();
|
|
|
|
await database.exec("DELETE FROM mechanic_authored_sources WHERE id='source-1'");
|
|
for (const table of [
|
|
'mechanic_authored_variants',
|
|
'mechanic_authored_slots',
|
|
]) {
|
|
expect(
|
|
await (
|
|
await database.prepare(`SELECT COUNT(*) AS count FROM ${table}`)
|
|
).get(),
|
|
).toMatchObject({ count: 0 });
|
|
}
|
|
});
|
|
|
|
it('allows deliberate duplicate graphs while proving selection ownership', async () => {
|
|
const sourceFingerprint = 'a'.repeat(64);
|
|
const programHash = 'b'.repeat(64);
|
|
await insertSource(database, 'source-1', 'talent-1', sourceFingerprint);
|
|
await (
|
|
await database.prepare(
|
|
`INSERT INTO mechanic_authored_variants(source_id,variant_key,materialized,sort_order)
|
|
VALUES('source-1','I','Gain 1',0)`,
|
|
)
|
|
).run();
|
|
await (
|
|
await database.prepare(
|
|
`INSERT INTO mechanic_model_revisions(
|
|
id,source_id,revision_number,source_fingerprint,mechanic_contract_version,
|
|
program_hash,simulation_readiness,scenario_inputs_json,source_variants_json,
|
|
program_name,origin,created_at
|
|
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
)
|
|
).run(
|
|
'revision-1',
|
|
'source-1',
|
|
1,
|
|
sourceFingerprint,
|
|
1,
|
|
programHash,
|
|
'ready',
|
|
'[]',
|
|
'["I"]',
|
|
'Model',
|
|
'manual',
|
|
'now',
|
|
);
|
|
await (
|
|
await database.prepare(
|
|
`INSERT INTO mechanic_model_revisions(
|
|
id,source_id,revision_number,source_fingerprint,mechanic_contract_version,
|
|
program_hash,simulation_readiness,scenario_inputs_json,source_variants_json,
|
|
program_name,origin,created_at
|
|
) SELECT 'revision-2',source_id,2,source_fingerprint,mechanic_contract_version,
|
|
program_hash,simulation_readiness,scenario_inputs_json,source_variants_json,
|
|
program_name,origin,created_at FROM mechanic_model_revisions WHERE id='revision-1'`,
|
|
)
|
|
).run();
|
|
|
|
await expect(
|
|
database.exec(
|
|
`INSERT INTO mechanic_model_revisions(
|
|
id,source_id,revision_number,source_fingerprint,mechanic_contract_version,
|
|
program_hash,simulation_readiness,scenario_inputs_json,source_variants_json,
|
|
program_name,origin,created_at
|
|
) SELECT 'revision-3',source_id,1,source_fingerprint,mechanic_contract_version,
|
|
program_hash,simulation_readiness,scenario_inputs_json,source_variants_json,
|
|
program_name,origin,created_at FROM mechanic_model_revisions WHERE id='revision-1'`,
|
|
),
|
|
).rejects.toThrow();
|
|
|
|
await (
|
|
await database.prepare(
|
|
`INSERT INTO mechanic_model_selections(source_id,revision_id,updated_at)
|
|
VALUES('source-1','revision-2','now')`,
|
|
)
|
|
).run();
|
|
await expect(
|
|
database.exec(
|
|
`INSERT INTO mechanic_model_selections(source_id,revision_id,updated_at)
|
|
VALUES('source-1','not-this-source','now')`,
|
|
),
|
|
).rejects.toThrow();
|
|
|
|
await (
|
|
await database.prepare(
|
|
`INSERT INTO mechanic_model_revision_pools(
|
|
id,revision_id,pool_key,label,state_kind,state_scope,initial_value,
|
|
minimum_value,maximum_value,best_case_value,duration_seconds,
|
|
duration_variants_json,sort_order
|
|
) VALUES('pool-1','revision-1','stacks','Stacks','stack','agent',0,0,5,5,NULL,NULL,0)`,
|
|
)
|
|
).run();
|
|
await (
|
|
await database.prepare(
|
|
`INSERT INTO mechanic_model_revision_clauses(
|
|
id,revision_id,clause_key,label,event_kind,recipe_key,conditions_json,
|
|
reducer_json,weight_expression_json,mutations_json,cooldown_seconds,
|
|
proc_chance_percent,requires_assigned_ability_active,sort_order
|
|
) VALUES('clause-1','revision-1','on-hit','On hit','weapon_hit',NULL,NULL,
|
|
'{"kind":"none"}',NULL,'[]',NULL,25,NULL,0)`,
|
|
)
|
|
).run();
|
|
await (
|
|
await database.prepare(
|
|
`INSERT INTO mechanic_model_revision_outcomes(
|
|
id,revision_id,clause_id,state_pool_id,effect_key,variant_key,label,
|
|
outcome_kind,effect_scope,value,value_expression_json,value_basis,
|
|
resource_kind,delivery_mode,delivery_duration_seconds,application_phase,
|
|
combat_timing,stat_attribute_id,per_stack,min_state_value,max_state_value,
|
|
targeting_json,probability_percent,sort_order
|
|
) VALUES('outcome-1','revision-1','clause-1','pool-1','mark','I','Mark',
|
|
'marker','self',1,NULL,'flat',NULL,'instant',NULL,'while_active','current',
|
|
NULL,0,NULL,NULL,NULL,NULL,0)`,
|
|
)
|
|
).run();
|
|
await (
|
|
await database.prepare(
|
|
`INSERT INTO mechanic_public_variants(
|
|
source_id,variant_key,selected_revision_id,current_source_fingerprint,
|
|
authored_contract_version,mechanic_contract_version,status,behavior_text,
|
|
display_source,updated_at
|
|
) VALUES('source-1','I','revision-1',?,1,1,'modeled','Modeled behavior','model','now')`,
|
|
)
|
|
).run(sourceFingerprint);
|
|
|
|
await database.exec("DELETE FROM mechanic_authored_sources WHERE id='source-1'");
|
|
for (const table of [
|
|
'mechanic_authored_variants',
|
|
'mechanic_authored_slots',
|
|
'mechanic_model_revisions',
|
|
'mechanic_model_revision_pools',
|
|
'mechanic_model_revision_clauses',
|
|
'mechanic_model_revision_outcomes',
|
|
'mechanic_model_selections',
|
|
'mechanic_public_variants',
|
|
]) {
|
|
expect(
|
|
await (
|
|
await database.prepare(`SELECT COUNT(*) AS count FROM ${table}`)
|
|
).get(),
|
|
).toMatchObject({ count: 0 });
|
|
}
|
|
});
|
|
});
|