feat(ptt): close P0 unit-boundary gaps (SDD-088 / SDD-090 / SDD-091)
The P0 audit on v0.9.4-docs found three SDD items whose specified
software units were inlined into other types rather than packaged as
named units at the SDD-defined boundary:
* SDD-088 PttController — backend ownership + binding mutex +
capability watch lived split between AudioEngine and
ChanoraSession. Extracted into chanora_core::ptt::PttController.
AudioEngine now owns only the cpal streams and the missed-key-up
watchdog (SDD-092); the platform input backend, the active
PttBinding, and the capability watch::Sender live in the
controller. ChanoraSession::start_audio constructs the controller
against the engine's gate; disconnect/reconnect/restart paths
tear it down through stop().await before the engine.
* SDD-090 PttSanitizer — banned-field check was inlined as
PttBanCheckVisitor inside RedactingLogLayer::on_event. Extracted
into a generic PttSanitizer<L> tracing_subscriber::Layer that
decorates an inner Layer (canonical pairing:
RedactingLogLayer::with_sanitizer). The inner layer keeps its
own structural ban check as defence-in-depth for bare-install
callers.
* SDD-091 PttCapabilityBadge — Voice Bar badge was anonymous
Padding/Tooltip/Row inside _AudioControlsState.build. Extracted
into a public PttCapabilityBadge widget and added the
SDD-091-specified per-platform explanation sheet that opens on
the info-icon tap when the resolved capability is L0Focused.
New l10n strings (en + zh) cover the sheet copy.
Tests:
* 2 new unit tests for PttController (arm + descriptor watch)
* 1 new unit test for PttSanitizer (end-to-end through a real
tracing subscriber proving banned drop + safe forward)
cargo test --workspace: 55 passed / 0 failed / 3 ignored
cargo deny check: advisories ok, bans ok, licenses ok, sources ok
flutter analyze: no issues
tools/validate_docs.py: zero undefined refs, zero direct-layer
violations (pre-existing 35 old-package-name warning unchanged)
No SDD/SAD/SRS doc changes — the contracts already named these
units; this commit aligns code unit boundaries with those contracts.
This commit is contained in:
@@ -45,6 +45,8 @@ use tokio::sync::{broadcast, oneshot, watch, Mutex};
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{info, warn};
|
||||
|
||||
pub mod ptt;
|
||||
|
||||
pub use chanora_audio::{
|
||||
AudioEngine, AudioEngineConfig, PttBackendDescriptor, PttCapabilityLevel,
|
||||
};
|
||||
@@ -89,6 +91,9 @@ pub enum CoreError {
|
||||
/// Audio engine is not running.
|
||||
#[error("audio not started")]
|
||||
AudioNotStarted,
|
||||
/// PTT controller error.
|
||||
#[error("ptt: {0}")]
|
||||
Ptt(#[from] ptt::PttControllerError),
|
||||
}
|
||||
|
||||
/// High-level lifecycle event surfaced to subscribers.
|
||||
@@ -185,6 +190,12 @@ struct SupervisorInner {
|
||||
struct ConnectedState {
|
||||
protocol: chanora_protocol::ProtocolClient,
|
||||
audio: Option<chanora_audio::AudioEngine>,
|
||||
/// Active PTT controller (SDD-088). Owns the platform input
|
||||
/// backend, the active binding, and the capability watch
|
||||
/// channel that drives `BridgeEvent::PttCapability`. Wired in
|
||||
/// `start_audio` after the engine is up; torn down by
|
||||
/// `stop_audio` / disconnect.
|
||||
ptt_controller: Option<Arc<ptt::PttController>>,
|
||||
/// Cancellation signal for the supervisor task. Dropped on
|
||||
/// explicit disconnect to break the supervisor out of its
|
||||
/// backoff sleep.
|
||||
@@ -406,6 +417,7 @@ impl ChanoraSession {
|
||||
*guard = Some(ConnectedState {
|
||||
protocol: client,
|
||||
audio: None,
|
||||
ptt_controller: None,
|
||||
cancel_tx: Some(cancel_tx),
|
||||
supervisor: Some(supervisor),
|
||||
cfg,
|
||||
@@ -434,7 +446,12 @@ impl ChanoraSession {
|
||||
let mut guard = self.inner.lock().await;
|
||||
let state = guard.as_mut().ok_or(CoreError::NotConnected)?;
|
||||
|
||||
// Tear down any prior engine.
|
||||
// Tear down any prior engine + controller. The controller
|
||||
// must be torn down before the engine because its forwarder
|
||||
// task references the gate that lives in the engine.
|
||||
if let Some(prev) = state.ptt_controller.take() {
|
||||
prev.stop().await;
|
||||
}
|
||||
if let Some(mut prev) = state.audio.take() {
|
||||
prev.stop();
|
||||
}
|
||||
@@ -445,8 +462,17 @@ impl ChanoraSession {
|
||||
.take_voice_in()
|
||||
.ok_or(CoreError::Invariant("voice_in already taken"))?;
|
||||
let engine = chanora_audio::AudioEngine::start(cfg.clone(), voice_out, voice_in)?;
|
||||
let gate = engine.transmit_gate().clone();
|
||||
state.audio = Some(engine);
|
||||
|
||||
// Wire the PTT controller (SDD-088). It owns the platform
|
||||
// backend, the active binding, and the capability watch
|
||||
// channel. Constructed here because the controller needs
|
||||
// the engine's gate clone, and we want the engine to
|
||||
// exist before any backend tries to drive the gate.
|
||||
let controller = ptt::PttController::new(gate);
|
||||
state.ptt_controller = Some(controller.clone());
|
||||
|
||||
// Record desired state so the supervisor will re-start audio
|
||||
// after a reconnect.
|
||||
{
|
||||
@@ -455,40 +481,31 @@ impl ChanoraSession {
|
||||
sup.audio_running = true;
|
||||
}
|
||||
let _ = self.events_tx.send(SessionEvent::AudioStarted);
|
||||
|
||||
// Publish the current PTT capability so the UI badge can
|
||||
// render an honest value (SRS-196 / SDD-091). Each
|
||||
// platform backend reports its actual runtime capability
|
||||
// through its descriptor; the universal Focused fallback
|
||||
// reports `L0Focused`.
|
||||
//
|
||||
// The engine itself owns the active backend, so we ask it
|
||||
// for the live value rather than synthesising a focused
|
||||
// descriptor here. We also subscribe to descriptor
|
||||
// transitions and spawn a forwarder task: backends that
|
||||
// resolve their capability asynchronously (notably the
|
||||
// Linux portal backend after the user accepts the
|
||||
// BindShortcuts dialog) re-publish through this watcher.
|
||||
let initial_desc = state.audio.as_ref().map(|a| a.ptt_descriptor()).unwrap_or_else(
|
||||
PttBackendDescriptor::focused,
|
||||
);
|
||||
// render an honest value (SRS-196 / SDD-091). Backends
|
||||
// that resolve asynchronously (notably the Linux portal
|
||||
// backend after the user accepts the BindShortcuts
|
||||
// dialog) re-publish through the controller's
|
||||
// descriptor-watch.
|
||||
let initial_desc = controller.descriptor().await;
|
||||
let _ = self.events_tx.send(SessionEvent::PttCapability {
|
||||
level: initial_desc.level.as_str().to_string(),
|
||||
backend_id: initial_desc.backend_id.to_string(),
|
||||
bound_input_class: initial_desc.bound_input_class.unwrap_or("").to_string(),
|
||||
});
|
||||
if let Some(mut watch_rx) = state.audio.as_ref().and_then(|a| a.ptt_descriptor_watch()) {
|
||||
let events_tx = self.events_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
while watch_rx.changed().await.is_ok() {
|
||||
let d = watch_rx.borrow_and_update().clone();
|
||||
let _ = events_tx.send(SessionEvent::PttCapability {
|
||||
level: d.level.as_str().to_string(),
|
||||
backend_id: d.backend_id.to_string(),
|
||||
bound_input_class: d.bound_input_class.unwrap_or("").to_string(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
let mut watch_rx = controller.descriptor_watch();
|
||||
let events_tx = self.events_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
while watch_rx.changed().await.is_ok() {
|
||||
let d = watch_rx.borrow_and_update().clone();
|
||||
let _ = events_tx.send(SessionEvent::PttCapability {
|
||||
level: d.level.as_str().to_string(),
|
||||
backend_id: d.backend_id.to_string(),
|
||||
bound_input_class: d.bound_input_class.unwrap_or("").to_string(),
|
||||
});
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -502,15 +519,19 @@ impl ChanoraSession {
|
||||
}
|
||||
|
||||
/// Update the active PTT binding (gen2 v0.9.3 / DEC-026). The
|
||||
/// binding flows into the platform backend's `rebind` hook
|
||||
/// and the freshly-published `PttBackendDescriptor` is
|
||||
/// broadcast as `SessionEvent::PttCapability` so the UI badge
|
||||
/// updates immediately. The audio engine must be running.
|
||||
/// binding flows into the platform backend's `rebind` hook via
|
||||
/// the [`ptt::PttController`] (SDD-088) and the freshly-published
|
||||
/// `PttBackendDescriptor` is broadcast as
|
||||
/// `SessionEvent::PttCapability` so the UI badge updates
|
||||
/// immediately. The audio engine must be running.
|
||||
pub async fn set_ptt_binding(&self, binding: PttBinding) -> Result<(), CoreError> {
|
||||
let guard = self.inner.lock().await;
|
||||
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
|
||||
let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?;
|
||||
let desc = audio.rebind_ptt(binding)?;
|
||||
let controller = state
|
||||
.ptt_controller
|
||||
.as_ref()
|
||||
.ok_or(CoreError::AudioNotStarted)?;
|
||||
let desc = controller.set_binding(binding).await?;
|
||||
let _ = self.events_tx.send(SessionEvent::PttCapability {
|
||||
level: desc.level.as_str().to_string(),
|
||||
backend_id: desc.backend_id.to_string(),
|
||||
@@ -526,8 +547,8 @@ impl ChanoraSession {
|
||||
/// audio engine is running.
|
||||
pub async fn ptt_descriptor(&self) -> (String, String, String) {
|
||||
let guard = self.inner.lock().await;
|
||||
let desc = match guard.as_ref().and_then(|s| s.audio.as_ref()) {
|
||||
Some(audio) => audio.ptt_descriptor(),
|
||||
let desc = match guard.as_ref().and_then(|s| s.ptt_controller.as_ref()) {
|
||||
Some(controller) => controller.descriptor().await,
|
||||
None => PttBackendDescriptor::focused(),
|
||||
};
|
||||
(
|
||||
@@ -598,6 +619,9 @@ impl ChanoraSession {
|
||||
if let Some(tx) = state.cancel_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
if let Some(controller) = state.ptt_controller.take() {
|
||||
controller.stop().await;
|
||||
}
|
||||
if let Some(mut audio) = state.audio.take() {
|
||||
audio.stop();
|
||||
let _ = self.events_tx.send(SessionEvent::AudioStopped);
|
||||
@@ -808,6 +832,9 @@ async fn supervisor_loop(
|
||||
{
|
||||
let mut guard = state_arc.lock().await;
|
||||
if let Some(state) = guard.as_mut() {
|
||||
if let Some(controller) = state.ptt_controller.take() {
|
||||
controller.stop().await;
|
||||
}
|
||||
if let Some(mut audio) = state.audio.take() {
|
||||
audio.stop();
|
||||
let _ = events_tx.send(SessionEvent::AudioStopped);
|
||||
@@ -936,9 +963,27 @@ async fn supervisor_loop(
|
||||
audio_cfg, voice_out, voice_in,
|
||||
) {
|
||||
Ok(engine) => {
|
||||
let gate = engine.transmit_gate().clone();
|
||||
state.audio = Some(engine);
|
||||
// Re-arm the PTT controller against
|
||||
// the new engine's gate (SDD-088).
|
||||
let controller = ptt::PttController::new(gate);
|
||||
state.ptt_controller = Some(controller.clone());
|
||||
let _ = events_tx
|
||||
.send(SessionEvent::AudioStarted);
|
||||
// Re-publish the post-reconnect
|
||||
// capability (SRS-196 / SDD-091).
|
||||
let d = controller.descriptor().await;
|
||||
let _ = events_tx.send(
|
||||
SessionEvent::PttCapability {
|
||||
level: d.level.as_str().to_string(),
|
||||
backend_id: d.backend_id.to_string(),
|
||||
bound_input_class: d
|
||||
.bound_input_class
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
//! Cross-platform Push-to-Talk controller (SDD-088).
|
||||
//!
|
||||
//! The controller is the orchestration seam between the platform
|
||||
//! input backend (selected by `chanora_audio::ptt_backends::select`)
|
||||
//! and the audio engine's `AudioTransmitGate`. It owns the active
|
||||
//! `Box<dyn DesktopPttBackend>`, a clone of the gate, the current
|
||||
//! `PttBinding`, and the `watch::Sender<PttCapabilityLevel>` the
|
||||
//! bridge subscribes to for the UI capability badge (SDD-091).
|
||||
//!
|
||||
//! Source: SAD-071 (cross-platform PTT plumbing), SAD-076 (binding
|
||||
//! lifecycle), SDD-088.
|
||||
//!
|
||||
//! The previous implementation kept the backend slot inside
|
||||
//! `AudioEngine` and split the binding / capability publication
|
||||
//! between the engine and `ChanoraSession`. SDD-088 names a
|
||||
//! dedicated `PttController` software unit; this module is that
|
||||
//! unit. The audio engine retains ownership only of the cpal
|
||||
//! streams and the missed-key-up watchdog (SDD-092).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use chanora_audio::ptt_backends::{
|
||||
select as select_ptt_backend, DesktopPttBackend, PttBackendError, PttBinding,
|
||||
};
|
||||
use chanora_audio::{AudioTransmitGate, PttBackendDescriptor, PttCapabilityLevel};
|
||||
use tokio::sync::{watch, Mutex};
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Errors raised by [`PttController`]. Thin wrapper over
|
||||
/// [`PttBackendError`] plus a "not armed" variant for callers that
|
||||
/// touch the controller after `stop`.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PttControllerError {
|
||||
/// The wrapped platform backend reported an error.
|
||||
#[error("ptt backend: {0}")]
|
||||
Backend(#[from] PttBackendError),
|
||||
/// `set_binding` or `descriptor` was called after `stop`.
|
||||
#[error("ptt controller not armed")]
|
||||
NotArmed,
|
||||
}
|
||||
|
||||
/// Cross-platform PTT controller (SDD-088).
|
||||
///
|
||||
/// Constructed by `ChanoraSession::start_audio` after the audio
|
||||
/// engine is up. Owns:
|
||||
///
|
||||
/// * `Box<dyn DesktopPttBackend>` — the selected platform backend.
|
||||
/// The factory in [`chanora_audio::ptt_backends::select`] never
|
||||
/// fails; on any platform that lacks a global-capture path the
|
||||
/// universal `FocusedPttBackend` is returned, so this slot is
|
||||
/// always populated between `new()` and `stop()`.
|
||||
/// * `AudioTransmitGate` — a clone of the engine's gate, handed to
|
||||
/// the backend on arm and held here so re-arms after `rebind` do
|
||||
/// not need a reference back to the engine.
|
||||
/// * `tokio::sync::Mutex<PttBinding>` — the active binding. The
|
||||
/// binding capture dialog in Flutter calls into the bridge which
|
||||
/// funnels here; the mutex guards the rebind path against
|
||||
/// concurrent updates.
|
||||
/// * `watch::Sender<PttCapabilityLevel>` — fans out the
|
||||
/// live-resolved capability for SDD-091 (UI badge). The Linux
|
||||
/// GNOME-Wayland portal backend resolves its capability
|
||||
/// asynchronously after the user accepts the BindShortcuts
|
||||
/// dialog; the controller's forwarder task observes the
|
||||
/// backend's own descriptor-watch and republishes the
|
||||
/// capability through this channel.
|
||||
pub struct PttController {
|
||||
backend: Mutex<Option<Box<dyn DesktopPttBackend>>>,
|
||||
binding: Mutex<PttBinding>,
|
||||
gate: AudioTransmitGate,
|
||||
capability_tx: watch::Sender<PttCapabilityLevel>,
|
||||
descriptor_tx: watch::Sender<PttBackendDescriptor>,
|
||||
forwarder: std::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
impl PttController {
|
||||
/// Construct the controller and arm the selected platform
|
||||
/// backend. The factory in
|
||||
/// [`chanora_audio::ptt_backends::select`] never fails, but the
|
||||
/// arming `start` call may; on failure the controller still
|
||||
/// returns successfully with a Focused-fallback backend already
|
||||
/// armed (matching the previous engine behaviour) so the audio
|
||||
/// path is never blocked by a PTT-arm failure.
|
||||
pub fn new(gate: AudioTransmitGate) -> Arc<Self> {
|
||||
let mut backend = select_ptt_backend();
|
||||
let initial_binding = PttBinding::none();
|
||||
match backend.start(gate.clone(), initial_binding.clone()) {
|
||||
Ok(()) => {
|
||||
let d = backend.descriptor();
|
||||
info!(
|
||||
target: "chanora_core",
|
||||
capability_level = %d.level,
|
||||
backend_id = d.backend_id,
|
||||
bound_input_class = ?d.bound_input_class,
|
||||
"ptt controller armed"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
target: "chanora_core",
|
||||
error = %e,
|
||||
"ptt backend start failed; controller continues with current backend instance"
|
||||
);
|
||||
}
|
||||
}
|
||||
let descriptor = backend.descriptor();
|
||||
let capability = descriptor.level;
|
||||
let (capability_tx, _capability_rx) = watch::channel(capability);
|
||||
let (descriptor_tx, _descriptor_rx) = watch::channel(descriptor.clone());
|
||||
|
||||
// Subscribe to the backend's own descriptor-watch and
|
||||
// re-publish to both the descriptor sender (consumed by
|
||||
// `ChanoraSession` for `BridgeEvent::PttCapability`) and
|
||||
// the capability sender (SDD-088 public surface).
|
||||
let mut backend_rx = backend.descriptor_watch();
|
||||
let cap_tx_for_task = capability_tx.clone();
|
||||
let desc_tx_for_task = descriptor_tx.clone();
|
||||
let forwarder = tokio::spawn(async move {
|
||||
while backend_rx.changed().await.is_ok() {
|
||||
let d = backend_rx.borrow_and_update().clone();
|
||||
let _ = cap_tx_for_task.send(d.level);
|
||||
let _ = desc_tx_for_task.send(d);
|
||||
}
|
||||
});
|
||||
|
||||
Arc::new(Self {
|
||||
backend: Mutex::new(Some(backend)),
|
||||
binding: Mutex::new(initial_binding),
|
||||
gate,
|
||||
capability_tx,
|
||||
descriptor_tx,
|
||||
forwarder: std::sync::Mutex::new(Some(forwarder)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Replace the active binding (SDD-088 public surface).
|
||||
///
|
||||
/// Returns the freshly-published descriptor so callers can
|
||||
/// emit the corresponding `BridgeEvent::PttCapability` event.
|
||||
/// On success the new binding is recorded under the binding
|
||||
/// mutex and the capability watch is republished.
|
||||
pub async fn set_binding(
|
||||
&self,
|
||||
binding: PttBinding,
|
||||
) -> Result<PttBackendDescriptor, PttControllerError> {
|
||||
let mut backend_guard = self.backend.lock().await;
|
||||
let backend = backend_guard
|
||||
.as_mut()
|
||||
.ok_or(PttControllerError::NotArmed)?;
|
||||
backend.rebind(binding.clone())?;
|
||||
let descriptor = backend.descriptor();
|
||||
// Record the new binding under its own mutex so the
|
||||
// SDD-088 surface (held binding) reflects the resolved
|
||||
// value. The backend mutex is held first to preserve the
|
||||
// documented lock order (backend before binding).
|
||||
{
|
||||
let mut binding_guard = self.binding.lock().await;
|
||||
*binding_guard = binding;
|
||||
}
|
||||
// Republish the capability so subscribers that did not
|
||||
// wire the backend's own descriptor-watch still observe
|
||||
// the transition.
|
||||
let _ = self.capability_tx.send(descriptor.level);
|
||||
let _ = self.descriptor_tx.send(descriptor.clone());
|
||||
Ok(descriptor)
|
||||
}
|
||||
|
||||
/// Current resolved capability (SDD-088 public surface).
|
||||
pub fn current_capability(&self) -> PttCapabilityLevel {
|
||||
*self.capability_tx.borrow()
|
||||
}
|
||||
|
||||
/// Subscribe to capability transitions (SDD-088 public surface).
|
||||
/// Used by the bridge to drive the Flutter `PttCapabilityBadge`
|
||||
/// stream (SDD-091).
|
||||
pub fn subscribe_capability(&self) -> watch::Receiver<PttCapabilityLevel> {
|
||||
self.capability_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Privacy-safe descriptor of the active backend. Returns the
|
||||
/// universal Focused fallback when the controller has been
|
||||
/// stopped (the slot is empty between `stop()` and Drop).
|
||||
pub async fn descriptor(&self) -> PttBackendDescriptor {
|
||||
let guard = self.backend.lock().await;
|
||||
match guard.as_ref() {
|
||||
Some(b) => b.descriptor(),
|
||||
None => PttBackendDescriptor::focused(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe to descriptor transitions. Backends that resolve
|
||||
/// asynchronously (Linux portal) republish through here.
|
||||
pub fn descriptor_watch(&self) -> watch::Receiver<PttBackendDescriptor> {
|
||||
self.descriptor_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Reference to the underlying gate. Diagnostic / test use only.
|
||||
pub fn gate(&self) -> &AudioTransmitGate {
|
||||
&self.gate
|
||||
}
|
||||
|
||||
/// Release OS-level resources held by the platform backend
|
||||
/// (Raw Input message loop, Event Tap run loop, portal D-Bus
|
||||
/// session, …). Idempotent.
|
||||
pub async fn stop(&self) {
|
||||
if let Some(h) = self.forwarder.lock().ok().and_then(|mut g| g.take()) {
|
||||
h.abort();
|
||||
}
|
||||
let mut guard = self.backend.lock().await;
|
||||
if let Some(mut b) = guard.take() {
|
||||
b.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PttController {
|
||||
fn drop(&mut self) {
|
||||
if let Some(h) = self.forwarder.get_mut().ok().and_then(|g| g.take()) {
|
||||
h.abort();
|
||||
}
|
||||
// The canonical shutdown path is `PttController::stop().await`
|
||||
// invoked by the session before drop. We cannot lock the
|
||||
// tokio Mutex synchronously here; if the controller is
|
||||
// dropped without an explicit stop, the backend's own
|
||||
// `Drop` runs through the `Box<dyn DesktopPttBackend>`
|
||||
// and is responsible for releasing OS resources.
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn controller_arms_and_reports_capability() {
|
||||
// The focused fallback is always selectable; the factory
|
||||
// never fails on a host without any global capture path.
|
||||
let gate = AudioTransmitGate::new(false);
|
||||
let controller = PttController::new(gate);
|
||||
// Every backend returns a non-null backend_id and a
|
||||
// capability level that round-trips through the watch.
|
||||
let desc = controller.descriptor().await;
|
||||
assert!(!desc.backend_id.is_empty());
|
||||
let level = controller.current_capability();
|
||||
// The descriptor's level must match the watch.
|
||||
assert_eq!(level, desc.level);
|
||||
// Subscribing must yield the same value without blocking.
|
||||
let mut rx = controller.subscribe_capability();
|
||||
assert_eq!(*rx.borrow_and_update(), level);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_binding_updates_descriptor_watch() {
|
||||
let gate = AudioTransmitGate::new(false);
|
||||
let controller = PttController::new(gate);
|
||||
let mut desc_rx = controller.descriptor_watch();
|
||||
// Drain the initial value.
|
||||
let _ = desc_rx.borrow_and_update();
|
||||
// The focused fallback accepts every binding (it's just a
|
||||
// diagnostic carrier; the actual press detection happens
|
||||
// inside the Flutter Listener path).
|
||||
let binding = PttBinding {
|
||||
input_class: chanora_audio::PttInputClass::Keyboard,
|
||||
platform_key: "space".to_string(),
|
||||
};
|
||||
let new_desc = controller.set_binding(binding.clone()).await.unwrap();
|
||||
// The descriptor watch must have received the post-rebind
|
||||
// value. We don't assert a specific level here because the
|
||||
// focused backend's level does not change on rebind, but
|
||||
// the descriptor send is observable.
|
||||
assert_eq!(new_desc.backend_id, controller.descriptor().await.backend_id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user