feat: add weekly curriculum cover image upload and display

This commit is contained in:
2026-09-23 16:03:33 +09:00
parent a7c9cdc16e
commit 656b1bf62b
23 changed files with 723 additions and 95 deletions
+55
View File
@@ -0,0 +1,55 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const vm = require('node:vm');
const { spawnSync } = require('node:child_process');
const ts = require('typescript');
const root = path.join(__dirname, '..');
const source = fs.readFileSync(path.join(root, 'src/main/index.ts'), 'utf8');
const ast = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, true);
const ctx = { exports: {} };
vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(root, 'src/main/yahboomRuntime.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText, ctx);
Object.assign(ctx, ctx.exports);
vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(root, 'src/main/cameraRuntime.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText, ctx);
const images = [{ image: 'future-nav2-camera-test:humble-jetson_orin' },
{ image: 'future-nav2:humble-jetson_orin' }, { image: 'slam-ros-bridge:jetson_orin' }];
assert.deepEqual(ctx.exports.cameraRuntimeArtifacts('[lms-nav2-camera-test]nav2-camera-test_v1.0.0_ws', images), images.slice(0, 1));
assert.deepEqual(ctx.exports.cameraRuntimeArtifacts('[lms-nav2-mkmini]nav2-mkmini_v1.0.0_ws', images), images.slice(1));
assert.equal(ctx.exports.cameraRuntimeArtifacts('anything', undefined), undefined);
const names = ['buildNav2DockerRunScript', 'buildNav2DockerBuildScript'];
for (const node of ast.statements) {
if (ts.isFunctionDeclaration(node) && names.includes(node.name?.text)) {
vm.runInNewContext(ts.transpileModule(node.getText(ast), {}).outputText, ctx);
}
}
const run = ctx.buildNav2DockerRunScript();
const build = ctx.buildNav2DockerBuildScript();
for (const script of [run, build]) {
const syntax = spawnSync('bash', ['-n'], { input: script, encoding: 'utf8' });
assert.equal(syntax.status, 0, syntax.stderr);
}
assert.match(run, /lms-nav2-camera-test\*\) PACKAGE=camera_test_pkg; LAUNCH=camera.launch.py/);
assert.match(build, /lms-nav2-camera-test\*\) TARGET=camera_test_pkg/);
assert.match(run, /--device "\$CAMERA_DEVICE:\$CAMERA_DEVICE"/);
assert.match(run, /for capture_device in \/dev\/capture-vi-channel\*/);
assert.match(run, /--device "\$capture_device:\$capture_device"/);
assert.match(run, /FASTDDS_DEFAULT_PROFILES_FILE/);
assert.match(run, /ROS_LOCALHOST_ONLY=\$ROS_LOCALHOST_ONLY_VALUE/);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'camera-runtime-'));
const folder = '[lms-nav2-camera-test]nav2-camera-test_v1.0.0_ws';
try {
fs.mkdirSync(path.join(dir, folder, 'install'), { recursive: true });
fs.writeFileSync(path.join(dir, folder, 'install/setup.bash'), '');
for (const device of ['/dev/null', '/dev/video999999', '/tmp/video0']) {
const result = spawnSync('bash', ['-s', '--', 'camera-test', dir, folder, 'camera-image', '77', 'false', `video_device:=${device}`],
{ input: run, encoding: 'utf8' });
assert.notEqual(result.status, 0);
assert.match(result.stderr, /Camera device not found/);
}
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
console.log('Camera module routing, shell syntax, DDS settings and device validation passed.');
+70
View File
@@ -0,0 +1,70 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const html = fs.readFileSync(path.join(__dirname, '../src/renderer/public/slam-viewer/index.html'), 'utf8');
const source = html.slice(html.indexOf('function hasLiveCameraState()'), html.indexOf("cameraPreview?.addEventListener('load'"));
let now = 10000;
let requests = 0;
let ok = true;
let visible = false;
let afterBlob = () => {};
const context = vm.createContext({
ENABLE_CAMERA_PREVIEW: true,
cameraFrameRequestInFlight: false,
cameraFrameRetryAt: 0,
cameraFrameObjectUrl: null,
cameraPreview: {},
latestSlamState: null,
latestSlamStateReceivedAt: now,
document: { hidden: false },
Date: { now: () => now },
BACKEND_URL: 'http://localhost:8765',
AbortSignal,
URL: { createObjectURL: () => 'blob:frame', revokeObjectURL() {} },
setCameraPreviewAvailable(value) { visible = value; },
async fetch() {
requests++;
return { ok, async blob() { afterBlob(); return {}; } };
},
});
vm.runInContext(source, context);
const refresh = () => context.refreshCameraFrame();
const available = (value) => {
context.latestSlamState = { topic_status: { camera_image: { available: value } } };
context.latestSlamStateReceivedAt = now;
};
(async () => {
await refresh();
available(false);
await refresh();
assert.equal(requests, 0, 'Absent camera must not fetch frames');
available(true);
await refresh();
assert.equal(requests, 1);
assert.equal(context.cameraPreview.src, 'blob:frame');
now += 3001;
await refresh();
assert.equal(requests, 1, 'Stale websocket state must stop polling');
assert.equal(visible, false);
available(true);
ok = false;
await refresh();
await refresh();
assert.equal(requests, 2, 'HTTP errors must back off');
now += 5000;
available(true);
ok = true;
afterBlob = () => available(false);
context.cameraPreview.src = '';
await refresh();
assert.equal(context.cameraPreview.src, '', 'Late responses must not revive a disconnected camera');
assert.equal(context.cameraFrameRequestInFlight, false);
afterBlob = () => {};
available(true);
await refresh();
assert.equal(context.cameraPreview.src, 'blob:frame', 'Camera reconnect must resume frames');
console.log('camera preview polling tests passed');
})().catch((error) => { console.error(error); process.exitCode = 1; });
+51
View File
@@ -0,0 +1,51 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ts = require('typescript');
const moduleRow = { name: 'Test', folderName: 'test' };
const clean = 'modules/Test/clean.py';
const dirty = 'modules/Test/dirty.py';
let reads = 0;
const context = {
exports: {}, console,
require: name => name === 'react' ? { useRef: value => ({ current: value }) } : {},
window: { remoteWorkspace: { moduleFiles: { read: async () => {
reads++;
return { files: {}, filePaths: [clean, dirty], folders: [], absoluteRoot: '/module' };
} } } }
};
const source = fs.readFileSync(path.join(__dirname, '../src/renderer/src/features/workspace/useWorkspaceFileActions.ts'), 'utf8');
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, context);
let files = { [clean]: 'cached', [dirty]: 'unsaved' };
const options = {
selectedModule: moduleRow, remoteWorkspace: true, getRemoteWorkspaceTarget: () => ({ host: 'test' }),
clonePathPreview: '/workspace', dirtyEditorFiles: { [dirty]: true },
setEditorFiles: fn => { files = fn(files); },
setNewFileDraft: () => {}
};
for (const name of ['setModuleFileLoadingByName', 'setSavedEditorFiles', 'setLoadedModuleFilePaths', 'setLoadedModuleFolders', 'setLoadedModuleRoots']) {
options[name] = fn => fn({});
}
(async () => {
const actions = context.exports.useWorkspaceFileActions(options);
const cached = files;
await actions.loadModuleFiles(moduleRow);
await actions.loadModuleFiles(moduleRow);
assert.equal(reads, 2);
assert.equal(files, cached, 'Automatic listing refresh must retain the content cache');
await actions.loadModuleFiles(moduleRow, true);
assert.equal(files[clean], undefined, 'Explicit refresh invalidates clean content');
assert.equal(files[dirty], 'unsaved', 'Explicit refresh preserves unsaved edits');
files = { [clean]: 'reloaded', [dirty]: 'unsaved' };
actions.refreshModuleFiles();
await new Promise(resolve => setImmediate(resolve));
assert.equal(files[clean], undefined, 'Refresh button must explicitly invalidate content');
assert.equal(files[dirty], 'unsaved');
options.remoteWorkspace = false;
context.window.localWorkspace = { moduleFiles: { read: async () => ({ files: { [clean]: 'local disk' }, folders: [], absoluteRoot: '/module' }) } };
await context.exports.useWorkspaceFileActions(options).loadModuleFiles(moduleRow);
assert.equal(files[clean], 'local disk', 'Local workspace retains its existing eager loading');
console.log('Automatic cache retention, explicit refresh, dirty protection and local loading tests passed.');
})().catch(error => { console.error(error); process.exitCode = 1; });
+67
View File
@@ -0,0 +1,67 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ts = require('typescript');
// Exercise effect cleanup and asynchronous IPC responses without starting Electron.
const slots = [];
let cursor = 0;
let effects = [];
const react = {
useRef(value) { const i = cursor++; return slots[i] ??= { current: value }; },
useState(value) {
const i = cursor++;
if (!(i in slots)) slots[i] = value;
return [slots[i], next => { slots[i] = typeof next === 'function' ? next(slots[i]) : next; }];
},
useEffect(fn, deps) {
const i = cursor++;
const previous = slots[i];
if (previous && deps.every((dep, index) => Object.is(dep, previous.deps[index]))) return;
effects.push(() => { previous?.cleanup?.(); slots[i] = { deps, cleanup: fn() }; });
}
};
const requests = [];
const context = { exports: {}, require: name => { assert.equal(name, 'react'); return react; }, window: {
remoteWorkspace: { moduleFiles: { content: (...args) => new Promise((resolve, reject) => requests.push({ args, resolve, reject })) } }
} };
const source = fs.readFileSync(path.join(__dirname, '../src/renderer/src/features/workspace/useRemoteEditorFile.ts'), 'utf8');
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, context);
const options = {
enabled: true, targetKey: 'platform', getTarget: () => ({}), module: { name: 'Test', folderName: 'test' },
path: null, files: {}, dirty: {},
setFiles(fn) { options.files = fn(options.files); },
setSaved(fn) { options.saved = fn(options.saved ?? {}); }
};
function render() {
cursor = 0;
const result = context.exports.useRemoteEditorFile(options);
const pending = effects; effects = []; pending.forEach(fn => fn());
return result;
}
const tick = () => new Promise(resolve => setImmediate(resolve));
(async () => {
render(); assert.equal(requests.length, 0);
options.path = 'modules/Test/first.py';
assert.equal(render().loading, true); assert.equal(requests.length, 1);
render(); assert.equal(requests.length, 1);
options.path = 'modules/Test/second.py'; render();
requests[0].resolve({ content: 'old response' }); await tick();
assert.equal(options.files['modules/Test/first.py'], undefined);
requests[1].resolve({ content: 'second' }); await tick();
assert.equal(render().loading, false);
assert.equal(options.files[options.path], 'second');
options.path = null; render(); options.path = 'modules/Test/second.py'; render();
assert.equal(requests.length, 2);
options.path = 'modules/Test/fail.py'; render();
requests[2].reject(new Error('offline')); await tick();
assert.ok(render().error);
render().retry(); render(); assert.equal(requests.length, 4);
options.dirty[options.path] = true; options.files[options.path] = 'unsaved'; render();
requests[3].resolve({ content: 'server copy' }); await tick();
assert.equal(options.files[options.path], 'unsaved');
options.path = 'modules/Test/image.png'; render(); assert.equal(requests.length, 4);
options.enabled = false; options.path = 'modules/Test/local.py'; render(); assert.equal(requests.length, 4);
console.log('Remote editor click loading, caching, cancellation, retry and dirty-file protection tests passed.');
})().catch(error => { console.error(error); process.exitCode = 1; });
+44
View File
@@ -0,0 +1,44 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const vm = require('node:vm');
const { spawnSync } = require('node:child_process');
const ts = require('typescript');
const source = fs.readFileSync(path.join(__dirname, '../src/main/remoteModuleFiles.ts'), 'utf8');
const context = { exports: {}, Buffer };
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, context);
const script = context.exports.remoteModuleFilesScript;
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'module-files-'));
function run(request) {
return spawnSync('bash', ['-s'], { input: script(root, request), encoding: 'utf8' });
}
function read(request) {
const result = run(request);
assert.equal(result.status, 0, result.stderr);
return JSON.parse(result.stdout);
}
try {
fs.mkdirSync(path.join(root, 'src'));
fs.mkdirSync(path.join(root, 'build'));
fs.writeFileSync(path.join(root, 'src', 'hello.py'), 'first\nHello world\n');
fs.writeFileSync(path.join(root, 'build', 'hidden.py'), 'Hello');
fs.writeFileSync(path.join(root, 'large.txt'), 'x'.repeat(1048577));
fs.writeFileSync(path.join(root, 'binary.txt'), Buffer.from([0, 1]));
fs.symlinkSync('/etc/passwd', path.join(root, 'outside.txt'));
const listing = read({ action: 'list' });
assert.deepEqual(listing, { filePaths: ['binary.txt', 'large.txt', 'src/hello.py'], folders: ['src'] });
assert.equal(read({ action: 'content', path: 'src/hello.py' }).content, 'first\nHello world\n');
for (const file of ['large.txt', 'binary.txt']) {
assert.equal(read({ action: 'content', path: file }).content, 'Binary or large file preview is not available.');
}
for (const file of ['../outside.txt', '/etc/passwd', 'outside.txt', 'missing.py']) {
assert.notEqual(run({ action: 'content', path: file }).status, 0);
}
assert.deepEqual(read({ action: 'search', query: 'HELLO' }), [{ file: 'src/hello.py', lineNumber: 2, lineText: 'Hello world' }]);
fs.writeFileSync(path.join(root, 'src', 'many.txt'), 'hello\n'.repeat(300));
assert.equal(read({ action: 'search', query: 'hello' }).length, 200);
console.log('Remote module listing, on-demand content, path validation and search tests passed.');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
+47 -5
View File
@@ -4,12 +4,13 @@ const vm = require('node:vm');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '../src/renderer/public/slam-viewer/index.html'), 'utf8');
const code = html.slice(html.indexOf('let maxLinear ='), html.indexOf('// A static Gazebo model'));
function create(real = true, yahboom = true) {
function create(real = true, yahboom = true, mkmini = !yahboom) {
const handlers = {}, messages = [], intervals = new Map();
let id = 0;
const context = vm.createContext({
REAL_RUNTIME: real, USE_YAHBOOM_MODEL: yahboom, REAL_YAHBOOM_TELEOP: real && yahboom,
USE_MKMINI_MODEL: !yahboom, DIRECT_BRIDGE: true,
REAL_RUNTIME: real, USE_YAHBOOM_MODEL: yahboom, REAL_TELEOP: real && (yahboom || mkmini),
REAL_MKMINI_TELEOP: real && mkmini, EDITOR_MODE: false,
USE_MKMINI_MODEL: mkmini, DIRECT_BRIDGE: true,
latestSlamState: {teleop_deadman: true}, latestSlamStateReceivedAt: Date.now(),
controlSocket: {readyState: 1, bufferedAmount: 0, send: data => messages.push(JSON.parse(data)), close() {this.readyState = 3;}},
WebSocket: {OPEN: 1}, document: {hidden: false, hasFocus: () => true, addEventListener: (name, fn) => handlers[name] = fn},
@@ -45,8 +46,49 @@ assert.equal(t.messages.at(-1).linear, 0);
assert.equal(t.intervals.size, 0);
t = create(); t.context.latestSlamState.teleop_deadman = false; t.key('keydown', 'i');
assert.equal(t.messages.length, 0);
t = create(true, false); t.key('keydown', 'i'); assert.equal(t.messages.length, 0);
t = create(true, false, false); t.key('keydown', 'i'); assert.equal(t.messages.length, 0);
for (const [forward, left, reverse] of [['i', 'j', ','], ['ArrowUp', 'ArrowLeft', 'ArrowDown']]) {
t = create(true, false);
t.key('keydown', left);
assert.equal(t.messages.at(-1).linear, 0);
assert.equal(t.messages.at(-1).angular, 0);
t.key('keydown', forward);
assert.equal(t.messages.at(-1).linear, 0.1);
assert.equal(t.messages.at(-1).angular, 0.08);
assert.equal(t.messages.at(-1).deadman, true);
t.key('keyup', left, {target: {tagName: 'INPUT'}});
assert.equal(t.messages.at(-1).linear, 0);
assert.equal(t.intervals.size, 0);
t.key('keydown', reverse);
t.key('keydown', 'l');
assert.equal(t.messages.at(-1).linear, -0.1);
assert.equal(t.messages.at(-1).angular, -0.08);
t.key('keydown', ' ');
assert.equal(t.messages.at(-1).linear, 0);
assert.equal(t.messages.at(-1).angular, 0);
}
for (const event of ['blur', 'pagehide', 'visibilitychange']) {
t = create(true, false);
t.key('keydown', 'i');
if (event === 'visibilitychange') t.context.document.hidden = true;
t.handlers[event]();
assert.equal(t.messages.at(-1).linear, 0);
assert.equal(t.intervals.size, 0);
}
t = create(true, false);
t.key('keydown', 'q'); t.key('keydown', 'i');
assert.equal(t.messages.at(-1).linear, 0.1);
t.context.latestSlamStateReceivedAt = 0;
for (const tick of t.intervals.values()) tick();
assert.equal(t.messages.at(-1).linear, 0);
t = create(true, false); t.context.latestSlamState.teleop_deadman = false;
t.key('keydown', 'i'); assert.equal(t.messages.length, 0);
t = create(true, false); t.context.controlSocket.bufferedAmount = 1;
t.key('keydown', 'i'); assert.equal(t.messages.length, 0);
assert.equal(t.context.controlSocket.readyState, 3);
t = create(true, false); t.context.EDITOR_MODE = true;
t.key('keydown', 'i'); assert.equal(t.messages.length, 0);
t = create(false); t.key('keydown', 'i'); t.key('keyup', 'i');
assert.equal(t.messages.at(-1).linear, 0.22);
assert.equal(t.intervals.size, 1);
console.log('PASS: deadman release, focus/visibility/stale-state stops, legacy bridge and other real vehicles blocked, simulation unchanged');
console.log('PASS: Yahboom/MK-mini deadman, combined steering, arrows/reverse, release/focus/stale-state stops, speed cap, legacy/read-only blocking, simulation unchanged');