aquarium_control/database/
sql_interface_schedule.rs

1/* Copyright 2024 Uwe Martin
2
3Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
5The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
7THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8*/
9
10//! Manages time-based scheduling for various application modules.
11//!
12//! This module provides a dedicated interface, `SqlInterfaceSchedule`, for reading and
13//! interpreting time-based restrictions from the `schedule` table in the database.
14//! Its primary purpose is to allow users to inhibit certain activities that may cause
15//! noise or other disturbances (e.g., pumps for Balling or Refill) during specific
16//! times, such as at night.
17//!
18//! ## Key Components
19//!
20//! - **`SqlInterfaceSchedule`**: The main struct that holds a database connection,
21//!   performs validation, and provides methods to read and query schedule data.
22//!
23//! - **`ScheduleEntry`**: A processed, type-safe representation of a single schedule
24//!   rule. It converts raw string data from the database into strongly typed
25//!   `ScheduleType` and `chrono::NaiveTime` fields.
26//!
27//! - **`ScheduleType`**: An enum that identifies the specific application module
28//!   (e.g., `Balling`, `Refill`, `Heating`) that a schedule rule applies to.
29//!
30//! ## Design and Purpose
31//!
32//! The module is designed to be a robust and centralized point for all scheduling logic.
33//!
34//! - **Fail-Fast Validation**: The `new()` constructor performs several "fail-fast"
35//!   checks at startup. It verifies that the `schedule` table contains no `NULL`
36//!   values and that the number of entries does not exceed a configured limit. This
37//!   prevents the application from starting in an invalid state.
38//!
39//! - **Data Processing and Caching**: The `read_schedule()` method fetches all rules
40//!   from the database, parses them into `ScheduleEntry` objects, and stores them in
41//!   an internal cache (`Vec`). This is efficient as the database is only queried once.
42//!
43//! - **Robust Error Handling**: During processing, `read_schedule()` does not fail on
44//!   the first malformed entry. Instead, it collects all parsing errors (e.g., for
45//!   an invalid time string) and returns them in a `Vec`, allowing for comprehensive
46//!   diagnostics of database integrity issues.
47//!
48//! - **Time Window Logic**: The `ScheduleEntry::check_if_allowed()` method provides a
49//!   simple way to determine if the current system time falls within the active
50//!   window of a given schedule.
51
52use chrono::*;
53use mysql::prelude::*;
54use mysql::*;
55use std::fmt;
56use std::str::FromStr;
57
58use crate::database::sql_interface::SqlInterface;
59use crate::database::sql_query_strings::{
60    SQL_QUERY_CHECK_SCHEDULE_COUNT, SQL_QUERY_CHECK_SCHEDULE_NULL,
61};
62use crate::database::{sql_interface_error::SqlInterfaceError, sql_query_strings};
63
64/// List of all modules that are subject to limitations by schedule.
65/// Background: These activities cause noise - the user shall be able to inhibit these noises during nighttime.
66#[derive(PartialEq, Debug)]
67pub enum ScheduleType {
68    Balling,
69    Refill,
70    Ventilation,
71    Heating,
72}
73
74impl FromStr for ScheduleType {
75    type Err = ();
76
77    /// Converts a string slice into a `ScheduleType` enum variant.
78    ///
79    /// This function attempts to match the input string to one of the predefined
80    /// `ScheduleType` variants (e.g., "Balling", "Refill"). The comparison is case-sensitive.
81    ///
82    /// # Arguments
83    /// * `input` - The string slice to be converted.
84    ///
85    /// # Returns
86    /// A `Result` containing the corresponding `ScheduleType` variant on a successful match.
87    ///
88    /// # Errors
89    /// Returns an empty `Err(())` if the input string does not match any of the known
90    /// `ScheduleType` variants.
91    fn from_str(input: &str) -> Result<ScheduleType, Self::Err> {
92        match input {
93            "Balling" => Ok(ScheduleType::Balling),
94            "Refill" => Ok(ScheduleType::Refill),
95            "Ventilation" => Ok(ScheduleType::Ventilation),
96            "Heating" => Ok(ScheduleType::Heating),
97            _ => Err(()),
98        }
99    }
100}
101
102impl fmt::Display for ScheduleType {
103    /// Formats the `ScheduleType` enum into a human-readable string representation.
104    ///
105    /// This implementation allows `ScheduleType` variants to be displayed directly
106    /// using `println!` or `format!`, returning the string name of the variant.
107    ///
108    /// # Arguments
109    /// * `f` - A mutable reference to the formatter.
110    ///
111    /// # Returns
112    /// A `fmt::Result` indicating whether the formatting was successful.
113    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
114        match self {
115            ScheduleType::Balling => write!(f, "Balling"),
116            ScheduleType::Refill => write!(f, "Refill"),
117            ScheduleType::Ventilation => write!(f, "Ventilation"),
118            ScheduleType::Heating => write!(f, "Heating"),
119        }
120    }
121}
122
123/// Contains one schedule data set as read from the database
124pub struct SqlScheduleEntry {
125    /// Schedule type (either Balling, Refill, Ventilation or Heating)
126    schedule_type: String,
127
128    /// Time of day from which on the control shall be active
129    start_time: NaiveTime,
130
131    /// Time of day from which on the control shall not be active
132    stop_time: NaiveTime,
133
134    /// Flag describing if the schedule entry is active
135    is_active: u32,
136}
137
138/// Contains one schedule data set after post-processing
139#[derive(Debug)]
140pub struct ScheduleEntry {
141    /// Schedule type (either Balling, Refill, Ventilation or Heating)
142    schedule_type: ScheduleType,
143
144    /// Time of day from which on the control shall be active
145    start_time: NaiveTime,
146
147    /// Time of day from which on the control shall not be active
148    stop_time: NaiveTime,
149
150    /// Flag describing if the schedule entry is active
151    #[allow(unused)]
152    is_active: bool,
153}
154
155impl ScheduleEntry {
156    /// Creates a new `ScheduleEntry` by converting raw data from an `SqlScheduleEntry`.
157    ///
158    /// This constructor takes a `SqlScheduleEntry` (typically read from the database)
159    /// and performs necessary type conversions, such as transforming string representations
160    /// of the schedule type and times into their respective Rust types.
161    ///
162    /// # Arguments
163    /// * `sql_schedule_entry` - A struct containing the raw schedule data from the SQL database.
164    ///
165    /// # Returns
166    /// A `Result` containing a new, fully typed `ScheduleEntry` on success.
167    ///
168    /// # Errors
169    /// This function will return an `Err` variant of `SqlInterfaceError` if:
170    /// - The `schedule_type` string cannot be parsed into a valid `ScheduleType` enum
171    ///   (`ScheduleTypeConversionFailure`).
172    pub fn new(sql_schedule_entry: SqlScheduleEntry) -> Result<ScheduleEntry, SqlInterfaceError> {
173        let schedule_type =
174            ScheduleType::from_str(&sql_schedule_entry.schedule_type).map_err(|_| {
175                SqlInterfaceError::ScheduleTypeConversionFailure(
176                    module_path!().to_string(),
177                    sql_schedule_entry.schedule_type,
178                )
179            })?;
180
181        let is_active = sql_schedule_entry.is_active > 0;
182
183        Ok(Self {
184            schedule_type,
185            start_time: sql_schedule_entry.start_time,
186            stop_time: sql_schedule_entry.stop_time,
187            is_active,
188        })
189    }
190
191    /// Determines if the current local time falls within the allowed time window of this schedule entry.
192    ///
193    /// This function compares the system's current local time (`Local::now().time()`)
194    /// against the `start_time` and `stop_time` defined in this `ScheduleEntry`.
195    ///
196    /// # Returns
197    /// - `true`: If the current local time is on or after `start_time` AND on or before `stop_time`.
198    /// - `false`: Given an active schedule limitation: If the current local time is outside this inclusive window.
199    pub fn check_if_allowed(&self) -> bool {
200        let current_time = Local::now().time();
201        if self.is_active {
202            // allow operation only if time is within the permissible interval
203            (current_time >= self.start_time) && (current_time <= self.stop_time)
204        } else {
205            // allow operation if schedule limitation is not active
206            true
207        }
208    }
209
210    #[cfg(test)]
211    pub fn new_for_test(
212        schedule_type: ScheduleType,
213        start_time: NaiveTime,
214        stop_time: NaiveTime,
215        is_active: bool,
216    ) -> ScheduleEntry {
217        Self {
218            schedule_type,
219            start_time,
220            stop_time,
221            is_active,
222        }
223    }
224
225    #[cfg(test)]
226    pub fn deactivate(&mut self) {
227        self.is_active = false;
228    }
229}
230
231/// Contains the configuration and the implementation of the SQL interface for Schedule.
232#[derive(Debug)]
233pub struct SqlInterfaceSchedule {
234    /// Connection to the database
235    pub conn: PooledConn,
236
237    /// Result of the post-processed SQL query    
238    schedules: Vec<ScheduleEntry>,
239}
240
241impl SqlInterfaceSchedule {
242    /// Creates a new `SqlInterfaceSchedule` instance.
243    ///
244    /// This constructor initializes the interface with a database connection. It performs
245    /// several pre-flight checks to ensure data integrity, such as verifying that the
246    /// `schedule` table contains no NULL values and that its row count is within the
247    /// configured limit.
248    ///
249    /// # Arguments
250    /// * `conn` - An active, pooled database connection.
251    /// * `max_rows_schedule` - The maximum allowed number of rows in the `schedule` table.
252    ///
253    /// # Returns
254    /// A `Result` containing a new `SqlInterfaceSchedule` instance on success.
255    ///
256    /// # Errors
257    /// This function will return an error if:
258    /// - Any of the initial database queries to get table counts fail (`DatabaseCheckScheduleFailure`).
259    /// - Any of the retrieved counts are negative, indicating a database issue (`DatabaseScheduleTableNegativeValue`).
260    /// - The `schedule` table contains entries with `NULL` values (`DatabaseScheduleTableContainsNull`).
261    /// - The number of rows in the `schedule` table exceeds `max_rows_schedule` (`DatabaseScheduleTableContainsTooManyRows`).
262    pub fn new(
263        mut conn: PooledConn,
264        max_rows_schedule: u64,
265    ) -> Result<SqlInterfaceSchedule, SqlInterfaceError> {
266        let count_null_values = SqlInterface::get_single_integer_from_database(
267            &mut conn,
268            SQL_QUERY_CHECK_SCHEDULE_NULL,
269        )
270        .map_err(|e| SqlInterfaceError::DatabaseCheckScheduleFailure {
271            location: module_path!().to_string(),
272            source: Box::new(e),
273        })?;
274        let count_rows = SqlInterface::get_single_integer_from_database(
275            &mut conn,
276            SQL_QUERY_CHECK_SCHEDULE_COUNT,
277        )
278        .map_err(|e| SqlInterfaceError::DatabaseCheckScheduleFailure {
279            location: module_path!().to_string(),
280            source: Box::new(e),
281        })?;
282
283        // check the query results
284        if count_null_values < 0 || count_rows < 0 {
285            return Err(SqlInterfaceError::DatabaseScheduleTableNegativeValue(
286                module_path!().to_string(),
287                count_null_values,
288                count_rows,
289            ));
290        }
291
292        if count_null_values > 0 {
293            return Err(SqlInterfaceError::DatabaseScheduleTableContainsNull(
294                module_path!().to_string(),
295                count_null_values,
296            ));
297        }
298
299        if max_rows_schedule > 0 {
300            // execute the check only when the limit is greater than zero
301            if count_rows > max_rows_schedule.cast_signed() {
302                return Err(SqlInterfaceError::DatabaseScheduleTableContainsTooManyRows(
303                    module_path!().to_string(),
304                    count_rows.cast_unsigned(),
305                    max_rows_schedule,
306                ));
307            }
308        }
309
310        Ok(Self {
311            conn,
312            schedules: Vec::new(),
313        })
314    }
315
316    /// Reads all schedule entries from the SQL database, processing and storing them internally.
317    ///
318    /// This function queries the database for all available schedule configurations.
319    /// It then attempts to convert each raw database entry into a `ScheduleEntry` struct.
320    /// The internal `schedules` vector is cleared and then populated with only the
321    /// successfully processed entries.
322    ///
323    /// # Returns
324    /// - `Ok(())`: If the database query was successful and all retrieved entries were processed without errors.
325    ///
326    /// # Errors
327    /// Returns a `Vec<SqlInterfaceError>` which will contain one or more errors if:
328    /// - The initial database query fails (`ScheduleRequestFailure`).
329    /// - One or more raw schedule entries fail to be converted into a `ScheduleEntry`
330    ///   (`ScheduleProcessingFailure`). This can happen due to invalid data formats
331    ///   (e.g., a malformed time string) in the database.
332    pub fn read_schedule(&mut self) -> Result<(), Vec<SqlInterfaceError>> {
333        // Get all schedule entries from the database
334        let schedule_entry_array = match self.conn.query_map(
335            sql_query_strings::SQL_QUERY_READ_SCHEDULE,
336            |(schedule_type, start_time, stop_time, is_active)| SqlScheduleEntry {
337                schedule_type,
338                start_time,
339                stop_time,
340                is_active,
341            },
342        ) {
343            Ok(c) => c,
344            Err(e) => {
345                return Err(vec![SqlInterfaceError::ScheduleRequestFailure {
346                    location: module_path!().to_string(),
347                    query: sql_query_strings::SQL_QUERY_READ_SCHEDULE.to_string(),
348                    source: e,
349                }]);
350            }
351        };
352
353        self.schedules.clear();
354
355        let mut errors = vec![]; // for collecting the errors
356        for schedule_string in schedule_entry_array {
357            match ScheduleEntry::new(schedule_string) {
358                Ok(schedule_entry) => {
359                    self.schedules.push(schedule_entry);
360                }
361                Err(e) => {
362                    errors.push(
363                        // collect the error
364                        SqlInterfaceError::ScheduleProcessingFailure {
365                            location: module_path!().to_string(),
366                            source: Box::new(e),
367                        },
368                    );
369                }
370            }
371        }
372        if errors.is_empty() {
373            Ok(())
374        } else {
375            Err(errors)
376        }
377    }
378
379    /// Finds a specific schedule entry by its type from the internal cache.
380    ///
381    /// This function searches the `schedules` vector, which must be populated by
382    /// a prior call to `read_schedule`, for an entry matching the given `schedule_type`.
383    ///
384    /// # Arguments
385    /// * `schedule_type` - The `ScheduleType` to search for.
386    ///
387    /// # Returns
388    /// A `Result` containing a reference to the found `ScheduleEntry` on success.
389    ///
390    /// # Errors
391    /// Returns `SqlInterfaceError::ScheduleTypeNotFound` if no schedule entry
392    /// matching the provided `schedule_type` exists in the internal cache.
393    pub fn find_schedule(
394        &self,
395        schedule_type: ScheduleType,
396    ) -> Result<&ScheduleEntry, SqlInterfaceError> {
397        self.schedules
398            .iter()
399            .find(|&schedule| schedule.schedule_type == schedule_type)
400            .ok_or_else(|| {
401                // Generate a comma-separated string of all available schedule types.
402                let available_types = self
403                    .schedules
404                    .iter()
405                    .map(|s| s.schedule_type.to_string())
406                    .collect::<Vec<_>>()
407                    .join(", ");
408
409                SqlInterfaceError::ScheduleTypeNotFound(
410                    module_path!().to_string(),
411                    schedule_type.to_string(),
412                    available_types,
413                )
414            })
415    }
416}
417
418#[cfg(test)]
419pub mod tests {
420    use crate::database::sql_interface::SqlInterface;
421    use crate::database::sql_interface_error::SqlInterfaceError;
422    use crate::database::sql_interface_schedule::{
423        ScheduleEntry, ScheduleType, SqlInterfaceSchedule, SqlScheduleEntry,
424    };
425    use crate::database::sql_query_strings;
426    use crate::database::sql_query_strings::SQL_TABLE_SCHEDULE;
427    use crate::utilities::config::{read_config_file_with_test_database, ConfigData};
428    use mysql::params;
429    use mysql::prelude::Queryable;
430    use std::collections::HashMap;
431
432    #[test]
433    // This test case verifies the validation and data processing logic for SqlInterfaceSchedule.
434    // It covers the following scenarios:
435    // 1. `new()`: Happy path, failure on NULL, and failure on row limits.
436    // 2. `read_schedule()`: Failure on invalid data (bad schedule type) and partial success.
437    // Test case uses test database #63.
438    fn test_sql_interface_schedule_new_and_read() {
439        // --- Common Setup ---
440        let config: ConfigData = read_config_file_with_test_database(
441            "/config/aquarium_control_test_generic.toml".to_string(),
442            63, // Using database #63 as requested
443        );
444        println!("Testing with database {}", config.sql_interface.db_name);
445        let mut sql_interface: SqlInterface = SqlInterface::new(config.sql_interface.clone())
446            .expect("Initialization of SQL interface for test failed.");
447
448        // --- Test `new()`: Happy Path (empty table) ---
449        println!("* Testing new() with an empty table (Happy Path)...");
450        SqlInterface::truncate_table(&mut sql_interface, SQL_TABLE_SCHEDULE.to_string())
451            .expect("Test setup failed: Could not truncate table.");
452
453        let result = SqlInterfaceSchedule::new(sql_interface.get_connection().unwrap(), 10);
454        assert!(
455            result.is_ok(),
456            "Expected new() to succeed with an empty table, but it failed: {:?}",
457            result.err()
458        );
459        println!("* Succeeded: Happy path initialization is successful on an empty table.");
460
461        // --- Test `new()`: Failure on too many rows ---
462        println!("* Testing new() with more rows than the limit...");
463        SqlInterface::truncate_table(&mut sql_interface, SQL_TABLE_SCHEDULE.to_string())
464            .expect("Test setup failed: Could not truncate table.");
465        let mut conn = sql_interface.get_connection().unwrap();
466        conn.exec_drop(
467            sql_query_strings::SQL_QUERY_WRITE_SCHEDULE_TEST_DATA,
468            params! { "schedule_name" => "Balling", "start_time" => "01:00:00", "stop_time" => "02:00:00" },
469        )
470            .unwrap();
471        conn.exec_drop(
472            sql_query_strings::SQL_QUERY_WRITE_SCHEDULE_TEST_DATA,
473            params! { "schedule_name" => "Refill", "start_time" => "03:00:00", "stop_time" => "04:00:00" },
474        )
475            .unwrap();
476
477        // Set the limit to 1
478        let result = SqlInterfaceSchedule::new(conn, 1);
479        assert!(
480            matches!(
481                result,
482                Err(SqlInterfaceError::DatabaseScheduleTableContainsTooManyRows(
483                    _,
484                    _,
485                    _
486                ))
487            ),
488            "Expected row limit error, but got {:?}",
489            result
490        );
491        println!("* Succeeded: Initialization fails if table exceeds row limit.");
492
493        // --- Test `read_schedule()`: Failure on invalid data ---
494        println!("* Testing read_schedule() with an invalid schedule type...");
495        SqlInterface::truncate_table(&mut sql_interface, SQL_TABLE_SCHEDULE.to_string())
496            .expect("Test setup failed: Could not truncate table.");
497        let mut conn = sql_interface.get_connection().unwrap();
498        conn.exec_drop(
499            sql_query_strings::SQL_QUERY_WRITE_SCHEDULE_TEST_DATA,
500            params! { "schedule_name" => "InvalidType", "start_time" => "01:00:00", "stop_time" => "02:00:00" },
501        )
502            .unwrap();
503
504        let mut schedule_db = SqlInterfaceSchedule::new(conn, 10).unwrap();
505        let read_result = schedule_db.read_schedule();
506
507        assert!(
508            matches!(read_result, Err(ref errors) if !errors.is_empty()),
509            "Expected read_schedule to return a vector of errors"
510        );
511
512        if let Err(errors) = read_result {
513            assert!(matches!(
514                errors[0],
515                SqlInterfaceError::ScheduleProcessingFailure { .. }
516            ));
517            println!("* Succeeded: read_schedule correctly fails on invalid data.");
518        }
519
520        // --- Test `read_schedule()`: Partial success ---
521        println!("* Testing read_schedule() with mixed valid and invalid data...");
522        SqlInterface::truncate_table(&mut sql_interface, SQL_TABLE_SCHEDULE.to_string())
523            .expect("Test setup failed: Could not truncate table.");
524        let mut conn = sql_interface.get_connection().unwrap();
525        // Insert one valid row
526        conn.exec_drop(
527            sql_query_strings::SQL_QUERY_WRITE_SCHEDULE_TEST_DATA,
528            params! { "schedule_name" => "Balling", "start_time" => "01:00:00", "stop_time" => "02:00:00" },
529        )
530            .unwrap();
531        // Insert one invalid row
532        conn.exec_drop(
533            sql_query_strings::SQL_QUERY_WRITE_SCHEDULE_TEST_DATA,
534            params! { "schedule_name" => "AnotherInvalidType", "start_time" => "03:00:00", "stop_time" => "04:00:00" },
535        )
536            .unwrap();
537
538        let mut schedule_db = SqlInterfaceSchedule::new(conn, 10).unwrap();
539        let read_result = schedule_db.read_schedule();
540
541        // Expect an error, but also check that the valid data was processed.
542        assert!(
543            read_result.is_err(),
544            "Expected an error due to the invalid entry"
545        );
546        assert_eq!(
547            schedule_db.schedules.len(),
548            1,
549            "Expected exactly one valid schedule to be processed"
550        );
551        assert_eq!(
552            schedule_db.schedules[0].schedule_type,
553            ScheduleType::Balling
554        );
555        println!("* Succeeded: read_schedule correctly processes valid data while reporting errors for invalid data.");
556    }
557
558    #[test]
559    // Test case reads entries from the SQL database.
560    // Since the application does not modify schedule data in the SQL database,
561    // results are compared to fixed references.
562    // Test case uses test database #38.
563    pub fn test_sql_interface_schedule() {
564        let config: ConfigData = read_config_file_with_test_database(
565            "/config/aquarium_control_test_generic.toml".to_string(),
566            38,
567        );
568        println!("Testing with database {}", config.sql_interface.db_name);
569        let max_rows_schedule = config.sql_interface.max_rows_schedule;
570        let mut sql_interface: SqlInterface = SqlInterface::new(config.sql_interface)
571            .expect("Initialization of SQL interface for test failed.");
572        SqlInterface::truncate_table(&mut sql_interface, SQL_TABLE_SCHEDULE.to_string()).unwrap();
573
574        let test_data = [
575            ("Balling", "01:00:00", "23:00:00"),
576            ("Heating", "00:00:00", "23:59:00"),
577            ("Ventilation", "03:00:00", "21:00:00"),
578            ("Refill", "02:00:00", "22:00:00"),
579        ];
580
581        for (name, start, stop) in &test_data {
582            sql_interface
583                .conn
584                .exec_drop(
585                    sql_query_strings::SQL_QUERY_WRITE_SCHEDULE_TEST_DATA,
586                    params! {
587                        "schedule_name" => *name,
588                        "start_time" => *start,
589                        "stop_time" => *stop,
590                    },
591                )
592                .unwrap();
593        }
594
595        let mut sql_interface_schedule =
596            SqlInterfaceSchedule::new(sql_interface.get_connection().unwrap(), max_rows_schedule)
597                .unwrap();
598
599        match sql_interface_schedule.read_schedule() {
600            Ok(_) => {
601                println!("Reading schedule succeeded checking content now.");
602            }
603            Err(e) => {
604                panic!("Reading schedule from SQL database failed: {e:?}");
605            }
606        }
607
608        let references: HashMap<String, _> = test_data
609            .iter()
610            .map(|(name, start, stop)| {
611                let entry = ScheduleEntry::new(SqlScheduleEntry {
612                    schedule_type: name.to_string(),
613                    start_time: start.to_string().parse().unwrap(),
614                    stop_time: stop.to_string().parse().unwrap(),
615                    is_active: 1,
616                })
617                .unwrap();
618                (name.to_string(), entry)
619            })
620            .collect();
621
622        assert_eq!(sql_interface_schedule.schedules.len(), 4);
623
624        for schedule_entry in sql_interface_schedule.schedules {
625            let reference = references
626                .get(&schedule_entry.schedule_type.to_string())
627                .unwrap();
628            assert_eq!(reference.start_time, schedule_entry.start_time);
629            assert_eq!(reference.stop_time, schedule_entry.stop_time);
630            assert_eq!(reference.is_active, schedule_entry.is_active);
631        }
632        // *** check if row limitation is active ***
633        let test_result = SqlInterfaceSchedule::new(sql_interface.conn, 1);
634        assert!(matches!(
635            test_result,
636            Err(SqlInterfaceError::DatabaseScheduleTableContainsTooManyRows(
637                _,
638                _,
639                _
640            ))
641        ));
642    }
643
644    #[test]
645    // Test case verifies that the correct error is returned when a schedule is not found.
646    // Test case uses test database #53.
647    pub fn test_sql_interface_schedule_not_found() {
648        let config: ConfigData = read_config_file_with_test_database(
649            "/config/aquarium_control_test_generic.toml".to_string(),
650            53,
651        );
652        println!(
653            "Testing ScheduleTypeNotFound with database {}",
654            config.sql_interface.db_name
655        );
656        let mut sql_interface: SqlInterface = SqlInterface::new(config.sql_interface.clone())
657            .expect("Initialization of SQL interface for test failed.");
658        SqlInterface::truncate_table(&mut sql_interface, SQL_TABLE_SCHEDULE.to_string()).unwrap();
659
660        // Insert a SUBSET of schedules to test the "not found" case
661        let test_data = [
662            ("Heating", "00:00:00", "23:59:00"),
663            ("Ventilation", "03:00:00", "21:00:00"),
664        ];
665
666        for (name, start, stop) in &test_data {
667            sql_interface
668                .conn
669                .exec_drop(
670                    sql_query_strings::SQL_QUERY_WRITE_SCHEDULE_TEST_DATA,
671                    params! {
672                        "schedule_name" => *name,
673                        "start_time" => *start,
674                        "stop_time" => *stop,
675                    },
676                )
677                .unwrap();
678        }
679
680        let mut sql_interface_schedule = SqlInterfaceSchedule::new(
681            sql_interface.get_connection().unwrap(),
682            config.sql_interface.max_rows_schedule,
683        )
684        .unwrap();
685
686        sql_interface_schedule
687            .read_schedule()
688            .expect("Reading schedule from SQL database failed");
689
690        // Now, try to find a schedule that was NOT inserted (e.g., Balling)
691        println!("Checking for non-existent schedule entry (Balling).");
692        let find_result = sql_interface_schedule.find_schedule(ScheduleType::Balling);
693
694        // Assert that we get the correct error variant
695        assert!(matches!(
696            find_result,
697            Err(SqlInterfaceError::ScheduleTypeNotFound(_, _, _))
698        ));
699
700        // For more robust testing, also verify the content of the error message.
701        if let Err(SqlInterfaceError::ScheduleTypeNotFound(_, requested, available)) = find_result {
702            assert_eq!(requested, "Balling");
703            assert!(available.contains("Heating") && available.contains("Ventilation"));
704            println!(
705                "Correctly received ScheduleTypeNotFound for '{}'. Available types: '{}'",
706                requested, available
707            );
708        }
709    }
710}