test(mobile): the gate checks the File Viewer button's phone default again (#809)

Until #809 a display:none rule in mobile.css and its KNOWN_PHONE_HIDDEN
entry kept the File Viewer button off phones, and the gate checked both.
With that rule gone, the handheld default in getDefaultSettings() and the
html.mobile-init startup rule are the whole lock, and only the browser
test saw them, which npm test does not run.

The policy test now loads the real getDefaultSettings() on a handheld and
on a desktop, checks that every reader of the setting goes through the
device default, and that mobile.css keeps the startup hide.

Docs: architecture-invariants, src/web/CLAUDE.md and the wiki say the
button is off on a phone until it is switched on there, and that a window
narrower than 600px on a device that is not a handheld now shows it.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-11 23:07:46 +01:00
1 parent b587ac71f4
commit d192eb6825
8 files changed
+86 -11

No files matched your search

+72 -1
View File
@@ -20,6 +20,7 @@ import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
import vm from 'node:vm';
import postcss from 'postcss';
const HERE = fileURLToPath(new URL('.', import.meta.url));
@@ -30,7 +31,9 @@ const PUBLIC = join(HERE, '../src/web/public');
const PHONE_WIDTH = 393;
// Issue #759: the maintainer approved File Viewer on phones when explicitly enabled.
// Its per-device setting still defaults off; the browser test covers that default.
// Its per-device setting still defaults off on a handheld. With the blanket phone
// hide gone, that default and the startup hide are the whole lock, so both are
// pinned below ("File Viewer on a phone: off until it is switched on").
const MOBILE_VISIBLE_ALLOWLIST = new Set<string>(['btn-file-viewer']);
// Buttons we expect to STAY hidden on phones — an explicit lock so a future edit
@@ -152,6 +155,74 @@ describe('Mobile header button policy (static guard)', () => {
});
});
// Until #759 the gate kept the File Viewer button off phones with a blanket
// display:none rule and its KNOWN_PHONE_HIDDEN entry. That rule is gone, so what
// keeps the button off a phone nobody switched it on for is now two things, and
// the real-page test that exercises them (test/mobile/file-browser.test.ts) is
// not part of `npm test`:
// 1. the handheld branch of getDefaultSettings() says false, and the header
// reads the setting through that default (a bare `?? true` would show it);
// 2. `html.mobile-init` hides the button until the settings have been applied,
// or it would flash on every phone page load (the template ships it visible).
describe('File Viewer on a phone: off until it is switched on (#759)', () => {
const settingsUi = readFileSync(join(PUBLIC, 'settings-ui.js'), 'utf-8');
/** The real getDefaultSettings() of settings-ui.js on one kind of device. */
function defaultsOn(handheld: boolean): Record<string, unknown> {
const CodemanApp = function CodemanApp(this: unknown) {};
const context = vm.createContext({
CodemanApp,
VoiceInput: {},
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: () => null },
console,
MobileDetection: {
isHandheldDevice: () => handheld,
isTouchDevice: () => handheld,
getDeviceType: () => (handheld ? 'mobile' : 'desktop'),
},
window: { matchMedia: () => ({ matches: false }) },
});
vm.runInContext(settingsUi, context, { filename: 'settings-ui.js' });
const app = Object.create(CodemanApp.prototype) as { getDefaultSettings(): Record<string, unknown> };
return app.getDefaultSettings();
}
it('a handheld defaults the button off', () => {
expect(defaultsOn(true).showFileViewerButton).toBe(false);
});
it('a desktop leaves the key unset, which every reader resolves to on', () => {
expect(defaultsOn(false)).not.toHaveProperty('showFileViewerButton');
});
it('the header and the App Settings chip both read the setting through the device default', () => {
const read = 'settings.showFileViewerButton ?? defaults.showFileViewerButton ?? true;';
expect(settingsUi).toContain(`const showFileViewerButton = ${read}`);
expect(settingsUi).toContain(`document.getElementById('appSettingsShowFileViewerButton').checked = ${read}`);
// Every reader goes through it: one that skipped the device default would resolve to on.
const reads = [...settingsUi.matchAll(/\bsettings\.showFileViewerButton\b(.{0,40})/g)];
expect(reads.length).toBeGreaterThanOrEqual(2);
for (const [line, rest] of reads) {
expect(rest.startsWith(' ?? defaults.showFileViewerButton'), `"${line}" skips the device default`).toBe(true);
}
});
it('html.mobile-init hides the button until the settings have been applied', () => {
const css = readFileSync(join(PUBLIC, 'mobile.css'), 'utf-8');
let hidesAtStartup = false;
postcss.parse(css).each((node) => {
// Top level only: the rule must apply at every width mobile.css is loaded for.
if (node.type !== 'rule') return;
if (!node.selectors.includes('html.mobile-init .btn-file-viewer')) return;
node.walkDecls('display', (decl) => {
if (decl.value.trim() === 'none' && decl.important) hidesAtStartup = true;
});
});
expect(hidesAtStartup, 'mobile.css must keep .btn-file-viewer in its html.mobile-init display:none rule').toBe(true);
});
});
// The flip side of the policy above: the ONE header control phones do keep has
// to be pressable. The brand "C" is the way back to the home screen and was a
// 0.85rem inline span — roughly a 12x13px target, well under the 44px minimum.