use std::collections::BTreeMap;
use std::fs;
use aion_awl::doc::{DocumentDoc, SourceState};
use aion_package::{ExtractionLimits, Package};
use super::{DocFormat, json, render};
const DOCUMENTED: &str = "\
//! Grading a batch.
workflow grade
/// The essay under review.
input essay: String
/// How far along the marking is.
query progress: String
/// The mark the grader settled on.
outcome graded: type String, route success
/// A grade.
type Grade =
/// Nothing to fix.
| Pass
| Fail
worker marker
/// Read the essay.
action mark(
/// The text to read.
essay: String,
) -> Grade
/// Read it once.
step mark
mark(essay: essay) -> grade
answer progress(\"done\")
outcome graded: else, route graded(\"done\")
";
const ARCHIVE_ONLY_ROOT_FIELDS: &[&str] = &["content_hash", "contract"];
const ARCHIVE_UNSUPPLIABLE_CONTRACT_FIELDS: &[&str] = &["query_schema", "unattributable"];
fn source_model() -> anyhow::Result<DocumentDoc> {
Ok(aion_awl::doc::derive(DOCUMENTED, &BTreeMap::new())?)
}
fn archive_model() -> anyhow::Result<DocumentDoc> {
let temp = tempfile::tempdir()?;
let prepared =
aion_awl_package::compile_and_assemble_awl(DOCUMENTED, temp.path(), "grade.awl")?;
let package = Package::load_from_bytes(prepared.archive, ExtractionLimits::unbounded())?;
let entry = package.manifest().entry_module.clone();
Ok(aion_awl::doc::from_package(&package, &entry)?)
}
#[test]
fn the_model_derives_from_a_published_archive_alone() -> anyhow::Result<()> {
let model = archive_model()?;
assert_eq!(model.source_state, SourceState::Available);
let prose = model
.prose
.as_ref()
.ok_or_else(|| anyhow::anyhow!("the archive carries the authored document"))?;
assert_eq!(
prose.narration.as_deref(),
Some("Grading a batch."),
"the archived document's narration reaches the model"
);
let input = prose
.declarations
.iter()
.find(|declaration| declaration.kind == aion_awl::doc::DeclarationKind::Input)
.ok_or_else(|| anyhow::anyhow!("the model names the document's input"))?;
assert_eq!(
input.documentation.as_deref(),
Some("The essay under review.")
);
assert!(
model.content_hash.is_some(),
"a model derived for a revision names that revision"
);
Ok(())
}
#[test]
fn the_archive_and_the_source_agree_outside_the_enumerated_fields() -> anyhow::Result<()> {
let from_source = serde_json::to_value(source_model()?)?;
let from_archive = serde_json::to_value(archive_model()?)?;
let (Some(source_object), Some(archive_object)) =
(from_source.as_object(), from_archive.as_object())
else {
anyhow::bail!("the model serialises as a JSON object");
};
assert_eq!(
source_object.keys().collect::<Vec<_>>(),
archive_object.keys().collect::<Vec<_>>(),
"both paths produce the same field set"
);
for key in source_object.keys() {
if ARCHIVE_ONLY_ROOT_FIELDS.contains(&key.as_str()) {
continue;
}
assert_eq!(
source_object.get(key),
archive_object.get(key),
"`{key}` differs between the archive path and the source path, and is not one of the \
enumerated fields the archive answers differently"
);
}
assert_ne!(
source_object.get("content_hash"),
archive_object.get("content_hash"),
"`content_hash` is enumerated because the source path has none; if they now agree, the \
exemption is stale"
);
Ok(())
}
#[test]
fn the_contract_exemption_is_one_named_field_and_not_a_blanket() -> anyhow::Result<()> {
let source = serde_json::to_value(source_model()?)?;
let archive = serde_json::to_value(archive_model()?)?;
let (Some(source_contract), Some(archive_contract)) = (
source
.get("contract")
.and_then(serde_json::Value::as_object),
archive
.get("contract")
.and_then(serde_json::Value::as_object),
) else {
anyhow::bail!("the contract half serialises as a JSON object");
};
assert_eq!(
source_contract.keys().collect::<Vec<_>>(),
archive_contract.keys().collect::<Vec<_>>(),
"both paths produce the same contract field set"
);
for key in source_contract.keys() {
if ARCHIVE_UNSUPPLIABLE_CONTRACT_FIELDS.contains(&key.as_str()) {
continue;
}
assert_eq!(
source_contract.get(key),
archive_contract.get(key),
"contract field `{key}` differs between the archive path and the source path and is \
not named as archive-unsuppliable"
);
}
Ok(())
}
#[test]
fn a_deployed_revision_states_the_absence_of_its_query_schema() -> anyhow::Result<()> {
let archive = archive_model()?;
assert!(
!archive.contract.reports(aion_awl::doc::QUERY_SCHEMA_FIELD),
"the archive path cannot supply a query schema and must say so"
);
let reason = archive
.contract
.why_unreported(aion_awl::doc::QUERY_SCHEMA_FIELD)
.ok_or_else(|| anyhow::anyhow!("the absence must carry its reason"))?;
assert!(
reason.contains("content hash"),
"the reason must say why it is not attributable: {reason}"
);
let source = source_model()?;
assert!(
source.contract.reports(aion_awl::doc::QUERY_SCHEMA_FIELD),
"the source path supplies it, so the absence is the ARCHIVE's and not the model's"
);
assert!(
source.contract.query_schema.is_some(),
"the fixture declares a query, so the source path derives its schema"
);
let page = render(&archive, DocFormat::Html).map_err(|error| anyhow::anyhow!(error))?;
assert!(
page.contains("data-unreported=\"query_schema\""),
"the HTML page states it"
);
assert!(page.contains("Not reported for this revision"));
let markdown = render(&archive, DocFormat::Markdown).map_err(|error| anyhow::anyhow!(error))?;
assert!(
markdown.contains("Not reported for this revision"),
"the Markdown states it"
);
Ok(())
}
#[test]
fn the_json_form_is_the_models_own_serialisation() -> anyhow::Result<()> {
let model = source_model()?;
let rendered = render(&model, DocFormat::Json).map_err(|error| anyhow::anyhow!(error))?;
let direct = json(&model).map_err(|error| anyhow::anyhow!(error))?;
assert_eq!(
rendered, direct,
"the `--json` form must BE the model's serialisation, never a second shape"
);
let decoded: DocumentDoc = serde_json::from_str(&rendered)?;
assert_eq!(decoded, model, "the JSON round-trips back to the model");
assert!(rendered.ends_with('\n'));
Ok(())
}
#[test]
fn every_rendering_is_byte_identical_for_byte_identical_input() -> anyhow::Result<()> {
let first = source_model()?;
let second = source_model()?;
for format in [
DocFormat::Html,
DocFormat::Json,
DocFormat::Markdown,
DocFormat::Plain,
] {
let left = render(&first, format).map_err(|error| anyhow::anyhow!(error))?;
let right = render(&second, format).map_err(|error| anyhow::anyhow!(error))?;
assert_eq!(left, right, "{format:?} is not byte-stable");
}
Ok(())
}
#[test]
fn the_html_page_is_self_contained_and_marks_its_provenance() -> anyhow::Result<()> {
let page = render(&source_model()?, DocFormat::Html).map_err(|error| anyhow::anyhow!(error))?;
assert!(page.starts_with("<!doctype html>"));
assert!(page.contains("<svg "), "the step graph is inline SVG");
assert!(
!page.contains("<script"),
"the page carries no script: there is no JS runtime in the CLI and none in the page"
);
for fetch in ["src=\"", "href=\"http", "<link", "@import", "url(http"] {
assert!(
!page.contains(fetch),
"the page fetches nothing, but carries `{fetch}`"
);
}
assert!(
page.contains("<svg xmlns=\"http://www.w3.org/2000/svg\""),
"the inline SVG names its namespace, so it renders when copied out on its own"
);
assert!(
page.contains("data-provenance=\"provenance\""),
"a source-derived section is marked as provenance"
);
assert!(
page.contains("data-provenance=\"contract\""),
"a contract-committed section is marked as contract"
);
assert!(
!page.contains("Undocumented"),
"no placeholder prose reaches the page"
);
Ok(())
}
#[test]
fn the_plain_view_numbers_the_steps_in_reading_order() -> anyhow::Result<()> {
let text =
render(&source_model()?, DocFormat::Plain).map_err(|error| anyhow::anyhow!(error))?;
assert!(text.contains("1. mark"), "got:\n{text}");
assert!(text.contains("Read it once."), "got:\n{text}");
assert!(
!text.contains("Undocumented"),
"the plain view invents no prose"
);
Ok(())
}
#[test]
fn the_command_writes_exactly_the_librarys_rendering() -> anyhow::Result<()> {
let model = source_model()?;
let text = render(&model, DocFormat::Plain).map_err(|error| anyhow::anyhow!(error))?;
let prose = model
.prose
.as_ref()
.ok_or_else(|| anyhow::anyhow!("the fixture parses"))?;
let steps = aion_awl::doc::plain::steps(&prose.graph);
assert!(!steps.is_empty(), "the fixture declares a step");
for step in &steps {
assert!(
text.contains(&format!("{}. {}", step.number, step.name)),
"the command must write every numbered step the library produces; missing {}",
step.name
);
}
Ok(())
}
#[test]
fn the_doc_path_spawns_no_process() -> anyhow::Result<()> {
let source = fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/awl_doc.rs"))?;
let code: String = source
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n");
for forbidden in [
"Command::new",
"std::process::Command",
"graphviz",
"dot -T",
] {
assert!(
!code.contains(forbidden),
"src/awl_doc.rs names `{forbidden}` in code"
);
}
Ok(())
}