aquarium_control/database/
sql_interface_heating_stats.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 the aggregation and persistence of daily heating statistics.
11//!
12//! This module provides a dedicated interface, `SqlInterfaceHeatingStats`, for handling
13//! all SQL operations related to the `heatingstats` table. It is responsible for
14//! tracking daily energy consumption, average temperatures, and heater runtime.
15//!
16//! ## Key Components
17//!
18//! - **`SqlInterfaceHeatingStats` Struct**: The primary interface that holds a database
19//!   connection and provides methods for all heating statistics operations.
20//!
21//! - **`HeatingStatsEntry` Struct**: A data structure representing the aggregated
22//!   statistics for a single day. It includes an `update` method for incrementally
23//!   calculating running averages.
24//!
25//! - **`new()` Constructor**: A critical entry point that performs "fail-fast"
26//!   validation at initialization. It checks the database to ensure:
27//!   - The `heatingstats` table contains no `NULL` values.
28//!   - The table's row count does not exceed the configured maximum.
29//!
30//! - **`get_single_heating_stats_from_database()`**: Retrieves the statistics record
31//!   for the current day.
32//!
33//! - **`insert_heating_stats_entry()`**: Writes a `HeatingStatsEntry` to the database
34//!   using an `INSERT ... ON DUPLICATE KEY UPDATE` query to either create a new
35//!   daily record or update an existing one.
36//!
37//! ## Design and Purpose
38//!
39//! The main goal of this module is to provide a robust and encapsulated interface
40//! for managing the application's historical heating data.
41//!
42//! - **Encapsulation**: All SQL logic specific to the `heatingstats` table is
43//!   contained within this module, separating concerns.
44//!
45//! - **Data Integrity**: The constructor's validation logic enforces data integrity
46//!   rules at startup, preventing runtime errors caused by an invalid database state.
47//!
48//! - **Time-Based Logic**: It relies on a `SqlInterfaceMidnightCalculatorTrait` to
49//!   determine when a day has ended, which is crucial for finalizing one day's
50//!   statistics and starting a new record.
51
52use crate::database::{
53    sql_interface::SqlInterface, sql_interface_error::SqlInterfaceError,
54    sql_interface_midnight::SqlInterfaceMidnightCalculatorTrait, sql_query_strings,
55};
56
57use crate::database::sql_query_strings::{
58    SQL_QUERY_CHECK_HEATING_STATS_COUNT, SQL_QUERY_CHECK_HEATING_STATS_NULL,
59};
60use chrono::*;
61use log::warn;
62use mysql::prelude::*;
63use mysql::*;
64use std::fmt;
65
66/// Holds the statistical data of heating for one day.
67/// It is filled with post-processed data from a database query which is then updated by the control application.
68#[derive(Clone)]
69pub struct HeatingStatsEntry {
70    /// calendar date of the entry after having postprocessed the SQL data read as String
71    pub date: NaiveDate,
72
73    /// energy used for heating on a specific date in kWh
74    pub energy: f64,
75
76    /// average ambient temperature value on a specific date in °C
77    pub ambient_temperature_average_value: f64,
78
79    /// counter how many samples were used to calculate the average value
80    pub ambient_temperature_average_counter: i64,
81
82    /// average water temperature value on a specific date in °C
83    pub water_temperature_average_value: f64,
84
85    /// counter how many samples were used to calculate the average value    
86    pub water_temperature_average_counter: i64,
87
88    /// counter how many seconds the heating control was active on a specific date
89    pub heating_control_runtime: i64,
90}
91
92impl HeatingStatsEntry {
93    /// Creates a new `HeatingStatsEntry` initialized for the current local date.
94    ///
95    /// This constructor is used when there's no existing heating statistics
96    /// record for the current day in the database. It sets the `date` field to today's
97    /// date and initializes all statistical counters and values to zero.
98    ///
99    /// # Returns
100    /// A new `HeatingStatsEntry` struct with default values for the current date.
101    pub fn new(conn: &mut PooledConn) -> HeatingStatsEntry {
102        // Get the current date from the database
103        let today: NaiveDate = SqlInterface::get_current_date(conn).unwrap_or_else(|_| {
104            warn!(target: module_path!(), "Could not get current date from database.");
105            // fall back: calculation using machine-local time
106            let now = Local::now();
107            let year = now.year();
108            let month = now.month();
109            let day = now.day();
110            // Create NaiveDate from components - unwrap is safe in this context
111            NaiveDate::from_ymd_opt(year, month, day).unwrap()
112        });
113
114        Self {
115            date: today,
116            energy: 0.0,
117            ambient_temperature_average_value: 0.0,
118            ambient_temperature_average_counter: 0,
119            water_temperature_average_value: 0.0,
120            water_temperature_average_counter: 0,
121            heating_control_runtime: 0,
122        }
123    }
124
125    /// Updates the heating statistics entry with new sensor readings and energy consumption data.
126    ///
127    /// This method incorporates new samples to incrementally calculate average water and ambient
128    /// temperatures, sum up energy consumption, and track the heating control's active runtime.
129    ///
130    /// # Arguments
131    /// * `water_temperature_opt` - An `Option<f64>` representing the latest water temperature reading.
132    ///   If `Some`, it contributes to the average; if `None`, the water temperature average remains unchanged.
133    /// * `ambient_temperature_opt` - An `Option<f64>` representing the latest ambient air temperature reading.
134    ///   If `Some`, it contributes to the average; if `None`, the ambient temperature average remains unchanged.
135    /// * `energy_increment` - The amount of energy (in kWh) consumed by the heater since the last update.
136    pub fn update(
137        &mut self,
138        water_temperature_opt: Option<f64>,
139        ambient_temperature_opt: Option<f64>,
140        energy_increment: f64,
141    ) {
142        // add energy to the sum of heating energy
143        self.energy += energy_increment;
144
145        // calculate new average water temperature value
146        self.water_temperature_average_value = match water_temperature_opt {
147            Some(water_temperature) => {
148                self.water_temperature_average_counter += 1;
149                if self.water_temperature_average_counter > 1 {
150                    // multiple samples existing, so mix value proportionally
151                    water_temperature / self.water_temperature_average_counter as f64
152                        + self.water_temperature_average_value
153                            * (1.0 - 1.0 / self.water_temperature_average_counter as f64)
154                } else {
155                    water_temperature
156                }
157            }
158            None => {
159                // do nothing - wait for the next valid sample
160                self.water_temperature_average_value
161            }
162        };
163
164        // calculate new average ambient air temperature value
165        self.ambient_temperature_average_value = match ambient_temperature_opt {
166            Some(ambient_temperature) => {
167                self.ambient_temperature_average_counter += 1;
168                if self.ambient_temperature_average_counter > 1 {
169                    // multiple samples existing, so mix value proportionally
170                    ambient_temperature / self.ambient_temperature_average_counter as f64
171                        + self.ambient_temperature_average_value
172                            * (1.0 - 1.0 / self.ambient_temperature_average_counter as f64)
173                } else {
174                    ambient_temperature
175                }
176            }
177            None => {
178                // do nothing - wait for the next valid sample
179                self.ambient_temperature_average_value
180            }
181        };
182
183        self.heating_control_runtime += 1;
184    }
185
186    #[cfg(test)]
187    pub fn default() -> Self {
188        Self {
189            date: Local::now().date_naive(),
190            energy: 0.0,
191            ambient_temperature_average_value: 0.0,
192            ambient_temperature_average_counter: 0,
193            water_temperature_average_value: 0.0,
194            water_temperature_average_counter: 0,
195            heating_control_runtime: 0,
196        }
197    }
198
199    #[cfg(test)]
200    pub fn increment_date(&mut self) {
201        let tomorrow = self.date.checked_add_days(Days::new(1));
202        self.date = tomorrow.unwrap();
203    }
204}
205
206impl fmt::Display for HeatingStatsEntry {
207    /// Formats the `HeatingStatsEntry` struct into a multi-line, human-readable string.
208    ///
209    /// This implementation is primarily intended for debugging and logging, providing
210    /// a clear overview of the heating statistics for a given date, including
211    /// average temperatures, energy consumption, and control runtime.
212    ///
213    /// # Arguments
214    /// * `f` - A mutable reference to the formatter, as required by the `fmt::Display` trait.
215    ///
216    /// # Returns
217    /// A `fmt::Result` indicating whether the formatting operation was successful.
218    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
219        write!(
220            f,
221            "Heating Stats Entry for {}: \n\
222                Water temperature: average={}°C, counter={} \n\
223                Ambient temperature: average={}°C, counter={} \n\
224                Energy: {}kWh\n\
225                Control runtime: {}s",
226            self.date,
227            self.water_temperature_average_value,
228            self.water_temperature_average_counter,
229            self.ambient_temperature_average_value,
230            self.ambient_temperature_average_counter,
231            self.energy,
232            self.heating_control_runtime
233        )
234    }
235}
236
237/// Contains the configuration and the implementation of the SQL interface for heating.
238pub struct SqlInterfaceHeatingStats {
239    /// Connection to the database
240    pub conn: PooledConn,
241
242    /// Struct with implementation of trait for calculation of the time interval until midnight in seconds
243    pub sql_interface_heating_midnight_calculator:
244        Box<dyn SqlInterfaceMidnightCalculatorTrait + Sync + Send>,
245}
246
247impl SqlInterfaceHeatingStats {
248    /// Creates a new `SqlInterfaceHeatingStats` instance.
249    ///
250    /// This constructor initializes the SQL interface for heating statistics. It performs
251    /// several pre-flight checks to ensure data integrity, such as verifying that the
252    /// `heatingstats` table contains no NULL values and that its row count is within
253    /// the configured limit.
254    ///
255    /// # Arguments
256    /// * `conn` - An active, pooled database connection.
257    /// * `max_rows_heating_stats` - The maximum allowed number of rows in the `heatingstats` table.
258    /// * `sql_interface_heating_midnight_calculator` - A boxed trait object that provides
259    ///   the implementation for calculating the duration until midnight.
260    ///
261    /// # Returns
262    /// A `Result` containing a new `SqlInterfaceHeatingStats` instance on success.
263    ///
264    /// # Errors
265    /// This function will return an error if:
266    /// - Any of the initial database queries to get table counts fail (`DatabaseCheckHeatingStatsFailure`).
267    /// - Any of the retrieved counts are negative, indicating a database issue (`DatabaseHeatingStatsTableNegativeValue`).
268    /// - The `heatingstats` table contains entries with `NULL` values (`DatabaseHeatingStatsTableContainsNull`).
269    /// - The number of rows in the `heatingstats` table exceeds `max_rows_heating_stats` (`DatabaseHeatingStatsTableContainsTooManyRows`).
270    pub fn new(
271        mut conn: PooledConn,
272        max_rows_heating_stats: u64,
273        sql_interface_heating_midnight_calculator: Box<
274            dyn SqlInterfaceMidnightCalculatorTrait + Sync + Send,
275        >,
276    ) -> Result<SqlInterfaceHeatingStats, SqlInterfaceError> {
277        let count_null_values = SqlInterface::get_single_integer_from_database(
278            &mut conn,
279            SQL_QUERY_CHECK_HEATING_STATS_NULL,
280        )
281        .map_err(|e| SqlInterfaceError::DatabaseCheckHeatingStatsFailure {
282            location: module_path!().to_string(),
283            source: Box::new(e),
284        })?;
285        let count_rows = SqlInterface::get_single_integer_from_database(
286            &mut conn,
287            SQL_QUERY_CHECK_HEATING_STATS_COUNT,
288        )
289        .map_err(|e| SqlInterfaceError::DatabaseCheckHeatingStatsFailure {
290            location: module_path!().to_string(),
291            source: Box::new(e),
292        })?;
293
294        // check the query results
295        if count_null_values < 0 || count_rows < 0 {
296            return Err(SqlInterfaceError::DatabaseHeatingStatsTableNegativeValue(
297                module_path!().to_string(),
298                count_null_values,
299                count_rows,
300            ));
301        }
302
303        if count_null_values > 0 {
304            return Err(SqlInterfaceError::DatabaseHeatingStatsTableContainsNull(
305                module_path!().to_string(),
306                count_null_values,
307            ));
308        }
309
310        if max_rows_heating_stats > 0 {
311            // execute the check only when the limit is greater than zero
312            if count_rows > max_rows_heating_stats.cast_signed() {
313                return Err(
314                    SqlInterfaceError::DatabaseHeatingStatsTableContainsTooManyRows(
315                        module_path!().to_string(),
316                        count_rows.cast_unsigned(),
317                        max_rows_heating_stats,
318                    ),
319                );
320            }
321        }
322
323        Ok(SqlInterfaceHeatingStats {
324            conn,
325            sql_interface_heating_midnight_calculator,
326        })
327    }
328
329    /// Retrieves a single `HeatingStatsEntry` for the current date from the database.
330    ///
331    /// This function executes an SQL query to fetch heating statistical data that
332    /// corresponds to the database's current date. It is designed to retrieve
333    /// exactly one record for "today."
334    ///
335    /// # Returns
336    /// A `Result` containing the `HeatingStatsEntry` for the current date on success.
337    ///
338    /// # Errors
339    /// This function will return an error if:
340    /// - The underlying database query fails (`SingleHeatingStatsRequestFailure`). This can be
341    ///   due to a connection issue or an invalid query.
342    /// - The query returns zero rows, indicating no statistics have been recorded for
343    ///   the current day (`SingleHeatingStatsRequestEmptyResponse`).
344    /// - The query returns more than one row, which signifies a data inconsistency
345    ///   (`SingleHeatingStatsRequestNoSingleResponse`).
346    /// - The date string retrieved from the database cannot be parsed into a `NaiveDate`
347    ///   (`TimestampConversionFailure`).
348    pub fn get_single_heating_stats_from_database(
349        &mut self,
350    ) -> Result<HeatingStatsEntry, SqlInterfaceError> {
351        // Get single heating stats entry from database
352        let single_heating_stats_entry_array = self
353            .conn
354            .query_map(
355                sql_query_strings::SQL_QUERY_READ_HEATING_STATS,
356                |(
357                    date,
358                    energy,
359                    ambient_temperature_average_value,
360                    ambient_temperature_average_counter,
361                    water_temperature_average_value,
362                    water_temperature_average_counter,
363                    heating_control_runtime,
364                )| HeatingStatsEntry {
365                    date,
366                    energy,
367                    ambient_temperature_average_value,
368                    ambient_temperature_average_counter,
369                    water_temperature_average_value,
370                    water_temperature_average_counter,
371                    heating_control_runtime,
372                },
373            )
374            .map_err(|e| SqlInterfaceError::SingleHeatingStatsRequestFailure {
375                location: module_path!().to_string(),
376                query: sql_query_strings::SQL_QUERY_READ_HEATING_STATS.to_string(),
377                source: e,
378            })?;
379
380        // check the error case if there is no response
381        if single_heating_stats_entry_array.is_empty() {
382            return Err(SqlInterfaceError::SingleHeatingStatsRequestEmptyResponse(
383                module_path!().to_string(),
384                sql_query_strings::SQL_QUERY_READ_HEATING_STATS.to_string(),
385            ));
386        }
387        // check the error case if there is more than one response
388        if single_heating_stats_entry_array.len() > 1 {
389            return Err(
390                SqlInterfaceError::SingleHeatingStatsRequestNoSingleResponse(
391                    module_path!().to_string(),
392                    sql_query_strings::SQL_QUERY_READ_HEATING_STATS.to_string(),
393                ),
394            );
395        }
396        Ok(single_heating_stats_entry_array[0].clone())
397    }
398
399    /// Inserts new heating statistical data into the database or updates an existing entry for a specific date.
400    ///
401    /// This function attempts to write a `HeatingStatsEntry` into the `heatingstats` table.
402    /// It's designed to manage daily statistics, where only one unique entry per date is expected.
403    ///
404    /// The underlying SQL query utilizes an `INSERT ... ON DUPLICATE KEY UPDATE` clause:
405    /// - If no record exists in the `heatingstats` table for the `date` specified in `heating_stats_entry`,
406    ///   a new row containing all provided statistical data is inserted.
407    /// - There is a `PRIMARY KEY` constraint for the `Date` column in the `heatingstats` table.
408    /// - If a record *already exists* for `heating_stats_entry.date`, the existing row's
409    ///   `Energy`, `AmbientTempAverage`, `AmbientTempCounter`, `WaterTempAverage`, `WaterTempCounter`,
410    ///   and `HeatingControlRuntime` columns are updated with the new values from `heating_stats_entry`.
411    ///
412    /// **Important Note on Query Construction: **
413    /// The query is constructed in two parts:
414    /// 1.  The `INSERT` clause uses **parameterized queries** (e.g., `:date`, `:energy`), which is the
415    ///     recommended and secure way to prevent SQL injection.
416    /// 2.  The `ON DUPLICATE KEY UPDATE` clause, however, is built by **directly embedding string
417    ///     representations** of the data (e.g., `Energy=#Energy`).
418    ///
419    /// # Arguments
420    ///
421    /// * `heating_stats_entry` - A reference to the `HeatingStatsEntry` struct containing all the
422    ///   statistical data (date, energy, temperatures, runtime) to be inserted or updated.
423    ///   The `date` field is crucial as it identifies the unique daily record.
424    ///
425    /// # Returns
426    /// An empty `Result` (`Ok(())`) if the data was successfully inserted or updated.
427    ///
428    /// # Errors
429    /// Returns `SqlInterfaceError::InsertHeatingStatsEntryFailure` if the database
430    /// operation fails. This can be caused by a lost connection, constraint violations,
431    /// incorrect data types, or a malformed SQL query. The original `mysql::Error` is
432    /// included as the source for detailed debugging.
433    pub fn insert_heating_stats_entry(
434        &mut self,
435        heating_stats_entry: &HeatingStatsEntry,
436    ) -> Result<(), SqlInterfaceError> {
437        let params = params! {
438            "date" => heating_stats_entry.date,
439            "energy" => heating_stats_entry.energy,
440            "ambient_temperature_average" => heating_stats_entry.ambient_temperature_average_value,
441            "ambient_temperature_counter" => heating_stats_entry.ambient_temperature_average_counter,
442            "water_temperature_average" => heating_stats_entry.water_temperature_average_value,
443            "water_temperature_counter" => heating_stats_entry.water_temperature_average_counter,
444            "heating_control_runtime" => heating_stats_entry.heating_control_runtime,
445        };
446
447        self.conn
448            .exec_drop(sql_query_strings::SQL_QUERY_WRITE_HEATING_STATS, params)
449            .map_err(|e| SqlInterfaceError::InsertHeatingStatsEntryFailure {
450                location: module_path!().to_string(),
451                query: sql_query_strings::SQL_QUERY_WRITE_HEATING_STATS.to_string(),
452                source: e,
453            })
454    }
455
456    /// Calculates the duration in seconds until the next midnight.
457    ///
458    /// This function delegates the calculation to the `sql_interface_heating_midnight_calculator`
459    /// trait object, which was provided during construction. This allows for flexible
460    /// and testable time calculations.
461    ///
462    /// # Returns
463    /// A `Result` containing the number of seconds until midnight on success.
464    ///
465    /// # Errors
466    /// This function will return an error if the underlying calculator implementation
467    /// fails. This is typically a pass-through of the error from the calculator's
468    /// `get_duration_until_midnight` method.
469    pub fn get_duration_until_midnight(&mut self) -> Result<i64, SqlInterfaceError> {
470        self.sql_interface_heating_midnight_calculator
471            .get_duration_until_midnight()
472    }
473}
474
475#[cfg(test)]
476pub mod tests {
477    use crate::database::sql_interface_heating_stats::{
478        HeatingStatsEntry, SqlInterfaceHeatingStats,
479    };
480    use crate::database::sql_interface_midnight::SqlInterfaceMidnightCalculator;
481    use crate::database::sql_query_strings::SQL_TABLE_HEATING_STATS;
482    use crate::database::{sql_interface::SqlInterface, sql_interface_error::SqlInterfaceError};
483    use crate::utilities::config::{read_config_file_with_test_database, ConfigData};
484    use chrono::{Datelike, Local, NaiveDate, NaiveDateTime};
485
486    #[test]
487    // This test case verifies the validation logic within the `SqlInterfaceHeatingStats::new()` constructor.
488    // It covers the following scenarios:
489    // 1. Happy Path: Initialization succeeds when the table has a valid number of rows.
490    // 2. Failure on Row Limit: Fails when `heatingstats` contains more rows than the configured limit.
491    // 3. Deactivated Check: Succeeds even with excess rows if the limit is set to 0.
492    // Test case uses test database #61.
493    fn test_sql_interface_heating_stats_new() {
494        // --- Common Setup ---
495        let config: ConfigData = read_config_file_with_test_database(
496            "/config/aquarium_control_test_generic.toml".to_string(),
497            61, // Using database #61 as requested
498        );
499        println!("Testing with database {}", config.sql_interface.db_name);
500        let mut sql_interface: SqlInterface = SqlInterface::new(config.sql_interface.clone())
501            .expect("Initialization of SQL interface for test failed.");
502
503        // --- Test Case 1: Happy Path (empty table) ---
504        println!("* Testing new() with an empty table (Happy Path)...");
505        SqlInterface::truncate_table(&mut sql_interface, SQL_TABLE_HEATING_STATS.to_string())
506            .expect("Test setup failed: Could not truncate table.");
507
508        let midnight_calc = Box::new(SqlInterfaceMidnightCalculator::new(
509            sql_interface.get_connection().unwrap(),
510        ));
511        let result_happy_case = SqlInterfaceHeatingStats::new(
512            sql_interface.get_connection().unwrap(),
513            10,
514            midnight_calc,
515        );
516        assert!(
517            result_happy_case.is_ok(),
518            "Expected new() to succeed with an empty table, but it failed: {:?}",
519            result_happy_case.err()
520        );
521        println!("* Succeeded: Happy path initialization is successful on an empty table.");
522
523        // --- Test Case 2: Failure on too many rows ---
524        println!("* Testing new() with more rows than the limit...");
525        SqlInterface::truncate_table(&mut sql_interface, SQL_TABLE_HEATING_STATS.to_string())
526            .expect("Test setup failed: Could not truncate table.");
527        let conn = sql_interface.get_connection().unwrap();
528
529        let mut heating_stats_entry = HeatingStatsEntry::default();
530        let mut sql_interface_heating_stats_for_test_setup = result_happy_case.unwrap();
531        _ = sql_interface_heating_stats_for_test_setup
532            .insert_heating_stats_entry(&heating_stats_entry);
533        heating_stats_entry.increment_date();
534        _ = sql_interface_heating_stats_for_test_setup
535            .insert_heating_stats_entry(&heating_stats_entry);
536
537        let midnight_calc = Box::new(SqlInterfaceMidnightCalculator::new(
538            sql_interface.get_connection().unwrap(),
539        ));
540        // Set the limit to 1
541        let result = SqlInterfaceHeatingStats::new(conn, 1, midnight_calc);
542        assert!(matches!(
543            result,
544            Err(SqlInterfaceError::DatabaseHeatingStatsTableContainsTooManyRows(_, _, _))
545        ));
546        println!("* Succeeded: Initialization fails if table exceeds row limit.");
547
548        // --- Test Case 3: Deactivated check (limit is 0) ---
549        println!("* Testing new() with a deactivated row limit check (limit = 0)...");
550        // We reuse the state from the previous test (2 rows in the table)
551        let midnight_calc = Box::new(SqlInterfaceMidnightCalculator::new(
552            sql_interface.get_connection().unwrap(),
553        ));
554        // Set the limit to 0
555        let result = SqlInterfaceHeatingStats::new(
556            sql_interface.get_connection().unwrap(),
557            0,
558            midnight_calc,
559        );
560        assert!(
561            result.is_ok(),
562            "Expected new() to succeed with deactivated check, but it failed: {:?}",
563            result.err()
564        );
565        println!("* Succeeded: Initialization passes when row limit check is deactivated.");
566    }
567
568    #[test]
569    // Test case includes all checks for this table combined in one function to limit the number of additional databases.
570    // Test case uses test database #36.
571    pub fn test_sql_interface_heating() {
572        let config: ConfigData = read_config_file_with_test_database(
573            "/config/aquarium_control_test_generic.toml".to_string(),
574            36,
575        );
576        println!("Testing with database {}", config.sql_interface.db_name);
577        let max_rows_heating_stats = config.sql_interface.max_rows_heating_stats;
578        let mut sql_interface: SqlInterface = SqlInterface::new(config.sql_interface)
579            .expect("Initialization of SQL interface for test failed.");
580        let sql_interface_heating_midnight_calculator = Box::new(
581            SqlInterfaceMidnightCalculator::new(sql_interface.get_connection().unwrap()),
582        );
583        let mut sql_interface_heating = SqlInterfaceHeatingStats::new(
584            sql_interface.get_connection().unwrap(),
585            max_rows_heating_stats,
586            sql_interface_heating_midnight_calculator,
587        )
588        .unwrap();
589
590        // *** retrieve heating stats - ************************************************************
591        match SqlInterface::truncate_table(&mut sql_interface, SQL_TABLE_HEATING_STATS.to_string())
592        {
593            Ok(_) => {}
594            Err(e) => panic!("Could not prepare test case: {e:?}"),
595        }
596        match sql_interface_heating.get_single_heating_stats_from_database() {
597            Ok(_) => {
598                panic!("Call to get_single_heating_stats_from_database should have failed.");
599            }
600            Err(e) => {
601                assert!(matches!(
602                    e,
603                    SqlInterfaceError::SingleHeatingStatsRequestEmptyResponse(_, _)
604                ));
605            }
606        }
607        println!("* checking reading from empty heating stat table succeeded.");
608        // *****************************************************************************************
609
610        // *** insert heating stats ****************************************************************
611        // prepare data for inserting into the database
612        let today =
613            match SqlInterface::get_current_date(&mut sql_interface.get_connection().unwrap()) {
614                Ok(c) => c,
615                Err(_) => panic!("Could not get current date."),
616            };
617        let mut heating_stats_entry = HeatingStatsEntry {
618            date: today.into(),
619            energy: 0.9,
620            ambient_temperature_average_value: 22.0,
621            ambient_temperature_average_counter: 72000,
622            water_temperature_average_value: 25.0,
623            water_temperature_average_counter: 78000,
624            heating_control_runtime: 64000,
625        };
626        assert!(matches!(
627            sql_interface_heating.insert_heating_stats_entry(&heating_stats_entry),
628            Ok(())
629        ));
630        println!("* checking inserting heating stats succeeded.");
631        // *****************************************************************************************
632
633        // *** retrieve stats **********************************************************************
634        match sql_interface_heating.get_single_heating_stats_from_database() {
635            Ok(c) => {
636                assert_eq!(c.date, today.into());
637                assert_eq!(c.energy, 0.9);
638                assert_eq!(c.ambient_temperature_average_value, 22.0);
639                assert_eq!(c.ambient_temperature_average_counter, 72000);
640                assert_eq!(c.water_temperature_average_value, 25.0);
641                assert_eq!(c.water_temperature_average_counter, 78000);
642                assert_eq!(c.heating_control_runtime, 64000);
643            }
644            Err(_) => {
645                panic!("Call to get_single_heating_stats_from_database has failed.");
646            }
647        }
648        println!("* checking retrieving heating stats succeeded.");
649
650        // *** retrieve stats **********************************************************************
651        // update data and trigger SQL statement (ON DUPLICATE UPDATE)
652        heating_stats_entry.energy += 0.1;
653        heating_stats_entry.ambient_temperature_average_value += 0.1;
654        heating_stats_entry.ambient_temperature_average_counter += 1;
655        heating_stats_entry.water_temperature_average_value += 0.1;
656        heating_stats_entry.water_temperature_average_counter += 1;
657        heating_stats_entry.heating_control_runtime += 1;
658        assert!(matches!(
659            sql_interface_heating.insert_heating_stats_entry(&heating_stats_entry),
660            Ok(())
661        ));
662        match sql_interface_heating.get_single_heating_stats_from_database() {
663            Ok(c) => {
664                assert_eq!(c.date, today.into());
665                assert_eq!(c.energy, 1.0);
666                assert_eq!(c.ambient_temperature_average_value, 22.1);
667                assert_eq!(c.ambient_temperature_average_counter, 72001);
668                assert_eq!(c.water_temperature_average_value, 25.1);
669                assert_eq!(c.water_temperature_average_counter, 78001);
670                assert_eq!(c.heating_control_runtime, 64001);
671            }
672            Err(_) => {
673                panic!("Call to get_single_heating_stats_from_database has failed.");
674            }
675        }
676        println!("* checking retrieving updated heating stats succeeded.");
677        // *****************************************************************************************
678
679        // *** calculate duration until midnight ***************************************************
680        // reference calculation in Rust
681        // Get current date components
682        let now = Local::now();
683        let year = now.year();
684        let month = now.month();
685        let day = now.day();
686
687        // Create NaiveDate from components
688        let today: NaiveDate = NaiveDate::from_ymd_opt(year, month, day).unwrap();
689
690        // Create a NaiveDateTime for midnight
691        let midnight = NaiveDateTime::new(today, chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap());
692        // Calculate the difference
693        let duration_until_midnight = 24 * 3600
694            - SqlInterface::get_duration_from_naive_timestamp_to_local_now(midnight)
695                .unwrap()
696                .num_seconds();
697        match sql_interface_heating.get_duration_until_midnight() {
698            Ok(c) => {
699                assert_eq!(c, duration_until_midnight);
700            }
701            Err(_) => {
702                panic!("Call to get_duration_until_midnight failed.");
703            }
704        }
705        println!("* checking duration until midnight in seconds succeeded.");
706
707        // *** check if too many rows are detected ***
708        // insert one more entry with modified date
709        heating_stats_entry.date =
710            SqlInterface::get_tomorrow_date(&mut sql_interface.get_connection().unwrap()).unwrap();
711        assert!(matches!(
712            sql_interface_heating.insert_heating_stats_entry(&heating_stats_entry),
713            Ok(())
714        ));
715        let sql_interface_heating_midnight_calculator = Box::new(
716            SqlInterfaceMidnightCalculator::new(sql_interface.get_connection().unwrap()),
717        );
718        let test_result = SqlInterfaceHeatingStats::new(
719            sql_interface.get_connection().unwrap(),
720            1,
721            sql_interface_heating_midnight_calculator,
722        );
723        assert!(matches!(
724            test_result,
725            Err(SqlInterfaceError::DatabaseHeatingStatsTableContainsTooManyRows(_, _, _))
726        ));
727        // *****************************************************************************************
728    }
729}