#[path = "support/live.rs"]
mod live;
#[path = "support/supervisor.rs"]
mod supervisor;
use std::collections::BTreeMap;
use std::time::{Duration, Instant};
use printobserver_sdk::{
ActionExecutedPayload, ActionRejectedPayload, ActionRequestedPayload, Actor, Client,
ClientError, EventRecord, ExecutionOutcome, JobManifest, PolicyDecision, PrintAction,
PrinterState, Range, RejectionReason,
};
const REASON: &str = "a printer-integration journey is asking";
const DURATION: i64 = 60;
const INSIDE: f64 = 1.1;
const OUTSIDE: f64 = 9.9;
const PATIENCE: Duration = Duration::from_secs(180);
fn manifest(file_name: &str) -> JobManifest {
let mut allowed = BTreeMap::new();
allowed.insert("feedrate".to_owned(), Range { min: 0.9, max: 1.2 });
JobManifest {
file_name: file_name.to_owned(),
material: "PLA".to_owned(),
nozzle_diameter_mm: 0.4,
slicer_profile: "a journey's own profile".to_owned(),
allowed,
metadata: BTreeMap::new(),
}
}
fn until(client: &Client, print_id: &str, wanted: &[PrinterState]) -> PrinterState {
let deadline = Instant::now() + PATIENCE;
let mut last = PrinterState::Unknown("nothing was reported".to_owned());
while Instant::now() < deadline {
let status = client.status(print_id).expect("a status read is answered");
if let Some(printer) = status.printer {
last = printer.connection.clone();
if wanted.contains(&printer.connection) {
return printer.connection;
}
}
std::thread::sleep(Duration::from_millis(500));
}
panic!("the machine reported {last:?} and this step needs one of {wanted:?}");
}
#[test]
fn the_same_nine_steps_are_answered_against_a_real_octoprint() {
let root = tempfile::tempdir().expect("this journey's own root");
let mut standing = supervisor::standing(root.path(), false);
let world = standing.at.clone();
let client = Client::new(&world.server, Actor::Operator).with_credential(&*world.credential);
reads_status(&client, &world);
reads_context_and_image(&client, &world);
let wanted = the_manifest_the_print_runs_under(&client, &world);
let opened_by_the_alert = world.print_id.clone();
let world = starts(&client, &world, &wanted);
the_accepted_adjustment(&client, &world);
the_refused_adjustment(&client, &world);
unreasoned(&world);
accounting(&client, &world, &opened_by_the_alert);
cancels(&client, &world, &wanted);
standing.stop();
}
fn reads_status(client: &Client, world: &supervisor::Supervisor) {
let status = client
.status(&world.print_id)
.expect("a status read is answered");
assert_eq!(status.print.id, world.print_id);
}
fn reads_context_and_image(client: &Client, world: &supervisor::Supervisor) {
let context = client
.context(&world.print_id)
.expect("a context read is answered");
assert_eq!(context.context.print.id, world.print_id);
let image = client
.image(&world.image_id)
.expect("an image read is answered");
let path = image.path.as_ref().expect("the image is on this host");
assert!(
std::path::Path::new(path).is_absolute(),
"an image is an absolute path on the server's own host: {path}"
);
assert_eq!(
digest(std::path::Path::new(path)),
image.record.sha256,
"the file at the answered path is not the image the record declares"
);
}
fn the_manifest_the_print_runs_under(
client: &Client,
world: &supervisor::Supervisor,
) -> JobManifest {
let wanted = manifest(&world.file_name);
let written = client
.manifest_set(&world.print_id, REASON, &wanted)
.expect("a manifest write is answered");
assert_eq!(written.manifest.as_ref(), Some(&wanted));
let read_back = client
.manifest_get(&world.print_id)
.expect("a manifest read is answered");
assert_eq!(read_back.manifest, written.manifest);
wanted
}
fn starts(
client: &Client,
world: &supervisor::Supervisor,
wanted: &JobManifest,
) -> supervisor::Supervisor {
let _ = client.cancel(&world.print_id, "making room for the step that starts one");
until(client, &world.print_id, &[PrinterState::Operational]);
let started = client
.start_print(&world.print_id, &world.file_name, wanted, REASON, None)
.expect("a start is answered");
assert!(matches!(started.record.decision, PolicyDecision::Accepted));
let world = supervisor::Supervisor {
print_id: started.record.print_id.clone(),
..world.clone()
};
until(client, &world.print_id, &[PrinterState::Printing]);
assert_eq!(
client
.manifest_get(&world.print_id)
.expect("a manifest read is answered")
.manifest
.as_ref(),
Some(wanted),
"the print the start opened does not run under the manifest it was given"
);
world
}
fn the_accepted_adjustment(client: &Client, world: &supervisor::Supervisor) {
let adjusted = client
.set_feedrate_factor(&world.print_id, INSIDE, REASON, Some(DURATION))
.expect("an adjustment inside the bounds is answered");
assert!(matches!(adjusted.record.decision, PolicyDecision::Accepted));
let intervention = adjusted
.intervention
.as_ref()
.expect("a bounded adjustment opens an intervention");
assert!(
(intervention.applied_value - INSIDE).abs() < f64::EPSILON,
"the intervention applied {} rather than {INSIDE}",
intervention.applied_value
);
}
fn the_refused_adjustment(client: &Client, world: &supervisor::Supervisor) {
let refused = client
.set_feedrate_factor(&world.print_id, OUTSIDE, REASON, None)
.expect_err("an adjustment outside the bounds is refused");
let ClientError::Rejected(rejection) = refused else {
panic!("the policy's refusal did not arrive as one: {refused}");
};
assert!(matches!(
rejection.reason,
RejectionReason::OutOfBounds { .. }
));
assert_eq!(rejection.requested, Some(OUTSIDE));
let allowed = rejection
.allowed
.as_ref()
.expect("the range that is allowed");
assert!(
allowed.max < OUTSIDE,
"what is allowed does not admit what was asked for: {allowed:?}"
);
}
fn unreasoned(world: &supervisor::Supervisor) {
let proxy = live::Proxy::in_front_of(&world.server);
let watched = Client::new(proxy.url(), Actor::Operator).with_credential(&*world.credential);
let before = watched
.history(&world.print_id, None)
.expect("a history read is answered");
live::same("history", &before, &proxy.last().answer);
let reads = proxy.calls();
assert_eq!(reads, 1, "the read this step stands on went somewhere else");
for empty in ["", " "] {
let refused = watched
.pause(&world.print_id, empty)
.expect_err("a call with no reason is refused");
assert!(matches!(refused, ClientError::NoReason));
}
assert_eq!(
proxy.calls(),
reads,
"a call with no reason reached the supervisor"
);
let nowhere = Client::new("127.0.0.1:1", Actor::Operator);
assert!(matches!(
nowhere
.pause(&world.print_id, "")
.expect_err("a call with no reason is refused"),
ClientError::NoReason
));
let after = watched
.history(&world.print_id, None)
.expect("a history read is answered");
assert_eq!(
after.events.len(),
before.events.len(),
"a call with no reason left something in the history"
);
assert_eq!(
proxy.calls(),
reads + 1,
"the read that closes this step went somewhere else"
);
}
fn read_as<P: printobserver_sdk::EventPayloadKind>(event: &EventRecord) -> Option<P> {
event
.payload_as::<P>()
.map(|read| read.expect("a payload under its own kind is of its own type"))
}
fn accounting(client: &Client, world: &supervisor::Supervisor, opened_by_the_alert: &str) {
let history = client
.history(&world.print_id, Some(200))
.expect("a history read is answered");
let asked = history
.events
.iter()
.filter_map(read_as::<ActionRequestedPayload>)
.find(|payload| {
matches!(
payload.action,
PrintAction::SetFeedrateFactor { factor, .. }
if (factor - INSIDE).abs() < f64::EPSILON
)
})
.expect("the accepted adjustment is in the history");
assert!(
!history
.events
.iter()
.filter_map(read_as::<ActionRejectedPayload>)
.any(|payload| payload.action_id == asked.action_id),
"the accepted adjustment is in the history as a rejected one"
);
assert!(
history
.events
.iter()
.filter_map(read_as::<ActionExecutedPayload>)
.any(|payload| payload.action_id == asked.action_id),
"the history accounts for the accepted adjustment reaching no machine"
);
assert!(
history
.events
.iter()
.filter_map(read_as::<ActionRejectedPayload>)
.any(|payload| matches!(payload.decision, PolicyDecision::Rejected(_))),
"the history accounts for no refused action"
);
let first = client
.history(opened_by_the_alert, Some(200))
.expect("a history read is answered");
assert!(
first.events.iter().any(|event| event.id == world.event_id),
"the history does not account for the event the first print was opened by"
);
}
fn cancels(client: &Client, world: &supervisor::Supervisor, wanted: &JobManifest) {
let cancelled = client
.cancel(&world.print_id, REASON)
.expect("a cancel is answered");
assert!(matches!(
cancelled.record.decision,
PolicyDecision::Accepted
));
assert!(matches!(
cancelled.record.outcome,
Some(ExecutionOutcome::Succeeded) | None
));
until(client, &world.print_id, &[PrinterState::Operational]);
let _ = client.start_print(
&world.print_id,
&world.file_name,
wanted,
"putting the hold print back where the bring-up left it",
None,
);
}
fn digest(path: &std::path::Path) -> String {
use std::fmt::Write as _;
use sha2::Digest as _;
let mut hasher = sha2::Sha256::new();
hasher.update(std::fs::read(path).expect("the image at the answered path opens"));
hasher
.finalize()
.iter()
.fold(String::new(), |mut said, byte| {
let _ = write!(said, "{byte:02x}");
said
})
}
#[test]
fn a_client_without_the_credential_in_force_is_refused_by_a_real_supervisor() {
let root = tempfile::tempdir().expect("this journey's own root");
let mut standing = supervisor::standing(root.path(), false);
let world = standing.at.clone();
for (what, client) in [
("no credential", Client::new(&world.server, Actor::Operator)),
(
"a wrong credential",
Client::new(&world.server, Actor::Operator)
.with_credential("not-the-credential-in-force"),
),
] {
match client.status(&world.print_id) {
Err(ClientError::Refused { status, detail }) => {
assert_eq!(status, 401, "a client with {what} was refused as {detail}");
assert!(
detail.contains("Authorization: Bearer") && !detail.contains(&world.credential),
"a client with {what} was refused saying the wrong thing: {detail}"
);
}
other => panic!("a client with {what} was not refused as unauthenticated: {other:?}"),
}
}
let presenting =
Client::new(&world.server, Actor::Operator).with_credential(&*world.credential);
assert!(
presenting.status(&world.print_id).is_ok(),
"a client presenting the credential in force was not served"
);
standing.stop();
}