mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-23 19:12:23 +00:00
Fix notification preview audio playback and startup timing
This commit is contained in:
1 parent
fb9f330636
commit
53f1ba9329
5 files changed
+283
-36
No files matched your search
@@ -33,6 +33,43 @@
|
||||
return { ms, hz: match[2] === "p" ? 0 : 440 * Math.pow(2, (midi - 69) / 12) };
|
||||
});
|
||||
}
|
||||
function melodyWav(text) {
|
||||
const notes = melody(text), rate = 22050;
|
||||
const frames = Math.round(notes.reduce((total, note) => total + note.ms, 0) * rate / 1000);
|
||||
const bytes = new Uint8Array(44 + frames * 2), view = new DataView(bytes.buffer);
|
||||
const label = (offset, value) => { for (let i = 0; i < value.length; i++) bytes[offset + i] = value.charCodeAt(i); };
|
||||
label(0, "RIFF"); view.setUint32(4, bytes.length - 8, true); label(8, "WAVE");
|
||||
label(12, "fmt "); view.setUint32(16, 16, true); view.setUint16(20, 1, true);
|
||||
view.setUint16(22, 1, true); view.setUint32(24, rate, true); view.setUint32(28, rate * 2, true);
|
||||
view.setUint16(32, 2, true); view.setUint16(34, 16, true); label(36, "data"); view.setUint32(40, frames * 2, true);
|
||||
let elapsed = 0, start = 0;
|
||||
for (const note of notes) {
|
||||
elapsed += note.ms;
|
||||
const end = Math.round(elapsed * rate / 1000), count = end - start;
|
||||
if (note.hz) {
|
||||
// Match the buzzer's square wave, with short fades to avoid speaker clicks.
|
||||
const fade = Math.min(rate * 0.003, count / 2);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const envelope = Math.min(1, i / fade, (count - 1 - i) / fade);
|
||||
const sample = Math.round(6553 * envelope) * (i * note.hz / rate % 1 < 0.5 ? 1 : -1);
|
||||
view.setInt16(44 + (start + i) * 2, sample, true);
|
||||
}
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
function parseOutputs(config) {
|
||||
const pulses = { vibration: pulse(config.vibration), led: pulse(config.led), gpio: [] };
|
||||
const notes = melody(config.sound);
|
||||
if (!["inherit", "on", "off"].includes(config.screen)) throw new Error("Screen must be on, off, or inherit.");
|
||||
if (config.gpio !== "off" && config.gpio !== "inherit") {
|
||||
const match = config.gpio.match(/^(\d{1,2}):(.+)$/);
|
||||
if (!match || Number(match[1]) > 63) throw new Error("GPIO needs pin:pattern, for example 22:50,300,50.");
|
||||
pulses.gpio = pulse(match[2]);
|
||||
}
|
||||
return { pulses, notes };
|
||||
}
|
||||
function selector(config) {
|
||||
let target = "all";
|
||||
if (config.kind === "contact" || config.kind === "room") {
|
||||
@@ -47,14 +84,7 @@
|
||||
}
|
||||
function commands(config) {
|
||||
const target = selector(config), result = [];
|
||||
for (const name of ["vibration", "led"]) pulse(config[name]);
|
||||
melody(config.sound);
|
||||
if (!["inherit", "on", "off"].includes(config.screen)) throw new Error("Screen must be on, off, or inherit.");
|
||||
if (config.gpio !== "off" && config.gpio !== "inherit") {
|
||||
const match = config.gpio.match(/^(\d{1,2}):(.+)$/);
|
||||
if (!match || Number(match[1]) > 63) throw new Error("GPIO needs pin:pattern, for example 22:50,300,50.");
|
||||
pulse(match[2]);
|
||||
}
|
||||
parseOutputs(config);
|
||||
if (config.repeat !== "forever" && (!/^\d+$/.test(config.repeat) || Number(config.repeat) < 1 || Number(config.repeat) > 65535)) throw new Error("Repeat must be 1-65535 or forever.");
|
||||
if (!/^\d+$/.test(config.gap) || Number(config.gap) < 1 || Number(config.gap) > 60000) throw new Error("Repeat gap must be 1-60000 ms.");
|
||||
if (!["button", "connected", "never"].includes(config.stop)) throw new Error("Unknown stop condition.");
|
||||
@@ -148,7 +178,8 @@
|
||||
if (root.dataset.ready) return; root.dataset.ready = "true";
|
||||
const find = role => root.querySelector('[data-role="' + role + '"]');
|
||||
const form = find("form"), error = find("error"), output = find("commands"), log = find("device-log");
|
||||
let client = null, supported = null, previewTimer = null, audio = null, oscillators = [];
|
||||
const audio = find("preview-audio");
|
||||
let client = null, supported = null, previewTimer = null, previewUrl = null, previewVersion = 0;
|
||||
const config = () => Object.fromEntries(new FormData(form));
|
||||
function render() {
|
||||
try {
|
||||
@@ -163,38 +194,58 @@
|
||||
for (const [key, value] of Object.entries(values)) if (form.elements[key]) form.elements[key].value = value;
|
||||
render();
|
||||
}
|
||||
function stopPreview() {
|
||||
function stopPreview(status = "Stopped") {
|
||||
previewVersion++;
|
||||
clearInterval(previewTimer); previewTimer = null;
|
||||
for (const oscillator of oscillators) { try { oscillator.stop(); } catch (_) {} } oscillators = [];
|
||||
audio.onerror = null;
|
||||
audio.pause(); audio.removeAttribute("src"); audio.load();
|
||||
if (previewUrl) { global.URL.revokeObjectURL(previewUrl); previewUrl = null; }
|
||||
for (const item of root.querySelectorAll("[data-indicator]")) item.dataset.on = "false";
|
||||
find("preview-sound").textContent = status;
|
||||
}
|
||||
function preview() {
|
||||
stopPreview(); const c = config(); commands(c);
|
||||
const pulses = { vibration: pulse(c.vibration), led: pulse(c.led), gpio: pulse(c.gpio.includes(":") ? c.gpio.split(":")[1] : c.gpio) };
|
||||
const notes = melody(c.sound), tuneMs = notes.reduce((n, item) => n + item.ms, 0);
|
||||
async function preview() {
|
||||
stopPreview(); const version = previewVersion, c = config();
|
||||
// A local preview does not need a contact/room key or a saved repeat policy.
|
||||
const { pulses, notes } = parseOutputs(c), tuneMs = notes.reduce((n, item) => n + item.ms, 0);
|
||||
const duration = Math.max(tuneMs, ...Object.values(pulses).map(list => list.reduce((a, b) => a + b, 0))) || 1000;
|
||||
// Preview one cycle. Never creates an unbounded oscillator or vibration loop.
|
||||
if (notes.length && (global.AudioContext || global.webkitAudioContext)) {
|
||||
audio = audio || new (global.AudioContext || global.webkitAudioContext)(); audio.resume();
|
||||
let offset = 0;
|
||||
for (const note of notes) {
|
||||
if (note.hz) {
|
||||
const oscillator = audio.createOscillator(), gain = audio.createGain();
|
||||
oscillator.type = "square"; oscillator.frequency.value = note.hz; gain.gain.value = 0.03;
|
||||
oscillator.connect(gain); gain.connect(audio.destination);
|
||||
oscillator.start(audio.currentTime + offset / 1000); oscillator.stop(audio.currentTime + (offset + note.ms) / 1000);
|
||||
oscillators.push(oscillator);
|
||||
}
|
||||
offset += note.ms;
|
||||
if (notes.length) {
|
||||
// Use media playback rather than Web Audio's ambient/ringer channel on iOS.
|
||||
previewUrl = global.URL.createObjectURL(new Blob([melodyWav(c.sound)], { type: "audio/wav" }));
|
||||
audio.src = previewUrl; audio.loop = false; audio.muted = false; audio.volume = 1;
|
||||
find("preview-sound").textContent = "Starting melody...";
|
||||
find("preview-time").textContent = "0 / " + duration + " ms";
|
||||
audio.onerror = () => {
|
||||
if (version !== previewVersion) return;
|
||||
stopPreview("Sound unavailable"); error.textContent = "The browser could not play the sound preview.";
|
||||
};
|
||||
try { await audio.play(); }
|
||||
catch (e) {
|
||||
// Stop can cancel a pending play() without showing a playback failure.
|
||||
if (version !== previewVersion) return;
|
||||
stopPreview("Sound unavailable"); throw new Error("Sound preview could not start: " + e.message);
|
||||
}
|
||||
if (version !== previewVersion) return;
|
||||
find("preview-sound").textContent = "Playing melody";
|
||||
} else {
|
||||
find("preview-sound").textContent = c.sound === "inherit" ? "Choose a melody to preview inherited sound." : "Sound is off for this preview.";
|
||||
}
|
||||
const started = performance.now();
|
||||
let finishedAt = null;
|
||||
const started = performance.now() - (notes.length ? audio.currentTime * 1000 : 0);
|
||||
previewTimer = setInterval(() => {
|
||||
const elapsed = performance.now() - started;
|
||||
if (!root.isConnected) { stopPreview(); return; }
|
||||
let elapsed = performance.now() - started;
|
||||
if (notes.length) {
|
||||
if (!audio.ended) elapsed = audio.currentTime * 1000;
|
||||
else {
|
||||
if (finishedAt === null) finishedAt = performance.now();
|
||||
elapsed = tuneMs + performance.now() - finishedAt;
|
||||
}
|
||||
}
|
||||
for (const name of Object.keys(pulses)) root.querySelector('[data-indicator="' + name + '"]').dataset.on = String(level(pulses[name], elapsed));
|
||||
root.querySelector('[data-indicator="screen"]').dataset.on = String(c.screen === "on");
|
||||
find("preview-time").textContent = Math.min(Math.round(elapsed), duration) + " / " + duration + " ms";
|
||||
if (elapsed >= duration) stopPreview();
|
||||
if (elapsed >= duration && (!notes.length || audio.ended)) stopPreview("Cycle finished");
|
||||
}, 10);
|
||||
}
|
||||
const append = text => { log.textContent += text + "\n"; log.scrollTop = log.scrollHeight; };
|
||||
@@ -205,12 +256,12 @@
|
||||
return reply;
|
||||
}
|
||||
async function act(action) {
|
||||
if (action === "preview-stop") { stopPreview(); return; }
|
||||
error.textContent = "";
|
||||
const buttons = root.querySelectorAll("button[data-action]"); buttons.forEach(b => b.disabled = true);
|
||||
const buttons = root.querySelectorAll("button[data-action]"); buttons.forEach(b => b.disabled = b.dataset.action !== "preview-stop");
|
||||
try {
|
||||
if (action === "copy") { await navigator.clipboard.writeText(commands(config()).join("\n"));find("copy-status").textContent = "Copied"; }
|
||||
if (action === "preview") preview();
|
||||
if (action === "preview-stop") stopPreview();
|
||||
if (action === "preview") await preview();
|
||||
if (action === "connect") {
|
||||
if (!navigator.serial) throw new Error("USB testing requires Chrome or Edge with Web Serial support.");
|
||||
if (client) { await client.close(); client = null; }
|
||||
@@ -240,9 +291,9 @@
|
||||
const example = event.target.closest("[data-example]"); if (example) preset(example.dataset.example);
|
||||
const button = event.target.closest("[data-action]"); if (button) act(button.dataset.action);
|
||||
});
|
||||
global.addEventListener("pagehide", stopPreview);preset("food");
|
||||
global.addEventListener("pagehide", () => stopPreview());preset("food");
|
||||
}
|
||||
const api = { pulse, melody, selector, commands, notificationText, level, encodeCommand, FrameDecoder, SerialClient, EXAMPLES, init };
|
||||
const api = { pulse, melody, melodyWav, parseOutputs, selector, commands, notificationText, level, encodeCommand, FrameDecoder, SerialClient, EXAMPLES, init };
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
global.MeshCoreNotifications = api;
|
||||
if (typeof document !== "undefined") {
|
||||
|
||||
@@ -70,8 +70,12 @@ stop policy. USB testing uses Binary Companion mode at 115200 baud.
|
||||
<h2>Preview one cycle</h2>
|
||||
<div class="notification-indicators"><span data-indicator="vibration" data-on="false">Vibration</span><span data-indicator="led" data-on="false">LED</span><span data-indicator="gpio" data-on="false">GPIO</span><span data-indicator="screen" data-on="false">Screen</span></div>
|
||||
<p data-role="preview-time" aria-live="off">Ready</p>
|
||||
<p data-role="preview-sound" aria-live="polite">Ready to play the melody</p>
|
||||
<audio data-role="preview-audio" preload="none"></audio>
|
||||
<button type="button" data-action="preview">Preview with sound</button>
|
||||
<button type="button" data-action="preview-stop">Stop preview</button>
|
||||
<p>The melody plays on this device. No contact or room key is needed to
|
||||
preview an example. Sound set to off or inherit plays no melody here.</p>
|
||||
<p>Timing here is a visual approximation. Firmware schedules outputs using
|
||||
millisecond timestamps without blocking radio work; loop and hardware
|
||||
latency can delay an edge. A haptic motor also takes time to start/stop.</p>
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ extra_css:
|
||||
- _stylesheets/preset_test.css?v=20260923-2
|
||||
|
||||
extra_javascript:
|
||||
- _javascript/notification_builder.js
|
||||
- _javascript/notification_builder.js?v=20260930-audio
|
||||
- https://unpkg.com/leaflet@1.9.4/dist/leaflet.js
|
||||
- _javascript/firmware_picker.js?v=20260929-4
|
||||
- _javascript/telemetry_decoder.js
|
||||
|
||||
@@ -28,6 +28,41 @@ assert.equal(builder.notificationText(longPost).length, 151);
|
||||
assert.throws(() => builder.notificationText({...longPost, kind: "room"}), /150 characters for a room post/);
|
||||
assert.throws(() => builder.commands({...builder.EXAMPLES.food, remote: "on"}));
|
||||
assert.throws(() => builder.commands({...vip, id: "1234"}));
|
||||
// Local examples must play before choosing a recipient. Saved rules still need a key.
|
||||
assert.equal(builder.parseOutputs(builder.EXAMPLES.vip).notes.length, 4);
|
||||
assert.throws(() => builder.commands(builder.EXAMPLES.vip), /full 64-digit/);
|
||||
assert.throws(() => builder.parseOutputs({...vip, gpio: "64:50,50"}), /GPIO needs/);
|
||||
assert.throws(() => builder.parseOutputs({...vip, screen: "invalid"}), /Screen must/);
|
||||
// Verify the actual PCM file sent to the browser: note pitch, rests, and one cycle.
|
||||
const wav = builder.melodyWav("test:d=4,o=5,b=120:a,p,a6");
|
||||
const waveView = new DataView(wav.buffer), rate = waveView.getUint32(24, true);
|
||||
assert.equal(new TextDecoder().decode(wav.slice(0, 4)), "RIFF");
|
||||
assert.equal(new TextDecoder().decode(wav.slice(8, 12)), "WAVE");
|
||||
assert.equal(new TextDecoder().decode(wav.slice(36, 40)), "data");
|
||||
assert.equal(waveView.getUint32(4, true), wav.length - 8);
|
||||
assert.equal(waveView.getUint16(20, true), 1); // PCM
|
||||
assert.equal(waveView.getUint16(22, true), 1); // Mono
|
||||
assert.equal(waveView.getUint16(34, true), 16);
|
||||
assert.equal(waveView.getUint32(40, true), rate * 1.5 * 2);
|
||||
assert.equal(wav.length, 44 + rate * 1.5 * 2);
|
||||
function samples(from, to) {
|
||||
return Array.from({length: Math.round((to - from) * rate)}, (_, i) => waveView.getInt16(44 + (Math.round(from * rate) + i) * 2, true));
|
||||
}
|
||||
function frequency(values) {
|
||||
const crossings = values.slice(1).filter((sample, i) => sample > 0 && values[i] <= 0).length;
|
||||
return crossings * rate / values.length;
|
||||
}
|
||||
const firstNote = samples(0.01, 0.49), lastNote = samples(1.01, 1.49);
|
||||
assert.ok(Math.abs(frequency(firstNote) - 880) < 3);
|
||||
assert.ok(Math.abs(frequency(lastNote) - 1760) < 3);
|
||||
assert.ok(Math.sqrt(firstNote.reduce((sum, sample) => sum + sample * sample, 0) / firstNote.length) > 6000);
|
||||
assert.ok(samples(0.5, 1).every(sample => sample === 0));
|
||||
assert.equal(waveView.getInt16(44, true), 0);
|
||||
assert.equal(waveView.getInt16(wav.length - 2, true), 0);
|
||||
assert.equal(builder.melodyWav("off").length, 44);
|
||||
assert.equal(builder.melodyWav("inherit").length, 44);
|
||||
assert.throws(() => builder.melodyWav("invalid"));
|
||||
console.log("Sound preview: audible PCM, note pitch, silence, duration, and recipient-free examples passed");
|
||||
const encoded = builder.encodeCommand("notify.stop", "A7");
|
||||
assert.equal(encoded[0], 60);assert.equal(encoded[3], 0x42);
|
||||
assert.equal(new TextDecoder().decode(encoded.slice(4)), "A7|notify.stop");
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Optional browser checks for notification audio. Requires Playwright and its browser.
|
||||
|
||||
Use MESHCORE_PREVIEW_BROWSER=webkit to also check the Safari engine, or set
|
||||
MESHCORE_NOTIFICATION_PREVIEW_URL to check the deployed editor.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
sync_playwright = None
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
TAP_AUDIO = """() => {
|
||||
const audio = document.querySelector('[data-role="preview-audio"]');
|
||||
window.previewAudioStats = { plays: 0, rms: 0 };
|
||||
// Attach before playback: late MediaElementSource wiring is silent in WebKit.
|
||||
const context = new AudioContext(), analyser = context.createAnalyser();
|
||||
context.createMediaElementSource(audio).connect(analyser);
|
||||
analyser.connect(context.destination);
|
||||
const samples = new Float32Array(analyser.fftSize);
|
||||
setInterval(() => {
|
||||
analyser.getFloatTimeDomainData(samples);
|
||||
const rms = Math.sqrt(samples.reduce((sum, v) => sum + v * v, 0) / samples.length);
|
||||
previewAudioStats.rms = Math.max(previewAudioStats.rms, rms);
|
||||
}, 10);
|
||||
audio.addEventListener('playing', () => {
|
||||
previewAudioStats.plays++;
|
||||
context.resume();
|
||||
});
|
||||
}"""
|
||||
|
||||
|
||||
@unittest.skipUnless(sync_playwright, "Install Playwright to run browser audio checks")
|
||||
class NotificationAudioTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.playwright = sync_playwright().start()
|
||||
engine = getattr(cls.playwright, os.environ.get("MESHCORE_PREVIEW_BROWSER", "chromium"))
|
||||
try:
|
||||
cls.browser = engine.launch(headless=True)
|
||||
except Exception:
|
||||
cls.playwright.stop()
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.browser.close()
|
||||
cls.playwright.stop()
|
||||
|
||||
def setUp(self):
|
||||
self.page = self.browser.new_page()
|
||||
self.errors = []
|
||||
self.page.on("pageerror", lambda error: self.errors.append(str(error)))
|
||||
url = os.environ.get("MESHCORE_NOTIFICATION_PREVIEW_URL")
|
||||
if url:
|
||||
self.page.goto(url, wait_until="networkidle")
|
||||
else:
|
||||
source = (ROOT / "docs/notifications.md").read_text(encoding="utf-8")
|
||||
editor = '<div class="notification-builder"' + source.split('<div class="notification-builder"', 1)[1].split("\n</div>", 1)[0] + "\n</div>"
|
||||
self.page.set_content(editor)
|
||||
self.page.add_script_tag(path=str(ROOT / "docs/_javascript/notification_builder.js"))
|
||||
self.page.evaluate(TAP_AUDIO)
|
||||
|
||||
def tearDown(self):
|
||||
self.page.close()
|
||||
self.assertEqual(self.errors, [])
|
||||
|
||||
def click(self, action):
|
||||
self.page.locator('[data-action="' + action + '"]').click()
|
||||
|
||||
def audible(self):
|
||||
# Inspect decoded audio at the speaker output, not just a successful play() call.
|
||||
self.page.wait_for_function("previewAudioStats.rms > 0.05")
|
||||
|
||||
def test_examples_play_audible_melodies_once_without_a_recipient(self):
|
||||
for index, example in enumerate(("food", "find", "vip"), 1):
|
||||
self.page.locator('[data-example="' + example + '"]').click()
|
||||
self.page.evaluate("previewAudioStats.rms = 0")
|
||||
self.click("preview")
|
||||
self.audible()
|
||||
self.assertEqual(self.page.locator('[data-role="error"]').inner_text(), "")
|
||||
self.page.wait_for_function("document.querySelector('[data-role=preview-sound]').textContent === 'Cycle finished'")
|
||||
self.assertEqual(self.page.evaluate("previewAudioStats.plays"), index)
|
||||
self.assertTrue(self.page.evaluate("document.querySelector('audio').paused && !document.querySelector('audio').hasAttribute('src')"))
|
||||
|
||||
def test_timer_waits_for_audio_and_stop_cancels_pending_start(self):
|
||||
self.page.evaluate("""() => {
|
||||
const original = HTMLMediaElement.prototype.play;
|
||||
const originalLoad = HTMLMediaElement.prototype.load;
|
||||
let cancelStart;
|
||||
HTMLMediaElement.prototype.play = function() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
cancelStart = null;
|
||||
original.call(this).then(resolve, reject);
|
||||
}, 1200);
|
||||
cancelStart = () => {
|
||||
clearTimeout(timer); cancelStart = null;
|
||||
reject(new DOMException('Playback stopped', 'AbortError'));
|
||||
};
|
||||
});
|
||||
};
|
||||
// Native pending play() promises reject when load() cancels the source.
|
||||
HTMLMediaElement.prototype.load = function() {
|
||||
if (cancelStart) cancelStart();
|
||||
return originalLoad.call(this);
|
||||
};
|
||||
}""")
|
||||
self.click("preview")
|
||||
self.page.wait_for_timeout(1000) # Longer than the default 910 ms visual cycle.
|
||||
self.assertEqual(self.page.locator('[data-role="preview-time"]').inner_text(), "0 / 910 ms")
|
||||
self.assertEqual(self.page.evaluate("previewAudioStats.plays"), 0)
|
||||
self.assertTrue(self.page.locator('[data-action="preview"]').is_disabled())
|
||||
self.assertTrue(self.page.locator('[data-action="preview-stop"]').is_enabled())
|
||||
self.audible()
|
||||
self.page.wait_for_function("document.querySelector('[data-role=preview-sound]').textContent === 'Cycle finished'")
|
||||
self.click("preview")
|
||||
self.click("preview-stop")
|
||||
self.page.wait_for_timeout(1500)
|
||||
self.assertEqual(self.page.evaluate("previewAudioStats.plays"), 1)
|
||||
self.assertEqual(self.page.locator('[data-role="error"]').inner_text(), "")
|
||||
self.assertEqual(self.page.locator('[data-role="preview-sound"]').inner_text(), "Stopped")
|
||||
self.assertTrue(self.page.locator('[data-action="preview"]').is_enabled())
|
||||
|
||||
def test_off_inherit_stop_and_playback_errors(self):
|
||||
for value in ("off", "inherit"):
|
||||
self.page.locator('input[name="sound"]').fill(value)
|
||||
self.click("preview")
|
||||
self.page.wait_for_timeout(80)
|
||||
self.assertEqual(self.page.evaluate("previewAudioStats.plays"), 0)
|
||||
self.assertIn("ms", self.page.locator('[data-role="preview-time"]').inner_text())
|
||||
self.click("preview-stop")
|
||||
self.page.locator('[data-example="food"]').click()
|
||||
self.page.evaluate("""() => {
|
||||
window.originalPlay = HTMLMediaElement.prototype.play;
|
||||
HTMLMediaElement.prototype.play = () => Promise.reject(new DOMException('Playback blocked', 'NotAllowedError'));
|
||||
}""")
|
||||
self.click("preview")
|
||||
self.assertIn("Playback blocked", self.page.locator('[data-role="error"]').inner_text())
|
||||
self.assertEqual(self.page.locator('[data-role="preview-sound"]').inner_text(), "Sound unavailable")
|
||||
self.page.evaluate("() => { HTMLMediaElement.prototype.play = originalPlay; }")
|
||||
self.click("preview")
|
||||
self.audible()
|
||||
self.click("preview-stop")
|
||||
self.assertTrue(self.page.evaluate("document.querySelector('audio').paused && !document.querySelector('audio').hasAttribute('src')"))
|
||||
self.assertEqual(self.page.locator('[data-role="error"]').inner_text(), "")
|
||||
self.assertEqual(self.page.locator('[data-indicator="screen"]').get_attribute("data-on"), "false")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user