API
 
Loading...
Searching...
No Matches
flowRPM_test.cpp
Go to the documentation of this file.
1/** \file flowRPM_test.cpp
2 * \brief Catch2 tests for the flowRPM app.
3 * \author Jared R. Males (jaredmales@gmail.com)
4 *
5 * \ingroup flowRPM_files
6 */
7
8#include "../../../tests/testXWC.hpp"
9
10#include <filesystem>
11
12#define protected public
13#include "../flowRPM.hpp"
14#undef protected
15
16using namespace MagAOX::app;
17
18namespace libXWCTest
19{
20
21/** \defgroup flowRPM_unit_test flowRPM Unit Tests
22 * \brief Unit tests for the flowRPM application.
23 *
24 * \ingroup application_unit_test
25 */
26
27/// Namespace for `flowRPM` unit tests.
28/** \ingroup flowRPM_unit_test
29 */
30namespace flowRPMTest
31{
32
33namespace
34{
35
36/// Convert a numeric INDI element value to `double` for assertions.
37double indiNumberValue( const pcf::IndiProperty &property, const std::string &element )
38{
39 return std::stod( property[element].getValue() );
40}
41
42/// Test harness that forces `loadConfig()` to see a configuration failure.
43class flowRPMLoadConfigFailure : public flowRPM
44{
45 public:
46 /// Return a failure so the inherited `loadConfig()` path sets `m_shutdown`.
47 int loadConfigImpl( mx::app::appConfigurator &_config /**< [in] unused configuration object */ ) override
48 {
49 static_cast<void>( _config );
50 return -1;
51 }
52};
53
54/// Test harness that can inject failures into the `appLogic()` call chain.
55class flowRPMFaultInject : public flowRPM
56{
57 public:
58 /// Failure sites that can be forced during `appLogic()`.
59 enum class faultMode
60 {
61 none,
62 readAndParse,
63 publishResult,
64 recordFlow
65 };
66
67 /// Select which call inside `appLogic()` should fail.
68 void fault( faultMode mode /**< [in] injected failure site */ )
69 {
70 m_faultMode = mode;
71 }
72
73 /// Return an injected failure from `readAndParse()` or provide a valid sample.
74 int readAndParse( parseResult &result, /**< [out] parse result */
75 const timespec &now /**< [in] current time */
76 ) const override
77 {
78 if( m_faultMode == faultMode::readAndParse )
79 {
80 return -1;
81 }
82
83 result.m_status = parseStatus::success;
84 result.m_flowRate = 1.9;
85 result.m_age = 0.0;
86 result.m_sourceTs = now;
87
88 return 0;
89 }
90
91 /// Return an injected failure from `publishResult()`.
92 int publishResult( const parseResult &result /**< [in] parse result to publish */ ) override
93 {
94 if( m_faultMode == faultMode::publishResult )
95 {
96 return -1;
97 }
98
99 return flowRPM::publishResult( result );
100 }
101
102 /// Return an injected failure from `recordFlow()`.
103 int recordFlow( bool force = false /**< [in] whether telemetry is forced */ ) override
104 {
105 if( m_faultMode == faultMode::recordFlow )
106 {
107 return -1;
108 }
109
110 return flowRPM::recordFlow( force );
111 }
112
113 private:
114 faultMode m_faultMode{ faultMode::none }; ///< Current fault injected into the `appLogic()` flow.
115};
116
117} // namespace
118
119/// Verify default `flowRPM` configuration values are loaded.
120/**
121 * \ingroup flowRPM_unit_test
122 */
123TEST_CASE( "flowRPM configuration defaults load correctly", "[flowRPM]" )
124{
125 flowRPM app;
126
127 app.setupConfig();
128
129 mx::app::writeConfigFile( "/tmp/flowRPM_test.conf", { "none" }, { "nada" }, { "0" } );
130 app.config.readConfig( "/tmp/flowRPM_test.conf" );
131
132 app.loadConfig();
133 // clang-format off
134 #ifdef FLOWRPM_TEST_DOXYGEN_REF
137 #endif
138 // clang-format on
139
140 REQUIRE( app.m_shutdown == 0 );
141 REQUIRE( app.inputPath() == "/tmp/fac_flow.txt" );
142 REQUIRE( app.maxAge() == Approx( 60.0 ) );
143 REQUIRE( app.fanDescriptor() == "CHA_FAN1" );
144 REQUIRE( app.badValue() == Approx( -999.0 ) );
145 REQUIRE( app.errorLogInterval() == Approx( 60.0 ) );
146}
147
148/// Verify configured `flowRPM` overrides are loaded.
149/**
150 * \ingroup flowRPM_unit_test
151 */
152TEST_CASE( "flowRPM configuration overrides load correctly", "[flowRPM]" )
153{
154 flowRPM app;
155
156 app.setupConfig();
157
158 mx::app::writeConfigFile( "/tmp/flowRPM_test_override.conf",
159 { "input", "input", "input", "input", "input" },
160 { "path", "maxAge", "fanDescriptor", "badValue", "errorLogInterval" },
161 { "/tmp/custom_flow.txt", "12.5", "SYS_FAN9", "-321", "17" } );
162 app.config.readConfig( "/tmp/flowRPM_test_override.conf" );
163
164 app.loadConfig();
165 // clang-format off
166 #ifdef FLOWRPM_TEST_DOXYGEN_REF
169 #endif
170 // clang-format on
171
172 REQUIRE( app.m_shutdown == 0 );
173 REQUIRE( app.inputPath() == "/tmp/custom_flow.txt" );
174 REQUIRE( app.maxAge() == Approx( 12.5 ) );
175 REQUIRE( app.fanDescriptor() == "SYS_FAN9" );
176 REQUIRE( app.badValue() == Approx( -321.0 ) );
177 REQUIRE( app.errorLogInterval() == Approx( 17.0 ) );
178}
179
180/// Verify `loadConfig()` requests shutdown when configuration loading fails.
181/**
182 * \ingroup flowRPM_unit_test
183 */
184TEST_CASE( "flowRPM loadConfig sets shutdown on configuration failure", "[flowRPM]" )
185{
186 flowRPMLoadConfigFailure app;
187
188 app.setupConfig();
189 app.loadConfig();
190 // clang-format off
191 #ifdef FLOWRPM_TEST_DOXYGEN_REF
194 #endif
195 // clang-format on
196
197 REQUIRE( app.m_shutdown == 1 );
198}
199
200/// Verify `trimToken()` removes surrounding whitespace and handles blank tokens.
201/**
202 * \ingroup flowRPM_unit_test
203 */
204TEST_CASE( "flowRPM helper token parsing handles whitespace-only input", "[flowRPM]" )
205{
206 // clang-format off
207 #ifdef FLOWRPM_TEST_DOXYGEN_REF
209 #endif
210 // clang-format on
211 REQUIRE( flowRPMDetail::trimToken( " CHA_FAN1 \t\r" ) == "CHA_FAN1" );
212 REQUIRE( flowRPMDetail::trimToken( " \t\r " ).empty() );
213}
214
215/// Verify `splitLogicalLines()` removes CRLF suffixes and ignores blank lines.
216/**
217 * \ingroup flowRPM_unit_test
218 */
219TEST_CASE( "flowRPM helper logical-line splitting handles CRLF and blank lines", "[flowRPM]" )
220{
221 // clang-format off
222 #ifdef FLOWRPM_TEST_DOXYGEN_REF
224 #endif
225 // clang-format on
226 const std::vector<std::string> lines =
227 flowRPMDetail::splitLogicalLines( "\r\n \t \r\n1775430287 145131374\r\n"
228 "36 | CHA_FAN1 | Fan | 1900.00 | RPM | 'OK'\r\n" );
229
230 REQUIRE( lines.size() == 2 );
231 REQUIRE( lines[0] == "1775430287 145131374" );
232 REQUIRE( lines[1] == "36 | CHA_FAN1 | Fan | 1900.00 | RPM | 'OK'" );
233}
234
235/// Verify the default-initialized `parseResult` sentinel state.
236/**
237 * \ingroup flowRPM_unit_test
238 */
239TEST_CASE( "flowRPM parseResult defaults to the invalid sentinel state", "[flowRPM]" )
240{
241 // clang-format off
242 #ifdef FLOWRPM_TEST_DOXYGEN_REF
244 #endif
245 // clang-format on
247
248 REQUIRE( result.m_status == flowRPM::parseStatus::fileReadError );
249 REQUIRE( result.m_flowRate == Approx( -999.0 ) );
250 REQUIRE( result.m_age == Approx( -999.0 ) );
251 REQUIRE( result.m_sourceTs.tv_sec == 0 );
252 REQUIRE( result.m_sourceTs.tv_nsec == 0 );
253}
254
255/// Verify `parseTimestamp()` accepts valid input and rejects malformed timestamps.
256/**
257 * \ingroup flowRPM_unit_test
258 */
259TEST_CASE( "flowRPM timestamp parsing covers valid and malformed inputs", "[flowRPM]" )
260{
261 flowRPM app;
262 timespec ts;
263 // clang-format off
264 #ifdef FLOWRPM_TEST_DOXYGEN_REF
265 flowRPM::parseTimestamp( ts, "" );
266 #endif
267 // clang-format on
268
269 SECTION( "a valid timestamp parses successfully" )
270 {
271 REQUIRE( app.parseTimestamp( ts, "1775430287 145131374" ) == 0 );
272 REQUIRE( ts.tv_sec == 1775430287 );
273 REQUIRE( ts.tv_nsec == 145131374 );
274 }
275
276 SECTION( "a trailing token is rejected" )
277 {
278 REQUIRE( app.parseTimestamp( ts, "1775430287 145131374 extra" ) == -1 );
279 }
280
281 SECTION( "a negative nanosecond field is rejected" )
282 {
283 REQUIRE( app.parseTimestamp( ts, "1775430287 -1" ) == -1 );
284 }
285
286 SECTION( "an overflowing nanosecond field is rejected" )
287 {
288 REQUIRE( app.parseTimestamp( ts, "1775430287 1000000000" ) == -1 );
289 }
290}
291
292/// Verify `parseRecordLine()` covers the accepted and rejected record formats.
293/**
294 * \ingroup flowRPM_unit_test
295 */
296TEST_CASE( "flowRPM record-line parsing covers all parse branches", "[flowRPM]" )
297{
298 flowRPM app;
299 double flowRate = app.badValue();
300 // clang-format off
301 #ifdef FLOWRPM_TEST_DOXYGEN_REF
302 flowRPM::parseRecordLine( flowRate, "" );
303 #endif
304 // clang-format on
305
306 SECTION( "a valid record converts RPM to LPM" )
307 {
308 REQUIRE( app.parseRecordLine( flowRate, "36 | CHA_FAN1 | Fan | 1900.00 | RPM | 'OK'" ) ==
309 flowRPM::parseStatus::success );
310 REQUIRE( flowRate == Approx( 1.9 ) );
311 }
312
313 SECTION( "the zero-flow threshold status is accepted as a valid reading" )
314 {
315 REQUIRE(
316 app.parseRecordLine(
317 flowRate, "36 | CHA_FAN1 | Fan | 0.00 | RPM | 'At or Below (<=) Lower Non-Recoverable Threshold'" ) ==
318 flowRPM::parseStatus::success );
319 REQUIRE( flowRate == Approx( 0.0 ) );
320 }
321
322 SECTION( "the wrong field count is rejected" )
323 {
324 REQUIRE( app.parseRecordLine( flowRate, "36 | CHA_FAN1 | Fan | 1900.00 | RPM" ) ==
325 flowRPM::parseStatus::malformedRecord );
326 }
327
328 SECTION( "the wrong descriptor is rejected" )
329 {
330 REQUIRE( app.parseRecordLine( flowRate, "36 | OTHER_FAN | Fan | 1900.00 | RPM | 'OK'" ) ==
331 flowRPM::parseStatus::wrongDescriptor );
332 }
333
334 SECTION( "the wrong units are rejected" )
335 {
336 REQUIRE( app.parseRecordLine( flowRate, "36 | CHA_FAN1 | Fan | 1900.00 | LPM | 'OK'" ) ==
337 flowRPM::parseStatus::wrongUnits );
338 }
339
340 SECTION( "a non-OK status is rejected" )
341 {
342 REQUIRE( app.parseRecordLine( flowRate, "36 | CHA_FAN1 | Fan | 1900.00 | RPM | 'BAD'" ) ==
343 flowRPM::parseStatus::badStatus );
344 }
345
346 SECTION( "a malformed numeric field is rejected" )
347 {
348 REQUIRE( app.parseRecordLine( flowRate, "36 | CHA_FAN1 | Fan | 19x | RPM | 'OK'" ) ==
349 flowRPM::parseStatus::badValue );
350 }
351}
352
353/// Verify `parseFileContents()` handles valid, invalid, and stale two-line inputs.
354/**
355 * \ingroup flowRPM_unit_test
356 */
357TEST_CASE( "flowRPM file parsing", "[flowRPM]" )
358{
359 flowRPM app;
360 timespec now{ 1775430290, 145131374 };
362 // clang-format off
363 #ifdef FLOWRPM_TEST_DOXYGEN_REF
364 flowRPM::parseFileContents( result, "", now );
365 #endif
366 // clang-format on
367
368 SECTION( "empty contents are reported as missing timestamps" )
369 {
370 REQUIRE( app.parseFileContents( result, "", now ) == 0 );
371 REQUIRE( result.m_status == flowRPM::parseStatus::missingTimestamp );
372 }
373
374 SECTION( "whitespace-only contents are reported as missing timestamps" )
375 {
376 REQUIRE( app.parseFileContents( result, " \t\r\n\r\n", now ) == 0 );
377 REQUIRE( result.m_status == flowRPM::parseStatus::missingTimestamp );
378 }
379
380 SECTION( "valid two-line input parses successfully" )
381 {
382 const std::string contents = "1775430287 145131374\n"
383 "36 | CHA_FAN1 | Fan | 1900.00 | RPM | 'OK'\n";
384
385 REQUIRE( app.parseFileContents( result, contents, now ) == 0 );
386 REQUIRE( result.m_status == flowRPM::parseStatus::success );
387 REQUIRE( result.m_flowRate == Approx( 1.9 ) );
388 REQUIRE( result.m_age == Approx( 3.0 ) );
389 }
390
391 SECTION( "zero-flow threshold status parses as a valid zero reading" )
392 {
393 const std::string contents = "1775430287 145131374\n"
394 "36 | CHA_FAN1 | Fan | 0.00 | RPM | "
395 "'At or Below (<=) Lower Non-Recoverable Threshold'\n";
396
397 REQUIRE( app.parseFileContents( result, contents, now ) == 0 );
398 REQUIRE( result.m_status == flowRPM::parseStatus::success );
399 REQUIRE( result.m_flowRate == Approx( 0.0 ) );
400 REQUIRE( result.m_age == Approx( 3.0 ) );
401 }
402
403 SECTION( "wrong descriptor is rejected" )
404 {
405 const std::string contents = "1775430287 145131374\n"
406 "36 | OTHER_FAN | Fan | 1900.00 | RPM | 'OK'\n";
407
408 REQUIRE( app.parseFileContents( result, contents, now ) == 0 );
409 REQUIRE( result.m_status == flowRPM::parseStatus::wrongDescriptor );
410 REQUIRE( result.m_flowRate == app.badValue() );
411 }
412
413 SECTION( "wrong units are rejected" )
414 {
415 const std::string contents = "1775430287 145131374\n"
416 "36 | CHA_FAN1 | Fan | 1900.00 | LPM | 'OK'\n";
417
418 REQUIRE( app.parseFileContents( result, contents, now ) == 0 );
419 REQUIRE( result.m_status == flowRPM::parseStatus::wrongUnits );
420 }
421
422 SECTION( "bad status is rejected" )
423 {
424 const std::string contents = "1775430287 145131374\n"
425 "36 | CHA_FAN1 | Fan | 1900.00 | RPM | 'BAD'\n";
426
427 REQUIRE( app.parseFileContents( result, contents, now ) == 0 );
428 REQUIRE( result.m_status == flowRPM::parseStatus::badStatus );
429 }
430
431 SECTION( "partial writes are reported as missing records" )
432 {
433 const std::string contents = "1775430287 145131374\n";
434
435 REQUIRE( app.parseFileContents( result, contents, now ) == 0 );
436 REQUIRE( result.m_status == flowRPM::parseStatus::missingRecord );
437 REQUIRE( result.m_age == app.badValue() );
438 }
439
440 SECTION( "malformed timestamps are rejected" )
441 {
442 const std::string contents = "1775430287 nope\n"
443 "36 | CHA_FAN1 | Fan | 1900.00 | RPM | 'OK'\n";
444
445 REQUIRE( app.parseFileContents( result, contents, now ) == 0 );
446 REQUIRE( result.m_status == flowRPM::parseStatus::malformedTimestamp );
447 }
448
449 SECTION( "timestamps with trailing tokens are rejected" )
450 {
451 const std::string contents = "1775430287 145131374 extra\n"
452 "36 | CHA_FAN1 | Fan | 1900.00 | RPM | 'OK'\n";
453
454 REQUIRE( app.parseFileContents( result, contents, now ) == 0 );
455 REQUIRE( result.m_status == flowRPM::parseStatus::malformedTimestamp );
456 }
457
458 SECTION( "malformed numeric values are rejected" )
459 {
460 const std::string contents = "1775430287 145131374\n"
461 "36 | CHA_FAN1 | Fan | nineteen | RPM | 'OK'\n";
462
463 REQUIRE( app.parseFileContents( result, contents, now ) == 0 );
464 REQUIRE( result.m_status == flowRPM::parseStatus::badValue );
465 }
466
467 SECTION( "stale readings are marked invalid while retaining age" )
468 {
469 const timespec nowStale{ 1775430400, 145131374 };
470 const std::string contents = "1775430287 145131374\n"
471 "36 | CHA_FAN1 | Fan | 1900.00 | RPM | 'OK'\n";
472
473 REQUIRE( app.parseFileContents( result, contents, nowStale ) == 0 );
474 REQUIRE( result.m_status == flowRPM::parseStatus::staleReading );
475 REQUIRE( result.m_flowRate == app.badValue() );
476 REQUIRE( result.m_age == Approx( 113.0 ) );
477 }
478
479 SECTION( "extra non-empty lines are rejected when only one sensor row is expected" )
480 {
481 const std::string contents = "1775430287 145131374\n"
482 "36 | CHA_FAN1 | Fan | 1900.00 | RPM | 'OK'\n"
483 "37 | CHA_FAN2 | Fan | 1900.00 | RPM | 'OK'\n";
484
485 REQUIRE( app.parseFileContents( result, contents, now ) == 0 );
486 REQUIRE( result.m_status == flowRPM::parseStatus::malformedRecord );
487 }
488}
489
490/// Verify `readAndParse()` reads through the configured path and handles missing files.
491/**
492 * \ingroup flowRPM_unit_test
493 */
494TEST_CASE( "flowRPM reads and parses configured files", "[flowRPM]" )
495{
496 flowRPM app;
498 const timespec now{ 1775430290, 145131374 };
499 // clang-format off
500 #ifdef FLOWRPM_TEST_DOXYGEN_REF
501 flowRPM::readAndParse( result, now );
502 #endif
503 // clang-format on
504
505 SECTION( "a missing file reports fileReadError without crashing" )
506 {
507 app.m_inputPath = "/tmp/flowRPM_missing_file.txt";
508
509 REQUIRE( app.readAndParse( result, now ) == 0 );
510 REQUIRE( result.m_status == flowRPM::parseStatus::fileReadError );
511 REQUIRE( result.m_flowRate == Approx( app.badValue() ) );
512 }
513
514 SECTION( "a present file is parsed through the configured path" )
515 {
516 const std::string path = "/tmp/flowRPM_read_and_parse.txt";
517 std::ofstream ofs( path.c_str() );
518
519 ofs << "1775430287 145131374\n";
520 ofs << "36 | CHA_FAN1 | Fan | 1900.00 | RPM | 'OK'\n";
521 ofs.close();
522
523 app.m_inputPath = path;
524
525 REQUIRE( app.readAndParse( result, now ) == 0 );
526 REQUIRE( result.m_status == flowRPM::parseStatus::success );
527 REQUIRE( result.m_flowRate == Approx( 1.9 ) );
528 REQUIRE( result.m_age == Approx( 3.0 ) );
529 }
530}
531
532/// Verify `appStartup()` initializes the published state and transitions to READY.
533/**
534 * \ingroup flowRPM_unit_test
535 */
536TEST_CASE( "flowRPM appStartup initializes state and published status", "[flowRPM]" )
537{
538 flowRPM app;
539
540 REQUIRE( app.appStartup() == 0 );
541 // clang-format off
542 #ifdef FLOWRPM_TEST_DOXYGEN_REF
544 #endif
545 // clang-format on
546 REQUIRE( app.state() == stateCodes::READY );
547 REQUIRE( indiNumberValue( app.m_indiP_status, "flow_rate" ) == Approx( app.badValue() ) );
548 REQUIRE( indiNumberValue( app.m_indiP_status, "age" ) == Approx( app.badValue() ) );
549}
550
551/// Verify `appShutdown()` completes cleanly.
552/**
553 * \ingroup flowRPM_unit_test
554 */
555TEST_CASE( "flowRPM appShutdown completes cleanly", "[flowRPM]" )
556{
557 flowRPM app;
558
559 REQUIRE( app.appStartup() == 0 );
560 REQUIRE( app.appShutdown() == 0 );
561 // clang-format off
562 #ifdef FLOWRPM_TEST_DOXYGEN_REF
565 #endif
566 // clang-format on
567}
568
569/// Verify repeated error logging is rate-limited per status key.
570/**
571 * \ingroup flowRPM_unit_test
572 */
573TEST_CASE( "flowRPM log backoff is per error key and interval", "[flowRPM]" )
574{
575 flowRPM app;
576 // clang-format off
577 #ifdef FLOWRPM_TEST_DOXYGEN_REF
578 flowRPM::shouldLogError( "", timespec{ 0, 0 } );
579 #endif
580 // clang-format on
581
582 REQUIRE( app.shouldLogError( "open_failed", timespec{ 10, 0 } ) == true );
583 REQUIRE( app.shouldLogError( "open_failed", timespec{ 20, 0 } ) == false );
584 REQUIRE( app.shouldLogError( "timestamp_parse_failed", timespec{ 21, 0 } ) == true );
585 REQUIRE( app.shouldLogError( "timestamp_parse_failed", timespec{ 82, 0 } ) == true );
586}
587
588/// Verify `recordTelem()` forces the telemeter bookkeeping update.
589/**
590 * \ingroup flowRPM_unit_test
591 */
592TEST_CASE( "flowRPM recordTelem forces telemetry bookkeeping refresh", "[flowRPM]" )
593{
594 flowRPM app;
595
596 app.m_flowRate = 2.5;
597 app.m_age = 7.0;
598 app.m_haveValidReading = true;
599 app.m_lastTelemFlowRate = std::numeric_limits<double>::quiet_NaN();
600 app.m_lastTelemValid = false;
601
602 REQUIRE( app.recordTelem( static_cast<const MagAOX::logger::telem_flowrpm *>( nullptr ) ) == 0 );
603 // clang-format off
604 #ifdef FLOWRPM_TEST_DOXYGEN_REF
605 flowRPM::recordTelem( static_cast<const MagAOX::logger::telem_flowrpm *>( nullptr ) );
606 #endif
607 // clang-format on
608 REQUIRE( app.m_lastTelemFlowRate == Approx( 2.5 ) );
609 REQUIRE( app.m_lastTelemValid == true );
610}
611
612/// Verify `appLogic()` executes the nominal runtime control flow.
613/**
614 * \ingroup flowRPM_unit_test
615 */
616TEST_CASE( "flowRPM appLogic drives end-to-end display-state updates", "[flowRPM]" )
617{
618 flowRPM app;
619
620 REQUIRE( app.appStartup() == 0 );
621 // clang-format off
622 #ifdef FLOWRPM_TEST_DOXYGEN_REF
625 #endif
626 // clang-format on
627
628 SECTION( "a valid file publishes a fresh good reading" )
629 {
630 timespec now;
631 REQUIRE( clock_gettime( CLOCK_REALTIME, &now ) == 0 );
632
633 const std::string path = "/tmp/flowRPM_app_logic_valid.txt";
634 std::ofstream ofs( path.c_str() );
635
636 ofs << now.tv_sec << ' ' << now.tv_nsec << '\n';
637 ofs << "36 | CHA_FAN1 | Fan | 1900.00 | RPM | 'OK'\n";
638 ofs.close();
639
640 app.m_inputPath = path;
641
642 REQUIRE( app.appLogic() == 0 );
643 REQUIRE( app.haveValidReading() == true );
644 REQUIRE( app.flowRate() == Approx( 1.9 ) );
645 REQUIRE( app.age() >= 0.0 );
646 REQUIRE( app.age() < app.maxAge() );
647 REQUIRE( app.m_lastErrorKey.empty() );
648 }
649
650 SECTION( "a missing file publishes the sentinel and records the error key" )
651 {
652 app.m_inputPath = "/tmp/flowRPM_app_logic_missing.txt";
653 std::filesystem::remove( app.m_inputPath );
654
655 REQUIRE( app.appLogic() == 0 );
656 REQUIRE( app.haveValidReading() == false );
657 REQUIRE( app.flowRate() == Approx( app.badValue() ) );
658 REQUIRE( app.age() == Approx( app.badValue() ) );
659 REQUIRE( app.m_lastErrorKey == "open_failed" );
660 REQUIRE( indiNumberValue( app.m_indiP_status, "flow_rate" ) == Approx( app.badValue() ) );
661 REQUIRE( indiNumberValue( app.m_indiP_status, "age" ) == Approx( app.badValue() ) );
662 }
663
664 SECTION( "a valid file clears a previously latched error key" )
665 {
666 timespec now;
667 REQUIRE( clock_gettime( CLOCK_REALTIME, &now ) == 0 );
668
669 const std::string path = "/tmp/flowRPM_app_logic_recovery.txt";
670 std::ofstream ofs;
671
672 app.m_inputPath = path;
673 std::filesystem::remove( path );
674
675 REQUIRE( app.appLogic() == 0 );
676 REQUIRE( app.m_lastErrorKey == "open_failed" );
677
678 ofs.open( path.c_str() );
679 ofs << now.tv_sec << ' ' << now.tv_nsec << '\n';
680 ofs << "36 | CHA_FAN1 | Fan | 1900.00 | RPM | 'OK'\n";
681 ofs.close();
682
683 REQUIRE( app.appLogic() == 0 );
684 REQUIRE( app.haveValidReading() == true );
685 REQUIRE( app.flowRate() == Approx( 1.9 ) );
686 REQUIRE( app.m_lastErrorKey.empty() );
687 }
688
689 SECTION( "a transient partial write preserves the last good display state" )
690 {
691 timespec now;
692 REQUIRE( clock_gettime( CLOCK_REALTIME, &now ) == 0 );
693
694 const std::string path = "/tmp/flowRPM_app_logic_partial.txt";
695 std::ofstream ofs( path.c_str() );
696
697 ofs << now.tv_sec << ' ' << now.tv_nsec << '\n';
698 ofs << "36 | CHA_FAN1 | Fan | 1900.00 | RPM | 'OK'\n";
699 ofs.close();
700
701 app.m_inputPath = path;
702
703 REQUIRE( app.appLogic() == 0 );
704 REQUIRE( app.haveValidReading() == true );
705 REQUIRE( app.flowRate() == Approx( 1.9 ) );
706
707 REQUIRE( clock_gettime( CLOCK_REALTIME, &now ) == 0 );
708 ofs.open( path.c_str() );
709 ofs << now.tv_sec << ' ' << now.tv_nsec << '\n';
710 ofs.close();
711
712 REQUIRE( app.appLogic() == 0 );
713 REQUIRE( app.haveValidReading() == true );
714 REQUIRE( app.flowRate() == Approx( 1.9 ) );
715 REQUIRE( app.age() >= 0.0 );
716 REQUIRE( app.age() < app.maxAge() );
717 REQUIRE( app.m_lastErrorKey.empty() );
718 }
719}
720
721/// Verify `appLogic()` returns errors when internal runtime steps fail.
722/**
723 * \ingroup flowRPM_unit_test
724 */
725TEST_CASE( "flowRPM appLogic returns errors when internal steps fail", "[flowRPM]" )
726{
727 flowRPMFaultInject app;
728
729 REQUIRE( app.appStartup() == 0 );
730 // clang-format off
731 #ifdef FLOWRPM_TEST_DOXYGEN_REF
734 #endif
735 // clang-format on
736
737 SECTION( "readAndParse failures propagate as appLogic errors" )
738 {
739 app.fault( flowRPMFaultInject::faultMode::readAndParse );
740
741 REQUIRE( app.appLogic() == -1 );
742 }
743
744 SECTION( "publishResult failures propagate as appLogic errors" )
745 {
746 app.fault( flowRPMFaultInject::faultMode::publishResult );
747
748 REQUIRE( app.appLogic() == -1 );
749 }
750
751 SECTION( "recordFlow failures propagate as appLogic errors" )
752 {
753 app.fault( flowRPMFaultInject::faultMode::recordFlow );
754
755 REQUIRE( app.appLogic() == -1 );
756 }
757}
758
759/// Verify `reconcileResult()` and `publishResult()` manage held and invalid display state.
760/**
761 * \ingroup flowRPM_unit_test
762 */
763TEST_CASE( "flowRPM display-state reconciliation", "[flowRPM]" )
764{
766 flowRPM::parseResult lastGood;
767 flowRPM::parseResult partialWrite;
768 flowRPM app;
769 // clang-format off
770 #ifdef FLOWRPM_TEST_DOXYGEN_REF
771 flowRPM::publishResult( result );
772 flowRPM::reconcileResult( result, timespec{ 0, 0 } );
773 #endif
774 // clang-format on
775
776 SECTION( "publishResult updates valid and invalid states" )
777 {
778 result.m_status = flowRPM::parseStatus::success;
779 result.m_flowRate = 1.9;
780 result.m_age = 3.0;
781 result.m_sourceTs = timespec{ 1, 2 };
782
783 REQUIRE( app.publishResult( result ) == 0 );
784 REQUIRE( app.haveValidReading() == true );
785 REQUIRE( app.flowRate() == Approx( 1.9 ) );
786 REQUIRE( app.age() == Approx( 3.0 ) );
787
788 result.m_status = flowRPM::parseStatus::missingRecord;
789 result.m_flowRate = app.badValue();
790 result.m_age = app.badValue();
791
792 REQUIRE( app.publishResult( result ) == 0 );
793 REQUIRE( app.haveValidReading() == false );
794 REQUIRE( app.flowRate() == Approx( app.badValue() ) );
795 }
796
797 SECTION( "last good value is held through transient partial-write failures" )
798 {
799 lastGood.m_status = flowRPM::parseStatus::success;
800 lastGood.m_flowRate = 1.9;
801 lastGood.m_age = 2.0;
802 lastGood.m_sourceTs = timespec{ 100, 0 };
803
804 REQUIRE( app.publishResult( lastGood ) == 0 );
805
806 partialWrite.m_status = flowRPM::parseStatus::missingRecord;
807 partialWrite.m_flowRate = app.badValue();
808 partialWrite.m_age = app.badValue();
809
810 flowRPM::parseResult display = app.reconcileResult( partialWrite, timespec{ 120, 0 } );
811
812 REQUIRE( display.m_status == flowRPM::parseStatus::success );
813 REQUIRE( display.m_flowRate == Approx( 1.9 ) );
814 REQUIRE( display.m_age == Approx( 20.0 ) );
815 }
816
817 SECTION( "sentinel is published once the last good value ages past maxAge" )
818 {
819 lastGood.m_status = flowRPM::parseStatus::success;
820 lastGood.m_flowRate = 1.9;
821 lastGood.m_age = 2.0;
822 lastGood.m_sourceTs = timespec{ 100, 0 };
823
824 REQUIRE( app.publishResult( lastGood ) == 0 );
825
826 partialWrite.m_status = flowRPM::parseStatus::missingRecord;
827 partialWrite.m_flowRate = app.badValue();
828 partialWrite.m_age = app.badValue();
829
830 flowRPM::parseResult display = app.reconcileResult( partialWrite, timespec{ 161, 0 } );
831
832 REQUIRE( display.m_status == flowRPM::parseStatus::staleReading );
833 REQUIRE( display.m_flowRate == Approx( app.badValue() ) );
834 REQUIRE( display.m_age == Approx( 61.0 ) );
835 }
836}
837
838/// Verify `statusKey()` maps every parser status to a stable log key.
839/**
840 * \ingroup flowRPM_unit_test
841 */
842TEST_CASE( "flowRPM statusKey maps parse statuses consistently", "[flowRPM]" )
843{
844 // clang-format off
845 #ifdef FLOWRPM_TEST_DOXYGEN_REF
846 flowRPM::statusKey( flowRPM::parseStatus::success );
847 #endif
848 // clang-format on
849 REQUIRE( flowRPM::statusKey( flowRPM::parseStatus::success ) == "success" );
850 REQUIRE( flowRPM::statusKey( flowRPM::parseStatus::fileReadError ) == "open_failed" );
851 REQUIRE( flowRPM::statusKey( flowRPM::parseStatus::missingTimestamp ) == "missing_timestamp" );
852 REQUIRE( flowRPM::statusKey( flowRPM::parseStatus::malformedTimestamp ) == "timestamp_parse_failed" );
853 REQUIRE( flowRPM::statusKey( flowRPM::parseStatus::missingRecord ) == "missing_record" );
854 REQUIRE( flowRPM::statusKey( flowRPM::parseStatus::malformedRecord ) == "record_parse_failed" );
855 REQUIRE( flowRPM::statusKey( flowRPM::parseStatus::wrongDescriptor ) == "wrong_descriptor" );
856 REQUIRE( flowRPM::statusKey( flowRPM::parseStatus::wrongUnits ) == "wrong_units" );
857 REQUIRE( flowRPM::statusKey( flowRPM::parseStatus::badStatus ) == "bad_status" );
858 REQUIRE( flowRPM::statusKey( flowRPM::parseStatus::badValue ) == "bad_value" );
859 REQUIRE( flowRPM::statusKey( flowRPM::parseStatus::staleReading ) == "stale" );
860 REQUIRE( flowRPM::statusKey( static_cast<flowRPM::parseStatus>( 999 ) ) == "unknown" );
861}
862
863} // namespace flowRPMTest
864} // namespace libXWCTest
stateCodes::stateCodeT state()
Get the current state code.
int m_shutdown
Flag to signal it's time to shutdown. When not 0, the main loop exits.
The MagAO-X flow-from-RPM monitor.
Definition flowRPM.hpp:41
parseStatus m_status
Outcome of the parse attempt.
Definition flowRPM.hpp:67
double m_age
Current published age in seconds or the bad-value sentinel.
Definition flowRPM.hpp:108
parseStatus
Status returned by the file parser.
Definition flowRPM.hpp:50
bool m_lastTelemValid
Last validity state written to telemetry.
Definition flowRPM.hpp:120
double m_flowRate
Displayed flow rate in LPM or the bad-value sentinel.
Definition flowRPM.hpp:68
virtual int readAndParse(parseResult &result, const timespec &now) const
Read the configured file and parse its current contents.
Definition flowRPM.hpp:597
const std::string & fanDescriptor() const
Get the configured fan descriptor.
Definition flowRPM.hpp:658
int recordTelem(const logger::telem_flowrpm *)
Record telemetry when requested by the telemeter helper.
Definition flowRPM.hpp:693
parseResult reconcileResult(const parseResult &result, const timespec &now) const
Reconcile a newly parsed result against the currently displayed state.
Definition flowRPM.hpp:711
double m_flowRate
Current published flow rate in LPM or the bad-value sentinel.
Definition flowRPM.hpp:105
virtual void setupConfig()
Set up the application configuration.
Definition flowRPM.hpp:330
double m_age
Displayed age in seconds or the bad-value sentinel.
Definition flowRPM.hpp:69
std::string m_lastErrorKey
Key for the most recently logged error class.
Definition flowRPM.hpp:126
double badValue() const
Get the configured bad-value sentinel.
Definition flowRPM.hpp:663
int parseTimestamp(timespec &ts, const std::string &line) const
Parse a source timestamp line into a timespec.
Definition flowRPM.hpp:477
virtual void loadConfig()
Load the application configuration.
Definition flowRPM.hpp:394
const std::string & inputPath() const
Get the configured input path.
Definition flowRPM.hpp:648
parseStatus parseRecordLine(double &flowRate, const std::string &line) const
Parse the sensor record line into a flow rate in LPM.
Definition flowRPM.hpp:505
bool m_haveValidReading
Whether the current displayed value is valid.
Definition flowRPM.hpp:114
static std::string statusKey(parseStatus status)
Get the string key used for a parse status in log rate limiting.
Definition flowRPM.hpp:617
double flowRate() const
Get the currently published flow rate.
Definition flowRPM.hpp:673
std::string m_inputPath
Path to the file written by the systemd producer.
Definition flowRPM.hpp:83
double age() const
Get the currently published age.
Definition flowRPM.hpp:678
virtual int appLogic()
Implementation of the FSM for flowRPM.
Definition flowRPM.hpp:418
pcf::IndiProperty m_indiP_status
Read-only status property exposing flow rate and age.
Definition flowRPM.hpp:129
timespec m_sourceTs
Parsed source timestamp when available.
Definition flowRPM.hpp:70
bool haveValidReading() const
Get whether the current published value is valid.
Definition flowRPM.hpp:683
virtual int recordFlow(bool force=false)
Record the currently displayed flow state to telemetry.
Definition flowRPM.hpp:698
virtual int publishResult(const parseResult &result)
Publish a parse result to INDI and runtime state.
Definition flowRPM.hpp:744
virtual int appStartup()
Perform application startup.
Definition flowRPM.hpp:402
virtual int appShutdown()
Shut the application down.
Definition flowRPM.hpp:470
double m_lastTelemFlowRate
Last flow value written to telemetry.
Definition flowRPM.hpp:117
bool shouldLogError(const std::string &key, const timespec &now)
Determine whether a repeated error should be logged now.
Definition flowRPM.hpp:759
double maxAge() const
Get the configured maximum reading age in seconds.
Definition flowRPM.hpp:653
int parseFileContents(parseResult &result, const std::string &contents, const timespec &now) const
Parse the two-line file contents using a supplied current time.
Definition flowRPM.hpp:543
double errorLogInterval() const
Get the configured repeated-error log interval.
Definition flowRPM.hpp:668
Parsed result of the current file contents.
Definition flowRPM.hpp:66
TEST_CASE("flowRPM configuration defaults load correctly", "[flowRPM]")
Verify default flowRPM configuration values are loaded.
@ READY
The device is ready for operation, but is not operating.
@ none
Don't publish.
std::vector< std::string > splitLogicalLines(const std::string &contents)
Split file contents into non-empty logical lines.
Definition flowRPM.hpp:295
std::string trimToken(const std::string &token)
Trim leading and trailing ASCII whitespace from a token.
Definition flowRPM.hpp:248
Namespace for all libXWC tests.
Log entry recording the displayed flow value and source age.