Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions crates/procnote-core/src/event/log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,14 @@ mod tests {
content_type: "image/jpeg".to_string(),
sha256: "abc123".to_string(),
},
Event::StepContentUpdated {
at: now,
execution_id: id,
step_heading: "Step 1".to_string(),
content: vec![StepContent::Prose {
text: "Updated description".to_string(),
}],
},
];

// Round-trip all event types through JSON.
Expand Down
12 changes: 12 additions & 0 deletions crates/procnote-core/src/event/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,14 @@ pub enum Event {
sha256: String,
},

// -- Step Content --
StepContentUpdated {
at: DateTime<Utc>,
execution_id: ExecutionId,
step_heading: String,
content: Vec<StepContent>,
},

// -- Name --
ExecutionRenamed {
at: DateTime<Utc>,
Expand Down Expand Up @@ -161,6 +169,7 @@ impl Event {
| Self::InputRecorded { .. }
| Self::NoteAdded { .. }
| Self::AttachmentAdded { .. }
| Self::StepContentUpdated { .. }
| Self::ExecutionRenamed { .. } => Revertibility::Revertible,

// Revert marker — not revertible
Expand Down Expand Up @@ -237,6 +246,9 @@ impl Event {
} => {
format!("Recorded {label} = {filename} in {step_heading}")
}
Self::StepContentUpdated { step_heading, .. } => {
format!("Updated content of step: {step_heading}")
}
Self::ExecutionRenamed { name, .. } => {
format!("Renamed execution to: {name}")
}
Expand Down
171 changes: 171 additions & 0 deletions crates/procnote-core/src/execution/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,16 @@ impl ExecutionState {
);
}

Event::StepContentUpdated {
step_heading,
content,
..
} => {
self.require_active()?;
let step = self.get_step_mut(step_heading)?;
step.content.clone_from(content);
}

Event::ExecutionRenamed { name, .. } => {
if self.execution_id.is_none() {
return Err(ExecutionError::NotStarted);
Expand Down Expand Up @@ -419,6 +429,23 @@ impl ExecutionState {
Ok(event)
}

/// Update the content of an existing step.
pub fn update_step_content(
&mut self,
step_heading: &str,
content: Vec<StepContent>,
) -> Result<Event, ExecutionError> {
self.require_active()?;
let event = Event::StepContentUpdated {
at: Utc::now(),
execution_id: self.require_execution_id()?,
step_heading: step_heading.to_string(),
content,
};
self.apply(&event)?;
Ok(event)
}

/// Start a step.
pub fn start_step(&mut self, heading: &str) -> Result<Event, ExecutionError> {
self.require_active()?;
Expand Down Expand Up @@ -1234,4 +1261,148 @@ mod tests {
.contains_key("Current")
);
}

// -- StepContentUpdated tests --

#[test]
fn test_update_step_content() {
let template = sample_template();
let mut state = ExecutionState::new();
state.start(&template).unwrap();

let new_content = vec![StepContent::Prose {
text: "Updated description".to_string(),
}];
state
.update_step_content("Preconditions", new_content)
.unwrap();

assert_eq!(state.steps["Preconditions"].content.len(), 1);
assert!(
matches!(&state.steps["Preconditions"].content[0], StepContent::Prose { text } if text == "Updated description")
);
}

#[test]
fn test_update_step_content_preserves_inputs_and_notes() {
let template = sample_template();
let mut state = ExecutionState::new();
state.start(&template).unwrap();
state.start_step("Preconditions").unwrap();
state
.record_input("Preconditions", "Voltage", "5.0", Some("V"))
.unwrap();
state
.add_note("Some observation", Some("Preconditions"))
.unwrap();

let new_content = vec![StepContent::Prose {
text: "New prose".to_string(),
}];
state
.update_step_content("Preconditions", new_content)
.unwrap();

// Content is replaced.
assert_eq!(state.steps["Preconditions"].content.len(), 1);
// Inputs and notes are untouched.
assert_eq!(state.steps["Preconditions"].inputs["Voltage"].value, "5.0");
assert_eq!(state.steps["Preconditions"].notes.len(), 1);
assert_eq!(state.steps["Preconditions"].notes[0], "Some observation");
}

#[test]
fn test_cannot_update_content_before_start() {
let mut state = ExecutionState::new();
let result = state.update_step_content("Step 1", vec![]);
assert_eq!(result.unwrap_err(), ExecutionError::NotStarted);
}

#[test]
fn test_cannot_update_content_after_finish() {
let template = sample_template();
let mut state = ExecutionState::new();
state.start(&template).unwrap();
state.complete(CompletionStatus::Pass).unwrap();

let result = state.update_step_content("Preconditions", vec![]);
assert_eq!(result.unwrap_err(), ExecutionError::AlreadyFinished);
}

#[test]
fn test_cannot_update_content_nonexistent_step() {
let template = sample_template();
let mut state = ExecutionState::new();
state.start(&template).unwrap();

let result = state.update_step_content("Nonexistent Step", vec![]);
assert_eq!(
result.unwrap_err(),
ExecutionError::StepNotFound("Nonexistent Step".to_string())
);
}

#[test]
fn test_revert_step_content_updated() {
let template = sample_template();
let mut state = ExecutionState::new();
let mut events: Vec<Event> = Vec::new();
events.extend(state.start(&template).unwrap());
// index 0..4: ExecutionStarted + ExecutionRenamed + 3 StepAdded

let original_content = state.steps["Preconditions"].content.clone();

events.push(
state
.update_step_content(
"Preconditions",
vec![StepContent::Prose {
text: "Changed".to_string(),
}],
)
.unwrap(),
); // index 5

// Revert the content update.
let revert = ExecutionState::revert_event(&events, 5, "undo edit").unwrap();
events.push(revert);

let rebuilt = ExecutionState::from_events(&events).unwrap();
assert_eq!(rebuilt.steps["Preconditions"].content, original_content);
}

#[test]
fn test_update_step_content_replay() {
let template = sample_template();
let mut state = ExecutionState::new();
let mut events: Vec<Event> = Vec::new();
events.extend(state.start(&template).unwrap());

events.push(
state
.update_step_content(
"Preconditions",
vec![StepContent::Prose {
text: "Updated via replay".to_string(),
}],
)
.unwrap(),
);

// Serialize and deserialize all events.
let jsons: Vec<String> = events
.iter()
.map(|e| serde_json::to_string(e).unwrap())
.collect();
let deserialized_events: Vec<Event> = jsons
.iter()
.map(|j| serde_json::from_str(j).unwrap())
.collect();

let replayed = ExecutionState::from_events(&deserialized_events).unwrap();
assert_eq!(replayed.steps["Preconditions"].content.len(), 1);
assert!(
matches!(&replayed.steps["Preconditions"].content[0], StepContent::Prose { text } if text == "Updated via replay")
);
}
}
15 changes: 14 additions & 1 deletion src-tauri/src/commands/execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,8 @@ fn event_step_and_label(event: &Event) -> (Option<String>, Option<String>) {
Event::StepStarted { step_heading, .. }
| Event::StepCompleted { step_heading, .. }
| Event::StepSkipped { step_heading, .. }
| Event::CheckboxToggled { step_heading, .. } => (Some(step_heading.clone()), None),
| Event::CheckboxToggled { step_heading, .. }
| Event::StepContentUpdated { step_heading, .. } => (Some(step_heading.clone()), None),
Event::InputRecorded {
step_heading,
label,
Expand Down Expand Up @@ -397,6 +398,7 @@ fn event_type_string(event: &Event) -> String {
Event::InputRecorded { .. } => "input_recorded",
Event::NoteAdded { .. } => "note_added",
Event::AttachmentAdded { .. } => "attachment_added",
Event::StepContentUpdated { .. } => "step_content_updated",
Event::ExecutionRenamed { .. } => "execution_renamed",
Event::EventReverted { .. } => "event_reverted",
}
Expand All @@ -416,6 +418,7 @@ fn event_at(event: &Event) -> String {
| Event::InputRecorded { at, .. }
| Event::NoteAdded { at, .. }
| Event::AttachmentAdded { at, .. }
| Event::StepContentUpdated { at, .. }
| Event::ExecutionRenamed { at, .. }
| Event::EventReverted { at, .. } => at.to_rfc3339(),
}
Expand Down Expand Up @@ -557,6 +560,10 @@ pub enum ExecutionAction {
#[ts(optional)]
after_step: Option<String>,
},
UpdateStepContent {
step_heading: String,
content: Vec<StepContent>,
},
AddAttachment {
step_heading: String,
label: String,
Expand Down Expand Up @@ -652,6 +659,12 @@ pub fn record_action(
} => exec_state
.add_step(&heading, content, after_step.as_deref())
.map_err(|e| e.to_string())?,
ExecutionAction::UpdateStepContent {
step_heading,
content,
} => exec_state
.update_step_content(&step_heading, content)
.map_err(|e| e.to_string())?,
ExecutionAction::AddAttachment {
step_heading,
label,
Expand Down
Loading