diff --git a/containers/libreportal/frontend/core/setup/css/setup-wizard.css b/containers/libreportal/frontend/core/setup/css/setup-wizard.css index 30d6084..8ae7750 100755 --- a/containers/libreportal/frontend/core/setup/css/setup-wizard.css +++ b/containers/libreportal/frontend/core/setup/css/setup-wizard.css @@ -1431,6 +1431,7 @@ body.setup-wizard-open .custom-select-popup { z-index: 10001; } opacity: 0.92; } .setup-storage-choice select, +.setup-storage-choice input[type="text"], .setup-storage-choice .custom-select { flex: 1; min-width: 180px; } .setup-storage-choice-msg { margin: 2px 0 4px; @@ -1450,7 +1451,6 @@ body.setup-wizard-open .custom-select-popup { z-index: 10001; } /* A custom storage path that cannot be used says so under the box, rather than being quietly swapped for the system disk on the apply side. Its own flex line, offset to sit under the input rather than under the label. */ -.setup-storage-choice-custom { padding-left: 132px; } .setup-storage-choice-err { flex: 0 0 100%; margin: -4px 0 0; diff --git a/containers/libreportal/frontend/core/setup/js/setup-wizard.js b/containers/libreportal/frontend/core/setup/js/setup-wizard.js index 0888d4e..7225e46 100755 --- a/containers/libreportal/frontend/core/setup/js/setup-wizard.js +++ b/containers/libreportal/frontend/core/setup/js/setup-wizard.js @@ -7,8 +7,6 @@ // Marker for "I will type a path" in the two storage dropdowns. Not a valid // path itself, so it can never be mistaken for one. -const CUSTOM_PATH = '__custom__'; - class SetupWizard { constructor() { this.container = null; @@ -33,6 +31,10 @@ class SetupWizard { this.hasStorageCandidates = false; this.storageCandidates = []; this.storageSystem = null; + this.storagePrimary = ''; + // The relocate target for LibrePortal itself — never registered as an + // app-data location, so it is kept apart from storageSystemChoice. + this.storageSystemTarget = ''; this.selectedStorage = []; // Paths the user asked us to make permanent in /etc/fstab. this.fstabWanted = []; @@ -283,6 +285,7 @@ class SetupWizard { ?
+ @@ -410,6 +413,9 @@ class SetupWizard { r.addEventListener('change', () => { if (!r.checked) return; this.installLevel = (r.value === 'advanced') ? 'advanced' : 'beginner'; + // Exact paths are advanced-only, so the section has to appear or go + // away when the level changes rather than only at first render. + this.renderStoragePaths(false); this.totalSteps = this._effectiveTotalSteps(); // Re-paint progress so "Step 1 of N" updates immediately. this.showStep(this.currentStep); @@ -496,10 +502,12 @@ class SetupWizard { this.storageCandidates = registered.concat( unregistered.filter(c => !seen.has((c.path || '').replace(/\/$/, '')))); this.storageSystem = data.system || null; + this.storagePrimary = data.primary || ''; } catch (e) { console.log('[setup] storage scan unavailable:', e.message); this.storageCandidates = []; this.storageSystem = null; + this.storagePrimary = ''; } this.hasStorageCandidates = this.storageCandidates.some(c => c.verdict !== 'refuse'); this.totalSteps = this._effectiveTotalSteps(); @@ -655,7 +663,6 @@ class SetupWizard { // Somewhere the scan did not find: a NAS mount, an LVM volume, a directory // on a drive already in use. The scan lists whole filesystems, so anything // that is a path rather than a disk had no way in before this. - opts.push({ value: CUSTOM_PATH, label: 'Custom path\u2026' }); return opts; } @@ -703,62 +710,146 @@ class SetupWizard { box.innerHTML = ''; this.storageDefault = 'primary'; this.storageSystemChoice = 'primary'; + this.storageSystemTarget = ''; return; } - // A value that is not one of the offered options is a path the user typed, - // so the control has to come back up showing Custom path rather than - // silently snapping to the first entry. - const isCustom = (v) => !!v && v !== 'primary' && !opts.some(o => o.value === v); + // The dropdown picks a DRIVE and nothing else. Exact paths are a separate + // section below, because most people installing this want to pick a disk + // and move on — a "Custom path…" entry sitting in the same list makes + // typing a directory look like one of the normal answers. const row = (id, label, tip, value) => `sudo libreportal-relocate --system-dir=${this.escapeHtml(this.storageSystemChoice)}/libreportal-system`;
+ sudo libreportal-relocate --system-dir=${this.escapeHtml(this.storageSystemTarget || (this.storageSystemChoice + '/libreportal-system'))}`;
}
// Backups: one question, asked at setup rather than left to be discovered.
@@ -1768,7 +1854,7 @@ class SetupWizard {
storage_default: (this.storageDefault && this.storageDefault !== 'primary') ? this.storageDefault : 'primary',
// Recorded so the finished-setup screen can repeat the relocate command.
// Nothing acts on it: moving LibrePortal's own tree needs real root.
- storage_system: (this.storageSystemChoice && this.storageSystemChoice !== 'primary') ? this.storageSystemChoice : 'primary',
+ storage_system: (this.storageSystemChoice && this.storageSystemChoice !== 'primary') ? (this.storageSystemTarget || this.storageSystemChoice) : 'primary',
// '' = don't configure backups; 'primary' = system disk; else a drive path.
backup_dest: this.backupDest || '',
backup_mode: this.backupMode || 'automatic',
diff --git a/docs/roadmap/storage-locations.md b/docs/roadmap/storage-locations.md
index f651c2e..a514738 100644
--- a/docs/roadmap/storage-locations.md
+++ b/docs/roadmap/storage-locations.md
@@ -560,7 +560,29 @@ there would have anywhere to write.
**Custom paths.** The dropdowns offered only what the scan turned up, which is
wrong for a NAS mount, an LVM volume, or anything else the disk heuristics do
-not rank as a candidate. Both now end in *Custom path…*, revealing a text box.
+not rank as a candidate.
+
+The first attempt put a *Custom path…* entry in the dropdown itself, which was
+wrong: the dropdown answers "which disk", and an entry that means "actually,
+let me type a directory" sitting in the same list makes typing one look like one
+of the normal answers. Picking a disk is the whole question for most people.
+
+So the dropdown offers drives and nothing else, and exact paths are their own
+section under it — **advanced only**, for the same reason Metrics is: the
+beginner path deliberately does not get a wall of operator detail, and the
+directory under a chosen drive is exactly that. A beginner gets the drive's
+default, which is what they would have typed anyway.
+
+Each path field is prefilled from the selected drive and follows it when the
+drive changes, so a path belonging to the old drive is never left behind. The
+LibrePortal row appears only when a non-primary drive is chosen — relocating it
+onto the drive it already sits on is not a move.
+
+The two are kept apart in state, which matters more than it looks:
+`storageSystemChoice` stays a *drive* and `storageSystemTarget` holds the
+relocate path. `collectStorage()` registers what the dropdowns point at, and a
+system directory is not an app-data location — registering `…/libreportal-system`
+as one would be wrong. The test asserts that specifically.
The validation lives in `validateStep(3)` rather than in a disabled button. The
apply side already refuses a relative or system path — but its refusal is to
@@ -569,6 +591,10 @@ the system disk on purpose*. This is the failure shape this project keeps
having to fix (§10, and the compose-guard and `app-data-remove` bugs before it),
so the step blocks and says which rule the path broke.
+An untouched default is skipped by the validator: it is whatever this install
+already uses, and second-guessing it would reject a legitimate layout. Only an
+edit gets checked.
+
A path typed here is not a registered location, so `setup_apply.sh` registers it
through `storageAdd` before writing `CFG_STORAGE_DEFAULT`. Going through
`storageAdd` rather than writing the path straight into the config is what keeps
@@ -583,15 +609,21 @@ table displayed as `location-3`.
### What the test found about the tests
-`lp-storage-custom-test` drives the step in a real browser. Two things it caught
+`lp-storage-step-test` drives the step in a real browser. Two things it caught
about itself are worth recording, because both are the same shape as the bugs it
exists to prevent — a check whose failure mode is to not run.
It first counted "how many cards say *not connected*" and asserted things about
those. Disabling the feature makes that count zero, `every()` over an empty list
-is true, and the whole block passed while asserting nothing. The expected count
-now comes from the feed (`storageCandidates` in state `unmounted`), so removing
-the rendering is a failure rather than an empty set.
+is true, and the whole block passed while asserting nothing. It now *injects* an
+unmounted candidate and re-renders, so the assertion runs on every machine
+rather than only where a drive happens to be unplugged — ambient state is not a
+guard either.
+
+(That injection had its own trap: `renderStorage()` rebuilds the selects, so a
+reference held across it points at a detached node, and setting `.value` on one
+of those succeeds silently while changing nothing. Three assertions passed
+against a control that was no longer in the page.)
And both browser tests skipped — exit 0 — whenever the page returned nothing.
Run under `sudo`, where chromium refuses to start, they reported PASS having
diff --git a/scripts/dev/lp-storage-custom-test b/scripts/dev/lp-storage-custom-test
deleted file mode 100755
index de81110..0000000
--- a/scripts/dev/lp-storage-custom-test
+++ /dev/null
@@ -1,138 +0,0 @@
-#!/bin/bash
-# Drive the wizard's Storage step in a real browser.
-#
-# scripts/dev/lp-storage-custom-test # needs a running WebUI
-#
-# Two things this guards.
-#
-# A location whose drive is absent used to render as a warn card — "needs care",
-# "free of" with no numbers, an empty meter. A new user has no way to read that
-# as "this is a drive you registered and it is unplugged", so it looked like the
-# scan had found two broken disks. It must now say it is not connected, name the
-# path, and draw no meter.
-#
-# And a typed custom path must never reach the payload half-typed. The apply
-# side refuses a relative or system path and falls back to the system disk —
-# a fallback that looks exactly like having chosen the system disk on purpose,
-# which is the failure shape this project keeps having to fix. validateStep
-# has to block it at the step instead.
-#
-# One page load: the whole interaction runs in a single `lp-shot --eval`.
-
-REPO="$(cd "$(dirname "$0")/../.." && pwd)"
-SHOT="$REPO/scripts/dev/lp-shot"
-fail=0
-chk(){ if [[ "$2" == "$3" ]]; then echo " ok $1"; else echo " FAIL $1: got '$2' want '$3'"; fail=1; fi; }
-command -v jq >/dev/null 2>&1 || { echo " SKIP jq not installed"; exit 0; }
-
-# A skip must not be able to stand in for a pass. If the WebUI answers HTTP
-# then the browser is the only thing that can have failed, and that is a
-# failure — skipping there is how this test reported success under sudo, where
-# chromium refuses to start at all.
-lp_reachable() {
- local u; u=$("$SHOT" --url 2>/dev/null) || return 1
- [[ -n "$u" ]] || return 1
- curl -fsS -o /dev/null --max-time 5 "$u" 2>/dev/null
-}
-
-read -r -d '' DRIVE <<'JS'
- const out = {};
- const w = window.setupWizard;
- if (!w) return JSON.stringify({ error: 'wizard handle missing' });
- const fire = (el, ev) => el.dispatchEvent(new Event(ev, { bubbles: true }));
-
- const sel = document.querySelector('#sw-storage-apps');
- const inp = document.querySelector('#sw-storage-apps-custom');
- const row = document.querySelector('#sw-storage-apps-custom-row');
- const err = document.querySelector('#sw-storage-apps-custom-err');
- if (!sel || !inp || !row) return JSON.stringify({ error: 'storage step not rendered' });
-
- // An unmounted location must not be offered as somewhere to put app data:
- // the wizard cannot check its free space or write to it.
- // How many SHOULD be offline comes from the feed, not from the rendering.
- // Counting the cards that say "not connected" would make every assertion
- // below vacuous the moment the feature stopped working.
- const cards = Array.from(document.querySelectorAll('.setup-app-card, .setup-storage-card'));
- const expected = (w.storageCandidates || []).filter(c => c.state === 'unmounted');
- out.expectedOffline = expected.length;
- const offline = cards.filter(c =>
- expected.some(e => e.path && c.textContent.includes(e.path)));
- out.offlineCards = offline.length;
- out.offlineSayNotConnected = offline.length > 0
- && offline.every(c => /not connected/i.test(c.textContent));
- out.offlineSaysNotMounted = offline.length > 0 && offline.every(c => /not mounted right now/i.test(c.textContent));
- out.offlineNamesPath = offline.length > 0 && offline.every(c => /·\s*\//.test(c.textContent));
- out.offlineHasNoMeter = offline.length > 0 && offline.every(c => !c.querySelector('.setup-storage-meter'));
- out.offlineSaysNeedsCare = offline.some(c => /needs care/i.test(c.textContent));
- out.offlineOffered = Array.from(sel.options)
- .some(o => offline.some(c => o.value && c.textContent.includes(o.value)));
-
- out.hasCustomOption = Array.from(sel.options).some(o => o.value === '__custom__');
- out.rowHiddenAtRest = row.style.display === 'none';
-
- sel.value = '__custom__'; fire(sel, 'change');
- out.rowShownOnPick = row.style.display !== 'none';
- out.emptyBlocks = !!w.validateStep(3);
-
- const probe = (v) => { inp.value = v; fire(inp, 'input');
- return { blocks: !!w.validateStep(3),
- marked: inp.classList.contains('is-invalid'),
- said: (err && err.textContent) || '' }; };
- out.relative = probe('mnt/nas');
- out.systemDir = probe('/etc/libreportal');
- out.root = probe('/');
- out.spaces = probe('/mnt/my disk');
- out.good = probe('/mnt/nas/apps');
- out.goodStored = w.storageDefault;
-
- // Back to a scanned option: never blocked, and the error clears.
- sel.value = 'primary'; fire(sel, 'change');
- out.primaryBlocks = !!w.validateStep(3);
- out.primaryStored = w.storageDefault;
- return JSON.stringify(out);
-JS
-
-J=$("$SHOT" --eval "/?step=3" "$DRIVE" 2>/dev/null | tr "'" '"' | sed 's/\bTrue\b/true/g; s/\bFalse\b/false/g')
-if [[ -z "$J" ]]; then
- if lp_reachable; then
- echo " FAIL the WebUI is up but the page returned nothing (browser failed?)"; exit 1
- fi
- echo " SKIP no WebUI reachable"; exit 0
-fi
-g(){ echo "$J" | jq -r "$1" 2>/dev/null; }
-
-if [[ "$(g '.error // empty')" != "" ]]; then echo " FAIL $(g .error)"; exit 1; fi
-
-echo "unmounted locations"
-if [[ "$(g .expectedOffline)" == "0" ]]; then
- echo " SKIP no unmounted location registered — nothing to check"
-else
- chk "every one has a card" "$(g .offlineCards)" "$(g .expectedOffline)"
- chk "badged 'not connected'" "$(g .offlineSayNotConnected)" true
- chk "say they are not mounted" "$(g .offlineSaysNotMounted)" true
- chk "name the path" "$(g .offlineNamesPath)" true
- chk "draw no free-space meter" "$(g .offlineHasNoMeter)" true
- chk "do not say 'needs care'" "$(g .offlineSaysNeedsCare)" false
- chk "are not offered as a target" "$(g .offlineOffered)" false
-fi
-
-echo "custom path"
-chk "the option exists" "$(g .hasCustomOption)" true
-chk "input hidden until picked" "$(g .rowHiddenAtRest)" true
-chk "input shown once picked" "$(g .rowShownOnPick)" true
-chk "empty blocks the step" "$(g .emptyBlocks)" true
-chk "relative path blocks" "$(g .relative.blocks)" true
-chk "relative path is marked" "$(g .relative.marked)" true
-chk "relative path says why" "$(g '.relative.said != ""')" true
-chk "system dir blocks" "$(g .systemDir.blocks)" true
-chk "/ blocks" "$(g .root.blocks)" true
-chk "path with spaces blocks" "$(g .spaces.blocks)" true
-chk "an absolute path passes" "$(g .good.blocks)" false
-chk "and is not marked" "$(g .good.marked)" false
-chk "and its error is cleared" "$(g '.good.said == ""')" true
-chk "and it is what gets stored" "$(g .goodStored)" /mnt/nas/apps
-chk "a scanned option never blocks" "$(g .primaryBlocks)" false
-chk "and replaces the custom value" "$(g .primaryStored)" primary
-
-[[ $fail -eq 0 ]] && echo "storage custom-path test: OK"
-exit $fail
diff --git a/scripts/dev/lp-storage-step-test b/scripts/dev/lp-storage-step-test
new file mode 100755
index 0000000..a43b119
--- /dev/null
+++ b/scripts/dev/lp-storage-step-test
@@ -0,0 +1,201 @@
+#!/bin/bash
+# Drive the wizard's Storage step in a real browser.
+#
+# scripts/dev/lp-storage-step-test # needs a running WebUI
+#
+# Three things this guards.
+#
+# A location whose drive is absent used to render as a warn card — "needs care",
+# "free of" with no numbers, an empty meter. A new user has no way to read that
+# as "a drive you registered is unplugged", so it looked like the scan had found
+# two broken disks. It must say it is not connected, name the path, draw no
+# meter, and not be offered as somewhere to put data — the wizard cannot stat a
+# directory on a drive that is not there.
+#
+# The drive dropdown must offer drives and NOTHING else. It shipped once with a
+# "Custom path…" entry in the same list, which made typing a directory look like
+# one of the normal answers when most people just want to pick a disk.
+#
+# Exact paths are advanced-only, and an edited one must not reach the payload
+# half-typed. The apply side refuses a relative or system path by falling back
+# to the system disk — indistinguishable from having chosen the system disk on
+# purpose, which is the failure shape this project keeps having to fix. So
+# validateStep blocks it at the step instead.
+#
+# One page load: the whole interaction runs in a single `lp-shot --eval`.
+
+REPO="$(cd "$(dirname "$0")/../.." && pwd)"
+SHOT="$REPO/scripts/dev/lp-shot"
+fail=0
+chk(){ if [[ "$2" == "$3" ]]; then echo " ok $1"; else echo " FAIL $1: got '$2' want '$3'"; fail=1; fi; }
+command -v jq >/dev/null 2>&1 || { echo " SKIP jq not installed"; exit 0; }
+
+# A skip must not be able to stand in for a pass. If the WebUI answers HTTP
+# then the browser is the only thing that can have failed, and that is a
+# failure — skipping there is how this test reported success under sudo, where
+# chromium refuses to start at all.
+lp_reachable() {
+ local u; u=$("$SHOT" --url 2>/dev/null) || return 1
+ [[ -n "$u" ]] || return 1
+ curl -fsS -o /dev/null --max-time 5 "$u" 2>/dev/null
+}
+
+read -r -d '' DRIVE <<'JS'
+ const out = {};
+ const w = window.setupWizard;
+ if (!w) return JSON.stringify({ error: 'wizard handle missing' });
+ const fire = (el, ev) => el.dispatchEvent(new Event(ev, { bubbles: true }));
+ const $ = s => document.querySelector(s);
+
+ // Re-queried after every renderStorage(), which rebuilds the selects — a
+ // reference held across a re-render points at a detached node, and setting
+ // .value on one of those succeeds silently while changing nothing.
+ const sel = () => ({ sys: $('#sw-storage-system'), app: $('#sw-storage-apps') });
+ let { sys: sysSel, app: appSel } = sel();
+ const paths = $('#sw-storage-paths');
+ if (!sysSel || !appSel || !paths) return JSON.stringify({ error: 'storage step not rendered' });
+
+ // --- the dropdown offers drives, and only drives ---
+ const labels = Array.from(appSel.options).map(o => o.textContent.trim());
+ out.optionCount = labels.length;
+ out.offersCustomPath = labels.some(l => /custom path/i.test(l));
+ out.offersPrimary = labels.some(l => /system disk/i.test(l));
+ // Every non-primary option is a path, i.e. a drive, not an instruction.
+ out.allOptionsArePaths = Array.from(appSel.options)
+ .filter(o => o.value !== 'primary').every(o => o.value.startsWith('/'));
+
+ // --- unmounted locations ---
+ // Injected rather than waited for: whether a drive happens to be mounted is
+ // ambient state, and an assertion that only runs on some machines is not a
+ // guard. Counting the cards that say "not connected" would be worse still —
+ // turn the feature off and the count is zero, every() over an empty list is
+ // true, and the whole block passes having checked nothing.
+ const FAKE = '/mnt/__lp_test_offline';
+ w.storageCandidates = w.storageCandidates.concat([{
+ id: '99', name: 'unplugged-disk', path: FAKE, state: 'unmounted',
+ verdict: 'warn', size: '', free: '', apps: '', registered: true }]);
+ w.renderStorage();
+ ({ sys: sysSel, app: appSel } = sel());
+ const card = Array.from(document.querySelectorAll('.setup-app-card, .setup-storage-card'))
+ .find(c => c.textContent.includes(FAKE));
+ out.offlineHasCard = !!card;
+ const txt = card ? card.textContent.replace(/\s+/g, ' ') : '';
+ out.offlineSaysNotConnected = /not connected/i.test(txt);
+ out.offlineSaysNotMounted = /not mounted right now/i.test(txt);
+ out.offlineSaysNeedsCare = /needs care/i.test(txt);
+ out.offlineHasNoMeter = !!card && !card.querySelector('.setup-storage-meter');
+ out.offlineOffered = Array.from($('#sw-storage-apps').options).some(o => o.value === FAKE);
+
+ // --- exact paths: hidden for a beginner ---
+ w.installLevel = 'beginner'; w.renderStoragePaths(false);
+ out.pathsHiddenForBeginner = paths.style.display === 'none';
+ out.beginnerStoresDrive = w.storageDefault;
+
+ w.installLevel = 'advanced'; w.renderStoragePaths(false);
+ out.pathsShownForAdvanced = paths.style.display !== 'none';
+
+ // On the primary drive there is nothing to relocate, so no LibrePortal row.
+ out.systemRowOnPrimary = !!$('#sw-path-system');
+ out.appsRowAlways = !!$('#sw-path-apps');
+
+ // --- picking a drive fills the path in from that drive ---
+ const disk = Array.from(appSel.options).map(o => o.value)
+ .find(v => v !== 'primary');
+ if (!disk) return JSON.stringify(Object.assign(out, { noSecondDrive: true }));
+ appSel.value = disk; fire(appSel, 'change');
+ out.pathFollowsDrive = $('#sw-path-apps').value;
+ out.pathFollowsDriveWant = disk;
+
+ sysSel.value = disk; fire(sysSel, 'change');
+ out.systemRowAppears = !!$('#sw-path-system');
+ out.systemPathDefault = $('#sw-path-system') ? $('#sw-path-system').value : '';
+ out.systemPathWant = disk + '/libreportal-system';
+ // The system directory is not an app-data location and must never be
+ // registered as one — collectStorage takes the drive, not the subdirectory.
+ out.registersDriveNotSystemDir = (w.collectStorage() || []).includes(out.systemPathDefault);
+
+ // --- editing a path ---
+ const inp = $('#sw-path-apps'), err = $('#sw-path-apps-err');
+ out.untouchedDefaultBlocks = !!w.validateStep(3);
+ const probe = (v) => { inp.value = v; fire(inp, 'input');
+ return { blocks: !!w.validateStep(3),
+ marked: inp.classList.contains('is-invalid'),
+ said: (err && err.textContent) || '' }; };
+ out.relative = probe('mnt/nas');
+ out.systemDir = probe('/etc/libreportal');
+ out.root = probe('/');
+ out.spaces = probe('/mnt/my disk');
+ out.good = probe(disk + '/myapps');
+ out.goodStored = w.storageDefault;
+ out.goodRegistered = (w.collectStorage() || []).includes(disk + '/myapps');
+
+ // Changing drive must not leave a path belonging to the old one behind.
+ const other = Array.from(appSel.options).map(o => o.value)
+ .find(v => v !== 'primary' && v !== disk);
+ if (other) {
+ appSel.value = other; fire(appSel, 'change');
+ out.staleAfterDriveChange = $('#sw-path-apps').value;
+ out.staleWant = other;
+ }
+ return JSON.stringify(out);
+JS
+
+J=$("$SHOT" --eval "/?step=3" "$DRIVE" 2>/dev/null)
+if [[ -z "$J" ]] || ! jq -e . >/dev/null 2>&1 <<< "$J"; then
+ if lp_reachable; then
+ echo " FAIL the WebUI is up but the page returned nothing (browser failed?)"; exit 1
+ fi
+ echo " SKIP no WebUI reachable"; exit 0
+fi
+g(){ jq -r "$1" <<< "$J" 2>/dev/null; }
+if [[ "$(g '.error // empty')" != "" ]]; then echo " FAIL $(g .error)"; exit 1; fi
+
+echo "the drive dropdown"
+chk "offers the system disk" "$(g .offersPrimary)" true
+chk "offers no 'Custom path' entry" "$(g .offersCustomPath)" false
+chk "every other entry is a drive" "$(g .allOptionsArePaths)" true
+
+echo "an unmounted location"
+chk "still gets a card" "$(g .offlineHasCard)" true
+chk "badged 'not connected'" "$(g .offlineSaysNotConnected)" true
+chk "says it is not mounted" "$(g .offlineSaysNotMounted)" true
+chk "does not say 'needs care'" "$(g .offlineSaysNeedsCare)" false
+chk "draws no free-space meter" "$(g .offlineHasNoMeter)" true
+chk "is not offered as a target" "$(g .offlineOffered)" false
+
+echo "exact paths"
+chk "hidden for a beginner" "$(g .pathsHiddenForBeginner)" true
+chk "beginner keeps the drive" "$(g .beginnerStoresDrive)" primary
+chk "shown for advanced" "$(g .pathsShownForAdvanced)" true
+chk "no LibrePortal row on primary" "$(g .systemRowOnPrimary)" false
+chk "New apps row always" "$(g .appsRowAlways)" true
+
+if [[ "$(g '.noSecondDrive // false')" == "true" ]]; then
+ echo " SKIP only one drive registered — nothing to switch to"
+else
+ chk "path follows the chosen drive" "$(g .pathFollowsDrive)" "$(g .pathFollowsDriveWant)"
+ chk "LibrePortal row appears" "$(g .systemRowAppears)" true
+ chk "and defaults under that drive" "$(g .systemPathDefault)" "$(g .systemPathWant)"
+ chk "system dir is not registered" "$(g .registersDriveNotSystemDir)" false
+
+ echo "editing a path"
+ chk "an untouched default passes" "$(g .untouchedDefaultBlocks)" false
+ chk "relative path blocks" "$(g .relative.blocks)" true
+ chk "relative path is marked" "$(g .relative.marked)" true
+ chk "relative path says why" "$(g '.relative.said != ""')" true
+ chk "system dir blocks" "$(g .systemDir.blocks)" true
+ chk "/ blocks" "$(g .root.blocks)" true
+ chk "path with spaces blocks" "$(g .spaces.blocks)" true
+ chk "an absolute path passes" "$(g .good.blocks)" false
+ chk "and is not marked" "$(g .good.marked)" false
+ chk "and its error is cleared" "$(g '.good.said == ""')" true
+ chk "and it is what gets stored" "$(g .goodStored)" "$(g .pathFollowsDriveWant)/myapps"
+ chk "and what gets registered" "$(g .goodRegistered)" true
+
+ if [[ "$(g '.staleWant // empty')" != "" ]]; then
+ chk "changing drive drops the old path" "$(g .staleAfterDriveChange)" "$(g .staleWant)"
+ fi
+fi
+
+[[ $fail -eq 0 ]] && echo "storage step test: OK"
+exit $fail