352 lines
10 KiB
Python
352 lines
10 KiB
Python
import threading
|
|
import time
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
from fenrirscreenreader.speechDriver import dectalkUsbDriver
|
|
|
|
|
|
class FakeUsbDevice:
|
|
def __init__(self):
|
|
self.writes = []
|
|
|
|
def get_active_configuration(self):
|
|
return object()
|
|
|
|
def write(self, endpoint, data, timeout=None):
|
|
payload = bytes(data)
|
|
self.writes.append((endpoint, payload, timeout))
|
|
return len(payload)
|
|
|
|
|
|
class FakeUsbCore:
|
|
USBError = OSError
|
|
|
|
class NoBackendError(ValueError):
|
|
pass
|
|
|
|
def __init__(self, device, find_error=None):
|
|
self.device = device
|
|
self.find_error = find_error
|
|
self.find_calls = []
|
|
|
|
def find(self, **kwargs):
|
|
self.find_calls.append(kwargs)
|
|
if self.find_error:
|
|
raise self.find_error
|
|
return self.device
|
|
|
|
|
|
class FakeUsbUtil:
|
|
def __init__(
|
|
self,
|
|
claim_error=None,
|
|
release_error=None,
|
|
dispose_error=None,
|
|
):
|
|
self.claim_error = claim_error
|
|
self.release_error = release_error
|
|
self.dispose_error = dispose_error
|
|
self.claimed = []
|
|
self.released = []
|
|
self.disposed = []
|
|
|
|
def claim_interface(self, device, interface):
|
|
if self.claim_error:
|
|
raise self.claim_error
|
|
self.claimed.append((device, interface))
|
|
|
|
def release_interface(self, device, interface):
|
|
if self.release_error:
|
|
raise self.release_error
|
|
self.released.append((device, interface))
|
|
|
|
def dispose_resources(self, device):
|
|
if self.dispose_error:
|
|
raise self.dispose_error
|
|
self.disposed.append(device)
|
|
|
|
|
|
class BlockingUsbDevice(FakeUsbDevice):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.normal_write_started = threading.Event()
|
|
self.release_normal_write = threading.Event()
|
|
self.immediate_write_finished = threading.Event()
|
|
|
|
def write(self, endpoint, data, timeout=None):
|
|
if endpoint == 0x02:
|
|
self.normal_write_started.set()
|
|
self.release_normal_write.wait(timeout=1.0)
|
|
result = super().write(endpoint, data, timeout=timeout)
|
|
if endpoint == 0x04:
|
|
self.immediate_write_finished.set()
|
|
return result
|
|
|
|
|
|
class CancelDuringSpeechUsbDevice(FakeUsbDevice):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.first_normal_write_started = threading.Event()
|
|
self.release_first_normal_write = threading.Event()
|
|
|
|
def write(self, endpoint, data, timeout=None):
|
|
if endpoint == 0x02 and not self.first_normal_write_started.is_set():
|
|
self.first_normal_write_started.set()
|
|
self.release_first_normal_write.wait(timeout=1.0)
|
|
return super().write(endpoint, data, timeout=timeout)
|
|
|
|
|
|
def build_environment():
|
|
return {
|
|
"runtime": {
|
|
"SettingsManager": Mock(),
|
|
"DebugManager": Mock(),
|
|
}
|
|
}
|
|
|
|
|
|
def initialized_driver(monkeypatch, clear_startup_writes=True):
|
|
device = FakeUsbDevice()
|
|
usb_core = FakeUsbCore(device)
|
|
usb_util = FakeUsbUtil()
|
|
speech_driver = dectalkUsbDriver.driver()
|
|
monkeypatch.setattr(
|
|
speech_driver,
|
|
"_load_usb_modules",
|
|
lambda: (usb_core, usb_util),
|
|
)
|
|
speech_driver.initialize(build_environment())
|
|
if clear_startup_writes:
|
|
device.writes.clear()
|
|
return speech_driver, device, usb_core, usb_util
|
|
|
|
|
|
def wait_for_writes(device, count, timeout=1.0):
|
|
deadline = time.monotonic() + timeout
|
|
while len(device.writes) < count and time.monotonic() < deadline:
|
|
time.sleep(0.01)
|
|
|
|
|
|
def test_dectalk_usb_driver_finds_and_claims_native_device(monkeypatch):
|
|
speech_driver, device, usb_core, usb_util = initialized_driver(monkeypatch)
|
|
try:
|
|
assert usb_core.find_calls == [
|
|
{"idVendor": 0x0DD0, "idProduct": 0x2001}
|
|
]
|
|
assert usb_util.claimed == [(device, 0)]
|
|
assert speech_driver._is_initialized
|
|
finally:
|
|
speech_driver.shutdown()
|
|
|
|
assert usb_util.released == [(device, 0)]
|
|
assert usb_util.disposed == [device]
|
|
|
|
|
|
def test_dectalk_usb_driver_sets_low_buffer_timeout_on_initialize(monkeypatch):
|
|
speech_driver, device, _, _ = initialized_driver(
|
|
monkeypatch,
|
|
clear_startup_writes=False,
|
|
)
|
|
try:
|
|
assert device.writes[0] == (0x02, b"[:timeout 1]", 1000)
|
|
finally:
|
|
speech_driver.shutdown()
|
|
|
|
|
|
def test_dectalk_usb_driver_writes_commands_and_speech(monkeypatch):
|
|
speech_driver, device, _, _ = initialized_driver(monkeypatch)
|
|
try:
|
|
speech_driver.set_rate(1.0)
|
|
speech_driver.set_pitch(1.0)
|
|
speech_driver.set_volume(0.5)
|
|
speech_driver.speak("Hello\nworld")
|
|
wait_for_writes(device, 4)
|
|
|
|
assert device.writes == [
|
|
(0x02, b"[:ra 650]", 1000),
|
|
(0x02, b"[:dv ap 350]", 1000),
|
|
(0x02, b"[:dv g5 73]", 1000),
|
|
(0x02, b"Hello world\x0b", 1000),
|
|
]
|
|
finally:
|
|
speech_driver.shutdown()
|
|
|
|
|
|
def test_dectalk_usb_driver_skips_redundant_setting_writes(monkeypatch):
|
|
speech_driver, device, _, _ = initialized_driver(monkeypatch)
|
|
try:
|
|
speech_driver.set_rate(0.5)
|
|
speech_driver.set_pitch(0.5)
|
|
speech_driver.set_volume(1.0)
|
|
speech_driver.set_rate(0.5)
|
|
speech_driver.set_pitch(0.5)
|
|
speech_driver.set_volume(1.0)
|
|
|
|
assert device.writes == [
|
|
(0x02, b"[:ra 180]", 1000),
|
|
(0x02, b"[:dv ap 122]", 1000),
|
|
(0x02, b"[:dv g5 86]", 1000),
|
|
]
|
|
finally:
|
|
speech_driver.shutdown()
|
|
|
|
|
|
def test_dectalk_usb_driver_chunks_normal_writes(monkeypatch):
|
|
speech_driver, device, _, _ = initialized_driver(monkeypatch)
|
|
try:
|
|
speech_driver._write_bytes(b"x" * 70, "speech")
|
|
assert device.writes == [
|
|
(0x02, b"x" * 64, 1000),
|
|
(0x02, b"x" * 6, 1000),
|
|
]
|
|
finally:
|
|
speech_driver.shutdown()
|
|
|
|
|
|
def test_dectalk_usb_driver_uses_immediate_cancel_endpoint(monkeypatch):
|
|
speech_driver, device, _, _ = initialized_driver(monkeypatch)
|
|
try:
|
|
speech_driver.cancel()
|
|
assert device.writes == [(0x04, b"\x06", 1000)]
|
|
finally:
|
|
speech_driver.shutdown()
|
|
|
|
|
|
def test_dectalk_usb_cancel_bypasses_blocked_normal_write(monkeypatch):
|
|
device = BlockingUsbDevice()
|
|
usb_core = FakeUsbCore(device)
|
|
usb_util = FakeUsbUtil()
|
|
speech_driver = dectalkUsbDriver.driver()
|
|
monkeypatch.setattr(
|
|
speech_driver,
|
|
"_load_usb_modules",
|
|
lambda: (usb_core, usb_util),
|
|
)
|
|
speech_driver.initialize(build_environment())
|
|
device.writes.clear()
|
|
normal_thread = threading.Thread(
|
|
target=speech_driver._write_bytes,
|
|
args=(b"normal speech", "speech"),
|
|
)
|
|
cancel_thread = threading.Thread(target=speech_driver.cancel)
|
|
try:
|
|
normal_thread.start()
|
|
assert device.normal_write_started.wait(timeout=0.2)
|
|
cancel_thread.start()
|
|
assert device.immediate_write_finished.wait(timeout=0.2)
|
|
finally:
|
|
device.release_normal_write.set()
|
|
normal_thread.join(timeout=1.0)
|
|
cancel_thread.join(timeout=1.0)
|
|
speech_driver.shutdown()
|
|
|
|
|
|
def test_dectalk_usb_cancel_stops_current_speech_upload(monkeypatch):
|
|
device = CancelDuringSpeechUsbDevice()
|
|
usb_core = FakeUsbCore(device)
|
|
usb_util = FakeUsbUtil()
|
|
speech_driver = dectalkUsbDriver.driver()
|
|
monkeypatch.setattr(
|
|
speech_driver,
|
|
"_load_usb_modules",
|
|
lambda: (usb_core, usb_util),
|
|
)
|
|
speech_driver.initialize(build_environment())
|
|
device.writes.clear()
|
|
device.first_normal_write_started.clear()
|
|
normal_thread = threading.Thread(
|
|
target=speech_driver._write_bytes,
|
|
args=(b"x" * 192, "speech"),
|
|
)
|
|
try:
|
|
normal_thread.start()
|
|
assert device.first_normal_write_started.wait(timeout=0.2)
|
|
speech_driver.cancel()
|
|
finally:
|
|
device.release_first_normal_write.set()
|
|
normal_thread.join(timeout=1.0)
|
|
speech_driver.shutdown()
|
|
|
|
normal_writes = [
|
|
payload for endpoint, payload, _ in device.writes if endpoint == 0x02
|
|
]
|
|
assert normal_writes == [b"x" * 64]
|
|
|
|
|
|
def test_dectalk_usb_driver_reports_missing_pyusb(monkeypatch):
|
|
speech_driver = dectalkUsbDriver.driver()
|
|
|
|
def missing_pyusb():
|
|
raise ImportError("No module named usb")
|
|
|
|
monkeypatch.setattr(speech_driver, "_load_usb_modules", missing_pyusb)
|
|
|
|
with pytest.raises(RuntimeError, match="PyUSB"):
|
|
speech_driver.initialize(build_environment())
|
|
|
|
|
|
def test_dectalk_usb_driver_reports_missing_libusb_backend(monkeypatch):
|
|
speech_driver = dectalkUsbDriver.driver()
|
|
backend_error = FakeUsbCore.NoBackendError("No backend available")
|
|
usb_core = FakeUsbCore(None, find_error=backend_error)
|
|
monkeypatch.setattr(
|
|
speech_driver,
|
|
"_load_usb_modules",
|
|
lambda: (usb_core, FakeUsbUtil()),
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="discovery failed.*backend"):
|
|
speech_driver.initialize(build_environment())
|
|
|
|
|
|
def test_dectalk_usb_driver_reports_missing_device(monkeypatch):
|
|
speech_driver = dectalkUsbDriver.driver()
|
|
usb_core = FakeUsbCore(None)
|
|
usb_util = FakeUsbUtil()
|
|
monkeypatch.setattr(
|
|
speech_driver,
|
|
"_load_usb_modules",
|
|
lambda: (usb_core, usb_util),
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="0dd0:2001"):
|
|
speech_driver.initialize(build_environment())
|
|
|
|
|
|
def test_dectalk_usb_driver_reports_interface_permission_error(monkeypatch):
|
|
speech_driver = dectalkUsbDriver.driver()
|
|
device = FakeUsbDevice()
|
|
usb_core = FakeUsbCore(device)
|
|
usb_util = FakeUsbUtil(claim_error=PermissionError("access denied"))
|
|
monkeypatch.setattr(
|
|
speech_driver,
|
|
"_load_usb_modules",
|
|
lambda: (usb_core, usb_util),
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="open failed.*access denied"):
|
|
speech_driver.initialize(build_environment())
|
|
|
|
|
|
def test_dectalk_usb_shutdown_survives_cleanup_errors(monkeypatch):
|
|
device = FakeUsbDevice()
|
|
usb_core = FakeUsbCore(device)
|
|
usb_util = FakeUsbUtil(
|
|
release_error=OSError("release failed"),
|
|
dispose_error=OSError("dispose failed"),
|
|
)
|
|
speech_driver = dectalkUsbDriver.driver()
|
|
monkeypatch.setattr(
|
|
speech_driver,
|
|
"_load_usb_modules",
|
|
lambda: (usb_core, usb_util),
|
|
)
|
|
speech_driver.initialize(build_environment())
|
|
|
|
speech_driver.shutdown()
|
|
|
|
assert not speech_driver._is_initialized
|
|
assert speech_driver.usb_device is None
|