Add application read models and thin TUI command adapter
This commit is contained in:
+493
-54
@@ -3,7 +3,10 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::net::IpAddr;
|
||||
|
||||
use crate::app::{Application, ClientDisplay};
|
||||
use crate::app::{
|
||||
ActionEligibility, Application, ApplicationView, ClientDisplay, FirstDeliveryEligibility,
|
||||
PeerRecord,
|
||||
};
|
||||
use crate::domain::{ApplyStatus, Client, PublicKey};
|
||||
use crate::runtime::RuntimeObservation;
|
||||
|
||||
@@ -56,45 +59,191 @@ pub struct SessionView {
|
||||
pub statistics: RuntimeObservation,
|
||||
pub has_draft: bool,
|
||||
pub vault_initialized: bool,
|
||||
/// Authoritative application facts used to present action availability.
|
||||
/// The parallel fields above are the navigation/rendering projection.
|
||||
pub application: Option<ApplicationView>,
|
||||
}
|
||||
|
||||
impl SessionView {
|
||||
pub fn from_application(app: &Application, generation: u64) -> Self {
|
||||
let states = app.states();
|
||||
let mut identities = BTreeSet::new();
|
||||
identities.extend(states.saved().clients().map(|client| client.identity));
|
||||
identities.extend(states.draft().clients().map(|client| client.identity));
|
||||
let client_states = identities
|
||||
.into_iter()
|
||||
.map(|identity| {
|
||||
(
|
||||
identity,
|
||||
ClientStateRow {
|
||||
saved: states.saved().get(&identity).cloned(),
|
||||
draft: states.draft().get(&identity).cloned(),
|
||||
},
|
||||
)
|
||||
})
|
||||
let application = app.read_model();
|
||||
let mut client_states = std::collections::BTreeMap::new();
|
||||
for row in &application.clients {
|
||||
if let Some(identity) = row.identity {
|
||||
if row.saved_client.is_none() && row.draft_client.is_none() {
|
||||
continue;
|
||||
}
|
||||
let state = client_states
|
||||
.entry(identity)
|
||||
.or_insert_with(|| ClientStateRow {
|
||||
saved: None,
|
||||
draft: None,
|
||||
});
|
||||
if state.saved.is_none() {
|
||||
state.saved.clone_from(&row.saved_client);
|
||||
}
|
||||
if state.draft.is_none() {
|
||||
state.draft.clone_from(&row.draft_client);
|
||||
}
|
||||
}
|
||||
}
|
||||
let clients = application
|
||||
.clients
|
||||
.iter()
|
||||
.filter_map(navigation_peer)
|
||||
.collect();
|
||||
let projection = app.display_projection();
|
||||
let saved_peer_count =
|
||||
crate::wireguard::ConfigDocument::parse(&app.saved_bytes().server_config)
|
||||
.ok()
|
||||
.map(|document| document.peers().len());
|
||||
Self {
|
||||
generation,
|
||||
clients: projection.clients,
|
||||
clients,
|
||||
client_states,
|
||||
saved_peer_count,
|
||||
document_issues: projection.document_issues,
|
||||
applied: *states.applied(),
|
||||
reserved_addresses: states.reserved_addresses().clone(),
|
||||
saved_peer_count: Some(application.saved_peer_count),
|
||||
document_issues: application.document_issues.clone(),
|
||||
applied: application.applied,
|
||||
reserved_addresses: application.reservations.committed.iter().copied().collect(),
|
||||
statistics: RuntimeObservation::unknown(),
|
||||
has_draft: app.has_draft_changes(),
|
||||
vault_initialized: app.vault_initialized(),
|
||||
has_draft: application.has_draft,
|
||||
vault_initialized: application.vault_initialized,
|
||||
application: Some(application),
|
||||
}
|
||||
}
|
||||
|
||||
fn application_peer(
|
||||
&self,
|
||||
identity: PublicKey,
|
||||
peer_index: Option<usize>,
|
||||
) -> Option<&PeerRecord> {
|
||||
let application = self.application.as_ref()?;
|
||||
let mut matching = application
|
||||
.clients
|
||||
.iter()
|
||||
.filter(|peer| peer.identity == Some(identity));
|
||||
let peer = matching.next()?;
|
||||
if matching.next().is_some() || peer.peer_index != peer_index {
|
||||
return None;
|
||||
}
|
||||
Some(peer)
|
||||
}
|
||||
|
||||
pub fn application_record_for_client(&self, client: &ClientDisplay) -> Option<&PeerRecord> {
|
||||
let identity = client.identity?;
|
||||
self.application_peer(identity, Some(client.peer_index))
|
||||
}
|
||||
|
||||
pub fn selected_rename_eligibility(&self, identity: PublicKey, peer_index: usize) -> bool {
|
||||
self.application_peer(identity, Some(peer_index))
|
||||
.is_some_and(|peer| peer.actions.rename.available)
|
||||
}
|
||||
|
||||
pub fn selected_delete_eligibility(&self, identity: PublicKey, peer_index: usize) -> bool {
|
||||
self.application_peer(identity, Some(peer_index))
|
||||
.is_some_and(|peer| peer.actions.delete.available)
|
||||
}
|
||||
|
||||
pub fn selected_first_delivery_eligibility(
|
||||
&self,
|
||||
identity: PublicKey,
|
||||
peer_index: usize,
|
||||
) -> bool {
|
||||
self.application_peer(identity, Some(peer_index))
|
||||
.is_some_and(|peer| {
|
||||
peer.actions.first_delivery != FirstDeliveryEligibility::Unavailable
|
||||
})
|
||||
}
|
||||
|
||||
pub fn selected_reissue_eligibility(&self, identity: PublicKey, peer_index: usize) -> bool {
|
||||
self.application_peer(identity, Some(peer_index))
|
||||
.is_some_and(|peer| peer.actions.reissue.available)
|
||||
}
|
||||
|
||||
pub fn restart_eligibility(&self) -> Option<&ActionEligibility> {
|
||||
self.application.as_ref().map(|view| &view.restart)
|
||||
}
|
||||
|
||||
pub fn is_capability_limitation_issue(
|
||||
&self,
|
||||
client: &ClientDisplay,
|
||||
issue: &crate::wireguard::PeerIssue,
|
||||
) -> bool {
|
||||
self.application
|
||||
.as_ref()
|
||||
.and_then(|application| {
|
||||
application.clients.iter().find(|peer| {
|
||||
peer.peer_index == Some(client.peer_index) && peer.identity == client.identity
|
||||
})
|
||||
})
|
||||
.and_then(|peer| {
|
||||
peer.classified_issues
|
||||
.iter()
|
||||
.find(|classified| &classified.issue == issue)
|
||||
})
|
||||
.map_or_else(
|
||||
|| client.is_capability_limitation_issue(issue),
|
||||
|classified| classified.class == crate::app::PeerIssueClass::UnsupportedCapability,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn presented_issues<'a>(
|
||||
&'a self,
|
||||
client: &'a ClientDisplay,
|
||||
) -> Vec<&'a crate::wireguard::PeerIssue> {
|
||||
client
|
||||
.issues
|
||||
.iter()
|
||||
.filter(|issue| !self.is_capability_limitation_issue(client, issue))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn client_state_change(
|
||||
&self,
|
||||
client: &ClientDisplay,
|
||||
) -> Option<crate::app::ClientStateChange> {
|
||||
self.application
|
||||
.as_ref()?
|
||||
.clients
|
||||
.iter()
|
||||
.find(|peer| {
|
||||
peer.peer_index == Some(client.peer_index) && peer.identity == client.identity
|
||||
})
|
||||
.map(|peer| peer.state_change)
|
||||
}
|
||||
|
||||
pub fn saved_managed_client_count(&self) -> usize {
|
||||
self.application.as_ref().map_or_else(
|
||||
|| {
|
||||
self.client_states
|
||||
.values()
|
||||
.filter(|row| row.saved.is_some())
|
||||
.count()
|
||||
},
|
||||
|application| application.saved_client_count,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn managed_change_count(&self) -> usize {
|
||||
self.application.as_ref().map_or_else(
|
||||
|| {
|
||||
self.client_states
|
||||
.values()
|
||||
.filter(|row| row.draft != row.saved)
|
||||
.count()
|
||||
},
|
||||
|application| {
|
||||
application
|
||||
.clients
|
||||
.iter()
|
||||
.filter(|peer| {
|
||||
matches!(
|
||||
peer.state_change,
|
||||
crate::app::ClientStateChange::Added
|
||||
| crate::app::ClientStateChange::Removed
|
||||
| crate::app::ClientStateChange::Changed
|
||||
)
|
||||
})
|
||||
.count()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn contains_identity(&self, identity: &PublicKey) -> bool {
|
||||
self.clients
|
||||
.iter()
|
||||
@@ -109,31 +258,60 @@ impl SessionView {
|
||||
.iter()
|
||||
.map(VisibleRecord::Peer)
|
||||
.collect::<Vec<_>>();
|
||||
records.extend(self.client_states.iter().filter_map(|(identity, row)| {
|
||||
let client = row.saved.as_ref()?;
|
||||
if row.draft.is_some()
|
||||
|| self
|
||||
.clients
|
||||
.iter()
|
||||
.any(|peer| peer.identity == Some(*identity))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(VisibleRecord::DraftRemoved {
|
||||
identity: *identity,
|
||||
client,
|
||||
})
|
||||
}));
|
||||
if let Some(application) = &self.application {
|
||||
records.extend(application.clients.iter().filter_map(|peer| {
|
||||
if peer.state_change != crate::app::ClientStateChange::Removed {
|
||||
return None;
|
||||
}
|
||||
Some(VisibleRecord::DraftRemoved {
|
||||
identity: peer.identity?,
|
||||
peer: Some(peer),
|
||||
client: peer.saved_client.as_ref(),
|
||||
})
|
||||
}));
|
||||
} else {
|
||||
records.extend(self.client_states.iter().filter_map(|(identity, row)| {
|
||||
let client = row.saved.as_ref()?;
|
||||
if row.draft.is_some()
|
||||
|| self
|
||||
.clients
|
||||
.iter()
|
||||
.any(|peer| peer.identity == Some(*identity))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(VisibleRecord::DraftRemoved {
|
||||
identity: *identity,
|
||||
peer: None,
|
||||
client: Some(client),
|
||||
})
|
||||
}));
|
||||
}
|
||||
records
|
||||
}
|
||||
}
|
||||
|
||||
fn navigation_peer(peer: &PeerRecord) -> Option<ClientDisplay> {
|
||||
let peer_index = peer.peer_index?;
|
||||
Some(ClientDisplay {
|
||||
peer_index,
|
||||
line: peer.line,
|
||||
identity: peer.identity,
|
||||
label: peer.label.clone(),
|
||||
allowed_ips: peer.allowed_ips.clone(),
|
||||
has_subnet_routes: peer.has_subnet_routes,
|
||||
issues: peer.issues.clone(),
|
||||
mutable_identity: peer.mutable_identity,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum VisibleRecord<'a> {
|
||||
Peer(&'a ClientDisplay),
|
||||
DraftRemoved {
|
||||
identity: PublicKey,
|
||||
client: &'a Client,
|
||||
peer: Option<&'a PeerRecord>,
|
||||
client: Option<&'a Client>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -248,13 +426,8 @@ impl UiState {
|
||||
self.select_first_record(view);
|
||||
return;
|
||||
}
|
||||
let removed = view.client_states.get(&identity).is_some_and(|row| {
|
||||
row.saved.is_some()
|
||||
&& row.draft.is_none()
|
||||
&& !view
|
||||
.clients
|
||||
.iter()
|
||||
.any(|client| client.identity == Some(identity))
|
||||
let removed = view.visible_records().iter().any(|record| {
|
||||
matches!(record, VisibleRecord::DraftRemoved { identity: removed, .. } if *removed == identity)
|
||||
});
|
||||
if removed {
|
||||
self.selected_peer_index = None;
|
||||
@@ -277,9 +450,9 @@ impl UiState {
|
||||
}
|
||||
|
||||
if self.selected_identity.is_some_and(|identity| {
|
||||
view.client_states
|
||||
.get(&identity)
|
||||
.is_some_and(|row| row.saved.is_some() && row.draft.is_none())
|
||||
view.visible_records().iter().any(|record| {
|
||||
matches!(record, VisibleRecord::DraftRemoved { identity: removed, .. } if *removed == identity)
|
||||
})
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
@@ -477,11 +650,75 @@ impl UiState {
|
||||
&& client.mutable_identity == Some(identity)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn selected_rename_available(&self) -> bool {
|
||||
self.view.as_ref().is_some_and(|view| {
|
||||
view.application.as_ref().map_or_else(
|
||||
|| self.selected_client_mutable(),
|
||||
|_| self.selected_peer_action(|peer| peer.actions.rename.available),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn selected_delete_available(&self) -> bool {
|
||||
self.view.as_ref().is_some_and(|view| {
|
||||
view.application.as_ref().map_or_else(
|
||||
|| self.selected_client_mutable(),
|
||||
|_| self.selected_peer_action(|peer| peer.actions.delete.available),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn selected_first_delivery_available(&self) -> bool {
|
||||
self.view.as_ref().is_some_and(|view| {
|
||||
view.application.as_ref().map_or_else(
|
||||
|| self.selected_client_is_saved(),
|
||||
|_| {
|
||||
self.selected_peer_action(|peer| {
|
||||
peer.actions.first_delivery != FirstDeliveryEligibility::Unavailable
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn selected_reissue_available(&self) -> bool {
|
||||
self.view.as_ref().is_some_and(|view| {
|
||||
view.application.as_ref().map_or_else(
|
||||
|| self.selected_client_is_saved(),
|
||||
|_| self.selected_peer_action(|peer| peer.actions.reissue.available),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn selected_client_is_saved(&self) -> bool {
|
||||
self.selected_identity.is_some_and(|identity| {
|
||||
self.view
|
||||
.as_ref()
|
||||
.and_then(|view| view.client_states.get(&identity))
|
||||
.is_some_and(|row| row.saved.is_some())
|
||||
})
|
||||
}
|
||||
|
||||
fn selected_peer_action(&self, action: impl FnOnce(&PeerRecord) -> bool) -> bool {
|
||||
let (Some(identity), Some(peer_index)) = (self.selected_identity, self.selected_peer_index)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
self.view
|
||||
.as_ref()
|
||||
.and_then(|view| view.application_peer(identity, Some(peer_index)))
|
||||
.is_some_and(action)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::{
|
||||
ActionEligibility, ActionUnavailableReason, ClientActions, ClientStateChange,
|
||||
FirstDeliveryEligibility, PeerIssueClass, ReservationFacts,
|
||||
};
|
||||
use crate::domain::PublicKey;
|
||||
use crate::wireguard::PeerIssue;
|
||||
|
||||
@@ -514,6 +751,72 @@ mod tests {
|
||||
statistics: RuntimeObservation::unknown(),
|
||||
has_draft: false,
|
||||
vault_initialized: true,
|
||||
application: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn application_peer(
|
||||
identity: PublicKey,
|
||||
peer_index: usize,
|
||||
actions: ClientActions,
|
||||
) -> PeerRecord {
|
||||
PeerRecord {
|
||||
peer_index: Some(peer_index),
|
||||
draft_peer_index: Some(peer_index),
|
||||
saved_peer_index: Some(peer_index),
|
||||
line: peer_index + 1,
|
||||
identity: Some(identity),
|
||||
label: "inspect-only peer".to_owned(),
|
||||
allowed_ips: vec!["10.0.0.2/32".to_owned()],
|
||||
has_subnet_routes: false,
|
||||
issues: vec![PeerIssue::MissingIpv4HostRoute],
|
||||
classified_issues: vec![crate::app::ClassifiedPeerIssue {
|
||||
issue: PeerIssue::MissingIpv4HostRoute,
|
||||
class: PeerIssueClass::MalformedOrAmbiguous,
|
||||
}],
|
||||
mutable_identity: None,
|
||||
draft_client: None,
|
||||
saved_client: None,
|
||||
state_change: ClientStateChange::UnsupportedOrUnmanaged,
|
||||
actions,
|
||||
}
|
||||
}
|
||||
|
||||
fn available_actions(delete: bool) -> ClientActions {
|
||||
let unavailable = ActionEligibility {
|
||||
available: false,
|
||||
reason: Some(ActionUnavailableReason::UnsafePeerOrDocument),
|
||||
};
|
||||
ClientActions {
|
||||
rename: unavailable.clone(),
|
||||
delete: if delete {
|
||||
ActionEligibility {
|
||||
available: true,
|
||||
reason: None,
|
||||
}
|
||||
} else {
|
||||
unavailable.clone()
|
||||
},
|
||||
first_delivery: FirstDeliveryEligibility::Unavailable,
|
||||
reissue: unavailable,
|
||||
}
|
||||
}
|
||||
|
||||
fn application_view(records: Vec<PeerRecord>) -> ApplicationView {
|
||||
ApplicationView {
|
||||
clients: records,
|
||||
document_issues: Vec::new(),
|
||||
saved_peer_count: 1,
|
||||
draft_client_count: 0,
|
||||
saved_client_count: 0,
|
||||
reservations: ReservationFacts::default(),
|
||||
applied: ApplyStatus::Unknown,
|
||||
vault_initialized: false,
|
||||
has_draft: false,
|
||||
restart: ActionEligibility {
|
||||
available: true,
|
||||
reason: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -598,6 +901,72 @@ mod tests {
|
||||
assert!(!state.selected_client_mutable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_eligibility_is_per_action_and_bound_to_the_selected_peer_position() {
|
||||
let identity = PublicKey::from_bytes([40; 32]);
|
||||
let mut peer = application_peer(identity, 6, available_actions(true));
|
||||
let mut session = view(
|
||||
0,
|
||||
vec![ClientDisplay {
|
||||
peer_index: 6,
|
||||
line: 7,
|
||||
identity: Some(identity),
|
||||
label: peer.label.clone(),
|
||||
allowed_ips: peer.allowed_ips.clone(),
|
||||
has_subnet_routes: false,
|
||||
issues: peer.issues.clone(),
|
||||
mutable_identity: None,
|
||||
}],
|
||||
);
|
||||
session.application = Some(application_view(vec![peer.clone()]));
|
||||
let mut state = UiState {
|
||||
view: Some(session),
|
||||
..UiState::default()
|
||||
};
|
||||
assert!(state.select_peer_index(6));
|
||||
assert!(!state.selected_client_mutable());
|
||||
assert!(!state.selected_rename_available());
|
||||
assert!(state.selected_delete_available());
|
||||
assert!(!state.selected_first_delivery_available());
|
||||
assert!(!state.selected_reissue_available());
|
||||
|
||||
state.selected_peer_index = Some(7);
|
||||
assert!(!state.selected_delete_available());
|
||||
|
||||
peer.peer_index = Some(7);
|
||||
if let Some(application) = state
|
||||
.view
|
||||
.as_mut()
|
||||
.and_then(|view| view.application.as_mut())
|
||||
{
|
||||
application.clients.push(peer);
|
||||
}
|
||||
state.selected_peer_index = Some(6);
|
||||
assert!(!state.selected_delete_available());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_peer_inspection_never_inherits_keyed_action_eligibility() {
|
||||
let identity = PublicKey::from_bytes([41; 32]);
|
||||
let keyed = application_peer(identity, 2, available_actions(true));
|
||||
let mut malformed = keyed.clone();
|
||||
malformed.peer_index = Some(9);
|
||||
malformed.draft_peer_index = Some(9);
|
||||
malformed.identity = None;
|
||||
let mut inspection = row(identity, "peer", false);
|
||||
inspection.peer_index = 9;
|
||||
inspection.identity = None;
|
||||
let mut state_view = view(0, vec![inspection]);
|
||||
state_view.application = Some(application_view(vec![keyed.clone(), malformed]));
|
||||
let mut state = UiState {
|
||||
view: Some(state_view),
|
||||
..UiState::default()
|
||||
};
|
||||
assert!(state.select_peer_index(9));
|
||||
assert_eq!(state.selected_identity, None);
|
||||
assert!(!state.selected_delete_available());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_saved_client_is_visible_and_never_mutable() {
|
||||
let key = PublicKey::from_bytes([13; 32]);
|
||||
@@ -635,6 +1004,76 @@ mod tests {
|
||||
assert_eq!(state.view.as_ref().unwrap().visible_records().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_model_removed_rows_remain_visible_without_state_map_reconstruction() {
|
||||
let identity = PublicKey::from_bytes([42; 32]);
|
||||
let removed = Client::new(
|
||||
identity,
|
||||
"removed by draft",
|
||||
crate::domain::ClientAddresses {
|
||||
ipv4: "10.0.0.42".parse().unwrap(),
|
||||
ipv6: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let mut peer = application_peer(identity, 0, available_actions(false));
|
||||
peer.peer_index = None;
|
||||
peer.draft_peer_index = None;
|
||||
peer.saved_client = Some(removed.clone());
|
||||
peer.state_change = ClientStateChange::Removed;
|
||||
let mut session = view(0, Vec::new());
|
||||
session.application = Some(application_view(vec![peer]));
|
||||
|
||||
let mut state = UiState::default();
|
||||
assert!(state.apply_view(session));
|
||||
assert_eq!(state.selected_identity, Some(identity));
|
||||
assert!(matches!(
|
||||
state.selected_record(),
|
||||
Some(VisibleRecord::DraftRemoved {
|
||||
identity: selected,
|
||||
client: Some(client),
|
||||
..
|
||||
}) if selected == identity && client == &removed
|
||||
));
|
||||
assert!(state.view.as_ref().unwrap().client_states.is_empty());
|
||||
assert!(!state.selected_delete_available());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_unprojectable_authoritative_peer_stays_selectable_without_actions() {
|
||||
let identity = PublicKey::from_bytes([43; 32]);
|
||||
let mut peer = application_peer(identity, 0, available_actions(true));
|
||||
peer.peer_index = None;
|
||||
peer.draft_peer_index = None;
|
||||
peer.saved_peer_index = Some(4);
|
||||
peer.label = "legacy route peer".to_owned();
|
||||
peer.allowed_ips = vec!["10.0.0.43/32".to_owned()];
|
||||
peer.saved_client = None;
|
||||
peer.state_change = ClientStateChange::Removed;
|
||||
let mut session = view(0, Vec::new());
|
||||
session.application = Some(application_view(vec![peer]));
|
||||
|
||||
let mut state = UiState::default();
|
||||
assert!(state.apply_view(session));
|
||||
assert_eq!(state.selected_identity, Some(identity));
|
||||
assert_eq!(state.selected_peer_index, None);
|
||||
assert!(matches!(
|
||||
state.selected_record(),
|
||||
Some(VisibleRecord::DraftRemoved {
|
||||
identity: selected,
|
||||
peer: Some(record),
|
||||
client: None,
|
||||
}) if selected == identity && record.label == "legacy route peer"
|
||||
));
|
||||
assert!(!state.selected_client_mutable());
|
||||
assert!(!state.selected_rename_available());
|
||||
assert!(!state.selected_delete_available());
|
||||
assert!(!state.selected_first_delivery_available());
|
||||
assert!(!state.selected_reissue_available());
|
||||
assert!(state.open_selected_record());
|
||||
assert_eq!(state.screen, Screen::ClientDetails);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_a_dialog_does_not_claim_submitted_work_was_cancelled() {
|
||||
let mut state = UiState {
|
||||
|
||||
Reference in New Issue
Block a user