Transitions now declare which state they fire from (default "open", today's implicit behavior - existing content needs no edits). AnswerRow renders only the buttons legal from a row's current state; two declared transitions may share a target (open -> declined, in_dialogue -> declined), so apply_transition matches on (from, to) against the row's actual state instead of the old blanket "already decided" check. validate_questions/question_lint reject a from naming an undeclared state or a from -> to pair the bucket's graph never declared. store_answer's KV projection now starts a record at its schema's declared initial state, not a hardcoded "open" - organizations begin at "prospect", and the read model has to agree with the event log about that. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
35663a28c1
commit
40694a8f3f
@@ -216,10 +216,20 @@ impl ResourceSpec {
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Transition {
|
||||
/// The state a row must currently be in for this transition's
|
||||
/// button to render and its server call to be accepted - defaults
|
||||
/// to `"open"`, which is what every transition meant before graphs
|
||||
/// grew deeper than one decision (invited -> active, etc.).
|
||||
#[serde(default = "default_transition_from")]
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
fn default_transition_from() -> String {
|
||||
crate::answers::OPEN_STATE.to_string()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Requirement {
|
||||
pub name: String,
|
||||
@@ -473,12 +483,24 @@ pub fn validate_questions(
|
||||
continue;
|
||||
};
|
||||
for transition in &resource.transitions {
|
||||
if !schema.has_state(&transition.from) {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: transition.from {:?} is not a declared state for bucket {:?}",
|
||||
question.id, alternative.name, feature.name, transition.from, bucket
|
||||
);
|
||||
}
|
||||
if !schema.has_state(&transition.to) {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: transition.to {:?} is not a declared state for bucket {:?}",
|
||||
question.id, alternative.name, feature.name, transition.to, bucket
|
||||
);
|
||||
}
|
||||
if !schema.allowed(&transition.from).iter().any(|s| s == &transition.to) {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: transition {:?} -> {:?} is not a declared edge for bucket {:?}",
|
||||
question.id, alternative.name, feature.name, transition.from, transition.to, bucket
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -680,3 +702,76 @@ pub async fn automation_kv_handler(
|
||||
}
|
||||
Ok(axum::Json(serde_json::Value::Array(items)))
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "ssr"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn schema_fixture() -> std::collections::HashMap<String, crate::aggregates::AggregateSchema> {
|
||||
crate::aggregates::parse_aggregates_yaml(
|
||||
r#"
|
||||
aggregates:
|
||||
- bucket: things
|
||||
initial: open
|
||||
states:
|
||||
open: { event: opened }
|
||||
middle: { event: advanced }
|
||||
done: { event: finished }
|
||||
transitions:
|
||||
open: [middle]
|
||||
middle: [done]
|
||||
"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn question_with_transitions(transitions_yaml: &str) -> std::collections::HashMap<String, Question> {
|
||||
let question: Question = serde_yaml::from_str(&format!(
|
||||
r#"
|
||||
id: /t
|
||||
name: T
|
||||
alternatives:
|
||||
- name: A
|
||||
features:
|
||||
- name: ""
|
||||
resource:
|
||||
source: {{ kind: kv, bucket: things }}
|
||||
requires_group: owners
|
||||
transitions:
|
||||
{transitions_yaml}
|
||||
"#
|
||||
))
|
||||
.unwrap();
|
||||
std::collections::HashMap::from([(question.id.clone(), question)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declared_edge_passes() {
|
||||
let questions = question_with_transitions(
|
||||
" - { from: open, to: middle, label: Advance }\n - { from: middle, to: done, label: Finish }",
|
||||
);
|
||||
assert!(validate_questions(&questions, &schema_fixture()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_defaults_to_open() {
|
||||
let questions = question_with_transitions(" - { to: middle, label: Advance }");
|
||||
assert!(validate_questions(&questions, &schema_fixture()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undeclared_from_state_is_rejected() {
|
||||
let questions = question_with_transitions(" - { from: bogus, to: middle, label: X }");
|
||||
let err = validate_questions(&questions, &schema_fixture()).unwrap_err();
|
||||
assert!(err.to_string().contains("transition.from"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undeclared_edge_is_rejected() {
|
||||
// Both states exist, but open -> done skips a step the graph
|
||||
// never declared.
|
||||
let questions = question_with_transitions(" - { from: open, to: done, label: Skip }");
|
||||
let err = validate_questions(&questions, &schema_fixture()).unwrap_err();
|
||||
assert!(err.to_string().contains("not a declared edge"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user