From 656b1bf62bcced309a83c225fe56f9ab111386a9 Mon Sep 17 00:00:00 2001 From: chyoo Date: Wed, 23 Sep 2026 16:03:33 +0900 Subject: [PATCH] feat: add weekly curriculum cover image upload and display --- package.json | 2 +- src/main/cameraRuntime.ts | 11 +++ src/main/index.ts | 84 ++++++++--------- src/main/remoteModuleFiles.ts | 69 ++++++++++++++ src/preload/index.ts | 2 + src/renderer/public/slam-viewer/index.html | 89 ++++++++++++++----- src/renderer/src/App.tsx | 32 ++++++- src/renderer/src/env.d.ts | 3 + .../curriculum/CurriculumDetailPanel.tsx | 28 +++++- src/renderer/src/features/curriculum/types.ts | 1 + .../src/features/workspace/ModuleListView.tsx | 2 + .../workspace/WorkspaceEditorPane.tsx | 17 +++- .../features/workspace/WorkspaceFilePanel.tsx | 8 +- .../features/workspace/WorkspaceTopBar.tsx | 10 ++- .../features/workspace/useRemoteEditorFile.ts | 49 ++++++++++ .../workspace/useWorkspaceFileActions.ts | 32 ++++--- .../features/workspace/useWorkspaceSearch.ts | 40 +++++++-- tests/camera-module-runtime.cjs | 55 ++++++++++++ tests/camera-preview-polling.cjs | 70 +++++++++++++++ tests/module-file-refresh.cjs | 51 +++++++++++ tests/remote-editor-file.cjs | 67 ++++++++++++++ tests/remote-module-files.cjs | 44 +++++++++ tests/yahboom-teleop.cjs | 52 +++++++++-- 23 files changed, 723 insertions(+), 95 deletions(-) create mode 100644 src/main/cameraRuntime.ts create mode 100644 src/main/remoteModuleFiles.ts create mode 100644 src/renderer/src/features/workspace/useRemoteEditorFile.ts create mode 100644 tests/camera-module-runtime.cjs create mode 100644 tests/camera-preview-polling.cjs create mode 100644 tests/module-file-refresh.cjs create mode 100644 tests/remote-editor-file.cjs create mode 100644 tests/remote-module-files.cjs diff --git a/package.json b/package.json index edbed28..2fc240f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lms", - "version": "1.1.32", + "version": "1.2.1", "description": "Autonomy Studio LMS Electron app", "main": "./out/main/index.js", "author": { diff --git a/src/main/cameraRuntime.ts b/src/main/cameraRuntime.ts new file mode 100644 index 0000000..442aff3 --- /dev/null +++ b/src/main/cameraRuntime.ts @@ -0,0 +1,11 @@ +const image = 'future-nav2-camera-test:humble-jetson_orin' + +export function cameraRuntimeArtifacts( + folder: string, + configured: T[] | null | undefined, +): T[] | null | undefined { + if (!configured) return configured + const camera = folder.includes('lms-nav2-camera-test') + // The optional camera image must not be downloaded for other Nav2 modules. + return configured.filter((item) => (item.image?.trim() === image) === camera) +} diff --git a/src/main/index.ts b/src/main/index.ts index a5150af..6d16a5c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,5 +1,7 @@ import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron' import { checkDockerStatus } from './dockerStatus' +import { cameraRuntimeArtifacts } from './cameraRuntime' +import { remoteModuleFilesScript } from './remoteModuleFiles' import { resolveContainerTerminal } from './containerTerminal' import { yahboomDevicePreflight, yahboomModuleCases, yahboomRuntimeImage, yahboomRuntimeArtifacts, yahboomSimulation } from './yahboomRuntime' import { visualizationProgress, reportVisualizationProgress } from './visualizationProgress' @@ -142,6 +144,7 @@ function getBundledZstdPath(): string | null { } type CachedModuleFiles = { + filePaths?: string[] root: string | null absoluteRoot: string | null files: Record @@ -4598,6 +4601,7 @@ function buildNav2DockerRunScript(): string { 'else', 'case "$MODULE_FOLDER" in', ...yahboomModuleCases(), + ' *lms-nav2-camera-test*) PACKAGE=camera_test_pkg; LAUNCH=camera.launch.py ;;', ' *lms-nav2-mkmini*) PACKAGE=vehicle_pkg; LAUNCH=vehicle.launch.py ;;', ' *lms-nav2-gazebo-simulator*) PACKAGE=nav2_turtlebot_simulator; LAUNCH=simulator.launch.py ;;', ' *lms-nav2-gazebo-vehicle*) PACKAGE=vehicle_pkg; LAUNCH=vehicle.launch.py ;;', @@ -4635,6 +4639,12 @@ function buildNav2DockerRunScript(): string { '', 'test -f "$MODULE_ROOT/$MODULE_FOLDER/install/setup.bash" || { echo "Module is not built: $MODULE_FOLDER" >&2; exit 1; }', ...yahboomDevicePreflight(), + 'CAMERA_DEVICE=""', + 'if [[ "$MODULE_FOLDER" == *lms-nav2-camera-test* ]]; then', + ' CAMERA_DEVICE=/dev/video0', + ' for arg in "$@"; do case "$arg" in video_device:=*) CAMERA_DEVICE=${arg#video_device:=} ;; esac; done', + ' [[ "$CAMERA_DEVICE" =~ ^/dev/video[0-9]+$ ]] && [ -c "$CAMERA_DEVICE" ] || { echo "Camera device not found: $CAMERA_DEVICE" >&2; exit 1; }', + 'fi', 'docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true', '', 'args=(', @@ -4652,6 +4662,12 @@ function buildNav2DockerRunScript(): string { ' -v /etc/localtime:/etc/localtime:ro', ')', 'if [ -n "$YAHBOOM_DEVICE" ]; then args+=(--device "$YAHBOOM_DEVICE:$YAHBOOM_DEVICE"); fi', + 'if [ -n "$CAMERA_DEVICE" ]; then', + ' args+=(--device "$CAMERA_DEVICE:$CAMERA_DEVICE")', + ' for capture_device in /dev/capture-vi-channel*; do', + ' if [ -c "$capture_device" ]; then args+=(--device "$capture_device:$capture_device"); fi', + ' done', + 'fi', 'if [[ "$GPU_ENABLED" == "true" || "$GPU_ENABLED" == "1" ]]; then', ' args+=(--gpus all -e NVIDIA_VISIBLE_DEVICES=all -e NVIDIA_DRIVER_CAPABILITIES=compute,utility,graphics)', 'fi', @@ -4695,6 +4711,7 @@ function buildNav2DockerBuildScript(): string { 'else', 'case "$MODULE_FOLDER" in', ...yahboomModuleCases(true), + ' *lms-nav2-camera-test*) TARGET=camera_test_pkg ;;', ' *lms-nav2-mkmini*) TARGET=vehicle_pkg ;;', ' *lms-nav2-gazebo-simulator*) TARGET=nav2_turtlebot_simulator ;;', ' *lms-nav2-gazebo-vehicle*) TARGET=vehicle_pkg ;;', @@ -5051,6 +5068,7 @@ async function resolveLocalModuleRuntime( rosParams: string, runtimeImages?: RuntimeImageArtifact[] | null, ): Promise<{ image: string; artifacts: RuntimeImageArtifact[] | null | undefined }> { + runtimeImages = cameraRuntimeArtifacts(folderName, runtimeImages) if (yahboomSimulation(folderName, rosParams)) { const artifact = runtimeImages?.find((item) => item.image?.trim() === defaultNav2GazeboRuntimeImage.image && item.url?.trim()) @@ -5062,7 +5080,9 @@ async function resolveLocalModuleRuntime( ) return { image, - artifacts: yahboomRuntimeImage(folderName) + artifacts: folderName.includes('lms-nav2-camera-test') + ? (runtimeImages ?? []).filter((item) => item.image?.trim() === image) + : yahboomRuntimeImage(folderName) ? yahboomRuntimeArtifacts(folderName, image, runtimeImages) : requiredRuntimeImages(runtimeImages, image), } @@ -5309,6 +5329,7 @@ async function startRemoteWorkspaceModule( if (existsOutput.trim() !== 'yes') throw new Error('설치된 모듈을 찾을 수 없습니다.') const nav2Module = isNav2ModuleFolder(targetFolderName) + runtimeImages = cameraRuntimeArtifacts(targetFolderName, runtimeImages) const runtimeImage = nav2Module ? await resolveRemoteCustomModuleRuntimeImage( target, @@ -5320,7 +5341,9 @@ async function startRemoteWorkspaceModule( ? await ensureRemoteNav2RuntimeAssets(target) : await ensureRemoteStudioRuntimeAssets(target) const fastDdsConfigPath = await prepareRemoteRosNetworkConfig(target, runtimeRoot, rosNetwork, rosDomainId) - await prepareRemoteRuntimeImages(target, yahboomRuntimeArtifacts(targetFolderName, runtimeImage, runtimeImages)) + await prepareRemoteRuntimeImages(target, targetFolderName.includes('lms-nav2-camera-test') + ? (runtimeImages ?? []).filter((item) => item.image?.trim() === runtimeImage) + : yahboomRuntimeArtifacts(targetFolderName, runtimeImage, runtimeImages)) reportModuleProgress("모듈 실행 중...") const currentStatus = await runSshOutput(target, `timeout 12s docker ps -a --filter ${quoteShellSingle(`name=^/${dockerInfo.containerKey}$`)} --format "{{.Status}}" 2>/dev/null || true`, 20000) if (/^Up\b/i.test(currentStatus.trim())) return { ok: true, message: '이미 실행 중입니다.' } @@ -5403,6 +5426,7 @@ async function buildRemoteWorkspaceModule( ): Promise<{ ok: boolean; message: string }> { const target = normalizeRemoteTarget(targetInput) const targetFolderName = folderName?.trim() || moduleName + runtimeImages = cameraRuntimeArtifacts(targetFolderName, runtimeImages) const dockerInfo = parseModuleDockerInfo(targetFolderName) if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.') @@ -5413,7 +5437,9 @@ async function buildRemoteWorkspaceModule( targetFolderName, yahboomRuntimeImage(targetFolderName) ?? await resolveRemoteNav2RuntimeImage(runtimeImages), ) - await prepareRemoteRuntimeImages(target, yahboomRuntimeArtifacts(targetFolderName, runtimeImage, runtimeImages)) + await prepareRemoteRuntimeImages(target, targetFolderName.includes('lms-nav2-camera-test') + ? (runtimeImages ?? []).filter((item) => item.image?.trim() === runtimeImage) + : yahboomRuntimeArtifacts(targetFolderName, runtimeImage, runtimeImages)) reportModuleProgress("모듈 빌드 중...") await runSsh(target, [ 'set -e', @@ -5723,44 +5749,11 @@ async function collectModuleFiles(moduleName: string, workspaceRoot?: string | n async function collectRemoteModuleFiles(targetInput: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null): Promise { const { target, moduleRoot } = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName) - const skippedNames = Array.from(skippedCacheDirs).map(quoteShellSingle).join(' ') - const script = [ - 'set +e', - `ROOT=${quoteShellSingle(moduleRoot)}`, - `SKIPPED="${skippedNames}"`, - 'find "$ROOT" -mindepth 1 -maxdepth 10 \\( -name .git -o -name build -o -name install -o -name log -o -name node_modules -o -name docker \\) -prune -o -type d -printf "D\\t%P\\n" -o -type f -printf "F\\t%P\\t%s\\n" | while IFS="$(printf \'\\t\')" read -r KIND REL SIZE; do', - ' [ -z "$REL" ] && continue', - ' NAME="${REL##*/}"', - ' case "$KIND" in', - ' D) printf "D\\t%s\\n" "$(printf %s "$REL" | base64 -w0)" ;;', - ' F)', - ' CONTENT="QmluYXJ5IG9yIGxhcmdlIGZpbGUgcHJldmlldyBpcyBub3QgYXZhaWxhYmxlLg=="', - ' if [ "${SIZE:-0}" -le 1048576 ]; then', - ' case "$NAME" in', - ' *.c|*.cc|*.cpp|*.cxx|*.h|*.hpp|*.hh|*.py|*.xml|*.urdf|*.xacro|*.yaml|*.yml|*.json|*.txt|*.md|*.cmake|*.launch|*.sh|*.bash|*.toml|*.ini|*.cfg|*.rviz) CONTENT="$(base64 -w0 "$ROOT/$REL" 2>/dev/null)" ;;', - ' esac', - ' fi', - ' printf "F\\t%s\\t%s\\n" "$(printf %s "$REL" | base64 -w0)" "$CONTENT"', - ' ;;', - ' esac', - 'done' - ].join('\n') - // Source transfers must not hold up module start/stop on the control queue. - const output = await runUnqueuedSshOutput(target, script, 30000) - const files: Record = {} - const folders: string[] = [] - - for (const line of output.split('\n')) { - if (!line.trim()) continue - const [kind, encodedPath = '', encodedContent = ''] = line.split('\t') - const relativePath = decodeRemoteField(encodedPath) - if (!relativePath) continue - const rendererPath = `modules/${moduleName}/${relativePath}` - if (kind === 'D') folders.push(rendererPath) - if (kind === 'F') files[rendererPath] = decodeRemoteField(encodedContent) - } - - return { root: moduleRoot.split('/').pop() ?? moduleName, absoluteRoot: moduleRoot, files, folders } + const output = await runUnqueuedSshOutput(target, remoteModuleFilesScript(moduleRoot, { action: 'list' }), 30000) + const listing = JSON.parse(output) as { filePaths: string[]; folders: string[] } + const prefix = (path: string): string => `modules/${moduleName}/${path}` + return { root: moduleRoot.split('/').pop() ?? moduleName, absoluteRoot: moduleRoot, + files: {}, filePaths: listing.filePaths.map(prefix), folders: listing.folders.map(prefix) } } async function collectRootFiles(rootPath: string, rendererRoot: string): Promise { @@ -6447,6 +6440,15 @@ function registerLocalWorkspaceHandlers(): void { ipcMain.handle('remote-workspace:git:submissions', (_event, target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => readRemoteWorkspaceGitSubmissions(target, modules)) ipcMain.handle('remote-workspace:git:commit-diff', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, commitHash?: string | null) => readRemoteWorkspaceGitCommitDiff(target, moduleName, folderName, commitHash)) ipcMain.handle('remote-workspace:module-files:read', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => collectRemoteModuleFiles(target, moduleName, folderName)) + ipcMain.handle('remote-workspace:module-files:content', async (_event, target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string | null) => { + const resolved = await resolveRemoteRendererModulePath(target, moduleName, path, folderName) + return JSON.parse(await runUnqueuedSshOutput(resolved.target, remoteModuleFilesScript(resolved.moduleRoot, { action: 'content', path: resolved.relativePath }), 30000)) + }) + ipcMain.handle('remote-workspace:module-files:search', async (_event, target: RemoteWorkspaceTarget, moduleName: string, query: string, folderName?: string | null) => { + const resolved = await resolveRemoteGitModuleRoot(target, moduleName, folderName) + const results = JSON.parse(await runUnqueuedSshOutput(resolved.target, remoteModuleFilesScript(resolved.moduleRoot, { action: 'search', query }), 30000)) as Array<{ file: string; lineNumber: number; lineText: string }> + return results.map((result) => ({ ...result, file: `modules/${moduleName}/${result.file}` })) + }) ipcMain.handle('remote-workspace:module-files:image', (_event, target: RemoteWorkspaceTarget, moduleName: string, rendererPath: string, folderName?: string | null) => readRemoteWorkspaceModuleImage(target, moduleName, rendererPath, folderName)) ipcMain.handle('remote-workspace:module-files:create', async (_event, target: RemoteWorkspaceTarget, moduleName: string, rendererPath: string, kind: 'file' | 'folder', folderName?: string | null) => { const resolved = await resolveRemoteRendererModulePath(target, moduleName, rendererPath, folderName) diff --git a/src/main/remoteModuleFiles.ts b/src/main/remoteModuleFiles.ts new file mode 100644 index 0000000..c660eb9 --- /dev/null +++ b/src/main/remoteModuleFiles.ts @@ -0,0 +1,69 @@ +type Request = { action: 'list' } | { action: 'content'; path: string } | { action: 'search'; query: string } + +export function remoteModuleFilesScript(root: string, request: Request): string { + const input = Buffer.from(JSON.stringify({ root, ...request })).toString('base64') + return `python3 - '${input}' <<'PY' +import base64, json, os, stat, sys +args = json.loads(base64.b64decode(sys.argv[1])) +root = os.path.realpath(args['root']) +skip = {'.git', 'build', 'install', 'log', 'node_modules', 'docker'} +extensions = {'.c', '.cc', '.cpp', '.cxx', '.h', '.hpp', '.hh', '.py', '.xml', '.urdf', '.xacro', '.yaml', '.yml', '.json', '.txt', '.md', '.cmake', '.launch', '.sh', '.bash', '.toml', '.ini', '.cfg', '.rviz'} +placeholder = 'Binary or large file preview is not available.' +def content(relative): + if os.path.isabs(relative) or '..' in relative.split('/'): + raise ValueError('Invalid module path') + path = os.path.realpath(os.path.join(root, relative)) + if os.path.commonpath([root, path]) != root: + raise ValueError('Invalid module path') + info = os.stat(path) + if not stat.S_ISREG(info.st_mode): + raise ValueError('Not a regular file') + if os.path.splitext(path)[1].lower() not in extensions or info.st_size > 1048576: + return placeholder + with open(path, 'rb') as fp: + data = fp.read(1048577) + if len(data) > 1048576 or b'\\x00' in data: + return placeholder + return data.decode('utf-8', errors='replace') +def entries(): + def fail(error): + raise error + for current, dirs, files in os.walk(root, onerror=fail, followlinks=False): + depth = len(os.path.relpath(current, root).split('/')) if current != root else 0 + dirs[:] = sorted(d for d in dirs if d not in skip and not os.path.islink(os.path.join(current, d))) + if depth >= 10: + dirs[:] = [] + continue + for name in dirs: + yield 'D', os.path.relpath(os.path.join(current, name), root) + for name in sorted(files): + path = os.path.join(current, name) + if not os.path.islink(path) and os.path.isfile(path): + yield 'F', os.path.relpath(path, root) +if args['action'] == 'content': + print(json.dumps({'content': content(args['path'])})) +elif args['action'] == 'list': + paths, folders = [], [] + for kind, path in entries(): + (folders if kind == 'D' else paths).append(path) + print(json.dumps({'filePaths': paths, 'folders': folders})) +else: + query = args['query'].strip().lower() + results = [] + if len(query) >= 2: + for kind, path in entries(): + if kind != 'F': + continue + text = content(path) + if text == placeholder: + continue + for number, line in enumerate(text.split('\\n'), 1): + if query in line.lower(): + results.append({'file': path, 'lineNumber': number, 'lineText': (line.strip() or line)[:2000]}) + if len(results) >= 200: + break + if len(results) >= 200: + break + print(json.dumps(results)) +PY` +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 56d7e46..2e47394 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -289,6 +289,8 @@ contextBridge.exposeInMainWorld('localWorkspace', { contextBridge.exposeInMainWorld('remoteWorkspace', { moduleFiles: { read: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:read', target, moduleName, folderName), + content: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:content', target, moduleName, path, folderName), + search: (target: RemoteWorkspaceTarget, moduleName: string, query: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:search', target, moduleName, query, folderName), image: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:image', target, moduleName, path, folderName), create: (target: RemoteWorkspaceTarget, moduleName: string, path: string, kind: 'file' | 'folder', folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:create', target, moduleName, path, kind, folderName), write: (target: RemoteWorkspaceTarget, moduleName: string, path: string, content: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:write', target, moduleName, path, content, folderName), diff --git a/src/renderer/public/slam-viewer/index.html b/src/renderer/public/slam-viewer/index.html index 01130e4..13313cc 100644 --- a/src/renderer/public/slam-viewer/index.html +++ b/src/renderer/public/slam-viewer/index.html @@ -49,7 +49,7 @@ #cameraPreviewWrap { position:absolute; left:16px; - top:48px; + top:16px; z-index:10; display:none; width:min(260px, calc(100vw - 32px)); @@ -69,7 +69,7 @@ #cameraPreviewWrap.is-collapsed #cameraPreview { display:none; } #cameraPreviewToggle { position:absolute; - right:0; + left:0; top:100%; display:grid; width:30px; @@ -110,6 +110,8 @@ bottom:16px; z-index:10; padding:7px 9px; + max-width:calc(100% - 32px); + box-sizing:border-box; border:1px solid rgba(255,255,255,.24); background:rgba(17,24,39,.34); color:rgba(255,255,255,.82); @@ -369,8 +371,8 @@ .nav2-navigation #rosTopicStatusWrap { bottom:16px; } .real-runtime #topLeftControls { display:none; } .real-runtime #keyGuide { display:none; } - .real-yahboom-teleop:not(.editor-mode) #keyGuide { display:block; } - .real-yahboom-teleop #rosTopicStatusWrap { bottom:72px; } + .real-teleop:not(.editor-mode) #keyGuide { display:block; } + .real-teleop #rosTopicStatusWrap { bottom:50px; } @@ -444,7 +446,8 @@ const ROBOT_PLATFORM = (params.get('platform') || '').trim().toLowerCase(); // Keep the prior real-platform default when an older LMS URL has no platform. const USE_MKMINI_MODEL = ROBOT_PLATFORM === 'mkmini' || (!ROBOT_PLATFORM && REAL_RUNTIME); const USE_YAHBOOM_MODEL = ROBOT_PLATFORM === 'yahboom'; -const REAL_YAHBOOM_TELEOP = REAL_RUNTIME && USE_YAHBOOM_MODEL; +const REAL_MKMINI_TELEOP = REAL_RUNTIME && USE_MKMINI_MODEL; +const REAL_TELEOP = REAL_RUNTIME && (USE_YAHBOOM_MODEL || USE_MKMINI_MODEL); const SHOW_POINT_CLOUD = params.get('pointCloud') === '1'; const SHOW_SENSOR_POINT_CLOUD = REAL_RUNTIME && SHOW_POINT_CLOUD; const SENSOR_POINT_CLOUD_MAX_AGE_MS = 3_000; @@ -495,8 +498,11 @@ const rosTopicStatusList = document.getElementById('rosTopicStatusList'); if (EDITOR_MODE) document.body.classList.add('editor-mode'); if (!EDITOR_MODE) document.body.classList.add(`nav2-${NAV2_VIEW_MODE}`); if (REAL_RUNTIME) document.body.classList.add('real-runtime'); - if (REAL_YAHBOOM_TELEOP && !EDITOR_MODE) { - document.body.classList.add('real-yahboom-teleop'); + if (REAL_TELEOP && !EDITOR_MODE) { + document.body.classList.add('real-teleop'); + if (REAL_MKMINI_TELEOP) { + document.querySelector('#keyGuide div').textContent = 'i 전진 · , 후진 · j/l 주행 중 조향 · k 정지'; + } } if (EDITOR_MODE) responseWaitOverlay?.classList.add('is-hidden'); @@ -895,6 +901,7 @@ let savedMapPointCloudLoaded = false; let sensorPointCloudRequestInFlight = false; let sensorPointCloudRefreshTimer = null; let cameraFrameRequestInFlight = false; +let cameraFrameRetryAt = 0; let cameraFrameRefreshTimer = null; let cameraFrameObjectUrl = null; let pathVisible = false; @@ -2051,28 +2058,47 @@ function setCameraPreviewAvailable(available) { if (cameraPreview) cameraPreview.removeAttribute('src'); } +function hasLiveCameraState() { + return latestSlamState?.topic_status?.camera_image?.available === true + && Date.now() - latestSlamStateReceivedAt < 3000; +} + async function refreshCameraFrame() { if (!ENABLE_CAMERA_PREVIEW || cameraFrameRequestInFlight || document.hidden) return; + // Reuse websocket topic status instead of probing an absent camera with HTTP. + if (!hasLiveCameraState()) { + setCameraPreviewAvailable(false); + return; + } + if (Date.now() < cameraFrameRetryAt) return; cameraFrameRequestInFlight = true; try { const response = await fetch(`${BACKEND_URL}/camera-frame`, { cache: 'no-store', signal: AbortSignal.timeout(2000) }); if (!response.ok) { + cameraFrameRetryAt = Date.now() + 5000; setCameraPreviewAvailable(false); return; } - const nextUrl = URL.createObjectURL(await response.blob()); + const frame = await response.blob(); + if (!hasLiveCameraState() || document.hidden) { + setCameraPreviewAvailable(false); + return; + } + cameraFrameRetryAt = 0; + const nextUrl = URL.createObjectURL(frame); const previousUrl = cameraFrameObjectUrl; cameraFrameObjectUrl = nextUrl; if (cameraPreview) cameraPreview.src = nextUrl; if (previousUrl) URL.revokeObjectURL(previousUrl); } catch (_) { + cameraFrameRetryAt = Date.now() + 5000; setCameraPreviewAvailable(false); } finally { cameraFrameRequestInFlight = false; } } -cameraPreview?.addEventListener('load', () => setCameraPreviewAvailable(true)); +cameraPreview?.addEventListener('load', () => setCameraPreviewAvailable(hasLiveCameraState())); cameraPreview?.addEventListener('error', () => setCameraPreviewAvailable(false)); function drawMiniMap(state) { @@ -3217,7 +3243,7 @@ function connectControlSocket() { if (!CONTROL_WS) return; controlSocket = new WebSocket(CONTROL_WS); controlSocket.addEventListener('close', () => { - if (REAL_YAHBOOM_TELEOP) { + if (REAL_TELEOP) { activeMoveKey = null; stopRobot(); } @@ -3228,8 +3254,16 @@ function connectControlSocket() { }); } -let maxLinear = REAL_YAHBOOM_TELEOP ? 0.1 : 0.22; -let maxAngular = REAL_YAHBOOM_TELEOP ? 0.5 : 2.84; +let maxLinear = REAL_TELEOP ? 0.1 : 0.22; +let maxAngular = REAL_TELEOP ? 0.5 : 2.84; +const mkMiniHeldKeys = new Set(); +const MKMINI_REAL_ANGULAR_SPEED = 0.08; +function teleopKey(event) { + const key = event.key.toLowerCase(); + return REAL_MKMINI_TELEOP + ? ({ arrowup: 'i', arrowdown: ',', arrowleft: 'j', arrowright: 'l', ' ': 'k' }[key] ?? key) + : key; +} let activeMoveKey = null; let teleopInterval = null; let singleMoveStopTimer = null; @@ -3253,7 +3287,7 @@ const speedBindings = { }; function canUseTeleop(event) { - if (REAL_RUNTIME && (!REAL_YAHBOOM_TELEOP || !realTeleopReady())) return false; + if (EDITOR_MODE || (REAL_RUNTIME && (!REAL_TELEOP || !realTeleopReady()))) return false; if (!document.hasFocus()) return false; if (document.hidden || event.ctrlKey || event.altKey || event.metaKey || event.target?.isContentEditable) return false; const tag = event.target?.tagName?.toLowerCase(); @@ -3292,7 +3326,7 @@ function flushQueuedCmdVel() { function sendCmdVel(linear, angular) { const payload = { type:'cmd_vel', linear, angular }; - if (REAL_YAHBOOM_TELEOP) { + if (REAL_TELEOP) { // Never queue physical motion for replay after a connection recovers. if (!controlSocket || controlSocket.readyState !== WebSocket.OPEN) return; if ((linear || angular) && !realTeleopReady()) return; @@ -3346,6 +3380,7 @@ function setSimulatorMkMiniSteering(angle) { } function stopRobot() { + mkMiniHeldKeys.clear(); if (teleopInterval) { window.clearInterval(teleopInterval); teleopInterval = null; @@ -3359,13 +3394,25 @@ function stopRobot() { function sendActiveMoveCommand() { if (!activeMoveKey) return; - if (REAL_YAHBOOM_TELEOP && (!realTeleopReady() || document.hidden || !document.hasFocus())) { + if (REAL_TELEOP && (!realTeleopReady() || document.hidden || !document.hasFocus())) { activeMoveKey = null; stopRobot(); return; } const [linearSign, angularSign] = moveBindings[activeMoveKey]; if (linearSign === 0 && angularSign === 0) return; + if (REAL_MKMINI_TELEOP) { + let drive = 0; + let steering = 0; + for (const key of mkMiniHeldKeys) { + drive += moveBindings[key][0]; + steering += moveBindings[key][1]; + } + // The TOD adapter uses abs(linear) for steering: keep wheel direction + // consistent in reverse, and never turn a steering-only key into motion. + sendCmdVel(Math.sign(drive) * maxLinear, drive ? Math.sign(steering) * MKMINI_REAL_ANGULAR_SPEED : 0); + return; + } if (USE_MKMINI_MODEL && !REAL_RUNTIME) { if (activeMoveKey === 'j' || activeMoveKey === 'l') { @@ -3389,6 +3436,7 @@ function sendActiveMoveCommand() { function startActiveMoveCommand() { sendActiveMoveCommand(); + if (!activeMoveKey) return; if (teleopInterval) return; teleopInterval = window.setInterval(sendActiveMoveCommand, TELEOP_COMMAND_INTERVAL_MS); } @@ -3406,7 +3454,7 @@ function sendSingleMoveCommand(key) { window.addEventListener('keydown', (event) => { if (!canUseTeleop(event)) return; - const key = event.key.toLowerCase(); + const key = teleopKey(event); if (event.repeat) { if (moveBindings[key]) event.preventDefault(); @@ -3415,7 +3463,7 @@ window.addEventListener('keydown', (event) => { if (speedBindings[key]) { event.preventDefault(); - if (REAL_YAHBOOM_TELEOP) return; + if (REAL_TELEOP) return; const [linearScale, angularScale] = speedBindings[key]; maxLinear = Math.max(0.01, maxLinear * linearScale); maxAngular = Math.max(0.1, maxAngular * angularScale); @@ -3431,7 +3479,8 @@ window.addEventListener('keydown', (event) => { stopRobot(); return; } - if (REAL_YAHBOOM_TELEOP) { + if (REAL_TELEOP) { + if (REAL_MKMINI_TELEOP) mkMiniHeldKeys.add(key); activeMoveKey = key; startActiveMoveCommand(); return; @@ -3451,7 +3500,7 @@ window.addEventListener('keydown', (event) => { }); window.addEventListener('keyup', (event) => { - if (REAL_YAHBOOM_TELEOP && event.key.toLowerCase() === activeMoveKey) { + if (REAL_TELEOP && (teleopKey(event) === activeMoveKey || (REAL_MKMINI_TELEOP && mkMiniHeldKeys.has(teleopKey(event))))) { activeMoveKey = null; stopRobot(); return; @@ -3468,7 +3517,7 @@ window.addEventListener('blur', () => { }); function releaseRealTeleop() { - if (!REAL_YAHBOOM_TELEOP || !activeMoveKey) return; + if (!REAL_TELEOP || !activeMoveKey) return; activeMoveKey = null; stopRobot(); } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index c14ab98..51d1c8a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -82,6 +82,7 @@ import WorkspaceModuleTabs from './features/workspace/WorkspaceModuleTabs' import WorkspaceNav, { type WorkspaceActivity } from './features/workspace/WorkspaceNav' import WorkspaceTopBar from './features/workspace/WorkspaceTopBar' import WorkspaceCameraPanel from './features/workspace/WorkspaceCameraPanel' +import { useRemoteEditorFile } from './features/workspace/useRemoteEditorFile' import WorkspaceVisualizationPanel from './features/workspace/WorkspaceVisualizationPanel' import { GitFileContextMenu, PowerActionModal, SourceSettingsSaveConfirmModal, SubmitConfirmModal, TreeTargetDialog, UnsavedEditorCloseModal } from './features/workspace/WorkspaceModals' @@ -1679,6 +1680,7 @@ function LocalPcWorkspaceMockup({ setOpenExampleEditorTabs, loadedModuleFolders, setLoadedModuleFolders, + loadedModuleFilePaths, setLoadedModuleFilePaths, setModuleFileLoadingByName, loadedModuleRoots, @@ -1712,7 +1714,7 @@ function LocalPcWorkspaceMockup({ return nextDirtyFiles }) setSelectedGitTarget(null) - if (module) await loadModuleFiles(module) + if (module) await loadModuleFiles(module, true) await refreshGitStatuses(null) onTitlebarNotice('변경사항을 되돌렸습니다', 'info') } catch (error) { @@ -1786,7 +1788,7 @@ function LocalPcWorkspaceMockup({ : await window.localWorkspace.git.teamPull(module.name, clonePathPreview, module.folderName) if (!result.ok) throw new Error(`[${module.name}] ${result.message}`) } - await Promise.all(installedGitModules.map((module) => loadModuleFiles(module))) + await Promise.all(installedGitModules.map((module) => loadModuleFiles(module, true))) await refreshGitStatuses(null) onTitlebarNotice('팀 변경사항을 받았습니다', 'info') } catch (error) { @@ -2321,6 +2323,17 @@ function LocalPcWorkspaceMockup({ const editorLanguage = currentActiveEditorFile ? getEditorLanguage(currentActiveEditorFile) : 'plaintext' const activeFileIsImage = currentActiveEditorFile ? /\.(png|jpe?g|webp|gif|svg)$/i.test(currentActiveEditorFile) : false const activeImagePreviewSource = currentActiveEditorFile ? imagePreviewSources[currentActiveEditorFile] : undefined + const remoteEditorFile = useRemoteEditorFile({ + enabled: remoteWorkspace, + targetKey: remoteWorkspaceTargetKey, + getTarget: getRemoteWorkspaceTarget, + module: selectedModule, + path: currentActiveEditorFile, + files: editorFiles, + dirty: dirtyEditorFiles, + setFiles: setEditorFiles, + setSaved: setSavedEditorFiles + }) useEffect(() => { if (!currentActiveEditorFile || !activeFileIsImage || currentActiveEditorFile.startsWith('examples/example-code/') || !selectedModule) return if (imagePreviewSources[currentActiveEditorFile]) return @@ -3043,9 +3056,11 @@ function LocalPcWorkspaceMockup({ onClose() } const revealSearchMatch = (lineNumber: number, query: string): boolean => { + if (remoteEditorFile.loading || remoteEditorFile.error) return false if (!editorRef.current) return false const editor = editorRef.current const model = editor.getModel() + if (!model || model.isDisposed()) return false const lineText = model?.getLineContent(lineNumber) ?? '' const matchIndex = query ? lineText.toLowerCase().indexOf(query.toLowerCase()) : -1 const startColumn = matchIndex >= 0 ? matchIndex + 1 : 1 @@ -3227,9 +3242,14 @@ function LocalPcWorkspaceMockup({ setModuleSearchQuery, activeModuleSearchQuery, moduleSearchRunning, + moduleSearchError, moduleSearchResults, openSearchResult } = useWorkspaceSearch({ + remoteScopeKey: remoteWorkspace ? remoteWorkspaceTargetKey : undefined, + remoteSearch: remoteWorkspace && selectedModule + ? (query) => window.remoteWorkspace.moduleFiles.search(getRemoteWorkspaceTarget(), selectedModule.name, query, selectedModule.folderName) + : undefined, searchActive, selectedModule, editorFiles, @@ -3420,10 +3440,11 @@ function LocalPcWorkspaceMockup({ const controlRemainingSeconds = controlRemainingMs === null ? null : Math.ceil(controlRemainingMs / 1000) const showControlCountdown = canReleaseControl && controlRemainingSeconds !== null const controlCountdownDanger = showControlCountdown && controlRemainingSeconds < 5 * 60 + const cameraAvailable = Boolean(curriculumWeek.detail.camera?.url?.trim()) return (
- {cameraOpen && workspaceVisible && setCameraOpen(false)} />} setCameraOpen((open) => !open)} dark={dark} @@ -3675,6 +3697,7 @@ function LocalPcWorkspaceMockup({ moduleSearchQuery={moduleSearchQuery} activeModuleSearchQuery={activeModuleSearchQuery} moduleSearchRunning={moduleSearchRunning} + moduleSearchError={moduleSearchError} moduleSearchResults={moduleSearchResults} onStartCreateFile={startCreateFile} onRefreshModuleFiles={refreshModuleFiles} @@ -3917,6 +3940,9 @@ function LocalPcWorkspaceMockup({ /> + filePaths?: string[] folders: string[] }> image: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => Promise<{ dataUrl: string }> @@ -298,6 +299,8 @@ interface Window { remoteWorkspace: { moduleFiles: { read: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ReturnType + content: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => Promise<{ content: string }> + search: (target: RemoteWorkspaceTarget, moduleName: string, query: string, folderName?: string) => Promise> image: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ReturnType create: (target: RemoteWorkspaceTarget, moduleName: string, path: string, kind: 'file' | 'folder', folderName?: string) => ReturnType write: (target: RemoteWorkspaceTarget, moduleName: string, path: string, content: string, folderName?: string) => ReturnType diff --git a/src/renderer/src/features/curriculum/CurriculumDetailPanel.tsx b/src/renderer/src/features/curriculum/CurriculumDetailPanel.tsx index d36d176..34a0eaa 100644 --- a/src/renderer/src/features/curriculum/CurriculumDetailPanel.tsx +++ b/src/renderer/src/features/curriculum/CurriculumDetailPanel.tsx @@ -1,11 +1,13 @@ import { CheckCircle2, FileText } from 'lucide-react' import type React from 'react' +import { useState } from 'react' type CurriculumDetailWeek = { week: number title: string topics: string[] detail: { + coverImage?: { downloadUrl: string } | null overview: string topicDetails: string[] practiceContents?: Array<{ @@ -31,16 +33,34 @@ export default function CurriculumDetailPanel({ allowLearningStepSelection = false, onSelectLearningStep }: CurriculumDetailPanelProps): React.JSX.Element { + const [failedImageUrl, setFailedImageUrl] = useState(null) + const imageUrl = week.detail.coverImage?.downloadUrl + const hasCover = Boolean(imageUrl && failedImageUrl !== imageUrl) return (

상세정보

{selected ? ( <> -
-

{week.week}주차 개요

-

{week.title}

-

{week.detail.overview}

+
+
+
+

{week.week}주차 개요

+

{week.title}

+
+ {hasCover && ( +
+ {`${week.week}주차 setFailedImageUrl(imageUrl ?? null)} /> + + )} +
+

{week.detail.overview}

+
+
diff --git a/src/renderer/src/features/curriculum/types.ts b/src/renderer/src/features/curriculum/types.ts index 644c340..9d31dba 100644 --- a/src/renderer/src/features/curriculum/types.ts +++ b/src/renderer/src/features/curriculum/types.ts @@ -41,6 +41,7 @@ export type CurriculumWeek = { duration: string github: string detail: { + coverImage?: DownloadResource | null camera?: { url: string } | null overview: string learningSteps?: LearningStep[] diff --git a/src/renderer/src/features/workspace/ModuleListView.tsx b/src/renderer/src/features/workspace/ModuleListView.tsx index e55d1ab..877aaac 100644 --- a/src/renderer/src/features/workspace/ModuleListView.tsx +++ b/src/renderer/src/features/workspace/ModuleListView.tsx @@ -752,6 +752,7 @@ function ModuleAddDialog({ + @@ -933,6 +934,7 @@ function ModuleImportDialog({ + diff --git a/src/renderer/src/features/workspace/WorkspaceEditorPane.tsx b/src/renderer/src/features/workspace/WorkspaceEditorPane.tsx index 344b3e7..546615c 100644 --- a/src/renderer/src/features/workspace/WorkspaceEditorPane.tsx +++ b/src/renderer/src/features/workspace/WorkspaceEditorPane.tsx @@ -1,7 +1,10 @@ import Editor, { type BeforeMount, type OnMount } from '@monaco-editor/react' -import { FileText } from 'lucide-react' +import { FileText, Loader2, RotateCw } from 'lucide-react' type Props = { + fileLoading?: boolean + fileError?: string + onRetryFile?: () => void dark: boolean textDim: string editorPaneRef: React.RefObject @@ -21,6 +24,9 @@ type Props = { } function WorkspaceEditorPane({ + fileLoading, + fileError, + onRetryFile, dark, textDim, editorPaneRef, @@ -70,6 +76,13 @@ function WorkspaceEditorPane({ )}
+ ) : fileLoading || fileError ? ( +
+ {fileError ? <> +

파일을 불러오지 못했습니다.

+ + : <>파일 불러오는 중...} +
) : ( )} diff --git a/src/renderer/src/features/workspace/WorkspaceFilePanel.tsx b/src/renderer/src/features/workspace/WorkspaceFilePanel.tsx index 043719a..5ace4dc 100644 --- a/src/renderer/src/features/workspace/WorkspaceFilePanel.tsx +++ b/src/renderer/src/features/workspace/WorkspaceFilePanel.tsx @@ -57,6 +57,7 @@ type WorkspaceFilePanelProps = { moduleSearchQuery: string activeModuleSearchQuery: string moduleSearchRunning: boolean + moduleSearchError?: string | null moduleSearchResults: SearchResult[] onStartCreateFile: (kind: 'file' | 'folder') => void onRefreshModuleFiles: () => void @@ -171,6 +172,7 @@ export default function WorkspaceFilePanel({ moduleSearchQuery, activeModuleSearchQuery, moduleSearchRunning, + moduleSearchError, moduleSearchResults, onStartCreateFile, onRefreshModuleFiles, @@ -232,6 +234,7 @@ export default function WorkspaceFilePanel({ moduleSearchQuery={moduleSearchQuery} activeModuleSearchQuery={activeModuleSearchQuery} moduleSearchRunning={moduleSearchRunning} + moduleSearchError={moduleSearchError} moduleSearchResults={moduleSearchResults} onSearchChange={onSearchChange} onOpenSearchResult={onOpenSearchResult} @@ -284,12 +287,13 @@ function SearchPanel({ moduleSearchQuery, activeModuleSearchQuery, moduleSearchRunning, + moduleSearchError, moduleSearchResults, onSearchChange, onOpenSearchResult }: Pick): React.JSX.Element { return (
@@ -318,6 +322,8 @@ function SearchPanel({

검색 중...

) : moduleSearchRunning ? (

검색 중...

+ ) : moduleSearchError ? ( +

{moduleSearchError}

) : moduleSearchResults.length === 0 ? (

검색 결과가 없습니다.

) : ( diff --git a/src/renderer/src/features/workspace/WorkspaceTopBar.tsx b/src/renderer/src/features/workspace/WorkspaceTopBar.tsx index 641b634..64c7d6e 100644 --- a/src/renderer/src/features/workspace/WorkspaceTopBar.tsx +++ b/src/renderer/src/features/workspace/WorkspaceTopBar.tsx @@ -3,6 +3,7 @@ import type React from 'react' import Tooltip from '../../components/Tooltip' type WorkspaceTopBarProps = { + cameraAvailable: boolean cameraOpen: boolean onToggleCamera: () => void dark: boolean @@ -36,6 +37,7 @@ type WorkspaceTopBarProps = { } export default function WorkspaceTopBar({ + cameraAvailable, cameraOpen, onToggleCamera, dark, @@ -97,9 +99,11 @@ export default function WorkspaceTopBar({ 시각화 - + {cameraAvailable && ( + + )} {showControlCountdown && ( <> diff --git a/src/renderer/src/features/workspace/useRemoteEditorFile.ts b/src/renderer/src/features/workspace/useRemoteEditorFile.ts new file mode 100644 index 0000000..45fa8d9 --- /dev/null +++ b/src/renderer/src/features/workspace/useRemoteEditorFile.ts @@ -0,0 +1,49 @@ +import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' +import type { ModuleRow } from './types' + +export function useRemoteEditorFile(options: { + enabled: boolean + targetKey: string + getTarget: () => RemoteWorkspaceTarget + module: ModuleRow | null + path: string | null + files: Record + dirty: Record + setFiles: Dispatch>> + setSaved: Dispatch>> +}) { + const latest = useRef(options) + latest.current = options + const pending = useRef(new Map>()) + const [failure, setFailure] = useState<{ key: string; message: string } | null>(null) + const [attempt, setAttempt] = useState(0) + const { enabled, path, module, targetKey, files } = options + const eligible = enabled && !!path && !!module && path.startsWith(`modules/${module.name}/`) && !/\.(png|jpe?g|webp|gif|svg)$/i.test(path) + const key = JSON.stringify([targetKey, module?.name, module?.folderName, path]) + const cached = path ? files[path] : undefined + useEffect(() => { + if (!eligible || !path || !module || cached !== undefined) return + let cancelled = false + setFailure(null) + let request = pending.current.get(key) + if (!request) { + request = window.remoteWorkspace.moduleFiles.content(latest.current.getTarget(), module.name, path, module.folderName) + pending.current.set(key, request) + const cleanup = (): void => { pending.current.delete(key) } + void request.then(cleanup, cleanup) + } + void request.then(({ content }) => { + if (cancelled || latest.current.dirty[path] || latest.current.files[path] !== undefined) return + latest.current.setFiles((current) => current[path] === undefined ? { ...current, [path]: content } : current) + latest.current.setSaved((current) => ({ ...current, [path]: content })) + }).catch((error) => { + if (!cancelled) setFailure({ key, message: error instanceof Error ? error.message : String(error) }) + }) + return () => { cancelled = true } + }, [eligible, key, cached, attempt]) + return { + loading: eligible && cached === undefined && failure?.key !== key, + error: eligible && cached === undefined && failure?.key === key ? failure.message : undefined, + retry: () => { setFailure(null); setAttempt((value) => value + 1) } + } +} diff --git a/src/renderer/src/features/workspace/useWorkspaceFileActions.ts b/src/renderer/src/features/workspace/useWorkspaceFileActions.ts index 0ddc596..3d2e48c 100644 --- a/src/renderer/src/features/workspace/useWorkspaceFileActions.ts +++ b/src/renderer/src/features/workspace/useWorkspaceFileActions.ts @@ -27,6 +27,7 @@ type UseWorkspaceFileActionsOptions = { setActiveExampleEditorFile: Dispatch> setOpenExampleEditorTabs: Dispatch> loadedModuleFolders: Record + loadedModuleFilePaths: Record setLoadedModuleFolders: Dispatch>> setLoadedModuleFilePaths: Dispatch>> setModuleFileLoadingByName: Dispatch>> @@ -52,7 +53,7 @@ type UseWorkspaceFileActionsOptions = { } type UseWorkspaceFileActionsResult = { - loadModuleFiles: (module: ModuleRow) => Promise + loadModuleFiles: (module: ModuleRow, refreshContents?: boolean) => Promise startCreateFile: (kind: 'file' | 'folder') => void commitCreateFile: () => Promise getTreeDisplayPath: (path: string) => string @@ -95,6 +96,7 @@ export function useWorkspaceFileActions({ setActiveExampleEditorFile, setOpenExampleEditorTabs, loadedModuleFolders, + loadedModuleFilePaths, setLoadedModuleFolders, setLoadedModuleFilePaths, setModuleFileLoadingByName, @@ -115,14 +117,16 @@ export function useWorkspaceFileActions({ refreshGitStatuses }: UseWorkspaceFileActionsOptions): UseWorkspaceFileActionsResult { const pendingFileReads = useRef(new Map>()) + const dirtyFilesRef = useRef(dirtyEditorFiles) + dirtyFilesRef.current = dirtyEditorFiles - const loadModuleFiles = async (module: ModuleRow): Promise => { + const loadModuleFiles = async (module: ModuleRow, refreshContents = false): Promise => { const target = remoteWorkspace ? getRemoteWorkspaceTarget() : null - const key = JSON.stringify([target, clonePathPreview, module.name, module.folderName]) + const key = JSON.stringify([target, clonePathPreview, module.name, module.folderName, refreshContents]) const pending = pendingFileReads.current.get(key) if (pending) return pending - const request = readModuleFiles(module, target) + const request = readModuleFiles(module, target, refreshContents) pendingFileReads.current.set(key, request) try { await request @@ -131,7 +135,7 @@ export function useWorkspaceFileActions({ } } - const readModuleFiles = async (module: ModuleRow, target: RemoteWorkspaceTarget | null): Promise => { + const readModuleFiles = async (module: ModuleRow, target: RemoteWorkspaceTarget | null, refreshContents: boolean): Promise => { setModuleFileLoadingByName((loading) => ({ ...loading, [module.name]: true })) try { const result = target @@ -141,14 +145,20 @@ export function useWorkspaceFileActions({ const nextFolders = result.folders.length ? result.folders : [] setEditorFiles((files) => { + if (target && result.filePaths && !refreshContents) return files const merged = { ...files } + if (target && result.filePaths) { + Object.keys(merged).forEach((path) => { + if (path.startsWith(`modules/${module.name}/`) && !dirtyFilesRef.current[path]) delete merged[path] + }) + } Object.entries(nextFiles).forEach(([path, content]) => { - if (!dirtyEditorFiles[path]) merged[path] = content + if (!dirtyFilesRef.current[path]) merged[path] = content }) return merged }) setSavedEditorFiles((files) => ({ ...files, ...nextFiles })) - setLoadedModuleFilePaths((paths) => ({ ...paths, [module.name]: Object.keys(nextFiles) })) + setLoadedModuleFilePaths((paths) => ({ ...paths, [module.name]: result.filePaths ?? Object.keys(nextFiles) })) setLoadedModuleFolders((folders) => ({ ...folders, [module.name]: nextFolders })) setLoadedModuleRoots((roots) => ({ ...roots, [module.name]: result.absoluteRoot })) } catch (error) { @@ -165,7 +175,7 @@ export function useWorkspaceFileActions({ if (!selectedModule) return const rootPath = `modules/${selectedModule.name}` const selectedPathInModule = selectedTreePath?.startsWith(`${rootPath}/`) || selectedTreePath === rootPath ? selectedTreePath : null - const selectedIsFile = selectedPathInModule ? editorFiles[selectedPathInModule] !== undefined : false + const selectedIsFile = selectedPathInModule ? editorFiles[selectedPathInModule] !== undefined || (loadedModuleFilePaths[selectedModule.name] ?? []).includes(selectedPathInModule) : false const parent = selectedPathInModule ? selectedIsFile ? selectedPathInModule.split('/').slice(0, -1).join('/') @@ -244,6 +254,7 @@ export function useWorkspaceFileActions({ const saveEditorFile = async (file: EditorFilePath): Promise => { if (!selectedModule || file.startsWith('examples/example-code/')) return + if (editorFiles[file] === undefined) throw new Error('파일 내용을 먼저 불러와야 합니다.') if (remoteWorkspace) { await window.remoteWorkspace.moduleFiles.write(getRemoteWorkspaceTarget(), selectedModule.name, file, editorFiles[file] ?? '', selectedModule.folderName) } else { @@ -403,7 +414,8 @@ export function useWorkspaceFileActions({ if (pasteParent === sourcePath || pasteParent.startsWith(`${sourcePath}/`)) return const rootPath = `modules/${selectedModule.name}` const allFolders = Array.from(new Set([...(loadedModuleFolders[selectedModule.name] ?? []), ...createdModuleFolders.filter((folder) => folder.startsWith(rootPath))])) - const targetBasePath = makeUniquePath(`${pasteParent}/${getPathName(sourcePath)}`, editorFiles, allFolders) + const knownFiles = Object.fromEntries((loadedModuleFilePaths[selectedModule.name] ?? []).map((path) => [path, ''])) + const targetBasePath = makeUniquePath(`${pasteParent}/${getPathName(sourcePath)}`, { ...knownFiles, ...editorFiles }, allFolders) try { if (remoteWorkspace) { await window.remoteWorkspace.moduleFiles.copy(getRemoteWorkspaceTarget(), selectedModule.name, sourcePath, targetBasePath, treeClipboard.mode, selectedModule.folderName) @@ -447,7 +459,7 @@ export function useWorkspaceFileActions({ const refreshModuleFiles = (): void => { if (!selectedModule) return setNewFileDraft(null) - void loadModuleFiles(selectedModule) + void loadModuleFiles(selectedModule, true) } return { diff --git a/src/renderer/src/features/workspace/useWorkspaceSearch.ts b/src/renderer/src/features/workspace/useWorkspaceSearch.ts index 5a89b99..4b8a734 100644 --- a/src/renderer/src/features/workspace/useWorkspaceSearch.ts +++ b/src/renderer/src/features/workspace/useWorkspaceSearch.ts @@ -14,6 +14,8 @@ type PendingReveal = { } type UseWorkspaceSearchOptions = { + remoteSearch?: (query: string) => Promise + remoteScopeKey?: string searchActive: boolean selectedModule: ModuleRow | null editorFiles: Record @@ -24,6 +26,7 @@ type UseWorkspaceSearchOptions = { } type UseWorkspaceSearchResult = { + moduleSearchError: string | null moduleSearchInputRef: React.RefObject moduleSearchQuery: string setModuleSearchQuery: React.Dispatch> @@ -35,6 +38,8 @@ type UseWorkspaceSearchResult = { } export function useWorkspaceSearch({ + remoteSearch, + remoteScopeKey, searchActive, selectedModule, editorFiles, @@ -47,6 +52,9 @@ export function useWorkspaceSearch({ const [debouncedModuleSearchQuery, setDebouncedModuleSearchQuery] = useState('') const [moduleSearchResults, setModuleSearchResults] = useState([]) const [moduleSearchRunning, setModuleSearchRunning] = useState(false) + const [moduleSearchError, setModuleSearchError] = useState(null) + const remoteSearchRef = useRef(remoteSearch) + remoteSearchRef.current = remoteSearch const [pendingReveal, setPendingReveal] = useState(null) const moduleSearchInputRef = useRef(null) const searchRunIdRef = useRef(0) @@ -64,8 +72,14 @@ export function useWorkspaceSearch({ useEffect(() => { if (!pendingReveal) return - const revealed = onRevealSearchMatch(pendingReveal.lineNumber, pendingReveal.query) - if (revealed) setPendingReveal(null) + const reveal = (): boolean => { + const revealed = onRevealSearchMatch(pendingReveal.lineNumber, pendingReveal.query) + if (revealed) setPendingReveal(null) + return revealed + } + if (reveal()) return + const timer = window.setInterval(() => { if (reveal()) window.clearInterval(timer) }, 100) + return () => window.clearInterval(timer) }, [activeEditorFile, onRevealSearchMatch, pendingReveal]) useEffect(() => { @@ -84,6 +98,7 @@ export function useWorkspaceSearch({ useEffect(() => { const query = debouncedModuleSearchQuery.trim() + setModuleSearchError(null) if (!searchActive || !selectedModule || query.length < 2) { setModuleSearchResults([]) setModuleSearchRunning(false) @@ -102,7 +117,7 @@ export function useWorkspaceSearch({ if (cancelled || searchRunIdRef.current !== searchRunId) return const chunkEnd = Math.min(moduleFilePaths.length, fileIndex + 12) - for (; fileIndex < chunkEnd; fileIndex += 1) { + for (; fileIndex < chunkEnd && nextResults.length < 200; fileIndex += 1) { const file = moduleFilePaths[fileIndex] const content = editorFiles[file] ?? '' const lines = content.split('\n') @@ -127,14 +142,29 @@ export function useWorkspaceSearch({ } } - const timer = window.setTimeout(searchChunk, 0) + const searchRemote = remoteSearchRef.current + const timer = window.setTimeout(() => { + if (!searchRemote) { searchChunk(); return } + void searchRemote(query).then((results) => { + if (cancelled) return + nextResults.push(...results.filter((result) => editorFiles[result.file] === undefined)) + searchChunk() + }).catch((error) => { + if (cancelled) return + console.error('[module-files] search failed', error) + setModuleSearchResults([]) + setModuleSearchError('검색하지 못했습니다. 다시 시도해주세요.') + setModuleSearchRunning(false) + }) + }, 0) return () => { cancelled = true window.clearTimeout(timer) } - }, [debouncedModuleSearchQuery, editorFiles, moduleFilePathKey, searchActive, selectedModule]) + }, [debouncedModuleSearchQuery, editorFiles, moduleFilePathKey, searchActive, selectedModule, remoteScopeKey]) return { + moduleSearchError, moduleSearchInputRef, moduleSearchQuery, setModuleSearchQuery, diff --git a/tests/camera-module-runtime.cjs b/tests/camera-module-runtime.cjs new file mode 100644 index 0000000..33243f8 --- /dev/null +++ b/tests/camera-module-runtime.cjs @@ -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.'); diff --git a/tests/camera-preview-polling.cjs b/tests/camera-preview-polling.cjs new file mode 100644 index 0000000..38ec725 --- /dev/null +++ b/tests/camera-preview-polling.cjs @@ -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; }); diff --git a/tests/module-file-refresh.cjs b/tests/module-file-refresh.cjs new file mode 100644 index 0000000..57f9aa6 --- /dev/null +++ b/tests/module-file-refresh.cjs @@ -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; }); diff --git a/tests/remote-editor-file.cjs b/tests/remote-editor-file.cjs new file mode 100644 index 0000000..d8797bf --- /dev/null +++ b/tests/remote-editor-file.cjs @@ -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; }); diff --git a/tests/remote-module-files.cjs b/tests/remote-module-files.cjs new file mode 100644 index 0000000..497f22c --- /dev/null +++ b/tests/remote-module-files.cjs @@ -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 }); +} diff --git a/tests/yahboom-teleop.cjs b/tests/yahboom-teleop.cjs index 05f1296..abc880b 100644 --- a/tests/yahboom-teleop.cjs +++ b/tests/yahboom-teleop.cjs @@ -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');