1use serde::{Deserialize, Serialize};
19
20use super::CouncilDecisionRecord;
21use crate::enums::GovernanceLogEntryType;
22use crate::ids::{GovernanceLogId, PostId};
23
24pub const TEST_ENTRY_VERSION: u32 = 1;
26
27pub const TEST_LABEL: &str = "TEST: a test sitting; not binding";
30
31pub const TEST_INNER_POINTER: &str = "/inner";
34
35pub const TEST_MACHINE_FIELDS: [&str; 3] =
38 ["/agora_test", "/post", "/inner_type"];
39
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
46#[cfg_attr(feature = "schemars", schemars(inline))]
47pub struct TestEntry {
48 pub agora_test: u32,
50 pub title: String,
52 pub description: String,
55 pub post: PostId,
57 pub inner_type: GovernanceLogEntryType,
59 pub inner: serde_json::Value,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
66pub enum TestEntryError {
67 #[error("agora_test is not the version this writer writes")]
68 Version,
69 #[error("a test entry's title and description must both say something")]
70 Blank,
71 #[error(
72 "a test entry wraps a decision-like entry (council_decision, \
73 appeals_court_decision, emergency_action, policy_change, \
74 steward_veto), not {0}"
75 )]
76 InnerType(GovernanceLogEntryType),
77 #[error("a test entry's inner data must be a JSON object")]
78 InnerNotObject,
79 #[error(
80 "the inner data carries its own _blind; the test entry's blind \
81 covers it, and the writer supplies that"
82 )]
83 InnerBlinded,
84 #[error("the inner record's id is {inner}, not this entry's id {outer}")]
85 InnerId {
86 inner: GovernanceLogId,
87 outer: GovernanceLogId,
88 },
89 #[error("the inner record does not parse as a {0}: {1}")]
90 Inner(GovernanceLogEntryType, String),
91}
92
93impl TestEntry {
94 pub const fn wraps(inner_type: GovernanceLogEntryType) -> bool {
99 matches!(
100 inner_type,
101 GovernanceLogEntryType::CouncilDecision
102 | GovernanceLogEntryType::AppealsCourtDecision
103 | GovernanceLogEntryType::EmergencyAction
104 | GovernanceLogEntryType::PolicyChange
105 | GovernanceLogEntryType::StewardVeto
106 )
107 }
108
109 pub fn council_decision(
113 title: impl Into<String>,
114 description: impl Into<String>,
115 post: PostId,
116 record: &CouncilDecisionRecord,
117 ) -> Self {
118 Self {
119 agora_test: TEST_ENTRY_VERSION,
120 title: title.into(),
121 description: description.into(),
122 post,
123 inner_type: GovernanceLogEntryType::CouncilDecision,
124 inner: serde_json::to_value(record)
125 .expect("a CouncilDecisionRecord always serializes"),
126 }
127 }
128
129 pub fn validate(&self, id: &GovernanceLogId) -> Result<(), TestEntryError> {
134 if self.agora_test != TEST_ENTRY_VERSION {
135 return Err(TestEntryError::Version);
136 }
137 if self.title.trim().is_empty() || self.description.trim().is_empty() {
138 return Err(TestEntryError::Blank);
139 }
140 if !Self::wraps(self.inner_type) {
141 return Err(TestEntryError::InnerType(self.inner_type));
142 }
143 let inner = self
144 .inner
145 .as_object()
146 .ok_or(TestEntryError::InnerNotObject)?;
147 if inner.contains_key(super::BLIND_KEY) {
148 return Err(TestEntryError::InnerBlinded);
149 }
150 if let Some(written) = inner.get("id") {
151 let inner_id: GovernanceLogId =
152 serde_json::from_value(written.clone()).map_err(|e| {
153 TestEntryError::Inner(self.inner_type, e.to_string())
154 })?;
155 if &inner_id != id {
156 return Err(TestEntryError::InnerId {
157 inner: inner_id,
158 outer: id.clone(),
159 });
160 }
161 }
162 if let Some(Err(e)) = self.council_record() {
163 return Err(TestEntryError::Inner(self.inner_type, e.to_string()));
164 }
165 Ok(())
166 }
167
168 pub fn council_record(
170 &self,
171 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
172 (self.inner_type == GovernanceLogEntryType::CouncilDecision)
173 .then(|| CouncilDecisionRecord::deserialize(&self.inner))
174 }
175
176 pub fn labelled_title(title: &str) -> String {
178 if title.starts_with(TEST_LABEL) {
179 title.to_string()
180 } else {
181 format!("{TEST_LABEL}. {title}")
182 }
183 }
184
185 pub fn may_change(pointer: &str) -> bool {
190 !pointer.is_empty()
193 && pointer != TEST_INNER_POINTER
194 && !TEST_MACHINE_FIELDS.iter().any(|field| {
195 pointer == *field
196 || pointer
197 .strip_prefix(field)
198 .is_some_and(|rest| rest.starts_with('/'))
199 })
200 }
201}
202
203pub fn decision_data(
207 entry_type: GovernanceLogEntryType,
208 data: &serde_json::Value,
209) -> Option<&serde_json::Value> {
210 if entry_type == GovernanceLogEntryType::Test {
211 data.get("inner")
212 } else {
213 Some(data)
214 }
215}
216
217pub fn decision_type(
220 entry_type: GovernanceLogEntryType,
221 data: &serde_json::Value,
222) -> GovernanceLogEntryType {
223 if entry_type == GovernanceLogEntryType::Test {
224 data.get("inner_type")
225 .and_then(|t| serde_json::from_value(t.clone()).ok())
226 .unwrap_or(GovernanceLogEntryType::Unknown)
227 } else {
228 entry_type
229 }
230}
231
232#[cfg(test)]
233pub(crate) mod tests {
234 use super::*;
235 use serde_json::json;
236
237 pub(crate) fn test_id() -> GovernanceLogId {
238 "TEST-2026-0001".parse().unwrap()
239 }
240
241 pub(crate) fn inner() -> serde_json::Value {
243 json!({
244 "id": "TEST-2026-0001",
245 "meeting_id": "00000000-0000-4000-8000-0000000000aa",
246 "category": "Routine",
247 "title": "A test of the witness toolset",
248 "rounds": [],
249 "final_votes": {
250 "artist": "yes", "philosopher": "yes", "lawyer": "yes",
251 "engineer": "yes", "steward": "yes"
252 },
253 "outcome": "approved",
254 "vote_tally": "5-0",
255 "landmark": false,
256 "attachments": [{
257 "name": "provenance.json",
258 "note": "What ran",
259 "content": "{\"commit\":\"0000000\"}"
260 }]
261 })
262 }
263
264 pub(crate) fn entry() -> TestEntry {
265 TestEntry {
266 agora_test: TEST_ENTRY_VERSION,
267 title: "A test of the witness toolset".into(),
268 description: "Heard by a rehearsal composition; nothing binds."
269 .into(),
270 post: "00000000-0000-4000-8000-0000000000bb".parse().unwrap(),
271 inner_type: GovernanceLogEntryType::CouncilDecision,
272 inner: inner(),
273 }
274 }
275
276 #[test]
277 fn round_trips_and_validates() {
278 let e = entry();
279 let value = serde_json::to_value(&e).unwrap();
280 assert_eq!(value["inner_type"], "council_decision");
281 let back: TestEntry = serde_json::from_value(value).unwrap();
282 assert_eq!(back, e);
283 back.validate(&test_id()).unwrap();
284 let record = back.council_record().unwrap().unwrap();
285 assert_eq!(record.id, test_id());
286 assert_eq!(record.attachments[0].name, "provenance.json");
287 }
288
289 #[test]
290 fn a_writer_refuses_what_is_not_a_test_of_a_decision() {
291 let refused = |f: &dyn Fn(&mut TestEntry)| {
292 let mut e = entry();
293 f(&mut e);
294 e.validate(&test_id()).unwrap_err()
295 };
296 assert_eq!(refused(&|e| e.agora_test = 2), TestEntryError::Version);
297 assert_eq!(refused(&|e| e.title = " ".into()), TestEntryError::Blank);
298 for inner_type in [
299 GovernanceLogEntryType::Test,
300 GovernanceLogEntryType::Amendment,
301 GovernanceLogEntryType::KeyRotation,
302 GovernanceLogEntryType::DataSet,
303 GovernanceLogEntryType::Unknown,
304 ] {
305 assert_eq!(
306 refused(&|e| e.inner_type = inner_type),
307 TestEntryError::InnerType(inner_type)
308 );
309 }
310 assert_eq!(
311 refused(&|e| e.inner = json!("x")),
312 TestEntryError::InnerNotObject
313 );
314 assert_eq!(
315 refused(&|e| e.inner["_blind"] = json!("00")),
316 TestEntryError::InnerBlinded
317 );
318 assert!(matches!(
319 refused(&|e| e.inner["id"] = json!("GOV-2026-0040")),
320 TestEntryError::InnerId { .. }
321 ));
322 assert!(matches!(
323 refused(&|e| e.inner["rounds"] = json!("none")),
324 TestEntryError::Inner(GovernanceLogEntryType::CouncilDecision, _)
325 ));
326 }
327
328 #[test]
329 fn labels_and_pointers() {
330 let t = TestEntry::labelled_title("A test");
331 assert_eq!(t, "TEST: a test sitting; not binding. A test");
332 assert_eq!(TestEntry::labelled_title(&t), t);
333 for ok in [
334 "/title",
335 "/description",
336 "/inner/summary/text",
337 "/inner/attachments/0/content",
338 ] {
339 assert!(TestEntry::may_change(ok), "{ok}");
340 }
341 for no in [
342 "",
343 "/post",
344 "/inner_type",
345 "/agora_test",
346 "/inner",
347 "/post/x",
348 ] {
349 assert!(!TestEntry::may_change(no), "{no}");
350 }
351 assert!(TestEntry::may_change("/posts"));
352 let data = serde_json::to_value(entry()).unwrap();
353 assert_eq!(
354 decision_data(GovernanceLogEntryType::Test, &data),
355 Some(&data["inner"])
356 );
357 assert_eq!(
358 decision_type(GovernanceLogEntryType::Test, &data),
359 GovernanceLogEntryType::CouncilDecision
360 );
361 assert_eq!(
362 decision_data(GovernanceLogEntryType::CouncilDecision, &data),
363 Some(&data)
364 );
365 }
366}