diff --git a/Cargo.lock b/Cargo.lock index 5d67936e..a7499689 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -187,9 +187,6 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] [[package]] name = "errno" @@ -327,9 +324,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "mio" @@ -604,9 +601,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "strsim" @@ -627,7 +624,7 @@ dependencies = [ [[package]] name = "technique" -version = "0.6.0" +version = "0.6.1" dependencies = [ "clap", "crossterm", @@ -665,12 +662,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -680,15 +676,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" dependencies = [ "num-conv", "time-core", diff --git a/Cargo.toml b/Cargo.toml index 70f9c323..f0762857 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "technique" -version = "0.6.0" +version = "0.6.1" edition = "2021" description = "A domain specific language for procedures." authors = [ "Andrew Cowie" ] diff --git a/examples/prototype/NetworkProbe.tq b/examples/prototype/NetworkProbe.tq new file mode 100644 index 00000000..92f12b47 --- /dev/null +++ b/examples/prototype/NetworkProbe.tq @@ -0,0 +1,102 @@ +% technique v1 +! Proprietary and Confidential; © 2024 ACME, Inc + +connectivity_check(e, s) : LocalEnvironment, TargetService -> NetworkHealth + +# Network Connectivity Check + +We check the health of the network path between a machine at a customer site +and a service running in a datacenter by establishing functionality at each +layer between our device and the remote server. + +I. Local network connectivity + +local_network : + +# Local Network Connectivity + +Establish that the local network environment is functioning. + + 1. Check physical network interface { exec( + ```bash + ip addr + ``` + ) } and look for eth0 being marked UP. + 2. Check local network connectivity + 3. Check local DHCP is working + 4. Check local DNS responding + 5. Verify reachability of local network gateway + +II. Reachability of site border relay + +home_border : + +# Check site border router + +Can we traverse from the immediate local network to the edge of the network at +this site? + + 1. Verify reachability of this-side border router inside interface. + +III. Check internet connectivity + +public_network : + +# Check connectivity to public internet + +Ensure we have a working internet uplink! + + 1. Verify reachability known first hop upstream + 2. Check reachability of known global services + 3. Check global DNS responding. + 'Reachable' + +IV. Check overlay network + +overlay_network : + +# Check overlay network + +Verify that the overlay network control plane services are responding and that +outbound packets are able to reach known hub nodes on that network. + + 1. Check overlay control plane is responding with valid status. + 2. Verify reachability of known hub and exit nodes on overlay network. + +V. Reachability of away border relay + +away_border : + +# Check connectivity to away-side border + + 1. Verify reachability of away-side border router or relay node + +VI. Traversal of away side local network + +away_network : + +# Check away-side network + +Now that we have connectivity to our foothold in the remote datacenter, we +need to work out if we can get any further. At this point we establish that we +are able to reach major services of the cloud infrastructure provider, +specifically RDS and S3. + + 1. Check away-side local network connectivity + 2. Check known cloud services responding + +check_aws_health : + +VII. Check response from remote service + +service_endpoint : Target -> Health + +# Confirm remote service health + +Finally run a sequence of checks to establish the remote service is reachable, +answering, and healthy. + + 1. Verify reachability of load balancer + 2. Verify health of load balancer + 3. Verify service responds to health check request + 4. Validate service responds correctly to test transaction diff --git a/src/formatting/formatter.rs b/src/formatting/formatter.rs index 878c12f0..3f6a3660 100644 --- a/src/formatting/formatter.rs +++ b/src/formatting/formatter.rs @@ -156,9 +156,16 @@ pub fn render_expression<'i>(expression: &'i Expression, renderer: &dyn Render) pub fn render_procedure_declaration<'i>(procedure: &'i Procedure, renderer: &dyn Render) -> String { render_declaration( - procedure.name.value, - procedure.parameters.as_ref().map(|v| v.as_slice()), - procedure.signature.as_ref(), + procedure + .name + .value, + procedure + .parameters + .as_ref() + .map(|v| v.as_slice()), + procedure + .signature + .as_ref(), renderer, ) } @@ -552,6 +559,7 @@ impl<'i> Formatter<'i> { let mut elements = procedure .elements .iter(); + let mut preceded = false; if let Some(Element::Title(_, _)) = procedure .elements .first() @@ -561,6 +569,7 @@ impl<'i> Formatter<'i> { .next() .unwrap(), ); + preceded = true; } self.add_fragment_reference(Syntax::BlockEnd, ""); @@ -568,7 +577,15 @@ impl<'i> Formatter<'i> { // remaining elements for element in elements { + // A code block separates from a preceding title or description with + // a blank line, but collapses onto the declaration as the first element. + if let Element::CodeBlock(..) = element { + if preceded { + self.add_fragment_reference(Syntax::Newline, "\n"); + } + } self.append_element(element); + preceded = true; } } @@ -588,7 +605,7 @@ impl<'i> Formatter<'i> { self.add_fragment_reference(Syntax::Newline, "\n"); self.append_steps(steps); } - Element::CodeBlock(expressions, _) => { + Element::CodeBlock(expressions, subscopes, _) => { self.add_fragment_reference(Syntax::Structure, "{"); self.add_fragment_reference(Syntax::Newline, "\n"); @@ -601,6 +618,13 @@ impl<'i> Formatter<'i> { self.decrease(4); self.add_fragment_reference(Syntax::Structure, "}"); + + if !subscopes.is_empty() { + self.add_fragment_reference(Syntax::Newline, "\n"); + self.increase(4); + self.append_scopes(subscopes); + self.decrease(4); + } } } } diff --git a/src/language/types.rs b/src/language/types.rs index da453bbb..e46e8422 100644 --- a/src/language/types.rs +++ b/src/language/types.rs @@ -64,7 +64,7 @@ pub enum Element<'i> { Title(&'i str, Span), Description(Vec>, Span), Steps(Vec>, Span), - CodeBlock(Vec>, Span), + CodeBlock(Vec>, Vec>, Span), } impl PartialEq for Element<'_> { @@ -73,7 +73,7 @@ impl PartialEq for Element<'_> { (Element::Title(a, _), Element::Title(b, _)) => a == b, (Element::Description(a, _), Element::Description(b, _)) => a == b, (Element::Steps(a, _), Element::Steps(b, _)) => a == b, - (Element::CodeBlock(a, _), Element::CodeBlock(b, _)) => a == b, + (Element::CodeBlock(a, sa, _), Element::CodeBlock(b, sb, _)) => a == b && sa == sb, _ => false, } } diff --git a/src/parsing/mod.rs b/src/parsing/mod.rs index 71a4fe5b..df8ce538 100644 --- a/src/parsing/mod.rs +++ b/src/parsing/mod.rs @@ -10,7 +10,7 @@ mod parser; mod scope; // Export the actual public API -pub use parser::{parse_with_recovery, Parser, ParsingError}; +pub use parser::{parse_numeric, parse_with_recovery, Parser, ParsingError}; /// Read a file and return an owned String. We pass that ownership back to the /// main function so that the Technique object created by parse() below can diff --git a/src/parsing/parser.rs b/src/parsing/parser.rs index 0b349840..7e1a7396 100644 --- a/src/parsing/parser.rs +++ b/src/parsing/parser.rs @@ -1158,8 +1158,26 @@ impl<'i> Parser<'i> { } else if is_code_block(content) { match parser.read_code_block() { Ok(expressions) => { + // A loop code block owns the steps below it as its body, + // the way an attribute block owns its scope; `read_scopes` + // stops at a trailing description, leaving it to be flagged. + // A plain code block owns nothing, so following content + // stays at this level. + let subscopes = if is_loop_block(&expressions) { + match parser.read_scopes() { + Ok(subscopes) => subscopes, + Err(error) => { + self.problems + .push(error); + parser.skip_to_next_line(); + vec![] + } + } + } else { + vec![] + }; let span = parser.span_since(elem_start); - elements.push(Element::CodeBlock(expressions, span)); + elements.push(Element::CodeBlock(expressions, subscopes, span)); } Err(error) => { self.problems @@ -3159,6 +3177,18 @@ fn is_code_block(content: &str) -> bool { re.is_match(content) } +/// Is this code block is a control structure (`foreach` or `repeat`) which +/// owns the steps below it as its body. A plain code does not. +fn is_loop_block(expressions: &[Expression]) -> bool { + if expressions.len() != 1 { + return false; + } + match &expressions[0] { + Expression::Foreach(..) | Expression::Repeat(..) => true, + _ => false, + } +} + #[allow(unused)] fn is_code_inline(content: &str) -> bool { let content = content.trim_ascii_start(); @@ -3257,6 +3287,26 @@ fn malformed_response_pattern(content: &str) -> bool { re.is_match(content) } +/// Parse a standalone numeric literal with the same grammar the parser uses +/// inside a document. Used by the runner to validate a numeric value when +/// input or edited. +pub fn parse_numeric(text: &str) -> Option> { + let mut parser = Parser::new(); + parser.initialize(text); + let numeric = parser + .read_numeric() + .ok()?; + parser.trim_whitespace(); + if parser + .source + .is_empty() + { + Some(numeric) + } else { + None + } +} + fn is_numeric(content: &str) -> bool { is_numeric_integral(content) || is_numeric_quantity(content) } diff --git a/src/problem/messages.rs b/src/problem/messages.rs index 95fa7add..e997b076 100644 --- a/src/problem/messages.rs +++ b/src/problem/messages.rs @@ -1227,6 +1227,10 @@ you can iterate over. format!("Unknown function {}()", function), format!("The function {}() is undefined. This should have been caught during the linking phase!", function), ), + RunnerError::FunctionArityMismatch { function, expected, actual } => ( + format!("Wrong number of arguments to {}()", function), + format!("The function {}() expects {} but {} given.", function, expected, actual), + ), RunnerError::ExecError(error) => ( "Could not run external command".to_string(), format!("Launching or reading from the external command failed: {}.", error), diff --git a/src/runner/checks/driver.rs b/src/runner/checks/driver.rs index 21b97550..c01df23d 100644 --- a/src/runner/checks/driver.rs +++ b/src/runner/checks/driver.rs @@ -1,7 +1,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::runner::driver::{draw, Console, Driver, Event, Interaction, Mock, UserInput}; -use crate::value::Value; +use crate::value::{Numeric, Value}; #[test] fn mock_returns_canned_answers_in_order() { @@ -140,6 +140,51 @@ fn esc_edit_seeds_buffer_and_backspace_trims() { ); } +#[test] +fn edited_quanticle_stays_a_quanticle() { + // Editing a numeric value and changing it keeps it numeric: 42 -> 43 is + // re-parsed back to a Quanticle, not flattened to text. + let mut it = Interaction::begin(&[], Value::Quanticle(Numeric::Integral(42))); + it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + it.handle(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE)), + None + ); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Some(UserInput::Done(Value::Quanticle(Numeric::Integral(43)))) + ); +} + +#[test] +fn unedited_quanticle_returns_verbatim() { + // Entering and leaving the edit without a change returns the original + // numeric value untouched. + let mut it = Interaction::begin(&[], Value::Quanticle(Numeric::Integral(42))); + it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Some(UserInput::Done(Value::Quanticle(Numeric::Integral(42)))) + ); +} + +#[test] +fn edited_quanticle_rejects_non_numeric() { + // A numeric value edited into something that is not a number is not + // accepted: Enter stays in the edit so it can be corrected. + let mut it = Interaction::begin(&[], Value::Quanticle(Numeric::Integral(42))); + it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + it.handle(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + None + ); +} + #[test] fn esc_menu_navigates_edit_skip_fail_quit() { // For an editable scalar the menu is edit, skip, fail, quit in order. diff --git a/src/runner/checks/library.rs b/src/runner/checks/library.rs index ff647bfe..87df1bfd 100644 --- a/src/runner/checks/library.rs +++ b/src/runner/checks/library.rs @@ -22,6 +22,18 @@ fn call(name: &str, args: &[Value]) -> Result { library.call(id, &context, args) } +// Invoke a system-layer builtin (exec, now) through an assembled core+system +// Library, the way an interactive or automatic run does. +fn call_system(name: &str, args: &[Value]) -> Result { + let mut library = Library::core(); + library.extend(Library::system()); + let context = Context::native(); + let id = library + .resolve(name) + .expect("builtin registered"); + library.call(id, &context, args) +} + #[test] fn seq_builds_inclusive_range() { let result = call("seq", &[int(1), int(4)]).expect("seq"); @@ -43,6 +55,22 @@ fn seq_rejects_non_integer() { assert_eq!(function, "seq"); } +#[test] +fn call_with_wrong_arity_errors_rather_than_panicking() { + let result = call("seq", &[int(1)]); + let Err(RunnerError::FunctionArityMismatch { + function, + expected, + actual, + }) = result + else { + panic!("expected FunctionArityMismatch, got {:?}", result); + }; + assert_eq!(function, "seq"); + assert_eq!(expected, 2); + assert_eq!(actual, 1); +} + #[test] fn zip_pairs_truncating_to_shorter() { let xs = Value::Arraeum(vec![int(1), int(2), int(3)]); @@ -94,3 +122,46 @@ fn projections_reject_non_tablet() { }; assert_eq!(function, "values"); } + +#[test] +fn exec_captures_stdout_as_literal() { + let result = call_system("exec", &[text("printf 'hello world'")]).expect("exec"); + assert_eq!(result, text("hello world")); +} + +#[test] +fn exec_tees_output_through_context() { + // exec streams the child's stdout live through the Context as it runs (the + // tee) while also accumulating it as the return value. A capturing Context + // lets the test observe the streamed bytes via `captured()`; they match the + // returned value. + let mut library = Library::core(); + library.extend(Library::system()); + let context = Context::capture(); + let id = library + .resolve("exec") + .expect("builtin registered"); + let result = library + .call(id, &context, &[text("printf 'streamed'")]) + .expect("exec"); + assert_eq!(result, text("streamed")); + assert_eq!(context.captured(), b"streamed".to_vec()); +} + +#[test] +fn exec_rejects_non_string_argument() { + let result = call_system("exec", &[int(3)]); + let Err(RunnerError::InvalidArgument { function, .. }) = result else { + panic!("expected InvalidArgument, got {:?}", result); + }; + assert_eq!(function, "exec"); +} + +#[test] +fn exec_nonzero_exit_is_command_failed() { + let result = call_system("exec", &[text("exit 3")]); + let Err(RunnerError::CommandFailed(code)) = result else { + panic!("expected CommandFailed, got {:?}", result); + }; + assert_eq!(code, 3); +} diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index e90f8356..5b77a332 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -5,13 +5,16 @@ use crate::language; use crate::language::{Identifier, Numeric as LangNumeric}; use crate::parsing; use crate::program::{ - Executable, ExecutableRef, Fragment, Operation, Ordinal, Program, Subroutine, + Executable, ExecutableRef, Fragment, Invocable, Operation, Ordinal, Program, Subroutine, + SubroutineRef, }; use crate::runner::driver::{Automatic, Event, Mock, UserInput}; use crate::runner::evaluator::Environment; use crate::runner::library::Library; use crate::runner::runner::{bind_parameters, Outcome, Runner, RunnerError}; -use crate::runner::state::{parse_record, Appender, State, Store, Value as RecordValue}; +use crate::runner::state::{ + parse_record, Appender, InvokeTarget, State, Store, Value as RecordValue, +}; use crate::translation::translate; use crate::value::Value; @@ -232,6 +235,86 @@ fn step_outcomes_recorded() { ); } +#[test] +fn empty_fail_reason_records_none() { + // Failing a step without giving a reason records Fail with no reason at + // all, not an empty-string reason tablet. + let mut fixture = StoreFixture::new("fail-no-reason"); + let body = Operation::Sequence(vec![step( + Ordinal::Dependent("1"), + Operation::Sequence(vec![]), + )]); + let program = anonymous_with_body(body); + let prompt = Mock::with_answers([UserInput::Fail(String::new())]); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + prompt, + Library::stub(), + ); + runner + .run(Environment::new()) + .expect("run"); + let pfftt = fixture.pfftt_contents(); + let lines: Vec<&str> = pfftt + .lines() + .filter(|line| { + !line + .trim() + .is_empty() + }) + .collect(); + let record = parse_record(lines[2]).expect("parse record"); + assert_eq!(record.state, State::Fail(None)); +} + +#[test] +fn same_procedure_invoked_twice_runs_twice() { + // Two calls to the same procedure at the same path each run: `completed` is + // the resume snapshot, not a live within-run dedup, so the second call is + // not wrongly skipped just because the first reached the same FQP. + let source = r#" +% technique v1 + +main : + +{ } +{ } + +helper : + + 1. do the thing + "# + .trim_ascii(); + let document = parsing::parse(Path::new("Test.tq"), source).expect("parse"); + let program = translate(&document).expect("translate"); + + let mut fixture = StoreFixture::new("double-invoke"); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + Automatic::with_handle(Vec::new()), + Library::stub(), + ); + runner + .run(Environment::new()) + .expect("run"); + + let pfftt = fixture.pfftt_contents(); + let invokes = pfftt + .lines() + .filter(|line| line.contains("Invoke helper:")) + .count(); + let begins = pfftt + .lines() + .filter(|line| line.contains("/main:/helper:/1 Begin")) + .count(); + assert_eq!(invokes, 2, "both call sites should invoke helper"); + assert_eq!(begins, 2, "helper's body should run on both calls"); +} + #[test] fn two_steps_prompted_in_source_order() { let mut fixture = StoreFixture::new("two-steps"); @@ -612,6 +695,55 @@ helper : assert_eq!(step_fqns, vec!["/main:/helper:/1"]); } +#[test] +fn section_holding_procedure_descends() { + let source = r#" +% technique v1 + +outer : + +I. Setup + +inner : + +1. inner step + "# + .trim_ascii(); + let document = parsing::parse(Path::new("Test.tq"), source).expect("parse"); + let program = translate(&document).expect("translate"); + + let mut fixture = StoreFixture::new("section-holding-procedure"); + let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + prompt, + Library::stub(), + ); + let env = Environment::new(); + runner + .run(env) + .expect("run"); + + let prompt = runner.into_driver(); + let step_fqns: Vec<&str> = prompt + .events() + .iter() + .filter_map(|e| { + if let Event::Step { qualified, .. } = e { + Some(qualified.as_str()) + } else { + None + } + }) + .collect(); + // A section whose body declares a procedure descends into it: the step + // inside `inner` is reached, its FQN carrying the `outer` entry frame, + // the `I` section, then the invoked `inner` frame. + assert_eq!(step_fqns, vec!["/outer:/I/inner:/1"]); +} + #[test] fn invoke_binds_arguments_to_parameters() { let source = r#" @@ -1195,7 +1327,10 @@ fn foreach_destructures_tuple_elements() { _ => None, }) .collect(); - assert_eq!(steps, vec!["a. { first } / { second }", "a. { first } / { second }"]); + assert_eq!( + steps, + vec!["a. { first } / { second }", "a. { first } / { second }"] + ); } #[test] @@ -1265,6 +1400,52 @@ fn foreach_widens_primitive_to_singleton() { assert_eq!(steps, vec![("/[1]/a", "a. { item }")]); } +#[test] +fn foreach_over_unit_iterates_nothing() { + // foreach item in source, where `source` is Unit (the value of an empty + // sequence or a pure-prose step). Unit is the absence of a value, so the + // loop iterates over nothing: the body never runs and the run completes. + let names = [Identifier::new("item")]; + let substep = step(Ordinal::Dependent("a"), Operation::Sequence(Vec::new())); + let loop_op = Operation::Loop { + names: &names, + over: Some(Box::new(Operation::Variable(Identifier::new("source")))), + body: Box::new(Operation::Sequence(vec![substep])), + responses: Vec::new(), + }; + let mut sub = Subroutine::anonymous(); + sub.body = loop_op; + let mut program = Program::new(); + program + .subroutines + .push(sub); + + let mut fixture = StoreFixture::new("foreach-unit"); + let mut env = Environment::new(); + env.extend("source".to_string(), Value::Unitus); + + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + Mock::new(), + Library::stub(), + ); + runner + .run(env) + .expect("run"); + + let prompt = runner.into_driver(); + let ran = prompt + .events() + .iter() + .any(|event| match event { + Event::Step { .. } => true, + _ => false, + }); + assert!(!ran, "loop body must not run when iterating Unit"); +} + #[test] fn foreach_over_non_list_or_unbound_errors() { // foreach item in source, where `source` is supplied by the caller's @@ -1626,3 +1807,163 @@ fn multiline_body_value_records_unit_but_still_propagates() { let record = parse_record(lines[2]).expect("parse record"); assert_eq!(record.state, State::Done(Some(RecordValue::Unit))); } + +#[test] +fn sequence_value_is_last_member() { + // Block semantics: a multi-member body sequence runs each step in order + // and takes the LAST member's value, not the first and not a fold. Both + // steps run (each records its own value), but the run returns "second". + let mut fixture = StoreFixture::new("sequence-last-member"); + let body = Operation::Sequence(vec![ + step( + Ordinal::Dependent("1"), + Operation::String(vec![Fragment::Text("first")]), + ), + step( + Ordinal::Dependent("2"), + Operation::String(vec![Fragment::Text("second")]), + ), + ]); + let program = anonymous_with_body(body); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + Automatic::with_handle(Vec::new()), + Library::stub(), + ); + let outcome = runner + .run(Environment::new()) + .expect("run"); + assert_eq!( + outcome, + Outcome::Done(Value::Literali("second".to_string())) + ); + + // Both steps ran, recording their own value in order (a trailing scope + // seal records Unit, not a Literal, so it is excluded). + let pfftt = fixture.pfftt_contents(); + let dones: Vec = pfftt + .lines() + .filter_map(|line| parse_record(line).ok()) + .filter_map(|record| match record.state { + State::Done(Some(RecordValue::Literal(text))) => Some(text), + _ => None, + }) + .collect(); + assert_eq!(dones, vec!["first".to_string(), "second".to_string()]); +} + +#[test] +fn deferred_invoke_is_prompted_and_recorded() { + // A call to an external procedure this run cannot resolve (it lives in + // another document or system) is presented for the operator to settle: the + // run does not descend into it, but the operator can mark it Done (it was + // performed, or recorded elsewhere), Skip, or Fail. The call site and the + // settled outcome are both recorded. + fn deferred_program() -> Program<'static> { + let external = language::External { + value: "https://example.com/probe", + span: language::Span::default(), + }; + let invoke = Operation::Invoke(Invocable { + target: SubroutineRef::Deferred(external), + arguments: Vec::new(), + }); + anonymous_with_body(Operation::Sequence(vec![invoke])) + } + + // The operator marks the external procedure Done. + let mut fixture = StoreFixture::new("deferred-done"); + let program = deferred_program(); + let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + prompt, + Library::stub(), + ); + let outcome = runner + .run(Environment::new()) + .expect("run"); + assert_eq!(outcome, Outcome::Done(Value::Unitus)); + + // The operator was prompted about the external node, by its FQP. + let prompt = runner.into_driver(); + let asked: Vec<&str> = prompt + .events() + .iter() + .filter_map(|event| match event { + Event::External { qualified } => Some(qualified.as_str()), + _ => None, + }) + .collect(); + assert_eq!(asked, vec!["/"]); + + // The trail records the Invoke call site at the caller's path and the + // Done outcome at the external's FQP. + let pfftt = fixture.pfftt_contents(); + let records: Vec<(String, State)> = pfftt + .lines() + .filter_map(|line| parse_record(line).ok()) + .map(|record| (record.path, record.state)) + .collect(); + assert!(records.contains(&( + "/".to_string(), + State::Invoke(InvokeTarget::Uri("https://example.com/probe".to_string())) + ))); + assert!(records.contains(&( + "/".to_string(), + State::Done(Some(RecordValue::Unit)) + ))); + + // The operator declines: Skip is recorded at the external's FQP. (The + // enclosing sequence proceeds and returns its last Done value, so the + // run's overall outcome is not itself the Skip — that is walk_sequence's + // concern, tested elsewhere; what matters here is the recorded Skip.) + let mut fixture = StoreFixture::new("deferred-skip"); + let program = deferred_program(); + let prompt = Mock::with_answers([UserInput::Skip]); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + prompt, + Library::stub(), + ); + runner + .run(Environment::new()) + .expect("run"); + let pfftt = fixture.pfftt_contents(); + let settled: Vec = pfftt + .lines() + .filter_map(|line| parse_record(line).ok()) + .filter(|record| record.path == "/") + .map(|record| record.state) + .collect(); + assert_eq!(settled, vec![State::Skip]); + + // Under an automatic run there is no operator to attest the external work + // and nothing executed it, so it records Skip rather than a fabricated Done. + let mut fixture = StoreFixture::new("deferred-automatic"); + let program = deferred_program(); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + Automatic::with_handle(Vec::new()), + Library::stub(), + ); + runner + .run(Environment::new()) + .expect("run"); + let pfftt = fixture.pfftt_contents(); + let settled: Vec = pfftt + .lines() + .filter_map(|line| parse_record(line).ok()) + .filter(|record| record.path == "/") + .map(|record| record.state) + .collect(); + assert_eq!(settled, vec![State::Skip]); +} diff --git a/src/runner/checks/state.rs b/src/runner/checks/state.rs index 754ba6e1..c6dcd276 100644 --- a/src/runner/checks/state.rs +++ b/src/runner/checks/state.rs @@ -2,7 +2,8 @@ use std::path::{Path, PathBuf}; use crate::runner::runner::RunnerError; use crate::runner::state::{ - format_record, parse_record, InvokeTarget, Record, RecordError, RunId, State, Store, Value, + fail_reason, format_record, parse_record, InvokeTarget, Record, RecordError, RunId, State, + Store, Value, }; // A scratch directory under the system temp dir, cleaned up on drop so panics @@ -427,6 +428,27 @@ fn format_record_pins_on_disk_text() { ); } +#[test] +fn fail_reason_escapes_and_stays_on_one_line() { + let record = Record { + recorded: "2026-05-14T12:00:00Z".to_string(), + run_id: RunId(1), + path: "/make_coffee:2".to_string(), + state: State::Fail(Some(fail_reason("said \"unplug\"\nthen left"))), + }; + let line = format_record(&record); + assert_eq!( + line, + "2026-05-14T12:00:00Z 000001 /make_coffee:2 Fail [ reason = \"said \\\"unplug\\\"\\nthen left\" ]\n" + ); + assert_eq!( + line.matches('\n') + .count(), + 1 + ); + assert_eq!(parse_record(&line).unwrap(), record); +} + #[test] fn record_round_trips_through_format_and_parse() { let cases = vec![ diff --git a/src/runner/context.rs b/src/runner/context.rs index c1e22093..5bf1252d 100644 --- a/src/runner/context.rs +++ b/src/runner/context.rs @@ -1,32 +1,63 @@ //! Host capabilities available to native functions when they execute. For now -//! the only capability is passing output through to the operator, and there is -//! no state to carry — output goes straight to standard output. A future GUI -//! or web frontend would hold a sink here and route through it, at which point -//! `native()` stays the empty/default context and a separate constructor -//! carries the real one. +//! the only capability is passing output through to the operator: output goes +//! straight to standard output, or — for tests — into an in-memory buffer. A +//! future GUI or web frontend would hold its own sink here, with +//! `native()` staying the terminal default and a separate constructor carrying +//! the real one. +use std::cell::RefCell; use std::io::{self, Write}; -pub struct Context; +pub struct Context { + sink: Sink, +} + +enum Sink { + Stdout, + // An in-memory buffer, used by tests. Kept a distinct variant from + // `Stdout` so the terminal path carries no interior-mutability wrapper. + // The `RefCell` lets `write(&self)` append through the shared reference + // the `Context` is threaded by. + Capture(RefCell>), +} impl Context { - /// The default context of native host capabilities. Builtins that are - /// pure functions to manipulate Values ignore it. + /// The default context of native host capabilities, writing through to + /// standard output. Builtins that are pure functions to manipulate Values + /// ignore it. pub fn native() -> Self { - Context + Context { sink: Sink::Stdout } + } + + /// A context that captures everything written through it in memory, for + /// use in tests; the bytes are read back with `captured()`. Touches no + /// terminal. + pub fn capture() -> Self { + Context { + sink: Sink::Capture(RefCell::new(Vec::new())), + } } /// Pass a slice of bytes through to the user immediately. This is the /// streaming primitive: a function teeing a child process's stdout reads /// it in chunks and writes each chunk here (while separately accumulating /// those bytes for its return value). No intermediate `String` is - /// allocated and a chunk split mid-UTF-8 is harmless. This calls - /// `flush()` so output appears to the user live. - #[allow(dead_code)] + /// allocated and a chunk split mid-UTF-8 is harmless. The terminal sink + /// calls `flush()` so output appears to the user live. pub fn write(&self, bytes: &[u8]) -> io::Result<()> { - let mut out = io::stdout(); - out.write_all(bytes)?; - out.flush() + match &self.sink { + Sink::Stdout => { + let mut out = io::stdout(); + out.write_all(bytes)?; + out.flush() + } + Sink::Capture(buffer) => { + buffer + .borrow_mut() + .extend_from_slice(bytes); + Ok(()) + } + } } /// Pass a complete, already known, text string through to the user. This @@ -36,4 +67,14 @@ impl Context { pub fn emit(&self, message: &str) -> io::Result<()> { self.write(message.as_bytes()) } + + /// The bytes captured by a `capture()` context; empty for a native one. + pub fn captured(&self) -> Vec { + match &self.sink { + Sink::Capture(buffer) => buffer + .borrow() + .clone(), + Sink::Stdout => Vec::new(), + } + } } diff --git a/src/runner/driver.rs b/src/runner/driver.rs index 48fe3024..638e9e1b 100644 --- a/src/runner/driver.rs +++ b/src/runner/driver.rs @@ -42,7 +42,8 @@ pub enum UserInput { } /// What the walker uses to drive a run. Implementations are the interactive -/// console `Console`, the no-operator `Automatic`, and the test `Mock`. +/// console `Console`, the no-operator `Automatic`, the no-output `Headless`, +/// and the test `Mock`. pub trait Driver { /// Show the step's Qualified Name and rendered description. /// The implementation displays them; it does not block waiting for @@ -70,6 +71,14 @@ pub trait Driver { /// the step's Qualified Name, repeated on the live prompt line. fn ask(&mut self, qualified: &str, choices: &[&str], produced: Value) -> UserInput; + /// Settle an external invocation this run cannot perform (a `` call + /// into another document or system). `Console` prompts the operator to + /// attest it — `Done` if it was performed or recorded elsewhere, otherwise + /// `Skip` / `Fail` / `Quit`. The unattended drivers return `Skip`: nothing + /// executed it and no one is present to vouch that it was done, so the run + /// records that this execution did not do it rather than fabricating a Done. + fn external(&mut self, qualified: &str) -> UserInput; + /// Gate a shell `exec` on the user's command: show the `script` to be run /// and settle on the user's verdict. `Done` means run it now; Skip / Fail /// decline the run and settle the step; Quit stops. `Automatic` runs @@ -153,6 +162,10 @@ impl Driver for Console { prompt(&mut self.output, qualified, "→", choices, produced) } + fn external(&mut self, qualified: &str) -> UserInput { + prompt(&mut self.output, qualified, "→", &[], Value::Unitus) + } + fn command(&mut self, qualified: &str, script: &str) -> UserInput { prompt_command(&mut self.output, qualified, script) } @@ -179,7 +192,12 @@ fn prompt( choices: &[&str], produced: Value, ) -> UserInput { - let result = interact(out, qualified, settle, Interaction::begin(choices, produced)); + let result = interact( + out, + qualified, + settle, + Interaction::begin(choices, produced), + ); let col = settle .chars() .count() as u16 @@ -226,7 +244,11 @@ fn prompt_command(out: &mut W, qualified: &str, script: &str) -> UserI out, qualified, "→", - Interaction { field, menu: None, reason: None }, + Interaction { + field, + menu: None, + reason: None, + }, ); let col = "→" .chars() @@ -260,7 +282,12 @@ fn prompt_command(out: &mut W, qualified: &str, script: &str) -> UserI /// Drive one raw-mode interaction to a settled `UserInput`, leaving the prompt /// row cleared. Shared by the step/scope prompt and the exec command gate; the /// caller writes whatever record line it wants afterward. -fn interact(out: &mut W, qualified: &str, settle: &str, mut interaction: Interaction) -> UserInput { +fn interact( + out: &mut W, + qualified: &str, + settle: &str, + mut interaction: Interaction, +) -> UserInput { // The interactive path is guarded on stdout being a terminal before the // walk begins, so a raw-mode failure here is an unexpected terminal fault // rather than a redirect; bail by quitting. @@ -600,10 +627,23 @@ impl Interaction { original, } => match code { KeyCode::Enter => { - if *edited { - Some(UserInput::Done(Value::Literali(std::mem::take(buffer)))) - } else { + if !*edited { + // Unchanged: return the original value verbatim, with + // its type and exact value intact. Some(UserInput::Done(std::mem::replace(original, Value::Unitus))) + } else if let Value::Quanticle(_) = original { + // An edited numeric value stays numeric: re-parse the + // buffer with the language's own number grammar. A + // buffer that is not a valid number is not accepted — + // the edit stays open for correction. + match crate::parsing::parse_numeric(buffer) { + Some(numeric) => Some(UserInput::Done(Value::Quanticle( + crate::value::Numeric::from(&numeric), + ))), + None => None, + } + } else { + Some(UserInput::Done(Value::Literali(std::mem::take(buffer)))) } } other => { @@ -643,7 +683,12 @@ impl Interaction { /// Layout: `{settle} {path} ▶ {content}` — the settle arrow and path are dark /// grey (matching the trace lines above), and ▶ is the shell-prompt character /// before the cursor/content area. -fn draw(out: &mut W, qualified: &str, settle: &str, interaction: &Interaction) -> io::Result<()> { +fn draw( + out: &mut W, + qualified: &str, + settle: &str, + interaction: &Interaction, +) -> io::Result<()> { queue!(out, cursor::MoveToColumn(0), Clear(ClearType::CurrentLine))?; let prefix = prompt_prefix_width(qualified, settle); @@ -886,6 +931,10 @@ impl Driver for Automatic { UserInput::Done(produced) } + fn external(&mut self, _qualified: &str) -> UserInput { + UserInput::Skip + } + fn command(&mut self, _qualified: &str, script: &str) -> UserInput { write_indented(&mut self.output, script); UserInput::Done(Value::Literali(script.to_string())) @@ -900,6 +949,59 @@ impl Driver for Automatic { } } +/// No-operator, no-output driver: takes each step and scope's computed value as +/// its result, emitting nothing, and counts the results it settles — one per +/// step and per structural-scope close. Lets a Technique be run without a +/// terminal, the result count read back from `results()`. +pub struct Headless { + results: usize, +} + +impl Headless { + pub fn new() -> Self { + Headless { results: 0 } + } + + pub fn results(&self) -> usize { + self.results + } +} + +impl Driver for Headless { + fn step(&mut self, _qualified: &str, _description: &str) {} + + fn enter(&mut self, _qualified: &str) {} + + fn display(&mut self, _content: &str) {} + + fn announce(&mut self, _message: &str) {} + + fn ask(&mut self, _qualified: &str, _choices: &[&str], produced: Value) -> UserInput { + self.results += 1; + UserInput::Done(produced) + } + + fn external(&mut self, _qualified: &str) -> UserInput { + self.results += 1; + UserInput::Skip + } + + fn command(&mut self, _qualified: &str, script: &str) -> UserInput { + UserInput::Done(Value::Literali(script.to_string())) + } + + fn section(&mut self, _qualified: &str, _numeral: &str, _title: &str) {} + + fn seal(&mut self, _qualified: &str, produced: Value) -> UserInput { + self.results += 1; + UserInput::Done(produced) + } + + fn renderer(&self) -> &'static dyn Render { + &Identity + } +} + /// Simulated prompt responses for test cases. Returns answers from a /// pre-loaded queue and records every announcement / step / section call so a /// test can assert what the walker tried to show. @@ -936,6 +1038,9 @@ pub enum Event { qualified: String, choices: Vec, }, + External { + qualified: String, + }, Seal { qualified: String, }, @@ -1014,6 +1119,16 @@ impl Driver for Mock { .expect("Mock::ask called with no canned answers remaining") } + fn external(&mut self, qualified: &str) -> UserInput { + self.events + .push(Event::External { + qualified: qualified.to_string(), + }); + self.answers + .pop_front() + .expect("Mock::external called with no canned answers remaining") + } + /// Records the command beat and auto-commands the run (`Done`) without /// draining the answer queue — the exec gate is orthogonal to the step /// verdicts a test drives. A test asserting gate behaviour inspects the diff --git a/src/runner/library.rs b/src/runner/library.rs index ec2098e4..01174b4b 100644 --- a/src/runner/library.rs +++ b/src/runner/library.rs @@ -140,7 +140,15 @@ impl Library { context: &Context, args: &[Value], ) -> Result { - (self.functions[id.0].function)(context, args) + let builtin = &self.functions[id.0]; + if args.len() != builtin.arity { + return Err(RunnerError::FunctionArityMismatch { + function: builtin.name, + expected: builtin.arity, + actual: args.len(), + }); + } + (builtin.function)(context, args) } } diff --git a/src/runner/mod.rs b/src/runner/mod.rs index 5a930b9c..9a30e8e6 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -17,15 +17,15 @@ mod runner; mod state; pub use context::Context; -pub use driver::Mode; +pub use driver::{Headless, Mode}; +pub use evaluator::Environment; pub use library::{Builtin, Library, Native}; -pub use runner::{Outcome, RunnerError}; -pub use state::{RecordError, RunId}; +pub use runner::{Outcome, Runner, RunnerError}; +pub use state::{Appender, RecordError, RunId}; use driver::{Automatic, Console}; -use evaluator::Environment; -use runner::{bind_parameters, now_iso8601, Runner}; -use state::{construct_state_path, Appender, Record, State, Store}; +use runner::{bind_parameters, now_iso8601}; +use state::{construct_state_path, Record, State, Store}; const STORE_ROOT: &str = ".store"; diff --git a/src/runner/path.rs b/src/runner/path.rs index 98a3260d..b4fced74 100644 --- a/src/runner/path.rs +++ b/src/runner/path.rs @@ -15,6 +15,7 @@ pub enum PathSegment<'i> { Iteration(usize), Attributes(&'i [language::Attribute<'i>]), Procedure(&'i str), + External(&'i str), } /// Absolute path from the document root to the walker's current @@ -77,6 +78,8 @@ fn render_segment(segment: &PathSegment) -> Option { PathSegment::Iteration(number) => Some(format!("[{}]", number)), // you can't "index" into it! PathSegment::Attributes(frame) => render_attributes(frame), PathSegment::Procedure(name) => Some(format!("{}:", name)), + // An external invocation target, addressed by its source `` form. + PathSegment::External(uri) => Some(format!("<{}>", uri)), } } diff --git a/src/runner/runner.rs b/src/runner/runner.rs index 037a6b63..894da899 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -75,6 +75,11 @@ pub enum RunnerError { expected: &'static str, }, UnknownFunction(String), + FunctionArityMismatch { + function: &'static str, + expected: usize, + actual: usize, + }, ExecError(io::Error), CommandFailed(i32), IncompatibleCombination { @@ -94,10 +99,9 @@ pub enum RunnerError { /// Execute a Technique interactively by walking the `Program` tree. Tracks /// the position in the document via a `QualifiedPath` stack, carries an -/// `Environment` with known result values. Maintains a set of -/// already-completed step FQNs, an append handle to write results, and the -/// prompt the operator interacts through. -#[allow(dead_code)] +/// `Environment` with known result values. Holds the set of step FQNs already +/// completed in a *prior* run — the resume snapshotplus an append handle to +/// write results and the prompt the operator interacts through. pub struct Runner<'i, D: Driver> { program: &'i Program<'i>, appender: Appender, @@ -127,13 +131,19 @@ impl<'i, D: Driver> Runner<'i, D> { } } - /// Consume the runner and return the inner driver. Tests use this - /// to assert on the Mock's event log after a run completes. - #[cfg(test)] + /// Consume the runner and return the inner driver after a run completes. + /// Used to read a `Headless` driver's result count or to assert on the + /// Mock's event log. pub fn into_driver(self) -> D { self.driver } + /// Consume the runner and return the inner appender after a run completes. + /// Used to read the recorded trail of an in-memory `Appender`. + pub fn into_appender(self) -> Appender { + self.appender + } + /// Walk the entry procedure top to bottom. Entry-procedure /// selection here is `program.subroutines[0]` — the synthetic /// anonymous wrapper if the document is top-level Steps, otherwise @@ -160,14 +170,16 @@ impl<'i, D: Driver> Runner<'i, D> { name, entry.parameters, entry.signature, - self.driver.renderer(), + self.driver + .renderer(), ); self.driver .display(&declaration); if let Some(t) = entry.title { let title_text = crate::formatting::formatter::render_title( t, - self.driver.renderer(), + self.driver + .renderer(), ); self.driver .display(&title_text); @@ -209,12 +221,12 @@ impl<'i, D: Driver> Runner<'i, D> { body, .. } => self.walk_section(env, numeral, title.as_deref(), body), + // Every body the translator emits is a `Sequence`, so a Step is + // always reached as one of its members, where `walk_sequence` + // supplies the parallel ordinal counter. A bare Step never reaches + // `walk` directly. Operation::Step { .. } => { - // Dependent vs Parallel ordinal index needs the - // surrounding Sequence's parallel counter; a Step - // encountered outside a Sequence (i.e. as the entire - // body of a procedure) is treated as Dependent. - self.walk_step(env, op, 0) + unreachable!("a Step is always walked as a Sequence member") } Operation::Loop { names, over, body, .. @@ -430,14 +442,16 @@ impl<'i, D: Driver> Runner<'i, D> { name, subroutine.parameters, subroutine.signature, - self.driver.renderer(), + self.driver + .renderer(), ); self.driver .display(&declaration); if let Some(t) = subroutine.title { let title_text = crate::formatting::formatter::render_title( t, - self.driver.renderer(), + self.driver + .renderer(), ); self.driver .display(&title_text); @@ -473,9 +487,73 @@ impl<'i, D: Driver> Runner<'i, D> { Ok(Outcome::Done(Value::Unitus)) } SubroutineRef::Deferred(ext) => { + // An external target lives in another document or system, so + // this run cannot descend into it. Record the call site, then + // present the invocation as its own node for the operator to + // settle: Done if they performed (or recorded elsewhere) the + // external procedure, otherwise Skip or Fail. An unattended + // (automatic) run records Skip — nothing executed it and no one + // is present to attest it, so it is not marked Done. + let run_id = self + .appender + .run_id(); + let caller = self + .path + .render(); + self.appender + .append(&Record { + recorded: now_iso8601(), + run_id, + path: caller, + state: State::Invoke(InvokeTarget::Uri( + ext.value + .to_string(), + )), + })?; + + self.path + .push(PathSegment::External(ext.value)); + let qualified = self + .path + .render(); + if self + .completed + .contains(&qualified) + { + self.path + .pop(); + return Ok(Outcome::Done(Value::Unitus)); + } + self.driver .announce(&format!("<{}>", ext.value)); - Ok(Outcome::Done(Value::Unitus)) + let input = self + .driver + .external(&qualified); + if let UserInput::Quit = input { + self.appender + .append(&Record { + recorded: now_iso8601(), + run_id, + path: "/".to_string(), + state: State::Stop, + })?; + self.path + .pop(); + return Ok(Outcome::Stopped); + } + + let outcome = outcome_from(input); + self.appender + .append(&Record { + recorded: now_iso8601(), + run_id, + path: qualified, + state: record_state(&outcome), + })?; + self.path + .pop(); + Ok(outcome) } } } @@ -520,6 +598,9 @@ impl<'i, D: Driver> Runner<'i, D> { Value::Arraeum(items) => items, // A scalar in list context is a singleton list. value @ (Value::Literali(_) | Value::Quanticle(_)) => vec![value], + // Unit is the absence of a value, so there is nothing + // to iterate: the body runs zero times. + Value::Unitus => Vec::new(), // A tablet is a record, not a sequence, so it does not // iterate directly. _ => return Err(RunnerError::NotIterable), @@ -663,14 +744,7 @@ impl<'i, D: Driver> Runner<'i, D> { .path .render(); - let result = self.perform_step( - env, - &qualified, - ordinal, - body, - source, - responses, - ); + let result = self.perform_step(env, &qualified, ordinal, body, source, responses); self.path .pop(); @@ -715,7 +789,8 @@ impl<'i, D: Driver> Runner<'i, D> { let step_text = crate::formatting::formatter::render_scope( source, - self.driver.renderer(), + self.driver + .renderer(), ); self.driver @@ -736,8 +811,6 @@ impl<'i, D: Driver> Runner<'i, D> { }; self.appender .append(&record)?; - self.completed - .insert(qualified.to_string()); return Ok(settled); } }; @@ -775,8 +848,6 @@ impl<'i, D: Driver> Runner<'i, D> { }; self.appender .append(&record)?; - self.completed - .insert(qualified.to_string()); Ok(outcome) } @@ -813,13 +884,10 @@ impl<'i, D: Driver> Runner<'i, D> { }; self.appender .append(&record)?; - self.completed - .insert(qualified.to_string()); Ok(outcome) } } - fn describe_loop( names: &[crate::language::Identifier<'_>], over: Option<&Operation<'_>>, @@ -862,9 +930,15 @@ fn record_state(outcome: &Outcome) -> State { } Outcome::Done(_) => State::Done(Some(RecordValue::Unit)), Outcome::Skipped => State::Skip, - Outcome::Failed(Failure::Aborted(reason)) => State::Fail(Some(RecordValue::Tablet( - format!("[ reason = \"{}\" ]", reason), - ))), + Outcome::Failed(Failure::Aborted(reason)) => { + if reason.is_empty() { + // The operator failed the step without giving a reason; record + // the failure with no reason rather than an empty-string one. + State::Fail(None) + } else { + State::Fail(Some(super::state::fail_reason(reason))) + } + } Outcome::Stopped => { unreachable!("Stop is recorded as a lifecycle event, not a step result") } diff --git a/src/runner/state.rs b/src/runner/state.rs index b05dfabe..74a04694 100644 --- a/src/runner/state.rs +++ b/src/runner/state.rs @@ -278,14 +278,22 @@ pub(crate) fn construct_state_path(run_dir: &Path, document: &Path) -> PathBuf { run_dir.join(name) } +/// Where an `Appender` sends its records, normally an append-only PFFTT file +/// in the store, or an in-memory sink for test runs that keep no persistent +/// state. +enum Target { + File { file: std::fs::File, path: PathBuf }, + Memory(String), + Discard, +} + /// Append-only writer for a PFFTT file. Used by the runner to append a /// record for each step boundary and lifecycle event. Carries the -/// `RunId` so callers can stamp it onto records without plumbing it +/// `RunId` so callers can stamp it onto records. /// through every layer. #[allow(dead_code)] pub struct Appender { - file: std::fs::File, - path: PathBuf, + target: Target, run_id: RunId, } @@ -301,7 +309,36 @@ impl Appender { path: path.clone(), error, })?; - Ok(Appender { file, path, run_id }) + Ok(Appender { + target: Target::File { file, path }, + run_id, + }) + } + + /// An Appender that discards every record for use in tests. + pub fn sink() -> Self { + Appender { + target: Target::Discard, + run_id: RunId(0), + } + } + + /// An Appender that captures every record in memory for use in tests, + /// readable afterwards with `contents()`. Touches no filesystem. + pub fn memory() -> Self { + Appender { + target: Target::Memory(String::new()), + run_id: RunId(0), + } + } + + /// The records captured by an in-memory Appender (`memory()`), as the raw + /// PFFTT text; empty for a file or discarding Appender. + pub fn contents(&self) -> &str { + match &self.target { + Target::Memory(buffer) => buffer, + _ => "", + } } /// The `RunId` this Appender is writing records for. @@ -314,14 +351,19 @@ impl Appender { pub fn append(&mut self, record: &Record) -> Result<(), RunnerError> { use std::io::Write; let text = format_record(record); - self.file - .write_all(text.as_bytes()) - .map_err(|error| RunnerError::StoreError { - path: self - .path - .clone(), - error, - }) + match &mut self.target { + Target::File { file, path } => file + .write_all(text.as_bytes()) + .map_err(|error| RunnerError::StoreError { + path: path.clone(), + error, + }), + Target::Memory(buffer) => { + buffer.push_str(&text); + Ok(()) + } + Target::Discard => Ok(()), + } } } @@ -459,6 +501,17 @@ fn unescape_literal(text: &str) -> Result { Ok(out) } +// Build the `Value` recorded for a failed step: a single-entry tablet +// `[ reason = "" ]`. The reason is operator free text, so it is +// escaped exactly as a literal field is, keeping the record on one line and +// proof against an embedded quote, backslash, or newline. +pub(crate) fn fail_reason(reason: &str) -> Value { + let mut text = String::from("[ reason = \""); + escape_literal(&mut text, reason); + text.push_str("\" ]"); + Value::Tablet(text) +} + // Parse a single PFFTT record line into a Record. pub(crate) fn parse_record(line: &str) -> Result { let line = line.trim_end_matches(['\r', '\n']); diff --git a/src/translation/checks/translate.rs b/src/translation/checks/translate.rs index 119ffc83..d078f475 100644 --- a/src/translation/checks/translate.rs +++ b/src/translation/checks/translate.rs @@ -877,6 +877,37 @@ run : assert_eq!(inner.len(), 2); } +#[test] +fn top_level_foreach_codeblock_nests_subscopes() { + // A foreach code block at procedure-body level (no enclosing attribute) + // owns the steps below it as its loop body, the same as the scoped case. + let source = r#" +% technique v1 + +run : + +{ foreach node in seq(1, 6) } + 1. Check Availability + 2. Confirm. + "# + .trim_ascii(); + let path = Path::new("Test.tq"); + let document = parsing::parse(path, source).expect("parse"); + let program = translate(&document).expect("translate"); + + let Operation::Sequence(ops) = &program.subroutines[0].body else { + panic!("expected Sequence"); + }; + let Operation::Loop { names, body, .. } = &ops[0] else { + panic!("expected Loop, got {:?}", ops[0]); + }; + assert_eq!(names[0].value, "node"); + let Operation::Sequence(inner) = body.as_ref() else { + panic!("expected inner Sequence"); + }; + assert_eq!(inner.len(), 2); +} + #[test] fn foreach_with_tuple_names_borrows_all() { let source = r#" @@ -1416,7 +1447,9 @@ run : #[test] fn expression_repeat_translates() { - // `{ repeat 5 }` becomes Loop with names=[], over=None, body=Number(5). + // A bare `{ repeat }` with no subscopes to supply its body translates to a + // Loop with names=[], over=None, and an empty Sequence body — the body is + // provided by the code block's subscopes, as for `foreach` above. let source = r#" % technique v1 @@ -1446,9 +1479,10 @@ run : assert!(names.is_empty()); assert!(over.is_none(), "repeat has no `over` source"); assert!(responses.is_empty()); - let Operation::Number(_) = body.as_ref() else { - panic!("expected Number body"); + let Operation::Sequence(inner) = body.as_ref() else { + panic!("expected empty Sequence body"); }; + assert!(inner.is_empty()); } #[test] diff --git a/src/translation/translator.rs b/src/translation/translator.rs index 8cf819a9..8ff49606 100644 --- a/src/translation/translator.rs +++ b/src/translation/translator.rs @@ -186,9 +186,17 @@ impl<'i> Translator<'i> { self.append_attributes(&mut ops, &mut responses, scope, &[]); } } - language::Element::CodeBlock(expressions, _) => { - for expression in expressions { - ops.push(self.translate_expression(expression)); + language::Element::CodeBlock(expressions, subscopes, _) => { + match self.translate_loop_block(expressions, subscopes, &[]) { + Some(loop_op) => ops.push(loop_op), + None => { + for expression in expressions { + ops.push(self.translate_expression(expression)); + } + for sub in subscopes { + self.append_attributes(&mut ops, &mut responses, sub, &[]); + } + } } } _ => {} @@ -312,50 +320,63 @@ impl<'i> Translator<'i> { expressions, subscopes, .. - } => { - let single = expressions.len() == 1; - match expressions.first() { - Some(language::Expression::Foreach(names, source, _)) if single => { - let mut body_ops = Vec::new(); - let mut responses = Vec::new(); - for sub in subscopes { - self.append_attributes(&mut body_ops, &mut responses, sub, attrs); - } - Operation::Loop { - names, - over: Some(Box::new(self.translate_expression(source))), - body: Box::new(Operation::Sequence(body_ops)), - responses, - } + } => match self.translate_loop_block(expressions, subscopes, attrs) { + Some(loop_op) => loop_op, + None => { + let mut ops = Vec::new(); + for expression in expressions { + ops.push(self.translate_expression(expression)); } - Some(language::Expression::Repeat(_, _)) if single => { - let mut body_ops = Vec::new(); - let mut responses = Vec::new(); - for sub in subscopes { - self.append_attributes(&mut body_ops, &mut responses, sub, attrs); - } - Operation::Loop { - names: &[], - over: None, - body: Box::new(Operation::Sequence(body_ops)), - responses, - } + let mut responses = Vec::new(); + for sub in subscopes { + self.append_attributes(&mut ops, &mut responses, sub, attrs); } - _ => { - let mut ops = Vec::new(); - for expression in expressions { - ops.push(self.translate_expression(expression)); - } - let mut responses = Vec::new(); - for sub in subscopes { - self.append_attributes(&mut ops, &mut responses, sub, attrs); - } + Operation::Sequence(ops) + } + }, + } + } - let _ = responses; - Operation::Sequence(ops) - } + // Build the `Loop` for a foreach or repeat code block, its subscopes + // forming the body. Returns None for any other code block, which the + // caller translates as a plain sequence of its expressions. + fn translate_loop_block( + &mut self, + expressions: &'i [language::Expression<'i>], + subscopes: &'i [language::Scope<'i>], + attrs: &[&'i [language::Attribute<'i>]], + ) -> Option> { + if expressions.len() != 1 { + return None; + } + match &expressions[0] { + language::Expression::Foreach(names, source, _) => { + let mut body_ops = Vec::new(); + let mut responses = Vec::new(); + for sub in subscopes { + self.append_attributes(&mut body_ops, &mut responses, sub, attrs); } + Some(Operation::Loop { + names, + over: Some(Box::new(self.translate_expression(source))), + body: Box::new(Operation::Sequence(body_ops)), + responses, + }) + } + language::Expression::Repeat(_, _) => { + let mut body_ops = Vec::new(); + let mut responses = Vec::new(); + for sub in subscopes { + self.append_attributes(&mut body_ops, &mut responses, sub, attrs); + } + Some(Operation::Loop { + names: &[], + over: None, + body: Box::new(Operation::Sequence(body_ops)), + responses, + }) } + _ => None, } } @@ -540,7 +561,7 @@ impl<'i> Translator<'i> { description = Some(paragraphs.as_slice()); } } - language::Element::Steps(_, _) | language::Element::CodeBlock(_, _) => { + language::Element::Steps(_, _) | language::Element::CodeBlock(..) => { blocked = true; } } diff --git a/tests/formatting/formatter.rs b/tests/formatting/formatter.rs index 7e887968..07b1bc75 100644 --- a/tests/formatting/formatter.rs +++ b/tests/formatting/formatter.rs @@ -221,6 +221,7 @@ win_le_tour : Bicycle -> YellowJersey }, Span::default(), )], + vec![], Span::default(), )], span: Span::default(), diff --git a/tests/formatting/golden.rs b/tests/formatting/golden.rs index d5c869e5..200f3831 100644 --- a/tests/formatting/golden.rs +++ b/tests/formatting/golden.rs @@ -97,6 +97,6 @@ fn check_directory(dir: &Path) { #[test] fn ensure_identical_output() { - check_directory(Path::new("tests/golden/")); + check_directory(Path::new("tests/golden/parsing/")); check_directory(Path::new("examples/prototype/")); } diff --git a/tests/golden/Bedtime.tq b/tests/golden/parsing/Bedtime.tq similarity index 100% rename from tests/golden/Bedtime.tq rename to tests/golden/parsing/Bedtime.tq diff --git a/tests/golden/ExampleOfEverything.tq b/tests/golden/parsing/ExampleOfEverything.tq similarity index 100% rename from tests/golden/ExampleOfEverything.tq rename to tests/golden/parsing/ExampleOfEverything.tq diff --git a/tests/golden/ManyAttributes.tq b/tests/golden/parsing/ManyAttributes.tq similarity index 100% rename from tests/golden/ManyAttributes.tq rename to tests/golden/parsing/ManyAttributes.tq diff --git a/tests/integration.rs b/tests/integration.rs index f32a8dad..8871f692 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -2,5 +2,6 @@ mod common; mod formatting; mod linking; mod parsing; +mod runner; mod templating; mod translation; diff --git a/tests/runner/mod.rs b/tests/runner/mod.rs new file mode 100644 index 00000000..74f2ea66 --- /dev/null +++ b/tests/runner/mod.rs @@ -0,0 +1 @@ +mod samples; diff --git a/tests/runner/samples.rs b/tests/runner/samples.rs new file mode 100644 index 00000000..cb0f869a --- /dev/null +++ b/tests/runner/samples.rs @@ -0,0 +1,138 @@ +use std::collections::HashSet; +use std::fs; +use std::path::Path; + +use technique::parsing; +use technique::runner::{Appender, Environment, Headless, Library, Outcome, Runner}; +use technique::translation; + +use crate::common::list_technique_documents; + +// Strip the volatile leading fields — timestamp and run-id — from each +// recorded PFFTT line, leaving the ` ` tail. That tail is what +// the expected trail pins; the timestamp and run-id vary from one run to the +// next. +fn strip_timestamp_and_runid(trail: &str) -> Vec { + trail + .lines() + .map(|line| { + line.splitn(3, ' ') + .nth(2) + .unwrap_or(line) + .to_string() + }) + .collect() +} + +/// Run every sample to completion headless, capturing the trail in memory, +/// and assert two things: the run finishes `Done`, and the recorded walk +/// matches the expected `.pfftt` checked in beside the sample. The walk +/// records pin each step's qualified path and outcome in walk order, so a +/// wrong path, a missing seal, a dropped iteration segment, or a reordered +/// walk is caught. The in-memory capture holds only the walk (the store layer +/// writes Start), so the first line is skipped when comparing. A sample +/// without a matching `.pfftt` also fails. +#[test] +fn ensure_run() { + let dir = Path::new("tests/samples/runner/"); + let files = list_technique_documents(dir); + + let mut failures = Vec::new(); + + for file in &files { + let content = parsing::load(&file) + .unwrap_or_else(|e| panic!("Failed to load file {:?}: {:?}", file, e)); + + let document = match parsing::parse(&file, &content) { + Ok(document) => document, + Err(e) => { + println!("File {:?} failed to parse: {:?}", file, e); + failures.push(file.clone()); + continue; + } + }; + + let program = match translation::translate(&document) { + Ok(program) => program, + Err(e) => { + println!("File {:?} failed to translate: {:?}", file, e); + failures.push(file.clone()); + continue; + } + }; + + let mut library = Library::core(); + library.extend(Library::system()); + let mut runner = Runner::new( + &program, + Appender::memory(), + HashSet::new(), + Headless::new(), + library, + ); + let outcome = match runner.run(Environment::new()) { + Ok(outcome) => outcome, + Err(e) => { + println!("File {:?} did not run cleanly: {:?}", file, e); + failures.push(file.clone()); + continue; + } + }; + let recorded = strip_timestamp_and_runid( + runner + .into_appender() + .contents(), + ); + + // The expected file is a complete, valid PFFTT trail; its first line is + // the opening Start lifecycle record, which the in-memory walk capture + // does not include, so skip it before comparing the walk records. + let expected_path = file.with_extension("pfftt"); + let expected_text = fs::read_to_string(&expected_path).unwrap_or_else(|e| { + panic!( + "missing expected trail {:?}: {:?} — add the .pfftt beside the sample", + expected_path, e + ) + }); + let expected: Vec = strip_timestamp_and_runid(&expected_text) + .into_iter() + .skip(1) + .collect(); + + let finished = if let Outcome::Done(_) = outcome { + true + } else { + println!("File {:?} did not finish Done: {:?}", file, outcome); + false + }; + + if !finished || recorded != expected { + println!("\nTrail mismatch for {:?}", file); + println!("--- expected\n+++ recorded"); + let max = recorded + .len() + .max(expected.len()); + for i in 0..max { + let e = expected + .get(i) + .map(String::as_str) + .unwrap_or(""); + let r = recorded + .get(i) + .map(String::as_str) + .unwrap_or(""); + if e != r { + println!("@@ line {} @@\n- {}\n+ {}", i + 1, e, r); + } + } + failures.push(file.clone()); + } + } + + if !failures.is_empty() { + panic!( + "Sample runs must finish Done and match their expected trail, but {} files failed", + failures.len() + ); + } +} diff --git a/tests/samples/runner/InspectHatches.pfftt b/tests/samples/runner/InspectHatches.pfftt new file mode 100644 index 00000000..41144ba4 --- /dev/null +++ b/tests/samples/runner/InspectHatches.pfftt @@ -0,0 +1,20 @@ +2026-06-14T05:58:08.700Z 000001 / Start file://tests/samples/runner/InspectHatches.tq +2026-06-14T05:58:08.701Z 000001 /inspect_access_hatches:/[1]/1 Begin +2026-06-14T05:58:08.701Z 000001 /inspect_access_hatches:/[1]/1 Done () +2026-06-14T05:58:08.701Z 000001 /inspect_access_hatches:/[1]/2 Begin +2026-06-14T05:58:08.701Z 000001 /inspect_access_hatches:/[1]/2 Done () +2026-06-14T05:58:08.701Z 000001 /inspect_access_hatches:/[1]/3 Begin +2026-06-14T05:58:08.701Z 000001 /inspect_access_hatches:/[1]/3 Done () +2026-06-14T05:58:08.701Z 000001 /inspect_access_hatches:/[2]/1 Begin +2026-06-14T05:58:08.701Z 000001 /inspect_access_hatches:/[2]/1 Done () +2026-06-14T05:58:08.701Z 000001 /inspect_access_hatches:/[2]/2 Begin +2026-06-14T05:58:08.701Z 000001 /inspect_access_hatches:/[2]/2 Done () +2026-06-14T05:58:08.701Z 000001 /inspect_access_hatches:/[2]/3 Begin +2026-06-14T05:58:08.701Z 000001 /inspect_access_hatches:/[2]/3 Done () +2026-06-14T05:58:08.701Z 000001 /inspect_access_hatches:/[3]/1 Begin +2026-06-14T05:58:08.701Z 000001 /inspect_access_hatches:/[3]/1 Done () +2026-06-14T05:58:08.701Z 000001 /inspect_access_hatches:/[3]/2 Begin +2026-06-14T05:58:08.701Z 000001 /inspect_access_hatches:/[3]/2 Done () +2026-06-14T05:58:08.701Z 000001 /inspect_access_hatches:/[3]/3 Begin +2026-06-14T05:58:08.701Z 000001 /inspect_access_hatches:/[3]/3 Done () +2026-06-14T05:58:08.701Z 000001 /inspect_access_hatches: Done () diff --git a/tests/samples/runner/InspectHatches.tq b/tests/samples/runner/InspectHatches.tq new file mode 100644 index 00000000..eaa74641 --- /dev/null +++ b/tests/samples/runner/InspectHatches.tq @@ -0,0 +1,12 @@ +% technique v1 + +inspect_access_hatches : + +# Inspect each access panel + +{ + foreach panel in [ "east", "north", "west" ] +} + 1. Open panel + 2. Check seals + 3. Close panel diff --git a/tests/samples/runner/PatioCleaning.pfftt b/tests/samples/runner/PatioCleaning.pfftt new file mode 100644 index 00000000..00dc55cd --- /dev/null +++ b/tests/samples/runner/PatioCleaning.pfftt @@ -0,0 +1,67 @@ +2026-06-14T05:58:08.773Z 000002 / Start file://tests/samples/runner/PatioCleaning.tq +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/I Invoke setup_machine: +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/I/setup_machine:/1 Begin +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/I/setup_machine:/1/a Begin +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/I/setup_machine:/1/a Done () +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/I/setup_machine:/1/b Begin +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/I/setup_machine:/1/b Done () +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/I/setup_machine:/1 Done () +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/I/setup_machine:/2 Begin +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/I/setup_machine:/2/c Begin +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/I/setup_machine:/2/c Done () +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/I/setup_machine:/2/d Begin +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/I/setup_machine:/2/d Done () +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/I/setup_machine:/2/e Begin +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/I/setup_machine:/2/e Done () +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/I/setup_machine:/2 Done () +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/I/setup_machine:/3 Begin +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/I/setup_machine:/3/f Begin +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/I/setup_machine:/3/f Done () +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/I/setup_machine:/3/g Begin +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/I/setup_machine:/3/g Done () +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/I/setup_machine:/3/h Begin +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/I/setup_machine:/3/h Done () +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/I/setup_machine:/3 Done () +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/I/setup_machine:/4 Begin +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/I/setup_machine:/4 Done () +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/I/setup_machine:/5 Begin +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/I/setup_machine:/5 Done () +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/I/setup_machine: Done () +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/I Done () +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/II Invoke clean_patio: +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/II/clean_patio: Done () +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/II Done () +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/III Invoke tidy_up: +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/III/tidy_up:/1 Begin +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/III/tidy_up:/1 Done () +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/III/tidy_up:/2 Begin +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/III/tidy_up:/2 Done () +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/III/tidy_up:/3 Begin +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/III/tidy_up:/3/a Begin +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/III/tidy_up:/3/a Done () +2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/III/tidy_up:/3/b Begin +2026-06-14T05:58:08.774Z 000002 /high_pressure_cleaning:/III/tidy_up:/3/b Done () +2026-06-14T05:58:08.774Z 000002 /high_pressure_cleaning:/III/tidy_up:/3/c Begin +2026-06-14T05:58:08.774Z 000002 /high_pressure_cleaning:/III/tidy_up:/3/c Done () +2026-06-14T05:58:08.774Z 000002 /high_pressure_cleaning:/III/tidy_up:/3 Done () +2026-06-14T05:58:08.774Z 000002 /high_pressure_cleaning:/III/tidy_up:/4 Begin +2026-06-14T05:58:08.774Z 000002 /high_pressure_cleaning:/III/tidy_up:/4/d Begin +2026-06-14T05:58:08.774Z 000002 /high_pressure_cleaning:/III/tidy_up:/4/d Done () +2026-06-14T05:58:08.774Z 000002 /high_pressure_cleaning:/III/tidy_up:/4/e Begin +2026-06-14T05:58:08.774Z 000002 /high_pressure_cleaning:/III/tidy_up:/4/e Done () +2026-06-14T05:58:08.774Z 000002 /high_pressure_cleaning:/III/tidy_up:/4 Done () +2026-06-14T05:58:08.774Z 000002 /high_pressure_cleaning:/III/tidy_up:/5 Begin +2026-06-14T05:58:08.774Z 000002 /high_pressure_cleaning:/III/tidy_up:/5/f Begin +2026-06-14T05:58:08.774Z 000002 /high_pressure_cleaning:/III/tidy_up:/5/f Done () +2026-06-14T05:58:08.774Z 000002 /high_pressure_cleaning:/III/tidy_up:/5/g Begin +2026-06-14T05:58:08.774Z 000002 /high_pressure_cleaning:/III/tidy_up:/5/g Done () +2026-06-14T05:58:08.774Z 000002 /high_pressure_cleaning:/III/tidy_up:/5 Done () +2026-06-14T05:58:08.774Z 000002 /high_pressure_cleaning:/III/tidy_up:/6 Begin +2026-06-14T05:58:08.774Z 000002 /high_pressure_cleaning:/III/tidy_up:/6/h Begin +2026-06-14T05:58:08.774Z 000002 /high_pressure_cleaning:/III/tidy_up:/6/h Done () +2026-06-14T05:58:08.774Z 000002 /high_pressure_cleaning:/III/tidy_up:/6/j Begin +2026-06-14T05:58:08.774Z 000002 /high_pressure_cleaning:/III/tidy_up:/6/j Done () +2026-06-14T05:58:08.774Z 000002 /high_pressure_cleaning:/III/tidy_up:/6 Done () +2026-06-14T05:58:08.774Z 000002 /high_pressure_cleaning:/III/tidy_up: Done () +2026-06-14T05:58:08.774Z 000002 /high_pressure_cleaning:/III Done () +2026-06-14T05:58:08.774Z 000002 /high_pressure_cleaning: Done () diff --git a/tests/samples/runner/PatioCleaning.tq b/tests/samples/runner/PatioCleaning.tq new file mode 100644 index 00000000..9a8c7c04 --- /dev/null +++ b/tests/samples/runner/PatioCleaning.tq @@ -0,0 +1,52 @@ +% technique v1 +& checklist + +high_pressure_cleaning : + +I. Setup + +setup_machine : + +# Setup Machine + + 1. Water supply + a. Unroll garden water hose + b. Attach water hose to machine + 2. Sprayer + c. Unroll machine black water hose + d. Assemble wand + e. Connect wand to black water hose + 3. Power + f. Extend machine power cord + g. Uncoil extension power cord + h. Connect to mains + 4. Turn on water tap + 5. Turn on machine + +II. Cleaning + +clean_patio : + +# Perform Cleaning + +III. Finish + +tidy_up : + +# Tidy everything away + + 1. Turn off machine + 2. Turn off water tap + 3. Water supply + a. Drain nozzle pressure + b. Detach water hose from machine + c. Roll up garden water hose + 4. Sprayer + d. Disassemble wand + e. Roll up machine black water hose + 5. Power + f. Roll up machine power cord + g. Coil extension power cord + 6. Return equipment to storage + h. Put machine back + j. Put accessories away