feat(tui): reconcile scoped selection and publication lifetimes
Reuse canonical escaped absolute reference labels; retain structured operation selection across loading and row movement. Freeze editor targets and retire confirmation on Client observation invalidation. Mirror retained stale/loading Object, tree and Interface state without ref fabrication or automatic execution. Validation: 36 TUI unit tests, real filesystem HTTP workflow, 8 formatter and 6 asynchronous runtime tests, and 2 doc-tests pass. Full workspace fmt/check/all-target tests/doc-tests, Nix clippy -D warnings, rustdoc -D warnings, public example and diff check pass. Scoped regressions cover same-name dispatch, stale/ref-less/replaced/deleted scopes, old in-flight contexts and selective typed-entry observation.
This commit is contained in:
@@ -91,10 +91,29 @@ these labels. The output has no extra outer signature string quoting.
|
||||
Keep selection, cache and calls keyed by structured references and operation
|
||||
names, never display text. No parser, scope inference, identity reconstruction,
|
||||
Object/Interface signature renderer or new crate is introduced. Membership,
|
||||
ancestry, authorization and freshness remain runtime concerns. Screen integration
|
||||
is follow-up T-23; the current bounded screen/value/diagnostic rendering is
|
||||
unchanged. At integration, apply existing UI layout bounds to display labels
|
||||
without feeding shortened labels back into identity or calls.
|
||||
ancestry, authorization and freshness remain runtime concerns. Normal Entry,
|
||||
Interface/Operation, editor, confirmation and operation-history screens use these
|
||||
qualified labels with existing bounded layout. Shortened labels never feed back
|
||||
into identity or calls.
|
||||
|
||||
### Rust API and wire migration
|
||||
|
||||
Interface identities in the presentation map, action/selection rows, in-flight
|
||||
subjects and editors are now `InterfaceReference { scope, name }`, not strings.
|
||||
This is a breaking Rust API change; consumers must construct an explicit canonical
|
||||
absolute scope rather than parse a label or infer a scope from a target Entry.
|
||||
`ArgumentEditor` also retains its Entry `path`; `new` defaults to `/`, and
|
||||
`for_target(path)` fixes the chosen target before editing. Normal UI selection
|
||||
sets it automatically. Confirmation never retargets an existing editor.
|
||||
`App::operation_selection` retains the Entry and structured row through temporary
|
||||
loading gaps; the numeric cursor is only a layout position. Retaining this subject
|
||||
does not expose stale operations or authorize execution.
|
||||
|
||||
The TUI uses the scoped Protocol/HTTP Client wire: Interface references are
|
||||
structured, and calls use an Interface target carrying the reference and optional
|
||||
`scope_ref`/validator preconditions. The optional identity is copied by the Client,
|
||||
not invented by the UI; no additional required ref is introduced. This wire is not
|
||||
compatible with the former string-reference request/response shape.
|
||||
|
||||
## Keyboard bindings
|
||||
|
||||
@@ -128,10 +147,12 @@ a second explicit Enter (or `e`) to execute once. There is no operation auto-ret
|
||||
interfaces, and freshness. Validators, credentials, Host internals, and backend
|
||||
paths are deliberately hidden. If current metadata disappears, old text is not
|
||||
presented as current.
|
||||
- **Interfaces / Operations** identifies every operation by `(interface scope,
|
||||
interface name, operation name)` and explicitly displays `scope=…` and `name=…`.
|
||||
Structured references remain intact in selection, acquisition, and call state;
|
||||
display labels are never parsed back into references. Descriptor lookahead follows fresh visible Objects, shares the
|
||||
- **Interfaces / Operations** identifies every operation by structured `(scope,
|
||||
interface name, operation name)`, displaying `scope::interface#operation` with
|
||||
explicit absolute scope and safely quoted components. Structured references
|
||||
remain intact in selection, acquisition, and call state; display labels are
|
||||
never parsed back into references. Descriptor lookahead follows fresh visible
|
||||
Objects, shares the
|
||||
client's in-flight/cache bounds, and never retries retained failures implicitly.
|
||||
The pane shows descriptor documentation, ordered parameters, required/optional
|
||||
status, and result types.
|
||||
@@ -158,6 +179,22 @@ Validator mismatch, interface mismatch, `OperationOutcomeUnknown`, undecodable
|
||||
post-dispatch responses, and post-dispatch timeout/disconnect are displayed using
|
||||
the runtime's authoritative transition.
|
||||
|
||||
Every Client transition is mirrored into retained UI Object, tree and Interface
|
||||
state, including scope retirement and cache removal. An open editor/confirmation
|
||||
is bound to private Object/Interface snapshots (not displayed refs/validators).
|
||||
Changed, stale, loading, removed or retargeted observations retire the editor;
|
||||
reobservation never recreates or executes it. Descriptor rows remain identified
|
||||
by the structured reference when another Interface gains or loses operation rows.
|
||||
Explicitly select/load an Interface header to refresh a retained stale descriptor,
|
||||
then select an operation and confirm a new call. Client calls already dispatched
|
||||
retain their original immutable context and are never moved to a new scope.
|
||||
|
||||
Without refs, a same-path replacement with indistinguishable public metadata
|
||||
cannot always be detected; the UI does not synthesize identity or infer deletion
|
||||
from missing tree edges. Optional refs remain optional. Typed `Entry` results are
|
||||
only observed when individually selected; the UI neither bulk-fetches result
|
||||
paths nor adds guessed parent/indexable edges.
|
||||
|
||||
For an unknown outcome, the UI states that execution or side effects may have
|
||||
occurred, marks the related Entry stale, and tells the user to refresh. Refreshing
|
||||
does not recreate the call: the user must select the operation, review fresh input,
|
||||
|
||||
@@ -280,6 +280,9 @@ pub struct App {
|
||||
pub interfaces: BTreeMap<InterfaceReference, InterfaceView>,
|
||||
/// Selected row in the flattened interface/operation view.
|
||||
pub operation_cursor: usize,
|
||||
/// Retained Entry and structured row while current operation rows are hidden.
|
||||
/// This is selection identity only, never authority to execute a stale row.
|
||||
pub operation_selection: Option<(String, OperationRow)>,
|
||||
/// Selected recent-history row.
|
||||
pub history_cursor: usize,
|
||||
/// Active overlay.
|
||||
@@ -310,6 +313,7 @@ impl App {
|
||||
focus: Focus::Tree,
|
||||
interfaces: BTreeMap::new(),
|
||||
operation_cursor: 0,
|
||||
operation_selection: None,
|
||||
history_cursor: 0,
|
||||
modal: Modal::None,
|
||||
in_flight: BTreeMap::new(),
|
||||
@@ -362,7 +366,7 @@ impl App {
|
||||
}
|
||||
InputKey::Enter | InputKey::Character('e') => {
|
||||
let action = Action::Execute {
|
||||
path: self.selected_path.clone(),
|
||||
path: editor.path.clone(),
|
||||
interface: editor.interface.clone(),
|
||||
operation: editor.operation.name.clone(),
|
||||
arguments,
|
||||
@@ -464,17 +468,21 @@ impl App {
|
||||
if let Some(row) = rows.get(self.operation_cursor) {
|
||||
if let Some(operation_name) = &row.operation {
|
||||
if let Some(interface) = self.interfaces.get(&row.interface)
|
||||
&& interface.state == ViewState::Fresh
|
||||
&& let Some(descriptor) = &interface.descriptor
|
||||
&& let Some(operation) = descriptor
|
||||
.operations
|
||||
.iter()
|
||||
.find(|operation| operation.name == *operation_name)
|
||||
{
|
||||
self.modal = Modal::Editor(ArgumentEditor::new(
|
||||
row.interface.clone(),
|
||||
descriptor.clone(),
|
||||
operation.clone(),
|
||||
));
|
||||
self.modal = Modal::Editor(
|
||||
ArgumentEditor::new(
|
||||
row.interface.clone(),
|
||||
descriptor.clone(),
|
||||
operation.clone(),
|
||||
)
|
||||
.for_target(self.selected_path.clone()),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return vec![Action::FetchInterface(row.interface.clone())];
|
||||
@@ -515,6 +523,10 @@ impl App {
|
||||
|
||||
/// Records a pending request separately by interaction type.
|
||||
pub fn request_started(&mut self, id: u64, kind: InFlightKind) {
|
||||
// Object/Interface loading can remove all rows before completion. Keep
|
||||
// the exact subject now rather than recovering identity from a cursor.
|
||||
let selected = self.operation_rows().get(self.operation_cursor).cloned();
|
||||
self.restore_operation_selection(selected);
|
||||
match &kind {
|
||||
InFlightKind::Object(path) => {
|
||||
self.ensure_entry(path).state = ViewState::Loading(id);
|
||||
@@ -683,6 +695,37 @@ impl App {
|
||||
rows
|
||||
}
|
||||
|
||||
/// Preserves a selected structured subject across descriptor/row movement.
|
||||
pub(crate) fn restore_operation_selection(&mut self, selected: Option<OperationRow>) {
|
||||
if let Some(selected) = selected {
|
||||
self.operation_selection = Some((self.selected_path.clone(), selected));
|
||||
} else if self
|
||||
.operation_selection
|
||||
.as_ref()
|
||||
.is_some_and(|(path, _)| path != &self.selected_path)
|
||||
{
|
||||
self.operation_selection = None;
|
||||
}
|
||||
let rows = self.operation_rows();
|
||||
self.operation_cursor = self
|
||||
.operation_selection
|
||||
.as_ref()
|
||||
.and_then(|(_, selected)| {
|
||||
rows.iter().position(|row| row == selected).or_else(|| {
|
||||
rows.iter().position(|row| {
|
||||
row.interface == selected.interface && row.operation.is_none()
|
||||
})
|
||||
})
|
||||
})
|
||||
.unwrap_or(0);
|
||||
// Keep identity through a temporarily empty (loading/stale) view. Once
|
||||
// rows are current, the retained identity follows the actual selection,
|
||||
// falling back to its own Interface header if the Operation was retired.
|
||||
if let Some(row) = rows.get(self.operation_cursor) {
|
||||
self.operation_selection = Some((self.selected_path.clone(), row.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_entry(&mut self, path: &str) -> &mut EntryView {
|
||||
if !self.entries.contains_key(path) && self.entries.len() >= MAX_PRESENTATION_ENTRIES {
|
||||
let candidate = self
|
||||
|
||||
@@ -32,6 +32,8 @@ pub struct InvalidArguments;
|
||||
/// Pure, terminal-independent editor built from an operation descriptor.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ArgumentEditor {
|
||||
/// Exact Entry target selected when editing began; never read from display text.
|
||||
pub path: String,
|
||||
/// Structured interface reference selected for the call.
|
||||
pub interface: InterfaceReference,
|
||||
/// Selected operation declaration.
|
||||
@@ -68,6 +70,7 @@ impl ArgumentEditor {
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
path: "/".into(),
|
||||
interface,
|
||||
operation,
|
||||
descriptor,
|
||||
@@ -76,6 +79,13 @@ impl ArgumentEditor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixes the Entry target for the editor and subsequent confirmation.
|
||||
#[must_use]
|
||||
pub fn for_target(mut self, path: impl Into<String>) -> Self {
|
||||
self.path = path.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Moves to the preceding field without wrapping.
|
||||
pub fn previous_field(&mut self) {
|
||||
self.cursor = self.cursor.saturating_sub(1);
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
//! Presentation and terminal interaction for stateful WIP client sessions.
|
||||
//!
|
||||
//! Interface identity is a structured `wip_protocol::InterfaceReference` with an
|
||||
//! explicit absolute scope. Display-only [`mod@reference`] labels safely quote
|
||||
//! `scope::interface#operation`; shortened labels are never parsed for calls.
|
||||
//! This breaks the former string-reference Rust API and follows the scoped HTTP
|
||||
//! wire format. Ref and validator preconditions remain private Client metadata.
|
||||
//! Editors retain their selected Entry target and are retired when the Client's
|
||||
//! Object/Interface snapshots change; reobservation never executes an operation.
|
||||
|
||||
#![deny(missing_docs)]
|
||||
|
||||
|
||||
+118
-18
@@ -11,11 +11,22 @@ use crate::editor::type_name;
|
||||
|
||||
const MAX_RENDER_TEXT: usize = 512;
|
||||
|
||||
fn interface_label(reference: &wip_protocol::InterfaceReference) -> String {
|
||||
format!(
|
||||
"scope={}, name={}",
|
||||
sanitize(&reference.scope),
|
||||
sanitize(&reference.name)
|
||||
/// Bounded presentation only; invalid references are never repaired or inferred.
|
||||
pub(crate) fn interface_label(reference: &wip_protocol::InterfaceReference) -> String {
|
||||
sanitize(
|
||||
&crate::reference::format_interface_reference(reference)
|
||||
.unwrap_or_else(|_| "(invalid interface reference)".into()),
|
||||
)
|
||||
}
|
||||
|
||||
/// The exact structured subject remains separate from this shortened label.
|
||||
pub(crate) fn operation_label(
|
||||
reference: &wip_protocol::InterfaceReference,
|
||||
operation: &str,
|
||||
) -> String {
|
||||
sanitize(
|
||||
&crate::reference::format_qualified_operation(reference, operation)
|
||||
.unwrap_or_else(|_| "(invalid operation reference)".into()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -232,7 +243,7 @@ fn operation_line(app: &App, row: &OperationRow) -> (Line<'static>, Style) {
|
||||
.find(|operation| operation.name == *name)
|
||||
});
|
||||
let signature = declaration.map_or_else(
|
||||
|| format!(" ({}, {})", interface_label(&row.interface), name),
|
||||
|| format!(" {}", operation_label(&row.interface, name)),
|
||||
|operation| {
|
||||
let parameters = operation
|
||||
.parameters
|
||||
@@ -253,9 +264,8 @@ fn operation_line(app: &App, row: &OperationRow) -> (Line<'static>, Style) {
|
||||
.map(|documentation| format!(" — {}", documentation.summary))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
" ({}, {}) ({parameters}) -> {}{}",
|
||||
interface_label(&row.interface),
|
||||
name,
|
||||
" {} ({parameters}) -> {}{}",
|
||||
operation_label(&row.interface, name),
|
||||
type_name(&operation.returns.r#type),
|
||||
documentation
|
||||
)
|
||||
@@ -361,10 +371,9 @@ fn render_modal(frame: &mut Frame<'_>, app: &App) {
|
||||
let area = centered(frame.area(), 86, 78);
|
||||
frame.render_widget(Clear, area);
|
||||
let mut lines = vec![Line::from(format!(
|
||||
"Target: {} Operation: ({}, {})",
|
||||
sanitize(&app.selected_path),
|
||||
interface_label(&editor.interface),
|
||||
sanitize(&editor.operation.name)
|
||||
"Target: {} Operation: {}",
|
||||
sanitize(&editor.path),
|
||||
operation_label(&editor.interface, &editor.operation.name)
|
||||
))];
|
||||
if editor.fields.is_empty() {
|
||||
lines.push(Line::from("(no parameters)"));
|
||||
@@ -412,9 +421,12 @@ fn render_modal(frame: &mut Frame<'_>, app: &App) {
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Line::from(format!("Entry: {}", sanitize(&app.selected_path))),
|
||||
Line::from(format!("Entry: {}", sanitize(&editor.path))),
|
||||
Line::from(format!("Interface: {}", interface_label(&editor.interface))),
|
||||
Line::from(format!("Operation: {}", sanitize(&editor.operation.name))),
|
||||
Line::from(format!(
|
||||
"Operation: {}",
|
||||
operation_label(&editor.interface, &editor.operation.name)
|
||||
)),
|
||||
Line::from("Input values:"),
|
||||
];
|
||||
for (name, value) in arguments {
|
||||
@@ -726,7 +738,7 @@ mod tests {
|
||||
}
|
||||
let snapshot = rendered(&app, 140, 40);
|
||||
assert!(snapshot.contains("public root description"));
|
||||
assert!(snapshot.contains("(scope=/, name=demo-interface, run)"));
|
||||
assert!(snapshot.contains("/::demo-interface#run"));
|
||||
assert!(snapshot.contains("SUCCESS"));
|
||||
assert!(snapshot.contains("PROTOCOL ERROR"));
|
||||
assert!(snapshot.contains("TRANSPORT FAILURE"));
|
||||
@@ -822,7 +834,7 @@ mod tests {
|
||||
app.focus = Focus::Operations;
|
||||
app.operation_cursor = app.operation_rows().len() - 1;
|
||||
let snapshot = rendered(&app, 100, 30);
|
||||
assert!(snapshot.contains("(scope=/, name=many, operation-19)"));
|
||||
assert!(snapshot.contains("/::many#operation-19"));
|
||||
|
||||
for index in 0..20 {
|
||||
app.push_history(HistoryItem {
|
||||
@@ -850,13 +862,101 @@ mod tests {
|
||||
scope: "/nested".into(),
|
||||
name: "#::操作".into(),
|
||||
};
|
||||
assert_eq!(interface_label(&reference), "scope=/nested, name=#::操作");
|
||||
assert_eq!(interface_label(&reference), "/nested::\"#::操作\"");
|
||||
assert_ne!(
|
||||
interface_label(&reference),
|
||||
interface_label(&root_reference(&reference.name))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualified_labels_are_safely_quoted_on_normal_screens_and_modals() {
|
||||
let mut app = App::new("https://example.test/wip/");
|
||||
let local = wip_protocol::InterfaceReference {
|
||||
scope: "/a::b".into(),
|
||||
name: "same#name".into(),
|
||||
};
|
||||
let root = root_reference(&local.name);
|
||||
let operation = "run#\n\u{202e}";
|
||||
let descriptor = InterfaceDescriptor {
|
||||
format: INTERFACE_FORMAT_V1.into(),
|
||||
documentation: None,
|
||||
types: vec![],
|
||||
operations: vec![OperationDeclaration {
|
||||
name: operation.into(),
|
||||
documentation: None,
|
||||
parameters: vec![],
|
||||
returns: ReturnDeclaration {
|
||||
documentation: None,
|
||||
r#type: TypeExpr::Unit,
|
||||
},
|
||||
}],
|
||||
};
|
||||
app.apply_object(ObjectObservation {
|
||||
path: "/".into(),
|
||||
object: Some(Object {
|
||||
name: String::new(),
|
||||
description: None,
|
||||
interfaces: vec![root.clone(), local.clone()],
|
||||
r#ref: Some("hidden-object-ref".into()),
|
||||
validator: Some(b"hidden-object-validator".to_vec()),
|
||||
}),
|
||||
validator: Some(b"hidden-object-validator".to_vec()),
|
||||
state: ObservationState::Fresh,
|
||||
});
|
||||
for reference in [&root, &local] {
|
||||
app.apply_interface(InterfaceObservation {
|
||||
reference: reference.clone(),
|
||||
descriptor: Some(descriptor.clone()),
|
||||
validator: Some(b"hidden-interface-validator".to_vec()),
|
||||
scope_ref: Some("hidden-scope-ref".into()),
|
||||
state: ObservationState::Fresh,
|
||||
});
|
||||
}
|
||||
let root_label = "/::\"same#name\"";
|
||||
let local_label = "\"/a::b\"::\"same#name\"";
|
||||
let operation_label = "\"/a::b\"::\"same#name\"#\"run#\\n\\u202e\"";
|
||||
let normal = rendered(&app, 220, 55);
|
||||
assert!(normal.contains(root_label));
|
||||
assert!(normal.contains(local_label));
|
||||
assert!(normal.contains(operation_label));
|
||||
app.focus = Focus::Operations;
|
||||
app.operation_cursor = 3;
|
||||
app.reduce(crate::app::InputKey::Enter);
|
||||
let editor = rendered(&app, 220, 55);
|
||||
assert!(editor.contains(operation_label));
|
||||
assert!(editor.contains("Argument editor"));
|
||||
app.reduce(crate::app::InputKey::Enter);
|
||||
let confirmation = rendered(&app, 220, 55);
|
||||
assert!(confirmation.contains(operation_label));
|
||||
assert!(confirmation.contains("EXPLICIT EXECUTION CONFIRMATION"));
|
||||
for snapshot in [normal, editor, confirmation] {
|
||||
assert!(!snapshot.contains("hidden-"));
|
||||
assert!(!snapshot.contains('\u{202e}'));
|
||||
}
|
||||
assert!(matches!(
|
||||
app.reduce(crate::app::InputKey::Enter).as_slice(),
|
||||
[crate::app::Action::Execute { interface, operation: name, .. }]
|
||||
if interface == &local && name == operation
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortened_and_invalid_labels_never_infer_a_reference() {
|
||||
let reference = root_reference(&"x".repeat(1000));
|
||||
assert!(interface_label(&reference).ends_with('…'));
|
||||
assert_eq!(reference.name.len(), 1000);
|
||||
let invalid = wip_protocol::InterfaceReference {
|
||||
scope: "relative".into(),
|
||||
name: "name".into(),
|
||||
};
|
||||
assert_eq!(interface_label(&invalid), "(invalid interface reference)");
|
||||
assert_eq!(
|
||||
operation_label(&invalid, "run"),
|
||||
"(invalid operation reference)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_characters_and_large_bytes_are_bounded() {
|
||||
assert_eq!(sanitize("safe\u{1b}[31m"), "safe�[31m");
|
||||
|
||||
+136
-25
@@ -21,7 +21,7 @@ use crate::app::{
|
||||
Action, App, ConnectionState, HistoryItem, HistoryKind, InFlightKind, InputKey, Modal,
|
||||
};
|
||||
use crate::config::{Config, ConfigError};
|
||||
use crate::render::{bounded_value, draw, sanitize};
|
||||
use crate::render::{bounded_value, draw, interface_label, operation_label, sanitize};
|
||||
use crate::terminal::{TerminalGuard, translate_key};
|
||||
|
||||
/// Stable transport failure category.
|
||||
@@ -205,6 +205,12 @@ pub struct Runtime<T: Transport> {
|
||||
sender: mpsc::Sender<WorkerEvent>,
|
||||
receiver: mpsc::Receiver<WorkerEvent>,
|
||||
ignored: BTreeSet<u64>,
|
||||
// Private Client snapshots bind an open editor to the observed lifetime,
|
||||
// including optional refs/validators that must never appear on normal screens.
|
||||
editor_context: Option<(
|
||||
wip_client::ObjectObservation,
|
||||
wip_client::InterfaceObservation,
|
||||
)>,
|
||||
}
|
||||
|
||||
impl<T: Transport> Runtime<T> {
|
||||
@@ -231,6 +237,7 @@ impl<T: Transport> Runtime<T> {
|
||||
sender,
|
||||
receiver,
|
||||
ignored: BTreeSet::new(),
|
||||
editor_context: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -256,7 +263,15 @@ impl<T: Transport> Runtime<T> {
|
||||
|
||||
/// Applies a key through the reducer and starts any resulting effects.
|
||||
pub fn handle_key(&mut self, key: InputKey) -> bool {
|
||||
let was_editing = matches!(self.app.modal, Modal::Editor(_) | Modal::Confirm { .. });
|
||||
self.reconcile_authoritative();
|
||||
if was_editing && matches!(self.app.modal, Modal::None) {
|
||||
// Do not reinterpret a stale confirmation keystroke as selection
|
||||
// of another row/scope after the old editor has been retired.
|
||||
return false;
|
||||
}
|
||||
let actions = self.app.reduce(key);
|
||||
self.capture_editor_context();
|
||||
let mut quit = false;
|
||||
for action in actions {
|
||||
quit |= self.perform(action);
|
||||
@@ -387,9 +402,14 @@ impl<T: Transport> Runtime<T> {
|
||||
}
|
||||
|
||||
fn dispatch(&mut self, request: PreparedRequest, kind: InFlightKind, operation: bool) {
|
||||
let selected = self
|
||||
.app
|
||||
.operation_rows()
|
||||
.get(self.app.operation_cursor)
|
||||
.cloned();
|
||||
let id = request.id;
|
||||
self.app.request_started(id.get(), kind.clone());
|
||||
self.reconcile_authoritative();
|
||||
self.reconcile_authoritative_with_selection(selected);
|
||||
let prepared = match self.transport.prepare(request.request) {
|
||||
Ok(prepared) => prepared,
|
||||
Err(error) => {
|
||||
@@ -511,6 +531,11 @@ impl<T: Transport> Runtime<T> {
|
||||
}
|
||||
|
||||
fn finish(&mut self, event: WorkerEvent) {
|
||||
let selected = self
|
||||
.app
|
||||
.operation_rows()
|
||||
.get(self.app.operation_cursor)
|
||||
.cloned();
|
||||
let kind = self.app.in_flight.get(&event.id.get()).cloned();
|
||||
if kind.is_none() {
|
||||
return;
|
||||
@@ -549,7 +574,7 @@ impl<T: Transport> Runtime<T> {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.reconcile_authoritative();
|
||||
self.reconcile_authoritative_with_selection(selected);
|
||||
self.reconcile_visible_lookahead();
|
||||
}
|
||||
|
||||
@@ -580,9 +605,15 @@ impl<T: Transport> Runtime<T> {
|
||||
}
|
||||
}
|
||||
Completion::ProtocolFailure(error) => {
|
||||
let subject = match kind {
|
||||
Some(InFlightKind::Interface(reference)) => {
|
||||
format!(" for {}", interface_label(reference))
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
self.app.push_history(HistoryItem {
|
||||
kind: HistoryKind::DomainFailure,
|
||||
title: format!("Host {:?}", error.code),
|
||||
title: format!("Host {:?}{subject}", error.code),
|
||||
detail: sanitize(&error.message),
|
||||
entry_target: None,
|
||||
});
|
||||
@@ -605,11 +636,12 @@ impl<T: Transport> Runtime<T> {
|
||||
|
||||
fn push_call_history(&mut self, record: &wip_client::CallRecord) {
|
||||
let key = format!(
|
||||
"(scope={}, name={}, {}) on {}",
|
||||
sanitize(&record.context.interface().reference.scope),
|
||||
sanitize(&record.context.interface().reference.name),
|
||||
record.context.operation().name,
|
||||
record.context.object().path
|
||||
"{} on {}",
|
||||
operation_label(
|
||||
&record.context.interface().reference,
|
||||
&record.context.operation().name
|
||||
),
|
||||
sanitize(&record.context.object().path)
|
||||
);
|
||||
self.app.status = match &record.outcome {
|
||||
CallOutcome::Success(_) => "operation completed".into(),
|
||||
@@ -690,12 +722,14 @@ impl<T: Transport> Runtime<T> {
|
||||
let title = match kind {
|
||||
Some(InFlightKind::Object(path)) => format!("observe {path}"),
|
||||
Some(InFlightKind::Tree(path)) => format!("tree {path}"),
|
||||
Some(InFlightKind::Interface(reference)) => format!(
|
||||
"interface scope={}, name={}",
|
||||
sanitize(&reference.scope),
|
||||
sanitize(&reference.name)
|
||||
),
|
||||
Some(InFlightKind::Operation { operation, .. }) => operation.clone(),
|
||||
Some(InFlightKind::Interface(reference)) => {
|
||||
format!("interface {}", interface_label(reference))
|
||||
}
|
||||
Some(InFlightKind::Operation {
|
||||
interface,
|
||||
operation,
|
||||
..
|
||||
}) => operation_label(interface, operation),
|
||||
None => "request".into(),
|
||||
};
|
||||
self.app.push_history(HistoryItem {
|
||||
@@ -752,16 +786,87 @@ impl<T: Transport> Runtime<T> {
|
||||
}
|
||||
|
||||
fn reconcile_authoritative(&mut self) {
|
||||
self.sync_all_objects();
|
||||
let evicted_interfaces = self
|
||||
let selected = self
|
||||
.app
|
||||
.interfaces
|
||||
.keys()
|
||||
.filter(|reference| self.client.interface(&self.session, reference).is_none())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
for reference in evicted_interfaces {
|
||||
self.app.interfaces.remove(&reference);
|
||||
.operation_rows()
|
||||
.get(self.app.operation_cursor)
|
||||
.cloned();
|
||||
self.reconcile_authoritative_with_selection(selected);
|
||||
}
|
||||
|
||||
fn reconcile_authoritative_with_selection(
|
||||
&mut self,
|
||||
selected: Option<crate::app::OperationRow>,
|
||||
) {
|
||||
self.sync_all_objects();
|
||||
let references = self.app.interfaces.keys().cloned().collect::<Vec<_>>();
|
||||
for reference in references {
|
||||
match self.client.interface(&self.session, &reference).cloned() {
|
||||
Some(observation) => self.app.apply_interface(observation),
|
||||
None => {
|
||||
self.app.interfaces.remove(&reference);
|
||||
}
|
||||
}
|
||||
}
|
||||
let paths = self.app.entries.keys().cloned().collect::<Vec<_>>();
|
||||
for path in paths {
|
||||
if let Some(tree) = self.client.tree(&self.session, &path).cloned() {
|
||||
self.app.apply_tree(tree);
|
||||
} else if let Some(entry) = self.app.entries.get_mut(&path)
|
||||
&& !matches!(entry.tree_state, crate::app::ViewState::Unloaded)
|
||||
{
|
||||
entry.tree_state = crate::app::ViewState::Stale;
|
||||
entry.children.clear();
|
||||
}
|
||||
}
|
||||
self.app.restore_operation_selection(selected);
|
||||
self.reconcile_editor_context();
|
||||
}
|
||||
|
||||
fn capture_editor_context(&mut self) {
|
||||
let editor = match &self.app.modal {
|
||||
Modal::Editor(editor) | Modal::Confirm { editor, .. } => editor,
|
||||
_ => {
|
||||
self.editor_context = None;
|
||||
return;
|
||||
}
|
||||
};
|
||||
if self.editor_context.is_none() {
|
||||
self.editor_context = self.client.object(&self.session, &editor.path).zip(
|
||||
self.client
|
||||
.interface(&self.session, &editor.interface)
|
||||
.cloned(),
|
||||
);
|
||||
}
|
||||
self.reconcile_editor_context();
|
||||
}
|
||||
|
||||
fn reconcile_editor_context(&mut self) {
|
||||
let editor = match &self.app.modal {
|
||||
Modal::Editor(editor) | Modal::Confirm { editor, .. } => editor,
|
||||
_ => {
|
||||
self.editor_context = None;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let valid = self
|
||||
.editor_context
|
||||
.as_ref()
|
||||
.is_some_and(|(object, interface)| {
|
||||
object.path == editor.path
|
||||
&& self.app.selected_path == editor.path
|
||||
&& interface.reference == editor.interface
|
||||
&& interface.descriptor.as_ref() == Some(&editor.descriptor)
|
||||
&& object.state == wip_client::ObservationState::Fresh
|
||||
&& interface.state == wip_client::ObservationState::Fresh
|
||||
&& self.client.object(&self.session, &editor.path).as_ref() == Some(object)
|
||||
&& self.client.interface(&self.session, &editor.interface) == Some(interface)
|
||||
});
|
||||
if !valid {
|
||||
self.app.modal = Modal::None;
|
||||
self.editor_context = None;
|
||||
self.app.status =
|
||||
"editor retired: target or interface changed; reobserve and select again".into();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -771,7 +876,9 @@ impl<T: Transport> Runtime<T> {
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|(_, entry)| {
|
||||
entry.object.is_some() || self.client.object(&self.session, &entry.path).is_some()
|
||||
entry.object.is_some()
|
||||
|| entry.state != crate::app::ViewState::Unloaded
|
||||
|| self.client.object(&self.session, &entry.path).is_some()
|
||||
})
|
||||
.map(|(path, _)| path.clone())
|
||||
.collect::<Vec<_>>();
|
||||
@@ -930,6 +1037,10 @@ impl Display for RuntimeError {
|
||||
|
||||
impl Error for RuntimeError {}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "runtime_scoped_tests.rs"]
|
||||
mod scoped_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
//! UI lifetime regressions driven by real Client/HTTP codec completions.
|
||||
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
use wip_client::ObservationState;
|
||||
use wip_http::{
|
||||
decode_call_operation_request, encode_fetch_interface_response, encode_observe_response,
|
||||
encode_protocol_error_response,
|
||||
};
|
||||
use wip_protocol::{
|
||||
FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1, InterfaceReference, Object,
|
||||
ObjectObservation, ObserveRequest, OperationDeclaration, ProtocolError, ProtocolErrorCode,
|
||||
ProtocolInteraction, ReturnDeclaration,
|
||||
};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct RecordingTransport(Arc<Mutex<Vec<Request<Vec<u8>>>>>);
|
||||
|
||||
impl Transport for RecordingTransport {
|
||||
type Prepared = Request<Vec<u8>>;
|
||||
|
||||
fn prepare(&self, request: Request<Vec<u8>>) -> Result<Self::Prepared, TransportError> {
|
||||
self.0.lock().unwrap().push(request);
|
||||
Err(TransportError {
|
||||
kind: TransportErrorKind::Disconnect,
|
||||
detail: "deliberate pre-dispatch failure".into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn execute(&self, _: Self::Prepared) -> Result<Response<Vec<u8>>, TransportError> {
|
||||
panic!("test transport must never dispatch");
|
||||
}
|
||||
}
|
||||
|
||||
fn reference(scope: &str) -> InterfaceReference {
|
||||
InterfaceReference {
|
||||
scope: scope.into(),
|
||||
name: "same#::名前".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn descriptor() -> InterfaceDescriptor {
|
||||
InterfaceDescriptor {
|
||||
format: INTERFACE_FORMAT_V1.into(),
|
||||
documentation: None,
|
||||
types: vec![],
|
||||
operations: vec![OperationDeclaration {
|
||||
name: "run#::\n\u{202e}".into(),
|
||||
documentation: None,
|
||||
parameters: vec![],
|
||||
returns: ReturnDeclaration {
|
||||
documentation: None,
|
||||
r#type: TypeExpr::Unit,
|
||||
},
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime() -> Runtime<RecordingTransport> {
|
||||
let config = Config::parse(["https://example.test/wip"], None, None, |_| None).unwrap();
|
||||
Runtime::new(&config, RecordingTransport::default()).unwrap()
|
||||
}
|
||||
|
||||
fn observe(runtime: &mut Runtime<RecordingTransport>, identity: Option<&str>) {
|
||||
let request = runtime
|
||||
.client
|
||||
.refresh_object(&runtime.session, "/item")
|
||||
.unwrap();
|
||||
let response = encode_observe_response(
|
||||
&ObserveRequest {
|
||||
path: "/item".into(),
|
||||
depth: 0,
|
||||
},
|
||||
&ObjectObservation {
|
||||
object: Object {
|
||||
name: "item".into(),
|
||||
description: None,
|
||||
interfaces: vec![reference("/"), reference("/item")],
|
||||
r#ref: identity.map(str::to_owned),
|
||||
validator: None,
|
||||
},
|
||||
children: None,
|
||||
},
|
||||
default_wire_limits(),
|
||||
)
|
||||
.unwrap();
|
||||
runtime
|
||||
.app
|
||||
.request_started(request.id.get(), InFlightKind::Object("/item".into()));
|
||||
// Mirror the actual dispatch-time loading transition before the response.
|
||||
runtime.reconcile_authoritative();
|
||||
runtime.finish(WorkerEvent {
|
||||
id: request.id,
|
||||
response: Ok(response),
|
||||
});
|
||||
}
|
||||
|
||||
fn fetch(runtime: &mut Runtime<RecordingTransport>, scope: &str, identity: Option<&str>) {
|
||||
let request = runtime
|
||||
.client
|
||||
.prepare_interface(&runtime.session, reference(scope))
|
||||
.unwrap();
|
||||
runtime
|
||||
.app
|
||||
.request_started(request.id.get(), InFlightKind::Interface(reference(scope)));
|
||||
runtime.finish(WorkerEvent {
|
||||
id: request.id,
|
||||
response: Ok(interface_response(scope, identity)),
|
||||
});
|
||||
}
|
||||
|
||||
fn interface_response(scope: &str, identity: Option<&str>) -> Response<Vec<u8>> {
|
||||
encode_fetch_interface_response(
|
||||
&FetchInterfaceRequest {
|
||||
interface: reference(scope),
|
||||
},
|
||||
&FetchInterfaceResponse {
|
||||
interface: reference(scope),
|
||||
scope_ref: identity.map(str::to_owned),
|
||||
descriptor: descriptor(),
|
||||
validator: None,
|
||||
},
|
||||
default_wire_limits(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn populated() -> Runtime<RecordingTransport> {
|
||||
let mut runtime = runtime();
|
||||
observe(&mut runtime, Some("old-scope"));
|
||||
fetch(&mut runtime, "/", Some("root-scope"));
|
||||
fetch(&mut runtime, "/item", Some("old-scope"));
|
||||
runtime.app.navigate_entry("/item");
|
||||
runtime.app.focus = crate::app::Focus::Operations;
|
||||
runtime.app.operation_cursor = 3;
|
||||
runtime
|
||||
}
|
||||
|
||||
fn confirm_local(runtime: &mut Runtime<RecordingTransport>) {
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
assert!(matches!(&runtime.app.modal, Modal::Editor(editor)
|
||||
if editor.interface == reference("/item") && editor.path == "/item"));
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
assert!(matches!(runtime.app.modal, Modal::Confirm { .. }));
|
||||
assert!(runtime.transport.0.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_names_dispatch_exact_structured_scope_only_after_confirmation() {
|
||||
let mut runtime = populated();
|
||||
confirm_local(&mut runtime);
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
let requests = runtime.transport.0.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
let call =
|
||||
decode_call_operation_request(requests[0].body(), &descriptor(), default_wire_limits())
|
||||
.unwrap();
|
||||
assert_eq!(call.target.path, "/item");
|
||||
assert_eq!(call.interface.reference, reference("/item"));
|
||||
assert_eq!(call.interface.scope_ref.as_deref(), Some("old-scope"));
|
||||
assert_eq!(call.operation, descriptor().operations[0].name);
|
||||
assert!(
|
||||
runtime.app.history[0]
|
||||
.title
|
||||
.contains("/item::\"same#::名前\"#")
|
||||
);
|
||||
assert!(runtime.app.history[0].title.contains("\\n\\u202e"));
|
||||
assert!(!runtime.app.history[0].title.contains("old-scope"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_loading_preserves_scoped_operation_selection_until_reobservation() {
|
||||
let mut runtime = populated();
|
||||
observe(&mut runtime, Some("old-scope"));
|
||||
let selected = &runtime.app.operation_rows()[runtime.app.operation_cursor];
|
||||
assert_eq!(selected.interface, reference("/item"));
|
||||
assert_eq!(
|
||||
selected.operation.as_deref(),
|
||||
Some(descriptor().operations[0].name.as_str())
|
||||
);
|
||||
assert!(matches!(&runtime.app.operation_selection, Some((path, row))
|
||||
if path == "/item" && row == selected));
|
||||
assert!(runtime.transport.0.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_replacement_retires_confirmation_and_requires_explicit_reobservation() {
|
||||
let mut runtime = populated();
|
||||
confirm_local(&mut runtime);
|
||||
observe(&mut runtime, Some("new-scope"));
|
||||
let local = &runtime.app.interfaces[&reference("/item")];
|
||||
assert_eq!(local.state, crate::app::ViewState::Stale);
|
||||
assert!(local.descriptor.is_none());
|
||||
assert_eq!(
|
||||
runtime.app.interfaces[&reference("/")].state,
|
||||
crate::app::ViewState::Fresh
|
||||
);
|
||||
assert!(matches!(runtime.app.modal, Modal::None));
|
||||
assert!(runtime.app.status.contains("editor retired"));
|
||||
assert_eq!(
|
||||
runtime.app.operation_rows()[runtime.app.operation_cursor].interface,
|
||||
reference("/item")
|
||||
);
|
||||
assert!(
|
||||
runtime
|
||||
.client
|
||||
.ensure_interface(&runtime.session, reference("/item"))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
// Refetch without any execution; selecting and confirming again is required.
|
||||
fetch(&mut runtime, "/item", Some("new-scope"));
|
||||
assert!(matches!(runtime.app.modal, Modal::None));
|
||||
assert!(runtime.transport.0.lock().unwrap().is_empty());
|
||||
runtime.app.operation_cursor = 3;
|
||||
confirm_local(&mut runtime);
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
let requests = runtime.transport.0.lock().unwrap();
|
||||
let call =
|
||||
decode_call_operation_request(requests[0].body(), &descriptor(), default_wire_limits())
|
||||
.unwrap();
|
||||
assert_eq!(call.interface.scope_ref.as_deref(), Some("new-scope"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_old_fetch_cannot_revive_retired_editor_or_descriptor() {
|
||||
let mut runtime = populated();
|
||||
confirm_local(&mut runtime);
|
||||
let request = runtime
|
||||
.client
|
||||
.prepare_interface(&runtime.session, reference("/item"))
|
||||
.unwrap();
|
||||
runtime.app.request_started(
|
||||
request.id.get(),
|
||||
InFlightKind::Interface(reference("/item")),
|
||||
);
|
||||
observe(&mut runtime, Some("new-scope"));
|
||||
runtime.finish(WorkerEvent {
|
||||
id: request.id,
|
||||
response: Ok(interface_response("/item", Some("old-scope"))),
|
||||
});
|
||||
assert!(matches!(runtime.app.modal, Modal::None));
|
||||
assert_ne!(
|
||||
runtime.app.interfaces[&reference("/item")].state,
|
||||
crate::app::ViewState::Fresh
|
||||
);
|
||||
assert!(
|
||||
runtime.app.interfaces[&reference("/item")]
|
||||
.descriptor
|
||||
.is_none()
|
||||
);
|
||||
assert!(runtime.transport.0.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_deletion_and_republication_remove_ui_cache_without_switching_to_root() {
|
||||
let mut runtime = populated();
|
||||
confirm_local(&mut runtime);
|
||||
let request = runtime
|
||||
.client
|
||||
.refresh_object(&runtime.session, "/item")
|
||||
.unwrap();
|
||||
runtime
|
||||
.app
|
||||
.request_started(request.id.get(), InFlightKind::Object("/item".into()));
|
||||
runtime.finish(WorkerEvent {
|
||||
id: request.id,
|
||||
response: Ok(encode_protocol_error_response(
|
||||
ProtocolInteraction::Observe,
|
||||
&ProtocolError {
|
||||
code: ProtocolErrorCode::NotFound,
|
||||
message: "scope deleted".into(),
|
||||
},
|
||||
default_wire_limits(),
|
||||
)
|
||||
.unwrap()),
|
||||
});
|
||||
assert!(!runtime.app.interfaces.contains_key(&reference("/item")));
|
||||
assert!(runtime.app.interfaces.contains_key(&reference("/")));
|
||||
assert!(runtime.app.operation_rows().is_empty());
|
||||
assert!(matches!(runtime.app.modal, Modal::None));
|
||||
observe(&mut runtime, Some("republished-scope"));
|
||||
fetch(&mut runtime, "/item", Some("republished-scope"));
|
||||
assert!(runtime.transport.0.lock().unwrap().is_empty());
|
||||
assert!(matches!(runtime.app.modal, Modal::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lost_ref_and_retained_stale_descriptor_are_mirrored_without_synthetic_binding() {
|
||||
let mut runtime = populated();
|
||||
confirm_local(&mut runtime);
|
||||
observe(&mut runtime, None);
|
||||
assert!(matches!(runtime.app.modal, Modal::None));
|
||||
assert_eq!(
|
||||
runtime.app.interfaces[&reference("/item")].state,
|
||||
crate::app::ViewState::Stale
|
||||
);
|
||||
fetch(&mut runtime, "/item", None);
|
||||
runtime.app.operation_cursor = 3;
|
||||
confirm_local(&mut runtime);
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
let requests = runtime.transport.0.lock().unwrap();
|
||||
let call =
|
||||
decode_call_operation_request(requests[0].body(), &descriptor(), default_wire_limits())
|
||||
.unwrap();
|
||||
assert_eq!(call.interface.scope_ref, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_mirrors_retained_state_and_preserves_selected_reference_as_rows_shift() {
|
||||
let mut runtime = populated();
|
||||
// Fetch of the first Interface temporarily removes its Operation row. The
|
||||
// selected operation on the second Interface must stay selected by identity.
|
||||
let request = runtime
|
||||
.client
|
||||
.prepare_interface(&runtime.session, reference("/"))
|
||||
.unwrap();
|
||||
runtime.reconcile_authoritative();
|
||||
assert_eq!(
|
||||
runtime.app.operation_rows()[runtime.app.operation_cursor].interface,
|
||||
reference("/item")
|
||||
);
|
||||
assert!(
|
||||
runtime.app.operation_rows()[runtime.app.operation_cursor]
|
||||
.operation
|
||||
.is_some()
|
||||
);
|
||||
let error = runtime
|
||||
.client
|
||||
.fail_transport(request.id, "test failure")
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind, ClientErrorKind::Transport);
|
||||
runtime.reconcile_authoritative();
|
||||
assert!(matches!(
|
||||
runtime.app.interfaces[&reference("/")].state,
|
||||
crate::app::ViewState::Error(_)
|
||||
));
|
||||
assert_eq!(
|
||||
runtime.app.operation_rows()[runtime.app.operation_cursor].interface,
|
||||
reference("/item")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_dispatched_call_keeps_original_context_across_scope_republication() {
|
||||
let mut runtime = populated();
|
||||
let call = runtime
|
||||
.client
|
||||
.prepare_call(
|
||||
&runtime.session,
|
||||
"/item",
|
||||
&reference("/item"),
|
||||
&descriptor().operations[0].name,
|
||||
std::collections::BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let logical =
|
||||
decode_call_operation_request(call.request.body(), &descriptor(), default_wire_limits())
|
||||
.unwrap();
|
||||
runtime.client.mark_dispatched(call.id).unwrap();
|
||||
runtime.app.request_started(
|
||||
call.id.get(),
|
||||
InFlightKind::Operation {
|
||||
path: "/item".into(),
|
||||
interface: reference("/item"),
|
||||
operation: logical.operation.clone(),
|
||||
dispatched: true,
|
||||
},
|
||||
);
|
||||
observe(&mut runtime, Some("new-scope"));
|
||||
let mut new_descriptor = descriptor();
|
||||
new_descriptor.operations[0].returns.r#type = TypeExpr::String;
|
||||
let fetch = runtime
|
||||
.client
|
||||
.prepare_interface(&runtime.session, reference("/item"))
|
||||
.unwrap();
|
||||
runtime
|
||||
.app
|
||||
.request_started(fetch.id.get(), InFlightKind::Interface(reference("/item")));
|
||||
runtime.finish(WorkerEvent {
|
||||
id: fetch.id,
|
||||
response: Ok(encode_fetch_interface_response(
|
||||
&FetchInterfaceRequest {
|
||||
interface: reference("/item"),
|
||||
},
|
||||
&FetchInterfaceResponse {
|
||||
interface: reference("/item"),
|
||||
scope_ref: Some("new-scope".into()),
|
||||
validator: None,
|
||||
descriptor: new_descriptor.clone(),
|
||||
},
|
||||
default_wire_limits(),
|
||||
)
|
||||
.unwrap()),
|
||||
});
|
||||
runtime.finish(WorkerEvent {
|
||||
id: call.id,
|
||||
response: Ok(wip_http::encode_call_operation_response(
|
||||
&logical,
|
||||
&descriptor(),
|
||||
&wip_protocol::CallOperationResponse {
|
||||
result: Value::Unit,
|
||||
validator: None,
|
||||
},
|
||||
default_wire_limits(),
|
||||
)
|
||||
.unwrap()),
|
||||
});
|
||||
assert_eq!(runtime.app.history[0].kind, HistoryKind::Success);
|
||||
assert_eq!(runtime.app.history[0].detail, "null");
|
||||
assert_eq!(
|
||||
runtime.app.interfaces[&reference("/item")]
|
||||
.descriptor
|
||||
.as_ref(),
|
||||
Some(&new_descriptor)
|
||||
);
|
||||
assert_eq!(logical.interface.scope_ref.as_deref(), Some("old-scope"));
|
||||
assert!(
|
||||
runtime.transport.0.lock().unwrap().is_empty(),
|
||||
"no redispatch or retry"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_entry_results_are_only_observed_on_selection_without_guessed_edges() {
|
||||
let mut runtime = populated();
|
||||
// The original no-validator descriptor is immutable within its scope
|
||||
// lifetime. Republish the scope before testing a different result schema.
|
||||
observe(&mut runtime, Some("entry-result-scope"));
|
||||
let mut descriptor = descriptor();
|
||||
descriptor.operations[0].returns.r#type = TypeExpr::Record {
|
||||
fields: vec![
|
||||
wip_protocol::FieldDeclaration {
|
||||
name: "entries".into(),
|
||||
required: true,
|
||||
documentation: None,
|
||||
r#type: TypeExpr::List {
|
||||
items: Box::new(TypeExpr::Entry),
|
||||
},
|
||||
},
|
||||
wip_protocol::FieldDeclaration {
|
||||
name: "ordinary".into(),
|
||||
required: true,
|
||||
documentation: None,
|
||||
r#type: TypeExpr::String,
|
||||
},
|
||||
],
|
||||
};
|
||||
let fetch = runtime
|
||||
.client
|
||||
.prepare_interface(&runtime.session, reference("/item"))
|
||||
.unwrap();
|
||||
let completion = runtime
|
||||
.client
|
||||
.complete(
|
||||
fetch.id,
|
||||
encode_fetch_interface_response(
|
||||
&FetchInterfaceRequest {
|
||||
interface: reference("/item"),
|
||||
},
|
||||
&FetchInterfaceResponse {
|
||||
interface: reference("/item"),
|
||||
scope_ref: Some("entry-result-scope".into()),
|
||||
validator: None,
|
||||
descriptor: descriptor.clone(),
|
||||
},
|
||||
default_wire_limits(),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
runtime.apply_completion(completion, None);
|
||||
let call = runtime
|
||||
.client
|
||||
.prepare_call(
|
||||
&runtime.session,
|
||||
"/item",
|
||||
&reference("/item"),
|
||||
&descriptor.operations[0].name,
|
||||
std::collections::BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let logical =
|
||||
decode_call_operation_request(call.request.body(), &descriptor, default_wire_limits())
|
||||
.unwrap();
|
||||
runtime.client.mark_dispatched(call.id).unwrap();
|
||||
runtime.app.request_started(
|
||||
call.id.get(),
|
||||
InFlightKind::Operation {
|
||||
path: "/item".into(),
|
||||
interface: reference("/item"),
|
||||
operation: descriptor.operations[0].name.clone(),
|
||||
dispatched: true,
|
||||
},
|
||||
);
|
||||
runtime.finish(WorkerEvent {
|
||||
id: call.id,
|
||||
response: Ok(wip_http::encode_call_operation_response(
|
||||
&logical,
|
||||
&descriptor,
|
||||
&wip_protocol::CallOperationResponse {
|
||||
result: Value::Record(std::collections::BTreeMap::from([
|
||||
(
|
||||
"entries".into(),
|
||||
Value::List(vec![
|
||||
Value::String("/hidden/a".into()),
|
||||
Value::String("/hidden/b".into()),
|
||||
]),
|
||||
),
|
||||
("ordinary".into(), Value::String("/not-an-entry".into())),
|
||||
])),
|
||||
validator: None,
|
||||
},
|
||||
default_wire_limits(),
|
||||
)
|
||||
.unwrap()),
|
||||
});
|
||||
assert_eq!(
|
||||
runtime
|
||||
.app
|
||||
.history
|
||||
.iter()
|
||||
.filter(|item| item.entry_target.is_some())
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert!(!runtime.app.entries.contains_key("/hidden/a"));
|
||||
assert!(!runtime.app.entries.contains_key("/hidden/b"));
|
||||
assert!(!runtime.app.entries.contains_key("/not-an-entry"));
|
||||
assert!(runtime.app.entries["/"].children.is_empty());
|
||||
assert!(runtime.transport.0.lock().unwrap().is_empty());
|
||||
runtime.app.focus = crate::app::Focus::History;
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
let requests = runtime.transport.0.lock().unwrap();
|
||||
assert_eq!(
|
||||
requests.len(),
|
||||
1,
|
||||
"only the selected typed Entry is observed"
|
||||
);
|
||||
let observe =
|
||||
wip_http::decode_observe_request(requests[0].body(), default_wire_limits()).unwrap();
|
||||
assert_eq!(observe.path, "/hidden/b");
|
||||
assert_eq!(observe.depth, 0);
|
||||
assert!(
|
||||
!runtime.app.entries.contains_key("/hidden"),
|
||||
"no inferred indexable parent"
|
||||
);
|
||||
assert!(!runtime.app.entries.contains_key("/hidden/a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_change_during_confirmation_never_retargets_the_old_editor() {
|
||||
let mut runtime = populated();
|
||||
confirm_local(&mut runtime);
|
||||
runtime.app.selected_path = "/".into();
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
assert!(matches!(runtime.app.modal, Modal::None));
|
||||
assert!(runtime.transport.0.lock().unwrap().is_empty());
|
||||
assert_eq!(
|
||||
runtime
|
||||
.client
|
||||
.interface(&runtime.session, &reference("/item"))
|
||||
.unwrap()
|
||||
.state,
|
||||
ObservationState::Fresh
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user