1use axum::extract::{Path, Query, State};
17use axum::http::{HeaderValue, StatusCode, header};
18use axum::response::{IntoResponse, Response};
19use axum::routing::get;
20use axum::{Json, Router};
21use bhtune_core::{
22 ControllerDirection, ControllerType, DcsTemplate, LoopConfig, ProcessType, ResponseLevel, Tick,
23 TuningResultInvalidReason, TuningResultStatus,
24};
25use bhtune_db::models::{
26 MvActuationKind, MvActuationStatus, Pagination, RestoreStatus, RollbackState, SampleQuality,
27 TemplateOrigin, TimingMetrics, TuneDriver, TuneMvActuationRow, TuneOutcome, TuneResultRow,
28 TuneRunFilter, TuneRunRow, TuneSampleRow, TuneWriteRow, WriteKind,
29};
30use chrono::{DateTime, Utc};
31use serde::{Deserialize, Serialize};
32use std::future::Future;
33use utoipa::{IntoParams, ToSchema};
34
35use crate::error::{ApiError, ErrorBody};
36use crate::routes::runs::StartRunRequest;
37use crate::state::AppState;
38
39#[derive(Debug, Deserialize, IntoParams)]
43#[into_params(parameter_in = Query)]
44pub struct RunListQuery {
45 pub loop_id: Option<i64>,
46 pub process_type: Option<ProcessType>,
47 pub controller_type: Option<ControllerType>,
48 pub outcome: Option<TuneOutcome>,
49 pub driver: Option<TuneDriver>,
50 pub started_after: Option<DateTime<Utc>>,
51 pub started_before: Option<DateTime<Utc>>,
52 pub template_name: Option<String>,
53 pub template_origin: Option<TemplateOrigin>,
54 pub opc_server: Option<String>,
57 pub bridge_host: Option<String>,
59 pub limit: Option<i64>,
60 pub offset: Option<i64>,
61}
62
63pub(crate) fn filter_from_query(query: &RunListQuery) -> TuneRunFilter {
64 let mut filter = TuneRunFilter::default();
65 if let Some(v) = query.loop_id {
66 filter = filter.with_loop_id(v);
67 }
68 if let Some(v) = query.process_type {
69 filter = filter.with_process_type(v);
70 }
71 if let Some(v) = query.controller_type {
72 filter = filter.with_controller_type(v);
73 }
74 if let Some(v) = query.outcome {
75 filter = filter.with_outcome(v);
76 }
77 if let Some(v) = query.driver {
78 filter = filter.with_driver(v);
79 }
80 if let Some(v) = query.started_after {
81 filter = filter.with_started_after(v);
82 }
83 if let Some(v) = query.started_before {
84 filter = filter.with_started_before(v);
85 }
86 if let Some(v) = &query.template_name {
87 filter = filter.with_template_name(v.clone());
88 }
89 if let Some(v) = query.template_origin {
90 filter = filter.with_template_origin(v);
91 }
92 if let Some(v) = &query.opc_server {
93 filter = filter.with_opc_server(v.clone());
94 }
95 if let Some(v) = &query.bridge_host {
96 filter = filter.with_bridge_host(v.clone());
97 }
98 filter
99}
100
101#[derive(Debug, Serialize, ToSchema)]
105pub struct RunSummaryResponse {
106 pub id: i64,
107 pub tag_name: String,
108 pub notes: Option<String>,
109 pub driver: TuneDriver,
110 pub outcome: TuneOutcome,
111 pub process_type: ProcessType,
112 pub started_at: DateTime<Utc>,
113}
114
115impl From<&TuneRunRow> for RunSummaryResponse {
116 fn from(run: &TuneRunRow) -> Self {
117 RunSummaryResponse {
118 id: run.id,
119 tag_name: run.loop_name.clone(),
120 notes: run.notes.clone(),
121 driver: run.driver,
122 outcome: run.outcome,
123 process_type: run.config.process_type,
124 started_at: run.started_at,
125 }
126 }
127}
128
129#[derive(Debug, Serialize, ToSchema)]
130pub struct RunListResponse {
131 pub runs: Vec<RunSummaryResponse>,
132 pub returned: usize,
135 pub total: i64,
136}
137
138#[utoipa::path(
143 get,
144 path = "/api/runs",
145 tag = "runs",
146 params(RunListQuery),
147 responses(
148 (status = 200, description = "A page of runs matching the filter.", body = RunListResponse),
149 ),
150)]
151pub(crate) async fn list_runs(
152 State(state): State<AppState>,
153 Query(query): Query<RunListQuery>,
154) -> Result<Json<RunListResponse>, ApiError> {
155 let filter = filter_from_query(&query);
156 let pagination = Pagination::new(
157 query.limit.unwrap_or_else(|| Pagination::default().limit),
158 query.offset.unwrap_or(0),
159 );
160 let runs = TuneRunRow::list(&state.pool, &filter, pagination).await?;
161 let total = TuneRunRow::count(&state.pool, &filter).await?;
162 Ok(Json(RunListResponse {
163 returned: runs.len(),
164 runs: runs.iter().map(RunSummaryResponse::from).collect(),
165 total,
166 }))
167}
168
169#[utoipa::path(
180 get,
181 path = "/api/runs/last-request",
182 tag = "runs",
183 responses(
184 (status = 200, description = "The newest run's original request, or `null` if no runs exist yet or its request isn't usable.", body = Option<StartRunRequest>),
185 ),
186)]
187pub(crate) async fn last_request(
188 State(state): State<AppState>,
189) -> Result<Json<Option<StartRunRequest>>, ApiError> {
190 let newest =
191 TuneRunRow::list(&state.pool, &TuneRunFilter::default(), Pagination::first(1)).await?;
192 let Some(run) = newest.into_iter().next() else {
193 return Ok(Json(None));
194 };
195 Ok(Json(parse_stored_request(run.id, &run.request_json)))
196}
197
198pub(crate) fn parse_stored_request(run_id: i64, request_json: &str) -> Option<StartRunRequest> {
208 match serde_json::from_str(request_json) {
209 Ok(request) => Some(request),
210 Err(e) => {
211 tracing::warn!(
212 run_id,
213 error = %e,
214 "run's stored request_json did not parse as StartRunRequest; treating as unavailable"
215 );
216 None
217 }
218 }
219}
220
221#[derive(Debug, Serialize, ToSchema)]
225pub struct InitialReadingsResponse {
226 pub pv_ini: f32,
227 pub mv_ini: f32,
228 pub mv_range_low: f32,
229 pub mv_range_high: f32,
230 pub pv_range_high: f32,
231 pub pv_range_low: f32,
232 pub controller_direction: ControllerDirection,
233 pub mode_raw: Option<String>,
234 pub mode_attribute_raw: Option<String>,
235 pub setpoint_ini: Option<f32>,
236}
237
238impl From<bhtune_db::models::TuneRunInitialReadings> for InitialReadingsResponse {
239 fn from(r: bhtune_db::models::TuneRunInitialReadings) -> Self {
240 InitialReadingsResponse {
241 pv_ini: r.pv_ini,
242 mv_ini: r.mv_ini,
243 mv_range_low: r.mv_range_low,
244 mv_range_high: r.mv_range_high,
245 pv_range_high: r.pv_range_high,
246 pv_range_low: r.pv_range_low,
247 controller_direction: r.controller_direction,
248 mode_raw: r.mode_raw,
249 mode_attribute_raw: r.mode_attribute_raw,
250 setpoint_ini: r.setpoint_ini,
251 }
252 }
253}
254
255#[derive(Debug, Serialize, ToSchema)]
260pub struct SampleResponse {
261 pub tick_index: i64,
262 pub sample: Tick,
263 pub state: bhtune_core::MrftState,
264 pub pv_quality: SampleQuality,
265}
266
267impl From<&TuneSampleRow> for SampleResponse {
268 fn from(row: &TuneSampleRow) -> Self {
269 SampleResponse {
270 tick_index: row.tick_index,
271 sample: row.sample,
272 state: row.state,
273 pv_quality: row.pv_quality,
274 }
275 }
276}
277
278#[derive(Debug, Serialize, ToSchema)]
280pub struct ResultResponse {
281 pub response_level: ResponseLevel,
282 pub kp: Option<f32>,
283 pub ti_minutes: Option<f32>,
284 pub td_minutes: Option<f32>,
285 pub proportional: Option<f32>,
286 pub integral: Option<f32>,
287 pub derivative: Option<f32>,
288 pub status: TuningResultStatus,
289 pub invalid_reason: Option<TuningResultInvalidReason>,
290}
291
292impl From<&TuneResultRow> for ResultResponse {
293 fn from(r: &TuneResultRow) -> Self {
294 ResultResponse {
295 response_level: r.response_level,
296 kp: r.kp,
297 ti_minutes: r.ti_minutes,
298 td_minutes: r.td_minutes,
299 proportional: r.proportional,
300 integral: r.integral,
301 derivative: r.derivative,
302 status: r.status,
303 invalid_reason: r.invalid_reason,
304 }
305 }
306}
307
308#[derive(Debug, Serialize, ToSchema)]
310pub struct WriteResponse {
311 pub kind: WriteKind,
312 pub response_level: ResponseLevel,
313 pub allow_uncertain_quality: bool,
314 pub written_at: DateTime<Utc>,
315 pub proportional_previous: Option<f32>,
316 pub integral_previous: Option<f32>,
317 pub derivative_previous: Option<f32>,
318 pub proportional_written: Option<f32>,
319 pub integral_written: Option<f32>,
320 pub derivative_written: Option<f32>,
321 pub proportional_readback: Option<f32>,
322 pub integral_readback: Option<f32>,
323 pub derivative_readback: Option<f32>,
324 pub success: bool,
325 pub error_message: Option<String>,
326 pub rollback_state: Option<RollbackState>,
327 pub rollback_error: Option<String>,
328}
329
330impl From<&TuneWriteRow> for WriteResponse {
331 fn from(w: &TuneWriteRow) -> Self {
332 WriteResponse {
333 kind: w.kind,
334 response_level: w.response_level,
335 allow_uncertain_quality: w.allow_uncertain_quality,
336 written_at: w.written_at,
337 proportional_previous: w.previous.map(|p| p.proportional),
338 integral_previous: w.previous.map(|p| p.integral),
339 derivative_previous: w.previous.map(|p| p.derivative),
340 proportional_written: w.proportional_written,
341 integral_written: w.integral_written,
342 derivative_written: w.derivative_written,
343 proportional_readback: w.proportional_readback,
344 integral_readback: w.integral_readback,
345 derivative_readback: w.derivative_readback,
346 success: w.success,
347 error_message: w.error_message.clone(),
348 rollback_state: w.rollback_state,
349 rollback_error: w.rollback_error.clone(),
350 }
351 }
352}
353
354#[derive(Debug, Serialize, ToSchema)]
358pub struct MvActuationResponse {
359 pub id: i64,
360 pub sequence: i64,
361 pub kind: MvActuationKind,
362 pub commanded_at: DateTime<Utc>,
363 pub target_mv: f32,
364 pub previous_commanded_mv: Option<f32>,
365 pub tolerance: f32,
366 pub confirmation_due_at: DateTime<Utc>,
367 pub last_checked_at: Option<DateTime<Utc>>,
368 pub readback_mv: Option<f32>,
369 pub readback_quality: Option<SampleQuality>,
370 pub attempt_count: i64,
371 pub status: MvActuationStatus,
372 pub detail: Option<String>,
373}
374
375impl From<&TuneMvActuationRow> for MvActuationResponse {
376 fn from(row: &TuneMvActuationRow) -> Self {
377 Self {
378 id: row.id,
379 sequence: row.sequence,
380 kind: row.kind,
381 commanded_at: row.commanded_at,
382 target_mv: row.target_mv,
383 previous_commanded_mv: row.previous_commanded_mv,
384 tolerance: row.tolerance,
385 confirmation_due_at: row.confirmation_due_at,
386 last_checked_at: row.last_checked_at,
387 readback_mv: row.readback_mv,
388 readback_quality: row.readback_quality,
389 attempt_count: row.attempt_count,
390 status: row.status,
391 detail: row.detail.clone(),
392 }
393 }
394}
395
396#[derive(Debug, Serialize, ToSchema)]
401pub struct PidConstantTagsResponse {
402 pub proportional: String,
403 pub integral: String,
404 pub derivative: String,
405}
406
407#[derive(Debug, Serialize, ToSchema)]
410pub struct PidParameterLabelsResponse {
411 pub proportional: String,
412 pub integral: String,
413 pub derivative: String,
414}
415
416impl From<&DcsTemplate> for PidParameterLabelsResponse {
417 fn from(template: &DcsTemplate) -> Self {
418 Self {
419 proportional: pid_parameter_label(&template.proportional_constant_suffix, "P"),
420 integral: pid_parameter_label(&template.integral_constant_suffix, "I"),
421 derivative: pid_parameter_label(&template.derivative_constant_suffix, "D"),
422 }
423 }
424}
425
426fn pid_parameter_label(suffix: &str, fallback: &str) -> String {
427 if suffix.is_empty() {
428 fallback.to_owned()
429 } else {
430 suffix.to_owned()
431 }
432}
433
434#[derive(Debug, Serialize, ToSchema)]
435pub struct RunDetailResponse {
436 pub id: i64,
437 pub tag_name: String,
438 pub notes: Option<String>,
439 pub driver: TuneDriver,
440 pub outcome: TuneOutcome,
441 pub failure_reason: Option<String>,
442 pub started_at: DateTime<Utc>,
443 pub completed_at: Option<DateTime<Utc>>,
444 pub template_name: String,
447 pub template_origin: TemplateOrigin,
448 pub allow_uncertain_quality: bool,
450 pub config: LoopConfig,
451 pub effective_tuning: Option<bhtune_db::models::EffectiveTuning>,
454 pub opc_server: Option<String>,
458 pub bridge_host: Option<String>,
460 pub pid_constant_tags: Option<PidConstantTagsResponse>,
467 pub pid_parameter_labels: PidParameterLabelsResponse,
470 pub initial_readings: Option<InitialReadingsResponse>,
471 pub timing_metrics: Option<TimingMetrics>,
472 pub samples: Vec<SampleResponse>,
473 pub results: Vec<ResultResponse>,
474 pub writes: Vec<WriteResponse>,
475 pub mv_actuations: Vec<MvActuationResponse>,
476 pub restore_status: Option<RestoreStatus>,
477 pub restore_detail: Option<String>,
478 pub original_request: Option<StartRunRequest>,
485}
486
487pub(crate) async fn build_run_detail(
493 pool: &bhtune_db::SqlitePool,
494 run_id: i64,
495) -> Result<Option<RunDetailResponse>, ApiError> {
496 let Some(run) = TuneRunRow::get(pool, run_id).await? else {
497 return Ok(None);
498 };
499 let samples = TuneSampleRow::list_for_run(pool, run_id).await?;
500 let results = TuneResultRow::list_for_run(pool, run_id).await?;
501 let writes = TuneWriteRow::list_for_run(pool, run_id).await?;
502 let mv_actuations = TuneMvActuationRow::list_for_run(pool, run_id).await?;
503 let pid_constant_tags = match (
504 &run.tags.proportional_constant,
505 &run.tags.integral_constant,
506 &run.tags.derivative_constant,
507 ) {
508 (Some(proportional), Some(integral), Some(derivative)) => Some(PidConstantTagsResponse {
509 proportional: proportional.clone(),
510 integral: integral.clone(),
511 derivative: derivative.clone(),
512 }),
513 _ => None,
514 };
515 let pid_parameter_labels = PidParameterLabelsResponse::from(&run.template);
516
517 Ok(Some(RunDetailResponse {
518 id: run.id,
519 tag_name: run.loop_name,
520 notes: run.notes,
521 driver: run.driver,
522 outcome: run.outcome,
523 failure_reason: run.failure_reason,
524 started_at: run.started_at,
525 completed_at: run.completed_at,
526 template_name: run.template.name,
527 template_origin: run.template_origin,
528 allow_uncertain_quality: run.allow_uncertain_quality,
529 config: run.config,
530 effective_tuning: run.effective_tuning,
531 opc_server: run.opc_server,
532 bridge_host: run.bridge_host,
533 pid_constant_tags,
534 pid_parameter_labels,
535 initial_readings: run.initial_readings.map(InitialReadingsResponse::from),
536 timing_metrics: run.timing_metrics,
537 samples: samples.iter().map(SampleResponse::from).collect(),
538 results: results.iter().map(ResultResponse::from).collect(),
539 writes: writes.iter().map(WriteResponse::from).collect(),
540 mv_actuations: mv_actuations
541 .iter()
542 .map(MvActuationResponse::from)
543 .collect(),
544 restore_status: run.restore_status,
545 restore_detail: run.restore_detail,
546 original_request: parse_stored_request(run.id, &run.request_json),
547 }))
548}
549
550#[utoipa::path(
554 get,
555 path = "/api/runs/{id}",
556 tag = "runs",
557 params(
558 ("id" = i64, Path, description = "Run id"),
559 ),
560 responses(
561 (status = 200, description = "The full recorded detail for one run.", body = RunDetailResponse),
562 (status = 404, description = "No run with that id.", body = ErrorBody),
563 ),
564)]
565pub(crate) async fn show_run(
566 State(state): State<AppState>,
567 Path(run_id): Path<i64>,
568) -> Result<Json<RunDetailResponse>, ApiError> {
569 build_run_detail(&state.pool, run_id)
570 .await?
571 .map(Json)
572 .ok_or_else(|| ApiError::NotFound(format!("no run with id {run_id}")))
573}
574
575#[derive(Debug, Clone, Copy, Deserialize, ToSchema)]
584#[serde(rename_all = "snake_case")]
585pub enum RunExportFormat {
586 Csv,
587 Json,
588}
589
590impl From<RunExportFormat> for bhtune_cli::args::ExportFormat {
591 fn from(format: RunExportFormat) -> Self {
592 match format {
593 RunExportFormat::Csv => bhtune_cli::args::ExportFormat::Csv,
594 RunExportFormat::Json => bhtune_cli::args::ExportFormat::Json,
595 }
596 }
597}
598
599#[derive(Debug, Deserialize, IntoParams)]
600#[into_params(parameter_in = Query)]
601pub struct RunExportQuery {
602 pub format: Option<RunExportFormat>,
604}
605
606#[utoipa::path(
612 get,
613 path = "/api/runs/{id}/export",
614 tag = "runs",
615 params(
616 ("id" = i64, Path, description = "Run id"),
617 RunExportQuery,
618 ),
619 responses(
620 (status = 200, description = "The run's recorded samples, as CSV (default) or JSON.", content_type = "text/csv"),
621 (status = 404, description = "No run with that id, or it has no recorded samples.", body = ErrorBody),
622 ),
623)]
624pub(crate) async fn export_run(
625 State(state): State<AppState>,
626 Path(run_id): Path<i64>,
627 Query(query): Query<RunExportQuery>,
628) -> Result<Response, ApiError> {
629 let format = query.format.unwrap_or(RunExportFormat::Csv);
630 let samples = TuneSampleRow::list_for_run(&state.pool, run_id).await?;
631 if samples.is_empty() {
632 return Err(ApiError::NotFound(format!(
633 "run {run_id} has no recorded samples (unknown run id, or it never started)"
634 )));
635 }
636 let bytes = bhtune_cli::commands::export::samples_to_bytes(&samples, format.into())?;
637 let (content_type, extension) = match format {
638 RunExportFormat::Csv => ("text/csv", "csv"),
639 RunExportFormat::Json => ("application/json", "json"),
640 };
641 let mut response = bytes.into_response();
642 let headers = response.headers_mut();
643 headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
644 headers.insert(
645 header::CONTENT_DISPOSITION,
646 HeaderValue::from_str(&format!(
647 "attachment; filename=\"run-{run_id}.{extension}\""
648 ))
649 .map_err(|e| ApiError::Internal(e.into()))?,
650 );
651 Ok(response)
652}
653
654#[utoipa::path(
670 delete,
671 path = "/api/runs/{id}",
672 tag = "runs",
673 params(
674 ("id" = i64, Path, description = "Run id"),
675 ),
676 responses(
677 (status = 204, description = "Run deleted."),
678 (status = 404, description = "No run with that id.", body = ErrorBody),
679 (status = 409, description = "The run has not finished yet.", body = ErrorBody),
680 ),
681)]
682pub(crate) async fn delete_run(
683 State(state): State<AppState>,
684 Path(run_id): Path<i64>,
685) -> Result<StatusCode, ApiError> {
686 delete_run_with_hook(state, run_id, |_| async {}).await
687}
688
689async fn delete_run_with_hook<F, Fut>(
690 state: AppState,
691 run_id: i64,
692 after_lookup: F,
693) -> Result<StatusCode, ApiError>
694where
695 F: FnOnce(&AppState) -> Fut,
696 Fut: Future<Output = ()>,
697{
698 let run = TuneRunRow::get(&state.pool, run_id)
699 .await?
700 .ok_or_else(|| ApiError::NotFound(format!("no run with id {run_id}")))?;
701 if run.outcome == TuneOutcome::Running {
702 return Err(ApiError::Conflict(format!(
703 "run {run_id} has not finished yet; cancel it before deleting"
704 )));
705 }
706 after_lookup(&state).await;
707 if TuneRunRow::delete(&state.pool, run_id).await? {
708 Ok(StatusCode::NO_CONTENT)
709 } else {
710 Err(ApiError::NotFound(format!("no run with id {run_id}")))
714 }
715}
716
717pub fn router() -> Router<AppState> {
718 Router::new()
719 .route("/api/runs", get(list_runs))
720 .route("/api/runs/last-request", get(last_request))
721 .route("/api/runs/{id}", get(show_run).delete(delete_run))
722 .route("/api/runs/{id}/export", get(export_run))
723}
724
725#[cfg(test)]
726mod tests {
727 use super::*;
728 use axum::body::{Body, to_bytes};
729 use axum::http::{Request, StatusCode};
730 use bhtune_db::models::NewTuneMvActuation;
731 use tower::ServiceExt;
732
733 async fn body_json(response: axum::response::Response) -> serde_json::Value {
734 let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
735 serde_json::from_slice(&bytes).unwrap()
736 }
737
738 async fn seed_one_run(state: &AppState) -> i64 {
739 let template_row =
740 bhtune_db::models::DcsTemplateRow::get_by_name(&state.pool, "Yokogawa CentumVP")
741 .await
742 .unwrap()
743 .unwrap();
744 let template = template_row.template;
745 let config = LoopConfig {
746 process_type: ProcessType::Flow,
747 controller_type: ControllerType::Pi,
748 relay_amp_percent: 5.0,
749 num_cycles_skip: 1,
750 num_cycles_count: 3,
751 noise_protection_secs: 0,
752 mrft_delay_secs: 0,
753 };
754 let tags = bhtune_core::LoopTags::derive_from_pv_tag("Loop1.PV", &template);
755 let run = TuneRunRow::start(
756 &state.pool,
757 None,
758 "Loop1",
759 TuneDriver::Simulator,
760 config,
761 template_row.origin,
762 &template,
763 &tags,
764 Utc::now(),
765 )
766 .await
767 .unwrap();
768 run.id
769 }
770
771 async fn seed_full_run(state: &AppState) -> (i64, i64) {
778 let template_row =
779 bhtune_db::models::DcsTemplateRow::get_by_name(&state.pool, "Yokogawa CentumVP")
780 .await
781 .unwrap()
782 .unwrap();
783 let template = template_row.template;
784 let config = LoopConfig {
785 process_type: ProcessType::Flow,
786 controller_type: ControllerType::Pi,
787 relay_amp_percent: 5.0,
788 num_cycles_skip: 1,
789 num_cycles_count: 3,
790 noise_protection_secs: 0,
791 mrft_delay_secs: 0,
792 };
793 let tags = bhtune_core::LoopTags::derive_from_pv_tag("Loop2.PV", &template);
794 let now = Utc::now();
795
796 let loop_id = sqlx::query(
797 r#"
798 INSERT INTO loops (
799 name, dcs_template_id, tags_json, process_type, controller_type,
800 relay_amp_percent, num_cycles_skip, num_cycles_count, noise_protection_secs,
801 mrft_delay_secs, created_at, updated_at
802 ) VALUES ('Loop2', ?, '{}', 'flow', 'pi', 5.0, 1, 3, 0, 0, ?, ?)
803 "#,
804 )
805 .bind(template_row.id)
806 .bind(now)
807 .bind(now)
808 .execute(&state.pool)
809 .await
810 .unwrap()
811 .last_insert_rowid();
812
813 let run = TuneRunRow::start(
814 &state.pool,
815 Some(loop_id),
816 "Loop2",
817 TuneDriver::Simulator,
818 config,
819 template_row.origin,
820 &template,
821 &tags,
822 now,
823 )
824 .await
825 .unwrap();
826 let run_id = run.id;
827
828 TuneRunRow::record_initial_readings(
829 &state.pool,
830 run_id,
831 bhtune_db::models::TuneRunInitialReadings {
832 pv_ini: 50.0,
833 mv_ini: 45.0,
834 mv_range_low: 0.0,
835 mv_range_high: 100.0,
836 pv_range_high: 100.0,
837 pv_range_low: 0.0,
838 controller_direction: ControllerDirection::Direct,
839 mode_raw: Some("1".to_string()),
840 mode_attribute_raw: Some("2".to_string()),
841 setpoint_ini: Some(50.0),
842 },
843 )
844 .await
845 .unwrap();
846
847 TuneRunRow::record_timing_metrics(
848 &state.pool,
849 run_id,
850 TimingMetrics {
851 basis: bhtune_db::models::TimingBasis::SimulatedFixedStep,
852 requested_interval_ms: 5,
853 sample_gap_count: 9,
854 mean_sample_gap_ms: Some(5.0),
855 max_sample_gap_ms: Some(5.0),
856 missed_poll_opportunity_count: 0,
857 measured_oscillation_period_ms: Some(50.0),
858 approximate_samples_per_period: Some(10.0),
859 sampling_adequacy: bhtune_db::models::SamplingAdequacy::Adequate,
860 poll_latency: Some(bhtune_db::models::PollLatencyMetrics {
861 pv_read: bhtune_db::models::TimingSummary {
862 count: 10,
863 mean_ms: Some(1.25),
864 max_ms: Some(2.5),
865 },
866 mv_write: bhtune_db::models::TimingSummary {
867 count: 3,
868 mean_ms: Some(3.5),
869 max_ms: Some(4.0),
870 },
871 mv_verification: bhtune_db::models::TimingSummary {
872 count: 3,
873 mean_ms: Some(2.0),
874 max_ms: Some(2.5),
875 },
876 sample_persist: bhtune_db::models::TimingSummary {
877 count: 10,
878 mean_ms: Some(0.75),
879 max_ms: Some(1.0),
880 },
881 tick_work: bhtune_db::models::TimingSummary {
882 count: 10,
883 mean_ms: Some(5.5),
884 max_ms: Some(7.0),
885 },
886 }),
887 },
888 )
889 .await
890 .unwrap();
891
892 TuneSampleRow::insert(
893 &state.pool,
894 run_id,
895 0,
896 Tick {
897 time: now,
898 pv: 50.0,
899 },
900 bhtune_core::MrftState {
901 hysteresis: 1.0,
902 mv_value_current: 45.0,
903 mv_sign_next_step: 1,
904 counter_all_switches: 0,
905 cycles_completed: 0,
906 cycles_remaining: 3,
907 },
908 SampleQuality::Good,
909 )
910 .await
911 .unwrap();
912
913 TuneResultRow::insert(
914 &state.pool,
915 &TuneResultRow {
916 id: 0,
917 run_id,
918 response_level: ResponseLevel::Moderate,
919 kp: Some(1.5),
920 ti_minutes: Some(2.0),
921 td_minutes: Some(0.0),
922 proportional: Some(66.7),
923 integral: Some(2.0),
924 derivative: Some(0.0),
925 status: TuningResultStatus::Valid,
926 invalid_reason: None,
927 },
928 )
929 .await
930 .unwrap();
931
932 let mut successful_write =
934 bhtune_db::models::NewTuneWrite::new(ResponseLevel::Moderate, now);
935 successful_write.previous = Some(bhtune_db::models::WriteReadback {
936 proportional: 60.0,
937 integral: 3.0,
938 derivative: 0.0,
939 });
940 successful_write.proportional_written = Some(66.7);
941 successful_write.integral_written = Some(2.0);
942 successful_write.derivative_written = Some(0.0);
943 successful_write.proportional_readback = Some(66.7);
944 successful_write.integral_readback = Some(2.0);
945 successful_write.derivative_readback = Some(0.0);
946 successful_write.success = true;
947 TuneWriteRow::insert(&state.pool, run_id, successful_write)
948 .await
949 .unwrap();
950
951 let mut failed_write = bhtune_db::models::NewTuneWrite::new(ResponseLevel::Aggressive, now);
955 failed_write.previous = Some(bhtune_db::models::WriteReadback {
956 proportional: 60.0,
957 integral: 3.0,
958 derivative: 0.0,
959 });
960 failed_write.proportional_written = Some(100.0);
961 failed_write.success = false;
962 failed_write.error_message = Some("write rejected: value out of range".to_string());
963 failed_write.rollback_state = Some(RollbackState::Succeeded);
964 TuneWriteRow::insert(&state.pool, run_id, failed_write)
965 .await
966 .unwrap();
967
968 let confirmed_actuation = TuneMvActuationRow::insert_pending(
969 &state.pool,
970 run_id,
971 NewTuneMvActuation {
972 sequence: 0,
973 kind: MvActuationKind::Relay,
974 commanded_at: now,
975 target_mv: 55.0,
976 previous_commanded_mv: Some(50.0),
977 tolerance: 0.5,
978 confirmation_due_at: now + chrono::Duration::seconds(4),
979 },
980 )
981 .await
982 .unwrap();
983 TuneMvActuationRow::record_final_observation(
984 &state.pool,
985 confirmed_actuation.id,
986 now + chrono::Duration::seconds(1),
987 Some(55.0),
988 Some(SampleQuality::Good),
989 MvActuationStatus::Confirmed,
990 None,
991 )
992 .await
993 .unwrap();
994
995 let superseded_actuation = TuneMvActuationRow::insert_pending(
996 &state.pool,
997 run_id,
998 NewTuneMvActuation {
999 sequence: 1,
1000 kind: MvActuationKind::Restore,
1001 commanded_at: now + chrono::Duration::seconds(5),
1002 target_mv: 50.0,
1003 previous_commanded_mv: Some(55.0),
1004 tolerance: 0.5,
1005 confirmation_due_at: now + chrono::Duration::seconds(9),
1006 },
1007 )
1008 .await
1009 .unwrap();
1010 TuneMvActuationRow::finalize(
1011 &state.pool,
1012 superseded_actuation.id,
1013 MvActuationStatus::Superseded,
1014 Some("restore took over confirmation"),
1015 )
1016 .await
1017 .unwrap();
1018
1019 TuneRunRow::complete(&state.pool, run_id, now)
1020 .await
1021 .unwrap();
1022
1023 (run_id, loop_id)
1024 }
1025
1026 #[tokio::test]
1027 async fn list_runs_returns_empty_when_no_runs_exist() {
1028 let app = router().with_state(crate::test_support::in_memory_state().await);
1029 let response = app
1030 .oneshot(Request::get("/api/runs").body(Body::empty()).unwrap())
1031 .await
1032 .unwrap();
1033 assert_eq!(response.status(), StatusCode::OK);
1034 let body = body_json(response).await;
1035 assert_eq!(body["total"], 0);
1036 assert_eq!(body["returned"], 0);
1037 assert!(body["runs"].as_array().unwrap().is_empty());
1038 }
1039
1040 #[tokio::test]
1041 async fn list_runs_returns_a_seeded_run() {
1042 let state = crate::test_support::in_memory_state().await;
1043 let run_id = seed_one_run(&state).await;
1044 let app = router().with_state(state);
1045 let response = app
1046 .oneshot(Request::get("/api/runs").body(Body::empty()).unwrap())
1047 .await
1048 .unwrap();
1049 assert_eq!(response.status(), StatusCode::OK);
1050 let body = body_json(response).await;
1051 assert_eq!(body["total"], 1);
1052 assert_eq!(body["runs"][0]["id"], run_id);
1053 assert_eq!(body["runs"][0]["process_type"], "flow");
1054 }
1055
1056 #[tokio::test]
1057 async fn list_runs_filters_by_outcome() {
1058 let state = crate::test_support::in_memory_state().await;
1059 seed_one_run(&state).await;
1060 let app = router().with_state(state);
1061 let response = app
1062 .oneshot(
1063 Request::get("/api/runs?outcome=completed")
1064 .body(Body::empty())
1065 .unwrap(),
1066 )
1067 .await
1068 .unwrap();
1069 assert_eq!(response.status(), StatusCode::OK);
1070 let body = body_json(response).await;
1071 assert_eq!(body["total"], 0);
1074 }
1075
1076 #[tokio::test]
1077 async fn show_run_returns_full_detail() {
1078 let state = crate::test_support::in_memory_state().await;
1079 let run_id = seed_one_run(&state).await;
1080 let app = router().with_state(state);
1081 let response = app
1082 .oneshot(
1083 Request::get(format!("/api/runs/{run_id}"))
1084 .body(Body::empty())
1085 .unwrap(),
1086 )
1087 .await
1088 .unwrap();
1089 assert_eq!(response.status(), StatusCode::OK);
1090 let body = body_json(response).await;
1091 assert_eq!(body["id"], run_id);
1092 assert_eq!(body["template_name"], "Yokogawa CentumVP");
1093 assert!(body["samples"].as_array().unwrap().is_empty());
1094 assert!(body["results"].as_array().unwrap().is_empty());
1095 assert!(body["writes"].as_array().unwrap().is_empty());
1096 assert!(body["timing_metrics"].is_null());
1097 assert_eq!(body["pid_constant_tags"]["proportional"], "Loop1.P");
1100 assert_eq!(body["pid_constant_tags"]["integral"], "Loop1.I");
1101 assert_eq!(body["pid_constant_tags"]["derivative"], "Loop1.D");
1102 assert_eq!(body["pid_parameter_labels"]["proportional"], "P");
1103 assert_eq!(body["pid_parameter_labels"]["integral"], "I");
1104 assert_eq!(body["pid_parameter_labels"]["derivative"], "D");
1105 assert!(body["original_request"].is_null());
1110 }
1111
1112 #[test]
1113 fn pid_parameter_labels_fall_back_for_blank_template_suffixes() {
1114 let mut template = bhtune_core::built_in_templates().remove(0);
1115 template.proportional_constant_suffix.clear();
1116 template.integral_constant_suffix.clear();
1117 template.derivative_constant_suffix.clear();
1118
1119 let labels = PidParameterLabelsResponse::from(&template);
1120
1121 assert_eq!(labels.proportional, "P");
1122 assert_eq!(labels.integral, "I");
1123 assert_eq!(labels.derivative, "D");
1124 }
1125
1126 #[tokio::test]
1131 async fn show_run_reports_no_pid_constant_tags_as_a_null_pid_constant_tags_field() {
1132 let state = crate::test_support::in_memory_state().await;
1133 let template_row =
1134 bhtune_db::models::DcsTemplateRow::get_by_name(&state.pool, "Yokogawa CentumVP")
1135 .await
1136 .unwrap()
1137 .unwrap();
1138 let template = template_row.template;
1139 let config = LoopConfig {
1140 process_type: ProcessType::Flow,
1141 controller_type: ControllerType::Pi,
1142 relay_amp_percent: 5.0,
1143 num_cycles_skip: 1,
1144 num_cycles_count: 3,
1145 noise_protection_secs: 0,
1146 mrft_delay_secs: 0,
1147 };
1148 let mut tags = bhtune_core::LoopTags::derive_from_pv_tag("Loop3.PV", &template);
1149 tags.proportional_constant = None;
1150 tags.integral_constant = None;
1151 tags.derivative_constant = None;
1152 let run = TuneRunRow::start(
1153 &state.pool,
1154 None,
1155 "Loop3",
1156 TuneDriver::Simulator,
1157 config,
1158 template_row.origin,
1159 &template,
1160 &tags,
1161 Utc::now(),
1162 )
1163 .await
1164 .unwrap();
1165 let app = router().with_state(state);
1166 let response = app
1167 .oneshot(
1168 Request::get(format!("/api/runs/{}", run.id))
1169 .body(Body::empty())
1170 .unwrap(),
1171 )
1172 .await
1173 .unwrap();
1174 assert_eq!(response.status(), StatusCode::OK);
1175 let body = body_json(response).await;
1176 assert!(body["pid_constant_tags"].is_null());
1177 }
1178
1179 #[tokio::test]
1180 async fn show_run_returns_full_detail_with_samples_results_and_writes() {
1181 let state = crate::test_support::in_memory_state().await;
1182 let (run_id, _loop_id) = seed_full_run(&state).await;
1183 let app = router().with_state(state);
1184 let response = app
1185 .oneshot(
1186 Request::get(format!("/api/runs/{run_id}"))
1187 .body(Body::empty())
1188 .unwrap(),
1189 )
1190 .await
1191 .unwrap();
1192 assert_eq!(response.status(), StatusCode::OK);
1193 let body = body_json(response).await;
1194
1195 assert_eq!(body["id"], run_id);
1196 assert_eq!(body["outcome"], "completed");
1197 assert!(body["completed_at"].is_string());
1198 assert!(body["opc_server"].is_null());
1202 assert!(body["bridge_host"].is_null());
1203 assert_eq!(body["pid_constant_tags"]["proportional"], "Loop2.P");
1204 assert_eq!(body["pid_constant_tags"]["integral"], "Loop2.I");
1205 assert_eq!(body["pid_constant_tags"]["derivative"], "Loop2.D");
1206
1207 let initial_readings = &body["initial_readings"];
1208 assert_eq!(initial_readings["pv_ini"], 50.0);
1209 assert_eq!(initial_readings["mv_ini"], 45.0);
1210 assert_eq!(initial_readings["controller_direction"], "direct");
1211 assert_eq!(initial_readings["mode_raw"], "1");
1212 assert_eq!(initial_readings["mode_attribute_raw"], "2");
1213 assert_eq!(initial_readings["setpoint_ini"], 50.0);
1214
1215 let timing = &body["timing_metrics"];
1216 assert_eq!(timing["basis"], "simulated_fixed_step");
1217 assert_eq!(timing["requested_interval_ms"], 5);
1218 assert_eq!(timing["sample_gap_count"], 9);
1219 assert_eq!(timing["mean_sample_gap_ms"], 5.0);
1220 assert_eq!(timing["max_sample_gap_ms"], 5.0);
1221 assert_eq!(timing["missed_poll_opportunity_count"], 0);
1222 assert_eq!(timing["measured_oscillation_period_ms"], 50.0);
1223 assert_eq!(timing["approximate_samples_per_period"], 10.0);
1224 assert_eq!(timing["sampling_adequacy"], "adequate");
1225 assert_eq!(timing["poll_latency"]["pv_read"]["count"], 10);
1226 assert_eq!(timing["poll_latency"]["pv_read"]["mean_ms"], 1.25);
1227 assert_eq!(timing["poll_latency"]["pv_read"]["max_ms"], 2.5);
1228 assert_eq!(timing["poll_latency"]["mv_write"]["count"], 3);
1229 assert_eq!(timing["poll_latency"]["mv_verification"]["count"], 3);
1230 assert_eq!(timing["poll_latency"]["sample_persist"]["count"], 10);
1231 assert_eq!(timing["poll_latency"]["tick_work"]["count"], 10);
1232
1233 let samples = body["samples"].as_array().unwrap();
1234 assert_eq!(samples.len(), 1);
1235 assert_eq!(samples[0]["tick_index"], 0);
1236 assert_eq!(samples[0]["sample"]["pv"], 50.0);
1237 assert_eq!(samples[0]["state"]["mv_value_current"], 45.0);
1238 assert_eq!(samples[0]["pv_quality"], "good");
1239
1240 let results = body["results"].as_array().unwrap();
1241 assert_eq!(results.len(), 1);
1242 assert_eq!(results[0]["response_level"], "moderate");
1243 assert_eq!(results[0]["kp"], 1.5);
1244 assert_eq!(results[0]["proportional"], 66.7);
1245
1246 let writes = body["writes"].as_array().unwrap();
1247 assert_eq!(writes.len(), 2);
1248 let successful = writes
1249 .iter()
1250 .find(|w| w["response_level"] == "moderate")
1251 .unwrap();
1252 assert_eq!(successful["success"], true);
1253 assert_eq!(successful["proportional_previous"], 60.0);
1254 assert_eq!(successful["proportional_readback"], 66.7);
1255 assert!(successful["rollback_state"].is_null());
1256 assert!(successful["error_message"].is_null());
1257
1258 let failed = writes
1259 .iter()
1260 .find(|w| w["response_level"] == "aggressive")
1261 .unwrap();
1262 assert_eq!(failed["success"], false);
1263 assert_eq!(failed["proportional_written"], 100.0);
1264 assert!(failed["proportional_readback"].is_null());
1265 assert_eq!(failed["rollback_state"], "succeeded");
1266 assert_eq!(
1267 failed["error_message"],
1268 "write rejected: value out of range"
1269 );
1270
1271 let actuations = body["mv_actuations"].as_array().unwrap();
1272 assert_eq!(actuations.len(), 2);
1273 let confirmed = actuations
1274 .iter()
1275 .find(|actuation| actuation["kind"] == "relay")
1276 .unwrap();
1277 assert_eq!(confirmed["sequence"], 0);
1278 assert_eq!(confirmed["target_mv"], 55.0);
1279 assert_eq!(confirmed["previous_commanded_mv"], 50.0);
1280 assert_eq!(confirmed["tolerance"], 0.5);
1281 assert!(confirmed["commanded_at"].is_string());
1282 assert!(confirmed["confirmation_due_at"].is_string());
1283 assert!(confirmed["last_checked_at"].is_string());
1284 assert_eq!(confirmed["readback_mv"], 55.0);
1285 assert_eq!(confirmed["readback_quality"], "good");
1286 assert_eq!(confirmed["attempt_count"], 1);
1287 assert_eq!(confirmed["status"], "confirmed");
1288 assert!(confirmed["detail"].is_null());
1289
1290 let superseded = actuations
1291 .iter()
1292 .find(|actuation| actuation["kind"] == "restore")
1293 .unwrap();
1294 assert_eq!(superseded["sequence"], 1);
1295 assert_eq!(superseded["target_mv"], 50.0);
1296 assert_eq!(superseded["previous_commanded_mv"], 55.0);
1297 assert!(superseded["last_checked_at"].is_null());
1298 assert!(superseded["readback_mv"].is_null());
1299 assert!(superseded["readback_quality"].is_null());
1300 assert_eq!(superseded["attempt_count"], 0);
1301 assert_eq!(superseded["status"], "superseded");
1302 assert_eq!(superseded["detail"], "restore took over confirmation");
1303 }
1304
1305 #[tokio::test]
1306 async fn list_runs_filters_by_every_supported_query_parameter_simultaneously() {
1307 let state = crate::test_support::in_memory_state().await;
1308 let (run_id, loop_id) = seed_full_run(&state).await;
1309 seed_one_run(&state).await;
1312 let app = router().with_state(state);
1313
1314 let started_after = (Utc::now() - chrono::Duration::hours(1))
1315 .to_rfc3339()
1316 .replace('+', "%2B");
1317 let started_before = (Utc::now() + chrono::Duration::hours(1))
1318 .to_rfc3339()
1319 .replace('+', "%2B");
1320 let uri = format!(
1321 "/api/runs?loop_id={loop_id}&process_type=flow&controller_type=pi\
1322 &outcome=completed&driver=simulator&started_after={started_after}\
1323 &started_before={started_before}&template_name=Yokogawa+CentumVP\
1324 &template_origin=builtin"
1325 );
1326
1327 let response = app
1328 .oneshot(Request::get(uri).body(Body::empty()).unwrap())
1329 .await
1330 .unwrap();
1331 assert_eq!(response.status(), StatusCode::OK);
1332 let body = body_json(response).await;
1333 assert_eq!(body["total"], 1);
1334 assert_eq!(body["runs"][0]["id"], run_id);
1335 }
1336
1337 #[tokio::test]
1338 async fn list_runs_filters_by_opc_server_and_bridge_host() {
1339 let state = crate::test_support::in_memory_state().await;
1340 let (run_id, _loop_id) = seed_full_run(&state).await;
1342 TuneRunRow::record_connection(
1343 &state.pool,
1344 run_id,
1345 Some("Kepware.KEPServerEX.V6"),
1346 Some("gateway-a:7600"),
1347 "{}",
1348 )
1349 .await
1350 .unwrap();
1351 seed_one_run(&state).await;
1354 let app = router().with_state(state);
1355
1356 let response = app
1357 .clone()
1358 .oneshot(
1359 Request::get(
1360 "/api/runs?opc_server=Kepware.KEPServerEX.V6&bridge_host=gateway-a:7600",
1361 )
1362 .body(Body::empty())
1363 .unwrap(),
1364 )
1365 .await
1366 .unwrap();
1367 assert_eq!(response.status(), StatusCode::OK);
1368 let body = body_json(response).await;
1369 assert_eq!(body["total"], 1);
1370 assert_eq!(body["runs"][0]["id"], run_id);
1371
1372 let detail_response = app
1374 .oneshot(
1375 Request::get(format!("/api/runs/{run_id}"))
1376 .body(Body::empty())
1377 .unwrap(),
1378 )
1379 .await
1380 .unwrap();
1381 assert_eq!(detail_response.status(), StatusCode::OK);
1382 let detail_body = body_json(detail_response).await;
1383 assert_eq!(detail_body["opc_server"], "Kepware.KEPServerEX.V6");
1384 assert_eq!(detail_body["bridge_host"], "gateway-a:7600");
1385 }
1386
1387 fn fast_simulator_request_json_string() -> String {
1394 let value = serde_json::json!({
1395 "tagname": "ignored-for-simulator",
1396 "template": "Yokogawa CentumVP",
1397 "process_type": "flow",
1398 "controller_type": "pi",
1399 "relay_amp": 10.0,
1400 "cycles_skip": 1,
1401 "cycles_count": 2,
1402 "noise_protection_secs": 0,
1403 "driver": "simulator",
1404 "sim_gain": 1.0,
1405 "sim_tau": 0.01,
1406 "sim_dead_time": 0.025,
1407 "pv_range_high": 100.0,
1408 "pv_range_low": 0.0,
1409 "mv_range_high": 100.0,
1410 "mv_range_low": 0.0,
1411 "direction": "reverse",
1412 "poll_interval_ms": 5,
1413 "notes": "http-test-loop",
1414 });
1415 let request: StartRunRequest = serde_json::from_value(value).unwrap();
1416 serde_json::to_string(&request).unwrap()
1417 }
1418
1419 #[tokio::test]
1420 async fn last_request_returns_null_when_the_newest_runs_request_json_does_not_parse() {
1421 let state = crate::test_support::in_memory_state().await;
1426 seed_one_run(&state).await;
1427 let app = router().with_state(state);
1428 let response = app
1429 .oneshot(
1430 Request::get("/api/runs/last-request")
1431 .body(Body::empty())
1432 .unwrap(),
1433 )
1434 .await
1435 .unwrap();
1436 assert_eq!(response.status(), StatusCode::OK);
1437 assert!(body_json(response).await.is_null());
1438 }
1439
1440 #[tokio::test]
1445 async fn show_run_returns_the_runs_own_original_request() {
1446 let state = crate::test_support::in_memory_state().await;
1447 let run_id = seed_one_run(&state).await;
1448 let request_json = fast_simulator_request_json_string();
1449 TuneRunRow::record_connection(&state.pool, run_id, None, None, &request_json)
1450 .await
1451 .unwrap();
1452 seed_one_run(&state).await;
1455
1456 let app = router().with_state(state);
1457 let response = app
1458 .oneshot(
1459 Request::get(format!("/api/runs/{run_id}"))
1460 .body(Body::empty())
1461 .unwrap(),
1462 )
1463 .await
1464 .unwrap();
1465 assert_eq!(response.status(), StatusCode::OK);
1466 let body = body_json(response).await;
1467 assert_eq!(body["original_request"]["tagname"], "ignored-for-simulator");
1468 assert_eq!(body["original_request"]["driver"], "simulator");
1469 assert_eq!(body["original_request"]["notes"], "http-test-loop");
1470 }
1471
1472 #[tokio::test]
1473 async fn last_request_returns_null_when_no_runs_exist() {
1474 let app = router().with_state(crate::test_support::in_memory_state().await);
1475 let response = app
1476 .oneshot(
1477 Request::get("/api/runs/last-request")
1478 .body(Body::empty())
1479 .unwrap(),
1480 )
1481 .await
1482 .unwrap();
1483 assert_eq!(response.status(), StatusCode::OK);
1484 assert!(body_json(response).await.is_null());
1485 }
1486
1487 #[tokio::test]
1488 async fn last_request_returns_the_newest_runs_request_not_the_first() {
1489 let state = crate::test_support::in_memory_state().await;
1490 let older_run_id = seed_one_run(&state).await;
1491 TuneRunRow::record_connection(&state.pool, older_run_id, None, None, "{}")
1492 .await
1493 .unwrap();
1494
1495 let template_row =
1499 bhtune_db::models::DcsTemplateRow::get_by_name(&state.pool, "Yokogawa CentumVP")
1500 .await
1501 .unwrap()
1502 .unwrap();
1503 let template = template_row.template;
1504 let config = LoopConfig {
1505 process_type: ProcessType::Flow,
1506 controller_type: ControllerType::Pi,
1507 relay_amp_percent: 10.0,
1508 num_cycles_skip: 1,
1509 num_cycles_count: 2,
1510 noise_protection_secs: 0,
1511 mrft_delay_secs: 0,
1512 };
1513 let tags = bhtune_core::LoopTags::derive_from_pv_tag("Sim.PV", &template);
1514 let newer_run = TuneRunRow::start(
1515 &state.pool,
1516 None,
1517 "http-test-loop",
1518 TuneDriver::Simulator,
1519 config,
1520 template_row.origin,
1521 &template,
1522 &tags,
1523 Utc::now() + chrono::Duration::seconds(1),
1524 )
1525 .await
1526 .unwrap();
1527 let request_json = fast_simulator_request_json_string();
1528 TuneRunRow::record_connection(&state.pool, newer_run.id, None, None, &request_json)
1529 .await
1530 .unwrap();
1531
1532 let app = router().with_state(state);
1533 let response = app
1534 .oneshot(
1535 Request::get("/api/runs/last-request")
1536 .body(Body::empty())
1537 .unwrap(),
1538 )
1539 .await
1540 .unwrap();
1541 assert_eq!(response.status(), StatusCode::OK);
1542 let body = body_json(response).await;
1543 assert_eq!(body["tagname"], "ignored-for-simulator");
1544 assert_eq!(body["template"], "Yokogawa CentumVP");
1545 assert_eq!(body["process_type"], "flow");
1546 assert_eq!(body["controller_type"], "pi");
1547 assert_eq!(body["driver"], "simulator");
1548 assert_eq!(body["notes"], "http-test-loop");
1549 assert_eq!(body["relay_amp"], 10.0);
1550 }
1551
1552 #[tokio::test]
1553 async fn show_run_404s_for_an_unknown_id() {
1554 let app = router().with_state(crate::test_support::in_memory_state().await);
1555 let response = app
1556 .oneshot(
1557 Request::get("/api/runs/999999")
1558 .body(Body::empty())
1559 .unwrap(),
1560 )
1561 .await
1562 .unwrap();
1563 assert_eq!(response.status(), StatusCode::NOT_FOUND);
1564 }
1565
1566 #[tokio::test]
1567 async fn show_and_delete_propagate_database_failures_as_500() {
1568 let state = crate::test_support::in_memory_state().await;
1569 let app = router().with_state(state.clone());
1570 state.pool.close().await;
1571
1572 let show = app
1573 .clone()
1574 .oneshot(Request::get("/api/runs/1").body(Body::empty()).unwrap())
1575 .await
1576 .unwrap();
1577 assert_eq!(show.status(), StatusCode::INTERNAL_SERVER_ERROR);
1578
1579 let delete = app
1580 .oneshot(Request::delete("/api/runs/1").body(Body::empty()).unwrap())
1581 .await
1582 .unwrap();
1583 assert_eq!(delete.status(), StatusCode::INTERNAL_SERVER_ERROR);
1584 }
1585
1586 #[tokio::test]
1587 async fn export_run_defaults_to_csv_with_the_expected_headers_and_body() {
1588 let state = crate::test_support::in_memory_state().await;
1589 let (run_id, _loop_id) = seed_full_run(&state).await;
1590 let app = router().with_state(state);
1591
1592 let response = app
1593 .oneshot(
1594 Request::get(format!("/api/runs/{run_id}/export"))
1595 .body(Body::empty())
1596 .unwrap(),
1597 )
1598 .await
1599 .unwrap();
1600 assert_eq!(response.status(), StatusCode::OK);
1601 assert_eq!(
1602 response.headers().get(header::CONTENT_TYPE).unwrap(),
1603 "text/csv"
1604 );
1605 assert_eq!(
1606 response.headers().get(header::CONTENT_DISPOSITION).unwrap(),
1607 &format!("attachment; filename=\"run-{run_id}.csv\"")
1608 );
1609 let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1610 let text = String::from_utf8(bytes.to_vec()).unwrap();
1611 let mut lines = text.lines();
1612 assert_eq!(
1613 lines.next().unwrap(),
1614 "tick,time,pv,pv_quality,hysteresis,mv_value_current,mv_sign_next_step,counter_all_switches,cycles_completed,cycles_remaining"
1615 );
1616 assert!(lines.next().unwrap().starts_with("0,"));
1617 }
1618
1619 #[tokio::test]
1620 async fn export_run_supports_the_json_format() {
1621 let state = crate::test_support::in_memory_state().await;
1622 let (run_id, _loop_id) = seed_full_run(&state).await;
1623 let app = router().with_state(state);
1624
1625 let response = app
1626 .oneshot(
1627 Request::get(format!("/api/runs/{run_id}/export?format=json"))
1628 .body(Body::empty())
1629 .unwrap(),
1630 )
1631 .await
1632 .unwrap();
1633 assert_eq!(response.status(), StatusCode::OK);
1634 assert_eq!(
1635 response.headers().get(header::CONTENT_TYPE).unwrap(),
1636 "application/json"
1637 );
1638 assert_eq!(
1639 response.headers().get(header::CONTENT_DISPOSITION).unwrap(),
1640 &format!("attachment; filename=\"run-{run_id}.json\"")
1641 );
1642 let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1643 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1644 assert_eq!(parsed[0]["tick"], 0);
1645 assert_eq!(parsed[0]["pv"], 50.0);
1646 }
1647
1648 #[tokio::test]
1649 async fn export_run_404s_for_an_unknown_id() {
1650 let app = router().with_state(crate::test_support::in_memory_state().await);
1651 let response = app
1652 .oneshot(
1653 Request::get("/api/runs/999999/export")
1654 .body(Body::empty())
1655 .unwrap(),
1656 )
1657 .await
1658 .unwrap();
1659 assert_eq!(response.status(), StatusCode::NOT_FOUND);
1660 }
1661
1662 #[tokio::test]
1663 async fn export_run_404s_for_a_run_with_no_recorded_samples() {
1664 let state = crate::test_support::in_memory_state().await;
1665 let run_id = seed_one_run(&state).await;
1666 let app = router().with_state(state);
1667
1668 let response = app
1669 .oneshot(
1670 Request::get(format!("/api/runs/{run_id}/export"))
1671 .body(Body::empty())
1672 .unwrap(),
1673 )
1674 .await
1675 .unwrap();
1676 assert_eq!(response.status(), StatusCode::NOT_FOUND);
1677 }
1678
1679 #[tokio::test]
1680 async fn delete_run_removes_the_run_and_returns_204() {
1681 let state = crate::test_support::in_memory_state().await;
1682 let run_id = seed_one_run(&state).await;
1683 TuneRunRow::complete(&state.pool, run_id, Utc::now())
1686 .await
1687 .unwrap();
1688 let pool = state.pool.clone();
1689 let app = router().with_state(state);
1690
1691 let response = app
1692 .clone()
1693 .oneshot(
1694 Request::delete(format!("/api/runs/{run_id}"))
1695 .body(Body::empty())
1696 .unwrap(),
1697 )
1698 .await
1699 .unwrap();
1700 assert_eq!(response.status(), StatusCode::NO_CONTENT);
1701 assert!(TuneRunRow::get(&pool, run_id).await.unwrap().is_none());
1702
1703 let follow_up = app
1706 .oneshot(
1707 Request::get(format!("/api/runs/{run_id}"))
1708 .body(Body::empty())
1709 .unwrap(),
1710 )
1711 .await
1712 .unwrap();
1713 assert_eq!(follow_up.status(), StatusCode::NOT_FOUND);
1714 }
1715
1716 #[tokio::test]
1717 async fn delete_run_404s_for_an_unknown_id() {
1718 let app = router().with_state(crate::test_support::in_memory_state().await);
1719 let response = app
1720 .oneshot(
1721 Request::delete("/api/runs/999999")
1722 .body(Body::empty())
1723 .unwrap(),
1724 )
1725 .await
1726 .unwrap();
1727 assert_eq!(response.status(), StatusCode::NOT_FOUND);
1728 }
1729
1730 #[tokio::test]
1731 async fn delete_run_409s_when_the_run_has_not_finished_yet() {
1732 let state = crate::test_support::in_memory_state().await;
1733 let run_id = seed_one_run(&state).await;
1737 let pool = state.pool.clone();
1738 let app = router().with_state(state);
1739
1740 let response = app
1741 .oneshot(
1742 Request::delete(format!("/api/runs/{run_id}"))
1743 .body(Body::empty())
1744 .unwrap(),
1745 )
1746 .await
1747 .unwrap();
1748 assert_eq!(response.status(), StatusCode::CONFLICT);
1749 assert!(TuneRunRow::get(&pool, run_id).await.unwrap().is_some());
1751 }
1752
1753 #[tokio::test]
1754 async fn delete_run_succeeds_for_a_completed_run_even_if_active_run_has_not_released_it_yet() {
1755 let state = crate::test_support::in_memory_state().await;
1762 let run_id = seed_one_run(&state).await;
1763 TuneRunRow::complete(&state.pool, run_id, Utc::now())
1764 .await
1765 .unwrap();
1766 let (_ctrl_c, handle) = bhtune_cli::cancel::CtrlC::manual();
1767 state
1768 .active_run
1769 .start(run_id, handle, std::future::pending())
1770 .await
1771 .unwrap();
1772 let pool = state.pool.clone();
1773 let app = router().with_state(state);
1774
1775 let response = app
1776 .oneshot(
1777 Request::delete(format!("/api/runs/{run_id}"))
1778 .body(Body::empty())
1779 .unwrap(),
1780 )
1781 .await
1782 .unwrap();
1783 assert_eq!(response.status(), StatusCode::NO_CONTENT);
1784 assert!(TuneRunRow::get(&pool, run_id).await.unwrap().is_none());
1785 }
1786
1787 #[tokio::test]
1788 async fn delete_run_returns_404_when_the_row_vanishes_after_lookup() {
1789 let state = crate::test_support::in_memory_state().await;
1790 let run_id = seed_one_run(&state).await;
1791 TuneRunRow::complete(&state.pool, run_id, Utc::now())
1792 .await
1793 .unwrap();
1794
1795 let result = delete_run_with_hook(state.clone(), run_id, |state| {
1796 let pool = state.pool.clone();
1797 async move {
1798 assert!(TuneRunRow::delete(&pool, run_id).await.unwrap());
1799 }
1800 })
1801 .await;
1802
1803 assert!(
1804 matches!(result, Err(ApiError::NotFound(message)) if message.contains(&run_id.to_string()))
1805 );
1806 }
1807}