1use std::fmt;
56use std::fmt::Write as _;
57use std::path::{Path, PathBuf};
58
59use serde::{Deserialize, Serialize};
60use turnframe_core::ids::{CaseId, OptionId, WorkflowKey};
61use turnframe_core::interaction::InteractionStatus;
62use turnframe_core::locale::Locale;
63use turnframe_core::replay::TurnPhase;
64use turnframe_core::response::ResponseBlock;
65
66use crate::config::SelectionConfig;
67use crate::judge::JudgeCriterion;
68
69#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
71#[serde(transparent)]
72pub struct ItemId(pub String);
73
74impl ItemId {
75 #[must_use]
77 pub fn new(value: impl Into<String>) -> Self {
78 Self(value.into())
79 }
80
81 #[must_use]
83 pub fn as_str(&self) -> &str {
84 &self.0
85 }
86}
87
88impl From<&str> for ItemId {
89 fn from(value: &str) -> Self {
90 Self(value.to_owned())
91 }
92}
93
94impl fmt::Display for ItemId {
95 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96 f.write_str(&self.0)
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
102#[serde(transparent)]
103pub struct Tag(pub String);
104
105impl Tag {
106 #[must_use]
108 pub fn new(value: impl Into<String>) -> Self {
109 Self(value.into())
110 }
111
112 #[must_use]
114 pub fn as_str(&self) -> &str {
115 &self.0
116 }
117}
118
119impl From<&str> for Tag {
120 fn from(value: &str) -> Self {
121 Self(value.to_owned())
122 }
123}
124
125impl From<String> for Tag {
126 fn from(value: String) -> Self {
127 Self(value)
128 }
129}
130
131impl fmt::Display for Tag {
132 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133 f.write_str(&self.0)
134 }
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
145#[serde(rename_all = "snake_case")]
146#[non_exhaustive]
147pub enum ItemPart {
148 Setup,
151 Turn,
153 Expect,
155 Judge,
157}
158
159impl ItemPart {
160 pub const ALL: [Self; 4] = [Self::Setup, Self::Turn, Self::Expect, Self::Judge];
162
163 #[must_use]
165 pub const fn as_str(self) -> &'static str {
166 match self {
167 Self::Setup => "setup",
168 Self::Turn => "turn",
169 Self::Expect => "expect",
170 Self::Judge => "judge",
171 }
172 }
173}
174
175impl fmt::Display for ItemPart {
176 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177 f.write_str(self.as_str())
178 }
179}
180
181#[derive(
186 Debug, Clone, Copy, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
187)]
188#[serde(rename_all = "snake_case")]
189#[non_exhaustive]
190pub enum PartProvenance {
191 #[default]
197 Authored,
198 Derived,
203 Recorded,
209}
210
211impl PartProvenance {
212 #[must_use]
214 pub const fn as_str(self) -> &'static str {
215 match self {
216 Self::Authored => "authored",
217 Self::Derived => "derived",
218 Self::Recorded => "recorded",
219 }
220 }
221
222 #[must_use]
227 pub const fn may_be_regenerated(self) -> bool {
228 matches!(self, Self::Derived)
229 }
230}
231
232impl fmt::Display for PartProvenance {
233 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
234 f.write_str(self.as_str())
235 }
236}
237
238#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(deny_unknown_fields, default)]
253pub struct Provenance {
254 pub setup: PartProvenance,
256 pub turn: PartProvenance,
258 pub expect: PartProvenance,
260 pub judge: PartProvenance,
262}
263
264impl Provenance {
265 #[must_use]
267 pub const fn authored() -> Self {
268 Self {
269 setup: PartProvenance::Authored,
270 turn: PartProvenance::Authored,
271 expect: PartProvenance::Authored,
272 judge: PartProvenance::Authored,
273 }
274 }
275
276 #[must_use]
278 pub const fn of(&self, part: ItemPart) -> PartProvenance {
279 match part {
280 ItemPart::Setup => self.setup,
281 ItemPart::Turn => self.turn,
282 ItemPart::Expect => self.expect,
283 ItemPart::Judge => self.judge,
284 }
285 }
286
287 pub const fn set(&mut self, part: ItemPart, provenance: PartProvenance) {
289 match part {
290 ItemPart::Setup => self.setup = provenance,
291 ItemPart::Turn => self.turn = provenance,
292 ItemPart::Expect => self.expect = provenance,
293 ItemPart::Judge => self.judge = provenance,
294 }
295 }
296
297 #[must_use]
299 pub fn is_empty(&self) -> bool {
300 *self == Self::authored()
301 }
302
303 #[must_use]
306 pub fn declared(&self) -> Vec<ItemPart> {
307 ItemPart::ALL
308 .into_iter()
309 .filter(|part| self.of(*part) != PartProvenance::Authored)
310 .collect()
311 }
312}
313
314#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
316#[serde(deny_unknown_fields)]
317pub struct PartDigest {
318 pub part: ItemPart,
320 pub digest: String,
323 #[serde(default)]
327 pub provenance: PartProvenance,
328}
329
330#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
338#[serde(deny_unknown_fields, default)]
339pub struct ItemFingerprint {
340 pub parts: Vec<PartDigest>,
342}
343
344impl ItemFingerprint {
345 #[must_use]
347 pub fn is_unknown(&self) -> bool {
348 self.parts.is_empty()
349 }
350
351 #[must_use]
353 pub fn digest_of(&self, part: ItemPart) -> Option<&str> {
354 self.parts
355 .iter()
356 .find(|entry| entry.part == part)
357 .map(|entry| entry.digest.as_str())
358 }
359
360 #[must_use]
366 pub fn provenance_of(&self, part: ItemPart) -> PartProvenance {
367 self.parts
368 .iter()
369 .find(|entry| entry.part == part)
370 .map_or(PartProvenance::Authored, |entry| entry.provenance)
371 }
372
373 #[must_use]
378 pub fn differing_parts(&self, other: &Self) -> Vec<ItemPart> {
379 if self.is_unknown() || other.is_unknown() {
380 return Vec::new();
381 }
382 ItemPart::ALL
383 .into_iter()
384 .filter(
385 |part| match (self.digest_of(*part), other.digest_of(*part)) {
386 (Some(left), Some(right)) => left != right,
387 _ => false,
388 },
389 )
390 .collect()
391 }
392}
393
394#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
396#[serde(deny_unknown_fields)]
397pub struct EvalItem {
398 pub id: ItemId,
401 pub name: String,
403 #[serde(default, skip_serializing_if = "Option::is_none")]
405 pub description: Option<String>,
406 #[serde(default, skip_serializing_if = "Vec::is_empty")]
408 pub tags: Vec<Tag>,
409 #[serde(default)]
411 pub setup: Setup,
412 #[serde(default, skip_serializing_if = "Vec::is_empty")]
415 pub before: Vec<TurnSpec>,
416 pub turn: TurnSpec,
418 #[serde(default)]
420 pub expect: Expectations,
421 #[serde(default, skip_serializing_if = "Vec::is_empty")]
424 pub judge: Vec<JudgeCriterion>,
425 #[serde(default, skip_serializing_if = "Provenance::is_empty")]
432 pub provenance: Provenance,
433}
434
435impl EvalItem {
436 pub fn validate(&self) -> Result<(), CorpusError> {
442 if self.id.as_str().trim().is_empty() {
443 return Err(CorpusError::invalid("id", "an item needs an identifier"));
444 }
445 if self.name.trim().is_empty() {
446 return Err(CorpusError::invalid("name", "an item needs a name"));
447 }
448 for turn in &self.before {
449 turn.validate()?;
450 }
451 self.turn.validate()?;
452 if self.turn.external.is_some() {
453 return Err(CorpusError::invalid(
454 "turn",
455 "the observed turn is the person's; an outside change goes in `before`",
456 ));
457 }
458 self.expect.validate()?;
459 if let Some(understanding) = &self.expect.understanding {
460 understanding.validate(self.turn.text.as_deref())?;
461 }
462 for seed in &self.setup.cases {
463 seed.validate()?;
464 }
465 self.validate_provenance()?;
466 Ok(())
467 }
468
469 fn validate_provenance(&self) -> Result<(), CorpusError> {
476 for part in self.provenance.declared() {
477 if !self.carries(part) {
478 return Err(CorpusError::Invalid {
479 field: "provenance".to_owned(),
480 reason: format!(
481 "`{part}` is declared `{}`, and this item has no `{part}`",
482 self.provenance.of(part)
483 ),
484 });
485 }
486 }
487 Ok(())
488 }
489
490 #[must_use]
495 pub fn carries(&self, part: ItemPart) -> bool {
496 match part {
497 ItemPart::Setup => {
505 !self.setup.cases.is_empty()
506 || !self.setup.records.is_empty()
507 || !self.setup.history.is_empty()
508 }
509 ItemPart::Turn => true,
510 ItemPart::Expect => !self.expect.is_empty(),
511 ItemPart::Judge => !self.judge.is_empty(),
512 }
513 }
514
515 #[must_use]
517 pub fn has_tag(&self, tag: &Tag) -> bool {
518 self.tags.contains(tag)
519 }
520
521 #[must_use]
559 pub fn fingerprint(&self) -> ItemFingerprint {
560 ItemFingerprint {
561 parts: ItemPart::ALL
562 .into_iter()
563 .map(|part| PartDigest {
564 part,
565 digest: self.digest_of(part),
566 provenance: self.provenance.of(part),
567 })
568 .collect(),
569 }
570 }
571
572 fn digest_of(&self, part: ItemPart) -> String {
573 let rendered = match part {
574 ItemPart::Setup => serde_json::to_value(&self.setup),
575 ItemPart::Turn => serde_json::to_value(&self.turn),
576 ItemPart::Expect => serde_json::to_value(&self.expect),
577 ItemPart::Judge => serde_json::to_value(&self.judge),
578 };
579 let rendered = rendered.unwrap_or_else(|error| {
584 serde_json::Value::String(format!("unserializable {part}: {error}"))
585 });
586 let mut canonical = String::new();
587 write_canonical(&rendered, &mut canonical);
588 blake3::hash(canonical.as_bytes()).to_hex().to_string()
589 }
590}
591
592fn write_canonical(value: &serde_json::Value, out: &mut String) {
595 match value {
596 serde_json::Value::Null => out.push('n'),
597 serde_json::Value::Bool(true) => out.push('t'),
598 serde_json::Value::Bool(false) => out.push('f'),
599 serde_json::Value::Number(number) => {
600 let _ = write!(out, "#{number};");
601 }
602 serde_json::Value::String(text) => write_canonical_text(text, out),
603 serde_json::Value::Array(items) => {
604 out.push('[');
605 for item in items {
606 write_canonical(item, out);
607 }
608 out.push(']');
609 }
610 serde_json::Value::Object(map) => {
611 let mut keys: Vec<&String> = map.keys().collect();
615 keys.sort_unstable();
616 out.push('{');
617 for key in keys {
618 write_canonical_text(key, out);
619 if let Some(entry) = map.get(key) {
620 write_canonical(entry, out);
621 }
622 }
623 out.push('}');
624 }
625 }
626}
627
628fn write_canonical_text(text: &str, out: &mut String) {
629 let _ = write!(out, "s{}:{text}", text.len());
630}
631
632#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
634#[serde(deny_unknown_fields, default)]
635pub struct Setup {
636 pub cases: Vec<CaseSeed>,
638 #[serde(default, skip_serializing_if = "Vec::is_empty")]
649 pub records: Vec<SeededRecord>,
650 #[serde(default, skip_serializing_if = "Vec::is_empty")]
663 pub history: Vec<PriorExchange>,
664}
665
666#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
672#[serde(deny_unknown_fields)]
673pub struct SeededRecord {
674 pub kind: String,
676 pub data: serde_json::Value,
678}
679
680#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
687#[serde(deny_unknown_fields)]
688pub struct PriorExchange {
689 pub user: String,
691 #[serde(default, skip_serializing_if = "Option::is_none")]
693 pub assistant: Option<String>,
694}
695
696#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
703#[serde(deny_unknown_fields)]
704pub struct StateExpectation {
705 pub case_id: CaseId,
707 pub path: String,
709 #[serde(default, skip_serializing_if = "Option::is_none")]
711 pub equals: Option<serde_json::Value>,
712 #[serde(default, skip_serializing_if = "Vec::is_empty")]
715 pub one_of: Vec<serde_json::Value>,
716 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
718 pub unchanged: bool,
719 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
745 pub absent: bool,
746 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
765 pub ignore_case: bool,
766}
767
768impl StateExpectation {
769 pub fn validate(&self) -> Result<(), CorpusError> {
777 if self.ignore_case && self.equals.is_none() && self.one_of.is_empty() {
778 return Err(CorpusError::Invalid {
779 field: "expect.case_state".to_owned(),
780 reason: String::from(
781 "`ignore_case` says how to compare a value, so it needs `equals` or `one_of`: \
782 `unchanged` and `absent` compare no text",
783 ),
784 });
785 }
786 match u8::from(self.equals.is_some())
787 + u8::from(!self.one_of.is_empty())
788 + u8::from(self.unchanged)
789 + u8::from(self.absent)
790 {
791 1 => Ok(()),
792 0 => Err(CorpusError::Invalid {
793 field: "expect.case_state".to_owned(),
794 reason: String::from(
795 "a state expectation with none of `equals`, `one_of`, `unchanged` or \
796 `absent` asserts nothing",
797 ),
798 }),
799 _ => Err(CorpusError::Invalid {
800 field: "expect.case_state".to_owned(),
801 reason: String::from(
802 "a path is expected to hold a value, one of some values, to be \
803 unchanged, or to hold nothing — exactly one of the four",
804 ),
805 }),
806 }
807 }
808}
809
810#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
816#[serde(deny_unknown_fields)]
817pub struct CaseSeed {
818 pub workflow: WorkflowKey,
820 pub case_id: CaseId,
822 pub label: String,
824 #[serde(default = "one")]
826 pub revision: u64,
827 pub state: serde_json::Value,
829 #[serde(default, skip_serializing_if = "Option::is_none")]
841 pub conversation: Option<String>,
842}
843
844impl CaseSeed {
845 pub fn validate(&self) -> Result<(), CorpusError> {
851 if !self.state.is_object() {
852 return Err(CorpusError::invalid(
853 "setup.cases.state",
854 "a seeded state must be a JSON object",
855 ));
856 }
857 Ok(())
858 }
859}
860
861const fn one() -> u64 {
862 1
863}
864
865#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
867#[serde(deny_unknown_fields, default)]
868pub struct TurnSpec {
869 pub text: Option<String>,
871 pub reply: Option<CardReplySpec>,
874 pub origin: Option<OriginSpec>,
876 pub user_id: Option<String>,
878 pub locale: Option<Locale>,
880 #[serde(skip_serializing_if = "Option::is_none")]
882 pub external: Option<ExternalSpec>,
883}
884
885impl TurnSpec {
886 pub fn validate(&self) -> Result<(), CorpusError> {
893 if let Some(external) = &self.external {
894 if self.text.is_some() || self.reply.is_some() || self.origin.is_some() {
895 return Err(CorpusError::invalid(
896 "external",
897 "an outside change is not a person's turn: it carries no text, card or origin",
898 ));
899 }
900 return external.validate();
901 }
902 if self.text.is_none() && self.reply.is_none() {
903 return Err(CorpusError::invalid(
904 "turn",
905 "a turn needs text, a card answer, or both",
906 ));
907 }
908 Ok(())
909 }
910}
911
912#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
916#[serde(deny_unknown_fields)]
917pub struct ExternalSpec {
918 pub workflow: WorkflowKey,
920 pub case_id: CaseId,
922 pub command: serde_json::Value,
924}
925
926impl ExternalSpec {
927 fn validate(&self) -> Result<(), CorpusError> {
928 if self.workflow.as_str().trim().is_empty() || self.case_id.as_str().trim().is_empty() {
929 return Err(CorpusError::invalid(
930 "external",
931 "an outside change names its workflow and record",
932 ));
933 }
934 if self.command.is_null() {
935 return Err(CorpusError::invalid(
936 "external.command",
937 "the command is missing",
938 ));
939 }
940 Ok(())
941 }
942}
943
944#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
950#[serde(deny_unknown_fields)]
951pub struct CardReplySpec {
952 pub workflow: WorkflowKey,
954 #[serde(default, skip_serializing_if = "Option::is_none")]
957 pub case_id: Option<CaseId>,
958 pub option: OptionId,
960 #[serde(default, skip_serializing_if = "Option::is_none")]
962 pub freeform: Option<String>,
963}
964
965#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
967#[serde(deny_unknown_fields)]
968pub struct OriginSpec {
969 pub token: String,
971 #[serde(default, skip_serializing_if = "Option::is_none")]
973 pub surface: Option<String>,
974}
975
976#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
984#[serde(deny_unknown_fields, default)]
985pub struct Expectations {
986 pub outcome: OutcomeExpectation,
990 pub acts: Option<Vec<ActExpectation>>,
992 pub target_resolution: Vec<TargetExpectation>,
994 pub commands: Option<Vec<String>>,
996 pub events: Option<Vec<String>>,
998 pub case_revision: Vec<RevisionExpectation>,
1000 pub interaction_status: Vec<InteractionStatusExpectation>,
1002 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1011 pub case_state: Vec<StateExpectation>,
1012 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1015 pub workflow_state: Vec<WorkflowStateExpectation>,
1016 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1018 pub case_count: Vec<CaseCountExpectation>,
1019 pub blocks: Option<Vec<BlockKind>>,
1021 pub turn_phase: Option<TurnPhase>,
1023 pub forbid: ForbiddenEffects,
1025 #[serde(default, skip_serializing_if = "Option::is_none")]
1027 pub understanding: Option<crate::understanding::UnderstandingExpectation>,
1028}
1029
1030impl Expectations {
1031 pub fn validate(&self) -> Result<(), CorpusError> {
1039 for act in self.acts.iter().flatten() {
1040 act.validate()?;
1041 }
1042 for target in &self.target_resolution {
1043 target.validate()?;
1044 }
1045 for state in &self.case_state {
1046 state.validate()?;
1047 }
1048 contradiction("commands", self.commands.as_deref(), &self.forbid.commands)?;
1049 contradiction("events", self.events.as_deref(), &self.forbid.events)?;
1050 Ok(())
1051 }
1052
1053 #[must_use]
1055 pub fn is_empty(&self) -> bool {
1056 self.outcome == OutcomeExpectation::Succeeds
1057 && self.acts.is_none()
1058 && self.target_resolution.is_empty()
1059 && self.commands.is_none()
1060 && self.events.is_none()
1061 && self.case_revision.is_empty()
1062 && self.interaction_status.is_empty()
1063 && self.case_state.is_empty()
1064 && self.workflow_state.is_empty()
1065 && self.case_count.is_empty()
1066 && self.blocks.is_none()
1067 && self.turn_phase.is_none()
1068 && self.forbid.is_empty()
1069 && self.understanding.is_none()
1070 }
1071}
1072
1073fn contradiction(
1074 field: &'static str,
1075 required: Option<&[String]>,
1076 forbidden: &[String],
1077) -> Result<(), CorpusError> {
1078 let Some(required) = required else {
1079 return Ok(());
1080 };
1081 if let Some(clash) = forbidden.iter().find(|name| required.contains(name)) {
1082 return Err(CorpusError::Invalid {
1083 field: field.to_owned(),
1084 reason: format!("`{clash}` is both required and forbidden"),
1085 });
1086 }
1087 Ok(())
1088}
1089
1090#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1096#[serde(rename_all = "snake_case")]
1097#[non_exhaustive]
1098pub enum OutcomeExpectation {
1099 #[default]
1101 Succeeds,
1102 Fails,
1104 FailsWith(String),
1106}
1107
1108#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1115#[serde(deny_unknown_fields, default)]
1116pub struct ForbiddenEffects {
1117 pub commands: Vec<String>,
1119 pub events: Vec<String>,
1121}
1122
1123impl ForbiddenEffects {
1124 #[must_use]
1126 pub fn is_empty(&self) -> bool {
1127 self.commands.is_empty() && self.events.is_empty()
1128 }
1129}
1130
1131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1133#[serde(deny_unknown_fields)]
1134#[non_exhaustive]
1135pub struct ActExpectation {
1136 pub kind: ActKind,
1138 #[serde(default, skip_serializing_if = "Option::is_none")]
1140 pub operation: Option<String>,
1141 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1151 pub or: Vec<ActExpectation>,
1152}
1153
1154impl ActExpectation {
1155 pub fn validate(&self) -> Result<(), CorpusError> {
1163 if self.operation.is_some() && !self.kind.carries_operation() {
1164 return Err(CorpusError::Invalid {
1165 field: "expect.acts.operation".to_owned(),
1166 reason: format!("`{}` acts do not name an operation", self.kind),
1167 });
1168 }
1169 for alternative in &self.or {
1170 alternative.validate()?;
1171 }
1172 Ok(())
1173 }
1174
1175 #[must_use]
1178 pub fn admits(&self, kind: &str, operation: Option<&String>) -> bool {
1179 let matches = self.kind.as_str() == kind
1180 && self
1181 .operation
1182 .as_ref()
1183 .is_none_or(|wanted| Some(wanted) == operation);
1184 matches || self.or.iter().any(|other| other.admits(kind, operation))
1185 }
1186}
1187
1188#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1190#[serde(rename_all = "snake_case")]
1191#[non_exhaustive]
1192pub enum ActKind {
1193 ApplyOperation,
1195 StartWorkflow,
1197 CancelOperation,
1199 AnswerActiveInteractionFromText,
1201 SelectTarget,
1203}
1204
1205impl ActKind {
1206 #[must_use]
1209 pub const fn as_str(self) -> &'static str {
1210 match self {
1211 Self::ApplyOperation => "apply_operation",
1212 Self::StartWorkflow => "start_workflow",
1213 Self::CancelOperation => "cancel_operation",
1214 Self::AnswerActiveInteractionFromText => "answer_active_interaction_from_text",
1215 Self::SelectTarget => "select_target",
1216 }
1217 }
1218
1219 #[must_use]
1221 pub const fn carries_operation(self) -> bool {
1222 matches!(self, Self::ApplyOperation | Self::CancelOperation)
1223 }
1224}
1225
1226impl fmt::Display for ActKind {
1227 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1228 f.write_str(self.as_str())
1229 }
1230}
1231
1232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1234#[serde(deny_unknown_fields)]
1235pub struct TargetExpectation {
1236 pub act_index: usize,
1238 pub resolution: ResolutionKind,
1240 #[serde(default, skip_serializing_if = "Option::is_none")]
1242 pub case_id: Option<CaseId>,
1243}
1244
1245impl TargetExpectation {
1246 pub fn validate(&self) -> Result<(), CorpusError> {
1253 if self.case_id.is_some() && !self.resolution.carries_case() {
1254 return Err(CorpusError::Invalid {
1255 field: "expect.target_resolution.case_id".to_owned(),
1256 reason: format!("a `{}` resolution names no case", self.resolution),
1257 });
1258 }
1259 Ok(())
1260 }
1261}
1262
1263#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1266#[serde(rename_all = "snake_case")]
1267#[non_exhaustive]
1268pub enum ResolutionKind {
1269 Exact,
1271 Ambiguous,
1273 Missing,
1275 Unauthorized,
1277 Stale,
1279}
1280
1281impl ResolutionKind {
1282 #[must_use]
1284 pub const fn as_str(self) -> &'static str {
1285 match self {
1286 Self::Exact => "exact",
1287 Self::Ambiguous => "ambiguous",
1288 Self::Missing => "missing",
1289 Self::Unauthorized => "unauthorized",
1290 Self::Stale => "stale",
1291 }
1292 }
1293
1294 #[must_use]
1296 pub const fn carries_case(self) -> bool {
1297 matches!(self, Self::Exact | Self::Stale)
1298 }
1299}
1300
1301impl fmt::Display for ResolutionKind {
1302 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1303 f.write_str(self.as_str())
1304 }
1305}
1306
1307#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1309#[serde(deny_unknown_fields)]
1310pub struct WorkflowStateExpectation {
1311 pub workflow: WorkflowKey,
1313 pub path: String,
1315 pub equals: serde_json::Value,
1317 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1319 pub ignore_case: bool,
1320}
1321
1322#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1324#[serde(deny_unknown_fields)]
1325pub struct CaseCountExpectation {
1326 pub workflow: WorkflowKey,
1328 pub count: usize,
1330}
1331
1332#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1334#[serde(deny_unknown_fields)]
1335pub struct RevisionExpectation {
1336 pub workflow: WorkflowKey,
1338 pub case_id: CaseId,
1340 pub revision: u64,
1342}
1343
1344#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1346#[serde(deny_unknown_fields)]
1347pub struct InteractionStatusExpectation {
1348 pub workflow: WorkflowKey,
1350 pub case_id: CaseId,
1352 pub statuses: Vec<InteractionStatus>,
1355}
1356
1357#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1359#[serde(rename_all = "snake_case")]
1360#[non_exhaustive]
1361pub enum BlockKind {
1362 Answer,
1364 Transition,
1366 Receipt,
1368 Notice,
1370 Interaction,
1372 Artifact,
1374 Other,
1377}
1378
1379impl BlockKind {
1380 #[must_use]
1382 pub const fn of(block: &ResponseBlock) -> Self {
1383 match block {
1384 ResponseBlock::Answer(_) => Self::Answer,
1385 ResponseBlock::Transition(_) => Self::Transition,
1386 ResponseBlock::Receipt(_) => Self::Receipt,
1387 ResponseBlock::Notice(_) => Self::Notice,
1388 ResponseBlock::Interaction(_) => Self::Interaction,
1389 ResponseBlock::Artifact(_) => Self::Artifact,
1390 _ => Self::Other,
1391 }
1392 }
1393
1394 #[must_use]
1396 pub const fn as_str(self) -> &'static str {
1397 match self {
1398 Self::Answer => "answer",
1399 Self::Transition => "transition",
1400 Self::Receipt => "receipt",
1401 Self::Notice => "notice",
1402 Self::Interaction => "interaction",
1403 Self::Artifact => "artifact",
1404 Self::Other => "other",
1405 }
1406 }
1407}
1408
1409impl fmt::Display for BlockKind {
1410 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1411 f.write_str(self.as_str())
1412 }
1413}
1414
1415#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1432#[serde(deny_unknown_fields, default)]
1433pub struct SuiteManifest {
1434 pub provenance: Provenance,
1436}
1437
1438const MANIFEST_STEM: &str = "suite";
1441
1442#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1444#[serde(deny_unknown_fields)]
1445pub struct Suite {
1446 pub name: String,
1448 pub items: Vec<EvalItem>,
1450}
1451
1452impl Suite {
1453 pub fn new(name: impl Into<String>, items: Vec<EvalItem>) -> Result<Self, CorpusError> {
1462 let suite = Self {
1463 name: name.into(),
1464 items,
1465 };
1466 for (index, item) in suite.items.iter().enumerate() {
1467 item.validate()?;
1468 if suite.items[..index].iter().any(|other| other.id == item.id) {
1469 return Err(CorpusError::DuplicateId {
1470 id: item.id.clone(),
1471 });
1472 }
1473 }
1474 Ok(suite)
1475 }
1476
1477 pub fn with_provenance(
1490 name: impl Into<String>,
1491 mut items: Vec<EvalItem>,
1492 provenance: Provenance,
1493 ) -> Result<Self, CorpusError> {
1494 for item in &mut items {
1495 item.validate()?;
1496 for part in provenance.declared() {
1497 if item.carries(part) && item.provenance.of(part) == PartProvenance::Authored {
1498 item.provenance.set(part, provenance.of(part));
1499 }
1500 }
1501 }
1502 Self::new(name, items)
1503 }
1504
1505 pub fn load_item(path: impl AsRef<Path>) -> Result<EvalItem, CorpusError> {
1516 let item: EvalItem = read_document(path.as_ref())?;
1517 item.validate()?;
1518 Ok(item)
1519 }
1520
1521 pub fn load_manifest(dir: impl AsRef<Path>) -> Result<SuiteManifest, CorpusError> {
1533 for extension in ["toml", "json"] {
1534 let path = dir.as_ref().join(MANIFEST_STEM).with_extension(extension);
1535 if path.is_file() {
1536 return read_document(&path);
1537 }
1538 }
1539 Ok(SuiteManifest::default())
1540 }
1541
1542 pub fn load_dir(name: impl Into<String>, dir: impl AsRef<Path>) -> Result<Self, CorpusError> {
1555 let dir = dir.as_ref();
1556 let manifest = Self::load_manifest(dir)?;
1557 let mut paths = Vec::new();
1558 let entries = std::fs::read_dir(dir).map_err(|error| CorpusError::Read {
1559 path: dir.to_path_buf(),
1560 message: error.to_string(),
1561 })?;
1562 for entry in entries {
1563 let entry = entry.map_err(|error| CorpusError::Read {
1564 path: dir.to_path_buf(),
1565 message: error.to_string(),
1566 })?;
1567 let path = entry.path();
1568 if path.file_stem().and_then(|stem| stem.to_str()) == Some(MANIFEST_STEM) {
1569 continue;
1570 }
1571 if matches!(
1572 path.extension().and_then(|ext| ext.to_str()),
1573 Some("toml" | "json")
1574 ) {
1575 paths.push(path);
1576 }
1577 }
1578 paths.sort();
1579 let items = paths
1580 .iter()
1581 .map(Self::load_item)
1582 .collect::<Result<Vec<_>, _>>()?;
1583 Self::with_provenance(name, items, manifest.provenance)
1584 }
1585
1586 #[must_use]
1588 pub fn select(&self, selection: &SelectionConfig) -> Vec<&EvalItem> {
1589 self.items
1590 .iter()
1591 .filter(|item| selection.selects(&item.id, &item.tags))
1592 .collect()
1593 }
1594
1595 #[must_use]
1597 pub fn get(&self, id: &ItemId) -> Option<&EvalItem> {
1598 self.items.iter().find(|item| &item.id == id)
1599 }
1600
1601 #[must_use]
1603 pub fn tags(&self) -> Vec<Tag> {
1604 let mut tags: Vec<Tag> = self
1605 .items
1606 .iter()
1607 .flat_map(|item| item.tags.iter().cloned())
1608 .collect();
1609 tags.sort();
1610 tags.dedup();
1611 tags
1612 }
1613}
1614
1615fn read_document<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, CorpusError> {
1618 let source = std::fs::read_to_string(path).map_err(|error| CorpusError::Read {
1619 path: path.to_path_buf(),
1620 message: error.to_string(),
1621 })?;
1622 match path.extension().and_then(|ext| ext.to_str()) {
1623 Some("toml") => toml::from_str(&source).map_err(|error| CorpusError::Parse {
1624 path: path.to_path_buf(),
1625 message: error.to_string(),
1626 }),
1627 Some("json") => serde_json::from_str(&source).map_err(|error| CorpusError::Parse {
1628 path: path.to_path_buf(),
1629 message: error.to_string(),
1630 }),
1631 _ => Err(CorpusError::UnknownFormat {
1632 path: path.to_path_buf(),
1633 }),
1634 }
1635}
1636
1637#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1639#[non_exhaustive]
1640pub enum CorpusError {
1641 #[error("corpus path {path} could not be read: {message}")]
1643 Read {
1644 path: PathBuf,
1646 message: String,
1648 },
1649 #[error("corpus file {path} is neither .toml nor .json")]
1651 UnknownFormat {
1652 path: PathBuf,
1654 },
1655 #[error("corpus file {path} could not be parsed: {message}")]
1657 Parse {
1658 path: PathBuf,
1660 message: String,
1662 },
1663 #[error("corpus item is invalid at `{field}`: {reason}")]
1665 Invalid {
1666 field: String,
1668 reason: String,
1670 },
1671 #[error("corpus contains two items with the identifier `{id}`")]
1673 DuplicateId {
1674 id: ItemId,
1676 },
1677}
1678
1679impl CorpusError {
1680 fn invalid(field: &str, reason: &str) -> Self {
1681 Self::Invalid {
1682 field: field.to_owned(),
1683 reason: reason.to_owned(),
1684 }
1685 }
1686}
1687
1688#[cfg(test)]
1689mod tests {
1690
1691 #[test]
1692 fn a_world_can_hold_a_record_that_is_not_a_case() {
1693 let item: EvalItem = toml::from_str(
1694 r#"
1695 id = "traveler.delete_a_registered_one"
1696 name = "Deleting a traveler that is already registered"
1697
1698 [[setup.records]]
1699 kind = "traveler"
1700 data = { full_name = "Luca Ferri", loyalty_number = "AZ2345678" }
1701
1702 [turn]
1703 text = "elimina il viaggiatore Luca Ferri"
1704 "#,
1705 )
1706 .expect("si carica");
1707 item.validate().expect("è coerente");
1708 assert_eq!(item.setup.records.len(), 1);
1709 assert_eq!(item.setup.records[0].kind, "traveler");
1710 }
1711
1712 #[test]
1713 fn a_record_without_its_kind_is_refused() {
1714 let refused = toml::from_str::<EvalItem>(
1718 r#"
1719 id = "traveler.kindless"
1720 name = "A record with no kind"
1721
1722 [[setup.records]]
1723 data = { full_name = "Rossi" }
1724
1725 [turn]
1726 text = "ciao"
1727 "#,
1728 );
1729 assert!(refused.is_err());
1730 }
1731
1732 #[test]
1733 fn an_item_asserting_only_state_is_not_empty() {
1734 let item: EvalItem = toml::from_str(
1737 r#"
1738 id = "traveler.only_state"
1739 name = "Only a state expectation"
1740
1741 [[expect.case_state]]
1742 case_id = "c-1"
1743 path = "/fields/email/value"
1744 unchanged = true
1745
1746 [turn]
1747 text = "ciao"
1748 "#,
1749 )
1750 .expect("si carica");
1751 assert!(!item.expect.is_empty(), "questo item assertisce qualcosa");
1752 }
1753
1754 #[test]
1755 fn a_state_expectation_that_asks_nothing_is_refused() {
1756 let refused = toml::from_str::<EvalItem>(
1759 r#"
1760 id = "traveler.silent"
1761 name = "A state expectation with neither side"
1762
1763 [[expect.case_state]]
1764 case_id = "c-1"
1765 path = "/fields/email/value"
1766
1767 [turn]
1768 text = "ciao"
1769 "#,
1770 )
1771 .expect("si carica")
1772 .validate();
1773 assert!(refused.is_err(), "deve essere rifiutata in validazione");
1774 }
1775
1776 #[test]
1777 fn a_state_expectation_cannot_ask_both_at_once() {
1778 let refused = toml::from_str::<EvalItem>(
1779 r#"
1780 id = "traveler.both"
1781 name = "Both at once"
1782
1783 [[expect.case_state]]
1784 case_id = "c-1"
1785 path = "/fields/email/value"
1786 equals = "a@b.it"
1787 unchanged = true
1788
1789 [turn]
1790 text = "ciao"
1791 "#,
1792 )
1793 .expect("si carica")
1794 .validate();
1795 assert!(refused.is_err());
1796 }
1797
1798 #[test]
1799 fn a_state_expectation_carries_either_side() {
1800 let item: EvalItem = toml::from_str(
1801 r#"
1802 id = "traveler.mind_changed"
1803 name = "The second gate won"
1804
1805 [[expect.case_state]]
1806 case_id = "c-1"
1807 path = "/fields/gate/value"
1808 equals = "Gate B14"
1809
1810 [[expect.case_state]]
1811 case_id = "c-1"
1812 path = "/fields/email/value"
1813 unchanged = true
1814
1815 [turn]
1816 text = "sì quella"
1817 "#,
1818 )
1819 .expect("si carica");
1820 item.validate().expect("è coerente");
1821 assert_eq!(item.expect.case_state.len(), 2);
1822 }
1823
1824 #[test]
1825 fn a_setup_can_carry_what_was_already_said() {
1826 let item: EvalItem = toml::from_str(
1827 r#"
1828 id = "traveler.changed_their_mind"
1829 name = "The user contradicts something they said earlier"
1830
1831 [[setup.history]]
1832 user = "il viaggiatore è Luca Ferri"
1833 assistant = "Va bene. Mi serve anche l'indirizzo."
1834
1835 [[setup.history]]
1836 user = "anzi no, è la Bianchi"
1837
1838 [turn]
1839 text = "sì quella, vai avanti"
1840 "#,
1841 )
1842 .expect("l'item si carica");
1843 item.validate().expect("l'item è coerente");
1844 assert_eq!(item.setup.history.len(), 2);
1845 assert_eq!(
1846 item.setup.history[0].assistant.as_deref(),
1847 Some("Va bene. Mi serve anche l'indirizzo.")
1848 );
1849 assert!(
1850 item.setup.history[1].assistant.is_none(),
1851 "un turno senza risposta è il caso che rende utile questo campo"
1852 );
1853 }
1854
1855 #[test]
1856 fn a_setup_without_history_has_none_rather_than_an_empty_turn() {
1857 let item: EvalItem = toml::from_str(
1858 r#"
1859 id = "traveler.plain"
1860 name = "No history at all"
1861
1862 [turn]
1863 text = "ciao"
1864 "#,
1865 )
1866 .expect("l'item si carica");
1867 assert!(item.setup.history.is_empty());
1868 }
1869
1870 #[test]
1871 fn a_misspelled_side_of_an_exchange_is_refused() {
1872 let refused = toml::from_str::<EvalItem>(
1876 r#"
1877 id = "traveler.typo"
1878 name = "A typo"
1879
1880 [[setup.history]]
1881 user = "ciao"
1882 assistent = "ciao a te"
1883
1884 [turn]
1885 text = "ciao"
1886 "#,
1887 );
1888 assert!(refused.is_err(), "una chiave sconosciuta è un errore");
1889 }
1890
1891 #[test]
1892 fn a_case_says_which_conversation_opened_it_by_name() {
1893 let item: EvalItem = toml::from_str(
1894 r#"
1895 id = "trip.written_from_another_chat"
1896 name = "A draft opened elsewhere is not this turn's subject"
1897
1898 [[setup.cases]]
1899 workflow = "trip"
1900 case_id = "trip-1"
1901 label = "Trip 1"
1902 state = { status = "draft" }
1903 conversation = "the other chat"
1904
1905 [turn]
1906 text = "add a line for 100 euro"
1907 "#,
1908 )
1909 .expect("l'item si carica");
1910 item.validate().expect("l'item è coerente");
1911 assert_eq!(
1912 item.setup.cases[0].conversation.as_deref(),
1913 Some("the other chat")
1914 );
1915 }
1916
1917 #[test]
1918 fn a_case_without_one_belongs_to_the_turns_own_conversation() {
1919 let item: EvalItem = toml::from_str(
1920 r#"
1921 id = "trip.ordinary"
1922 name = "The ordinary case"
1923
1924 [[setup.cases]]
1925 workflow = "trip"
1926 case_id = "trip-1"
1927 label = "Trip 1"
1928 state = { status = "draft" }
1929
1930 [turn]
1931 text = "add a line for 100 euro"
1932 "#,
1933 )
1934 .expect("l'item si carica");
1935 assert!(
1936 item.setup.cases[0].conversation.is_none(),
1937 "l'assenza è il caso normale e non va confusa con un nome vuoto"
1938 );
1939 }
1940
1941 #[test]
1942 fn a_misspelled_conversation_key_is_still_refused() {
1943 let refused = toml::from_str::<EvalItem>(
1948 r#"
1949 id = "trip.typo"
1950 name = "A typo"
1951
1952 [[setup.cases]]
1953 workflow = "trip"
1954 case_id = "trip-1"
1955 label = "Trip 1"
1956 state = { status = "draft" }
1957 converstaion = "the other chat"
1958
1959 [turn]
1960 text = "hello"
1961 "#,
1962 );
1963 assert!(refused.is_err(), "una chiave sconosciuta è un errore");
1964 }
1965
1966 use super::*;
1967
1968 fn item(body: &str) -> Result<EvalItem, String> {
1969 let parsed: EvalItem = toml::from_str(body).map_err(|error| error.to_string())?;
1970 parsed.validate().map_err(|error| error.to_string())?;
1971 Ok(parsed)
1972 }
1973
1974 const MINIMAL: &str = r#"
1975id = "a"
1976name = "A scenario"
1977[turn]
1978text = "hello"
1979"#;
1980
1981 #[test]
1982 fn a_minimal_item_loads() {
1983 let parsed = item(MINIMAL).unwrap();
1984 assert_eq!(parsed.id, ItemId::new("a"));
1985 assert!(parsed.expect.is_empty());
1986 assert!(parsed.judge.is_empty());
1987 }
1988
1989 #[test]
1990 fn an_unknown_field_is_refused_rather_than_skipped() {
1991 let error = item(&format!("{MINIMAL}unexpected = 1\n")).expect_err("unknown key");
1992 assert!(error.contains("unexpected"), "{error}");
1993 }
1994
1995 #[test]
1996 fn a_turn_with_nothing_in_it_is_refused() {
1997 let error = item("id = \"a\"\nname = \"A\"\n[turn]\n").expect_err("empty turn");
1998 assert!(error.contains("turn"), "{error}");
1999 }
2000
2001 #[test]
2002 fn an_operation_on_a_kind_that_has_none_is_refused() {
2003 let error = item(&format!(
2004 "{MINIMAL}[[expect.acts]]\nkind = \"start_workflow\"\noperation = \"trip.rebook\"\n"
2005 ))
2006 .expect_err("operation on start_workflow");
2007 assert!(error.contains("start_workflow"), "{error}");
2008 }
2009
2010 #[test]
2011 fn a_case_on_an_ambiguous_resolution_is_refused() {
2012 let error = item(&format!(
2013 "{MINIMAL}[[expect.target_resolution]]\nact_index = 0\nresolution = \"ambiguous\"\ncase_id = \"trip-1\"\n"
2014 ))
2015 .expect_err("case on ambiguous");
2016 assert!(error.contains("ambiguous"), "{error}");
2017 }
2018
2019 #[test]
2020 fn a_command_both_required_and_forbidden_is_refused() {
2021 let error = item(&format!(
2022 "{MINIMAL}[expect]\ncommands = [\"trip.rebook\"]\nforbid = {{ commands = [\"trip.rebook\"] }}\n"
2023 ))
2024 .expect_err("contradiction");
2025 assert!(error.contains("trip.rebook"), "{error}");
2026 }
2027
2028 #[test]
2029 fn a_seeded_state_must_be_an_object() {
2030 let error = item(&format!(
2031 "{MINIMAL}[[setup.cases]]\nworkflow = \"trip\"\ncase_id = \"trip-1\"\nlabel = \"Trip 1\"\nstate = 7\n"
2032 ))
2033 .expect_err("scalar state");
2034 assert!(error.contains("state"), "{error}");
2035 }
2036
2037 #[test]
2038 fn a_suite_refuses_two_items_with_the_same_identifier() {
2039 let one = item(MINIMAL).unwrap();
2040 let two = item(MINIMAL).unwrap();
2041 let error = Suite::new("dup", vec![one, two]).expect_err("duplicate");
2042 assert!(matches!(error, CorpusError::DuplicateId { .. }), "{error}");
2043 }
2044
2045 const WITH_A_CASE: &str = r#"
2046id = "a"
2047name = "A scenario"
2048[turn]
2049text = "hello"
2050[[setup.cases]]
2051workflow = "trip"
2052case_id = "trip-1"
2053label = "Trip 1"
2054state = { status = "draft" }
2055"#;
2056
2057 #[test]
2058 fn a_provenance_declaration_loads_and_reaches_the_fingerprint() {
2059 let parsed = item(&format!(
2060 "provenance = {{ setup = \"derived\", turn = \"recorded\" }}\n{WITH_A_CASE}"
2061 ))
2062 .unwrap();
2063 assert_eq!(parsed.provenance.setup, PartProvenance::Derived);
2064 let fingerprint = parsed.fingerprint();
2065 assert_eq!(
2066 fingerprint.provenance_of(ItemPart::Setup),
2067 PartProvenance::Derived
2068 );
2069 assert_eq!(
2070 fingerprint.provenance_of(ItemPart::Turn),
2071 PartProvenance::Recorded
2072 );
2073 assert_eq!(
2075 fingerprint.provenance_of(ItemPart::Expect),
2076 PartProvenance::Authored
2077 );
2078 assert!(!fingerprint.is_unknown());
2079 }
2080
2081 #[test]
2082 fn only_a_derived_part_may_be_regenerated() {
2083 assert!(PartProvenance::Derived.may_be_regenerated());
2086 assert!(!PartProvenance::Authored.may_be_regenerated());
2087 assert!(!PartProvenance::Recorded.may_be_regenerated());
2088 }
2089
2090 #[test]
2091 fn an_unknown_part_name_is_refused_rather_than_skipped() {
2092 let error = item(&format!(
2093 "provenance = {{ setpu = \"derived\" }}\n{WITH_A_CASE}"
2094 ))
2095 .expect_err("typo");
2096 assert!(error.contains("setpu"), "{error}");
2097 }
2098
2099 #[test]
2100 fn an_unknown_provenance_is_refused_rather_than_skipped() {
2101 let error = item(&format!(
2102 "provenance = {{ setup = \"transcribed\" }}\n{WITH_A_CASE}"
2103 ))
2104 .expect_err("unknown value");
2105 assert!(error.contains("transcribed"), "{error}");
2106 }
2107
2108 #[test]
2109 fn a_part_declared_on_an_item_that_has_none_is_refused() {
2110 let error = item(&format!(
2114 "provenance = {{ setup = \"recorded\" }}\n{MINIMAL}"
2115 ))
2116 .expect_err("no setup");
2117 assert!(
2118 error.contains("this item has no `setup`"),
2119 "the reason must be the missing part, not a stray parse error: {error}"
2120 );
2121 }
2122
2123 #[test]
2131 fn a_setup_made_only_of_history_or_records_is_still_a_setup() {
2132 let mut item = item(MINIMAL).expect("the minimal item loads");
2133 assert!(
2134 !item.carries(ItemPart::Setup),
2135 "nothing seeded, nothing said"
2136 );
2137
2138 item.setup.history.push(PriorExchange {
2139 user: "e il viaggiatore di Torino?".to_owned(),
2140 assistant: None,
2141 });
2142 assert!(item.carries(ItemPart::Setup));
2143
2144 item.setup.history.clear();
2145 item.setup.records.push(SeededRecord {
2146 kind: "traveler".to_owned(),
2147 data: serde_json::json!({"name": "Ferri"}),
2148 });
2149 assert!(item.carries(ItemPart::Setup));
2150 }
2151
2152 #[test]
2153 fn a_blanket_declaration_reaches_every_item_that_has_the_part() {
2154 let with_case = item(WITH_A_CASE).unwrap();
2155 let mut without_case = item(MINIMAL).unwrap();
2156 without_case.id = ItemId::new("b");
2157
2158 let blanket = Provenance {
2159 setup: PartProvenance::Derived,
2160 turn: PartProvenance::Recorded,
2161 ..Provenance::authored()
2162 };
2163 let suite = Suite::with_provenance("s", vec![with_case, without_case], blanket).unwrap();
2164
2165 assert_eq!(suite.items[0].provenance.setup, PartProvenance::Derived);
2166 assert_eq!(suite.items[0].provenance.turn, PartProvenance::Recorded);
2167 assert_eq!(suite.items[1].provenance.setup, PartProvenance::Authored);
2170 assert_eq!(suite.items[1].provenance.turn, PartProvenance::Recorded);
2171 }
2172
2173 #[test]
2174 fn an_items_own_declaration_wins_over_the_directorys() {
2175 let recorded = item(&format!(
2176 "provenance = {{ setup = \"recorded\" }}\n{WITH_A_CASE}"
2177 ))
2178 .unwrap();
2179 let suite = Suite::with_provenance(
2180 "s",
2181 vec![recorded],
2182 Provenance {
2183 setup: PartProvenance::Derived,
2184 ..Provenance::authored()
2185 },
2186 )
2187 .unwrap();
2188 assert_eq!(suite.items[0].provenance.setup, PartProvenance::Recorded);
2189 }
2190
2191 #[test]
2192 fn a_fingerprint_ignores_the_order_the_seeded_state_was_written_in() {
2193 let one = item(
2194 "id = \"a\"\nname = \"A\"\n[turn]\ntext = \"hi\"\n[[setup.cases]]\nworkflow = \"trip\"\ncase_id = \"trip-1\"\nlabel = \"L\"\nstate = { alpha = 1, beta = 2 }\n",
2195 )
2196 .unwrap();
2197 let two = item(
2198 "id = \"a\"\nname = \"A\"\n[turn]\ntext = \"hi\"\n[[setup.cases]]\nworkflow = \"trip\"\ncase_id = \"trip-1\"\nlabel = \"L\"\nstate = { beta = 2, alpha = 1 }\n",
2199 )
2200 .unwrap();
2201 assert!(
2202 one.fingerprint()
2203 .differing_parts(&two.fingerprint())
2204 .is_empty()
2205 );
2206 }
2207
2208 #[test]
2209 fn a_changed_seeded_state_shows_up_as_a_changed_setup_part() {
2210 let one = item(WITH_A_CASE).unwrap();
2211 let two = item(&WITH_A_CASE.replace("draft", "sent")).unwrap();
2212 assert_eq!(
2213 one.fingerprint().differing_parts(&two.fingerprint()),
2214 vec![ItemPart::Setup]
2215 );
2216 }
2217
2218 #[test]
2219 fn an_unknown_fingerprint_claims_nothing() {
2220 let known = item(WITH_A_CASE).unwrap().fingerprint();
2221 let unknown = ItemFingerprint::default();
2222 assert!(unknown.is_unknown());
2223 assert!(known.differing_parts(&unknown).is_empty());
2224 assert!(unknown.differing_parts(&known).is_empty());
2225 }
2226
2227 #[test]
2228 fn selection_narrows_a_suite() {
2229 let mut tagged = item(MINIMAL).unwrap();
2230 tagged.tags = vec![Tag::new("trip")];
2231 let mut other = item(MINIMAL).unwrap();
2232 other.id = ItemId::new("b");
2233 other.tags = vec![Tag::new("traveler")];
2234 let suite = Suite::new("s", vec![tagged, other]).unwrap();
2235
2236 let selection = SelectionConfig {
2237 include_tags: vec![Tag::new("trip")],
2238 ..SelectionConfig::default()
2239 };
2240 let selected = suite.select(&selection);
2241 assert_eq!(selected.len(), 1);
2242 assert_eq!(selected[0].id, ItemId::new("a"));
2243 assert_eq!(suite.tags().len(), 2);
2244 }
2245}