mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-20 16:12:38 +00:00
Add channel-nine-only notification builder preset
This commit is contained in:
@@ -5,7 +5,8 @@
|
||||
const EXAMPLES = {
|
||||
find: { label: "Find my node", kind: "all", id: "", when: "disconnected", vibration: "200,200,200", led: "100,100,100,700", sound: "find:d=8,o=6,b=180:c,e,g,p", screen: "on", gpio: "off", repeat: "forever", gap: "1000", stop: "connected" },
|
||||
food: { label: "Your food truck order is ready", kind: "channel", id: "3", when: "any", vibration: "50,300,40,20,500", led: "100,100,100,500", sound: "order:d=8,o=5,b=180:c,e,g,4c6", screen: "on", gpio: "off", repeat: "forever", gap: "2000", stop: "button" },
|
||||
vip: { label: "VIP chat", kind: "contact", id: "", when: "disconnected", vibration: "100,100,300", led: "50,300,40,20,500", sound: "vip:d=8,o=5,b=180:c,e,g,4g", screen: "on", gpio: "off", repeat: "2", gap: "500", stop: "button" }
|
||||
vip: { label: "VIP chat", kind: "contact", id: "", when: "disconnected", vibration: "100,100,300", led: "50,300,40,20,500", sound: "vip:d=8,o=5,b=180:c,e,g,4g", screen: "on", gpio: "off", repeat: "2", gap: "500", stop: "button" },
|
||||
channel9: { label: "Only channel 9", kind: "channel", id: "9", when: "any", vibration: "off", led: "off", sound: "ch9:d=8,o=5,b=180:c,e,g", screen: "off", gpio: "off", repeat: "1", gap: "500", stop: "button", quietOthers: "on" }
|
||||
};
|
||||
function pulse(text) {
|
||||
if (text === "off" || text === "inherit") return [];
|
||||
@@ -91,6 +92,14 @@
|
||||
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.");
|
||||
if (config.quietOthers === "on") {
|
||||
if (config.kind !== "channel") throw new Error("Silencing other message alerts needs a channel exception.");
|
||||
// This is a shared RULE, not master off: the selected channel can still
|
||||
// override it. Copy and USB Save must apply the same two-rule recipe.
|
||||
result.push("set notify.enabled on");
|
||||
for (const name of Object.keys(OUTPUTS)) result.push("set notify." + name + " all off");
|
||||
result.push("set notify.repeat all 1", "set notify.gap all 500", "set notify.stop all button");
|
||||
}
|
||||
for (const name of Object.keys(OUTPUTS)) {
|
||||
if (!["off", "inherit"].includes(config[name])) result.push("set notify." + name + " on");
|
||||
result.push("set notify." + name + " " + target + " " + config[name]);
|
||||
@@ -104,6 +113,7 @@
|
||||
return result;
|
||||
}
|
||||
function notificationText(config) {
|
||||
if (config.quietOthers === "on") throw new Error("Use CLI commands or USB Save for the silent default and channel exception; a notification DM cannot configure these rules.");
|
||||
const parts = [];
|
||||
for (const name of ["vibration", "sound", "led", "screen"]) if (config[name] !== "inherit") parts.push(name + "=" + config[name]);
|
||||
for (const name of ["repeat", "gap"]) parts.push(name + "=" + config[name]);
|
||||
@@ -185,6 +195,10 @@
|
||||
let client = null, supported = null, previewTimer = null, previewUrl = null, previewVersion = 0;
|
||||
const config = () => Object.fromEntries(new FormData(form));
|
||||
function render() {
|
||||
const quiet = form.elements.quietOthers;
|
||||
quiet.disabled = form.elements.kind.value !== "channel";
|
||||
if (quiet.disabled) quiet.checked = false;
|
||||
find("quiet-warning").hidden = !quiet.checked;
|
||||
try {
|
||||
output.textContent = commands(config()).join("\n"); error.textContent = "";
|
||||
try { find("dm").textContent = notificationText(config()); }
|
||||
@@ -193,8 +207,11 @@
|
||||
catch (e) { output.textContent = ""; find("dm").textContent = ""; error.textContent = e.message; }
|
||||
}
|
||||
function preset(name) {
|
||||
const values = { remote: "inherit", ...EXAMPLES[name] };
|
||||
for (const [key, value] of Object.entries(values)) if (form.elements[key]) form.elements[key].value = value;
|
||||
const values = { remote: "inherit", quietOthers: "off", ...EXAMPLES[name] };
|
||||
for (const [key, value] of Object.entries(values)) if (form.elements[key]) {
|
||||
if (key === "quietOthers") form.elements[key].checked = value === "on";
|
||||
else form.elements[key].value = value;
|
||||
}
|
||||
render();
|
||||
}
|
||||
function stopPreview(status = "Stopped") {
|
||||
@@ -296,6 +313,9 @@
|
||||
const c = config(), list = commands(c);
|
||||
if (!client || supported === null) throw new Error("Connect updated Companion firmware first.");
|
||||
for (const name of Object.keys(OUTPUTS)) if (!["off", "inherit"].includes(c[name]) && !(supported & OUTPUTS[name])) throw new Error(name + " is unavailable on this device. Choose off or inherit.");
|
||||
// Resolve/check numeric channel slots before changing the shared
|
||||
// default: a missing exception channel must not leave all alerts mute.
|
||||
if (c.quietOthers === "on") await run("get notify.sound " + selector(c));
|
||||
for (const text of list) await run(text);
|
||||
if (action === "apply-test") await run("notify.test " + selector(c));
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
.notification-builder small { font-size: .7rem; font-weight: 400; color: var(--md-default-fg-color--light); }
|
||||
.notification-builder input, .notification-builder select { width: 100%; min-width: 0; border: 1px solid var(--md-default-fg-color--lighter); border-radius: 6px; padding: .6rem; background: var(--md-default-bg-color); color: var(--md-default-fg-color); font: inherit; font-weight: 400; }
|
||||
.notification-builder input:focus, .notification-builder select:focus { outline: 2px solid var(--md-primary-fg-color); outline-offset: 2px; }
|
||||
.notification-builder .notification-checkbox { flex-direction: row; align-items: center; margin-top: 1rem; }
|
||||
.notification-builder .notification-checkbox input { width: auto; flex: 0 0 auto; padding: 0; }
|
||||
.notification-builder button { border: 1px solid var(--md-primary-fg-color); border-radius: 6px; padding: .5rem .75rem; margin: .2rem; cursor: pointer; color: var(--md-primary-fg-color); font: inherit; }
|
||||
.notification-builder button:hover { background: var(--md-primary-fg-color); color: var(--md-primary-bg-color); }
|
||||
.notification-builder button:disabled { opacity: .5; cursor: wait; }
|
||||
|
||||
+14
-1
@@ -13,6 +13,7 @@ stop policy. USB testing uses Binary Companion mode at 115200 baud.
|
||||
<button type="button" data-example="find">Find my node</button>
|
||||
<button type="button" data-example="food">Food order ready</button>
|
||||
<button type="button" data-example="vip">VIP chat</button>
|
||||
<button type="button" data-example="channel9">Only channel 9</button>
|
||||
</div>
|
||||
<form data-role="form" onsubmit="return false">
|
||||
<section class="notification-card">
|
||||
@@ -22,6 +23,12 @@ stop policy. USB testing uses Binary Companion mode at 115200 baud.
|
||||
<label>Contact/room key or channel slot<input name="id" autocomplete="off" placeholder="Full contact/room public key, or channel slot such as 3"></label>
|
||||
<label>Companion client status<select name="when"><option value="any">Connected or disconnected</option><option value="connected">Connected</option><option value="disconnected">Disconnected</option></select></label>
|
||||
</div>
|
||||
<label class="notification-checkbox"><input type="checkbox" name="quietOthers" value="on">Silence other message alerts (channel exception)</label>
|
||||
<p data-role="quiet-warning" hidden>This saves a silent default plus the selected channel's rule.
|
||||
It also silences ordinary DMs and room-message alerts. Existing specific/state rules
|
||||
and authorized <code>!notify</code> strings can still alert; review the
|
||||
<a href="#only-channel-9-alerts-all-other-channels-silent">channel 9 example and caveats</a>.
|
||||
Copy commands and USB Save include both rules; preview only plays the exception.</p>
|
||||
<p>Connected means an active phone/app, USB, WiFi, Ethernet or serial
|
||||
companion client. It selects a message rule; connecting or disconnecting
|
||||
alone does not create an alert. Contact matching uses the full public key.
|
||||
@@ -106,7 +113,9 @@ stop policy. USB testing uses Binary Companion mode at 115200 baud.
|
||||
<p>Save writes the generated commands in order and stops at the first device
|
||||
error; earlier successful settings remain saved. A test with a connection
|
||||
suffix selects that profile even over USB. Stop-on-connect still watches
|
||||
real subsequent connections. USB testing requires Chrome or Edge.</p>
|
||||
real subsequent connections. The silence-other-alerts option saves both
|
||||
the silent default and the selected channel rule. Deleting the selected
|
||||
rule does not delete that default. USB testing requires Chrome or Edge.</p>
|
||||
<pre data-role="device-log" class="notification-log" aria-live="polite"></pre>
|
||||
</section>
|
||||
</div>
|
||||
@@ -247,6 +256,10 @@ This example makes message alerts silent by default, then plays one short
|
||||
melody for channel 9. It works both with and without a connected companion
|
||||
client and requires a device with a buzzer. Messages still arrive normally;
|
||||
this changes the node's alerts, not the phone app's notification settings.
|
||||
Use the **Only channel 9** button above to load this setup into the builder.
|
||||
You can change the channel slot or alert pattern before copying or saving.
|
||||
Uncheck **Silence other message alerts** to generate only the selected rule;
|
||||
this does not remove a silent default already saved on the node.
|
||||
|
||||
`channel:9` means the firmware's **zero-based slot 9** (the tenth slot), not
|
||||
a channel named `ch9`. Configure that slot before running these commands.
|
||||
|
||||
+2
-2
@@ -111,7 +111,7 @@ theme:
|
||||
- search.suggest
|
||||
|
||||
extra_css:
|
||||
- _stylesheets/notification_builder.css
|
||||
- _stylesheets/notification_builder.css?v=20261006-channel9
|
||||
- https://unpkg.com/leaflet@1.9.4/dist/leaflet.css
|
||||
- _stylesheets/extra.css
|
||||
- _stylesheets/firmware_picker.css?v=20261001-feedback-1
|
||||
@@ -122,7 +122,7 @@ extra_css:
|
||||
- _stylesheets/esp32_partition_catalog.css?v=20261001-1
|
||||
|
||||
extra_javascript:
|
||||
- _javascript/notification_builder.js?v=20260930-audio-start
|
||||
- _javascript/notification_builder.js?v=20261006-channel9
|
||||
- https://unpkg.com/leaflet@1.9.4/dist/leaflet.js
|
||||
- _javascript/firmware_picker.js?v=20261001-feedback-1
|
||||
- _javascript/telemetry_decoder.js
|
||||
|
||||
@@ -16,6 +16,10 @@ for (const config of Object.values(builder.EXAMPLES)) {
|
||||
const complete = {...config, id: config.kind === "contact" ? "01".repeat(32) : config.id};
|
||||
const commands = builder.commands(complete);
|
||||
assert.ok(commands.every(cmd => cmd.length + 4 <= 175));
|
||||
if (config.quietOthers === "on") {
|
||||
assert.throws(() => builder.notificationText(complete), /CLI|USB/);
|
||||
continue;
|
||||
}
|
||||
const dm = builder.notificationText(complete);
|
||||
assert.ok(dm.startsWith("!notify "));assert.ok(!dm.includes("gpio="));assert.ok(dm.length <= 159);
|
||||
}
|
||||
@@ -28,6 +32,38 @@ 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"}));
|
||||
// The channel-only example installs a quiet default before its exception.
|
||||
const quietPrefix = [
|
||||
"set notify.enabled on",
|
||||
"set notify.vibration all off", "set notify.sound all off",
|
||||
"set notify.led all off", "set notify.screen all off", "set notify.gpio all off",
|
||||
"set notify.repeat all 1", "set notify.gap all 500", "set notify.stop all button"
|
||||
];
|
||||
const channel9 = builder.EXAMPLES.channel9;
|
||||
assert.equal(channel9.kind, "channel");assert.equal(channel9.id, "9");
|
||||
assert.equal(channel9.when, "any");assert.equal(channel9.quietOthers, "on");
|
||||
assert.equal(channel9.sound, "ch9:d=8,o=5,b=180:c,e,g");
|
||||
assert.equal(channel9.repeat, "1");assert.equal(channel9.gap, "500");assert.equal(channel9.stop, "button");
|
||||
for (const name of ["vibration", "led", "screen", "gpio"]) assert.equal(channel9[name], "off");
|
||||
const exceptionOnly = builder.commands({...channel9, quietOthers: "off"});
|
||||
const onlyChannel9 = builder.commands(channel9);
|
||||
assert.deepEqual(onlyChannel9, [...quietPrefix, ...exceptionOnly]);
|
||||
assert.deepEqual(exceptionOnly, builder.commands({...channel9, quietOthers: undefined}));
|
||||
assert.deepEqual(exceptionOnly, [
|
||||
"set notify.vibration channel:9 off", "set notify.sound on",
|
||||
"set notify.sound channel:9 ch9:d=8,o=5,b=180:c,e,g",
|
||||
"set notify.led channel:9 off", "set notify.screen channel:9 off",
|
||||
"set notify.gpio channel:9 off", "set notify.repeat channel:9 1",
|
||||
"set notify.gap channel:9 500", "set notify.stop channel:9 button"
|
||||
]);
|
||||
assert.deepEqual(builder.commands({...channel9, id: "8"}), [
|
||||
...quietPrefix, ...exceptionOnly.map(command => command.replace("channel:9", "channel:8"))
|
||||
]);
|
||||
for (const kind of ["all", "contact", "room"]) {
|
||||
assert.throws(() => builder.commands({...channel9, kind, id: "01".repeat(32)}), /channel/i);
|
||||
}
|
||||
assert.throws(() => builder.notificationText(channel9), /CLI|USB/);
|
||||
assert.ok(builder.notificationText({...channel9, quietOthers: "off"}).startsWith("!notify "));
|
||||
// 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/);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Optional browser checks for notification audio. Requires Playwright and its browser.
|
||||
"""Optional browser checks for notification audio/editor. Requires Playwright and its browser.
|
||||
|
||||
Use MESHCORE_PREVIEW_BROWSER=webkit to also check the Safari engine, or set
|
||||
Use MESHCORE_PREVIEW_BROWSER=webkit to also check the Safari engine, set
|
||||
MESHCORE_PREVIEW_EXECUTABLE to use an installed browser executable, or set
|
||||
MESHCORE_NOTIFICATION_PREVIEW_URL to check the deployed editor.
|
||||
"""
|
||||
|
||||
@@ -57,7 +58,9 @@ class NotificationAudioTest(unittest.TestCase):
|
||||
cls.playwright = sync_playwright().start()
|
||||
engine = getattr(cls.playwright, os.environ.get("MESHCORE_PREVIEW_BROWSER", "chromium"))
|
||||
try:
|
||||
cls.browser = engine.launch(headless=True)
|
||||
executable = os.environ.get("MESHCORE_PREVIEW_EXECUTABLE")
|
||||
options = {"executable_path": executable} if executable else {}
|
||||
cls.browser = engine.launch(headless=True, **options)
|
||||
except Exception:
|
||||
cls.playwright.stop()
|
||||
raise
|
||||
@@ -92,6 +95,118 @@ class NotificationAudioTest(unittest.TestCase):
|
||||
# Inspect decoded audio at the speaker output, not just a successful play() call.
|
||||
self.page.wait_for_function("previewAudioStats.rms > 0.05")
|
||||
|
||||
def test_only_channel_9_button_sets_quiet_default_and_editable_exception(self):
|
||||
self.page.locator('[data-example="channel9"]').click()
|
||||
expected = {
|
||||
"kind": "channel", "id": "9", "when": "any", "vibration": "off",
|
||||
"led": "off", "sound": "ch9:d=8,o=5,b=180:c,e,g", "screen": "off",
|
||||
"gpio": "off", "repeat": "1", "gap": "500", "stop": "button",
|
||||
}
|
||||
for name, value in expected.items():
|
||||
self.assertEqual(self.page.locator('[name="' + name + '"]').input_value(), value)
|
||||
self.assertTrue(self.page.locator('[name="quietOthers"]').is_checked())
|
||||
self.assertTrue(self.page.locator('[data-role="quiet-warning"]').is_visible())
|
||||
self.assertEqual(self.page.locator('[data-role="error"]').inner_text(), "")
|
||||
commands = self.page.locator('[data-role="commands"]').inner_text().splitlines()
|
||||
self.assertEqual(commands[:9], self.quiet_prefix())
|
||||
self.assertIn("set notify.sound channel:9 ch9:d=8,o=5,b=180:c,e,g", commands)
|
||||
self.assertNotIn("!notify ", self.page.locator('[data-role="dm"]').inner_text())
|
||||
self.assertRegex(self.page.locator('[data-role="dm"]').inner_text(), "CLI|USB")
|
||||
self.page.locator('[name="id"]').fill("8")
|
||||
edited = self.page.locator('[data-role="commands"]').inner_text().splitlines()
|
||||
self.assertEqual(edited[:9], self.quiet_prefix())
|
||||
self.assertIn("set notify.sound channel:8 ch9:d=8,o=5,b=180:c,e,g", edited)
|
||||
self.assertNotIn("channel:9", "\n".join(edited))
|
||||
self.page.locator('[name="quietOthers"]').uncheck()
|
||||
normal = self.page.locator('[data-role="commands"]').inner_text()
|
||||
self.assertNotIn(" all ", normal)
|
||||
self.assertNotIn("set notify.enabled on", normal)
|
||||
self.assertTrue(self.page.locator('[data-role="dm"]').inner_text().startswith("!notify "))
|
||||
self.assertFalse(self.page.locator('[data-role="quiet-warning"]').is_visible())
|
||||
self.page.locator('[data-example="channel9"]').click()
|
||||
self.page.locator('[name="kind"]').select_option("all")
|
||||
self.assertFalse(self.page.locator('[name="quietOthers"]').is_checked())
|
||||
self.assertTrue(self.page.locator('[name="quietOthers"]').is_disabled())
|
||||
self.assertNotIn("set notify.enabled on", self.page.locator('[data-role="commands"]').inner_text())
|
||||
self.assertEqual(self.page.locator('[data-role="error"]').inner_text(), "")
|
||||
|
||||
def test_switching_example_clears_quiet_default(self):
|
||||
for example in ("food", "find", "vip"):
|
||||
self.page.locator('[data-example="channel9"]').click()
|
||||
self.assertTrue(self.page.locator('[name="quietOthers"]').is_checked())
|
||||
self.page.locator('[data-example="' + example + '"]').click()
|
||||
self.assertFalse(self.page.locator('[name="quietOthers"]').is_checked())
|
||||
if example == "vip":
|
||||
self.page.locator('[name="id"]').fill("01" * 32)
|
||||
commands = self.page.locator('[data-role="commands"]').inner_text()
|
||||
self.assertNotIn("set notify.enabled on", commands)
|
||||
self.assertNotIn("set notify.sound all off", commands)
|
||||
self.assertEqual(self.page.locator('[data-role="error"]').inner_text(), "")
|
||||
self.assertTrue(self.page.locator('[data-role="dm"]').inner_text().startswith("!notify "))
|
||||
|
||||
@staticmethod
|
||||
def quiet_prefix():
|
||||
return [
|
||||
"set notify.enabled on", "set notify.vibration all off",
|
||||
"set notify.sound all off", "set notify.led all off",
|
||||
"set notify.screen all off", "set notify.gpio all off",
|
||||
"set notify.repeat all 1", "set notify.gap all 500", "set notify.stop all button",
|
||||
]
|
||||
|
||||
def mock_usb(self, reject_channel=False):
|
||||
self.page.evaluate("""rejectChannel => {
|
||||
let incoming, active = 0;
|
||||
window.notificationUsbMock = { commands: [], maxActive: 0, replies: 0 };
|
||||
const port = {
|
||||
readable: new ReadableStream({ start(controller) { incoming = controller; } }),
|
||||
writable: new WritableStream({ write(bytes) {
|
||||
const text = new TextDecoder().decode(bytes.slice(4));
|
||||
const tag = text.slice(0, 2), command = text.slice(3);
|
||||
notificationUsbMock.commands.push(command);
|
||||
notificationUsbMock.maxActive = Math.max(notificationUsbMock.maxActive, ++active);
|
||||
setTimeout(() => {
|
||||
let value = command === 'get notify' ? 'supported=31' : 'OK';
|
||||
if (command.startsWith('get notify.sound channel:'))
|
||||
value = rejectChannel ? 'Error: channel slot is not configured' : 'inherit';
|
||||
const body = new TextEncoder().encode(tag + '|' + value);
|
||||
active--; notificationUsbMock.replies++;
|
||||
incoming.enqueue(Uint8Array.from([62, body.length + 1, 0, 0x1d, ...body]));
|
||||
}, 10);
|
||||
} }),
|
||||
async open() {}, async setSignals() {}, async close() {},
|
||||
};
|
||||
Object.defineProperty(navigator, 'serial', { configurable: true, value: {
|
||||
async requestPort() { return port; }
|
||||
} });
|
||||
}""", reject_channel)
|
||||
|
||||
def test_only_channel_9_usb_save_sends_default_then_exception_sequentially(self):
|
||||
self.mock_usb()
|
||||
self.page.locator('[data-example="channel9"]').click()
|
||||
expected = self.page.locator('[data-role="commands"]').inner_text().splitlines()
|
||||
self.assertEqual(expected[:9], self.quiet_prefix())
|
||||
self.click("connect")
|
||||
self.page.wait_for_function("notificationUsbMock.replies === 2 && !document.querySelector('[data-action=apply]').disabled")
|
||||
self.click("apply")
|
||||
self.page.wait_for_function("notificationUsbMock.replies === " + str(len(expected) + 3) + " && !document.querySelector('[data-action=apply]').disabled")
|
||||
self.assertEqual(self.page.evaluate("notificationUsbMock.commands"), ["get notify", "get notify.gpio.pins", "get notify.sound channel:9"] + expected)
|
||||
self.assertEqual(self.page.evaluate("notificationUsbMock.maxActive"), 1)
|
||||
self.assertEqual(self.page.locator('[data-role="error"]').inner_text(), "")
|
||||
self.click("disconnect")
|
||||
self.page.wait_for_function("document.querySelector('[data-role=device-status]').textContent === 'Device disconnected'")
|
||||
|
||||
def test_unconfigured_channel_is_rejected_before_quiet_default_is_written(self):
|
||||
self.mock_usb(reject_channel=True)
|
||||
self.page.locator('[data-example="channel9"]').click()
|
||||
self.click("connect")
|
||||
self.page.wait_for_function("notificationUsbMock.replies === 2 && !document.querySelector('[data-action=apply]').disabled")
|
||||
self.click("apply")
|
||||
self.page.wait_for_function("notificationUsbMock.replies === 3 && !document.querySelector('[data-action=apply]').disabled")
|
||||
self.assertEqual(self.page.evaluate("notificationUsbMock.commands"), ["get notify", "get notify.gpio.pins", "get notify.sound channel:9"])
|
||||
self.assertIn("channel slot is not configured", self.page.locator('[data-role="error"]').inner_text())
|
||||
self.click("disconnect")
|
||||
self.page.wait_for_function("document.querySelector('[data-role=device-status]').textContent === 'Device disconnected'")
|
||||
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user