Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions apps/predbat/tests/test_web_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -600,5 +600,36 @@ def run_web_dark_mode_preference_tests(my_predbat):
print(" ERROR: expected a fallback to the deprecated addListener() for older Safari/Chrome")
failed += 1

print("Test: the menu offers System, Light and Dark with an accessible theme selector")
from web_helper import get_menu_html

menu = get_menu_html(False, "./dash", [], "v1.0", "")
if 'id="theme-mode" aria-label="Theme" onchange="setThemeMode(this.value)"' not in menu or any(f'<option value="{mode}">{mode.title()}</option>' not in menu for mode in ("system", "light", "dark")):
print(" ERROR: expected an accessible System / Light / Dark selector")
failed += 1

print("Test: System clears the existing override; Light and Dark preserve the existing storage format")
setter_start = header.find("function setThemeMode(mode)")
setter_end = header.find("\n}", setter_start)
setter = header[setter_start:setter_end]
if "localStorage.removeItem('darkMode')" not in setter or "mode === 'dark' ? 'true' : 'false'" not in setter or "location.reload();" not in setter:
print(" ERROR: expected System to remove the override and explicit modes to persist before reloading")
failed += 1

# Run the actual generated JavaScript when Node.js is available, without a browser or live instance.
import shutil
import subprocess
import sys

node = shutil.which("node")
if node:
result = subprocess.run([node, os.path.join(os.path.dirname(__file__), "test_web_theme.cjs"), sys.executable], capture_output=True, text=True)
print(result.stdout, end="")
if result.returncode:
print(result.stderr)
failed += 1
else:
print(" SKIP: JavaScript behaviour tests require Node.js")

print("**** Web dark mode preference tests completed ****")
return failed
123 changes: 123 additions & 0 deletions apps/predbat/tests/test_web_theme.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Behaviour tests for the generated theme scripts, using only Node.js built-ins.
// Run directly: node apps/predbat/tests/test_web_theme.cjs [python executable]
const assert = require('node:assert/strict');
const { execFileSync } = require('node:child_process');
const path = require('node:path');
const vm = require('node:vm');

const header = execFileSync(process.argv[2] || 'python3', ['-c',
"from web_helper import get_header_html; print(get_header_html('Test', False, './dash', [], 'v1.0', ''))"
], { cwd: path.resolve(__dirname, '..'), encoding: 'utf8' });
const scripts = [...header.matchAll(/<script>([\s\S]*?)<\/script>/g)]
.map(match => match[1]).filter(script => /function (getDarkModePreference|applyDarkMode)/.test(script));
assert.equal(scripts.length, 2);

function classList() {
const values = new Set();
return { add: value => values.add(value), remove: value => values.delete(value), contains: value => values.has(value) };
}

function page(stored, dark, legacy = false, mediaAvailable = true) {
const storage = new Map(stored === null ? [] : [['darkMode', stored]]);
const selector = { value: 'system' };
const logo = { src: '', getAttribute: name => name === 'data-dark-src' ? 'dark.png' : 'light.png' };
let listener;
let reloads = 0;
const media = { matches: dark };
media[legacy ? 'addListener' : 'addEventListener'] = (...args) => { listener = args.at(-1); };
const document = {
documentElement: { classList: classList() }, body: { classList: classList() },
addEventListener: () => {},
getElementById: id => id === 'theme-mode' ? selector : id === 'logo-image' ? logo : null
};
const window = mediaAvailable ? { matchMedia: () => media } : {};
const context = vm.createContext({ document, window,
localStorage: { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, String(value)), removeItem: key => storage.delete(key) },
location: { reload: () => { reloads++; } }
});
scripts.forEach(script => vm.runInContext(script, context));
const preCssDark = document.documentElement.classList.contains('dark-mode');
window.onload();
return { context, document, storage, selector, logo, preCssDark, reloads: () => reloads,
osChange: value => { media.matches = value; listener(); } };
}

function checkAppearance(page, dark, mode) {
assert.equal(page.document.documentElement.classList.contains('dark-mode'), dark);
assert.equal(page.document.body.classList.contains('dark-mode'), dark);
assert.equal(page.logo.src, dark ? 'dark.png' : 'light.png');
assert.equal(page.selector.value, mode);
}

for (const legacy of [false, true]) {
for (const stored of [null, 'true', 'false']) {
for (const osDark of [false, true]) {
const current = page(stored, osDark, legacy);
const mode = stored === null ? 'system' : stored === 'true' ? 'dark' : 'light';
const expected = stored === null ? osDark : stored === 'true';
assert.equal(current.preCssDark, expected);
checkAppearance(current, expected, mode);
current.osChange(!osDark);
checkAppearance(current, stored === null ? !osDark : expected, mode);
assert.equal(current.storage.get('darkMode') ?? null, stored);
assert.equal(current.reloads(), 0);

for (const choice of ['light', 'dark', 'system']) {
current.context.setThemeMode(choice);
const saved = current.storage.get('darkMode') ?? null;
assert.equal(saved, choice === 'system' ? null : choice === 'dark' ? 'true' : 'false');
const reloaded = page(saved, osDark, legacy);
checkAppearance(reloaded, choice === 'system' ? osDark : choice === 'dark', choice);
reloaded.osChange(!osDark);
checkAppearance(reloaded, choice === 'system' ? !osDark : choice === 'dark', choice);
}
assert.equal(current.reloads(), 3);
}
}
}
checkAppearance(page(null, false, false, false), false, 'system');
checkAppearance(page('true', false, false, false), true, 'dark');
console.log('PASS: theme choices, persistence, pre-CSS appearance, logo, live OS changes, legacy listener and missing matchMedia');

// Run the actual generated Editor storage listener with an already-open editor.
const editorJs = execFileSync(process.argv[2] || 'python3', ['-c',
'from web_helper import get_editor_js; print(get_editor_js())'
], { cwd: path.resolve(__dirname, '..'), encoding: 'utf8' });
const storageListener = editorJs.match(/window.addEventListener\('storage', function\(e\) \{[\s\S]*?\n \}\);/);
assert.ok(storageListener, 'Editor storage listener must exist');
const editorPage = page('true', true);
const wrapper = { style: { backgroundColor: '#2d2d2d' } };
const gutter = { style: {} };
const tooltip = { style: {} };
let editorTheme = 'monokai';
let lintRuns = 0;
let refreshes = 0;
let onStorage;
editorPage.context.window.addEventListener = (event, callback) => {
assert.equal(event, 'storage');
onStorage = callback;
};
editorPage.document.querySelectorAll = selector => selector === '.CodeMirror-gutters' ? [gutter] : [tooltip];
editorPage.context.editor = {
setOption: (option, value) => { assert.equal(option, 'theme'); editorTheme = value; },
getWrapperElement: () => wrapper,
performLint: () => { lintRuns++; },
refresh: () => { refreshes++; }
};
vm.runInContext(storageListener[0], editorPage.context);
checkAppearance(editorPage, true, 'dark');
// Another tab selects System: shared storage changes, then the Editor receives the event.
const otherTab = page('true', true);
otherTab.context.localStorage = editorPage.context.localStorage;
otherTab.context.setThemeMode('system');
assert.equal(editorPage.context.localStorage.getItem('darkMode'), null);
onStorage({ key: 'darkMode', oldValue: 'true', newValue: null });
assert.equal(editorTheme, 'monokai');
assert.equal(wrapper.style.backgroundColor, '#2d2d2d');
assert.equal(gutter.style.backgroundColor, '#2d2d2d');
assert.equal(tooltip.style.backgroundColor, '#2d2d2d');
assert.equal(lintRuns, 1);
assert.equal(refreshes, 1);
assert.equal(editorPage.reloads(), 0);
assert.equal(otherTab.reloads(), 1);
console.log('PASS: clearing Dark override in another tab keeps the open Editor dark on a Dark OS');
30 changes: 21 additions & 9 deletions apps/predbat/web_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -6122,7 +6122,7 @@ def get_editor_js():
window.addEventListener('storage', function(e) {
if (e.key === 'darkMode') {
if (editor) {
const isDarkMode = localStorage.getItem('darkMode') === 'true';
const isDarkMode = getDarkModePreference();
editor.setOption('theme', isDarkMode ? 'monokai' : 'default');

// Update the editor's wrapper element styling
Expand Down Expand Up @@ -8374,6 +8374,11 @@ def get_header_html(title, calculating, default_page, arg_errors, THIS_VERSION,
};
function applyDarkMode() {
const darkModeEnabled = getDarkModePreference();
const themeMode = document.getElementById('theme-mode');
if (themeMode) {
const storedDarkMode = localStorage.getItem('darkMode');
themeMode.value = storedDarkMode === null ? 'system' : (storedDarkMode === 'true' ? 'dark' : 'light');
}
if (darkModeEnabled) {
document.body.classList.add('dark-mode');
document.documentElement.classList.add('dark-mode');
Expand Down Expand Up @@ -8410,9 +8415,12 @@ def get_header_html(title, calculating, default_page, arg_errors, THIS_VERSION,
}
}

function toggleDarkMode() {
const isDarkMode = document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', isDarkMode);
function setThemeMode(mode) {
if (mode === 'system') {
localStorage.removeItem('darkMode');

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e7499e2: the Editor storage listener now uses getDarkModePreference(). Added a regression test confirming that clearing a saved Dark override from another tab keeps the open Editor dark when the OS appearance is Dark. The test fails before the fix and passes afterward; the related web tests and quality checks also pass.

} else {
localStorage.setItem('darkMode', mode === 'dark' ? 'true' : 'false');
}
// Force reload to apply dark mode styles
location.reload();
}
Expand Down Expand Up @@ -8634,7 +8642,7 @@ def get_menu_html(calculating, default_page, arg_errors, THIS_VERSION, battery_s
flex-shrink: 0; /* Prevent from shrinking */
}

.dark-mode-toggle button {
.dark-mode-toggle select {
background-color: #f0f0f0;
color: #333;
border: 1px solid #ddd;
Expand All @@ -8644,7 +8652,7 @@ def get_menu_html(calculating, default_page, arg_errors, THIS_VERSION, battery_s
white-space: nowrap;
}

.dark-mode-toggle button:hover {
.dark-mode-toggle select:hover {
background-color: #e0e0e0;
}

Expand Down Expand Up @@ -8685,13 +8693,13 @@ def get_menu_html(calculating, default_page, arg_errors, THIS_VERSION, battery_s
color: #6CFF72 !important;
}

body.dark-mode .dark-mode-toggle button {
body.dark-mode .dark-mode-toggle select {
background-color: #444;
color: #e0e0e0;
border-color: #555;
}

body.dark-mode .dark-mode-toggle button:hover {
body.dark-mode .dark-mode-toggle select:hover {
background-color: #666;
}
</style>
Expand Down Expand Up @@ -8952,7 +8960,11 @@ def get_menu_html(calculating, default_page, arg_errors, THIS_VERSION, battery_s
"""
+ THIS_VERSION
+ """
<button onclick="toggleDarkMode()">Toggle Dark Mode</button>
<select id="theme-mode" aria-label="Theme" onchange="setThemeMode(this.value)">
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
</div>
"""
Expand Down
Loading