const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); require('dotenv').config({ quiet: true }); const { PrismaClient } = require('@prisma/client'); const url = new URL(process.env.DATABASE_URL); url.hostname = '192.168.0.20'; url.port = '15437'; url.pathname = '/lms'; url.searchParams.set('connect_timeout', '5'); const db = new PrismaClient({ datasources: { db: { url: url.toString() } } }); const apply = process.argv.includes('--apply'); const slug = 'futuredrive-autonomy-basic'; const localImage = 'future-nav2-gazebo:humble'; const remoteImage = 'future-nav2:humble-jetson_orin'; const names = ['NAV2-Control', 'NAV2-Localization', 'NAV2-Map', 'NAV2-Perception', 'NAV2-Perception3D', 'NAV2-Planning', 'NAV2-Gazebo', 'NAV2-SLAM', 'NAV2-SLAM3D', 'NAV2-MKMini', 'NAV2-Rviz2']; const steps = [ { title: 'SLAM', detail: '원거리 MK-mini 플랫폼에 연결하여 모듈 소스와 파라미터를 수정하고, 센서 토픽을 확인하며 SLAM 맵을 생성·저장합니다. 로컬 MK-mini 시뮬레이션에서 먼저 검증합니다.' }, { title: '자율주행', detail: '저장한 맵으로 위치추정·경로 계획·제어 모듈을 구성하고 목표 지점 주행을 검증합니다. 원격 코드 편집과 빌드, 실행 로그 분석 및 파라미터 튜닝을 실습합니다.' }, ]; function parameters(module, remote) { const sim = `sim_mode:=${!remote}`; switch (module.name) { case 'NAV2-Gazebo': return ['gui:=false', 'platform:=mkmini']; case 'NAV2-MKMini': return [sim, 'platform:=mkmini', 'robot:=mkmini', 'level:=2d', ...(!remote ? ['simulator:=gazebo'] : [])]; case 'NAV2-Control': return [sim, 'robot:=mkmini', 'level:=2d']; case 'NAV2-Perception': return [sim]; case 'NAV2-Perception3D': case 'NAV2-SLAM3D': return [sim, 'robot:=mkmini']; case 'NAV2-Localization': return [sim, 'robot:=mkmini', 'level:=2d', 'localization_mode:=amcl']; case 'NAV2-SLAM': case 'NAV2-Planning': case 'NAV2-Map': return [sim, 'robot:=mkmini', 'level:=2d']; default: return module.defaultParameters; } } async function snapshot(client) { return client.course.findUniqueOrThrow({ where: { slug }, include: { weeks: { orderBy: { week: 'asc' }, include: { modules: { orderBy: { sortOrder: 'asc' } }, topics: { orderBy: { sortOrder: 'asc' } }, practices: { orderBy: { sortOrder: 'asc' } }, references: true, } }, environmentProfiles: { orderBy: [{ week: 'asc' }, { platformKey: 'asc' }] }, weekMaps: true, } }); } async function main() { const before = await snapshot(db); assert.equal(before.weeks.length, 2, 'Expected only weeks 1 and 2; refusing to overwrite'); const source = before.weeks.find(w => w.week === 1); assert(source); const selected = names.map(name => { const matches = source.modules.filter(m => m.name === name); assert.equal(matches.length, 1, `Missing or ambiguous module: ${name}`); return matches[0]; }); const profiles = before.environmentProfiles.filter(p => p.week === 1); assert.equal(profiles.length, 2); assert(profiles.some(p => p.config.target === 'local')); assert(profiles.some(p => p.config.target === 'remote-jetson')); if (apply) { const backup = path.resolve('backups', `base-before-week3-${Date.now()}.json`); fs.mkdirSync(path.dirname(backup), { recursive: true }); fs.writeFileSync(backup, JSON.stringify(before, null, 2), { mode: 0o600, flag: 'wx' }); console.log('Backup:', backup); } let summary; try { await db.$transaction(async tx => { assert.deepEqual(await snapshot(tx), before, 'Source changed during preparation'); const week = await tx.courseWeek.create({ data: { courseId: before.id, week: 3, title: '원격 개발', tags: ['MK-mini', '진행예정'], progress: 0, state: 'locked', duration: '1주', github: source.github, overview: '원거리 MK-mini 플랫폼을 연결하고 원격으로 코드를 편집·빌드·실행합니다. 로컬 MK-mini 시뮬레이션과 실차에서 SLAM 및 자율주행을 검증합니다.', deliverable: 'SLAM 저장 맵, 자율주행 검증 결과, 원격 개발 소스 변경 내역 및 실행 로그를 제출합니다.', requiresSourceSubmission: true, topics: { create: steps.map((s, i) => ({ ...s, sortOrder: i + 1 })) }, practices: { create: steps.map((s, i) => ({ title: s.title, description: s.detail, sortOrder: i + 1 })) }, } }); const mapping = new Map(); const created = []; for (const [i, m] of selected.entries()) { const { id, weekId, createdAt, updatedAt, ...data } = m; const next = await tx.courseWeekModule.create({ data: { ...data, weekId: week.id, sortOrder: i + 1, dockerImage: localImage, defaultParameters: parameters(m, false), } }); mapping.set(id, next.id); created.push(next); } const newProfiles = []; for (const p of profiles) { const remote = p.config.target === 'remote-jetson'; const config = structuredClone(p.config); config.moduleSet.modules = p.config.moduleSet.modules.flatMap(entry => { if (!mapping.has(entry.id)) return []; const m = selected.find(m => m.id === entry.id); return [{ ...entry, id: mapping.get(entry.id), dockerImage: remote ? remoteImage : localImage, defaultParameters: parameters(m, remote) }]; }); config.moduleSet.includeUnlisted = false; assert.equal(config.moduleSet.modules.length, 9); assert(config.moduleSet.modules.every(m => created.some(c => c.id === m.id))); newProfiles.push(await tx.courseEnvironmentProfile.create({ data: { courseId: before.id, week: 3, platformKey: p.platformKey, profileType: p.profileType, name: p.name, enabled: p.enabled, config, } })); } for (const m of before.weekMaps.filter(m => m.week === 1)) { await tx.courseWeekMap.create({ data: { courseId: before.id, week: 3, mapKey: m.mapKey, sortOrder: m.sortOrder } }); } const after = await snapshot(tx); assert.deepEqual(after.weeks.filter(w => w.week !== 3), before.weeks); assert.deepEqual(after.environmentProfiles.filter(p => p.week !== 3), before.environmentProfiles); assert.equal(after.weeks.find(w => w.week === 3).topics.length, 2); assert(!created.some(m => /yahboom|turtlebot/i.test(JSON.stringify(m)))); summary = { applied: apply, course: before.title, weekId: week.id, title: week.title, steps: steps.map(s => s.title), modules: created.map(m => m.name), environments: newProfiles.map(p => ({ target: p.config.target, modules: p.config.moduleSet.modules.map(m => ({ name: created.find(c => c.id === m.id).name, image: m.dockerImage, parameters: m.defaultParameters, })) })) }; if (!apply) throw new Error('DRY_RUN_ROLLBACK'); }, { isolationLevel: 'Serializable', timeout: 30000 }); } catch (e) { if (e.message !== 'DRY_RUN_ROLLBACK') throw e; } console.log(JSON.stringify(summary, null, 2)); } main().catch(e => { console.error(e.message); process.exitCode = 1; }).finally(() => db.$disconnect());