1use axum::extract::{Path, State};
12use axum::http::StatusCode;
13use axum::routing::{post, put};
14use axum::{Json, Router};
15use bhtune_cli::args::{DriverKindArg, TuneArgs};
16use bhtune_cli::cancel::CtrlC;
17use bhtune_cli::commands::tune::{
18 PidWriteOutcome, drive, pid_parameters_for_result, prepare, write_pid_values,
19};
20use bhtune_cli::output::OutputFormat;
21use bhtune_core::{
22 ControllerDirection, ControllerType, ProcessType, ResponseLevel, TagOverrides, opc_write_values,
23};
24use bhtune_db::models::{
25 TuneDriver, TuneOutcome, TuneResultRow, TuneRunRow, TuneWriteRow, WriteKind, WriteReadback,
26};
27use bhtune_driver::OpcDaDriver;
28use chrono::Utc;
29use serde::{Deserialize, Serialize};
30use utoipa::ToSchema;
31
32use crate::active_run::RunAlreadyActive;
33use crate::error::{ApiError, ErrorBody};
34use crate::routes::history::{RunDetailResponse, build_run_detail};
35use crate::state::AppState;
36
37fn default_sim_gain() -> f32 {
38 1.0
39}
40fn default_sim_tau() -> f32 {
41 2.0
42}
43fn default_sim_dead_time() -> f32 {
44 5.0
45}
46fn default_sim_initial_value() -> f32 {
47 50.0
48}
49
50#[derive(Debug, Serialize, Deserialize, ToSchema)]
67pub struct StartRunRequest {
68 pub tagname: String,
70 pub template: String,
72 pub process_type: ProcessType,
73 pub controller_type: ControllerType,
74 pub relay_amp: f32,
76 pub cycles_skip: Option<u32>,
78 pub cycles_count: Option<u32>,
81 pub noise_protection_secs: Option<u32>,
84 pub driver: TuneDriver,
87 pub bridge_host: Option<String>,
91 pub server: Option<String>,
93 #[serde(default = "default_sim_gain")]
95 pub sim_gain: f32,
96 #[serde(default = "default_sim_tau")]
98 pub sim_tau: f32,
99 #[serde(default = "default_sim_dead_time")]
101 pub sim_dead_time: f32,
102 #[serde(default)]
104 pub sim_noise: f32,
105 #[serde(default)]
107 pub sim_seed: u64,
108 #[serde(default = "default_sim_initial_value")]
110 pub sim_initial_pv: f32,
111 #[serde(default = "default_sim_initial_value")]
113 pub sim_initial_mv: f32,
114 pub pv_range_high: Option<f32>,
117 pub pv_range_low: Option<f32>,
119 pub mv_range_high: Option<f32>,
121 pub mv_range_low: Option<f32>,
123 pub direction: Option<ControllerDirection>,
125 pub tag_overrides: Option<TagOverrides>,
128 #[serde(default)]
131 pub notes: Option<String>,
132 #[serde(default)]
135 pub yes: bool,
136 pub write_pid: Option<ResponseLevel>,
139}
140
141fn require_finite(field: &str, value: f32) -> Result<(), ApiError> {
149 if value.is_finite() {
150 Ok(())
151 } else {
152 Err(ApiError::BadRequest(format!(
153 "'{field}' must be a finite number (not NaN or infinite), got {value}"
154 )))
155 }
156}
157
158fn require_finite_if_some(field: &str, value: Option<f32>) -> Result<(), ApiError> {
159 match value {
160 Some(v) => require_finite(field, v),
161 None => Ok(()),
162 }
163}
164
165impl StartRunRequest {
166 pub(crate) fn into_tune_args(self) -> Result<TuneArgs, ApiError> {
174 require_finite("relay_amp", self.relay_amp)?;
175 require_finite("sim_gain", self.sim_gain)?;
176 require_finite("sim_tau", self.sim_tau)?;
177 require_finite("sim_dead_time", self.sim_dead_time)?;
178 require_finite("sim_noise", self.sim_noise)?;
179 require_finite("sim_initial_pv", self.sim_initial_pv)?;
180 require_finite("sim_initial_mv", self.sim_initial_mv)?;
181 require_finite_if_some("pv_range_high", self.pv_range_high)?;
182 require_finite_if_some("pv_range_low", self.pv_range_low)?;
183 require_finite_if_some("mv_range_high", self.mv_range_high)?;
184 require_finite_if_some("mv_range_low", self.mv_range_low)?;
185 if let Some(tag_overrides) = &self.tag_overrides {
186 tag_overrides
187 .validate()
188 .map_err(|error| ApiError::BadRequest(error.to_string()))?;
189 }
190
191 let driver = DriverKindArg::try_from(self.driver)
192 .map_err(|e| ApiError::BadRequest(e.to_string()))?;
193
194 Ok(TuneArgs {
195 tagname: self.tagname,
196 template: self.template,
197 process_type: self.process_type.into(),
198 controller_type: self.controller_type.into(),
199 relay_amp: self.relay_amp,
200 cycles_skip: self.cycles_skip,
201 cycles_count: self.cycles_count,
202 noise_protection_secs: self.noise_protection_secs,
203 driver,
204 bridge_host: self.bridge_host,
205 server: self.server,
206 sim_gain: self.sim_gain,
207 sim_tau: self.sim_tau,
208 sim_dead_time: self.sim_dead_time,
209 sim_noise: self.sim_noise,
210 sim_seed: self.sim_seed,
211 sim_initial_pv: self.sim_initial_pv,
212 sim_initial_mv: self.sim_initial_mv,
213 pv_range_high: self.pv_range_high,
214 pv_range_low: self.pv_range_low,
215 mv_range_high: self.mv_range_high,
216 mv_range_low: self.mv_range_low,
217 direction: self.direction.map(Into::into),
218 tag_overrides: self.tag_overrides,
219 notes: self.notes,
220 yes: self.yes,
221 write_pid: self.write_pid.map(Into::into),
222 output: OutputFormat::Json,
227 })
228 }
229}
230
231#[utoipa::path(
243 post,
244 path = "/api/runs",
245 tag = "runs",
246 request_body = StartRunRequest,
247 responses(
248 (status = 201, description = "The run was started; detail reflects its state right now.", body = RunDetailResponse),
249 (status = 400, description = "The request failed validation, or `prepare()` itself failed (unknown template, invalid flag combination, unreachable driver).", body = ErrorBody),
250 (status = 409, description = "An exclusive PID write/revert is already active.", body = ErrorBody),
251 ),
252)]
253pub(crate) async fn start_run(
254 State(state): State<AppState>,
255 Json(request): Json<StartRunRequest>,
256) -> Result<(StatusCode, Json<RunDetailResponse>), ApiError> {
257 start_run_with_hook(state, request, |_| async {}).await
258}
259
260async fn start_run_with_hook<F, Fut>(
261 state: AppState,
262 request: StartRunRequest,
263 after_prepare: F,
264) -> Result<(StatusCode, Json<RunDetailResponse>), ApiError>
265where
266 F: FnOnce(&AppState) -> Fut,
267 Fut: std::future::Future<Output = ()>,
268{
269 if let Some(active_id) = state.active_run.exclusive_id().await {
273 return Err(ApiError::Conflict(format!(
274 "run {active_id} has an exclusive PID write/revert in progress; wait for it to finish before starting another tune"
275 )));
276 }
277
278 let args = request.into_tune_args()?;
279
280 let app_config = state.config_snapshot()?;
285 let prepared = prepare(&state.pool, args, &app_config)
286 .await
287 .map_err(|e| ApiError::BadRequest(e.to_string()))?;
288 let run_id = prepared.run_id();
289 after_prepare(&state).await;
290
291 let (ctrl_c, cancel_handle) = CtrlC::manual();
292 let pool_for_task = state.pool.clone();
293 let task = async move {
294 let mut ctrl_c = ctrl_c;
295 let _ = drive(&pool_for_task, prepared, &mut ctrl_c).await;
299 };
300
301 if let Err(RunAlreadyActive { run_id: existing }) =
305 state.active_run.start(run_id, cancel_handle, task).await
306 {
307 let failure_reason = format!(
308 "run {existing} has an exclusive PID write/revert in progress; no tune task was started"
309 );
310 TuneRunRow::fail(&state.pool, run_id, Utc::now(), &failure_reason).await?;
311 return Err(ApiError::Conflict(failure_reason));
312 }
313
314 let detail = build_run_detail(&state.pool, run_id).await?.expect(
315 "the tune_runs row this handler just inserted via prepare() must exist immediately \
316 afterward",
317 );
318 Ok((StatusCode::CREATED, Json(detail)))
319}
320
321#[utoipa::path(
330 post,
331 path = "/api/runs/{id}/cancel",
332 tag = "runs",
333 params(
334 ("id" = i64, Path, description = "Run id"),
335 ),
336 responses(
337 (status = 204, description = "Cancellation requested (or the run was already inactive)."),
338 (status = 404, description = "No run with that id.", body = ErrorBody),
339 ),
340)]
341pub(crate) async fn cancel_run(
342 State(state): State<AppState>,
343 Path(run_id): Path<i64>,
344) -> Result<StatusCode, ApiError> {
345 if TuneRunRow::get(&state.pool, run_id).await?.is_none() {
346 return Err(ApiError::NotFound(format!("no run with id {run_id}")));
347 }
348 state.active_run.cancel(run_id).await;
349 Ok(StatusCode::NO_CONTENT)
350}
351
352#[derive(Debug, Deserialize, ToSchema)]
354pub struct UpdateNotesRequest {
355 pub notes: String,
357}
358
359fn normalized_notes(notes: String) -> Option<String> {
360 let trimmed = notes.trim();
361 (!trimmed.is_empty()).then(|| trimmed.to_string())
362}
363
364#[utoipa::path(
369 put,
370 path = "/api/runs/{id}/notes",
371 tag = "runs",
372 params(
373 ("id" = i64, Path, description = "Run id"),
374 ),
375 request_body = UpdateNotesRequest,
376 responses(
377 (status = 200, description = "The run with its updated notes.", body = RunDetailResponse),
378 (status = 404, description = "No run with that id.", body = ErrorBody),
379 ),
380)]
381pub(crate) async fn update_notes(
382 State(state): State<AppState>,
383 Path(run_id): Path<i64>,
384 Json(request): Json<UpdateNotesRequest>,
385) -> Result<Json<RunDetailResponse>, ApiError> {
386 update_notes_with_hook(state, run_id, request, |_| async {}).await
387}
388
389#[derive(Clone, Copy, PartialEq, Eq)]
390enum NotesHookStage {
391 AfterLookup,
392 AfterUpdate,
393}
394
395async fn update_notes_with_hook<F, Fut>(
396 state: AppState,
397 run_id: i64,
398 request: UpdateNotesRequest,
399 mut hook: F,
400) -> Result<Json<RunDetailResponse>, ApiError>
401where
402 F: FnMut(NotesHookStage) -> Fut,
403 Fut: std::future::Future<Output = ()>,
404{
405 TuneRunRow::get(&state.pool, run_id)
406 .await?
407 .ok_or_else(|| ApiError::NotFound(format!("no run with id {run_id}")))?;
408 hook(NotesHookStage::AfterLookup).await;
409 TuneRunRow::update_notes(
410 &state.pool,
411 run_id,
412 normalized_notes(request.notes).as_deref(),
413 )
414 .await?;
415 hook(NotesHookStage::AfterUpdate).await;
416 build_run_detail(&state.pool, run_id)
417 .await?
418 .map(Json)
419 .ok_or_else(|| ApiError::NotFound(format!("no run with id {run_id}")))
420}
421
422#[utoipa::path(
427 delete,
428 path = "/api/runs/{id}/notes",
429 tag = "runs",
430 params(
431 ("id" = i64, Path, description = "Run id"),
432 ),
433 responses(
434 (status = 200, description = "The run with its notes cleared.", body = RunDetailResponse),
435 (status = 404, description = "No run with that id.", body = ErrorBody),
436 ),
437)]
438pub(crate) async fn delete_notes(
439 State(state): State<AppState>,
440 Path(run_id): Path<i64>,
441) -> Result<Json<RunDetailResponse>, ApiError> {
442 delete_notes_with_hook(state, run_id, |_| async {}).await
443}
444
445async fn delete_notes_with_hook<F, Fut>(
446 state: AppState,
447 run_id: i64,
448 after_update: F,
449) -> Result<Json<RunDetailResponse>, ApiError>
450where
451 F: FnOnce(&AppState) -> Fut,
452 Fut: std::future::Future<Output = ()>,
453{
454 TuneRunRow::get(&state.pool, run_id)
455 .await?
456 .ok_or_else(|| ApiError::NotFound(format!("no run with id {run_id}")))?;
457 TuneRunRow::update_notes(&state.pool, run_id, None).await?;
458 after_update(&state).await;
459 build_run_detail(&state.pool, run_id)
460 .await?
461 .map(Json)
462 .ok_or_else(|| ApiError::NotFound(format!("no run with id {run_id}")))
463}
464
465#[derive(Debug, Deserialize, ToSchema)]
467pub struct WriteRunRequest {
468 pub response_level: ResponseLevel,
470}
471
472fn require_writable_run(run: &TuneRunRow) -> Result<(), ApiError> {
478 if run.outcome == TuneOutcome::Running {
479 return Err(ApiError::BadRequest(format!(
480 "run {} is still running; wait for it to finish before writing or reverting PID \
481 constants",
482 run.id
483 )));
484 }
485 if run.driver != TuneDriver::Opcda {
486 return Err(ApiError::BadRequest(format!(
487 "run {} used the {:?} driver, which has no live loop to write PID constants to",
488 run.id, run.driver
489 )));
490 }
491 if run.tags.proportional_constant.is_none()
492 || run.tags.integral_constant.is_none()
493 || run.tags.derivative_constant.is_none()
494 {
495 return Err(ApiError::BadRequest(format!(
496 "run {}'s snapshotted tags have no PID constant tags configured",
497 run.id
498 )));
499 }
500 if run.opc_server.is_none() || run.bridge_host.is_none() {
501 return Err(ApiError::BadRequest(format!(
502 "run {} has no recorded OPC server/bridge host; refusing to guess which \
503 connection to use",
504 run.id
505 )));
506 }
507 Ok(())
508}
509
510async fn connect_to_runs_recorded_driver(run: &TuneRunRow) -> Result<OpcDaDriver, ApiError> {
517 let opc_server = run
518 .opc_server
519 .as_deref()
520 .expect("require_writable_run already checked opc_server is Some");
521 let bridge_host = run
522 .bridge_host
523 .as_deref()
524 .expect("require_writable_run already checked bridge_host is Some");
525 OpcDaDriver::connect(bridge_host, opc_server)
526 .await
527 .map_err(|e| {
528 ApiError::BadRequest(format!(
529 "failed to connect to OPC server '{opc_server}' via bridge '{bridge_host}': {e}"
530 ))
531 })
532}
533
534#[allow(clippy::too_many_arguments)]
548async fn reserve_connect_and_write(
549 state: &AppState,
550 run_id: i64,
551 run: &TuneRunRow,
552 p_tag: &str,
553 i_tag: &str,
554 d_tag: &str,
555 response_level: ResponseLevel,
556 target: WriteReadback,
557 kind: WriteKind,
558 allow_uncertain_quality: bool,
559) -> Result<RunDetailResponse, ApiError> {
560 reserve_connect_and_write_with_hook(
561 state,
562 run_id,
563 run,
564 p_tag,
565 i_tag,
566 d_tag,
567 response_level,
568 target,
569 kind,
570 allow_uncertain_quality,
571 |_| async {},
572 )
573 .await
574}
575
576#[allow(clippy::too_many_arguments)]
577async fn reserve_connect_and_write_with_hook<F, Fut>(
578 state: &AppState,
579 run_id: i64,
580 run: &TuneRunRow,
581 p_tag: &str,
582 i_tag: &str,
583 d_tag: &str,
584 response_level: ResponseLevel,
585 target: WriteReadback,
586 kind: WriteKind,
587 allow_uncertain_quality: bool,
588 after_release: F,
589) -> Result<RunDetailResponse, ApiError>
590where
591 F: FnOnce(&AppState) -> Fut,
592 Fut: std::future::Future<Output = ()>,
593{
594 reserve_connect_and_write_with_hooks(
595 state,
596 run_id,
597 run,
598 p_tag,
599 i_tag,
600 d_tag,
601 response_level,
602 target,
603 kind,
604 allow_uncertain_quality,
605 |_| async {},
606 after_release,
607 )
608 .await
609}
610
611#[allow(clippy::too_many_arguments)]
612async fn reserve_connect_and_write_with_hooks<F, Fut, G, Gut>(
613 state: &AppState,
614 run_id: i64,
615 run: &TuneRunRow,
616 p_tag: &str,
617 i_tag: &str,
618 d_tag: &str,
619 response_level: ResponseLevel,
620 target: WriteReadback,
621 kind: WriteKind,
622 allow_uncertain_quality: bool,
623 before_write: F,
624 after_release: G,
625) -> Result<RunDetailResponse, ApiError>
626where
627 F: FnOnce(&AppState) -> Fut,
628 Fut: std::future::Future<Output = ()>,
629 G: FnOnce(&AppState) -> Gut,
630 Gut: std::future::Future<Output = ()>,
631{
632 state
633 .active_run
634 .reserve(run_id)
635 .await
636 .map_err(|RunAlreadyActive { run_id: existing }| {
637 ApiError::Conflict(format!(
638 "run {existing} or another PID write/revert is active; try again once it finishes"
639 ))
640 })?;
641
642 let result: Result<PidWriteOutcome, ApiError> = async {
643 let driver = connect_to_runs_recorded_driver(run).await?;
644 before_write(state).await;
645 let outcome = write_pid_values(
646 &state.pool,
647 run_id,
648 &driver,
649 p_tag,
650 i_tag,
651 d_tag,
652 response_level,
653 target,
654 kind,
655 allow_uncertain_quality,
656 )
657 .await?;
658 Ok(outcome)
659 }
660 .await;
661
662 state.active_run.release(run_id).await;
663 after_release(state).await;
664 result?;
665
666 build_run_detail(&state.pool, run_id).await?.ok_or_else(|| {
667 ApiError::Internal(anyhow::anyhow!(
668 "run {run_id} vanished while its write/revert was being processed"
669 ))
670 })
671}
672
673#[utoipa::path(
687 post,
688 path = "/api/runs/{id}/write",
689 tag = "runs",
690 params(
691 ("id" = i64, Path, description = "Run id"),
692 ),
693 request_body = WriteRunRequest,
694 responses(
695 (status = 200, description = "The write was attempted; see `writes[]` in the body for its outcome.", body = RunDetailResponse),
696 (status = 400, description = "The run isn't eligible for a post-hoc write (still running, wrong driver, no PID tags/connection recorded, or no calculated result for the requested response level), or the driver connection itself failed.", body = ErrorBody),
697 (status = 404, description = "No run with that id.", body = ErrorBody),
698 (status = 409, description = "A tune or another PID write/revert is already active.", body = ErrorBody),
699 ),
700)]
701pub(crate) async fn write_run(
702 State(state): State<AppState>,
703 Path(run_id): Path<i64>,
704 Json(request): Json<WriteRunRequest>,
705) -> Result<Json<RunDetailResponse>, ApiError> {
706 let run = TuneRunRow::get(&state.pool, run_id)
707 .await?
708 .ok_or_else(|| ApiError::NotFound(format!("no run with id {run_id}")))?;
709 require_writable_run(&run)?;
710 let allow_uncertain_quality = state.config_snapshot()?.allow_uncertain_quality;
711
712 let results = TuneResultRow::list_for_run(&state.pool, run_id).await?;
713 let selected = results
714 .iter()
715 .find(|r| r.response_level == request.response_level)
716 .ok_or_else(|| {
717 ApiError::BadRequest(format!(
718 "run {run_id} has no calculated {:?} result to write",
719 request.response_level
720 ))
721 })?;
722
723 let pid = pid_parameters_for_result(selected)
724 .map_err(|error| ApiError::BadRequest(error.to_string()))?;
725 let written = opc_write_values(pid, run.config.controller_type, run.template.integral_type);
726 let target = WriteReadback {
727 proportional: written.proportional,
728 integral: written.integral,
729 derivative: written.derivative,
730 };
731
732 let p_tag = run.tags.proportional_constant.clone().unwrap();
734 let i_tag = run.tags.integral_constant.clone().unwrap();
735 let d_tag = run.tags.derivative_constant.clone().unwrap();
736
737 let detail = reserve_connect_and_write(
738 &state,
739 run_id,
740 &run,
741 &p_tag,
742 &i_tag,
743 &d_tag,
744 request.response_level,
745 target,
746 WriteKind::Write,
747 allow_uncertain_quality,
748 )
749 .await?;
750 Ok(Json(detail))
751}
752
753#[utoipa::path(
767 post,
768 path = "/api/runs/{id}/revert",
769 tag = "runs",
770 params(
771 ("id" = i64, Path, description = "Run id"),
772 ),
773 responses(
774 (status = 200, description = "The revert was attempted; see `writes[]` in the body for its outcome.", body = RunDetailResponse),
775 (status = 400, description = "The run isn't eligible for a post-hoc revert (still running, wrong driver, no PID tags/connection recorded, no recorded write-back to revert, or its pre-write values were never recorded), or the driver connection itself failed.", body = ErrorBody),
776 (status = 404, description = "No run with that id.", body = ErrorBody),
777 (status = 409, description = "A tune or another PID write/revert is already active.", body = ErrorBody),
778 ),
779)]
780pub(crate) async fn revert_run(
781 State(state): State<AppState>,
782 Path(run_id): Path<i64>,
783) -> Result<Json<RunDetailResponse>, ApiError> {
784 let run = TuneRunRow::get(&state.pool, run_id)
785 .await?
786 .ok_or_else(|| ApiError::NotFound(format!("no run with id {run_id}")))?;
787 require_writable_run(&run)?;
788 let allow_uncertain_quality = state.config_snapshot()?.allow_uncertain_quality;
789
790 let writes = TuneWriteRow::list_for_run(&state.pool, run_id).await?;
791 let last_write = writes
792 .iter()
793 .rev()
794 .find(|w| w.kind == WriteKind::Write)
795 .ok_or_else(|| {
796 ApiError::BadRequest(format!(
797 "run {run_id} has no recorded PID write-back to revert"
798 ))
799 })?;
800 let response_level = last_write.response_level;
801 let target = last_write.previous.ok_or_else(|| {
802 ApiError::BadRequest(format!(
803 "run {run_id}'s {response_level:?} PID write-back never recorded pre-write values; \
804 nothing to revert to"
805 ))
806 })?;
807
808 let p_tag = run.tags.proportional_constant.clone().unwrap();
810 let i_tag = run.tags.integral_constant.clone().unwrap();
811 let d_tag = run.tags.derivative_constant.clone().unwrap();
812
813 let detail = reserve_connect_and_write(
814 &state,
815 run_id,
816 &run,
817 &p_tag,
818 &i_tag,
819 &d_tag,
820 response_level,
821 target,
822 WriteKind::Revert,
823 allow_uncertain_quality,
824 )
825 .await?;
826 Ok(Json(detail))
827}
828
829pub fn router() -> Router<AppState> {
830 Router::new()
831 .route("/api/runs", post(start_run))
832 .route("/api/runs/{id}/cancel", post(cancel_run))
833 .route(
834 "/api/runs/{id}/notes",
835 put(update_notes).delete(delete_notes),
836 )
837 .route("/api/runs/{id}/write", post(write_run))
838 .route("/api/runs/{id}/revert", post(revert_run))
839}
840
841#[cfg(test)]
842mod tests {
843 use super::*;
844 use axum::body::{Body, to_bytes};
845 use axum::http::Request;
846 use bhtune_core::LoopConfig;
847 use bhtune_db::models::{Pagination, TuneRunFilter};
848 use tokio::sync::oneshot;
849 use tower::ServiceExt;
850
851 fn fast_simulator_request_json() -> serde_json::Value {
855 serde_json::json!({
856 "tagname": "ignored-for-simulator",
857 "template": "Yokogawa CentumVP",
858 "process_type": "flow",
859 "controller_type": "pi",
860 "relay_amp": 10.0,
861 "cycles_skip": 1,
862 "cycles_count": 2,
863 "noise_protection_secs": 0,
864 "driver": "simulator",
865 "sim_gain": 1.0,
866 "sim_tau": 0.01,
867 "sim_dead_time": 0.025,
868 "pv_range_high": 100.0,
869 "pv_range_low": 0.0,
870 "mv_range_high": 100.0,
871 "mv_range_low": 0.0,
872 "direction": "reverse",
873 "notes": "http test note",
874 })
875 }
876
877 async fn post_json(
878 app: axum::Router,
879 path: &str,
880 body: serde_json::Value,
881 ) -> axum::http::Response<Body> {
882 app.oneshot(
883 Request::post(path)
884 .header(axum::http::header::CONTENT_TYPE, "application/json")
885 .body(Body::from(serde_json::to_vec(&body).unwrap()))
886 .unwrap(),
887 )
888 .await
889 .unwrap()
890 }
891
892 async fn body_json(response: axum::http::Response<Body>) -> serde_json::Value {
893 let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
894 serde_json::from_slice(&bytes).unwrap()
895 }
896
897 async fn wait_for_outcome(state: &AppState, run_id: i64) -> serde_json::Value {
900 wait_for_outcome_with_timeout(state, run_id, std::time::Duration::from_secs(10)).await
901 }
902
903 async fn wait_for_outcome_with_timeout(
904 state: &AppState,
905 run_id: i64,
906 timeout: std::time::Duration,
907 ) -> serde_json::Value {
908 let deadline = tokio::time::Instant::now() + timeout;
909 loop {
910 let app = crate::build_router(state.clone());
911 let response = app
912 .oneshot(
913 Request::get(format!("/api/runs/{run_id}"))
914 .body(Body::empty())
915 .unwrap(),
916 )
917 .await
918 .unwrap();
919 let detail = body_json(response).await;
920 if detail["outcome"] != "running" {
921 return detail;
922 }
923 if tokio::time::Instant::now() >= deadline {
924 panic!("run {run_id} did not leave 'running' within {timeout:?}: {detail:?}");
925 }
926 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
927 }
928 }
929
930 async fn wait_until_inactive(state: &AppState, run_id: i64) {
931 tokio::time::timeout(std::time::Duration::from_secs(1), async {
932 while state.active_run.active_run_ids().await.contains(&run_id) {
933 tokio::task::yield_now().await;
934 }
935 })
936 .await
937 .expect("a terminal run should release its active-run registration");
938 }
939
940 #[tokio::test]
941 async fn wait_for_outcome_panics_after_an_injected_deadline() {
942 let state = crate::test_support::in_memory_state().await;
943 let run_id = start_opcda_run(&state).await;
944 let state_for_task = state.clone();
945 let join = tokio::spawn(async move {
946 wait_for_outcome_with_timeout(&state_for_task, run_id, std::time::Duration::ZERO).await
947 });
948
949 let panic = join.await.expect_err("a zero deadline should panic");
950 assert!(panic.is_panic());
951 }
952
953 #[tokio::test]
954 async fn wait_until_inactive_observes_an_active_registration_before_release() {
955 let state = crate::test_support::in_memory_state().await;
956 let run_id = 42;
957 let (_ctrl_c, cancel) = bhtune_cli::cancel::CtrlC::manual();
958 let (finish_tx, finish_rx) = oneshot::channel();
959 state
960 .active_run
961 .start(run_id, cancel, async move {
962 finish_rx.await.unwrap();
963 })
964 .await
965 .unwrap();
966
967 let release = tokio::spawn(async move {
968 tokio::task::yield_now().await;
969 finish_tx.send(()).unwrap();
970 });
971 wait_until_inactive(&state, run_id).await;
972 release.await.unwrap();
973 }
974
975 #[tokio::test]
976 async fn starting_a_simulator_run_returns_201_and_it_eventually_completes() {
977 let state = crate::test_support::in_memory_state().await;
978 let app = crate::build_router(state.clone());
979
980 let response = post_json(app, "/api/runs", fast_simulator_request_json()).await;
981 assert_eq!(response.status(), StatusCode::CREATED);
982 let detail = body_json(response).await;
983 let run_id = detail["id"].as_i64().expect("response must carry an id");
984 assert_eq!(detail["tag_name"], "ignored-for-simulator");
985
986 let final_detail = wait_for_outcome(&state, run_id).await;
987 assert_eq!(final_detail["outcome"], "completed");
988 assert_eq!(final_detail["results"].as_array().unwrap().len(), 3);
989 wait_until_inactive(&state, run_id).await;
990 assert!(state.active_run.reserve(999).await.is_ok());
991 state.active_run.release(999).await;
992 }
993
994 #[tokio::test]
995 async fn notes_can_be_edited_while_running_and_after_completion_then_deleted() {
996 let state = crate::test_support::in_memory_state().await;
997 let mut request = fast_simulator_request_json();
998 request["cycles_count"] = serde_json::json!(50);
999
1000 let response = post_json(crate::build_router(state.clone()), "/api/runs", request).await;
1001 assert_eq!(response.status(), StatusCode::CREATED);
1002 let run_id = body_json(response).await["id"].as_i64().unwrap();
1003
1004 let updated = crate::build_router(state.clone())
1005 .oneshot(
1006 Request::put(format!("/api/runs/{run_id}/notes"))
1007 .header(axum::http::header::CONTENT_TYPE, "application/json")
1008 .body(Body::from(
1009 serde_json::to_vec(&serde_json::json!({
1010 "notes": "edited while running"
1011 }))
1012 .unwrap(),
1013 ))
1014 .unwrap(),
1015 )
1016 .await
1017 .unwrap();
1018 assert_eq!(updated.status(), StatusCode::OK);
1019 assert_eq!(body_json(updated).await["notes"], "edited while running");
1020
1021 state.active_run.cancel(run_id).await;
1022 let final_detail = wait_for_outcome(&state, run_id).await;
1023 assert_eq!(final_detail["outcome"], "aborted");
1024
1025 let replaced = crate::build_router(state.clone())
1026 .oneshot(
1027 Request::put(format!("/api/runs/{run_id}/notes"))
1028 .header(axum::http::header::CONTENT_TYPE, "application/json")
1029 .body(Body::from(
1030 serde_json::to_vec(&serde_json::json!({
1031 "notes": "edited after completion"
1032 }))
1033 .unwrap(),
1034 ))
1035 .unwrap(),
1036 )
1037 .await
1038 .unwrap();
1039 assert_eq!(replaced.status(), StatusCode::OK);
1040 assert_eq!(
1041 body_json(replaced).await["notes"],
1042 "edited after completion"
1043 );
1044
1045 let cleared = crate::build_router(state.clone())
1046 .oneshot(
1047 Request::delete(format!("/api/runs/{run_id}/notes"))
1048 .body(Body::empty())
1049 .unwrap(),
1050 )
1051 .await
1052 .unwrap();
1053 assert_eq!(cleared.status(), StatusCode::OK);
1054 assert!(body_json(cleared).await["notes"].is_null());
1055 }
1056
1057 #[tokio::test]
1058 async fn run_route_lookups_propagate_database_failures_as_500() {
1059 let state = crate::test_support::in_memory_state().await;
1060 let app = crate::build_router(state.clone());
1061 state.pool.close().await;
1062
1063 let update = app
1064 .clone()
1065 .oneshot(
1066 Request::put("/api/runs/1/notes")
1067 .header(axum::http::header::CONTENT_TYPE, "application/json")
1068 .body(Body::from(r#"{"notes":"updated"}"#))
1069 .unwrap(),
1070 )
1071 .await
1072 .unwrap();
1073 assert_eq!(update.status(), StatusCode::INTERNAL_SERVER_ERROR);
1074
1075 let delete = app
1076 .clone()
1077 .oneshot(
1078 Request::delete("/api/runs/1/notes")
1079 .body(Body::empty())
1080 .unwrap(),
1081 )
1082 .await
1083 .unwrap();
1084 assert_eq!(delete.status(), StatusCode::INTERNAL_SERVER_ERROR);
1085
1086 let write = post_json(
1087 app.clone(),
1088 "/api/runs/1/write",
1089 serde_json::json!({ "response_level": "moderate" }),
1090 )
1091 .await;
1092 assert_eq!(write.status(), StatusCode::INTERNAL_SERVER_ERROR);
1093
1094 let revert = post_empty(app, "/api/runs/1/revert").await;
1095 assert_eq!(revert.status(), StatusCode::INTERNAL_SERVER_ERROR);
1096 }
1097
1098 #[tokio::test]
1099 async fn note_routes_propagate_failures_after_each_successful_database_step() {
1100 let update_state = crate::test_support::in_memory_state().await;
1101 let update_run_id = start_opcda_run(&update_state).await;
1102 let update_pool = update_state.pool.clone();
1103 let update_result = update_notes_with_hook(
1104 update_state.clone(),
1105 update_run_id,
1106 UpdateNotesRequest {
1107 notes: "updated".to_string(),
1108 },
1109 move |_| {
1110 let pool = update_pool.clone();
1111 async move { pool.close().await }
1112 },
1113 )
1114 .await;
1115 assert!(matches!(update_result, Err(ApiError::Internal(_))));
1116
1117 let detail_state = crate::test_support::in_memory_state().await;
1118 let detail_run_id = start_opcda_run(&detail_state).await;
1119 let detail_pool = detail_state.pool.clone();
1120 let detail_result = update_notes_with_hook(
1121 detail_state.clone(),
1122 detail_run_id,
1123 UpdateNotesRequest {
1124 notes: "updated".to_string(),
1125 },
1126 move |stage| {
1127 let pool = detail_pool.clone();
1128 async move {
1129 if stage == NotesHookStage::AfterUpdate {
1130 pool.close().await;
1131 }
1132 }
1133 },
1134 )
1135 .await;
1136 assert!(matches!(detail_result, Err(ApiError::Internal(_))));
1137
1138 let delete_state = crate::test_support::in_memory_state().await;
1139 let delete_run_id = start_opcda_run(&delete_state).await;
1140 let delete_result = delete_notes_with_hook(delete_state.clone(), delete_run_id, |state| {
1141 let pool = state.pool.clone();
1142 async move { pool.close().await }
1143 })
1144 .await;
1145 assert!(matches!(delete_result, Err(ApiError::Internal(_))));
1146 }
1147
1148 #[tokio::test]
1149 async fn starting_a_second_run_while_one_is_active_succeeds() {
1150 let state = crate::test_support::in_memory_state().await;
1151
1152 let mut slow_request = fast_simulator_request_json();
1154 slow_request["cycles_count"] = serde_json::json!(50);
1155
1156 let first = post_json(
1157 crate::build_router(state.clone()),
1158 "/api/runs",
1159 slow_request,
1160 )
1161 .await;
1162 assert_eq!(first.status(), StatusCode::CREATED);
1163 let first_id = body_json(first).await["id"].as_i64().unwrap();
1164 assert_eq!(state.active_run.active_run_ids().await, vec![first_id]);
1165
1166 let second = post_json(
1167 crate::build_router(state.clone()),
1168 "/api/runs",
1169 fast_simulator_request_json(),
1170 )
1171 .await;
1172 assert_eq!(second.status(), StatusCode::CREATED);
1173 let second_id = body_json(second).await["id"].as_i64().unwrap();
1174 assert_ne!(second_id, first_id);
1175
1176 state.active_run.cancel(first_id).await;
1178 wait_for_outcome(&state, first_id).await;
1179 wait_for_outcome(&state, second_id).await;
1180 }
1181
1182 #[tokio::test]
1186 async fn a_genuine_race_between_two_starts_creates_two_runs() {
1187 let state = crate::test_support::in_memory_state().await;
1188
1189 let mut request_a = fast_simulator_request_json();
1190 request_a["notes"] = serde_json::json!("racer-a");
1191 let mut request_b = fast_simulator_request_json();
1192 request_b["notes"] = serde_json::json!("racer-b");
1193
1194 let (result_a, result_b) = tokio::join!(
1195 start_run(
1196 State(state.clone()),
1197 Json(serde_json::from_value(request_a).unwrap())
1198 ),
1199 start_run(
1200 State(state.clone()),
1201 Json(serde_json::from_value(request_b).unwrap())
1202 ),
1203 );
1204
1205 let outcomes = [result_a, result_b];
1206 assert!(
1207 outcomes.iter().all(Result::is_ok),
1208 "both concurrent starts should succeed: {outcomes:?}"
1209 );
1210
1211 for outcome in outcomes {
1213 let (_, Json(detail)) = outcome.unwrap();
1214 state.active_run.cancel(detail.id).await;
1215 wait_for_outcome(&state, detail.id).await;
1216 }
1217 }
1218
1219 #[tokio::test]
1220 async fn a_reservation_starting_after_prepare_marks_the_new_run_failed() {
1221 let state = crate::test_support::in_memory_state().await;
1222 let reservation_id = 9_999;
1223 let result = start_run_with_hook(
1224 state.clone(),
1225 serde_json::from_value(fast_simulator_request_json()).unwrap(),
1226 |state| {
1227 let active_run = state.active_run.clone();
1228 async move {
1229 active_run.reserve(reservation_id).await.unwrap();
1230 }
1231 },
1232 )
1233 .await;
1234
1235 let error = result.unwrap_err();
1236 assert!(matches!(error, ApiError::Conflict(_)));
1237 let filter = TuneRunFilter::default();
1238 let runs = TuneRunRow::list(&state.pool, &filter, Pagination::default())
1239 .await
1240 .unwrap();
1241 assert_eq!(runs.len(), 1);
1242 assert_eq!(runs[0].outcome, TuneOutcome::Failed);
1243 assert!(
1244 runs[0]
1245 .failure_reason
1246 .as_deref()
1247 .unwrap()
1248 .contains("no tune task was started")
1249 );
1250 state.active_run.release(reservation_id).await;
1251 }
1252
1253 #[tokio::test]
1254 async fn starting_a_run_while_a_write_reservation_is_active_returns_409() {
1255 let state = crate::test_support::in_memory_state().await;
1256 let reservation_id = 9_998;
1257 state.active_run.reserve(reservation_id).await.unwrap();
1258
1259 let response = post_json(
1260 crate::build_router(state.clone()),
1261 "/api/runs",
1262 fast_simulator_request_json(),
1263 )
1264 .await;
1265 assert_eq!(response.status(), StatusCode::CONFLICT);
1266 assert!(
1267 body_json(response).await["error"]
1268 .as_str()
1269 .unwrap()
1270 .contains("exclusive PID write/revert")
1271 );
1272 state.active_run.release(reservation_id).await;
1273 }
1274
1275 #[tokio::test]
1276 async fn unknown_template_name_returns_400() {
1277 let app = crate::build_router(crate::test_support::in_memory_state().await);
1278 let mut request = fast_simulator_request_json();
1279 request["template"] = serde_json::json!("Not A Real Template");
1280
1281 let response = post_json(app, "/api/runs", request).await;
1282 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1283 let error = body_json(response).await;
1284 assert!(
1285 error["error"]
1286 .as_str()
1287 .unwrap()
1288 .contains("Not A Real Template")
1289 );
1290 }
1291
1292 #[tokio::test]
1293 async fn write_pid_without_yes_returns_400() {
1294 let app = crate::build_router(crate::test_support::in_memory_state().await);
1295 let mut request = fast_simulator_request_json();
1296 request["write_pid"] = serde_json::json!("aggressive");
1297 let response = post_json(app, "/api/runs", request).await;
1300 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1301 let error = body_json(response).await;
1302 assert!(error["error"].as_str().unwrap().contains("--yes"));
1303 }
1304
1305 #[test]
1306 fn legacy_http_timing_fields_are_ignored() {
1307 let mut request = fast_simulator_request_json();
1308 request["mrft_delay"] = serde_json::json!(123);
1309 request["poll_interval_ms"] = serde_json::json!(1);
1310 request["timeout_secs"] = serde_json::json!(1);
1311 request["op_timeout_secs"] = serde_json::json!(1);
1312 request["restore_timeout_secs"] = serde_json::json!(1);
1313
1314 let parsed: StartRunRequest = serde_json::from_value(request).unwrap();
1315 let args = parsed
1316 .into_tune_args()
1317 .expect("legacy fields must not affect request parsing");
1318 assert_eq!(args.template, "Yokogawa CentumVP");
1319 assert_eq!(args.relay_amp, 10.0);
1320 }
1321
1322 #[tokio::test]
1323 async fn invalid_tag_override_returns_400_before_starting_a_run() {
1324 let state = crate::test_support::in_memory_state().await;
1325 let app = crate::build_router(state.clone());
1326 let mut request = fast_simulator_request_json();
1327 request["tag_overrides"] = serde_json::json!({
1328 "process_variable": "Loop\u{0000}PV"
1329 });
1330
1331 let response = post_json(app, "/api/runs", request).await;
1332 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1333 assert!(
1334 body_json(response).await["error"]
1335 .as_str()
1336 .unwrap()
1337 .contains("process_variable")
1338 );
1339 assert!(
1340 TuneRunRow::list(
1341 &state.pool,
1342 &TuneRunFilter::default(),
1343 Pagination::default(),
1344 )
1345 .await
1346 .unwrap()
1347 .is_empty()
1348 );
1349 }
1350
1351 #[tokio::test]
1352 async fn a_json_number_that_overflows_f32_to_infinity_is_rejected() {
1353 let app = crate::build_router(crate::test_support::in_memory_state().await);
1358 let mut request = fast_simulator_request_json();
1359 request["sim_gain"] = serde_json::json!(1e40);
1360
1361 let response = post_json(app, "/api/runs", request).await;
1362 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1363 let error = body_json(response).await;
1364 assert!(error["error"].as_str().unwrap().contains("sim_gain"));
1365 }
1366
1367 #[tokio::test]
1371 async fn a_non_finite_optional_range_field_is_also_rejected() {
1372 let app = crate::build_router(crate::test_support::in_memory_state().await);
1373 let mut request = fast_simulator_request_json();
1374 request["pv_range_high"] = serde_json::json!(1e40);
1375
1376 let response = post_json(app, "/api/runs", request).await;
1377 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1378 let error = body_json(response).await;
1379 assert!(error["error"].as_str().unwrap().contains("pv_range_high"));
1380 }
1381
1382 #[tokio::test]
1389 async fn an_omitted_optional_range_field_passes_validation_but_prepare_still_requires_it() {
1390 let app = crate::build_router(crate::test_support::in_memory_state().await);
1391 let mut request = fast_simulator_request_json();
1392 request.as_object_mut().unwrap().remove("pv_range_high");
1393
1394 let response = post_json(app, "/api/runs", request).await;
1395 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1396 let error = body_json(response).await;
1397 assert!(
1398 error["error"]
1399 .as_str()
1400 .unwrap()
1401 .contains("--pv-range-high is required")
1402 );
1403 }
1404
1405 #[tokio::test]
1409 async fn omitted_fields_with_custom_defaults_fall_back_to_the_cli_defaults() {
1410 let state = crate::test_support::in_memory_state().await;
1411 let mut request = fast_simulator_request_json();
1412 let object = request.as_object_mut().unwrap();
1413 object.remove("sim_gain");
1414 object.remove("sim_tau");
1415 object.remove("sim_dead_time");
1416 object.remove("sim_initial_pv");
1417 object.remove("sim_initial_mv");
1418
1419 let parsed: StartRunRequest = serde_json::from_value(request.clone()).unwrap();
1420 assert_eq!(parsed.sim_gain, 1.0);
1421 assert_eq!(parsed.sim_tau, 2.0);
1422 assert_eq!(parsed.sim_dead_time, 5.0);
1423 assert_eq!(parsed.sim_initial_pv, 50.0);
1424 assert_eq!(parsed.sim_initial_mv, 50.0);
1425
1426 let response = post_json(crate::build_router(state.clone()), "/api/runs", request).await;
1427 assert_eq!(response.status(), StatusCode::CREATED);
1428 let run_id = body_json(response).await["id"].as_i64().unwrap();
1429
1430 state.active_run.cancel(run_id).await;
1431 wait_for_outcome(&state, run_id).await;
1432 }
1433
1434 #[tokio::test]
1435 async fn the_replay_driver_is_rejected() {
1436 let app = crate::build_router(crate::test_support::in_memory_state().await);
1437 let mut request = fast_simulator_request_json();
1438 request["driver"] = serde_json::json!("replay");
1439
1440 let response = post_json(app, "/api/runs", request).await;
1441 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1442 let error = body_json(response).await;
1443 assert!(error["error"].as_str().unwrap().contains("replay"));
1444 }
1445
1446 #[tokio::test]
1447 async fn cancelling_an_unknown_run_returns_404() {
1448 let app = crate::build_router(crate::test_support::in_memory_state().await);
1449 let response = app
1450 .oneshot(
1451 Request::post("/api/runs/999999/cancel")
1452 .body(Body::empty())
1453 .unwrap(),
1454 )
1455 .await
1456 .unwrap();
1457 assert_eq!(response.status(), StatusCode::NOT_FOUND);
1458 }
1459
1460 #[tokio::test]
1461 async fn cancelling_an_active_run_aborts_it_and_returns_204() {
1462 let state = crate::test_support::in_memory_state().await;
1463
1464 let mut slow_request = fast_simulator_request_json();
1465 slow_request["poll_interval_ms"] = serde_json::json!(1000);
1466 slow_request["cycles_count"] = serde_json::json!(50);
1467
1468 let start_response = post_json(
1469 crate::build_router(state.clone()),
1470 "/api/runs",
1471 slow_request,
1472 )
1473 .await;
1474 assert_eq!(start_response.status(), StatusCode::CREATED);
1475 let run_id = body_json(start_response).await["id"].as_i64().unwrap();
1476
1477 let cancel_response = crate::build_router(state.clone())
1478 .oneshot(
1479 Request::post(format!("/api/runs/{run_id}/cancel"))
1480 .body(Body::empty())
1481 .unwrap(),
1482 )
1483 .await
1484 .unwrap();
1485 assert_eq!(cancel_response.status(), StatusCode::NO_CONTENT);
1486
1487 let final_detail = wait_for_outcome(&state, run_id).await;
1488 assert_eq!(final_detail["outcome"], "aborted");
1489 wait_until_inactive(&state, run_id).await;
1490 }
1491
1492 #[tokio::test]
1493 async fn cancelling_a_run_that_already_finished_still_returns_204() {
1494 let state = crate::test_support::in_memory_state().await;
1495 let start_response = post_json(
1496 crate::build_router(state.clone()),
1497 "/api/runs",
1498 fast_simulator_request_json(),
1499 )
1500 .await;
1501 let run_id = body_json(start_response).await["id"].as_i64().unwrap();
1502 wait_for_outcome(&state, run_id).await;
1503
1504 let cancel_response = crate::build_router(state.clone())
1505 .oneshot(
1506 Request::post(format!("/api/runs/{run_id}/cancel"))
1507 .body(Body::empty())
1508 .unwrap(),
1509 )
1510 .await
1511 .unwrap();
1512 assert_eq!(cancel_response.status(), StatusCode::NO_CONTENT);
1513 }
1514
1515 async fn start_opcda_run(state: &AppState) -> i64 {
1524 let template_row =
1525 bhtune_db::models::DcsTemplateRow::get_by_name(&state.pool, "Yokogawa CentumVP")
1526 .await
1527 .unwrap()
1528 .unwrap();
1529 let template = template_row.template;
1530 let config = LoopConfig {
1531 process_type: ProcessType::TemperatureHeatExchange,
1532 controller_type: ControllerType::Pid,
1533 relay_amp_percent: 5.0,
1534 num_cycles_skip: 1,
1535 num_cycles_count: 3,
1536 noise_protection_secs: 0,
1537 mrft_delay_secs: 0,
1538 };
1539 let tags = bhtune_core::LoopTags::derive_from_pv_tag("Loop3.PV", &template);
1540 let run = TuneRunRow::start(
1541 &state.pool,
1542 None,
1543 "Loop3",
1544 TuneDriver::Opcda,
1545 config,
1546 template_row.origin,
1547 &template,
1548 &tags,
1549 Utc::now(),
1550 )
1551 .await
1552 .unwrap();
1553 run.id
1554 }
1555
1556 async fn start_opcda_run_without_pid_tags(state: &AppState) -> i64 {
1561 let template_row =
1562 bhtune_db::models::DcsTemplateRow::get_by_name(&state.pool, "Yokogawa CentumVP")
1563 .await
1564 .unwrap()
1565 .unwrap();
1566 let template = template_row.template;
1567 let config = LoopConfig {
1568 process_type: ProcessType::TemperatureHeatExchange,
1569 controller_type: ControllerType::Pid,
1570 relay_amp_percent: 5.0,
1571 num_cycles_skip: 1,
1572 num_cycles_count: 3,
1573 noise_protection_secs: 0,
1574 mrft_delay_secs: 0,
1575 };
1576 let mut tags = bhtune_core::LoopTags::derive_from_pv_tag("Loop3.PV", &template);
1577 tags.proportional_constant = None;
1578 tags.integral_constant = None;
1579 tags.derivative_constant = None;
1580 let run = TuneRunRow::start(
1581 &state.pool,
1582 None,
1583 "Loop3",
1584 TuneDriver::Opcda,
1585 config,
1586 template_row.origin,
1587 &template,
1588 &tags,
1589 Utc::now(),
1590 )
1591 .await
1592 .unwrap();
1593 run.id
1594 }
1595
1596 async fn add_moderate_result(state: &AppState, run_id: i64) {
1599 TuneRunRow::complete(&state.pool, run_id, Utc::now())
1600 .await
1601 .unwrap();
1602 TuneResultRow::insert(
1603 &state.pool,
1604 &TuneResultRow {
1605 id: 0,
1606 run_id,
1607 response_level: ResponseLevel::Moderate,
1608 kp: Some(1.5),
1609 ti_minutes: Some(2.0),
1610 td_minutes: Some(1.0),
1611 proportional: Some(10.0),
1612 integral: Some(10.0),
1613 derivative: Some(10.0),
1614 status: bhtune_core::TuningResultStatus::Valid,
1615 invalid_reason: None,
1616 },
1617 )
1618 .await
1619 .unwrap();
1620 }
1621
1622 async fn add_invalid_moderate_result(state: &AppState, run_id: i64) {
1623 TuneRunRow::complete(&state.pool, run_id, Utc::now())
1624 .await
1625 .unwrap();
1626 TuneResultRow::insert(
1627 &state.pool,
1628 &TuneResultRow {
1629 id: 0,
1630 run_id,
1631 response_level: ResponseLevel::Moderate,
1632 kp: None,
1633 ti_minutes: None,
1634 td_minutes: None,
1635 proportional: None,
1636 integral: None,
1637 derivative: None,
1638 status: bhtune_core::TuningResultStatus::Invalid,
1639 invalid_reason: Some(
1640 bhtune_core::TuningResultInvalidReason::NonPositivePvAmplitude,
1641 ),
1642 },
1643 )
1644 .await
1645 .unwrap();
1646 }
1647
1648 async fn seed_writable_opcda_run(state: &AppState, bridge_host: &str, opc_server: &str) -> i64 {
1651 let run_id = start_opcda_run(state).await;
1652 TuneRunRow::record_connection(
1653 &state.pool,
1654 run_id,
1655 Some(opc_server),
1656 Some(bridge_host),
1657 "{}",
1658 )
1659 .await
1660 .unwrap();
1661 add_moderate_result(state, run_id).await;
1662 run_id
1663 }
1664
1665 async fn post_empty(app: axum::Router, path: &str) -> axum::http::Response<Body> {
1666 app.oneshot(Request::post(path).body(Body::empty()).unwrap())
1667 .await
1668 .unwrap()
1669 }
1670
1671 #[tokio::test]
1672 async fn write_run_succeeds_and_records_a_write_kind_row() {
1673 use crate::test_support::mock_bridge::{
1674 MockBridgeService, good_reading, start_mock_server,
1675 };
1676
1677 let host = start_mock_server(MockBridgeService {
1678 read_response: good_reading("10.0"),
1679 write_response: opcda_bridge_proto::bridge::WriteResponse {
1680 tag_id: "ignored".to_string(),
1681 success: true,
1682 error: None,
1683 },
1684 ..Default::default()
1685 })
1686 .await;
1687
1688 let state = crate::test_support::in_memory_state().await;
1689 let run_id = seed_writable_opcda_run(&state, &host, "Sim.Server").await;
1690
1691 let response = post_json(
1692 crate::build_router(state.clone()),
1693 &format!("/api/runs/{run_id}/write"),
1694 serde_json::json!({ "response_level": "moderate" }),
1695 )
1696 .await;
1697 assert_eq!(response.status(), StatusCode::OK);
1698 let detail = body_json(response).await;
1699
1700 let writes = detail["writes"].as_array().unwrap();
1701 let write_row = writes.iter().find(|w| w["kind"] == "write").unwrap();
1702 assert_eq!(write_row["response_level"], "moderate");
1703 assert_eq!(write_row["success"], true);
1704 assert_eq!(write_row["proportional_written"], 10.0);
1705 assert_eq!(write_row["integral_written"], 10.0);
1706 assert_eq!(write_row["derivative_written"], 10.0);
1707 assert_eq!(write_row["proportional_readback"], 10.0);
1708 assert!(write_row["rollback_state"].is_null());
1709
1710 assert!(state.active_run.reserve(999).await.is_ok());
1712 state.active_run.release(999).await;
1713 }
1714
1715 #[tokio::test]
1716 async fn write_reports_an_internal_error_when_the_run_vanishes_after_the_write() {
1717 use crate::test_support::mock_bridge::{
1718 MockBridgeService, good_reading, start_mock_server,
1719 };
1720
1721 let host = start_mock_server(MockBridgeService {
1722 read_response: good_reading("10.0"),
1723 write_response: opcda_bridge_proto::bridge::WriteResponse {
1724 tag_id: "ignored".to_string(),
1725 success: true,
1726 error: None,
1727 },
1728 ..Default::default()
1729 })
1730 .await;
1731 let state = crate::test_support::in_memory_state().await;
1732 let run_id = seed_writable_opcda_run(&state, &host, "Sim.Server").await;
1733 let run = TuneRunRow::get(&state.pool, run_id).await.unwrap().unwrap();
1734 let p_tag = run.tags.proportional_constant.clone().unwrap();
1735 let i_tag = run.tags.integral_constant.clone().unwrap();
1736 let d_tag = run.tags.derivative_constant.clone().unwrap();
1737 let pool = state.pool.clone();
1738
1739 let error = reserve_connect_and_write_with_hook(
1740 &state,
1741 run_id,
1742 &run,
1743 &p_tag,
1744 &i_tag,
1745 &d_tag,
1746 ResponseLevel::Moderate,
1747 WriteReadback {
1748 proportional: 10.0,
1749 integral: 10.0,
1750 derivative: 10.0,
1751 },
1752 WriteKind::Write,
1753 true,
1754 move |_| async move {
1755 assert!(TuneRunRow::delete(&pool, run_id).await.unwrap());
1756 },
1757 )
1758 .await
1759 .unwrap_err();
1760
1761 assert!(matches!(error, ApiError::Internal(_)));
1762 }
1763
1764 #[tokio::test]
1765 async fn write_propagates_an_unexpected_database_failure_and_releases_its_reservation() {
1766 use crate::test_support::mock_bridge::{
1767 MockBridgeService, good_reading, start_mock_server,
1768 };
1769
1770 let host = start_mock_server(MockBridgeService {
1771 read_response: good_reading("10.0"),
1772 write_response: opcda_bridge_proto::bridge::WriteResponse {
1773 tag_id: "ignored".to_string(),
1774 success: true,
1775 error: None,
1776 },
1777 ..Default::default()
1778 })
1779 .await;
1780 let state = crate::test_support::in_memory_state().await;
1781 let run_id = seed_writable_opcda_run(&state, &host, "Sim.Server").await;
1782 let run = TuneRunRow::get(&state.pool, run_id).await.unwrap().unwrap();
1783 let p_tag = run.tags.proportional_constant.clone().unwrap();
1784 let i_tag = run.tags.integral_constant.clone().unwrap();
1785 let d_tag = run.tags.derivative_constant.clone().unwrap();
1786
1787 let error = reserve_connect_and_write_with_hooks(
1788 &state,
1789 run_id,
1790 &run,
1791 &p_tag,
1792 &i_tag,
1793 &d_tag,
1794 ResponseLevel::Moderate,
1795 WriteReadback {
1796 proportional: 10.0,
1797 integral: 10.0,
1798 derivative: 10.0,
1799 },
1800 WriteKind::Write,
1801 true,
1802 |state| {
1803 let pool = state.pool.clone();
1804 async move { pool.close().await }
1805 },
1806 |_| async {},
1807 )
1808 .await
1809 .unwrap_err();
1810
1811 assert!(matches!(error, ApiError::Internal(_)));
1812 assert!(state.active_run.reserve(999).await.is_ok());
1813 state.active_run.release(999).await;
1814 }
1815
1816 #[tokio::test]
1817 async fn write_run_reports_a_failed_write_as_200_not_an_http_error() {
1818 use crate::test_support::mock_bridge::{
1819 MockBridgeService, good_reading, start_mock_server,
1820 };
1821
1822 let host = start_mock_server(MockBridgeService {
1823 read_response: good_reading("10.0"),
1829 write_error: Some(tonic::Status::invalid_argument("nope")),
1830 ..Default::default()
1831 })
1832 .await;
1833
1834 let state = crate::test_support::in_memory_state().await;
1835 let run_id = seed_writable_opcda_run(&state, &host, "Sim.Server").await;
1836
1837 let response = post_json(
1838 crate::build_router(state.clone()),
1839 &format!("/api/runs/{run_id}/write"),
1840 serde_json::json!({ "response_level": "moderate" }),
1841 )
1842 .await;
1843 assert_eq!(response.status(), StatusCode::OK);
1846 let detail = body_json(response).await;
1847
1848 let writes = detail["writes"].as_array().unwrap();
1849 let write_row = writes.iter().find(|w| w["kind"] == "write").unwrap();
1850 assert_eq!(write_row["success"], false);
1851 assert!(
1856 write_row["error_message"]
1857 .as_str()
1858 .unwrap()
1859 .contains("driver operation failed")
1860 );
1861 assert!(write_row["rollback_state"].is_null());
1862 }
1863
1864 #[tokio::test]
1865 async fn write_run_reports_a_failed_pre_read_as_200_not_an_http_error() {
1866 use crate::test_support::mock_bridge::{MockBridgeService, start_mock_server};
1867
1868 let host = start_mock_server(MockBridgeService {
1869 read_error: Some(tonic::Status::unavailable("gateway unreachable")),
1873 ..Default::default()
1874 })
1875 .await;
1876
1877 let state = crate::test_support::in_memory_state().await;
1878 let run_id = seed_writable_opcda_run(&state, &host, "Sim.Server").await;
1879
1880 let response = post_json(
1881 crate::build_router(state),
1882 &format!("/api/runs/{run_id}/write"),
1883 serde_json::json!({ "response_level": "moderate" }),
1884 )
1885 .await;
1886 assert_eq!(response.status(), StatusCode::OK);
1888 let detail = body_json(response).await;
1889
1890 let writes = detail["writes"].as_array().unwrap();
1891 let write_row = writes.iter().find(|w| w["kind"] == "write").unwrap();
1892 assert_eq!(write_row["success"], false);
1893 assert!(write_row["proportional_previous"].is_null());
1894 assert!(write_row["proportional_written"].is_null());
1895 assert!(write_row["rollback_state"].is_null());
1896 assert!(
1897 write_row["error_message"]
1898 .as_str()
1899 .unwrap()
1900 .contains("pre-read")
1901 );
1902 }
1903
1904 #[tokio::test]
1905 async fn write_run_returns_404_for_unknown_run() {
1906 let app = crate::build_router(crate::test_support::in_memory_state().await);
1907 let response = post_json(
1908 app,
1909 "/api/runs/999999/write",
1910 serde_json::json!({ "response_level": "moderate" }),
1911 )
1912 .await;
1913 assert_eq!(response.status(), StatusCode::NOT_FOUND);
1914 }
1915
1916 #[tokio::test]
1917 async fn write_run_returns_400_when_run_is_still_running() {
1918 let state = crate::test_support::in_memory_state().await;
1919 let run_id = start_opcda_run(&state).await;
1922
1923 let response = post_json(
1924 crate::build_router(state),
1925 &format!("/api/runs/{run_id}/write"),
1926 serde_json::json!({ "response_level": "moderate" }),
1927 )
1928 .await;
1929 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1930 let error = body_json(response).await;
1931 assert!(error["error"].as_str().unwrap().contains("still running"));
1932 }
1933
1934 #[tokio::test]
1935 async fn write_run_returns_400_for_simulator_driver() {
1936 let state = crate::test_support::in_memory_state().await;
1937 let template_row =
1938 bhtune_db::models::DcsTemplateRow::get_by_name(&state.pool, "Yokogawa CentumVP")
1939 .await
1940 .unwrap()
1941 .unwrap();
1942 let template = template_row.template;
1943 let config = LoopConfig {
1944 process_type: ProcessType::Flow,
1945 controller_type: ControllerType::Pi,
1946 relay_amp_percent: 5.0,
1947 num_cycles_skip: 1,
1948 num_cycles_count: 3,
1949 noise_protection_secs: 0,
1950 mrft_delay_secs: 0,
1951 };
1952 let tags = bhtune_core::LoopTags::derive_from_pv_tag("Sim.PV", &template);
1953 let run = TuneRunRow::start(
1954 &state.pool,
1955 None,
1956 "SimLoop",
1957 TuneDriver::Simulator,
1958 config,
1959 template_row.origin,
1960 &template,
1961 &tags,
1962 Utc::now(),
1963 )
1964 .await
1965 .unwrap();
1966 add_moderate_result(&state, run.id).await;
1967
1968 let response = post_json(
1969 crate::build_router(state),
1970 &format!("/api/runs/{}/write", run.id),
1971 serde_json::json!({ "response_level": "moderate" }),
1972 )
1973 .await;
1974 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1975 let error = body_json(response).await;
1976 assert!(error["error"].as_str().unwrap().contains("Simulator"));
1977 }
1978
1979 #[tokio::test]
1980 async fn write_run_returns_400_when_run_has_no_pid_constant_tags() {
1981 let state = crate::test_support::in_memory_state().await;
1982 let run_id = start_opcda_run_without_pid_tags(&state).await;
1983 TuneRunRow::complete(&state.pool, run_id, Utc::now())
1984 .await
1985 .unwrap();
1986
1987 let response = post_json(
1988 crate::build_router(state),
1989 &format!("/api/runs/{run_id}/write"),
1990 serde_json::json!({ "response_level": "moderate" }),
1991 )
1992 .await;
1993 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1994 let error = body_json(response).await;
1995 assert!(
1996 error["error"]
1997 .as_str()
1998 .unwrap()
1999 .contains("no PID constant tags configured")
2000 );
2001 }
2002
2003 #[tokio::test]
2004 async fn each_missing_pid_constant_tag_is_rejected_individually() {
2005 let state = crate::test_support::in_memory_state().await;
2006 let run_id = seed_writable_opcda_run(&state, "127.0.0.1:1", "Sim.Server").await;
2007 let base = TuneRunRow::get(&state.pool, run_id).await.unwrap().unwrap();
2008
2009 let mut missing_proportional = base.clone();
2010 missing_proportional.tags.proportional_constant = None;
2011 let mut missing_integral = base.clone();
2012 missing_integral.tags.integral_constant = None;
2013 let mut missing_derivative = base;
2014 missing_derivative.tags.derivative_constant = None;
2015
2016 for (missing_tag, run) in [
2017 ("proportional", missing_proportional),
2018 ("integral", missing_integral),
2019 ("derivative", missing_derivative),
2020 ] {
2021 let error = require_writable_run(&run).unwrap_err();
2022 assert!(
2023 matches!(
2024 error,
2025 ApiError::BadRequest(ref message)
2026 if message.contains("no PID constant tags configured")
2027 ),
2028 "missing {missing_tag} tag should be rejected: {error:?}"
2029 );
2030 }
2031 }
2032
2033 #[tokio::test]
2034 async fn each_missing_recorded_connection_field_is_rejected_individually() {
2035 let state = crate::test_support::in_memory_state().await;
2036 let run_id = seed_writable_opcda_run(&state, "127.0.0.1:1", "Sim.Server").await;
2037 let base = TuneRunRow::get(&state.pool, run_id).await.unwrap().unwrap();
2038
2039 let mut missing_server = base.clone();
2040 missing_server.opc_server = None;
2041 let mut missing_bridge = base;
2042 missing_bridge.bridge_host = None;
2043
2044 for (missing_field, run) in [
2045 ("opc_server", missing_server),
2046 ("bridge_host", missing_bridge),
2047 ] {
2048 let error = require_writable_run(&run).unwrap_err();
2049 assert!(
2050 matches!(
2051 error,
2052 ApiError::BadRequest(ref message)
2053 if message.contains("no recorded OPC server")
2054 ),
2055 "missing {missing_field} should be rejected: {error:?}"
2056 );
2057 }
2058 }
2059
2060 #[tokio::test]
2061 async fn write_run_returns_400_when_no_connection_was_recorded() {
2062 let state = crate::test_support::in_memory_state().await;
2063 let run_id = start_opcda_run(&state).await;
2064 add_moderate_result(&state, run_id).await;
2065 let response = post_json(
2071 crate::build_router(state),
2072 &format!("/api/runs/{run_id}/write"),
2073 serde_json::json!({ "response_level": "moderate" }),
2074 )
2075 .await;
2076 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
2077 let error = body_json(response).await;
2078 assert!(
2079 error["error"]
2080 .as_str()
2081 .unwrap()
2082 .contains("no recorded OPC server")
2083 );
2084 }
2085
2086 #[tokio::test]
2087 async fn write_run_returns_400_when_no_result_for_the_requested_level() {
2088 let state = crate::test_support::in_memory_state().await;
2089 let run_id = seed_writable_opcda_run(&state, "127.0.0.1:1", "Sim.Server").await;
2091
2092 let response = post_json(
2093 crate::build_router(state),
2094 &format!("/api/runs/{run_id}/write"),
2095 serde_json::json!({ "response_level": "sluggish" }),
2096 )
2097 .await;
2098 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
2099 let error = body_json(response).await;
2100 assert!(error["error"].as_str().unwrap().contains("Sluggish"));
2101 }
2102
2103 #[tokio::test]
2104 async fn write_run_rejects_an_invalid_result_before_connecting_to_the_driver() {
2105 let state = crate::test_support::in_memory_state().await;
2106 let run_id = start_opcda_run(&state).await;
2107 TuneRunRow::record_connection(
2108 &state.pool,
2109 run_id,
2110 Some("Sim.Server"),
2111 Some("127.0.0.1:1"),
2112 "{}",
2113 )
2114 .await
2115 .unwrap();
2116 add_invalid_moderate_result(&state, run_id).await;
2117
2118 let response = post_json(
2119 crate::build_router(state),
2120 &format!("/api/runs/{run_id}/write"),
2121 serde_json::json!({ "response_level": "moderate" }),
2122 )
2123 .await;
2124 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
2125 let error = body_json(response).await;
2126 let message = error["error"].as_str().unwrap();
2127 assert!(message.contains("Moderate"));
2128 assert!(message.contains("invalid"));
2129 assert!(message.contains("PV amplitude is not positive"));
2130 }
2131
2132 #[tokio::test]
2133 async fn write_run_returns_400_when_the_driver_connection_fails() {
2134 let state = crate::test_support::in_memory_state().await;
2135 let run_id = seed_writable_opcda_run(&state, "127.0.0.1:1", "Sim.Server").await;
2139
2140 let response = post_json(
2141 crate::build_router(state),
2142 &format!("/api/runs/{run_id}/write"),
2143 serde_json::json!({ "response_level": "moderate" }),
2144 )
2145 .await;
2146 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
2147 let error = body_json(response).await;
2148 assert!(
2149 error["error"]
2150 .as_str()
2151 .unwrap()
2152 .contains("failed to connect")
2153 );
2154 }
2155
2156 #[tokio::test]
2157 async fn write_run_returns_409_when_another_operation_is_active() {
2158 use crate::test_support::mock_bridge::{
2159 MockBridgeService, good_reading, start_mock_server,
2160 };
2161
2162 let host = start_mock_server(MockBridgeService {
2163 read_response: good_reading("10.0"),
2164 write_response: opcda_bridge_proto::bridge::WriteResponse {
2165 tag_id: "ignored".to_string(),
2166 success: true,
2167 error: None,
2168 },
2169 ..Default::default()
2170 })
2171 .await;
2172
2173 let state = crate::test_support::in_memory_state().await;
2174 let run_id = seed_writable_opcda_run(&state, &host, "Sim.Server").await;
2175 state.active_run.reserve(424242).await.unwrap();
2176
2177 let response = post_json(
2178 crate::build_router(state.clone()),
2179 &format!("/api/runs/{run_id}/write"),
2180 serde_json::json!({ "response_level": "moderate" }),
2181 )
2182 .await;
2183 assert_eq!(response.status(), StatusCode::CONFLICT);
2184 let error = body_json(response).await;
2185 assert!(error["error"].as_str().unwrap().contains("424242"));
2186
2187 state.active_run.release(424242).await;
2188 }
2189
2190 #[tokio::test]
2191 async fn revert_run_succeeds_and_records_a_revert_kind_row() {
2192 use crate::test_support::mock_bridge::{
2193 MockBridgeService, good_reading, start_mock_server,
2194 };
2195
2196 let host = start_mock_server(MockBridgeService {
2197 read_response: good_reading("10.0"),
2198 write_response: opcda_bridge_proto::bridge::WriteResponse {
2199 tag_id: "ignored".to_string(),
2200 success: true,
2201 error: None,
2202 },
2203 ..Default::default()
2204 })
2205 .await;
2206
2207 let state = crate::test_support::in_memory_state().await;
2208 let run_id = seed_writable_opcda_run(&state, &host, "Sim.Server").await;
2209
2210 let mut previous_write =
2211 bhtune_db::models::NewTuneWrite::new(ResponseLevel::Moderate, Utc::now());
2212 previous_write.previous = Some(WriteReadback {
2213 proportional: 10.0,
2214 integral: 10.0,
2215 derivative: 10.0,
2216 });
2217 previous_write.proportional_written = Some(66.7);
2218 previous_write.integral_written = Some(2.0);
2219 previous_write.derivative_written = Some(0.5);
2220 previous_write.proportional_readback = Some(66.7);
2221 previous_write.integral_readback = Some(2.0);
2222 previous_write.derivative_readback = Some(0.5);
2223 previous_write.success = true;
2224 TuneWriteRow::insert(&state.pool, run_id, previous_write)
2225 .await
2226 .unwrap();
2227
2228 let response = post_empty(
2229 crate::build_router(state.clone()),
2230 &format!("/api/runs/{run_id}/revert"),
2231 )
2232 .await;
2233 assert_eq!(response.status(), StatusCode::OK);
2234 let detail = body_json(response).await;
2235
2236 let writes = detail["writes"].as_array().unwrap();
2237 assert_eq!(writes.len(), 2);
2238 let revert_row = writes.iter().find(|w| w["kind"] == "revert").unwrap();
2239 assert_eq!(revert_row["response_level"], "moderate");
2240 assert_eq!(revert_row["success"], true);
2241 assert_eq!(revert_row["proportional_written"], 10.0);
2242 assert_eq!(revert_row["integral_written"], 10.0);
2243 assert_eq!(revert_row["derivative_written"], 10.0);
2244 assert!(revert_row["rollback_state"].is_null());
2246 }
2247
2248 #[tokio::test]
2249 async fn revert_run_can_restore_recorded_values_when_calculated_result_is_invalid() {
2250 use crate::test_support::mock_bridge::{
2251 MockBridgeService, good_reading, start_mock_server,
2252 };
2253
2254 let host = start_mock_server(MockBridgeService {
2255 read_response: good_reading("10.0"),
2256 write_response: opcda_bridge_proto::bridge::WriteResponse {
2257 tag_id: "ignored".to_string(),
2258 success: true,
2259 error: None,
2260 },
2261 ..Default::default()
2262 })
2263 .await;
2264
2265 let state = crate::test_support::in_memory_state().await;
2266 let run_id = start_opcda_run(&state).await;
2267 TuneRunRow::record_connection(&state.pool, run_id, Some("Sim.Server"), Some(&host), "{}")
2268 .await
2269 .unwrap();
2270 add_invalid_moderate_result(&state, run_id).await;
2271
2272 let mut previous_write =
2273 bhtune_db::models::NewTuneWrite::new(ResponseLevel::Moderate, Utc::now());
2274 previous_write.previous = Some(WriteReadback {
2275 proportional: 10.0,
2276 integral: 10.0,
2277 derivative: 10.0,
2278 });
2279 previous_write.proportional_written = Some(66.7);
2280 previous_write.integral_written = Some(2.0);
2281 previous_write.derivative_written = Some(0.5);
2282 previous_write.proportional_readback = Some(66.7);
2283 previous_write.integral_readback = Some(2.0);
2284 previous_write.derivative_readback = Some(0.5);
2285 previous_write.success = true;
2286 TuneWriteRow::insert(&state.pool, run_id, previous_write)
2287 .await
2288 .unwrap();
2289
2290 let response = post_empty(
2291 crate::build_router(state),
2292 &format!("/api/runs/{run_id}/revert"),
2293 )
2294 .await;
2295 assert_eq!(response.status(), StatusCode::OK);
2296 let detail = body_json(response).await;
2297 assert_eq!(detail["results"][0]["status"], "invalid");
2298 let revert_row = detail["writes"]
2299 .as_array()
2300 .unwrap()
2301 .iter()
2302 .find(|write| write["kind"] == "revert")
2303 .unwrap();
2304 assert_eq!(revert_row["success"], true);
2305 assert_eq!(revert_row["derivative_written"], 10.0);
2306 }
2307
2308 #[tokio::test]
2309 async fn revert_run_returns_400_when_there_is_no_write_to_revert() {
2310 let state = crate::test_support::in_memory_state().await;
2311 let run_id = seed_writable_opcda_run(&state, "127.0.0.1:1", "Sim.Server").await;
2312 let response = post_empty(
2315 crate::build_router(state),
2316 &format!("/api/runs/{run_id}/revert"),
2317 )
2318 .await;
2319 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
2320 let error = body_json(response).await;
2321 assert!(
2322 error["error"]
2323 .as_str()
2324 .unwrap()
2325 .contains("no recorded PID write-back to revert")
2326 );
2327 }
2328
2329 #[tokio::test]
2330 async fn revert_run_returns_400_when_the_last_write_has_no_previous_values() {
2331 let state = crate::test_support::in_memory_state().await;
2332 let run_id = seed_writable_opcda_run(&state, "127.0.0.1:1", "Sim.Server").await;
2333
2334 let mut failed_write =
2339 bhtune_db::models::NewTuneWrite::new(ResponseLevel::Moderate, Utc::now());
2340 failed_write.success = false;
2341 failed_write.error_message =
2342 Some("pre-read of Proportional tag failed: unavailable".to_string());
2343 TuneWriteRow::insert(&state.pool, run_id, failed_write)
2344 .await
2345 .unwrap();
2346
2347 let response = post_empty(
2348 crate::build_router(state),
2349 &format!("/api/runs/{run_id}/revert"),
2350 )
2351 .await;
2352 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
2353 let error = body_json(response).await;
2354 assert!(
2355 error["error"]
2356 .as_str()
2357 .unwrap()
2358 .contains("never recorded pre-write values")
2359 );
2360 }
2361
2362 #[tokio::test]
2363 async fn revert_run_returns_400_when_the_driver_connection_fails() {
2364 let state = crate::test_support::in_memory_state().await;
2365 let run_id = seed_writable_opcda_run(&state, "127.0.0.1:1", "Sim.Server").await;
2366 let mut previous_write =
2367 bhtune_db::models::NewTuneWrite::new(ResponseLevel::Moderate, Utc::now());
2368 previous_write.previous = Some(WriteReadback {
2369 proportional: 10.0,
2370 integral: 20.0,
2371 derivative: 30.0,
2372 });
2373 previous_write.success = true;
2374 TuneWriteRow::insert(&state.pool, run_id, previous_write)
2375 .await
2376 .unwrap();
2377
2378 let response = post_empty(
2379 crate::build_router(state),
2380 &format!("/api/runs/{run_id}/revert"),
2381 )
2382 .await;
2383 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
2384 assert!(
2385 body_json(response).await["error"]
2386 .as_str()
2387 .unwrap()
2388 .contains("failed to connect")
2389 );
2390 }
2391
2392 #[tokio::test]
2393 async fn revert_run_returns_404_for_unknown_run() {
2394 let app = crate::build_router(crate::test_support::in_memory_state().await);
2395 let response = post_empty(app, "/api/runs/999999/revert").await;
2396 assert_eq!(response.status(), StatusCode::NOT_FOUND);
2397 }
2398}