feat: add configurable VDO.Ninja overview and floating window focus
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
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());
|
||||
@@ -0,0 +1,107 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
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 include = {
|
||||
weeks: { orderBy: { week: 'asc' }, include: {
|
||||
modules: { orderBy: { sortOrder: 'asc' } }, topics: { orderBy: { sortOrder: 'asc' } },
|
||||
practices: { orderBy: { sortOrder: 'asc' } }, references: { orderBy: { id: 'asc' } },
|
||||
} },
|
||||
environmentProfiles: { orderBy: { id: 'asc' } }, maps: { orderBy: { id: 'asc' } },
|
||||
weekMaps: { orderBy: { id: 'asc' } }, instructorCurriculumWeekOverrides: { orderBy: { id: 'asc' } },
|
||||
};
|
||||
const snapshot = client => client.course.findMany({ orderBy: { id: 'asc' }, include });
|
||||
function data(row) {
|
||||
const { id, createdAt, updatedAt, ...rest } = row;
|
||||
return rest;
|
||||
}
|
||||
async function main() {
|
||||
const before = await snapshot(db);
|
||||
const base = before.find(c => c.slug === 'futuredrive-autonomy-basic');
|
||||
assert(base);
|
||||
const source = base.weeks.find(w => w.week === 3);
|
||||
assert.equal(source?.title, '원격 개발');
|
||||
assert.equal(source.topics.length, 2);
|
||||
assert.equal(source.modules.length, 11);
|
||||
const profiles = base.environmentProfiles.filter(p => p.week === 3);
|
||||
assert.equal(profiles.length, 2);
|
||||
const targets = before.filter(c => c.id !== base.id
|
||||
&& c.slug !== 'autonomy-studio-lms-guide'
|
||||
&& !c.weeks.some(w => w.week === 3));
|
||||
for (const c of targets) {
|
||||
assert(!c.environmentProfiles.some(p => p.week === 3), `${c.title}: existing profile`);
|
||||
assert(!c.weekMaps.some(p => p.week === 3), `${c.title}: existing map assignments`);
|
||||
assert(!c.instructorCurriculumWeekOverrides.some(p => p.week === 3), `${c.title}: existing override`);
|
||||
}
|
||||
if (apply) {
|
||||
fs.mkdirSync('backups', { recursive: true });
|
||||
const file = `backups/courses-before-remote-week-${Date.now()}.json`;
|
||||
fs.writeFileSync(file, JSON.stringify(before, null, 2), { mode: 0o600, flag: 'wx' });
|
||||
console.log('Backup:', file);
|
||||
}
|
||||
const summary = [];
|
||||
try {
|
||||
await db.$transaction(async tx => {
|
||||
assert.deepEqual(await snapshot(tx), before);
|
||||
for (const c of targets) {
|
||||
const { modules, topics, practices, references, ...weekData } = data(source);
|
||||
const week = await tx.courseWeek.create({ data: { ...weekData, courseId: c.id } });
|
||||
for (const topic of topics) await tx.courseWeekTopic.create({ data: { ...data(topic), weekId: week.id } });
|
||||
for (const practice of practices) await tx.courseWeekPractice.create({ data: { ...data(practice), weekId: week.id } });
|
||||
for (const reference of references) await tx.courseWeekReference.create({ data: { ...data(reference), weekId: week.id } });
|
||||
const ids = new Map();
|
||||
for (const module of modules) {
|
||||
const created = await tx.courseWeekModule.create({ data: { ...data(module), weekId: week.id } });
|
||||
ids.set(module.id, created.id);
|
||||
}
|
||||
for (const profile of profiles) {
|
||||
const config = structuredClone(profile.config);
|
||||
config.moduleSet.modules = config.moduleSet.modules.map(m => {
|
||||
assert(ids.has(m.id), 'Cross-week module reference');
|
||||
return { ...m, id: ids.get(m.id) };
|
||||
});
|
||||
await tx.courseEnvironmentProfile.create({ data: { ...data(profile), courseId: c.id, config } });
|
||||
}
|
||||
for (const assignment of base.weekMaps.filter(m => m.week === 3)) {
|
||||
const systemMap = ['nav2-turtlebot3-world', 'nav2-turtlebot3-house'].includes(assignment.mapKey);
|
||||
if (!systemMap && !c.maps.some(m => m.key === assignment.mapKey)) {
|
||||
const map = base.maps.find(m => m.key === assignment.mapKey);
|
||||
assert(map, 'Missing source map');
|
||||
await tx.courseMap.create({ data: { ...data(map), courseId: c.id } });
|
||||
}
|
||||
await tx.courseWeekMap.create({ data: { ...data(assignment), courseId: c.id } });
|
||||
}
|
||||
summary.push({ course: c.title, week: 3, weekId: week.id, modules: ids.size });
|
||||
}
|
||||
const after = await snapshot(tx);
|
||||
for (const old of before) {
|
||||
const current = after.find(c => c.id === old.id);
|
||||
if (!targets.some(c => c.id === old.id)) { assert.deepEqual(current, old); continue; }
|
||||
assert.deepEqual(current.weeks.filter(w => w.week !== 3), old.weeks);
|
||||
assert.deepEqual(current.environmentProfiles.filter(p => p.week !== 3), old.environmentProfiles);
|
||||
assert.deepEqual(current.weekMaps.filter(m => m.week !== 3), old.weekMaps);
|
||||
assert.deepEqual(current.instructorCurriculumWeekOverrides, old.instructorCurriculumWeekOverrides);
|
||||
for (const map of old.maps) assert.deepEqual(current.maps.find(m => m.id === map.id), map);
|
||||
const added = current.weeks.find(w => w.week === 3);
|
||||
assert.equal(added.topics.length, 2);
|
||||
assert.equal(added.modules.length, 11);
|
||||
const envs = current.environmentProfiles.filter(p => p.week === 3);
|
||||
assert.equal(envs.length, 2);
|
||||
for (const p of envs) {
|
||||
assert.equal(p.config.moduleSet.modules.length, 9);
|
||||
assert(p.config.moduleSet.modules.every(m => added.modules.some(n => n.id === m.id)));
|
||||
}
|
||||
}
|
||||
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({ applied: apply, courses: summary }, null, 2));
|
||||
}
|
||||
main().catch(e => { console.error(e.message); process.exitCode = 1; }).finally(() => db.$disconnect());
|
||||
@@ -0,0 +1,92 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
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 lessons = {
|
||||
1: [
|
||||
['ROS2 기초', '노드와 패키지 구조를 이해하고 Topic, Service, Action으로 데이터를 주고받습니다. CLI로 통신 상태를 확인하고 TF를 통해 센서와 차량 좌표계의 관계를 살펴봅니다.', ['노드와 패키지', 'Topic · Service · Action', 'TF 좌표계']],
|
||||
['Sensor Processing', 'LiDAR와 카메라 데이터의 형식, 갱신 주기와 좌표계를 확인합니다. 노이즈와 불필요한 데이터를 걸러내고 센서 보정 결과가 인식에 미치는 영향을 비교합니다.', ['센서 메시지와 타임스탬프', '필터링과 전처리', '센서 캘리브레이션']],
|
||||
['Simulation 환경 구축 Part 1', 'Gazebo 월드와 차량 모듈을 실행하고 센서 데이터가 ROS2 토픽으로 전달되는 과정을 확인합니다. 시뮬레이션 시간과 TF 연결을 점검해 실습의 기본 환경을 구성합니다.', ['Gazebo 월드와 로봇 모델', '시뮬레이션 시간', '센서 토픽과 TF 연결']],
|
||||
['Simulation 환경 구축 Part 2', '실습 차량에 맞는 모듈과 파라미터를 선택하고 실행 순서를 정리합니다. 차량 모델을 바꾸며 공통 NAV2 모듈과 차량별 인터페이스의 역할을 구분합니다.', ['환경별 모듈 구성', '차량 인터페이스', '실행 순서와 의존성']],
|
||||
['LiDAR SLAM', '차량을 이동시키며 LiDAR 관측과 오도메트리를 이용해 2D 맵을 생성합니다. 반복 구간과 이동 속도에 따른 맵 품질을 비교하고, 완성한 맵을 저장해 다시 확인합니다.', ['점유격자 지도', '오도메트리와 스캔 정합', '루프 클로저']],
|
||||
['LiDAR Localization', '저장한 맵을 불러오고 초기 위치를 지정해 AMCL 위치추정을 시작합니다. 이동 중 추정 위치와 센서 관측이 일치하는지 확인하고 초기화가 잘못된 경우의 증상을 살펴봅니다.', ['AMCL 입자 필터', '초기 위치 설정', 'map · odom · base_link']],
|
||||
['Parameter Tuning', '센서 처리와 NAV2 모듈의 주요 파라미터를 한 번에 하나씩 변경합니다. 같은 주행 조건에서 경로, 오차와 처리 시간을 비교하고 설정값과 결과를 기록합니다.', ['기준 시나리오', '단일 변수 비교', '성능 지표와 재현성']],
|
||||
['[심화] Camera based SLAM', '카메라 기반 SLAM의 영상 입력과 위치·맵 출력 구조를 살펴봅니다. 특징점 추적과 깊이 정보의 역할을 이해하고 조명 변화나 반복 무늬에서 발생하는 한계를 분석합니다.', ['영상 특징점 추적', '깊이 정보와 스케일', '시각 오도메트리']],
|
||||
['Occupancy Map', '센서 관측이 점유격자와 costmap에 반영되는 흐름을 확인합니다. 장애물 주변의 비용과 차량 크기 설정을 바꾸며 통과 가능한 공간과 안전 여유가 달라지는 모습을 비교합니다.', ['점유 확률', 'Costmap 레이어', 'Footprint와 Inflation']],
|
||||
['Planning', '시작 위치와 목표 지점을 설정해 전역 경로 생성 과정을 확인합니다. 장애물 배치와 주행 제약을 바꾸며 경로의 길이, 안전성 및 재계획 결과를 비교합니다.', ['전역 경로 탐색', '장애물 비용과 주행 제약', '경로 재계획']],
|
||||
['Control', '계획된 경로를 따라가도록 속도 명령을 생성하는 제어 흐름을 확인합니다. 속도와 회전 관련 설정을 조절하고 곡선 및 좁은 구간에서 추종 오차와 안정성을 비교합니다.', ['경로 추종', '속도·회전 제약', '추종 오차와 안정성']],
|
||||
['[심화] 딥러닝 기반 객체 인식', '카메라 영상에서 객체를 검출하고 클래스, 위치와 신뢰도 결과를 해석합니다. 인식 결과를 ROS2 데이터 흐름에 연결하고 오검출과 추론 지연이 주행 판단에 미치는 영향을 살펴봅니다.', ['객체 검출과 신뢰도', '오검출·미검출', '추론 지연과 ROS2 연동']],
|
||||
],
|
||||
2: [
|
||||
['프로젝트 주제와 역할 분담', '팀이 해결할 자율주행 문제를 정의하고 실습 가능한 시나리오로 구체화합니다. 성공 기준과 역할, 일정을 정하고 공통으로 기록할 검증 항목을 합의합니다.', ['문제 정의와 요구사항', '역할과 인터페이스 분담', '성공 기준']],
|
||||
['센서·모듈 구성 설계', '시나리오에 필요한 센서와 NAV2 모듈을 선택하고 입력·출력 토픽 및 TF 관계를 정리합니다. 로컬 시뮬레이션과 실차의 차이를 구분해 실행 순서와 파라미터를 설계합니다.', ['센서·모듈 데이터 흐름', '토픽과 TF 인터페이스', '환경별 실행 구성']],
|
||||
['알고리즘 구현과 튜닝', 'SLAM, 위치추정, 경로 계획과 제어 중 개선할 부분을 정해 소스나 파라미터를 수정합니다. 변경 전후를 같은 조건에서 비교하고 팀원이 재현할 수 있도록 설정과 결과를 공유합니다.', ['소스 변경 관리', '파라미터 튜닝', '비교 실험']],
|
||||
['시뮬레이션·플랫폼 검증', '시뮬레이션에서 기능을 확인한 뒤 안전한 실차 환경에서 동일한 시나리오를 검증합니다. 토픽, 로그와 주행 결과를 함께 분석해 환경 차이와 실패 원인을 정리합니다.', ['단계별 통합 검증', '시뮬레이션과 실차 차이', '로그 기반 원인 분석']],
|
||||
['결과 제출과 발표', '문제 정의부터 설계, 구현과 검증까지의 과정을 소스 및 실행 결과와 함께 정리합니다. 성능 개선 근거와 남은 한계를 설명하고 다른 팀이 실행할 수 있는 절차를 제공합니다.', ['재현 가능한 실행 절차', '정량·정성 평가', '결과와 한계 전달']],
|
||||
],
|
||||
3: [
|
||||
['SLAM', '원거리 MK-mini 플랫폼에 연결해 센서 토픽과 TF 상태를 점검하고 원격으로 모듈을 편집·빌드·실행합니다. 로컬 MK-mini 시뮬레이션에서 먼저 확인한 뒤 실차에서 SLAM 맵을 생성·저장하고 통신 지연과 맵 품질을 비교합니다.', ['원격 개발·실행 흐름', '센서 토픽과 TF 점검', 'SLAM 맵 생성·저장']],
|
||||
['자율주행', '저장한 맵으로 MK-mini의 위치추정, 경로 계획과 제어 모듈을 구성하고 초기 위치와 목표 지점을 설정합니다. 원격으로 로그와 파라미터를 확인하며 주행 결과를 개선하고, 통신 단절에 대비한 정지·복구 절차를 점검합니다.', ['위치추정·계획·제어 연동', '원격 튜닝과 로그 분석', '통신 지연과 안전한 복구']],
|
||||
],
|
||||
};
|
||||
const include = { weeks: { orderBy: { week: 'asc' }, include: { topics: { orderBy: { sortOrder: 'asc' } }, modules: { orderBy: { id: 'asc' } }, practices: true } }, instructorCurriculumWeekOverrides: { orderBy: { id: 'asc' } }, environmentProfiles: { orderBy: { id: 'asc' } } };
|
||||
const snapshot = tx => tx.course.findMany({ orderBy: { id: 'asc' }, include });
|
||||
async function main() {
|
||||
const before = await snapshot(db);
|
||||
const targets = before.filter(c => c.slug !== 'autonomy-studio-lms-guide');
|
||||
assert.equal(targets.length, 3);
|
||||
for (const c of targets) for (const week of [1, 2, 3]) {
|
||||
const w = c.weeks.find(w => w.week === week); assert(w);
|
||||
assert.deepEqual(w.topics.map(t => t.title), lessons[week].map(l => l[0]));
|
||||
}
|
||||
const backup = `backups/learning-steps-before-${Date.now()}.json`;
|
||||
fs.mkdirSync('backups', { recursive: true });
|
||||
fs.writeFileSync(backup, JSON.stringify(before, null, 2), { mode: 0o600, flag: 'wx' });
|
||||
console.log('Backup:', backup);
|
||||
await db.$transaction(async tx => {
|
||||
assert.deepEqual(await snapshot(tx), before);
|
||||
for (const c of targets) for (const week of [1, 2, 3]) {
|
||||
const w = c.weeks.find(w => w.week === week);
|
||||
for (const [i, topic] of w.topics.entries()) await tx.courseWeekTopic.update({ where: { id: topic.id }, data: { detail: lessons[week][i][1] } });
|
||||
const existing = c.instructorCurriculumWeekOverrides.filter(o => o.week === week);
|
||||
const overrides = existing.length ? existing : [{ organizationName: c.organizationName }];
|
||||
for (const o of overrides) {
|
||||
const steps = lessons[week].map(([title, description, concepts], i) => {
|
||||
const old = o.learningSteps?.[i];
|
||||
if (old) assert.equal(old.title, title);
|
||||
const videoUrl = old?.videoUrl ?? o.topicVideoUrls?.[i] ?? '';
|
||||
return { ...old, title, description, concepts, videoUrl, videoEnabled: old?.videoEnabled ?? Boolean(videoUrl) };
|
||||
});
|
||||
const data = { learningSteps: steps, topics: steps.map(s => s.title), topicDetails: steps.map(s => s.description) };
|
||||
if (o.id) await tx.instructorCurriculumWeekOverride.update({ where: { id: o.id }, data });
|
||||
else await tx.instructorCurriculumWeekOverride.create({ data: { courseId: c.id, week, organizationName: c.organizationName, ...data } });
|
||||
}
|
||||
}
|
||||
const after = await snapshot(tx);
|
||||
assert.deepEqual(after.find(c => c.slug === 'autonomy-studio-lms-guide'), before.find(c => c.slug === 'autonomy-studio-lms-guide'));
|
||||
for (const old of targets) {
|
||||
const current = after.find(c => c.id === old.id);
|
||||
assert.deepEqual(current.environmentProfiles, old.environmentProfiles);
|
||||
for (const w of current.weeks) {
|
||||
const prev = old.weeks.find(p => p.id === w.id);
|
||||
assert.deepEqual(w.modules, prev.modules);
|
||||
assert.deepEqual(w.practices, prev.practices);
|
||||
}
|
||||
for (const o of current.instructorCurriculumWeekOverrides.filter(o => [1, 2, 3].includes(o.week))) {
|
||||
assert.deepEqual(o.learningSteps.map(s => [s.title, s.description, s.concepts]), lessons[o.week]);
|
||||
const prev = old.instructorCurriculumWeekOverrides.find(p => p.id === o.id);
|
||||
if (prev) {
|
||||
const omit = v => { const { learningSteps, topics, topicDetails, updatedAt, ...rest } = v; return rest; };
|
||||
assert.deepEqual(omit(o), omit(prev));
|
||||
if (prev.learningSteps) assert.deepEqual(o.learningSteps.map(s => [s.videoUrl, s.videoEnabled]), prev.learningSteps.map(s => [s.videoUrl, s.videoEnabled]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { isolationLevel: 'Serializable', timeout: 30000 });
|
||||
console.log('Updated 3 courses, 9 weeks, 57 learning steps; 3 concepts per step.');
|
||||
}
|
||||
main().catch(e => { console.error(e.message); process.exitCode = 1; }).finally(() => db.$disconnect());
|
||||
@@ -221,7 +221,7 @@ CROSS JOIN (
|
||||
),
|
||||
'runtimeImages', jsonb_build_array(
|
||||
jsonb_build_object(
|
||||
'url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/runtime-images/slam-ros-bridge/jetson-orin/latest/slam-ros-bridge-jetson-orin.tar.zst?v=0659d4cf5578',
|
||||
'url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/runtime-images/slam-ros-bridge/arm64/20260819-0659d4cf5578/slam-ros-bridge-jetson-orin.tar.zst',
|
||||
'image', 'slam-ros-bridge:jetson_orin',
|
||||
'fileName', 'slam-ros-bridge-jetson-orin.tar.zst',
|
||||
'required', true,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Update only the legacy Jetson bridge URL; preserve array order and all other settings.
|
||||
WITH replacement AS (
|
||||
SELECT
|
||||
'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/runtime-images/slam-ros-bridge/jetson-orin/latest/slam-ros-bridge-jetson-orin.tar.zst'::text AS old_url,
|
||||
'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/runtime-images/slam-ros-bridge/arm64/20260819-0659d4cf5578/slam-ros-bridge-jetson-orin.tar.zst'::text AS new_url
|
||||
), updated_profiles AS (
|
||||
SELECT profile.id,
|
||||
jsonb_agg(
|
||||
CASE WHEN artifact->>'image' = 'slam-ros-bridge:jetson_orin'
|
||||
AND split_part(artifact->>'url', '?', 1) = replacement.old_url
|
||||
THEN jsonb_set(artifact, '{url}', to_jsonb(replacement.new_url))
|
||||
ELSE artifact END
|
||||
ORDER BY ordinal
|
||||
) AS images
|
||||
FROM "course_environment_profiles" profile
|
||||
CROSS JOIN replacement
|
||||
CROSS JOIN LATERAL jsonb_array_elements(
|
||||
CASE WHEN jsonb_typeof(profile.config::jsonb->'runtimeImages') = 'array'
|
||||
THEN profile.config::jsonb->'runtimeImages' ELSE '[]'::jsonb END
|
||||
) WITH ORDINALITY AS entries(artifact, ordinal)
|
||||
WHERE profile.config::jsonb->>'target' = 'remote-jetson'
|
||||
GROUP BY profile.id
|
||||
HAVING bool_or(artifact->>'image' = 'slam-ros-bridge:jetson_orin'
|
||||
AND split_part(artifact->>'url', '?', 1) = replacement.old_url)
|
||||
)
|
||||
UPDATE "course_environment_profiles" profile
|
||||
SET config = jsonb_set(profile.config::jsonb, '{runtimeImages}', updated.images),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
FROM updated_profiles updated
|
||||
WHERE profile.id = updated.id
|
||||
RETURNING profile.id, profile.config::jsonb->'runtimeImages' AS runtime_images;
|
||||
Reference in New Issue
Block a user