8#include "../../../tests/testXWC.hpp"
12#define protected public
13#include "../flowRPM.hpp"
37double indiNumberValue(
const pcf::IndiProperty &property,
const std::string &element )
39 return std::stod( property[element].getValue() );
43class flowRPMLoadConfigFailure :
public flowRPM
47 int loadConfigImpl( mx::app::appConfigurator &_config )
override
49 static_cast<void>( _config );
55class flowRPMFaultInject :
public flowRPM
68 void fault( faultMode mode )
74 int readAndParse( parseResult &result,
78 if( m_faultMode == faultMode::readAndParse )
83 result.m_status = parseStatus::success;
86 result.m_sourceTs = now;
92 int publishResult(
const parseResult &result )
override
94 if( m_faultMode == faultMode::publishResult )
103 int recordFlow(
bool force =
false )
override
105 if( m_faultMode == faultMode::recordFlow )
114 faultMode m_faultMode{ faultMode::none };
123TEST_CASE(
"flowRPM configuration defaults load correctly",
"[flowRPM]" )
129 mx::app::writeConfigFile(
"/tmp/flowRPM_test.conf", {
"none" }, {
"nada" }, {
"0" } );
130 app.config.readConfig(
"/tmp/flowRPM_test.conf" );
134 #ifdef FLOWRPM_TEST_DOXYGEN_REF
141 REQUIRE( app.
inputPath() ==
"/tmp/fac_flow.txt" );
142 REQUIRE( app.
maxAge() == Approx( 60.0 ) );
144 REQUIRE( app.
badValue() == Approx( -999.0 ) );
152TEST_CASE(
"flowRPM configuration overrides load correctly",
"[flowRPM]" )
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" );
166 #ifdef FLOWRPM_TEST_DOXYGEN_REF
173 REQUIRE( app.
inputPath() ==
"/tmp/custom_flow.txt" );
174 REQUIRE( app.
maxAge() == Approx( 12.5 ) );
176 REQUIRE( app.
badValue() == Approx( -321.0 ) );
184TEST_CASE(
"flowRPM loadConfig sets shutdown on configuration failure",
"[flowRPM]" )
186 flowRPMLoadConfigFailure app;
191 #ifdef FLOWRPM_TEST_DOXYGEN_REF
197 REQUIRE( app.m_shutdown == 1 );
204TEST_CASE(
"flowRPM helper token parsing handles whitespace-only input",
"[flowRPM]" )
207 #ifdef FLOWRPM_TEST_DOXYGEN_REF
219TEST_CASE(
"flowRPM helper logical-line splitting handles CRLF and blank lines",
"[flowRPM]" )
222 #ifdef FLOWRPM_TEST_DOXYGEN_REF
226 const std::vector<std::string> lines =
228 "36 | CHA_FAN1 | Fan | 1900.00 | RPM | 'OK'\r\n" );
230 REQUIRE( lines.size() == 2 );
231 REQUIRE( lines[0] ==
"1775430287 145131374" );
232 REQUIRE( lines[1] ==
"36 | CHA_FAN1 | Fan | 1900.00 | RPM | 'OK'" );
239TEST_CASE(
"flowRPM parseResult defaults to the invalid sentinel state",
"[flowRPM]" )
242 #ifdef FLOWRPM_TEST_DOXYGEN_REF
248 REQUIRE( result.
m_status == flowRPM::parseStatus::fileReadError );
249 REQUIRE( result.
m_flowRate == Approx( -999.0 ) );
250 REQUIRE( result.
m_age == Approx( -999.0 ) );
259TEST_CASE(
"flowRPM timestamp parsing covers valid and malformed inputs",
"[flowRPM]" )
264 #ifdef FLOWRPM_TEST_DOXYGEN_REF
269 SECTION(
"a valid timestamp parses successfully" )
272 REQUIRE( ts.tv_sec == 1775430287 );
273 REQUIRE( ts.tv_nsec == 145131374 );
276 SECTION(
"a trailing token is rejected" )
278 REQUIRE( app.
parseTimestamp( ts,
"1775430287 145131374 extra" ) == -1 );
281 SECTION(
"a negative nanosecond field is rejected" )
286 SECTION(
"an overflowing nanosecond field is rejected" )
288 REQUIRE( app.
parseTimestamp( ts,
"1775430287 1000000000" ) == -1 );
296TEST_CASE(
"flowRPM record-line parsing covers all parse branches",
"[flowRPM]" )
301 #ifdef FLOWRPM_TEST_DOXYGEN_REF
306 SECTION(
"a valid record converts RPM to LPM" )
308 REQUIRE( app.
parseRecordLine( flowRate,
"36 | CHA_FAN1 | Fan | 1900.00 | RPM | 'OK'" ) ==
309 flowRPM::parseStatus::success );
310 REQUIRE( flowRate == Approx( 1.9 ) );
313 SECTION(
"the zero-flow threshold status is accepted as a valid reading" )
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 ) );
322 SECTION(
"the wrong field count is rejected" )
324 REQUIRE( app.
parseRecordLine( flowRate,
"36 | CHA_FAN1 | Fan | 1900.00 | RPM" ) ==
325 flowRPM::parseStatus::malformedRecord );
328 SECTION(
"the wrong descriptor is rejected" )
330 REQUIRE( app.
parseRecordLine( flowRate,
"36 | OTHER_FAN | Fan | 1900.00 | RPM | 'OK'" ) ==
331 flowRPM::parseStatus::wrongDescriptor );
334 SECTION(
"the wrong units are rejected" )
336 REQUIRE( app.
parseRecordLine( flowRate,
"36 | CHA_FAN1 | Fan | 1900.00 | LPM | 'OK'" ) ==
337 flowRPM::parseStatus::wrongUnits );
340 SECTION(
"a non-OK status is rejected" )
342 REQUIRE( app.
parseRecordLine( flowRate,
"36 | CHA_FAN1 | Fan | 1900.00 | RPM | 'BAD'" ) ==
343 flowRPM::parseStatus::badStatus );
346 SECTION(
"a malformed numeric field is rejected" )
348 REQUIRE( app.
parseRecordLine( flowRate,
"36 | CHA_FAN1 | Fan | 19x | RPM | 'OK'" ) ==
349 flowRPM::parseStatus::badValue );
360 timespec now{ 1775430290, 145131374 };
363 #ifdef FLOWRPM_TEST_DOXYGEN_REF
368 SECTION(
"empty contents are reported as missing timestamps" )
371 REQUIRE( result.
m_status == flowRPM::parseStatus::missingTimestamp );
374 SECTION(
"whitespace-only contents are reported as missing timestamps" )
377 REQUIRE( result.
m_status == flowRPM::parseStatus::missingTimestamp );
380 SECTION(
"valid two-line input parses successfully" )
382 const std::string contents =
"1775430287 145131374\n"
383 "36 | CHA_FAN1 | Fan | 1900.00 | RPM | 'OK'\n";
386 REQUIRE( result.
m_status == flowRPM::parseStatus::success );
387 REQUIRE( result.
m_flowRate == Approx( 1.9 ) );
388 REQUIRE( result.
m_age == Approx( 3.0 ) );
391 SECTION(
"zero-flow threshold status parses as a valid zero reading" )
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";
398 REQUIRE( result.
m_status == flowRPM::parseStatus::success );
399 REQUIRE( result.
m_flowRate == Approx( 0.0 ) );
400 REQUIRE( result.
m_age == Approx( 3.0 ) );
403 SECTION(
"wrong descriptor is rejected" )
405 const std::string contents =
"1775430287 145131374\n"
406 "36 | OTHER_FAN | Fan | 1900.00 | RPM | 'OK'\n";
409 REQUIRE( result.
m_status == flowRPM::parseStatus::wrongDescriptor );
413 SECTION(
"wrong units are rejected" )
415 const std::string contents =
"1775430287 145131374\n"
416 "36 | CHA_FAN1 | Fan | 1900.00 | LPM | 'OK'\n";
419 REQUIRE( result.
m_status == flowRPM::parseStatus::wrongUnits );
422 SECTION(
"bad status is rejected" )
424 const std::string contents =
"1775430287 145131374\n"
425 "36 | CHA_FAN1 | Fan | 1900.00 | RPM | 'BAD'\n";
428 REQUIRE( result.
m_status == flowRPM::parseStatus::badStatus );
431 SECTION(
"partial writes are reported as missing records" )
433 const std::string contents =
"1775430287 145131374\n";
436 REQUIRE( result.
m_status == flowRPM::parseStatus::missingRecord );
440 SECTION(
"malformed timestamps are rejected" )
442 const std::string contents =
"1775430287 nope\n"
443 "36 | CHA_FAN1 | Fan | 1900.00 | RPM | 'OK'\n";
446 REQUIRE( result.
m_status == flowRPM::parseStatus::malformedTimestamp );
449 SECTION(
"timestamps with trailing tokens are rejected" )
451 const std::string contents =
"1775430287 145131374 extra\n"
452 "36 | CHA_FAN1 | Fan | 1900.00 | RPM | 'OK'\n";
455 REQUIRE( result.
m_status == flowRPM::parseStatus::malformedTimestamp );
458 SECTION(
"malformed numeric values are rejected" )
460 const std::string contents =
"1775430287 145131374\n"
461 "36 | CHA_FAN1 | Fan | nineteen | RPM | 'OK'\n";
464 REQUIRE( result.
m_status == flowRPM::parseStatus::badValue );
467 SECTION(
"stale readings are marked invalid while retaining age" )
469 const timespec nowStale{ 1775430400, 145131374 };
470 const std::string contents =
"1775430287 145131374\n"
471 "36 | CHA_FAN1 | Fan | 1900.00 | RPM | 'OK'\n";
474 REQUIRE( result.
m_status == flowRPM::parseStatus::staleReading );
476 REQUIRE( result.
m_age == Approx( 113.0 ) );
479 SECTION(
"extra non-empty lines are rejected when only one sensor row is expected" )
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";
486 REQUIRE( result.
m_status == flowRPM::parseStatus::malformedRecord );
494TEST_CASE(
"flowRPM reads and parses configured files",
"[flowRPM]" )
498 const timespec now{ 1775430290, 145131374 };
500 #ifdef FLOWRPM_TEST_DOXYGEN_REF
505 SECTION(
"a missing file reports fileReadError without crashing" )
510 REQUIRE( result.
m_status == flowRPM::parseStatus::fileReadError );
514 SECTION(
"a present file is parsed through the configured path" )
516 const std::string path =
"/tmp/flowRPM_read_and_parse.txt";
517 std::ofstream ofs( path.c_str() );
519 ofs <<
"1775430287 145131374\n";
520 ofs <<
"36 | CHA_FAN1 | Fan | 1900.00 | RPM | 'OK'\n";
526 REQUIRE( result.
m_status == flowRPM::parseStatus::success );
527 REQUIRE( result.
m_flowRate == Approx( 1.9 ) );
528 REQUIRE( result.
m_age == Approx( 3.0 ) );
536TEST_CASE(
"flowRPM appStartup initializes state and published status",
"[flowRPM]" )
542 #ifdef FLOWRPM_TEST_DOXYGEN_REF
555TEST_CASE(
"flowRPM appShutdown completes cleanly",
"[flowRPM]" )
562 #ifdef FLOWRPM_TEST_DOXYGEN_REF
573TEST_CASE(
"flowRPM log backoff is per error key and interval",
"[flowRPM]" )
577 #ifdef FLOWRPM_TEST_DOXYGEN_REF
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 );
592TEST_CASE(
"flowRPM recordTelem forces telemetry bookkeeping refresh",
"[flowRPM]" )
604 #ifdef FLOWRPM_TEST_DOXYGEN_REF
616TEST_CASE(
"flowRPM appLogic drives end-to-end display-state updates",
"[flowRPM]" )
622 #ifdef FLOWRPM_TEST_DOXYGEN_REF
628 SECTION(
"a valid file publishes a fresh good reading" )
631 REQUIRE( clock_gettime( CLOCK_REALTIME, &now ) == 0 );
633 const std::string path =
"/tmp/flowRPM_app_logic_valid.txt";
634 std::ofstream ofs( path.c_str() );
636 ofs << now.tv_sec <<
' ' << now.tv_nsec <<
'\n';
637 ofs <<
"36 | CHA_FAN1 | Fan | 1900.00 | RPM | 'OK'\n";
644 REQUIRE( app.
flowRate() == Approx( 1.9 ) );
645 REQUIRE( app.
age() >= 0.0 );
650 SECTION(
"a missing file publishes the sentinel and records the error key" )
652 app.
m_inputPath =
"/tmp/flowRPM_app_logic_missing.txt";
664 SECTION(
"a valid file clears a previously latched error key" )
667 REQUIRE( clock_gettime( CLOCK_REALTIME, &now ) == 0 );
669 const std::string path =
"/tmp/flowRPM_app_logic_recovery.txt";
673 std::filesystem::remove( path );
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";
685 REQUIRE( app.
flowRate() == Approx( 1.9 ) );
689 SECTION(
"a transient partial write preserves the last good display state" )
692 REQUIRE( clock_gettime( CLOCK_REALTIME, &now ) == 0 );
694 const std::string path =
"/tmp/flowRPM_app_logic_partial.txt";
695 std::ofstream ofs( path.c_str() );
697 ofs << now.tv_sec <<
' ' << now.tv_nsec <<
'\n';
698 ofs <<
"36 | CHA_FAN1 | Fan | 1900.00 | RPM | 'OK'\n";
705 REQUIRE( app.
flowRate() == Approx( 1.9 ) );
707 REQUIRE( clock_gettime( CLOCK_REALTIME, &now ) == 0 );
708 ofs.open( path.c_str() );
709 ofs << now.tv_sec <<
' ' << now.tv_nsec <<
'\n';
714 REQUIRE( app.
flowRate() == Approx( 1.9 ) );
715 REQUIRE( app.
age() >= 0.0 );
725TEST_CASE(
"flowRPM appLogic returns errors when internal steps fail",
"[flowRPM]" )
727 flowRPMFaultInject app;
729 REQUIRE( app.appStartup() == 0 );
731 #ifdef FLOWRPM_TEST_DOXYGEN_REF
737 SECTION(
"readAndParse failures propagate as appLogic errors" )
739 app.fault( flowRPMFaultInject::faultMode::readAndParse );
741 REQUIRE( app.appLogic() == -1 );
744 SECTION(
"publishResult failures propagate as appLogic errors" )
746 app.fault( flowRPMFaultInject::faultMode::publishResult );
748 REQUIRE( app.appLogic() == -1 );
751 SECTION(
"recordFlow failures propagate as appLogic errors" )
753 app.fault( flowRPMFaultInject::faultMode::recordFlow );
755 REQUIRE( app.appLogic() == -1 );
763TEST_CASE(
"flowRPM display-state reconciliation",
"[flowRPM]" )
770 #ifdef FLOWRPM_TEST_DOXYGEN_REF
776 SECTION(
"publishResult updates valid and invalid states" )
778 result.
m_status = flowRPM::parseStatus::success;
785 REQUIRE( app.
flowRate() == Approx( 1.9 ) );
786 REQUIRE( app.
age() == Approx( 3.0 ) );
788 result.
m_status = flowRPM::parseStatus::missingRecord;
797 SECTION(
"last good value is held through transient partial-write failures" )
799 lastGood.
m_status = flowRPM::parseStatus::success;
801 lastGood.
m_age = 2.0;
806 partialWrite.
m_status = flowRPM::parseStatus::missingRecord;
812 REQUIRE( display.
m_status == flowRPM::parseStatus::success );
813 REQUIRE( display.
m_flowRate == Approx( 1.9 ) );
814 REQUIRE( display.
m_age == Approx( 20.0 ) );
817 SECTION(
"sentinel is published once the last good value ages past maxAge" )
819 lastGood.
m_status = flowRPM::parseStatus::success;
821 lastGood.
m_age = 2.0;
826 partialWrite.
m_status = flowRPM::parseStatus::missingRecord;
832 REQUIRE( display.
m_status == flowRPM::parseStatus::staleReading );
834 REQUIRE( display.
m_age == Approx( 61.0 ) );
842TEST_CASE(
"flowRPM statusKey maps parse statuses consistently",
"[flowRPM]" )
845 #ifdef FLOWRPM_TEST_DOXYGEN_REF
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" );
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.
parseStatus m_status
Outcome of the parse attempt.
double m_age
Current published age in seconds or the bad-value sentinel.
parseStatus
Status returned by the file parser.
bool m_lastTelemValid
Last validity state written to telemetry.
double m_flowRate
Displayed flow rate in LPM or the bad-value sentinel.
virtual int readAndParse(parseResult &result, const timespec &now) const
Read the configured file and parse its current contents.
const std::string & fanDescriptor() const
Get the configured fan descriptor.
int recordTelem(const logger::telem_flowrpm *)
Record telemetry when requested by the telemeter helper.
parseResult reconcileResult(const parseResult &result, const timespec &now) const
Reconcile a newly parsed result against the currently displayed state.
double m_flowRate
Current published flow rate in LPM or the bad-value sentinel.
virtual void setupConfig()
Set up the application configuration.
double m_age
Displayed age in seconds or the bad-value sentinel.
std::string m_lastErrorKey
Key for the most recently logged error class.
double badValue() const
Get the configured bad-value sentinel.
int parseTimestamp(timespec &ts, const std::string &line) const
Parse a source timestamp line into a timespec.
virtual void loadConfig()
Load the application configuration.
const std::string & inputPath() const
Get the configured input path.
parseStatus parseRecordLine(double &flowRate, const std::string &line) const
Parse the sensor record line into a flow rate in LPM.
bool m_haveValidReading
Whether the current displayed value is valid.
static std::string statusKey(parseStatus status)
Get the string key used for a parse status in log rate limiting.
double flowRate() const
Get the currently published flow rate.
std::string m_inputPath
Path to the file written by the systemd producer.
double age() const
Get the currently published age.
virtual int appLogic()
Implementation of the FSM for flowRPM.
pcf::IndiProperty m_indiP_status
Read-only status property exposing flow rate and age.
timespec m_sourceTs
Parsed source timestamp when available.
bool haveValidReading() const
Get whether the current published value is valid.
virtual int recordFlow(bool force=false)
Record the currently displayed flow state to telemetry.
virtual int publishResult(const parseResult &result)
Publish a parse result to INDI and runtime state.
virtual int appStartup()
Perform application startup.
virtual int appShutdown()
Shut the application down.
double m_lastTelemFlowRate
Last flow value written to telemetry.
bool shouldLogError(const std::string &key, const timespec &now)
Determine whether a repeated error should be logged now.
double maxAge() const
Get the configured maximum reading age in seconds.
int parseFileContents(parseResult &result, const std::string &contents, const timespec &now) const
Parse the two-line file contents using a supplied current time.
double errorLogInterval() const
Get the configured repeated-error log interval.
Parsed result of the current file contents.
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.
std::vector< std::string > splitLogicalLines(const std::string &contents)
Split file contents into non-empty logical lines.
std::string trimToken(const std::string &token)
Trim leading and trailing ASCII whitespace from a token.
Namespace for all libXWC tests.
Log entry recording the displayed flow value and source age.