use super::{
DrainedPluginEffectReport, DrainedPluginEffectRequests, PendingPluginUpdateBatch,
PluginBufferEditProposal, PluginEffectBatchLimit, PluginEffectBatchLimitField,
PluginEffectCommitError, PluginEffectCommitErrorKind, PluginEffectDiscardReport,
};
use crate::{
buffer::{BufferEdit, BufferEditKind, BufferEditShape},
ecs::{
BufferPlugin, InitialEditorBuffer,
components::buffer::{BufferEntity, ViewEntity},
events::{
edit::{
BufferEditRejected, BufferEditRejectionReason, BufferEditRequested, BufferEdited,
},
status::{StatusMessage, StatusMessageRequested},
},
},
plugin::PluginIdentity,
text_stream::{TextByteStream, TextRevision},
};
use bevy::prelude::{App, Entity, MessageReader, Resource, Update};
use proptest::prelude::*;
use std::num::NonZeroUsize;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum FakeGuestUpdateOutcome {
Returned,
Trapped,
TimedOut,
DecodeFailed,
DeniedCapability,
MalformedData,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum FakeRuntimeFailureReason {
Trap,
Timeout,
DecodeFailure,
DeniedCapability,
MalformedData,
}
#[derive(Debug)]
struct FakeRuntimeUpdateReport {
drained: Option<DrainedPluginEffectReport>,
discarded: Option<PluginEffectDiscardReport>,
failure: Option<FakeRuntimeFailureReason>,
}
#[derive(Default, Resource)]
struct CapturedOwnerBoundary {
edited: Vec<BufferEdited>,
rejected: Vec<BufferEditRejected>,
}
#[test]
fn failed_update_discards_pending_effects_before_drain() {
let buffer = BufferEntity(test_entity(1));
let view = ViewEntity(test_entity(2));
let mut pending = PendingPluginUpdateBatch::from_validated_limit(
identity("status"),
PluginEffectBatchLimit::default(),
);
pending
.propose_buffer_edit(PluginBufferEditProposal::from_observed_revision(
buffer,
BufferEdit::insert(0, "secret text"),
TextRevision::from(0),
))
.expect("first effect should fit");
pending
.push_status_text(view, "secret\nstatus")
.expect("second effect should fit");
assert_eq!(pending.identity(), "status");
assert_eq!(pending.identity_proof().as_str(), "status");
let report = pending.discard();
assert_eq!(report.identity(), "status");
assert_eq!(report.identity_proof().as_str(), "status");
assert_eq!(report.discarded_effects(), 2);
}
#[test]
fn fake_runtime_successful_return_is_only_path_to_drain() {
let report = run_fake_runtime_update(FakeGuestUpdateOutcome::Returned);
let drained = report
.drained
.expect("successful guest return should drain sealed effects");
assert_eq!(drained.identity(), "fake-runtime");
assert_eq!(drained.identity_proof().as_str(), "fake-runtime");
assert_eq!(drained.buffer_edits().len(), 1);
assert_eq!(drained.status_messages().len(), 1);
assert!(report.discarded.is_none());
assert_eq!(report.failure, None);
}
#[test]
fn fake_runtime_failed_updates_discard_without_drain() {
for (outcome, reason) in [
(
FakeGuestUpdateOutcome::Trapped,
FakeRuntimeFailureReason::Trap,
),
(
FakeGuestUpdateOutcome::TimedOut,
FakeRuntimeFailureReason::Timeout,
),
(
FakeGuestUpdateOutcome::DecodeFailed,
FakeRuntimeFailureReason::DecodeFailure,
),
(
FakeGuestUpdateOutcome::DeniedCapability,
FakeRuntimeFailureReason::DeniedCapability,
),
(
FakeGuestUpdateOutcome::MalformedData,
FakeRuntimeFailureReason::MalformedData,
),
] {
let report = run_fake_runtime_update(outcome);
assert!(report.drained.is_none());
assert_eq!(report.failure, Some(reason));
assert_eq!(
report
.discarded
.as_ref()
.map(PluginEffectDiscardReport::identity),
Some("fake-runtime")
);
assert_eq!(
report
.discarded
.as_ref()
.map(PluginEffectDiscardReport::discarded_effects),
Some(2)
);
}
}
#[test]
fn successful_update_seals_then_drains_into_ecs_requests() {
let buffer = BufferEntity(test_entity(1));
let view = ViewEntity(test_entity(2));
let mut pending = PendingPluginUpdateBatch::from_validated_limit(
identity("formatter"),
PluginEffectBatchLimit::default(),
);
pending
.propose_buffer_edit(PluginBufferEditProposal::from_observed_revision(
buffer,
BufferEdit::insert(0, "formatted"),
TextRevision::from(0),
))
.expect("buffer edit should fit");
pending
.push_status_text(view, "formatted\nok")
.expect("status should fit");
assert_eq!(pending.identity(), "formatter");
assert_eq!(pending.identity_proof().as_str(), "formatter");
let sealed = pending.seal();
assert_eq!(sealed.identity(), "formatter");
assert_eq!(sealed.identity_proof().as_str(), "formatter");
let drained = sealed.drain();
assert_eq!(drained.identity(), "formatter");
assert_eq!(drained.identity_proof().as_str(), "formatter");
assert_eq!(drained.buffer_edits().len(), 1);
assert_eq!(drained.status_messages().len(), 1);
assert_eq!(drained.buffer_edits()[0].target, buffer);
assert_eq!(
drained.buffer_edits()[0].provenance.source_identity(),
"formatter"
);
assert_eq!(
drained.buffer_edits()[0].provenance.capability().as_str(),
"buffer.propose_edit"
);
assert_eq!(
drained.buffer_edits()[0].provenance.base_revision(),
TextRevision::from(0)
);
assert_eq!(
BufferEditShape::from_edit(&drained.buffer_edits()[0].edit).kind(),
BufferEditKind::Insert
);
assert_eq!(drained.status_messages()[0].target, view);
assert_eq!(
drained.status_messages()[0].message,
StatusMessage::Info(crate::StatusInfoText::escaped_display("formatted\nok"))
);
}
#[test]
fn drained_effect_requests_name_owner_batches_and_identity() {
let buffer = BufferEntity(test_entity(1));
let view = ViewEntity(test_entity(2));
let mut pending = PendingPluginUpdateBatch::from_validated_limit(
identity("split"),
PluginEffectBatchLimit::default(),
);
pending
.propose_buffer_edit(PluginBufferEditProposal::from_observed_revision(
buffer,
BufferEdit::insert(0, "plugin text"),
TextRevision::from(5),
))
.expect("buffer edit should fit");
pending
.push_status_text(view, "status")
.expect("status should fit");
let DrainedPluginEffectRequests {
identity,
buffer_edits,
status_messages,
} = pending.seal().drain().into_requests();
assert_eq!(identity.as_str(), "split");
assert_eq!(buffer_edits.len(), 1);
assert_eq!(status_messages.len(), 1);
assert_eq!(buffer_edits[0].target, buffer);
assert_eq!(status_messages[0].target, view);
}
#[test]
fn plugin_buffer_edit_effect_carries_explicit_base_revision() {
let buffer = BufferEntity(test_entity(1));
let effect = PluginBufferEditProposal::from_observed_revision(
buffer,
BufferEdit::insert(0, "secret plugin edit"),
TextRevision::from(9),
);
assert_eq!(effect.target(), buffer);
assert_eq!(effect.base_revision(), TextRevision::from(9));
let debug = format!("{effect:?}");
assert!(debug.contains("base_revision"));
assert!(debug.contains("Insert"));
assert!(!debug.contains("Entity"));
assert!(!debug.contains("BufferEntity"));
assert!(!debug.contains("secret"));
assert!(!debug.contains("plugin edit"));
}
#[test]
fn owner_rejection_after_drain_is_a_separate_commit_point() {
let missing_buffer = BufferEntity(test_entity(99));
let proposed_edit = BufferEdit::insert(0, "plugin text");
let mut pending = PendingPluginUpdateBatch::from_validated_limit(
identity("stale-plugin"),
PluginEffectBatchLimit::default(),
);
pending
.propose_buffer_edit(PluginBufferEditProposal::from_observed_revision(
missing_buffer,
proposed_edit.clone(),
TextRevision::from(0),
))
.expect("buffer edit should fit");
let drained = pending.seal().drain();
assert_eq!(drained.identity(), "stale-plugin");
assert_eq!(drained.buffer_edits().len(), 1);
let mut app = App::new();
let _app = app
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("alma"),
file: crate::buffer::BufferFile::scratch(0),
})
.init_resource::<CapturedOwnerBoundary>()
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin)
.add_systems(Update, capture_owner_boundary);
app.update();
for request in drained.buffer_edits() {
let _message = app.world_mut().write_message(request.clone());
}
app.update();
app.update();
let captured = app.world().resource::<CapturedOwnerBoundary>();
assert!(captured.edited.is_empty());
assert_eq!(
captured.rejected,
vec![BufferEditRejected {
target: missing_buffer,
rejected: BufferEditShape::from_edit(&proposed_edit),
reason: BufferEditRejectionReason::MissingTarget {
target: missing_buffer,
},
}]
);
}
#[test]
fn drained_plugin_edit_rejects_when_base_revision_is_stale() {
let mut app = App::new();
let _app = app
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("alma"),
file: crate::buffer::BufferFile::scratch(0),
})
.init_resource::<CapturedOwnerBoundary>()
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin)
.add_systems(Update, capture_owner_boundary);
app.update();
let buffer = BufferEntity(editor_buffer_entity(&mut app));
let _message = app.world_mut().write_message(BufferEditRequested {
target: buffer,
edit: BufferEdit::insert(4, "!"),
});
app.update();
app.update();
let proposed_edit = BufferEdit::insert(0, "plugin ");
let mut pending = PendingPluginUpdateBatch::from_validated_limit(
identity("stale-plugin"),
PluginEffectBatchLimit::default(),
);
pending
.propose_buffer_edit(PluginBufferEditProposal::from_observed_revision(
buffer,
proposed_edit.clone(),
TextRevision::from(0),
))
.expect("buffer edit should fit");
let drained = pending.seal().drain();
for request in drained.buffer_edits() {
let _message = app.world_mut().write_message(request.clone());
}
app.update();
app.update();
let buffer_text = app
.world()
.get::<crate::ecs::components::buffer::BufferText>(buffer.get())
.expect("editor buffer should exist");
assert_eq!(buffer_text.stream.as_str(), "alma!");
assert!(
app.world()
.resource::<CapturedOwnerBoundary>()
.rejected
.contains(&BufferEditRejected {
target: buffer,
rejected: BufferEditShape::from_edit(&proposed_edit),
reason: BufferEditRejectionReason::StaleRevision {
expected: TextRevision::from(0),
actual: TextRevision::from(1),
},
})
);
}
#[test]
fn effect_limit_fails_closed_without_adding_over_limit_effect() {
let view = ViewEntity(test_entity(2));
let mut pending = PendingPluginUpdateBatch::from_validated_limit(
identity("noisy"),
PluginEffectBatchLimit::try_new(1).expect("limit should be valid"),
);
pending
.push_status_text(view, "first")
.expect("first effect should fit");
let error = pending
.push_status_text(view, "second")
.expect_err("second effect should exceed limit");
assert_eq!(
error,
PluginEffectCommitError::TooManyEffects {
identity: identity("noisy"),
limit: count_limit(1),
}
);
assert_eq!(pending.effect_count(), 1);
}
#[test]
fn effect_limit_fields_are_closed() {
let error = PluginEffectBatchLimit::try_new(0).expect_err("zero limit should reject");
assert_eq!(
error,
PluginEffectCommitError::ZeroLimit {
field: PluginEffectBatchLimitField::MaxEffects,
}
);
assert_eq!(error.kind(), PluginEffectCommitErrorKind::ZeroLimit);
assert_eq!(
PluginEffectBatchLimitField::MaxEffects.as_str(),
"max_effects"
);
assert_eq!(
PluginEffectBatchLimitField::MaxEffects.to_string(),
"max_effects"
);
assert_eq!(
format!("{:?}", PluginEffectBatchLimitField::MaxEffects),
"max_effects"
);
assert_eq!(error.to_string(), "plugin effect limit must be non-zero");
}
#[test]
fn effect_commit_error_kinds_are_closed() {
let identity = identity("bounded");
let cases = [
(
PluginEffectCommitError::ZeroLimit {
field: PluginEffectBatchLimitField::MaxEffects,
},
PluginEffectCommitErrorKind::ZeroLimit,
"zero-limit",
),
(
PluginEffectCommitError::TooManyEffects {
identity,
limit: count_limit(1),
},
PluginEffectCommitErrorKind::TooManyEffects,
"too-many-effects",
),
];
for (error, kind, expected) in cases {
assert_eq!(error.kind(), kind);
assert_eq!(kind.as_str(), expected);
assert_eq!(kind.to_string(), expected);
assert_eq!(format!("{kind:?}"), expected);
}
}
#[test]
fn debug_output_redacts_guest_text() {
let view = ViewEntity(test_entity(2));
let mut pending = PendingPluginUpdateBatch::from_validated_limit(
identity("status"),
PluginEffectBatchLimit::default(),
);
pending
.push_status_text(view, "secret status payload")
.expect("status should fit");
let debug = format!("{pending:?}");
assert!(debug.contains("effect_count"));
assert!(debug.contains("byte_len"));
assert!(!debug.contains("Entity"));
assert!(!debug.contains("ViewEntity"));
assert!(!debug.contains("secret"));
assert!(!debug.contains("payload"));
}
#[test]
fn drained_effect_report_debug_redacts_owner_targets_and_payloads() {
let buffer = BufferEntity(test_entity(1));
let view = ViewEntity(test_entity(2));
let mut pending = PendingPluginUpdateBatch::from_validated_limit(
identity("drained"),
PluginEffectBatchLimit::default(),
);
pending
.propose_buffer_edit(PluginBufferEditProposal::from_observed_revision(
buffer,
BufferEdit::insert(0, "secret edit payload"),
TextRevision::from(7),
))
.expect("buffer edit should fit");
pending
.push_status_text(view, "secret status payload")
.expect("status should fit");
let report = pending.seal().drain();
let debug = format!("{report:?}");
assert!(debug.contains("DrainedPluginEffectReport"));
assert!(debug.contains("buffer_edit_shapes"));
assert!(debug.contains("status_message_shapes"));
assert!(debug.contains("base_revision"));
assert!(debug.contains("buffer.propose_edit"));
assert!(debug.contains("byte_len"));
assert!(!debug.contains("Entity"));
assert!(!debug.contains("BufferEntity"));
assert!(!debug.contains("ViewEntity"));
assert!(!debug.contains("secret"));
assert!(!debug.contains("payload"));
}
#[test]
fn raw_status_effect_debug_is_redacted_too() {
let view = ViewEntity(test_entity(2));
let request = StatusMessageRequested {
target: view,
message: StatusMessage::Info(crate::StatusInfoText::escaped_display(
"secret status payload",
)),
};
let effect = super::PluginEffect::Status(request);
let debug = format!("{effect:?}");
assert!(debug.contains("byte_len"));
assert!(!debug.contains("Entity"));
assert!(!debug.contains("ViewEntity"));
assert!(!debug.contains("secret"));
assert!(!debug.contains("payload"));
}
proptest! {
#[test]
fn effect_limit_rejections_leave_pending_batch_unchanged(limit in 1usize..32) {
let view = ViewEntity(test_entity(2));
let mut pending = PendingPluginUpdateBatch::from_validated_limit(
identity("bounded"),
PluginEffectBatchLimit::try_new(limit).expect("limit should be valid"),
);
for index in 0..limit {
pending
.push_status_text(view, format!("status {index}"))
.expect("effect should fit before limit");
}
prop_assert_eq!(pending.effect_count(), limit);
let error = pending
.push_status_text(view, "over limit")
.expect_err("extra effect should reject");
prop_assert_eq!(
error,
PluginEffectCommitError::TooManyEffects {
identity: identity("bounded"),
limit: count_limit(limit),
}
);
prop_assert_eq!(pending.effect_count(), limit);
}
}
fn test_entity(index: u32) -> Entity {
Entity::from_raw_u32(index).expect("test entity index should be valid")
}
fn identity(identity: &str) -> PluginIdentity {
PluginIdentity::try_new(identity).expect("test identity should be valid")
}
fn count_limit(value: usize) -> NonZeroUsize {
NonZeroUsize::new(value).expect("test count limit should be non-zero")
}
fn editor_buffer_entity(app: &mut App) -> Entity {
let mut query = app
.world_mut()
.query_filtered::<Entity, bevy::prelude::With<crate::ecs::components::buffer::EditorBuffer>>();
query
.iter(app.world())
.next()
.expect("editor buffer should exist")
}
fn capture_owner_boundary(
mut edited: MessageReader<BufferEdited>,
mut rejected: MessageReader<BufferEditRejected>,
mut captured: bevy::prelude::ResMut<CapturedOwnerBoundary>,
) {
captured.edited.extend(edited.read().copied());
captured.rejected.extend(rejected.read().cloned());
}
fn run_fake_runtime_update(outcome: FakeGuestUpdateOutcome) -> FakeRuntimeUpdateReport {
let mut pending = PendingPluginUpdateBatch::from_validated_limit(
identity("fake-runtime"),
PluginEffectBatchLimit::default(),
);
pending
.propose_buffer_edit(PluginBufferEditProposal::from_observed_revision(
BufferEntity(test_entity(10)),
BufferEdit::insert(0, "secret fake guest text"),
TextRevision::from(0),
))
.expect("buffer edit should fit");
pending
.push_status_text(ViewEntity(test_entity(11)), "secret fake guest status")
.expect("status should fit");
match outcome {
FakeGuestUpdateOutcome::Returned => FakeRuntimeUpdateReport {
drained: Some(pending.seal().drain()),
discarded: None,
failure: None,
},
FakeGuestUpdateOutcome::Trapped => {
discard_fake_update(pending, FakeRuntimeFailureReason::Trap)
}
FakeGuestUpdateOutcome::TimedOut => {
discard_fake_update(pending, FakeRuntimeFailureReason::Timeout)
}
FakeGuestUpdateOutcome::DecodeFailed => {
discard_fake_update(pending, FakeRuntimeFailureReason::DecodeFailure)
}
FakeGuestUpdateOutcome::DeniedCapability => {
discard_fake_update(pending, FakeRuntimeFailureReason::DeniedCapability)
}
FakeGuestUpdateOutcome::MalformedData => {
discard_fake_update(pending, FakeRuntimeFailureReason::MalformedData)
}
}
}
fn discard_fake_update(
pending: PendingPluginUpdateBatch,
failure: FakeRuntimeFailureReason,
) -> FakeRuntimeUpdateReport {
FakeRuntimeUpdateReport {
drained: None,
discarded: Some(pending.discard()),
failure: Some(failure),
}
}