diff --git a/openpilot/starpilot/galaxy/developer_settings.py b/openpilot/starpilot/galaxy/developer_settings.py new file mode 100644 index 0000000000..8a127d51c0 --- /dev/null +++ b/openpilot/starpilot/galaxy/developer_settings.py @@ -0,0 +1,29 @@ +"""Galaxy-only, next-drive developer preferences.""" + +from openpilot.starpilot.longitudinal.voacc import KEY, DESCRIPTION +from openpilot.starpilot.saved_document import commit_exact +from openpilot.starpilot.saved_source import read_saved +from openpilot.starpilot.ui.feature_settings_state import FeatureRow, FeatureSettingsState + + +class DeveloperSettingsOwner: + def __init__(self, params, authorized): + self.params = params + self.authorized = authorized + + def snapshot(self): + raw, readable = read_saved(self.params, KEY, 8) + valid = readable and raw in (None, b"0", b"1") + parked = self.authorized() + row = FeatureRow(KEY, "Use VOACC", ("On" if raw == b"1" else "Off") if valid else "Invalid saved value", + raw, ("Off", "On") if valid else (), available=parked and valid, + reason=(DESCRIPTION if parked else "Park the vehicle to change this setting.") if valid else + "Invalid saved value", default_value="Off") + return FeatureSettingsState("developer", "Developer driving options", "Changes apply next drive.", (row,), parked) + + def apply(self, request): + if request.key != KEY or request.value not in ("Off", "On") or request.expected not in (None, b"0", b"1"): + return False + result = commit_exact(self.params, key=KEY, max_bytes=8, raw=b"1" if request.value == "On" else b"0", + expected=request.expected, authorized=self.authorized, temp_prefix=".voacc-") + return result.committed and result.verified diff --git a/openpilot/starpilot/galaxy/tests/browser/developer-settings.cjs b/openpilot/starpilot/galaxy/tests/browser/developer-settings.cjs new file mode 100644 index 0000000000..c7208200ea --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/browser/developer-settings.cjs @@ -0,0 +1,71 @@ +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright') +const assert = require('node:assert/strict') + +;(async () => { + const browser = await chromium.launch({ headless: true, + ...(process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}) }) + try { + const page = await browser.newPage() + const errors = [], saves = [] + let enabled = false, parked = true + page.on('pageerror', error => errors.push(error.message)) + await page.route('**/developer-settings-test', route => route.fulfill({ contentType: 'text/html', body: + '
' })) + await page.route('**/api/**', route => { + const path = new URL(route.request().url()).pathname + let data + if (path.endsWith('/connect/provider')) data = { active: 'comma', selected: 'comma', revision: 'a'.repeat(64), + restartRequired: false, canSelect: parked, providers: [ + { id: 'comma', label: 'comma', url: 'https://connect.comma.ai' }, + { id: 'konik', label: 'Konik', url: 'https://stable.konik.ai' }] } + else if (path.endsWith('/pages/developer')) data = { page: 'developer', title: 'Developer driving options', + view: 'developer', parked, rows: [{ label: 'Use VOACC', value: enabled ? 'On' : 'Off', choices: ['Off', 'On'], + available: parked, action: true, reason: 'Vision-only lead selection. Applies next drive.', defaultValue: 'Off' }] } + else if (path.endsWith('/pages/hub')) data = { page: 'hub', title: 'Driving Controls', view: 'hub', parked, + rows: [['slc', 'Speed Limit Controller'], ['curve', 'Curve Speed Controller'], ['conditional', 'Conditional Driving Modes'], + ['profiles', 'Long Planner'], ['aggressive', 'Aggressive Personality']].map(([page, label]) => ({ page, label, available: true })) } + else if (path.endsWith('/preview')) { + saves.push(route.request().postDataJSON()) + data = { intent: 'voacc', proposed: 'On', question: 'Set Use VOACC to On?' } + } else if (path.endsWith('/confirm')) { enabled = true; data = { saved: true } } + else return route.fulfill({ status: 404, json: { error: 'Unexpected endpoint' } }) + return route.fulfill({ json: data }) + }) + await page.goto((process.env.GALAXY_URL || 'http://127.0.0.1:8765/') + 'developer-settings-test') + await page.evaluate(async () => { + const { createApp } = await import('./vendor/vue/vue.esm-browser.js') + const { SettingsPage } = await import('./js/settings.js') + window.mount = section => { + window.app?.unmount() + window.app = createApp(SettingsPage, { mode: 'local', unauthorized() {}, initialSection: section }) + window.vm = window.app.mount('#test') + } + mount('developer') + }) + const toggle = page.getByRole('switch', { name: 'Use VOACC', exact: true }) + await toggle.waitFor() + assert.equal(await toggle.isChecked(), false) + await toggle.click() + await page.waitForFunction(() => vm.state.status === 'ready' && vm.state.data.rows[0].value === 'On') + assert.equal(await toggle.isChecked(), true) + assert.equal(saves[0].value, 'On') + for (const width of [320, 1280]) { + await page.setViewportSize({ width, height: 900 }) + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false) + } + await page.getByRole('button', { name: 'Longitudinal (Speed & Following)', exact: true }).click() + await page.getByText('Long Planner', { exact: true }).waitFor() + await page.waitForFunction(() => document.querySelectorAll('.gx-panel-stage .gx-row').length === 5) + const order = await page.locator('.gx-panel-stage .gx-row').allTextContents() + assert.equal(order.length, 5) + for (const [index, label] of ['Long Planner', 'Conditional Driving Modes', 'Curve Speed Controller', + 'Speed Limit Controller', 'Aggressive Personality'].entries()) assert.ok(order[index].includes(label)) + assert.equal(await toggle.count(), 0) + parked = false + await page.getByRole('button', { name: 'Developer', exact: true }).click() + await toggle.waitFor() + assert.equal(await toggle.isDisabled(), true) + assert.deepEqual(errors, []) + console.log('Developer VOACC: saved toggle, parked guard, phone/desktop layout and longitudinal ordering passed.') + } finally { await browser.close() } +})().catch(error => { console.error(error); process.exit(1) }) diff --git a/openpilot/starpilot/galaxy/tests/browser/layout-scenes.cjs b/openpilot/starpilot/galaxy/tests/browser/layout-scenes.cjs new file mode 100644 index 0000000000..54e61d0297 --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/browser/layout-scenes.cjs @@ -0,0 +1,94 @@ +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright') +const { execFileSync } = require('node:child_process') +const { readFileSync, mkdirSync } = require('node:fs') +const path = require('node:path') +const assert = require('node:assert/strict') + +const web = path.resolve(__dirname, '../../web') +const fixtures = JSON.parse(execFileSync(process.env.PYTHON || 'python3', ['-c', ` +import json +from openpilot.starpilot.ui.onroad_customization import default_document, customization_metadata +from openpilot.starpilot.system.android_auto.projection_layout import default_layout_for_viewport, layout_metadata_for_viewport +doc=default_document() +for layout in doc['layouts'].values(): + for widget in layout.values(): widget['enabled']=True +native=dict(document=doc,defaults=doc,metadata=customization_metadata(),revision='scenes',editable=True,valid=True,activeProfile='large',supportedProfiles=['large','compact']) +layout=default_layout_for_viewport((2880,1080)) +for widget in layout['widgets'].values(): widget['enabled']=True +projection=dict(version=1,document=layout,defaults=layout,metadata=layout_metadata_for_viewport((2880,1080)),screen=dict(width=1280,height=720,margin_width=0,margin_height=240),revision='aa-scenes',editable=True,valid=True,colors=dict(palette=doc['palette'],widgetColors=doc['widgetColors']['large'],roadColors={})) +print(json.dumps(dict(native=native,projection=projection))) +`], { encoding: 'utf8' })) + +;(async () => { + const browser = await chromium.launch({ headless: true, ...(process.env.PLAYWRIGHT_EXECUTABLE_PATH ? { executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH } : {}) }) + const output = process.env.SCENE_SCREENSHOTS + const nativeArt = process.env.NATIVE_SCENE_ART + if (output) mkdirSync(output, { recursive: true }) + try { + const page = await browser.newPage({ viewport: { width: 1280, height: 1000 } }) + const errors = [] + page.on('pageerror', error => errors.push(error.message)) + await page.route('http://scenes.test/**', async route => { + const pathname = new URL(route.request().url()).pathname + if (pathname === '/api/ui/layout/preview') { + if (!nativeArt) return route.fulfill({ status: 503, json: { error: 'Scene sketch test' } }) + const { profile, scene, art } = route.request().postDataJSON() + return route.fulfill({ contentType: 'image/png', body: readFileSync(path.join(nativeArt, `${profile}-${scene}-${art === true ? 'art' : 'backdrop'}.png`)) }) + } + if (pathname.startsWith('/api/')) return route.fulfill({ json: pathname === '/api/android-auto/layout' ? fixtures.projection : fixtures.native }) + if (pathname === '/') return route.fulfill({ contentType: 'text/html', body: ` + +
` }) + return route.fulfill({ body: readFileSync(path.join(web, pathname)), contentType: pathname.endsWith('.js') ? 'text/javascript' : pathname.endsWith('.css') ? 'text/css' : 'application/octet-stream' }) + }) + for (const target of ['large', 'compact', 'projection']) { + await page.goto('http://scenes.test/' + (target === 'projection' ? '?projection' : '')) + await page.locator('.gx-layout__layer').first().waitFor() + if (target === 'compact') await page.getByRole('button', { name: /^Small/ }).click() + const scenes = await page.evaluate(() => window.editor.availableScenes) + const original = await page.evaluate(() => JSON.stringify(window.editor.state.draft)) + for (const scene of scenes) { + // Choose scenes through the real accessible dropdown. + await page.getByRole('combobox', { name: 'Preview scene', exact: true }).click() + await page.getByRole('option', { name: scene.label, exact: true }).click() + // After the first render, a scene switch keeps native artwork up instead of falling back to outlines. + if (nativeArt && scene !== scenes[0]) assert.equal(await page.locator('[data-preview-source="placeholder"]').count(), 0, `${target}/${scene.id}: no outlines while loading`) + if (nativeArt) await page.waitForFunction(() => window.editor.state.backdrop.scene === window.editor.state.scene && + window.editor.state.backdrop.status === 'ready' && window.editor.state.art.scene === window.editor.state.scene && window.editor.state.art.status === 'ready') + const state = await page.evaluate(() => ({ scene: window.editor.state.scene, border: window.editor.sceneBorder, + visible: window.editor.renderWidgets.map(widget => widget.id), draft: JSON.stringify(window.editor.state.draft), + general: window.editor.layerWidgets.map(widget => widget.id), hidden: window.editor.layerWidgets.filter(widget => window.editor.sceneHidden(widget.id)).map(widget => widget.id) })) + assert.equal(state.scene, scene.id) + assert.equal(state.draft, original, `${target}/${scene.id}: scene changes never edit the saved layout`) + if (nativeArt) assert.equal(await page.locator('[data-preview-source="device-backdrop"]').count(), 1) + else assert.equal(await page.locator('[data-preview-source="scene-border"]').getAttribute('stroke'), state.border) + if (scene.id === 'general') assert.deepEqual([...state.visible].sort(), [...state.general].sort()) + for (const id of state.hidden) { + assert(!state.visible.includes(id), `${target}/${scene.id}/${id}: hidden widgets have no hit box`) + const name = await page.evaluate(id => window.editor.profile.widgets[id].label, id) + assert.equal(await page.getByRole('button', { name: `${name} is hidden in this scene. Choose General edit to arrange it.`, exact: true }).isDisabled(), true) + } + if (target !== 'large') assert.equal(state.visible.includes('stopped_timer'), ['general', 'stopped'].includes(scene.id)) + if (scene.id === 'slc_pending') { + assert.equal(state.visible.includes('speed_limit_actions'), true) + const actions = page.locator('.gx-layout__widget[aria-label^="Speed limit actions"]') + assert.equal(await actions.locator(`[data-preview-source="${nativeArt ? 'runtime' : 'placeholder'}"]`).count(), 1) + } else if (scene.id !== 'general') { + assert.equal(await page.locator('.gx-layout-widget-art').getByText('Apply 54', { exact: true }).count(), 0) + } + if (output) await page.locator('svg.gx-layout__preview').screenshot({ path: path.join(output, `${target}-${scene.id}.png`) }) + } + await page.evaluate(() => { window.editor.state.scene = 'general'; window.editor.togglePreviewVisibility('driver_monitor'); window.editor.state.scene = 'engaged' }) + assert.equal(await page.evaluate(() => window.editor.state.hiddenPreviewWidgets[window.editor.state.profile].driver_monitor), true) + assert.equal(await page.evaluate(() => window.editor.sceneHidden('driver_monitor') || !window.editor.previewHidden('driver_monitor')), true) + for (const width of [390, 1280]) { + await page.setViewportSize({ width, height: 900 }) + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true) + } + } + assert.deepEqual(errors, []) + console.log('All three layout scene menus, border colors, automatic eyes, timer/CEM/actions, scene isolation, draft preservation and mobile sizing passed.') + } finally { await browser.close() } +})().catch(error => { console.error(error); process.exit(1) }) diff --git a/openpilot/starpilot/galaxy/tests/description-key-inventory.json b/openpilot/starpilot/galaxy/tests/description-key-inventory.json index 1d34d2afa6..1b541699fe 100644 --- a/openpilot/starpilot/galaxy/tests/description-key-inventory.json +++ b/openpilot/starpilot/galaxy/tests/description-key-inventory.json @@ -1 +1 @@ -["AggressiveFollow","AggressiveFollowHigh","AggressiveJerkAcceleration","AggressiveJerkDanger","AggressiveJerkDeceleration","AggressiveJerkSpeed","AggressiveJerkSpeedDecrease","AlwaysAllowUploads","AlwaysOnLateral","AolBrakePauseSpeedMps","BlindSpotMetrics","CameraView","CurveSpeedController","CurveSpeedControllerNoLead","CustomCruise","CustomCruiseLong","CustomPersonalities","DisableOpenpilotLongitudinal","DisengageVolume","DriverCamera","EVTuning","EngageVolume","FasterLeadTakeoff","ForceAutoTuneOff","ForceStopDistanceOffset","ForceStops","FordLateralMode","GMAutoHold","GMLongitudinalTune","GMPedalLongitudinal","HKGRemoteStartBootsComma","HideLeadMarker","HidePersonalityAlerts","IgnoreIgnitionLine","KeepScreenOnGpuLoading","LaneCenterOffset","LaneCentering","LaneCenteringE2EAuthority","LaneCenteringPauseOnSignal","LaneCenteringStrength","LateralControllerSelection","LateralResumeDelay","LeadApproachBuffer","LeadInfo","LongPitch","NostalgiaMode","Offset1","Offset2","Offset3","Offset4","Offset5","Offset6","Offset7","PIPPreviewEnabled","PIPPreviewInvert","PIPPreviewShowOnBSM","PIPPreviewShowOnBlinker","PauseLateralOnSignal","PauseLateralSpeed","PromptDistractedVolume","PromptVolume","RadarRecoveryAlert","RefuseVolume","RelaxedFollow","RelaxedFollowHigh","RelaxedJerkAcceleration","RelaxedJerkDanger","RelaxedJerkDeceleration","RelaxedJerkSpeed","RelaxedJerkSpeedDecrease","RemoteStartBootsComma","ReverseCruise","SLCConfirmation","SLCConfirmationHigher","SLCConfirmationLower","SLCFallback","SLCPriority1","SLCPriority2","ScreenBrightness","ScreenBrightnessOnroad","ScreenTimeout","ScreenTimeoutOnroad","ShowBrakeStatus","ShowCSCStatus","ShowSpeedLimits","SignalMetrics","SoundPack","SpeedLimitController","StandardFollow","StandardFollowHigh","StandardJerkAcceleration","StandardJerkDanger","StandardJerkDeceleration","StandardJerkSpeed","StandardJerkSpeedDecrease","StarPilotDisplayPreferencesEnabled","SteerFriction","SteerLatAccel","StockConfidenceBallWidget","TeslaAOLDisengageOnBrake","TeslaAOLScreenTap","TeslaWakeOnCAN","ToyotaAutoHold","ToyotaRadarMode","TrafficFollow","TrafficJerkAcceleration","TrafficJerkDanger","TrafficJerkDeceleration","TrafficJerkSpeed","TrafficJerkSpeedDecrease","TruckTuning","TurnAssist","TurnOffCommaScreen","UseStarPilotLongitudinalPlanner","VoltOnePedalMode","VoltSNG","WarningImmediateVolume","WarningSoftVolume","conditional:button:DistanceButtonControl","conditional:button:LongDistanceButtonControl","conditional:button:LongModeButtonControl","conditional:button:LongStarButtonControl","conditional:button:ModeButtonControl","conditional:button:StarButtonControl","conditional:button:VeryLongDistanceButtonControl","conditional:button:VeryLongModeButtonControl","conditional:button:VeryLongStarButtonControl","conditional:ccm:launch_assist","conditional:ccm:lead","conditional:ccm:persist_manual","conditional:ccm:set_speed_margin_mps","conditional:ccm:speed_mps","conditional:ccm:speed_with_lead_mps","conditional:cem:curves","conditional:cem:curves_with_lead","conditional:cem:lead","conditional:cem:model_stop_s","conditional:cem:open_road","conditional:cem:persist_manual","conditional:cem:signal_lane_detection","conditional:cem:signal_lane_width_m","conditional:cem:signal_speed_mps","conditional:cem:slower_lead","conditional:cem:speed_mps","conditional:cem:speed_with_lead_mps","conditional:cem:stop_lights","conditional:cem:stopped_lead","conditional:manual_reset","conditional:mode","conditional:reset","curve_reset","display:auto:ScreenBrightness","display:auto:ScreenBrightnessOnroad","lane_change:auto","lane_change:close_gap","lane_change:close_gap_seconds","lane_change:delay","lane_change:enabled","lane_change:one","lane_change:pace","lane_change:reset","lane_change:speed","lane_change:width","long_repair:AggressiveFollow","long_repair:AggressiveFollowHigh","long_repair:AggressiveJerkAcceleration","long_repair:AggressiveJerkDanger","long_repair:AggressiveJerkDeceleration","long_repair:AggressiveJerkSpeed","long_repair:AggressiveJerkSpeedDecrease","long_repair:CustomPersonalities","long_repair:RelaxedFollow","long_repair:RelaxedFollowHigh","long_repair:RelaxedJerkAcceleration","long_repair:RelaxedJerkDanger","long_repair:RelaxedJerkDeceleration","long_repair:RelaxedJerkSpeed","long_repair:RelaxedJerkSpeedDecrease","long_repair:StandardFollow","long_repair:StandardFollowHigh","long_repair:StandardJerkAcceleration","long_repair:StandardJerkDanger","long_repair:StandardJerkDeceleration","long_repair:StandardJerkSpeed","long_repair:StandardJerkSpeedDecrease","long_reset:aggressive","long_reset:relaxed","long_reset:standard","pip:format:1344x760","pip:format:1928x1208","pip:mask:editor","pip:mask:left:x","pip:mask:left:y","pip:mask:right:x","pip:mask:right:y","pip:mask:size","pip:reset","profile:aggressive:acceleration","profile:aggressive:acceleration:0","profile:aggressive:acceleration:1","profile:aggressive:acceleration:2","profile:aggressive:acceleration:3","profile:aggressive:acceleration:4","profile:aggressive:acceleration:5","profile:aggressive:acceleration:6","profile:aggressive:acceleration:7","profile:aggressive:acceleration:8","profile:aggressive:acceleration:9","profile:aggressive:braking","profile:aggressive:braking:0","profile:aggressive:braking:1","profile:aggressive:braking:2","profile:aggressive:braking:3","profile:aggressive:braking:4","profile:aggressive:braking:5","profile:aggressive:braking:6","profile:aggressive:braking:7","profile:aggressive:braking:8","profile:aggressive:braking:9","profile:aggressive:following","profile:aggressive:following:0","profile:aggressive:following:1","profile:aggressive:following:2","profile:aggressive:following:3","profile:aggressive:following:4","profile:aggressive:following:5","profile:aggressive:following:6","profile:aggressive:following:7","profile:aggressive:following:8","profile:aggressive:following:9","profile:global_acceleration","profile:global_braking","profile:relaxed:acceleration","profile:relaxed:acceleration:0","profile:relaxed:acceleration:1","profile:relaxed:acceleration:2","profile:relaxed:acceleration:3","profile:relaxed:acceleration:4","profile:relaxed:acceleration:5","profile:relaxed:acceleration:6","profile:relaxed:acceleration:7","profile:relaxed:acceleration:8","profile:relaxed:acceleration:9","profile:relaxed:braking","profile:relaxed:braking:0","profile:relaxed:braking:1","profile:relaxed:braking:2","profile:relaxed:braking:3","profile:relaxed:braking:4","profile:relaxed:braking:5","profile:relaxed:braking:6","profile:relaxed:braking:7","profile:relaxed:braking:8","profile:relaxed:braking:9","profile:relaxed:following","profile:relaxed:following:0","profile:relaxed:following:1","profile:relaxed:following:2","profile:relaxed:following:3","profile:relaxed:following:4","profile:relaxed:following:5","profile:relaxed:following:6","profile:relaxed:following:7","profile:relaxed:following:8","profile:relaxed:following:9","profile:standard:acceleration","profile:standard:acceleration:0","profile:standard:acceleration:1","profile:standard:acceleration:2","profile:standard:acceleration:3","profile:standard:acceleration:4","profile:standard:acceleration:5","profile:standard:acceleration:6","profile:standard:acceleration:7","profile:standard:acceleration:8","profile:standard:acceleration:9","profile:standard:braking","profile:standard:braking:0","profile:standard:braking:1","profile:standard:braking:2","profile:standard:braking:3","profile:standard:braking:4","profile:standard:braking:5","profile:standard:braking:6","profile:standard:braking:7","profile:standard:braking:8","profile:standard:braking:9","profile:standard:following","profile:standard:following:0","profile:standard:following:1","profile:standard:following:2","profile:standard:following:3","profile:standard:following:4","profile:standard:following:5","profile:standard:following:6","profile:standard:following:7","profile:standard:following:8","profile:standard:following:9","profile:traffic:acceleration","profile:traffic:acceleration:0","profile:traffic:acceleration:1","profile:traffic:acceleration:2","profile:traffic:acceleration:3","profile:traffic:acceleration:4","profile:traffic:acceleration:5","profile:traffic:acceleration:6","profile:traffic:acceleration:7","profile:traffic:acceleration:8","profile:traffic:acceleration:9","profile:traffic:braking","profile:traffic:braking:0","profile:traffic:braking:1","profile:traffic:braking:2","profile:traffic:braking:3","profile:traffic:braking:4","profile:traffic:braking:5","profile:traffic:braking:6","profile:traffic:braking:7","profile:traffic:braking:8","profile:traffic:braking:9","profile:traffic:following","profile:traffic:following:0","profile:traffic:following:1","profile:traffic:following:2","profile:traffic:following:3","profile:traffic:following:4","profile:traffic:following:5","profile:traffic:following:6","profile:traffic:following:7","profile:traffic:following:8","profile:traffic:following:9","reset_profiles","sentry:enabled","sentry:reset","sentry:sensitivity","sentry:warning","slc_adopt","slc_reset","sounds:auto:DisengageVolume","sounds:auto:EngageVolume","sounds:auto:PromptDistractedVolume","sounds:auto:PromptVolume","sounds:auto:RefuseVolume","sounds:auto:WarningImmediateVolume","sounds:auto:WarningSoftVolume","torque:automatic_delay:value","torque:factor:mode","torque:factor:reset","torque:factor:value","torque:friction:mode","torque:friction:reset","torque:friction:value","torque:full_delay:mode","torque:full_delay:value","torque:gain:mode","torque:gain:value","torque:geometry_reset:value","torque:learning:value","torque:ratio:mode","torque:ratio:value","torque_prepare_firestar","vasm:annotation","vasm:confidence","vasm:enabled","vasm:reset","vasm:smooth","wheel:CancelButtonControl","wheel:DistanceButtonControl","wheel:LKASButtonControl","wheel:LongCancelButtonControl","wheel:LongDistanceButtonControl","wheel:LongModeButtonControl","wheel:LongStarButtonControl","wheel:MainCruiseButtonControl","wheel:ModeButtonControl","wheel:StarButtonControl","wheel:VeryLongCancelButtonControl","wheel:VeryLongDistanceButtonControl","wheel:VeryLongModeButtonControl","wheel:VeryLongStarButtonControl"] +["AggressiveFollow","AggressiveFollowHigh","AggressiveJerkAcceleration","AggressiveJerkDanger","AggressiveJerkDeceleration","AggressiveJerkSpeed","AggressiveJerkSpeedDecrease","AlwaysAllowUploads","AlwaysOnLateral","AolBrakePauseSpeedMps","BlindSpotMetrics","CameraView","CurveSpeedController","CurveSpeedControllerNoLead","CustomCruise","CustomCruiseLong","CustomPersonalities","DeveloperSidebar","DisableOpenpilotLongitudinal","DisengageVolume","DriverCamera","EVTuning","EngageVolume","FasterLeadTakeoff","ForceAutoTuneOff","ForceStopDistanceOffset","ForceStops","FordLateralMode","GMAutoHold","GMLongitudinalTune","GMPedalLongitudinal","HKGRemoteStartBootsComma","HideLeadMarker","HidePersonalityAlerts","IgnoreIgnitionLine","KeepScreenOnGpuLoading","LaneCenterOffset","LaneCentering","LaneCenteringE2EAuthority","LaneCenteringPauseOnSignal","LaneCenteringStrength","LateralControllerSelection","LateralResumeDelay","LeadApproachBuffer","LeadInfo","LongPitch","NostalgiaMode","Offset1","Offset2","Offset3","Offset4","Offset5","Offset6","Offset7","PIPPreviewEnabled","PIPPreviewInvert","PIPPreviewShowOnBSM","PIPPreviewShowOnBlinker","PauseLateralOnSignal","PauseLateralSpeed","PromptDistractedVolume","PromptVolume","RadarRecoveryAlert","RefuseVolume","RelaxedFollow","RelaxedFollowHigh","RelaxedJerkAcceleration","RelaxedJerkDanger","RelaxedJerkDeceleration","RelaxedJerkSpeed","RelaxedJerkSpeedDecrease","RemoteStartBootsComma","ReverseCruise","SLCConfirmation","SLCConfirmationHigher","SLCConfirmationLower","SLCFallback","SLCPriority1","SLCPriority2","ScreenBrightness","ScreenBrightnessOnroad","ScreenTimeout","ScreenTimeoutOnroad","ShowBrakeStatus","ShowCSCStatus","ShowSpeedLimits","SignalMetrics","SoundPack","SpeedLimitController","StandardFollow","StandardFollowHigh","StandardJerkAcceleration","StandardJerkDanger","StandardJerkDeceleration","StandardJerkSpeed","StandardJerkSpeedDecrease","StarPilotDisplayPreferencesEnabled","SteerFriction","SteerLatAccel","StockConfidenceBallWidget","TeslaAOLDisengageOnBrake","TeslaAOLScreenTap","TeslaWakeOnCAN","ToyotaAutoHold","ToyotaRadarMode","TrafficFollow","TrafficJerkAcceleration","TrafficJerkDanger","TrafficJerkDeceleration","TrafficJerkSpeed","TrafficJerkSpeedDecrease","TruckTuning","TurnAssist","TurnOffCommaScreen","UseStarPilotLongitudinalPlanner","UseVOACC","VoltOnePedalMode","VoltSNG","WarningImmediateVolume","WarningSoftVolume","conditional:button:DistanceButtonControl","conditional:button:LongDistanceButtonControl","conditional:button:LongModeButtonControl","conditional:button:LongStarButtonControl","conditional:button:ModeButtonControl","conditional:button:StarButtonControl","conditional:button:VeryLongDistanceButtonControl","conditional:button:VeryLongModeButtonControl","conditional:button:VeryLongStarButtonControl","conditional:ccm:launch_assist","conditional:ccm:lead","conditional:ccm:persist_manual","conditional:ccm:set_speed_margin_mps","conditional:ccm:speed_mps","conditional:ccm:speed_with_lead_mps","conditional:cem:curves","conditional:cem:curves_with_lead","conditional:cem:lead","conditional:cem:model_stop_s","conditional:cem:open_road","conditional:cem:persist_manual","conditional:cem:signal_lane_detection","conditional:cem:signal_lane_width_m","conditional:cem:signal_speed_mps","conditional:cem:slower_lead","conditional:cem:speed_mps","conditional:cem:speed_with_lead_mps","conditional:cem:stop_lights","conditional:cem:stopped_lead","conditional:manual_reset","conditional:mode","conditional:reset","curve_reset","display:auto:ScreenBrightness","display:auto:ScreenBrightnessOnroad","lane_change:auto","lane_change:close_gap","lane_change:close_gap_seconds","lane_change:delay","lane_change:enabled","lane_change:one","lane_change:pace","lane_change:reset","lane_change:speed","lane_change:width","long_repair:AggressiveFollow","long_repair:AggressiveFollowHigh","long_repair:AggressiveJerkAcceleration","long_repair:AggressiveJerkDanger","long_repair:AggressiveJerkDeceleration","long_repair:AggressiveJerkSpeed","long_repair:AggressiveJerkSpeedDecrease","long_repair:CustomPersonalities","long_repair:RelaxedFollow","long_repair:RelaxedFollowHigh","long_repair:RelaxedJerkAcceleration","long_repair:RelaxedJerkDanger","long_repair:RelaxedJerkDeceleration","long_repair:RelaxedJerkSpeed","long_repair:RelaxedJerkSpeedDecrease","long_repair:StandardFollow","long_repair:StandardFollowHigh","long_repair:StandardJerkAcceleration","long_repair:StandardJerkDanger","long_repair:StandardJerkDeceleration","long_repair:StandardJerkSpeed","long_repair:StandardJerkSpeedDecrease","long_reset:aggressive","long_reset:relaxed","long_reset:standard","pip:format:1344x760","pip:format:1928x1208","pip:mask:editor","pip:mask:left:x","pip:mask:left:y","pip:mask:right:x","pip:mask:right:y","pip:mask:size","pip:reset","profile:aggressive:acceleration","profile:aggressive:acceleration:0","profile:aggressive:acceleration:1","profile:aggressive:acceleration:2","profile:aggressive:acceleration:3","profile:aggressive:acceleration:4","profile:aggressive:acceleration:5","profile:aggressive:acceleration:6","profile:aggressive:acceleration:7","profile:aggressive:acceleration:8","profile:aggressive:acceleration:9","profile:aggressive:braking","profile:aggressive:braking:0","profile:aggressive:braking:1","profile:aggressive:braking:2","profile:aggressive:braking:3","profile:aggressive:braking:4","profile:aggressive:braking:5","profile:aggressive:braking:6","profile:aggressive:braking:7","profile:aggressive:braking:8","profile:aggressive:braking:9","profile:aggressive:following","profile:aggressive:following:0","profile:aggressive:following:1","profile:aggressive:following:2","profile:aggressive:following:3","profile:aggressive:following:4","profile:aggressive:following:5","profile:aggressive:following:6","profile:aggressive:following:7","profile:aggressive:following:8","profile:aggressive:following:9","profile:global_acceleration","profile:global_braking","profile:relaxed:acceleration","profile:relaxed:acceleration:0","profile:relaxed:acceleration:1","profile:relaxed:acceleration:2","profile:relaxed:acceleration:3","profile:relaxed:acceleration:4","profile:relaxed:acceleration:5","profile:relaxed:acceleration:6","profile:relaxed:acceleration:7","profile:relaxed:acceleration:8","profile:relaxed:acceleration:9","profile:relaxed:braking","profile:relaxed:braking:0","profile:relaxed:braking:1","profile:relaxed:braking:2","profile:relaxed:braking:3","profile:relaxed:braking:4","profile:relaxed:braking:5","profile:relaxed:braking:6","profile:relaxed:braking:7","profile:relaxed:braking:8","profile:relaxed:braking:9","profile:relaxed:following","profile:relaxed:following:0","profile:relaxed:following:1","profile:relaxed:following:2","profile:relaxed:following:3","profile:relaxed:following:4","profile:relaxed:following:5","profile:relaxed:following:6","profile:relaxed:following:7","profile:relaxed:following:8","profile:relaxed:following:9","profile:standard:acceleration","profile:standard:acceleration:0","profile:standard:acceleration:1","profile:standard:acceleration:2","profile:standard:acceleration:3","profile:standard:acceleration:4","profile:standard:acceleration:5","profile:standard:acceleration:6","profile:standard:acceleration:7","profile:standard:acceleration:8","profile:standard:acceleration:9","profile:standard:braking","profile:standard:braking:0","profile:standard:braking:1","profile:standard:braking:2","profile:standard:braking:3","profile:standard:braking:4","profile:standard:braking:5","profile:standard:braking:6","profile:standard:braking:7","profile:standard:braking:8","profile:standard:braking:9","profile:standard:following","profile:standard:following:0","profile:standard:following:1","profile:standard:following:2","profile:standard:following:3","profile:standard:following:4","profile:standard:following:5","profile:standard:following:6","profile:standard:following:7","profile:standard:following:8","profile:standard:following:9","profile:traffic:acceleration","profile:traffic:acceleration:0","profile:traffic:acceleration:1","profile:traffic:acceleration:2","profile:traffic:acceleration:3","profile:traffic:acceleration:4","profile:traffic:acceleration:5","profile:traffic:acceleration:6","profile:traffic:acceleration:7","profile:traffic:acceleration:8","profile:traffic:acceleration:9","profile:traffic:braking","profile:traffic:braking:0","profile:traffic:braking:1","profile:traffic:braking:2","profile:traffic:braking:3","profile:traffic:braking:4","profile:traffic:braking:5","profile:traffic:braking:6","profile:traffic:braking:7","profile:traffic:braking:8","profile:traffic:braking:9","profile:traffic:following","profile:traffic:following:0","profile:traffic:following:1","profile:traffic:following:2","profile:traffic:following:3","profile:traffic:following:4","profile:traffic:following:5","profile:traffic:following:6","profile:traffic:following:7","profile:traffic:following:8","profile:traffic:following:9","reset_profiles","sentry:enabled","sentry:reset","sentry:sensitivity","sentry:warning","slc_adopt","slc_reset","sounds:auto:DisengageVolume","sounds:auto:EngageVolume","sounds:auto:PromptDistractedVolume","sounds:auto:PromptVolume","sounds:auto:RefuseVolume","sounds:auto:WarningImmediateVolume","sounds:auto:WarningSoftVolume","torque:automatic_delay:value","torque:factor:mode","torque:factor:reset","torque:factor:value","torque:friction:mode","torque:friction:reset","torque:friction:value","torque:full_delay:mode","torque:full_delay:value","torque:gain:mode","torque:gain:value","torque:geometry_reset:value","torque:learning:value","torque:ratio:mode","torque:ratio:value","torque_prepare_firestar","vasm:annotation","vasm:confidence","vasm:enabled","vasm:reset","vasm:smooth","wheel:CancelButtonControl","wheel:DistanceButtonControl","wheel:LKASButtonControl","wheel:LongCancelButtonControl","wheel:LongDistanceButtonControl","wheel:LongModeButtonControl","wheel:LongStarButtonControl","wheel:MainCruiseButtonControl","wheel:ModeButtonControl","wheel:StarButtonControl","wheel:VeryLongCancelButtonControl","wheel:VeryLongDistanceButtonControl","wheel:VeryLongModeButtonControl","wheel:VeryLongStarButtonControl"] diff --git a/openpilot/starpilot/galaxy/tests/test_auth_client.mjs b/openpilot/starpilot/galaxy/tests/test_auth_client.mjs index 6a71b6de2a..a5c8d292e0 100644 --- a/openpilot/starpilot/galaxy/tests/test_auth_client.mjs +++ b/openpilot/starpilot/galaxy/tests/test_auth_client.mjs @@ -178,7 +178,7 @@ assert.equal(localStates.at(-1).localAccess, false) const app = readFileSync(new URL("../web/js/app.js", import.meta.url), "utf8") assert.match(app, /authState\.status === 'authenticated' && !authState\.localAccess/) assert.match(app, /state\.monitorMode === 'local' && authState\.status !== 'authenticated'/) -assert.match(app, /sessionExpired\(\) \{ auth\.expired\(\); auth\.check\(\) \}/) +assert.match(app, /sessionExpired\(\) \{ if \(initializing\) return; auth\.expired\(\); initialize\(\) \}/) const oldMonitor = deferred(), nextMonitor = deferred(), lateStates = [] let lateUnauthorized = 0, monitorRequests = 0 diff --git a/openpilot/starpilot/galaxy/tests/test_back_navigation.mjs b/openpilot/starpilot/galaxy/tests/test_back_navigation.mjs new file mode 100644 index 0000000000..a735a517a7 --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/test_back_navigation.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict" +import { SettingsPage } from "../web/js/settings.js" +import { OnroadLayoutPage } from "../web/js/onroad-layout.js" + +const listeners = new Map() +globalThis.window = { scrollTo() {}, addEventListener(name, callback) { listeners.set(name, callback) } } +let current = new URL("https://galaxy.test/app#/cameras/pip") +globalThis.location = { get hash() { return current.hash }, pathname: "/app", search: "" } +const entries = [{ url: current.href, state: null }] +let cursor = 0 +globalThis.history = { + get state() { return entries[cursor].state }, + replaceState(state, _title, url) { current = new URL(url, current); entries[cursor] = { state, url: current.href } }, + pushState(state, _title, url) { entries.splice(cursor + 1); current = new URL(url, current); entries.push({ state, url: current.href }); cursor++ }, + go(delta) { if (cursor + delta >= 0 && cursor + delta < entries.length) { cursor += delta; current = new URL(entries[cursor].url); listeners.get("popstate")() } }, + back() { if (cursor > 0) { cursor--; current = new URL(entries[cursor].url); listeners.get("popstate")() } }, + forward() { if (cursor < entries.length - 1) { cursor++; current = new URL(entries[cursor].url); listeners.get("popstate")() } }, +} +const { route, navigate, navigateBack, startRouter } = await import("../web/js/router.js") +startRouter() +navigateBack() +assert.equal(route.path, "/") // A direct link cannot navigate outside Galaxy. +navigate("/settings"); navigate("/appearance"); navigate("/theme_maker") +navigateBack(); assert.equal(route.path, "/appearance") +navigateBack(); assert.equal(route.path, "/settings") +history.forward(); assert.equal(route.path, "/appearance") +navigate("/cameras"); navigate("/cameras/pip") +navigateBack(); assert.equal(route.path, "/cameras") +const length = entries.length +navigate("/cameras"); assert.equal(entries.length, length) + +let returned = 0, cancelled = 0 +const settings = { busy: false, state: { data: { page: "standard/following" } }, initialPage: "hub", atSectionRoot: false, + back() { returned++ }, feed: { cancel() { cancelled++ } } } +assert.equal(SettingsPage.methods.navigateBack.call(settings), true) +assert.equal(returned, 1) +settings.atSectionRoot = true +assert.equal(SettingsPage.methods.navigateBack.call(settings), false) +settings.state.pending = {}; settings.busy = true +assert.equal(SettingsPage.methods.navigateBack.call(settings), true) +assert.equal(cancelled, 1) + +let closed = 0 +const editor = { busy: false, dirty: true, state: { drag: null, discard: null }, + leave: OnroadLayoutPage.methods.leave, $emit(event) { if (event === "close") closed++ } } +settings.state = { layoutOpen: true }; settings.busy = false +settings.$refs = { layoutEditor: { requestLeave(action) { OnroadLayoutPage.methods.requestLeave.call(editor, action) } } } +assert.equal(SettingsPage.methods.navigateBack.call(settings), true) +assert.equal(editor.state.discard, "back"); assert.equal(closed, 0) +editor.state.discard = null // Cancel discard keeps the editor. +assert.equal(closed, 0) +editor.leave("back"); assert.equal(closed, 1) +console.log("Galaxy Back history and editor guard passed") + +// Parent is the page actually visited, not a guessed personality parent. +const nested = { initialPage: 'hub', state: { section: 'longitudinal', query: '', parents: [], data: {page: 'hub'} }, + activeSection: {id: 'longitudinal'}, feed: {load(page) { nested.state.data = {page} }} } +SettingsPage.methods.open.call(nested, 'profiles') +SettingsPage.methods.back.call(nested) +assert.equal(nested.state.data.page, 'hub') +SettingsPage.methods.open.call(nested, 'standard') +SettingsPage.methods.open.call(nested, 'standard/following') +SettingsPage.methods.back.call(nested) +assert.equal(nested.state.data.page, 'standard') +SettingsPage.methods.back.call(nested) +assert.equal(nested.state.data.page, 'hub', 'direct personality returns to Longitudinal hub') +SettingsPage.methods.open.call(nested, 'profiles') +SettingsPage.methods.open.call(nested, 'standard') +SettingsPage.methods.back.call(nested) +assert.equal(nested.state.data.page, 'profiles', 'personality opened through planner returns there') +SettingsPage.methods.selectSection.call({...nested,busy:false,feed:{active:true,load(){}}}, {id:'wheel',pages:['wheel']}) +assert.deepEqual(nested.state.parents, [], 'changing sections retires nested history') + +nested.initialPage = 'profiles'; nested.state.data = {page:'profiles'} +SettingsPage.methods.open.call(nested,'standard') +SettingsPage.methods.open.call(nested,'standard/following') +SettingsPage.methods.back.call(nested) +assert.equal(nested.state.data.page,'standard','dedicated tools also retain each nested parent') +SettingsPage.methods.back.call(nested) +assert.equal(nested.state.data.page,'profiles') diff --git a/openpilot/starpilot/galaxy/tests/test_cameras.mjs b/openpilot/starpilot/galaxy/tests/test_cameras.mjs index f0f8d288d7..96d98febe1 100644 --- a/openpilot/starpilot/galaxy/tests/test_cameras.mjs +++ b/openpilot/starpilot/galaxy/tests/test_cameras.mjs @@ -29,14 +29,15 @@ assert.equal(page.state.query, "") assert.match(CamerasPage.template, /Adjust the camera crop with a live cabin preview/) assert.doesNotMatch(CamerasPage.template, /live camera preview is unavailable/) assert.match(CamerasPage.template, /Sentry[\s\S]*View motion events/) -assert.match(CamerasPage.template, /Saved motion settings/) -assert.match(CamerasPage.template, /V-ASM[\s\S]*Open saved settings/) -assert.match(CamerasPage.template, /live camera image and current warning status are unavailable/) +assert.match(CamerasPage.template, /saved motion settings/) +assert.doesNotMatch(CamerasPage.template, /go\('\/cameras\/sentry-settings'\)/) +assert.match(CamerasPage.template, /MenuTile[^>]*title="V-ASM"/) +assert.match(CamerasPage.template, /Preview the cabin camera/) assert.match(CamerasPage.template, /snapshots.capture\(camera\)/) assert.match(SettingsPage.template, /\{\{ title \}\}/) const app = readFileSync(new URL("../web/js/app.js", import.meta.url), "utf8") assert.match(app, /]*\/cameras\/events[^>]*\/cameras\/sentry-settings/) assert.match(app, /CamerasPage v-else-if="route\.path === '\/cameras'"/) const captures = [], snapshots = [], revoked = [] @@ -64,3 +65,25 @@ timer() captures[2].resolve({ status: 401 }) await timed assert.match(snapshots.at(-1).error, /timed out/) + +// Browsers throw "Illegal invocation" when setTimeout/clearTimeout run with a `this` +// other than the global object; Node does not, so mimic the browser check here. +// The default timers once ran as feed methods, which killed every PiP/VASM/Cameras +// snapshot before its request with no visible error. +const realSetTimeout = globalThis.setTimeout, realClearTimeout = globalThis.clearTimeout +const strict = (real) => function (...args) { + if (this !== undefined && this !== globalThis) throw new TypeError("Illegal invocation") + return real(...args) +} +globalThis.setTimeout = strict(realSetTimeout) +globalThis.clearTimeout = strict(realClearTimeout) +try { + const defaults = new CameraSnapshotFeed({ publish: () => {}, unauthorized: () => {}, createURL: () => "blob:x", revokeURL: () => {}, + fetcher: async () => ({ status: 200, ok: true, headers: { get: () => "image/jpeg" }, blob: async () => ({ size: 3 }) }) }) + await defaults.capture("cabin") + assert.equal(defaults.url, "blob:x", "default timers must not throw before the request") + defaults.stop() +} finally { + globalThis.setTimeout = realSetTimeout + globalThis.clearTimeout = realClearTimeout +} diff --git a/openpilot/starpilot/galaxy/tests/test_developer_settings.py b/openpilot/starpilot/galaxy/tests/test_developer_settings.py new file mode 100644 index 0000000000..0129b834b8 --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/test_developer_settings.py @@ -0,0 +1,109 @@ +"""Galaxy developer preference admission, exact saves and drive-start selection.""" + +from dataclasses import replace +from pathlib import Path +from types import SimpleNamespace +from typing import cast +import tempfile +import unittest + +from openpilot.starpilot.galaxy.developer_settings import DeveloperSettingsOwner +from openpilot.starpilot.galaxy.settings import AuthorityContext, ContextSource, SettingsChanged, SettingsGateway +from openpilot.starpilot.longitudinal.voacc import KEY, selected +from openpilot.starpilot.ui.feature_settings_state import FeatureSettingsRequest + + +class FileParams: + def __init__(self, root): + self.root = Path(root) / "d" + self.root.mkdir() + + def get_param_path(self, key): + return str(self.root / key) + + +class DeveloperSettingsTest(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.params = FileParams(directory.name) + self.path = Path(self.params.get_param_path(KEY)) + self.cp = SimpleNamespace(openpilotLongitudinalControl=True, notCar=False, dashcamOnly=False, passive=False) + self.parked = True + self.owner = DeveloperSettingsOwner(self.params, lambda: self.parked) + + def test_default_off_strict_selection_and_vehicle_admission(self): + self.assertEqual(self.owner.snapshot().rows[0].value, "Off") + self.assertFalse(selected(self.params, self.cp)) + for raw in (b"0", b"", b"true", b"1\n", b"1" * 9): + with self.subTest(raw=raw): + self.path.write_bytes(raw) + self.assertFalse(selected(self.params, self.cp)) + self.path.write_bytes(b"1") + self.assertTrue(selected(self.params, self.cp)) + self.assertFalse(selected(self.params, None)) + self.assertFalse(selected(self.params, SimpleNamespace())) + for field, value in (("openpilotLongitudinalControl", False), ("notCar", True), + ("dashcamOnly", True), ("passive", True)): + with self.subTest(field=field): + cp = SimpleNamespace(**{**vars(self.cp), field: value}) + self.assertFalse(selected(self.params, cp)) + + def test_built_native_registry_and_actual_params_save(self): + from openpilot.common.params import Params, ParamKeyType + params = Params(str(self.params.root.parent / "native-params")) + self.assertTrue(params.check_key(KEY)) + self.assertEqual(params.get_type(KEY), ParamKeyType.BOOL) + self.assertIs(params.get_default_value(KEY), False) + owner = DeveloperSettingsOwner(params, lambda: True) + row = owner.snapshot().rows[0] + self.assertEqual(row.value, "Off") + self.assertTrue(owner.apply(FeatureSettingsRequest(KEY, row.source, "On"))) + self.assertTrue(params.get_bool(KEY)) + self.assertTrue(selected(params, self.cp)) + + def test_exact_save_rejects_races_invalid_values_and_onroad(self): + self.assertTrue(self.owner.apply(FeatureSettingsRequest(KEY, None, "On"))) + self.assertEqual(self.path.read_bytes(), b"1") + self.assertFalse(self.owner.apply(FeatureSettingsRequest(KEY, None, "Off"))) + self.parked = False + self.assertFalse(self.owner.snapshot().rows[0].available) + self.assertFalse(self.owner.apply(FeatureSettingsRequest(KEY, b"1", "Off"))) + self.parked = True + self.assertFalse(self.owner.apply(FeatureSettingsRequest(KEY, b"1", "yes"))) + self.assertFalse(self.owner.apply(FeatureSettingsRequest("AlwaysOnLateral", None, "On"))) + self.assertTrue(self.owner.apply(FeatureSettingsRequest(KEY, b"1", "Off"))) + self.assertEqual(self.path.read_bytes(), b"0") + self.path.write_bytes(b"garbage") + self.assertFalse(self.owner.snapshot().rows[0].available) + self.assertFalse(self.owner.apply(FeatureSettingsRequest(KEY, b"garbage", "On"))) + + def test_real_gateway_preview_save_default_and_authority_recheck(self): + context = SimpleNamespace(value=AuthorityContext(True, None, None)) + context.sample = lambda: context.value + gateway = SettingsGateway(self.params, cast(ContextSource, context), clock=lambda: 10.) + token, generation = "session", b"generation" + + def preview(value): + page = gateway.page("developer", token, generation) + self.assertEqual(page["rows"][0]["label"], "Use VOACC") + self.assertEqual(page["rows"][0]["defaultValue"], "Off") + self.assertIn("next drive", page["rows"][0]["reason"]) + return gateway.preview(page["view"], 0, 0, token, generation, value=value)["intent"] + + intent = preview("On") + self.assertTrue(gateway.confirm(intent, token, generation)) + self.assertEqual(self.path.read_bytes(), b"1") + intent = preview("Off") + context.value = replace(context.value, parked=False) + with self.assertRaises(SettingsChanged): + gateway.confirm(intent, token, generation) + self.assertEqual(self.path.read_bytes(), b"1") + context.value = replace(context.value, parked=True) + page = gateway.page("developer", token, generation) + intent = gateway.preview(page["view"], 0, 0, token, generation, reset_default=True) + self.assertTrue(gateway.confirm(intent["intent"], token, generation)) + self.assertEqual(self.path.read_bytes(), b"0") + intent = preview("On") + with self.assertRaises(SettingsChanged): + gateway.confirm(intent, token, generation, session_valid=lambda: False) diff --git a/openpilot/starpilot/galaxy/tests/test_device_restart.mjs b/openpilot/starpilot/galaxy/tests/test_device_restart.mjs new file mode 100644 index 0000000000..3b68bfc66e --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/test_device_restart.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict" +import { DeviceRestartWatch } from "../web/js/device-restart.js" + +const flush = async () => { for (let i = 0; i < 12; i++) await Promise.resolve() } +function fixture({ paused = false } = {}) { + const timers = new Map(), requests = [] + let nextTimer = 0, reloads = 0 + const watch = new DeviceRestartWatch({ hidden: () => false, paused: () => paused, + request: () => new Promise((resolve, reject) => requests.push({ resolve, reject })), + later: fn => { const id = ++nextTimer; timers.set(id, fn); return id }, + cancel: id => timers.delete(id), reload: () => reloads++ }) + return { watch, timers, requests, tick() { const [id, fn] = timers.entries().next().value; timers.delete(id); fn() }, get reloads() { return reloads } } +} + +const preview = fixture() +preview.watch.track(null) +assert.equal(preview.timers.size, 0) + +const blip = fixture() +blip.watch.track("one") +blip.tick() +blip.requests[0].reject(new TypeError("offline")) +await flush() +blip.tick() +blip.requests[1].resolve({ schemaVersion: 1, monitor: "local", instance: "one" }) +await flush() +assert.equal(blip.reloads, 0) +assert.equal(blip.timers.size, 1) + +const reboot = fixture() +reboot.watch.track("one") +reboot.tick() +reboot.requests[0].reject(new TypeError("offline")) +await flush() +reboot.tick() +reboot.requests[1].resolve({ schemaVersion: 1, monitor: "local", instance: "two" }) +await flush() +assert.equal(reboot.reloads, 1) +assert.equal(reboot.timers.size, 0) + +const startup = fixture() +startup.watch.track("one") +startup.watch.track("two") +assert.equal(startup.reloads, 1) + +const woken = fixture() +woken.watch.track("one") +woken.watch.wake() +assert.equal(woken.requests.length, 1) +assert.equal(woken.timers.size, 0) + +const updating = fixture({ paused: true }) +updating.watch.track("one") +updating.watch.track("two") +assert.equal(updating.reloads, 0) diff --git a/openpilot/starpilot/galaxy/tests/test_hotspot.mjs b/openpilot/starpilot/galaxy/tests/test_hotspot.mjs new file mode 100644 index 0000000000..5e7aeb785b --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/test_hotspot.mjs @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict' +import { compile } from '../web/vendor/vue/vue.esm-browser.js' +import { GalaxyHotspot, validHotspot } from '../web/js/hotspot.js' +const value = { version: 1, revision: 'a'.repeat(64), editable: true, active: false, state: 'disabled', reason: '', + url: 'http://172.31.254.1:8082/', config: { enabled: false, ssid: 'TheGalaxy-6b86', password: '' } } +assert.equal(validHotspot(value), true) +assert.equal(validHotspot({ ...value, config: { ...value.config, ssid: 'Custom network' } }), false) +assert.ok(!GalaxyHotspot.template.includes('v-model="draft.ssid"')) +assert.ok(GalaxyHotspot.template.includes('Broadcast hotspot automatically')) +assert.equal(validHotspot({ ...value, url: 'javascript:alert(1)' }), false) +assert.equal(validHotspot({ ...value, config: { ...value.config, password: 'bad\npassword' } }), false) +compile(GalaxyHotspot.template, { decodeEntities: value => value }) +function instance() { + const vm = { ...GalaxyHotspot.data(), mode: 'local', unauthorized() {} } + for (const [name, fn] of Object.entries(GalaxyHotspot.methods)) vm[name] = fn.bind(vm) + Object.defineProperty(vm, 'dirty', { get: () => GalaxyHotspot.computed.dirty.call(vm) }) + return vm +} +const vm = instance() +vm.adopt(value) +assert.equal(vm.dirty, false) +vm.draft.password = 'my-own-password' +vm.adopt({ ...value, revision: 'b'.repeat(64), config: { ...value.config, password: 'another-password' } }) +assert.equal(vm.draft.password, 'my-own-password') +assert.equal(vm.revision, value.revision) // Polls must not bless a stale draft. +vm.adopt(value, true) +assert.equal(vm.dirty, false) +let requests = 0, resolve +globalThis.fetch = (_url, options) => { requests++; return new Promise(done => { resolve = done }) } +vm.mode = 'sample'; vm.start() +assert.equal(requests, 0) +vm.active = true +const reading = vm.refresh() +vm.stop() +resolve({ ok: true, json: async () => value }) +await reading +assert.equal(vm.snapshot, null) +assert.equal(vm.draft, null) +let auth = 0 +vm.unauthorized = () => auth++ +globalThis.fetch = async () => ({ ok: false, status: 401, json: async () => ({ error: 'Sign in' }) }) +vm.active = true +await vm.refresh() +assert.equal(auth, 1) +assert.equal(vm.active, false) +assert.equal(vm.timer, null) +console.log('Hotspot: schema, Vue template, preview exclusion, stale drafts, unmount race, credential clearing and authentication passed') diff --git a/openpilot/starpilot/galaxy/tests/test_lane_strength.mjs b/openpilot/starpilot/galaxy/tests/test_lane_strength.mjs new file mode 100644 index 0000000000..3dee4512c0 --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/test_lane_strength.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict" +import { presentedSetting, laneStrengthDisplay, laneStrengthSaved, GalaxySettingRow } from "../web/js/galaxy-setting-row.js" +const row = { displayScale: "lane-strength", minimum: 33, maximum: 200, step: 1, value: "100", defaultValue: "100", unit: "%", available: true } +assert.deepEqual([33, 100, 200].map(v => laneStrengthDisplay(v, row)), [1, 5, 10]) +assert.deepEqual([1, 5, 10].map(v => laneStrengthSaved(v, row)), [33, 100, 200]) +const display = presentedSetting(row) +assert.equal(display.minimum, 1); assert.equal(display.maximum, 10); assert.equal(display.step, 1) +assert.equal(display.value, 5); assert.equal(display.defaultValue, 5); assert.equal(display.unit, "") +for (let value = 33; value <= 200; value++) assert.equal(laneStrengthSaved(laneStrengthDisplay(value, row), row), value) +assert.equal(laneStrengthSaved(0, row), null); assert.equal(laneStrengthSaved(11, row), null) +assert.equal(presentedSetting({ value: "100" }).value, "100") +const saves = [] +const context = { row, presentedRow: display, locked: false, saveValue: async (index, value) => saves.push(value), index: 0 } +await GalaxySettingRow.methods.commit.call(context, 5) +assert.deepEqual(saves, []) +await GalaxySettingRow.methods.commit.call(context, 10) +assert.deepEqual(saves, [200]) +assert.equal(row.value, "100") +console.log("Lane strength presentation and unchanged raw transport verified") + +// Mount the actual shared component with Vue's renderer, including its template. +const { createRenderer, nextTick } = await import('../web/vendor/vue/vue.esm-browser.js') +const node = (type, text = '') => ({ type, text, props: {}, children: [], parent: null }) +const renderer = createRenderer({ + createElement: type => node(type), createText: text => node('text', text), createComment: text => node('comment', text), + setText: (item, text) => { item.text = text }, setElementText: (item, text) => { item.text = text }, + patchProp: (item, key, before, after) => { item.props[key] = after }, + insert: (item, parent, anchor = null) => { item.parent = parent; const i = parent.children.indexOf(anchor); parent.children.splice(i < 0 ? parent.children.length : i, 0, item) }, + remove: item => { const i = item.parent?.children.indexOf(item); if (i >= 0) item.parent.children.splice(i, 1) }, + parentNode: item => item.parent, nextSibling: () => null, +}) +// Vue's browser compiler defaults to a document-backed HTML entity decoder. +// This exact template contains no entity references: reject any future named or +// numeric reference (including optional-semicolon forms), then preserve its text. +// The actual Vue compiler and actual component template still run unchanged. +const entityReference = /&(?:#[xX][0-9a-fA-F]+|#[0-9]+|[A-Za-z][A-Za-z0-9]*)/ +assert.doesNotMatch(GalaxySettingRow.template, entityReference) +const mountActualRow = (props, host) => { + const app = renderer.createApp(GalaxySettingRow, props) + app.config.compilerOptions.decodeEntities = raw => { + assert.doesNotMatch(raw, entityReference) + return raw + } + return app.mount(host) +} +const all = item => [item, ...item.children.flatMap(all)] +for (const surface of ['desktop-sidebar', 'mobile']) { + const host = node(surface), commits = [] + const vm = mountActualRow({ row: { ...row, label: 'Lane Centering Strength', action: true }, index: 3, + saveValue: async (index, value) => commits.push([index, value]) }, host) + await nextTick() + const slider = all(host).find(item => item.type === 'input' && item.props.type === 'range') + assert.ok(slider, `${surface} renders actual slider`) + assert.equal(Number(slider.props.min), 1); assert.equal(Number(slider.props.max), 10) + assert.equal(Number(slider.props.step), 1); assert.equal(Number(slider.props.value), 5) + assert.equal(commits.length, 0, 'mount never rewrites old bytes') + for (const invalid of [0, 11, NaN]) await vm.commit(invalid) + assert.equal(commits.length, 0, "invalid actual component commits have no effect") + await vm.commit(5); assert.equal(commits.length, 0) + await vm.commit(10); assert.deepEqual(commits, [[3, 200]]) + vm.$.appContext.app.unmount() +} +const disabledHost = node('disabled') +const disabledCommits = [] +const disabledVm = mountActualRow({ row: { ...row, available: false, action: false, label: 'Lane Centering Strength' }, + index: 0, saveValue: async value => disabledCommits.push(value) }, disabledHost) +await disabledVm.commit(10) +assert.equal(disabledCommits.length, 0, 'unavailable setting has no effect') +disabledVm.$.appContext.app.unmount() diff --git a/openpilot/starpilot/galaxy/tests/test_layout_leave_attention.mjs b/openpilot/starpilot/galaxy/tests/test_layout_leave_attention.mjs new file mode 100644 index 0000000000..51d272720f --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/test_layout_leave_attention.mjs @@ -0,0 +1,41 @@ +import assert from "node:assert/strict" +import { OnroadLayoutPage } from "../web/js/onroad-layout.js" + +for (const projection of [false, true]) { + assert.equal(OnroadLayoutPage.computed.inlineLeavePrompt.call({ projection, pendingLeave: true, state: { leavePresentation: "inline" } }), true) +} +for (const projection of [false, true]) { + const vm = { projection, pendingLeave: false, state: { discard: "reload", leavePresentation: "inline" } } + assert.equal(OnroadLayoutPage.computed.inlineLeavePrompt.call(vm), true) + assert.equal(OnroadLayoutPage.computed.modalLeavePrompt.call(vm), false) + vm.state.discard = null + assert.equal(OnroadLayoutPage.computed.inlineLeavePrompt.call(vm), false) +} +let flashes = 0 +const pendingAction = () => {} +const pending = { inlineLeavePrompt: true, state: { discard: pendingAction }, flashLeavePrompt() { flashes++ } } +OnroadLayoutPage.methods.requestLeave.call(pending, () => {}) +assert.equal(flashes, 1) +assert.equal(pending.state.discard, pendingAction) + +const originalWindow = globalThis.window +try { + let reduced = false, focuses = 0 + const scrolls = [] + globalThis.window = { matchMedia: () => ({ matches: reduced }) } + const vm = { $refs: { leaveBar: { + scrollIntoView(options) { scrolls.push(options) }, + querySelector() { return { focus(options) { assert.equal(options.preventScroll, true); focuses++ } } }, + } } } + OnroadLayoutPage.methods.flashLeavePrompt.call(vm) + assert.equal(scrolls.at(-1).behavior, "smooth") + reduced = true + OnroadLayoutPage.methods.flashLeavePrompt.call(vm) + assert.equal(scrolls.at(-1).behavior, "instant") + assert.equal(focuses, 2) + +} finally { + if (originalWindow === undefined) delete globalThis.window + else globalThis.window = originalWindow +} +console.log("Layout leave attention: both targets, repeated navigation, focus restoration, reduced motion passed") diff --git a/openpilot/starpilot/galaxy/tests/test_layout_preview_visibility.mjs b/openpilot/starpilot/galaxy/tests/test_layout_preview_visibility.mjs new file mode 100644 index 0000000000..c85d451737 --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/test_layout_preview_visibility.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict" +import { OnroadLayoutPage } from "../web/js/onroad-layout.js" + +const originalStorage = globalThis.localStorage +const saved = new Map() +const editor = projection => { + const vm = { ...OnroadLayoutPage.setup({ projection, mode: "local", unauthorized() {} }), projection } + for (const name of ["sceneHidden", "previewHidden", "togglePreviewVisibility"]) + vm[name] = OnroadLayoutPage.methods[name].bind(vm) + return vm +} +try { + globalThis.localStorage = { getItem: key => saved.get(key) ?? null, setItem: (key, value) => saved.set(key, value) } + for (const projection of [false, true]) { + const vm = editor(projection) + assert.equal(vm.state.scene, "general") + assert.equal(vm.previewHidden("stopped_timer"), false) + assert.equal(vm.previewHidden("current_speed"), false, "targets have separate preferences") + vm.togglePreviewVisibility("stopped_timer") + vm.togglePreviewVisibility("current_speed") + vm.state.profile = "compact" + vm.togglePreviewVisibility("driver_monitor") + const reopened = editor(projection) + assert.equal(reopened.previewHidden("stopped_timer"), true, "General edit eye preferences survive reopening") + assert.equal(reopened.previewHidden("current_speed"), true) + assert.equal(reopened.previewHidden("driver_monitor"), false) + reopened.state.profile = "compact" + assert.equal(reopened.previewHidden("driver_monitor"), true) + assert.equal(reopened.previewHidden("current_speed"), false, "profiles have separate preferences") + assert.equal(reopened.state.draft, null, "visibility is independent of the saved driving layout") + reopened.state.scene = "stopped" + assert.equal(reopened.previewHidden("stopped_timer"), false, "General edit cannot hide the stopped scene's timer") + reopened.togglePreviewVisibility("stopped_timer") + assert.equal(reopened.previewHidden("stopped_timer"), true) + reopened.state.scene = "engaged" + assert.equal(reopened.previewHidden("stopped_timer"), true) + reopened.togglePreviewVisibility("stopped_timer") + assert.equal(reopened.previewHidden("stopped_timer"), true, "Driving scenes cannot show an impossible timer") + reopened.togglePreviewVisibility("current_speed") + reopened.state.scene = "experimental" + assert.equal(reopened.previewHidden("current_speed"), false, "Eye preferences do not leak between scenes") + assert.equal(reopened.previewHidden("stopped_timer"), true) + } + const aa = editor(true) + const before = [...saved] + aa.togglePreviewVisibility("car_exit") + assert.equal(aa.previewHidden("car_exit"), false) + assert.deepEqual([...saved], before) + + saved.delete('gx-layout-preview-visibility-v2:comma') + saved.set('gx-layout-preview-visibility-v1:comma', JSON.stringify({ large: { current_speed: true }, compact: { stopped_timer: true } })) + const migrated = editor(false) + assert.equal(migrated.previewHidden('current_speed'), true, 'Old preview preferences become General edit preferences') + migrated.state.profile = 'compact' + assert.equal(migrated.previewHidden('stopped_timer'), false, 'The old automatic timer default does not hide General edit samples') + + for (const stored of ["broken JSON", "null", JSON.stringify({ large: [true], compact: "invalid" }), + JSON.stringify({ large: { stopped_timer: "false", current_speed: 1 } })]) { + globalThis.localStorage.getItem = () => stored + const vm = editor(false) + assert.equal(vm.previewHidden("stopped_timer"), false) + assert.equal(vm.previewHidden("current_speed"), false) + } + globalThis.localStorage = { + getItem() { throw new Error("Storage unavailable") }, + setItem() { throw new Error("Storage unavailable") }, + } + const unavailable = editor(false) + unavailable.togglePreviewVisibility("current_speed") + assert.equal(unavailable.previewHidden("current_speed"), true) +} finally { + if (originalStorage === undefined) delete globalThis.localStorage + else globalThis.localStorage = originalStorage +} +console.log("Preview visibility: General edit persistence, scene/target/profile isolation, timer eligibility, required exit and unavailable storage passed") diff --git a/openpilot/starpilot/galaxy/tests/test_layout_snap.mjs b/openpilot/starpilot/galaxy/tests/test_layout_snap.mjs new file mode 100644 index 0000000000..95920d08fb --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/test_layout_snap.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict" +import { SNAP_DISTANCE, snapTargets, snapMove, snapResize, snapThreshold } from "../web/js/layout-snap.js" + +const copy = value => structuredClone(value) +// Every helper call also checks that its inputs are unchanged. +function pure(fn, ...args) { + const before = args.map(arg => typeof arg === "function" ? arg : copy(arg)) + const result = fn(...args) + assert.deepEqual(args, before) + return result +} +const profile = { width: 1000, height: 600, bounds: { x: 30, y: 30, width: 900, height: 540 }, reservedZones: [], widgets: { + moving: { label: "Moving", kind: "clock", width: 100, height: 60 }, + home: { label: "Home", kind: "favorite", width: 100, height: 60 }, +} } +const layout = { moving: { x: 200, y: 200, enabled: true }, home: { x: 300, y: 350, enabled: true } } +const targets = pure(snapTargets, profile, layout, "moving", () => true) +const bounds = targets.filter(target => target.kind !== "widget") +const box = { x: 34, y: 113, width: 100, height: 60 } +let result = pure(snapMove, box, bounds, 6) +assert.equal(result.x, 30) +assert.equal(result.y, 113) +assert.deepEqual(result.guides, [{ axis: "x", value: 30, kind: "bounds", label: "the edge" }]) +assert.deepEqual(pure(snapMove, { ...box, x: 37 }, bounds, 6), { x: 37, y: 113, guides: [] }) +result = pure(snapMove, { ...box, x: 433 }, bounds, 6) +assert.equal(result.x, 430) +assert.equal(result.guides[0].kind, "center") +result = pure(snapMove, { ...box, x: 404 }, targets, 6) +assert.equal(result.x, 400) // Start to another widget's end. +assert.equal(result.guides[0].label, "Home") +assert.deepEqual(pure(snapMove, { ...box, x: 253 }, targets, 6), { x: 253, y: 113, guides: [] }) +assert.deepEqual(pure(snapMove, box, bounds, 0), { x: 34, y: 113, guides: [] }) +const target = (value, line = "start", kind = "widget", axis = "x", label = "Home") => ({ axis, value, line, kind, label }) +result = pure(snapMove, { ...box, x: 100 }, [target(104), target(146, "center", "center")], 6) +assert.equal(result.x, 96) // Equal distance prefers bounds center. +assert.equal(result.guides[0].kind, "center") +for (const kinds of [["zone", "widget"], ["widget", "bounds"], ["bounds", "center"]]) { + const first = target(104, "start", kinds[0]), second = target(96, "start", kinds[1]) + assert.equal(pure(snapMove, { ...box, x: 100 }, [first, second], 6).x, 96) +} +assert.equal(pure(snapMove, { ...box, x: 100 }, [target(104), target(96)], 6).x, 104) +result = pure(snapMove, { ...box, x: 104 }, [target(100), target(200, "end"), target(100, "end", "bounds")], 6) +assert.deepEqual(result.guides, [ + { axis: "x", value: 100, kind: "bounds", label: "Home" }, + { axis: "x", value: 200, kind: "widget", label: "Home" }, +]) +const exclusions = copy(profile), placements = copy(layout) +for (const [id, kind, enabled] of [["disabled", "clock", false], ["hidden", "clock", true], ["torque", "torque_bar", true], + ["pip", "pip_camera", true], ["invalid", "clock", true]]) { + exclusions.widgets[id] = { label: id, kind, layer: "underlay", width: 60, height: 60 } + placements[id] = { x: id === "invalid" ? Infinity : 100, y: 100, enabled } +} +exclusions.widgets.missing = { label: "missing", kind: "clock", width: 60, height: 60 } +const filtered = pure(snapTargets, exclusions, placements, "moving", id => id !== "hidden") +assert.deepEqual([...new Set(filtered.filter(item => item.kind === "widget").map(item => item.label))], ["Home", "pip"]) +assert.deepEqual(pure(snapTargets, profile, layout, "absent", () => true), []) +const sidebar = copy(profile) +sidebar.widgets.moving.bounds = { x: 940, y: 0, width: 60, height: 600 } +assert.deepEqual(pure(snapTargets, sidebar, layout, "moving", () => false).filter(item => item.axis === "x").map(item => item.value), [940, 1000, 970]) +const zones = copy(profile) +zones.reservedZones.push({ x: 150, y: 160, width: 100, height: 80 }) +assert.deepEqual(pure(snapTargets, zones, layout, "moving", () => false).filter(item => item.kind === "zone").map(item => item.value), [150, 250, 160, 240]) +const resizeBox = { x: 100, y: 150, width: 97, height: 146 } +result = pure(snapResize, resizeBox, [target(200), target(300, "end", "widget", "y")], 6) +assert.deepEqual(result, { width: 100, height: 150, guides: [ + { axis: "x", value: 200, kind: "widget", label: "Home" }, + { axis: "y", value: 300, kind: "widget", label: "Home" }, +] }) +assert.deepEqual(pure(snapResize, resizeBox, [target(200, "center")], 6), { width: 97, height: 146, guides: [] }) +assert.deepEqual(SNAP_DISTANCE, { touch: 10, fine: 6 }) +for (const [pointer, expected] of [["touch", 60], ["mouse", 36], ["pen", 36]]) + assert.equal(pure(snapThreshold, { width: 2160 }, { width: 360 }, pointer), expected) +assert.equal(pure(snapThreshold, profile, { width: 0 }, "touch"), 0) +assert.equal(pure(snapThreshold, profile, { width: -1 }, "touch"), 0) +console.log("Layout snapping: targets, matching, priority, guides, resize, scaling and purity passed") diff --git a/openpilot/starpilot/galaxy/tests/test_live_camera.mjs b/openpilot/starpilot/galaxy/tests/test_live_camera.mjs new file mode 100644 index 0000000000..dfb8d0a2be --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/test_live_camera.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict" +import { CameraSnapshotFeed, LiveCameraPreview } from "../web/js/cameras.js" + +// A server or body that ignores AbortSignal must release the polling caller. +for (const bodyStalls of [false, true]) { + let expire, signal + const updates = [] + const feed = new CameraSnapshotFeed({ publish: value => updates.push(value), unauthorized() {}, + later: fn => { expire = fn; return 1 }, cancelTimer() {}, + fetcher: async (_url, options) => { + signal = options.signal + return bodyStalls ? { ok: true, status: 200, headers: new Headers({ 'Content-Type': 'image/jpeg' }), + blob: () => new Promise(() => {}) } : new Promise(() => {}) + }, + }) + const pending = feed.capture("cabin") + await Promise.resolve() + expire() + await pending + assert.equal(signal.aborted, true) + assert.equal(updates.at(-1).capturing, false) + assert.match(updates.at(-1).error, /timed out/) + assert.equal(feed.request, null) +} + +// Retired image callbacks cannot restore an image after hiding/unmounting. +let image, stopped = 0 +const updates = [] +const preview = new LiveCameraPreview({ publish: value => updates.push(value), unauthorized() {}, + redraw() {}, enabled: () => true, imageFactory: () => image = {} }) +preview.stopped = false +preview.receive({ image: 'blob:frame' }) +preview.snapshots.stop = () => stopped++ +preview.clear() +image.onload() +assert.equal(preview.image, null) +assert.equal(updates.at(-1).imageName, '') +assert.equal(stopped, 1) +console.log('Live camera: stalled fetch/body settle, explicit failures, stale decode cleanup passed') + +const canceled = new CameraSnapshotFeed({ publish() {}, unauthorized() {}, fetcher: () => new Promise(() => {}) }) +const canceledCapture = canceled.capture("cabin") +canceled.stop() +await canceledCapture +assert.equal(canceled.request, null) + +// Editors warm up once, then keep the frame without background capture or flicker. +const timers = new Map() +let nextTimer = 0, captures = 0 +const originalDocument = globalThis.document +const listeners = new Map() +globalThis.document = { hidden: false, addEventListener: (name, fn) => listeners.set(name, fn), removeEventListener: name => listeners.delete(name) } +const snapshot = new LiveCameraPreview({ publish() {}, unauthorized() {}, redraw() {}, enabled: () => true, + later: (fn, ms) => { timers.set(++nextTimer, { fn, ms }); return nextTimer }, cancelTimer: id => timers.delete(id) }) +snapshot.snapshots.capture = async () => { captures++; snapshot.image = { src: 'last-good-frame' } } +snapshot.snapshots.stop = () => {} +snapshot.start() +await Promise.resolve() +const warmup = [...timers.values()].find(timer => timer.ms === 5000) +assert.ok(warmup) +warmup.fn() +const heldFrame = snapshot.image +await snapshot.poll() +globalThis.document.hidden = true; listeners.get('visibilitychange')() +globalThis.document.hidden = false; listeners.get('visibilitychange')() +assert.equal(captures, 1) +assert.equal(snapshot.image, heldFrame, 'visibility changes preserve the held snapshot') +snapshot.stop() +globalThis.document = originalDocument +console.log('Camera editor warm-up: five-second deadline, frozen frame and visibility stability passed') diff --git a/openpilot/starpilot/galaxy/tests/test_local_access.mjs b/openpilot/starpilot/galaxy/tests/test_local_access.mjs index 242f3e55b5..75dde44ae5 100644 --- a/openpilot/starpilot/galaxy/tests/test_local_access.mjs +++ b/openpilot/starpilot/galaxy/tests/test_local_access.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict" import { compile } from "../web/vendor/vue/vue.esm-browser.js" import { LocalAccess, LocalAccessFeed, validLocalAccess } from "../web/js/local-access.js" -import { GalaxyPage } from "../web/js/galaxy.js" +import { Home } from "../web/js/home.js" const snapshot = { available: true, addresses: [{ interface: 'wlan0', label: 'Wi-Fi', url: 'http://192.168.20.5:8082/' }], reason: '' } assert.equal(validLocalAccess(snapshot), true) assert.equal(validLocalAccess({ available: false, addresses: [], reason: 'No active addresses' }), true) @@ -16,14 +16,14 @@ const feed = new LocalAccessFeed({ publish: value => updates.push(value), onUnau await feed.start('sample') await feed.refresh() assert.equal(requests.length, 0) -assert.match(updates.at(-1).error, /sample mode/) +assert.match(updates.at(-1).error, /preview/) await feed.start('local') assert.equal(requests.length, 1) assert.equal(requests[0].url, './api/local-access') assert.equal(requests[0].options.credentials, 'same-origin') -assert.equal(timers[0].ms, 4000) +assert.equal(timers.at(-1).ms, 4000) assert.deepEqual(updates.at(-2).data, snapshot) -assert.equal(timers.length, 1) // One deadline only; no periodic polling timer. +assert.equal(timers.length, 2) // Background synchronization plus a bounded request. await feed.refresh() assert.equal(requests.length, 2) feed.fetcher = async () => ({ status: 401 }) @@ -47,15 +47,17 @@ const boundedRead = bounded.start('local') assert.equal(await bounded.refresh(), undefined) // Duplicate refresh is suppressed. deadline() await boundedRead -assert.match(timeoutUpdates.at(-2).error, /timed out/) +assert.match(timeoutUpdates.at(-2).error, /Reconnecting/) assert.equal(timeoutUpdates.at(-1).loading, false) bounded.stop() compile(LocalAccess.template, { decodeEntities: value => value }) const setup = LocalAccess.setup({ mode: 'sample', onUnauthorized() {} }) await setup.feed.start('sample') -assert.match(setup.state.error, /sample mode/) +assert.match(setup.state.error, /preview/) setup.feed.stop() -assert.equal(GalaxyPage.components.LocalAccess, LocalAccess) -assert.ok(GalaxyPage.template.includes(':on-unauthorized="unauthorized"')) -assert.ok(LocalAccess.template.includes('your browser must be able to reach its network')) -console.log('Local access: actual Vue component/setup, bounded manual-only read, sample exclusion, authentication, safe links and unmount race passed') +assert.equal(Home.components.LocalAccess, LocalAccess) +assert.ok(Home.template.includes(':on-unauthorized="unauthorized"')) +assert.ok(LocalAccess.template.includes('Connect your phone to the Galaxy hotspot')) +assert.ok(LocalAccess.template.includes('full http:// address into a new browser tab')) +assert.ok(LocalAccess.template.includes('public Galaxy link uses the Internet tunnel')) +console.log('Local access: actual Vue component/setup, bounded automatically synchronized read, sample exclusion, authentication, safe links and unmount race passed') diff --git a/openpilot/starpilot/galaxy/tests/test_navigation_autocomplete.mjs b/openpilot/starpilot/galaxy/tests/test_navigation_autocomplete.mjs new file mode 100644 index 0000000000..3cbcc70284 --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/test_navigation_autocomplete.mjs @@ -0,0 +1,182 @@ +import assert from "node:assert/strict" +import { compile } from "../web/vendor/vue/vue.esm-browser.js" +import { FavoriteChoices, NavigationClient, NavigationPage, validDestination } from "../web/js/navigation.js" + +const home = { id: "h1", name: "12 Elm St", latitude: 40.1, longitude: -90.1 } +const gym = { id: "g1", name: "Elm Fitness", latitude: 40.3, longitude: -90.3 } +const status = { enabled: true, isMetric: false, hasKey: true, status: "noDestination", revision: "r1", destination: null, + favorites: [home, gym], route: [], instruction: null, location: null } +const suggestion = (id, searchId) => ({ id, name: "Coffee " + id, description: "Main St", searchId }) + +assert.equal(validDestination(home), true) + +// Exercise the picker template as well as its parent methods: each choice must +// follow the declared available prop, including after connectivity is lost. +const renderChoices = compile(FavoriteChoices.template) +for (const available of [true, false]) { + const picker = renderChoices({ available, $emit() {} }, []) + assert.deepEqual(picker.children[0].children.map(button => button.props.disabled), Array(3).fill(!available)) +} + +// Suggestions share one search session while typing and never cancel status polling. +const requests = [] +const client = new NavigationClient({ publish() {}, later: () => 1, cancelTimer() {}, + fetcher: async (url, options = {}) => { + const body = options.body ? JSON.parse(options.body) : null + requests.push({ url, body, signal: options.signal }) + const payload = url.endsWith("/status") ? status : { results: [suggestion("a", body.searchId), suggestion("b", body.searchId)] } + return { ok: true, status: 200, json: async () => payload } + } }) +await client.start() +assert.equal(client.data.revision, "r1") +const first = await client.suggest("cof") +const second = await client.suggest("coffee") +const typed = requests.filter((request) => request.body?.autocomplete === true) +assert.equal(typed.length, 2) +assert.equal(typed[0].body.searchId, typed[1].body.searchId, "one session for the whole typing burst") +assert.equal(typed[0].body.clientId, client.clientId) +assert.equal(first.length, 2) +assert.deepEqual(second.map((place) => place.name), ["Coffee a", "Coffee b"]) +const searchRequests = requests.length +assert.deepEqual((await client.search("coffee")).results, second) +assert.equal(requests.length, searchRequests, "Search reuses matching suggestions without another provider request") +assert.equal(client.searchId, typed[0].body.searchId, "reused places keep their retrieval session") +await client.suggest("coffee") +assert.deepEqual(await client.suggest("co"), [], "two letters are too few to suggest") +assert.equal(client.suggestions.length, 0) + +// A status poll while typing does not abort the suggestion request (and vice versa). +const slow = [] +const parallel = new NavigationClient({ publish() {}, later: () => 1, cancelTimer() {}, + fetcher: (url, options = {}) => new Promise((resolve) => slow.push({ url, options, resolve })) }) +parallel.active = true +parallel.data = status +const pendingSuggest = parallel.suggest("coffee") +const pendingStatus = parallel.run("status") +assert.equal(slow.length, 2) +assert.equal(slow[0].options.signal.aborted, false, "status polling leaves the suggestion request alone") +slow[0].resolve({ ok: true, status: 200, json: async () => ({ results: [suggestion("c", JSON.parse(slow[0].options.body).searchId)] }) }) +slow[1].resolve({ ok: true, status: 200, json: async () => status }) +assert.equal((await pendingSuggest).length, 1) +await pendingStatus +parallel.stop() + +// Choosing a suggestion or running a full search ends the typing session. +const chosen = client.suggestions[0] || second[0] +await client.choose(chosen) +const select = requests.at(-1) +assert.deepEqual(select.body, { action: "selectPlace", revision: "r1", id: "a", searchId: typed[0].body.searchId }) +await client.suggest("tea") +const afterChoice = requests.filter((request) => request.body?.autocomplete === true).at(-1) +assert.notEqual(afterChoice.body.searchId, typed[0].body.searchId, "a new typing burst gets a new session") +await client.search("12 Elm St") +assert.equal(requests.at(-1).body.autocomplete, undefined, "the Search button is a full search") +assert.ok(requests.at(-1).body.searchId, "with its own search session, as upstream does") +assert.equal(client.suggestions.length, 0) +client.stop() + +// A refused session (expired or settings changed) is replaced on the next keystroke. +let refuse = true +const refused = new NavigationClient({ publish() {}, later: () => 1, cancelTimer() {}, + fetcher: async (_url, options) => ({ ok: !refuse, status: refuse ? 400 : 200, + json: async () => refuse ? { error: "Start a new destination search" } : { results: [suggestion("d", JSON.parse(options.body).searchId)] } }) }) +refused.active = true +refused.data = status +assert.deepEqual(await refused.suggest("coffee"), []) +const stale = refused.autocompleteId +assert.equal(stale, null) +refuse = false +assert.equal((await refused.suggest("coffee")).length, 1) + +// Page logic: saved and recent places match first in the dropdown; an empty box lists them all. +const computed = NavigationPage.computed +const recent = { id: "r1", name: "Elm Diner", address: "5 Oak Ave", latitude: 40.5, longitude: -90.5 } +const evaluate = (state) => { + const page = { suggestOpen: true, suggestions: [], results: [], ...state } + for (const name of ["ready", "favorites", "savedIds", "recents", "shortcuts", "localMatches", "showQuick", "showSuggestions", "showShortcuts"]) + page[name] = computed[name].call(page) + return page +} +let page = evaluate({ data: { ...status, recents: [recent, home] }, query: "elm" }) +assert.deepEqual(page.recents.map((place) => place.id), ["r1"], "recents leave out places that are already saved") +assert.deepEqual(page.localMatches.map((place) => place.id), ["h1", "g1", "r1"]) +assert.equal(page.showSuggestions, true) +assert.deepEqual(evaluate({ data: status, query: "oak" }).localMatches, [], "only this page's places are matched") +assert.deepEqual(evaluate({ data: { ...status, recents: [recent] }, query: "oak" }).localMatches.map((place) => place.id), ["r1"], "addresses match too") +assert.equal(evaluate({ data: status, query: "zzz" }).showSuggestions, false) +page = evaluate({ data: { ...status, recents: [recent] }, query: "" }) +assert.equal(page.showQuick, true) +assert.equal(page.showShortcuts, false, "one-tap places hide while the list is open") +page = evaluate({ data: { ...status, recents: [recent] }, query: "", suggestOpen: false }) +assert.deepEqual([page.showQuick, page.showShortcuts], [false, true]) +assert.equal(evaluate({ data: { ...status, destination: home }, query: "", suggestOpen: false }).showShortcuts, false) +const methods = NavigationPage.methods +const labeled = { ...home, label: "home" } +assert.deepEqual([methods.placeName(labeled), methods.placeDetail(labeled)], ["Home", "12 Elm St"]) +assert.equal(methods.placeIcon.call({ isFavorite: () => true }, labeled), "bi-house-fill") +const actions = [] +await methods.setLabel.call({ client: { action: async (...args) => actions.push(args) } }, labeled, "home") +assert.deepEqual(actions.at(-1), ["labelFavorite", { id: "h1", label: null }], "choosing the current label clears it") +const usage = { searchSessions: { used: 12, limit: 495, free: 500 }, geocoding: { used: 4, limit: 99000, free: 100000 }, + directions: { used: 3, limit: 99000, free: 100000 }, staticTiles: { used: 0, limit: 198000, free: 200000 } } +assert.deepEqual(computed.usageRows.call({ data: { mapboxUsage: usage } }).map((row) => [row.label, row.used, row.limit]), + [["Searches", 12, 495], ["Address lookups", 4, 99000], ["Routes", 3, 99000], ["Map views", 0, 198000]]) + +compile(NavigationPage.template, { decodeEntities: (value) => value.replaceAll("&", "&") }) +compile(FavoriteChoices.template, { decodeEntities: value => value.replaceAll("&", "&") }) +const favoriteActions = [], favoritePage = { favorites: [], savedSuggestionPlaces: {}, favoritePicker: "selected", + client: { save: async (place, label) => { favoriteActions.push([place, label]); return {favorites:[{...home,label}]} } } } +await methods.saveAs.call(favoritePage, home, "home") +assert.deepEqual(favoriteActions[0], [home, "home"]) +assert.equal(favoritePage.favoritePicker, null) +favoritePage.favorites = [{...home,label:"home"}] +favoritePage.client.action = async (...args) => { favoriteActions.push(args); return {favorites:[home]} } +await methods.saveAs.call(favoritePage, home, null) +assert.deepEqual(favoriteActions.at(-1), ["labelFavorite", {id:home.id,label:null}], "Other clears Home/Work without removing the favorite") +favoritePage.favoritePicker = "selected" +favoritePage.client.action = async () => null +await methods.saveAs.call(favoritePage, home, "work") +assert.equal(favoritePage.favoritePicker, "selected", "failed saves leave the inline choices open") +console.log("Navigation autocomplete: one session per typing burst, parallel polling, session reset, usage and template passed") + + +// Stars select categories; the separate remove action clears persisted IDs and suggestion markers. +{ + const suggestion = { id: 'search-result', searchId: 'search-session', name: 'Cafe' } + const saved = { id: 'saved-cafe', name: 'Cafe', latitude: 36, longitude: -115 } + const page = { favorites: [saved], savedSuggestionPlaces: { [suggestion.id]: saved.id }, favoritePicker: 'old-picker' } + Object.defineProperty(page, 'savedIds', { get: () => new Set(page.favorites.map(place => place.id)) }) + for (const name of ['isFavorite', 'favoriteKey', 'openFavorite', 'removeFavorite', 'saveAs']) page[name] = methods[name].bind(page) + const removed = [], savedPlaces = [] + page.client = { + action: async (action, value) => { removed.push([action, value]); page.favorites = []; return { favorites: [] } }, + save: async (place, label) => { savedPlaces.push([place, label]); return { favorites: [] } }, + } + assert.equal(page.isFavorite(suggestion), true) + page.openFavorite(suggestion) + assert.equal(page.favoritePicker, page.favoriteKey(suggestion)) + assert.deepEqual(removed, [], 'the star opens categories without removing a favorite') + await page.removeFavorite(suggestion) + assert.deepEqual(removed, [['removeFavorite', { id: saved.id }]]) + assert.equal(page.isFavorite(suggestion), false) + assert.deepEqual(page.savedSuggestionPlaces, {}) + assert.equal(page.favoritePicker, null) + const other = { ...saved, id: 'another-place', name: 'Park' } + page.openFavorite(other) + assert.equal(page.favoritePicker, page.favoriteKey(other)) + await page.saveAs(other, null) + assert.deepEqual(savedPlaces, [[other, null]], 'another Other favorite can be saved after removing one') + + page.savedSuggestionPlaces[suggestion.id] = saved.id + assert.equal(page.isFavorite(suggestion), false, 'a stale suggestion marker cannot override a refreshed saved list') + page.favorites = [saved] + page.client.action = async () => null + await page.removeFavorite(suggestion) + assert.equal(page.isFavorite(suggestion), true, 'failed removal preserves the saved place for retry') + assert.equal(page.savedSuggestionPlaces[suggestion.id], saved.id) + await page.openFavorite(saved) + assert.equal(page.favoritePicker, page.favoriteKey(saved), 'the filled star opens the category picker') +} +assert.match(NavigationPage.template, /@click="removeFavorite\(place\)"> ({ areas: [area], settings: { saveDriven: true }, position: { latitude: 36.1, longitude: -115.2 }, + service: { running: true, network: "wifi", failure: null, noSpace: false, hasKey: true }, + bytes: { saved: 5e6, driven: 2e6, cache: 1e6 }, limits: { saved: 3e9, driven: 6e8, cache: 1.5e8 }, + usage: { tiles: 1234, freeTiles: 200000 }, route: null, constants, ...extra }) + +assert.equal(validOffline(snapshot()), true) +assert.equal(validOffline(snapshot({ position: null })), true) +for (const bad of [{ areas: {} }, { settings: { saveDriven: "yes" } }, { position: { latitude: 99, longitude: 0 } }, + { areas: [{ ...area, id: "../x" }] }, { areas: [{ ...area, progress: { state: "exploded", total: 1, done: 0 } }] }, + { constants: { ...constants, maxAreas: "24" } }, { service: null }]) + assert.equal(validOffline(snapshot(bad)), false, JSON.stringify(bad)) + +// The same circle-of-tiles estimate as offline_roads.estimate (Python), e.g. 10 km at 36.1°N is 97 tiles. +assert.equal(estimateArea(36.1, 10, constants).tiles, 97) +assert.equal(estimateArea(36.1, 10, constants).downloadBytes, 97 * 38000) +assert.ok(estimateArea(60, 10, constants).tiles > estimateArea(0, 10, constants).tiles, "tiles shrink toward the poles") + +assert.equal(formatBytes(0), "0 KB") +assert.equal(formatBytes(512 * 1024), "512 KB") +assert.equal(formatBytes(5.5 * 1024 ** 2), "5.5 MB") +assert.equal(formatBytes(3 * 1024 ** 3), "3.00 GB") +assert.equal(areaLabel(area), "Downloading · 25%") +assert.equal(areaLabel({ ...area, progress: null }), "Waiting for the downloader") +assert.equal(areaLabel({ ...area, progress: { state: "complete", total: 4, done: 4, failed: 0 } }), "Saved for offline") +assert.equal(areaLabel({ ...area, progress: { state: "incomplete", total: 4, done: 3, failed: 1 } }), "1 tiles missing · retries later") + +// Imperial devices pick whole miles; areas are still saved in km within the device's limits. +assert.equal(radiusLabel(24.14, false), "15 mi") +assert.equal(radiusLabel(25, true), "25 km") +assert.deepEqual(radiusRange(constants, false), { min: 2, max: 124, initial: 15 }) +assert.deepEqual(radiusRange(constants, true), { min: 2, max: 200, initial: 25 }) +assert.equal(toKm(15, false), 24.1) +assert.ok(toKm(2, false) >= constants.minRadiusKm && toKm(124, false) <= constants.maxRadiusKm) +assert.equal(toKm(25, true), 25) + +// Map: a coarser tile stands in while zooming, trackpad wheels step one level per gesture, redraws share a frame. +globalThis.ResizeObserver ??= class { observe() {} disconnect() {} } +const frames = [] +globalThis.requestAnimationFrame = (fn) => { frames.push(fn); return frames.length } +globalThis.cancelAnimationFrame = () => {} +const listeners = {} +const canvas = { clientWidth: 400, clientHeight: 300, width: 0, height: 0, addEventListener: (name, fn) => { listeners[name] = fn }, + removeEventListener() {}, setPointerCapture() {}, getBoundingClientRect: () => ({ left: 0, top: 0, width: 400, height: 300 }), + getContext: () => new Proxy({}, { get: (_, key) => key === "drawImage" ? (...args) => drawn.push(args) : () => {}, set: () => true }) } +const drawn = [] +const map = new RasterMap(canvas) +map.load = () => {} +const parent = { close() {} } +map.tiles.set("11/600/800", parent) +const cover = map.placeholder(12, 1201, 1600) +assert.equal(cover[0], parent) +assert.deepEqual(cover.slice(1), [256, 0, 256], "the right quarter of the parent tile") +assert.equal(map.placeholder(12, 50, 50), null) +map.zoom = 3 +let now = 1000 +const realPerformance = globalThis.performance +globalThis.performance = { now: () => now } +for (let i = 0; i < 40; i++) listeners.wheel({ preventDefault() {}, deltaY: -12, deltaMode: 0 }) +assert.equal(map.zoom, 4, "one trackpad flick is one zoom level, not ten") +now += 200 +for (let i = 0; i < 9; i++) listeners.wheel({ preventDefault() {}, deltaY: -12, deltaMode: 0 }) +assert.equal(map.zoom, 5) +globalThis.performance = realPerformance +frames.length = 0 +map.requestDraw(); map.requestDraw(); map.requestDraw() +assert.equal(frames.length, 1, "pointer moves within a frame draw once") +let picked = null +map.onTap = point => { picked = point } +listeners.keydown({ key: "Enter", preventDefault() {} }) +assert.deepEqual(picked, map.center, "keyboard users can select the map center") +map.close() + +// Client: load, act, and hand 401s to the sign-in flow. +const requests = [] +let reply = { status: 200, body: snapshot() } +const updates = [] +const client = new OfflineRoadsClient({ publish: (update) => updates.push(update), later: () => 1, cancel() {}, + fetcher: async (url, options) => { requests.push({ url, method: options.method || "GET", body: options.body ? JSON.parse(options.body) : null }) + return { ok: reply.status < 400, status: reply.status, json: async () => reply.body } } }) +await client.start() +assert.equal(requests[0].url, "./api/navigation/offline") +assert.deepEqual(updates.at(-1).data.areas.map((row) => row.name), ["Las Vegas"]) +reply = { status: 200, body: snapshot({ areas: [] }) } +const result = await client.action({ action: "deleteArea", id: area.id }) +assert.deepEqual(requests.at(-1), { url: "./api/navigation/offline", method: "POST", body: { action: "deleteArea", id: area.id } }) +assert.deepEqual(result.areas, []) +assert.equal(updates.at(-1).busy, false) +reply = { status: 400, body: { error: "Choose a place and a radius between 2 and 200 km" } } +assert.equal(await client.action({ action: "addArea", area: {} }), null) +assert.equal(updates.at(-1).error, "Choose a place and a radius between 2 and 200 km") +reply = { status: 200, body: { areas: "nope" } } +await client.load() +assert.match(updates.at(-1).error, /unexpected answer/) +let signedOut = false +const guarded = new OfflineRoadsClient({ publish() {}, unauthorized: () => { signedOut = true }, later: () => 1, cancel() {}, + fetcher: async () => ({ ok: false, status: 401, json: async () => ({}) }) }) +await guarded.start() +assert.equal(signedOut, true) +assert.equal(guarded.active, false) +client.stop() + +// An older poll cannot undo a completed setting change, even if fetch ignores abort. +const pending = [] +const racing = new OfflineRoadsClient({ publish() {}, later: () => 1, cancel() {}, + fetcher: () => new Promise(resolve => pending.push(resolve)) }) +const oldPoll = racing.start() +const changed = racing.action({ action: "settings", patch: { saveDriven: false } }) +const response = body => ({ ok: true, status: 200, json: async () => body }) +pending[1](response(snapshot({ settings: { saveDriven: false } }))) +await changed +pending[0](response(snapshot())) +await oldPoll +assert.equal(racing.data.settings.saveDriven, false) +const stoppedPoll = racing.load() +racing.stop() +pending[2](response(snapshot())) +await stoppedPoll +assert.equal(racing.data.settings.saveDriven, false, "stopped requests cannot publish") + +let timeout +const stalled = new OfflineRoadsClient({ publish() {}, cancel() {}, later: (fn, ms) => { if (ms === 10000) timeout = fn; return 1 }, + fetcher: (_url, { signal }) => new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(new DOMException("Timed out", "AbortError")))) }) +stalled.active = true +const timedOut = stalled.action({ action: "settings", patch: { saveDriven: true } }) +timeout() +await timedOut +assert.equal(stalled.busy, false, "an unresponsive action releases the controls") +assert.ok(stalled.error) +stalled.stop() + +// Templates compile with the real Vue compiler, and the Offline Maps tab shows both panels. +const decodeEntities = (value) => value.replaceAll("&", "&") +compile(OfflineRoadMapsPanel.template, { decodeEntities }) +compile(NavigationPage.template, { decodeEntities }) +assert.ok(NavigationPage.template.includes(" 1, cancel: () => {}, + publish: state => states.push(state), stop() { this.active = false; this.generation++; this.request?.abort() }, unauthorized() {}, + fetcher: async (url, options) => { requests.push({ url, options }); return { ok: true, status: 200, json: async () => replies.shift() } } } + const accept = data => states.push(data) + const valid = data => data?.schemaVersion === 1 && Array.isArray(data.reports) + return { feed, requests, states, options: { accept, valid, subject: 'crash reports' } } +} +const cancel = fixture() +assert.equal(await deleteRecords(cancel.feed, '/delete', { all: true, confirmed: true }, { ...cancel.options, confirm: () => false }), false) +assert.equal(cancel.requests.length, 0) +const individual = fixture() +assert.equal(await deleteRecords(individual.feed, '/delete', { id: 'selected' }, individual.options), true) +assert.equal(individual.requests[0].options.method, 'POST') +assert.deepEqual(JSON.parse(individual.requests[0].options.body), { id: 'selected' }) +assert.equal(individual.states.find(state => state.storageBytes === 0).reportCount, 0) +assert.equal(individual.states[0].preview, null) +assert.equal(individual.feed.deleting, false) +const batches = fixture([{ ...empty, more: true, storageBytes: 5 }, empty]) +let message +assert.equal(await deleteRecords(batches.feed, '/delete', { all: true, confirmed: true }, { + ...batches.options, confirm: value => { message = value; return true }, +}), true) +assert.match(message, /Delete all crash reports.*cannot be undone/) +assert.equal(batches.requests.length, 2) +assert.deepEqual(batches.states.filter(state => state.storageBytes !== undefined).map(state => state.storageBytes), [5, 0]) +const stale = fixture() +let reply +stale.feed.fetcher = () => new Promise(resolve => { reply = resolve }) +const pending = deleteRecords(stale.feed, '/delete', { id: 'selected' }, stale.options) +stale.feed.stop() +reply({ ok: true, status: 200, json: async () => empty }) +assert.equal(await pending, false) +assert.equal(stale.states.some(state => state.reports), false) +assert.equal(storedSize(7), '7 B') +assert.equal(storedSize(2048), '2.0 KB') +for (const file of ['sentry.js', 'logs.js']) { + const source = readFileSync(new URL('../web/js/' + file, import.meta.url), 'utf8') + assert.match(source, /Delete All/) + assert.match(source, />Delete<\/button>/) + assert.match(source, /storageBytes/) + assert.match(source, /:disabled="(?:deleting|crashes.deleting)"/) + assert.doesNotMatch(source, /v-html/) +} +console.log('record deletion confirmation, refresh, lifecycle and UI hooks passed') + +const { SentryEventsFeed } = await import('../web/js/sentry.js') +const { CrashReportsFeed } = await import('../web/js/crash-reports.js') +for (const kind of ['sentry', 'crash']) { + const eventId = 'a'.repeat(32) + let inventory = kind === 'sentry' + ? { schemaVersion: 1, source: 'local', scanIncomplete: false, capacity: 512, storageBytes: 10, storageIncomplete: false, + events: [{ eventId, kind: 'warning', systemTimeMs: 1700000000000 }] } + : { schemaVersion: 1, reports: [{ id: 'opaque-id', name: 'report', size: 10, modifiedAt: 1 }], reportCount: 1, + storageBytes: 10, storageIncomplete: false, scanIncomplete: false, listLimited: false } + const updates = [], calls = [] + const Feed = kind === 'sentry' ? SentryEventsFeed : CrashReportsFeed + const feed = new Feed({ publish: update => updates.push(update), later: () => 1, cancel: () => {}, cancelTimer: () => {}, + fetcher: async (url, options) => { + calls.push({ url, options }) + if (options.method === 'POST') inventory = { ...inventory, events: [], reports: [], reportCount: 0, storageBytes: 0, more: false } + return { ok: true, status: 200, json: async () => inventory } + } }) + await feed.start() + assert.equal(await feed.remove(kind === 'sentry' ? eventId : 'opaque-id'), true) + assert.equal(calls.at(-1).url, kind === 'sentry' ? './api/sentry/events/delete' : './api/crash-reports/delete') + assert.equal(kind === 'sentry' ? feed.data.events.length : feed.reports.length, 0) + assert.equal(kind === 'sentry' ? feed.data.storageBytes : updates.find(update => update.storageBytes === 0).storageBytes, 0) + feed.stop() + assert.equal(feed.deleting, false) +} diff --git a/openpilot/starpilot/galaxy/tests/test_replay.mjs b/openpilot/starpilot/galaxy/tests/test_replay.mjs new file mode 100644 index 0000000000..1c80879a35 --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/test_replay.mjs @@ -0,0 +1,289 @@ +import assert from 'node:assert/strict' +import { ReplayClient, ReplayViewer, replayableSegment, validReplayStatus, replayTime, replayBufferTime, replaySeekPosition, replaySeekGaps } from '../web/js/replay.js' + +const ownerId = '1'.repeat(32), sessionId = '2'.repeat(32), clientId = '3'.repeat(32) +const status = { schemaVersion: 1, ownerId, sessionId, state: 'playing', revision: 3, epoch: 2, + position: 12, start: 0, end: 60, rate: 1, paused: false, offroad: true, owned: true, codec: null, sourceWarnings: [] } +assert.equal(validReplayStatus(status), true) +const idle = { ...status, state: "idle", sessionId: null, owned: false } +delete idle.codec +assert.equal(validReplayStatus(idle), true, "idle and another viewer legitimately omit codec") +for (const change of [{ position: Infinity }, { end: -1 }, { rate: 4 }, { ownerId: '../owner' }, { epoch: -1 }, { offroad: undefined }, { state: 'invented' }]) { + assert.equal(validReplayStatus({ ...status, ...change }), false) +} +assert.equal(replayTime(65), '1:05') +assert.match(ReplayViewer.template, /