refacto & cleanup of toplevel functions, use @p42/p42Modules, test microframework

This commit is contained in:
STEINNI
2026-06-14 17:39:52 +00:00
parent c399f9ddb4
commit 7435d96135
11 changed files with 427 additions and 108 deletions
+196
View File
@@ -0,0 +1,196 @@
import { MySQLClient } from '@p42/p42modules'
import { SimRepository } from '../../SimMaestro/simRepository.js'
function agentHashKey(template, agentId) {
return(template.replace(/\[UID\]/g, agentId))
}
function parseJsonField(raw) {
if(raw == null) return(null)
if(typeof(raw) === 'object') return(raw)
try { return(JSON.parse(raw)) }
catch { return(null) }
}
function vectorsEqual(a, b) {
for(const axis of ['x', 'y', 'z']) {
if(a[axis] !== b[axis]) return(false)
}
return(true)
}
async function findSimulationFixture(ctx) {
const { guiDatabase, simDatabase } = ctx.databases
const rows = await MySQLClient.poolExecute(ctx.db, `
SELECT u.usr_uuid AS user_uuid,
BIN_TO_UUID(s.sim_uuid) AS simulation_uuid,
BIN_TO_UUID(s.sim_root_kf_uuid) AS keyframe_id
FROM \`${guiDatabase}\`.users u
INNER JOIN \`${guiDatabase}\`.simowners o ON o.own_usr_id = u.usr_id
INNER JOIN \`${simDatabase}\`.simulations s ON o.own_sim_uuid = s.sim_uuid
INNER JOIN \`${simDatabase}\`.edited_kf_store ekfs ON ekfs.ekfs_ekf_uuid = s.sim_root_kf_uuid
GROUP BY u.usr_uuid, s.sim_uuid, s.sim_root_kf_uuid
HAVING COUNT(ekfs.ekfs_agent_id) > 0
LIMIT 1
`)
if(!rows.length) {
throw(new Error(
'No simulation fixture found in MySQL (need user-owned sim with root keyframe agents). '
+ 'Pass --userUuid, --simulationUuid, --keyframeId explicitly, or use --guiDatabase / --simDatabase for a test DB.'
))
}
return({
userUuid: rows[0].user_uuid,
simulationUuid: rows[0].simulation_uuid,
keyframeId: rows[0].keyframe_id,
})
}
function waitForLifecycleEvent(ctx, eventType, timeoutMs) {
return(new Promise((resolve, reject) => {
const lifecyclePattern = ctx.config.maestro.lifecycle.arenaChannel
const timer = setTimeout(() => {
ctx.offArenaMessage(handler)
reject(new Error(`Timeout waiting for ${eventType} on ${lifecyclePattern} (${timeoutMs}ms)`))
}, timeoutMs)
const handler = (msg, chan) => {
if(!ctx.arenaCnx.matchesChan(chan, lifecyclePattern)) return
if(msg.eventType !== eventType) return
clearTimeout(timer)
ctx.offArenaMessage(handler)
resolve(msg)
}
ctx.onArenaMessage(handler)
}))
}
export function configureYargs(yargsBuilder) {
return(yargsBuilder.options({
userUuid: {
describe: 'User UUID to send STARTSIMULATION as (auto-discovered if omitted)',
type: 'string',
},
simulationUuid: {
describe: 'Simulation UUID (auto-discovered if omitted)',
type: 'string',
},
keyframeId: {
describe: 'Root keyframe UUID (auto-discovered if omitted)',
type: 'string',
},
timeout: {
describe: 'Milliseconds to wait for onYourMarks',
default: 15000,
type: 'number',
},
}))
}
export async function run(ctx) {
const { log, argv, config, systemCnx, arenaCnx } = ctx
const arenaStorage = config.gps?.arenaStorage ?? {
agentHashKey: 'arena:agents:[UID]',
agentsIndexKey: 'arena:agents',
}
log('action', 'Resolving simulation fixture from MySQL...')
let userUuid = argv.userUuid
let simulationUuid = argv.simulationUuid
let keyframeId = argv.keyframeId
if(!userUuid || !simulationUuid || !keyframeId) {
const fixture = await findSimulationFixture(ctx)
userUuid = userUuid ?? fixture.userUuid
simulationUuid = simulationUuid ?? fixture.simulationUuid
keyframeId = keyframeId ?? fixture.keyframeId
}
log('action', `User: ${userUuid}`)
log('action', `Simulation: ${simulationUuid}`)
log('action', `Keyframe: ${keyframeId}`)
const simRepo = new SimRepository(ctx.db, ctx.databases, false)
const access = await simRepo.validateSimulationAccess(userUuid, simulationUuid, keyframeId)
if(!access.ok) throw(new Error(`Simulation access check failed: ${access.err}`))
const agentsResult = await simRepo.loadKeyframeAgents(keyframeId)
if(!agentsResult.ok) throw(new Error(`Failed to load keyframe agents: ${agentsResult.err}`))
if(!agentsResult.agents.length) throw(new Error('Keyframe has no agents with valid GPS values'))
log('success', `Loaded ${agentsResult.agents.length} agent(s) from MySQL`)
const expectedById = new Map(agentsResult.agents.map(a => [a.id, a]))
const lifecycleWait = waitForLifecycleEvent(ctx, 'onYourMarks', argv.timeout)
const reqid = `maestro1-${Date.now()}`
const actionsChan = config.maestro.maestroActionsChannel
log('action', `Publishing STARTSIMULATION on ${actionsChan} (reqid=${reqid})...`)
await systemCnx.redisPublish(actionsChan, {
action: 'STARTSIMULATION',
reqid,
sender: userUuid,
roles: ['*'],
payload: {
simulationUuid,
keyframeId,
infraId: null,
},
})
log('action', `Waiting for onYourMarks on ${config.maestro.lifecycle.arenaChannel}...`)
const lifecycleMsg = await lifecycleWait
const payload = lifecycleMsg.payload ?? {}
if(payload.simulationId !== simulationUuid) {
throw(new Error(`onYourMarks simulationId mismatch: expected ${simulationUuid}, got ${payload.simulationId}`))
}
log('success', `Received onYourMarks for simulationId=${payload.simulationId}`)
log('action', 'Reading arena store...')
const indexIds = await arenaCnx.redisSmembers(arenaStorage.agentsIndexKey)
const expectedIds = [...expectedById.keys()].sort()
const actualIds = [...indexIds].sort()
if(actualIds.length !== expectedIds.length) {
throw(new Error(`Agent index count mismatch: expected ${expectedIds.length}, got ${actualIds.length}`))
}
for(let i = 0; i < expectedIds.length; i++) {
if(actualIds[i] !== expectedIds[i]) {
throw(new Error(`Agent index mismatch at ${i}: expected ${expectedIds[i]}, got ${actualIds[i]}`))
}
}
log('success', `Arena agents index contains ${actualIds.length} agent(s)`)
for(const agentId of expectedIds) {
const key = agentHashKey(arenaStorage.agentHashKey, agentId)
const hash = await arenaCnx.redisHgetall(key)
const expected = expectedById.get(agentId)
const position = parseJsonField(hash.position)
const vector = parseJsonField(hash.vector)
if(!position || !vector) {
throw(new Error(`Agent ${agentId}: missing position or vector in arena hash ${key}`))
}
if(!vectorsEqual(position, expected.position)) {
throw(new Error(`Agent ${agentId}: position mismatch (MySQL vs arena store)`))
}
if(!vectorsEqual(vector, expected.vector)) {
throw(new Error(`Agent ${agentId}: vector mismatch (MySQL vs arena store)`))
}
log('success', `Agent ${agentId}: position and vector match MySQL`)
}
log('success', 'Arena store seeded correctly from MySQL (Maestro will stall waiting for readyToStart without GPS)')
}
+179
View File
@@ -0,0 +1,179 @@
import yargs from 'yargs/yargs'
import { hideBin } from 'yargs/helpers'
import { pathToFileURL } from 'url'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import { RedisConnexion } from '../redisConnexion.js'
import { configHelper } from '../configHelper.js'
import { MySQLClient } from '@p42/p42modules'
const __dirname = dirname(fileURLToPath(import.meta.url))
const LOG_COLORS = {
action: '\x1b[37m',
success: '\x1b[32m',
error: '\x1b[31m',
reset: '\x1b[0m',
}
function createTestMesh(handlers) {
return({
dispatchMessage(_cnx, msg, chan) {
for(const handler of handlers) handler(msg, chan)
},
})
}
function buildBaseParser() {
return(yargs(hideBin(process.argv))
.usage('$0 <module> [options]')
.command('$0 <module>', 'Run a GodDaemons test module', y => y
.positional('module', {
describe: 'Test module name (e.g. maestro1)',
type: 'string',
})
)
.options({
config: {
alias: 'c',
describe: 'Path to config.json',
default: join(__dirname, '..', 'config.json'),
type: 'string',
},
guiDatabase: {
describe: 'Override mysql.guiDatabase for this run',
type: 'string',
},
simDatabase: {
describe: 'Override mysql.simDatabase for this run',
type: 'string',
},
})
.help()
.version(false)
)
}
function meshRedisConn(mesh, meshName, rootConfig, meshModule) {
const { redis, ...meshConfig } = mesh
return(redis.map(cfg =>
new RedisConnexion({
debug: false,
config: { ...cfg, ...meshConfig, ...rootConfig },
redisId: cfg.redisId,
meshName,
meshModule,
})
))
}
async function loadTestModule(moduleName) {
const modulePath = join(__dirname, 'modules', `${moduleName}.js`)
try {
return(await import(pathToFileURL(modulePath).href))
} catch(err) {
if(err.code === 'ERR_MODULE_NOT_FOUND') {
throw(new Error(`Test module not found: ${moduleName} (expected ${modulePath})`))
}
throw(err)
}
}
async function main() {
const preArgv = buildBaseParser().parseSync()
const moduleName = preArgv.module ?? preArgv._[0]
if(!moduleName) {
buildBaseParser().showHelp()
process.exit(1)
}
const testModule = await loadTestModule(moduleName)
let parser = buildBaseParser()
if(typeof(testModule.configureYargs) === 'function') {
parser = testModule.configureYargs(parser)
}
const argv = parser.parseSync()
const resolvedModule = argv.module ?? argv._[0]
const cfgh = new configHelper({ localfile: argv.config })
await cfgh.fetchConfigFile()
const config = cfgh.config
if(argv.guiDatabase) config.mysql.guiDatabase = argv.guiDatabase
if(argv.simDatabase) config.mysql.simDatabase = argv.simDatabase
const arenaHandlers = new Set()
const testMesh = createTestMesh(arenaHandlers)
const systemConns = meshRedisConn(config.systemMesh, 'system', config, null)
const arenaConns = meshRedisConn(config.arenaMesh, 'arena', config, testMesh)
const systemCnx = systemConns.find(c => c.redisConfig.role === 'primary') ?? systemConns[0]
const arenaCnx = arenaConns.find(c => c.redisConfig.role === 'primary') ?? arenaConns[0]
const log = (type, message) => {
const color = LOG_COLORS[type] ?? LOG_COLORS.action
console.log(`${color}${message}${LOG_COLORS.reset}`)
}
log('action', `Loading test module: ${resolvedModule}`)
log('action', `Config: ${argv.config}`)
const databases = MySQLClient.resolveDatabases(config.mysql)
if(argv.guiDatabase || argv.simDatabase) {
log('action', `MySQL databases: gui=${databases.guiDatabase}, sim=${databases.simDatabase}`)
}
log('action', 'Connecting to Redis (system)...')
await systemCnx.redisLogin()
log('success', `System Redis connected (${systemCnx.redisConfig.host}:${systemCnx.redisConfig.port})`)
log('action', 'Connecting to Redis (arena)...')
await arenaCnx.redisLogin()
await arenaCnx.redisChansStart()
log('success', `Arena Redis connected (${arenaCnx.redisConfig.host}:${arenaCnx.redisConfig.port})`)
log('action', 'Connecting to MySQL...')
const db = await MySQLClient.createPool(config.mysql)
log('success', 'MySQL pool ready')
const ctx = {
argv,
config,
databases,
db,
systemCnx,
arenaCnx,
log,
onArenaMessage(handler) {
arenaHandlers.add(handler)
},
offArenaMessage(handler) {
arenaHandlers.delete(handler)
},
}
let exitCode = 0
try {
if(typeof(testModule.run) !== 'function') {
throw(new Error(`Test module ${resolvedModule} must export a run(ctx) function`))
}
await testModule.run(ctx)
log('success', `Test module ${resolvedModule} finished`)
} catch(err) {
log('error', err.message ?? String(err))
exitCode = 1
} finally {
await db.end()
await systemCnx.redisClient.quit()
await arenaCnx.redisClient.quit()
if(arenaCnx.redisSubscriber) await arenaCnx.redisSubscriber.quit()
}
process.exit(exitCode)
}
main().catch(err => {
console.error(`${LOG_COLORS.error}${err.message ?? err}${LOG_COLORS.reset}`)
process.exit(1)
})