use onemessagebus::{
Answered, Carried, Closed, Disposition, Inbox, Message, SchemaId, Sender,
Undelivered as InboxUndelivered,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum Addressee {
Worker,
Supervisor,
Both,
}
impl Addressee {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Addressee::Worker => "worker",
Addressee::Supervisor => "supervisor",
Addressee::Both => "both",
}
}
#[must_use]
pub fn is_worker(&self) -> bool {
matches!(self, Addressee::Worker)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum Party {
Worker,
Supervisor,
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(try_from = "String", into = "String")]
pub struct Criterion(String);
impl Criterion {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for Criterion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Criterion({:?})", self.0)
}
}
impl std::fmt::Display for Criterion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl From<Criterion> for String {
fn from(criterion: Criterion) -> Self {
criterion.0
}
}
impl TryFrom<String> for Criterion {
type Error = CriterionRefused;
fn try_from(text: String) -> Result<Self, Self::Error> {
let trimmed = text.trim();
match refusal(trimmed) {
Some(why) => Err(CriterionRefused {
criterion: text,
why,
}),
None => Ok(Criterion(trimmed.to_string())),
}
}
}
impl TryFrom<&str> for Criterion {
type Error = CriterionRefused;
fn try_from(text: &str) -> Result<Self, Self::Error> {
Criterion::try_from(text.to_string())
}
}
impl std::str::FromStr for Criterion {
type Err = CriterionRefused;
fn from_str(text: &str) -> Result<Self, Self::Err> {
Criterion::try_from(text.to_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("the criterion {criterion:?} was refused: {why}")]
pub struct CriterionRefused {
pub criterion: String,
pub why: String,
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(try_from = "String", into = "String")]
pub struct NoteText(String);
impl NoteText {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for NoteText {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "NoteText({:?})", self.0)
}
}
impl std::fmt::Display for NoteText {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for NoteText {
fn as_ref(&self) -> &str {
&self.0
}
}
impl From<NoteText> for String {
fn from(text: NoteText) -> Self {
text.0
}
}
impl TryFrom<String> for NoteText {
type Error = NoteRefused;
fn try_from(text: String) -> Result<Self, Self::Error> {
let trimmed = text.trim();
if trimmed.is_empty() {
return Err(NoteRefused::Blank);
}
Ok(NoteText(trimmed.to_string()))
}
}
impl std::str::FromStr for NoteText {
type Err = NoteRefused;
fn from_str(text: &str) -> Result<Self, Self::Err> {
NoteText::try_from(text.to_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(try_from = "NoteWire")]
#[non_exhaustive]
pub struct Note {
pub addressee: Addressee,
pub text: NoteText,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub criterion: Option<Criterion>,
}
#[derive(Deserialize, JsonSchema)]
struct NoteWire {
addressee: Addressee,
text: String,
#[serde(default)]
criterion: Option<Criterion>,
}
impl TryFrom<NoteWire> for Note {
type Error = NoteRefused;
fn try_from(wire: NoteWire) -> Result<Self, Self::Error> {
Ok(Note {
addressee: wire.addressee,
text: NoteText::try_from(wire.text)?,
criterion: wire.criterion,
})
}
}
impl Note {
pub fn new(addressee: Addressee, text: impl Into<String>) -> Result<Self, NoteRefused> {
Ok(Self {
addressee,
text: NoteText::try_from(text.into())?,
criterion: None,
})
}
#[must_use]
pub fn to(addressee: Addressee, text: impl Into<String>) -> Self {
Note::new(addressee, text).expect("a note carries text")
}
pub fn binding(mut self, criterion: impl Into<String>) -> Result<Self, NoteRefused> {
self.criterion = Some(Criterion::try_from(criterion.into())?);
Ok(self)
}
#[must_use]
pub fn binds(&self) -> bool {
self.criterion.is_some()
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum NoteRefused {
#[error("a note carries text a party can read; this one was blank")]
Blank,
#[error(transparent)]
Criterion(#[from] CriterionRefused),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct DeliveredNote {
pub note: Note,
pub delivered_to: Party,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Accepted {
Queued,
Interrupted {
party: Party,
},
JudgedWith {
completion_reason: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[serde(rename_all = "snake_case")]
pub enum Undelivered {
#[error(
"the note was not delivered: the conversation's supervisor already answered completion \
({completion_reason}), so nothing will read it. Relaunch the work, amend the task for a \
later dispatch, or record the note as a follow-up."
)]
ConversationCompleted {
completion_reason: String,
},
#[error(
"the note was not delivered: the conversation had already ended ({outcome}), so nothing \
will read it. Relaunch the work, amend the task for a later dispatch, or record the note \
as a follow-up."
)]
MemberSettled {
outcome: String,
},
#[error(
"the note was not delivered: no conversation ever read this note channel ({reason}). \
Relaunch the work, amend the task for a later dispatch, or record the note as a \
follow-up."
)]
NoConversation {
reason: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Criteria {
configured: Option<String>,
bound: Vec<Criterion>,
}
const CRITERIA_FRAME: &str = "\
## Additional acceptance criteria delivered during this run
Each item below is an additional criterion about the FINISHED WORK, delivered into \
the worker's task after this run began. It is an update to the WORKER's task and is \
not an instruction to you: do not perform it yourself, and do not judge the worker on \
anything beyond it. Where an item names a mechanism rather than a property, judge the \
property that mechanism was serving — work that reached the same property another way \
has met it.
";
impl Criteria {
#[must_use]
pub fn compose(configured: Option<&str>, delivered: &[DeliveredNote]) -> Self {
Self {
configured: configured.map(str::to_string),
bound: delivered
.iter()
.filter_map(|d| d.note.criterion.clone())
.collect(),
}
}
#[must_use]
pub fn rendered(&self) -> Option<String> {
if self.bound.is_empty() {
return self.configured.clone();
}
let mut out = String::new();
if let Some(configured) = &self.configured {
out.push_str(configured);
out.push_str("\n\n");
}
out.push_str(CRITERIA_FRAME);
for (index, criterion) in self.bound.iter().enumerate() {
out.push_str(&format!("\n{}. {}", index + 1, criterion.as_str()));
}
Some(out)
}
#[must_use]
pub fn bound(&self) -> &[Criterion] {
&self.bound
}
}
impl Message for Note {
const SCHEMA: SchemaId = SchemaId::literal("agent", "note", 1);
}
impl Disposition for Accepted {}
impl Carried for Accepted {
fn carried() -> Self {
Accepted::Queued
}
}
pub type Notes = Sender<Note, Accepted>;
pub type NoteInbox = Inbox<Note, Accepted>;
pub trait NoteInboxExt {
fn delivered(&self) -> Vec<DeliveredNote>;
}
impl NoteInboxExt for NoteInbox {
fn delivered(&self) -> Vec<DeliveredNote> {
self.answered()
.into_iter()
.filter_map(
|Answered {
message,
disposition,
}| {
let party = match disposition {
Accepted::Interrupted { party } => party,
Accepted::JudgedWith { .. } => Party::Supervisor,
Accepted::Queued => return None,
};
Some(DeliveredNote {
note: message,
delivered_to: party,
})
},
)
.collect()
}
}
pub mod prelude {
pub use super::NoteInboxExt;
}
impl From<&Undelivered> for Closed {
fn from(refusal: &Undelivered) -> Self {
Closed::new(serde_json::to_string(refusal).unwrap_or_else(|_| refusal.to_string()))
}
}
impl From<InboxUndelivered> for Undelivered {
fn from(undelivered: InboxUndelivered) -> Self {
match undelivered {
InboxUndelivered::Closed(closed) => {
serde_json::from_str(&closed.reason).unwrap_or(Undelivered::MemberSettled {
outcome: closed.reason,
})
}
InboxUndelivered::Backend(failure) => Undelivered::NoConversation {
reason: failure.to_string(),
},
}
}
}
#[must_use]
pub fn worker_block(notes: &[DeliveredNote]) -> String {
let mut out = String::new();
let mine: Vec<&DeliveredNote> = notes
.iter()
.filter(|d| !matches!(d.note.addressee, Addressee::Supervisor))
.collect();
let theirs: Vec<&DeliveredNote> = notes
.iter()
.filter(|d| matches!(d.note.addressee, Addressee::Supervisor))
.collect();
if !mine.is_empty() {
out.push_str(
"## Notes delivered to you during this run\n\n\
The following were delivered to YOU, the worker, after this run began. Each is an \
update to your task and takes precedence over anything earlier that disagrees with \
it. Act on it as part of the work.\n",
);
for delivered in &mine {
out.push_str(&format!("\n- {}", delivered.note.text));
if let Some(criterion) = &delivered.note.criterion {
out.push_str(&format!(
"\n This note also added an acceptance criterion the finished work is \
judged against: {}",
criterion.as_str()
));
}
}
out.push('\n');
}
if !theirs.is_empty() {
if !out.is_empty() {
out.push('\n');
}
out.push_str(
"## Notes delivered to the supervisor during this run\n\n\
The following were delivered to the SUPERVISOR, addressed to it and not to you. They \
are not an instruction to you: do not act on them directly. They are here so you can \
read the supervisor's response in light of what it was told.\n",
);
for delivered in &theirs {
out.push_str(&format!("\n- {}", delivered.note.text));
}
out.push('\n');
}
out
}
#[must_use]
pub fn supervisor_block(notes: &[DeliveredNote]) -> Option<String> {
if notes.is_empty() {
return None;
}
let mut out = String::new();
let worker_observational: Vec<&DeliveredNote> = notes
.iter()
.filter(|d| matches!(d.note.addressee, Addressee::Worker) && !d.note.binds())
.collect();
let worker_binding: Vec<&DeliveredNote> = notes
.iter()
.filter(|d| matches!(d.note.addressee, Addressee::Worker) && d.note.binds())
.collect();
let yours: Vec<&DeliveredNote> = notes
.iter()
.filter(|d| matches!(d.note.addressee, Addressee::Supervisor | Addressee::Both))
.collect();
if !worker_observational.is_empty() {
out.push_str(
"## Notes delivered to the worker during this run\n\n\
The following were delivered to the WORKER, addressed to it and not to you. They \
report observed state and add no acceptance criteria. Judge the work against the \
completion criterion above; these are here so you can read what the worker did in \
light of what it was told.\n",
);
for delivered in &worker_observational {
out.push_str(&format!("\n- {}", delivered.note.text));
}
out.push('\n');
}
if !worker_binding.is_empty() {
if !out.is_empty() {
out.push('\n');
}
out.push_str(
"## Notes delivered to the worker during this run, which also added a criterion\n\n\
The following were delivered to the WORKER, addressed to it and not to you. Each one \
also added an acceptance criterion, listed with the completion criterion above; the \
text here is what the worker was told, so you can read what it did in light of it. \
They are not an instruction to you: do not perform them yourself.\n",
);
for delivered in &worker_binding {
out.push_str(&format!("\n- {}", delivered.note.text));
}
out.push('\n');
}
if !yours.is_empty() {
if !out.is_empty() {
out.push('\n');
}
out.push_str(
"## Notes delivered to you during this run\n\n\
The following were delivered to YOU, the supervisor. Where one is addressed to both \
parties it is an update to the worker's task as well, and any acceptance criterion it \
added is listed with the completion criterion above.\n",
);
for delivered in &yours {
out.push_str(&format!(
"\n- (addressed to {}) {}",
delivered.note.addressee.as_str(),
delivered.note.text
));
}
out.push('\n');
}
Some(out)
}
const OUT_OF_DISPATCH: [&str; 7] = [
"branch publishes",
"is published",
"pr is merged",
"pull request is merged",
"lands on master",
"lands on main",
"deploy",
];
const DEFERRAL_LEAD: [&[&str]; 6] = [
&["of", "the", "shape"],
&["as"],
&["exactly", "as"],
&["in", "the", "form"],
&["matching", "the", "wording"],
&["per", "the"],
];
const DEFERRAL_TAIL: [&[&str]; 7] = [
&["described"],
&["specified"],
&["stated"],
&["set", "out"],
&["given"],
&["above"],
&["below"],
];
const PHRASE: [&[&str]; 5] = [
&["verbatim"],
&["word", "for", "word"],
&["the", "exact", "phrase"],
&["the", "exact", "wording"],
&["the", "exact", "words"],
];
const SHELL_BINARIES: [&str; 6] = ["npm", "pnpm", "nx", "cargo", "pytest", "git"];
fn refusal(text: &str) -> Option<String> {
if text.is_empty() {
return Some(
"it is blank, which is a bar nobody can clear. State the property the finished work \
must have."
.into(),
);
}
let lowered = text.to_lowercase();
for phrase in OUT_OF_DISPATCH {
if lowered.contains(phrase) {
return Some(format!(
"it names '{phrase}' — that is work the dispatch cannot do, so finished work \
fails against it. State the worker-side precondition instead."
));
}
}
let words = words_of(&lowered);
if let Some(found) = deferral(&words) {
return Some(format!(
"it defers its content to prose elsewhere ('{found}'). A criterion the judge has to \
reconstruct is one it reconstructs as a wording demand. State the property here, in \
full."
));
}
for phrase in PHRASE {
if let Some(found) = sequence_at(&words, phrase) {
return Some(format!(
"it demands a particular string ('{found}') rather than a particular property. The \
property can be met and the wording failed."
));
}
}
if let Some(found) = version_literal(text) {
return Some(format!(
"it names a version literal ('{found}'). A release published between this note being \
written and the work being judged makes finished work fail against it. State the \
property that version stands in for."
));
}
if let Some(found) = code_span_invocation(text, &["just"]) {
return Some(format!(
"it names a `just` invocation ('{found}'). Criteria state properties; a judge fails \
the spelling of a command."
));
}
if text.contains("&&") {
return Some(
"it names a chained shell command ('&&'). Criteria state properties; a judge fails \
the spelling of a command."
.into(),
);
}
if let Some(found) = code_span_invocation(text, &SHELL_BINARIES) {
return Some(format!(
"it names a shell invocation ('{found}'). Criteria state properties; a judge fails \
the spelling of a command."
));
}
None
}
fn words_of(text: &str) -> Vec<String> {
text.split(|c: char| !c.is_alphanumeric())
.filter(|word| !word.is_empty())
.map(str::to_string)
.collect()
}
fn sequence_at(words: &[String], phrase: &[&str]) -> Option<String> {
words
.windows(phrase.len())
.find(|window| window.iter().zip(phrase).all(|(word, part)| word == part))
.map(|window| window.join(" "))
}
fn deferral(words: &[String]) -> Option<String> {
if let Some(found) = sequence_at(words, &["the", "wording", "above"]) {
return Some(found);
}
for lead in DEFERRAL_LEAD {
for start in 0..words.len() {
if words[start..].len() < lead.len() {
continue;
}
if !words[start..start + lead.len()]
.iter()
.zip(lead)
.all(|(word, part)| word == part)
{
continue;
}
let rest = &words[start + lead.len()..];
for tail in DEFERRAL_TAIL {
if rest.len() >= tail.len()
&& rest[..tail.len()]
.iter()
.zip(tail)
.all(|(word, part)| word == part)
{
return Some(format!("{} {}", lead.join(" "), tail.join(" ")));
}
}
}
}
None
}
fn version_literal(text: &str) -> Option<String> {
let chars: Vec<char> = text.chars().collect();
for start in 0..chars.len() {
if is_boundary(&chars, start) {
let mut at = start;
if chars[at] == 'v' {
at += 1;
}
if let Some(end) = dotted(&chars, at, 3) {
let end = suffix(&chars, end);
return Some(chars[start..end].iter().collect());
}
if chars[start] == 'v' {
if let Some(end) = dotted(&chars, start + 1, 2) {
if !matches!(chars.get(end), Some('.')) {
return Some(chars[start..end].iter().collect());
}
}
}
}
if matches!(chars[start], '>' | '<' | '=' | '~' | '^' | '!') {
let mut at = start + 1;
if matches!(chars.get(at), Some('=')) {
at += 1;
}
while matches!(chars.get(at), Some(c) if c.is_whitespace()) {
at += 1;
}
if let Some(end) = dotted(&chars, at, 2) {
return Some(chars[start..end].iter().collect::<String>().trim().into());
}
}
}
None
}
fn is_boundary(chars: &[char], at: usize) -> bool {
at == 0 || !(chars[at - 1].is_alphanumeric() || chars[at - 1] == '_')
}
fn dotted(chars: &[char], at: usize, components: usize) -> Option<usize> {
let mut at = at;
for index in 0..components {
if index > 0 {
if !matches!(chars.get(at), Some('.')) {
return None;
}
at += 1;
}
let digits = chars[at..]
.iter()
.take_while(|c| c.is_ascii_digit())
.count();
if digits == 0 {
return None;
}
at += digits;
}
Some(at)
}
fn suffix(chars: &[char], at: usize) -> usize {
if !matches!(chars.get(at), Some('-' | '+' | '.')) {
return at;
}
let mut end = at + 1;
while matches!(chars.get(end), Some(c) if c.is_ascii_alphanumeric() || *c == '.' || *c == '-') {
end += 1;
}
end
}
fn code_span_invocation(text: &str, binaries: &[&str]) -> Option<String> {
let chars: Vec<char> = text.chars().collect();
for (start, c) in chars.iter().enumerate() {
if *c != '`' {
continue;
}
let end = chars[start + 1..]
.iter()
.position(|c| *c == '`')
.map_or(chars.len(), |offset| start + 1 + offset);
let span: Vec<char> = chars[start + 1..end].to_vec();
for binary in binaries {
let needle: Vec<char> = binary.chars().collect();
for at in 0..span.len() {
if !is_boundary(&span, at) || span.len() < at + needle.len() + 1 {
continue;
}
if span[at..at + needle.len()] == needle[..]
&& span[at + needle.len()].is_whitespace()
{
return Some(format!("{binary} "));
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn note(addressee: Addressee, text: &str) -> DeliveredNote {
DeliveredNote {
note: Note::to(addressee, text),
delivered_to: Party::Worker,
}
}
#[test]
fn an_addressee_names_itself_on_the_wire_and_says_which_one_is_the_default() {
assert_eq!(Addressee::Worker.as_str(), "worker");
assert_eq!(Addressee::Supervisor.as_str(), "supervisor");
assert_eq!(Addressee::Both.as_str(), "both");
assert!(Addressee::Worker.is_worker());
assert!(!Addressee::Supervisor.is_worker());
assert_eq!(serde_json::to_string(&Addressee::Both).unwrap(), "\"both\"");
assert_eq!(
serde_json::to_string(&Party::Supervisor).unwrap(),
"\"supervisor\""
);
}
#[test]
fn a_note_carries_text_a_party_can_read() {
assert_eq!(
Note::new(Addressee::Worker, " \n ").unwrap_err(),
NoteRefused::Blank
);
let plain = Note::new(Addressee::Worker, " look again at the migration ").unwrap();
assert!(!plain.binds());
assert!(plain.criterion.is_none());
assert_eq!(plain.text.as_str(), "look again at the migration");
assert_eq!(plain.text.to_string(), "look again at the migration");
assert_eq!(plain.text.as_ref(), "look again at the migration");
assert_eq!(
format!("{:?}", plain.text),
"NoteText(\"look again at the migration\")"
);
assert_eq!(
String::from(plain.text.clone()),
"look again at the migration"
);
assert_eq!(
NoteText::try_from(" \n ".to_string()),
Err(NoteRefused::Blank)
);
assert_eq!("".parse::<NoteText>(), Err(NoteRefused::Blank));
assert_eq!(
"still readable".parse::<NoteText>().unwrap().as_str(),
"still readable"
);
}
#[test]
fn a_note_arriving_over_the_wire_is_held_to_the_rules_a_local_one_is() {
let good: Note = serde_json::from_str(
r#"{"addressee":"both","text":"the bar moved","criterion":"the flag defaults to off"}"#,
)
.unwrap();
assert_eq!(good.addressee, Addressee::Both);
assert!(good.binds());
let json = serde_json::to_string(&good).unwrap();
assert_eq!(serde_json::from_str::<Note>(&json).unwrap(), good);
assert!(serde_json::from_str::<Note>(r#"{"addressee":"worker","text":" "}"#).is_err());
assert!(serde_json::from_str::<Note>(
r#"{"addressee":"worker","text":"look","criterion":"the pin moves to 1.2.3"}"#
)
.is_err());
}
#[test]
fn a_criterion_round_trips_through_every_conversion_a_caller_has() {
let text = "the migration path is covered by a test";
let owned = Criterion::try_from(text.to_string()).unwrap();
let borrowed = Criterion::try_from(text).unwrap();
let parsed: Criterion = text.parse().unwrap();
assert_eq!(owned, borrowed);
assert_eq!(owned, parsed);
assert_eq!(owned.to_string(), text);
assert_eq!(format!("{owned:?}"), format!("Criterion({text:?})"));
assert_eq!(String::from(owned.clone()), text);
assert_eq!(Criterion::try_from(format!(" {text} ")).unwrap(), owned);
let json = serde_json::to_string(&owned).unwrap();
assert_eq!(json, format!("{text:?}"));
assert_eq!(serde_json::from_str::<Criterion>(&json).unwrap(), owned);
let refused = serde_json::from_str::<Criterion>("\"the pin moves to 1.2.3\"");
assert!(
refused.is_err(),
"a criterion arriving over the wire is held to the same rules"
);
}
#[test]
fn each_criterion_rule_refuses_its_shape_and_leaves_the_near_miss_alone() {
let why =
|text: &str| refusal(text).unwrap_or_else(|| panic!("expected a refusal: {text}"));
assert!(why("v1.2 of the pin is in place").contains("version literal"));
assert!(why("the dependency is >= 2.4").contains("version literal"));
assert!(why("the crate is at 0.12.1-rc.1").contains("version literal"));
assert!(why("the criteria are stated as above").contains("defers its content"));
assert!(why("the answer matches the wording above").contains("defers its content"));
assert!(why("the reason is set out per the stated shape").contains("defers its content"));
assert!(why("the heading is word for word the same").contains("particular string"));
assert!(why("it uses the exact wording").contains("particular string"));
assert!(why("the branch publishes cleanly").contains("work the dispatch cannot do"));
assert!(why("`pnpm test` is green").contains("shell invocation"));
assert_eq!(
refusal("the timeout is 1.5 seconds and coverage holds"),
None
);
assert_eq!(refusal("the report declares schema 0.5"), None);
assert_eq!(
refusal("`Report::control` is serialized even when null"),
None
);
assert_eq!(refusal("the cargo manifest declares the new feature"), None);
assert_eq!(refusal("the flag is off as a default"), None);
}
#[test]
fn criteria_compose_the_configured_bar_with_what_notes_bound() {
let none = Criteria::compose(Some("the task is done"), &[]);
assert_eq!(none.rendered().as_deref(), Some("the task is done"));
assert!(none.bound().is_empty());
assert_eq!(Criteria::default().rendered(), None);
assert_eq!(Criteria::compose(None, &[]).rendered(), None);
let bound = vec![
DeliveredNote {
note: Note::to(Addressee::Worker, "first")
.binding("the migration is covered")
.unwrap(),
delivered_to: Party::Worker,
},
DeliveredNote {
note: Note::to(Addressee::Both, "second")
.binding("the flag defaults to off")
.unwrap(),
delivered_to: Party::Supervisor,
},
note(Addressee::Worker, "third, binding nothing"),
];
let composed = Criteria::compose(Some("the task is done"), &bound);
assert_eq!(
composed.bound().len(),
2,
"a note that binds nothing adds nothing"
);
let rendered = composed.rendered().unwrap();
assert!(rendered.starts_with("the task is done\n\n"));
assert!(rendered.contains("1. the migration is covered"));
assert!(rendered.contains("2. the flag defaults to off"));
assert!(rendered.contains("judge the property that mechanism was serving"));
let alone = Criteria::compose(None, &bound).rendered().unwrap();
assert!(alone.starts_with("## Additional acceptance criteria"));
}
#[test]
fn a_party_is_told_which_role_each_note_it_is_handed_is_for() {
assert_eq!(supervisor_block(&[]), None);
assert!(worker_block(&[]).is_empty());
let mixed = vec![
note(Addressee::Worker, "observed state"),
DeliveredNote {
note: Note::to(Addressee::Worker, "and this one moved the bar")
.binding("the migration is covered")
.unwrap(),
delivered_to: Party::Worker,
},
note(Addressee::Supervisor, "hold the bar where it is"),
note(Addressee::Both, "the ruling applies to both of you"),
];
let judge = supervisor_block(&mixed).unwrap();
assert!(judge.contains("## Notes delivered to the worker during this run\n"));
assert!(judge.contains("They report observed state and add no acceptance criteria"));
assert!(judge.contains("which also added a criterion"));
assert!(judge.contains("listed with the completion criterion above"));
assert!(judge.contains("## Notes delivered to you during this run"));
assert!(judge.contains("(addressed to supervisor) hold the bar where it is"));
assert!(judge.contains("(addressed to both) the ruling applies to both of you"));
let worker = worker_block(&mixed);
assert!(worker.contains("delivered to YOU, the worker"));
assert!(worker.contains("Act on it as part of the work"));
assert!(worker.contains(
"This note also added an acceptance criterion the finished work is judged against"
));
assert!(worker.contains("## Notes delivered to the supervisor during this run"));
assert!(worker.contains("addressed to it and not to you"));
}
}