feat: add weekly curriculum cover image upload and display
This commit is contained in:
@@ -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.');
|
||||
@@ -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; });
|
||||
@@ -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; });
|
||||
@@ -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; });
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user