feat: support typed in-process WIP clients without HTTP

This commit is contained in:
2026-10-16 05:26:11 +09:00
parent 53fa44da26
commit eb92878e3c
9 changed files with 1725 additions and 154 deletions
+614
View File
@@ -0,0 +1,614 @@
//! Public API tests: no HTTP types, codec, endpoint parser, or JSON dependency.
use std::collections::BTreeMap;
use wip_client::direct::{Client, PreparedRequest, Request, Response};
use wip_client::{
CallOutcome, ClientErrorKind, ClientLimits, Completion, DispatchedTransportFailure,
ObservationState, OutcomeUnknownReason, SecurityContext, SessionId,
};
use wip_protocol::{
CallOperationResponse, FetchInterfaceResponse, INTERFACE_FORMAT_V1, InterfaceDescriptor,
InterfaceReference, Object, ObjectObservation, OperationDeclaration, ParameterDeclaration,
ProtocolError, ProtocolErrorCode, ReturnDeclaration, TypeExpr, Value,
};
fn setup() -> (Client, SessionId) {
let mut client = Client::new(ClientLimits::new(4, 16, 8, 8, 4).unwrap());
let session = client
.open_session("in-process world", SecurityContext::new("alice"))
.unwrap();
(client, session)
}
fn reference() -> InterfaceReference {
InterfaceReference {
scope: "/scope".into(),
name: "math".into(),
}
}
fn object(name: &str, identity: Option<&str>) -> ObjectObservation {
ObjectObservation {
object: Object {
name: name.into(),
description: None,
interfaces: vec![reference()],
r#ref: identity.map(str::to_owned),
validator: None,
},
children: None,
}
}
fn descriptor() -> InterfaceDescriptor {
InterfaceDescriptor {
format: INTERFACE_FORMAT_V1.into(),
documentation: None,
types: vec![],
operations: vec![OperationDeclaration {
name: "double".into(),
documentation: None,
parameters: vec![ParameterDeclaration {
name: "n".into(),
documentation: None,
required: true,
r#type: TypeExpr::Integer,
}],
returns: ReturnDeclaration {
documentation: None,
r#type: TypeExpr::Integer,
},
}],
}
}
fn interface(scope_ref: Option<&str>) -> FetchInterfaceResponse {
FetchInterfaceResponse {
interface: reference(),
scope_ref: scope_ref.map(str::to_owned),
validator: None,
descriptor: descriptor(),
}
}
fn failure(code: ProtocolErrorCode) -> ProtocolError {
ProtocolError {
code,
message: "test failure".into(),
}
}
fn observe(client: &mut Client, session: &SessionId, path: &str, value: ObjectObservation) {
let request = client.refresh_object(session, path).unwrap();
assert!(
matches!(&request.request, Request::Observe(value) if value.path == path && value.depth == 0)
);
client
.complete(request.id, Response::Observe(Ok(value)))
.unwrap();
}
fn ready(client: &mut Client, session: &SessionId) {
observe(client, session, "/scope/item", object("item", None));
let request = client.prepare_interface(session, reference()).unwrap();
assert!(
matches!(request.request, Request::FetchInterface(value) if value.interface == reference())
);
client
.complete(request.id, Response::FetchInterface(Ok(interface(None))))
.unwrap();
}
fn call(client: &mut Client, session: &SessionId) -> PreparedRequest {
client
.prepare_call(
session,
"/scope/item",
&reference(),
"double",
BTreeMap::from([("n".into(), Value::Integer(3))]),
)
.unwrap()
}
#[test]
fn typed_interactions_succeed_with_optional_identity_and_validators_absent() {
let (mut client, session) = setup();
ready(&mut client, &session);
assert!(
client
.ensure_observed(&session, "/scope/item", 0)
.unwrap()
.is_none()
);
assert!(
client
.ensure_interface(&session, reference())
.unwrap()
.is_none()
);
let request = call(&mut client, &session);
let Request::CallOperation(logical) = request.request else {
panic!("wrong request")
};
assert_eq!(logical.target.path, "/scope/item");
assert_eq!(logical.target.validator, None);
assert_eq!(logical.interface.scope_ref, None);
assert_eq!(logical.interface.validator, None);
assert_eq!(logical.arguments["n"], Value::Integer(3));
client.mark_dispatched(request.id).unwrap();
let result = CallOperationResponse {
result: Value::Integer(6),
validator: Some(vec![2]),
};
let completion = client
.complete(request.id, Response::CallOperation(Ok(result.clone())))
.unwrap();
assert!(
matches!(completion, Completion::Call(record) if record.outcome == CallOutcome::Success(result))
);
assert_eq!(
client.object(&session, "/scope/item").unwrap().validator,
Some(vec![2])
);
assert_eq!(client.in_flight_len(), 0);
assert_eq!(
client
.complete(
request.id,
Response::CallOperation(Ok(CallOperationResponse {
result: Value::Integer(6),
validator: None,
}))
)
.unwrap_err()
.kind,
ClientErrorKind::InvalidRequest
);
}
#[test]
fn worldspace_and_security_identities_partition_observations_without_urls() {
let (mut client, session) = setup();
ready(&mut client, &session);
let other_world = client
.open_session("another world", SecurityContext::new("alice"))
.unwrap();
let other_security = client
.open_session("in-process world", SecurityContext::new("bob"))
.unwrap();
for other in [&other_world, &other_security] {
assert!(client.object(other, "/scope/item").is_none());
assert!(client.interface(other, &reference()).is_none());
}
assert_eq!(
client
.open_session("in-process world", SecurityContext::new("alice"))
.unwrap(),
session
);
}
#[test]
fn typed_observations_validate_name_depth_duplicate_children_and_interface_scope() {
let (mut client, session) = setup();
let mut wrong_scope = object("item", None);
wrong_scope.object.interfaces[0].scope = "/elsewhere".into();
let mut unexpected_children = object("item", None);
unexpected_children.children = Some(vec![]);
let mut duplicate = object("item", None);
duplicate.children = Some(vec![object("child", None), object("child", None)]);
for (depth, response) in [
(0, object("wrong-name", None)),
(1, object("item", None)),
(0, unexpected_children),
(0, wrong_scope),
(1, duplicate),
] {
let request = client
.refresh_observed(&session, "/scope/item", depth)
.unwrap();
let error = client
.complete(request.id, Response::Observe(Ok(response)))
.unwrap_err();
assert_eq!(error.kind, ClientErrorKind::InvalidResponse);
assert!(matches!(
client.object(&session, "/scope/item").unwrap().state,
ObservationState::Error(_)
));
}
}
#[test]
fn typed_descriptor_correspondence_structure_and_format_are_checked() {
let (mut client, session) = setup();
let mut mismatched = interface(None);
mismatched.interface.name = "other".into();
let mut malformed = interface(None);
malformed
.descriptor
.operations
.push(malformed.descriptor.operations[0].clone());
let mut unsupported = interface(None);
unsupported.descriptor.format = "future".into();
for (value, kind) in [
(mismatched, ClientErrorKind::InvalidResponse),
(malformed, ClientErrorKind::InvalidResponse),
(unsupported, ClientErrorKind::UnsupportedDescriptorFormat),
] {
let request = client.prepare_interface(&session, reference()).unwrap();
assert_eq!(
client
.complete(request.id, Response::FetchInterface(Ok(value)))
.unwrap_err()
.kind,
kind
);
}
}
#[test]
fn typed_arguments_and_results_use_the_fixed_descriptor() {
let (mut client, session) = setup();
ready(&mut client, &session);
let invalid = client
.prepare_call(
&session,
"/scope/item",
&reference(),
"double",
BTreeMap::from([("n".into(), Value::String("not integer".into()))]),
)
.unwrap_err();
assert_eq!(invalid.kind, ClientErrorKind::InvalidRequest);
assert_eq!(client.in_flight_len(), 0);
let request = call(&mut client, &session);
client.mark_dispatched(request.id).unwrap();
let completion = client
.complete(
request.id,
Response::CallOperation(Ok(CallOperationResponse {
result: Value::String("not integer".into()),
validator: None,
})),
)
.unwrap();
assert!(
matches!(completion, Completion::Call(record) if matches!(record.outcome,
CallOutcome::Unknown { reason: OutcomeUnknownReason::ResponseDecode, .. }))
);
assert_eq!(
client.object(&session, "/scope/item").unwrap().state,
ObservationState::Stale
);
assert!(
client
.ensure_observed(&session, "/scope/item", 0)
.unwrap()
.is_none()
);
assert_eq!(client.in_flight_len(), 0);
}
#[test]
fn typed_protocol_failures_validate_interaction_and_apply_recovery() {
let (mut client, session) = setup();
let request = client.refresh_object(&session, "/scope/item").unwrap();
assert!(matches!(
client
.complete(
request.id,
Response::Observe(Err(failure(ProtocolErrorCode::NotFound)))
)
.unwrap(),
Completion::ProtocolFailure(_)
));
let request = client.prepare_interface(&session, reference()).unwrap();
assert!(matches!(
client
.complete(
request.id,
Response::FetchInterface(Err(failure(ProtocolErrorCode::InterfaceNotFound)))
)
.unwrap(),
Completion::ProtocolFailure(_)
));
let request = client.refresh_object(&session, "/scope/item").unwrap();
assert_eq!(
client
.complete(
request.id,
Response::Observe(Err(failure(ProtocolErrorCode::OperationOutcomeUnknown)))
)
.unwrap_err()
.kind,
ClientErrorKind::InvalidResponse
);
let request = client.prepare_interface(&session, reference()).unwrap();
assert_eq!(
client
.complete(
request.id,
Response::FetchInterface(Err(failure(ProtocolErrorCode::ValidatorMismatch)))
)
.unwrap_err()
.kind,
ClientErrorKind::InvalidResponse
);
ready(&mut client, &session);
let request = call(&mut client, &session);
let completion = client
.complete(
request.id,
Response::CallOperation(Err(failure(ProtocolErrorCode::InterfaceMismatch))),
)
.unwrap();
assert!(
matches!(completion, Completion::Call(record) if matches!(record.outcome, CallOutcome::ProtocolFailure(_)))
);
assert_eq!(
client.object(&session, "/scope/item").unwrap().state,
ObservationState::Stale
);
assert_eq!(
client.interface(&session, &reference()).unwrap().state,
ObservationState::Stale
);
}
#[test]
fn mismatched_interactions_are_not_trusted_and_calls_remain_unknown() {
let (mut client, session) = setup();
let request = client.refresh_object(&session, "/scope/item").unwrap();
assert_eq!(
client
.complete(request.id, Response::FetchInterface(Ok(interface(None))))
.unwrap_err()
.kind,
ClientErrorKind::InvalidResponse
);
let request = client.prepare_interface(&session, reference()).unwrap();
assert_eq!(
client
.complete(request.id, Response::Observe(Ok(object("item", None))))
.unwrap_err()
.kind,
ClientErrorKind::InvalidResponse
);
ready(&mut client, &session);
let request = call(&mut client, &session);
let completion = client
.complete(
request.id,
Response::Observe(Err(failure(ProtocolErrorCode::NotFound))),
)
.unwrap();
assert!(
matches!(completion, Completion::Call(record) if matches!(record.outcome, CallOutcome::Unknown { .. }))
);
}
#[test]
fn typed_stale_responses_cannot_replace_new_observations() {
let (mut client, session) = setup();
let old = client.refresh_object(&session, "/scope/item").unwrap();
assert!(
client
.ensure_observed(&session, "/scope/item", 0)
.unwrap()
.is_none()
);
let new = client.refresh_object(&session, "/scope/item").unwrap();
client
.complete(new.id, Response::Observe(Ok(object("item", Some("new")))))
.unwrap();
assert_eq!(
client
.complete(old.id, Response::Observe(Ok(object("item", Some("old")))))
.unwrap(),
Completion::StaleResponseRejected(old.id)
);
assert_eq!(
client
.object(&session, "/scope/item")
.unwrap()
.object
.unwrap()
.r#ref
.as_deref(),
Some("new")
);
}
#[test]
fn scope_replacement_retires_descriptors_and_pending_fetch_but_not_call_snapshot() {
let (mut client, session) = setup();
observe(
&mut client,
&session,
"/scope",
object("scope", Some("old")),
);
ready(&mut client, &session);
let call = call(&mut client, &session);
let fetch = client.prepare_interface(&session, reference()).unwrap();
observe(
&mut client,
&session,
"/scope",
object("scope", Some("new")),
);
assert!(
client
.interface(&session, &reference())
.unwrap()
.descriptor
.is_none()
);
assert_eq!(
client
.complete(
fetch.id,
Response::FetchInterface(Ok(interface(Some("old"))))
)
.unwrap(),
Completion::StaleResponseRejected(fetch.id)
);
assert_eq!(
client.pending_call_context(call.id).unwrap().descriptor(),
&descriptor()
);
assert_eq!(
client
.pending_call_context(call.id)
.unwrap()
.request()
.interface
.scope_ref,
None
);
// A newer descriptor has a different return type; the prepared call still uses Integer.
let fetch = client.prepare_interface(&session, reference()).unwrap();
let mut next = interface(Some("new"));
next.descriptor.operations[0].returns.r#type = TypeExpr::String;
client
.complete(fetch.id, Response::FetchInterface(Ok(next)))
.unwrap();
let completion = client
.complete(
call.id,
Response::CallOperation(Ok(CallOperationResponse {
result: Value::Integer(6),
validator: None,
})),
)
.unwrap();
assert!(
matches!(completion, Completion::Call(record) if matches!(record.outcome, CallOutcome::Success(_)))
);
}
#[test]
fn scope_not_found_retires_interfaces_and_late_fetch_cannot_restore_them() {
let (mut client, session) = setup();
ready(&mut client, &session);
let old = client.prepare_interface(&session, reference()).unwrap();
let missing = client.refresh_object(&session, "/scope").unwrap();
client
.complete(
missing.id,
Response::Observe(Err(failure(ProtocolErrorCode::NotFound))),
)
.unwrap();
assert!(client.interface(&session, &reference()).is_none());
assert_eq!(
client
.complete(old.id, Response::FetchInterface(Ok(interface(None))))
.unwrap(),
Completion::StaleResponseRejected(old.id)
);
}
#[test]
fn failures_before_and_after_dispatch_never_retry_operations() {
let (mut client, session) = setup();
ready(&mut client, &session);
let before = call(&mut client, &session);
assert!(
matches!(client.fail_transport(before.id, "not handed to Host").unwrap(),
Completion::Call(record) if matches!(record.outcome, CallOutcome::NotDispatched(_)))
);
assert_eq!(
client.object(&session, "/scope/item").unwrap().state,
ObservationState::Fresh
);
let after = call(&mut client, &session);
client.mark_dispatched(after.id).unwrap();
assert!(
matches!(client.fail_dispatched_call(after.id, DispatchedTransportFailure::Timeout, "deadline").unwrap(),
Completion::Call(record) if matches!(record.outcome, CallOutcome::Unknown { reason: OutcomeUnknownReason::Timeout, .. }))
);
assert_eq!(client.in_flight_len(), 0);
assert!(
client
.ensure_observed(&session, "/scope/item", 0)
.unwrap()
.is_none()
);
ready(&mut client, &session);
let unknown = call(&mut client, &session);
assert!(
matches!(client.complete(unknown.id, Response::CallOperation(Err(failure(ProtocolErrorCode::OperationOutcomeUnknown)))).unwrap(),
Completion::Call(record) if matches!(record.outcome, CallOutcome::Unknown { reason: OutcomeUnknownReason::Protocol, .. }))
);
assert_eq!(client.in_flight_len(), 0);
assert_eq!(client.call_history(&session).unwrap().len(), 3);
}
#[test]
fn direct_limits_bound_sessions_inflight_cache_and_history() {
let mut client = Client::new(ClientLimits::new(1, 1, 1, 1, 1).unwrap());
let session = client
.open_session("world", SecurityContext::new("alice"))
.unwrap();
assert_eq!(
client
.open_session("other", SecurityContext::new("alice"))
.unwrap_err()
.kind,
ClientErrorKind::Capacity
);
let first = client.refresh_object(&session, "/scope/item").unwrap();
assert!(
client
.ensure_observed(&session, "/scope/other", 0)
.unwrap()
.is_none()
);
assert_eq!(
client
.refresh_object(&session, "/scope/other")
.unwrap_err()
.kind,
ClientErrorKind::Capacity
);
client
.complete(first.id, Response::Observe(Ok(object("item", None))))
.unwrap();
observe(&mut client, &session, "/scope/other", object("other", None));
assert_eq!(client.known_space(&session).unwrap().len(), 1);
for _ in 0..3 {
let request = client.refresh_object(&session, "/scope/other").unwrap();
client
.complete(request.id, Response::Observe(Ok(object("wrong", None))))
.unwrap_err();
}
assert_eq!(client.diagnostics(&session).unwrap().len(), 1);
}
#[test]
fn invalid_direct_requests_do_not_acquire_pending_state() {
let (mut client, session) = setup();
assert_eq!(
client
.refresh_object(&session, "not/absolute")
.unwrap_err()
.kind,
ClientErrorKind::InvalidRequest
);
assert_eq!(
client
.prepare_interface(
&session,
InterfaceReference {
scope: "/".into(),
name: "".into()
}
)
.unwrap_err()
.kind,
ClientErrorKind::InvalidRequest
);
assert_eq!(client.in_flight_len(), 0);
assert!(client.known_space(&session).unwrap().is_empty());
}