Skip to content

Commit 0154eda

Browse files
authored
Merge pull request #1041 from SignalK/feat/nullable-bool-tristate
feat(types): support Nullable<bool> via a flag-based specialization
2 parents ebe9a3a + 211b8c5 commit 0154eda

4 files changed

Lines changed: 253 additions & 16 deletions

File tree

platformio.ini

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -261,15 +261,17 @@ build_flags =
261261
${esp32c3.build_flags}
262262

263263
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
264-
; Host build for pure, dependency-free unit tests (e.g. the TOFU capture
265-
; decision logic in signalk_tofu.h). Does not build src/ -- which needs the
266-
; ESP toolchain -- so only the header under test is compiled. Run with:
264+
; Host build for unit tests that compile on the host toolchain (e.g. the TOFU
265+
; capture decision in signalk_tofu.h, or the Nullable<bool> type test, which
266+
; pulls in header-only ArduinoJson). Does not build src/ -- which needs the ESP
267+
; toolchain. Run with:
267268
; pio test -e native -f "native/*"
268269

269270
[env:native]
270271
platform = native
271272
test_build_src = false
272273
lib_deps =
274+
bblanchon/ArduinoJson @ ^7.0.0
273275
build_flags = -std=c++17 -I src
274276
; Override the inherited test_ignore: run host tests, skip on-target system tests.
275277
test_ignore = system/*

src/sensesp/types/nullable.h

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
#ifndef SENSESP_SRC_SENSESP_TYPES_NULLABLE_H_
22
#define SENSESP_SRC_SENSESP_TYPES_NULLABLE_H_
33

4-
#include <type_traits>
5-
64
#include "ArduinoJson.h"
75

86
namespace sensesp {
@@ -16,18 +14,11 @@ namespace sensesp {
1614
* Nullable holds T{} (0), so it is valid for types whose sentinel differs from
1715
* 0; use Nullable<T>::invalid() to obtain the invalid value explicitly.
1816
*
19-
* Nullable<bool> is intentionally unsupported: bool has only two values, so
20-
* there is no spare bit pattern to reserve as an invalid sentinel without
21-
* making one of `true`/`false` indistinguishable from "missing". Use a plain
22-
* bool, or a wider type if you need a distinct invalid state.
17+
* bool has no spare value to reserve as a sentinel, so Nullable<bool> is an
18+
* explicit specialization (below) that tracks validity with a separate flag.
2319
*/
2420
template <typename T>
2521
class Nullable {
26-
static_assert(
27-
!std::is_same<T, bool>::value,
28-
"Nullable<bool> is not supported: bool has no spare sentinel value. Use a "
29-
"plain bool, or a wider type if a distinct invalid state is needed.");
30-
3122
public:
3223
Nullable() : value_{} {}
3324
Nullable(T value) : value_{value} {}
@@ -64,9 +55,71 @@ class Nullable {
6455
static T invalid_value_;
6556
};
6657

58+
/**
59+
* @brief Validity-flag specialization of Nullable for bool.
60+
*
61+
* bool has no spare value to reserve as an invalid sentinel, so unlike the
62+
* primary template this specialization tracks validity with an explicit flag:
63+
* `true`, `false`, and invalid are all distinct. A default-constructed
64+
* Nullable<bool> is invalid (no value yet); construct from a bool for a valid
65+
* value, or use invalid() for the missing state.
66+
*
67+
* This mirrors the primary template's public interface by hand -- keep the two
68+
* in sync when that interface changes.
69+
*
70+
* Two intentional asymmetries with the primary template:
71+
* - invalid() returns a Nullable<bool> instance, not a bare bool: there is no
72+
* sentinel bool value to hand back. The only generic caller,
73+
* RepeatExpiring::repeat_function(), accepts it directly; generic code must
74+
* not assume invalid() yields a bare T.
75+
* - operator bool() and value() return the stored value regardless of validity
76+
* (an invalid Nullable<bool> reads as false), matching the primary's implicit
77+
* conversion. Check is_valid() before relying on the value.
78+
*/
79+
template <>
80+
class Nullable<bool> {
81+
public:
82+
Nullable() : value_{false}, valid_{false} {}
83+
Nullable(bool value) : value_{value}, valid_{true} {}
84+
Nullable(const Nullable<bool>& other) = default;
85+
Nullable<bool>& operator=(bool value) {
86+
value_ = value;
87+
valid_ = true;
88+
return *this;
89+
}
90+
Nullable<bool>& operator=(const Nullable<bool>& other) = default;
91+
operator bool() const {
92+
return value_;
93+
}
94+
95+
bool is_valid() const {
96+
return valid_;
97+
}
98+
99+
// Mirrors the primary template's write-makes-valid behavior: handing out
100+
// mutable storage marks the value valid (it cannot clear validity).
101+
bool* ptr() {
102+
valid_ = true;
103+
return &value_;
104+
}
105+
106+
static Nullable<bool> invalid() {
107+
return Nullable<bool>();
108+
}
109+
110+
bool value() const {
111+
return value_;
112+
}
113+
114+
private:
115+
bool value_;
116+
bool valid_;
117+
};
118+
67119
typedef Nullable<int> NullableInt;
68120
typedef Nullable<float> NullableFloat;
69121
typedef Nullable<double> NullableDouble;
122+
typedef Nullable<bool> NullableBool;
70123

71124
template <typename T>
72125
void convertFromJson(JsonVariantConst src, Nullable<T> &dst) {
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/**
2+
* @file nullable_bool_test.cpp
3+
* @brief Host tests for the Nullable<bool> tri-state specialization (#883).
4+
*
5+
* Unlike the numeric specializations, Nullable<bool> cannot use a sentinel
6+
* value (bool has no spare bit pattern), so it carries an explicit validity
7+
* flag: true / false / invalid are all distinct, and a valid `false` is NOT
8+
* the invalid state.
9+
*
10+
* pio test -e native -f native/test_nullable_bool
11+
*/
12+
13+
#include <unity.h>
14+
15+
#include "sensesp/types/nullable.h"
16+
17+
using namespace sensesp;
18+
19+
// ---------------------------------------------------------------------------
20+
// true / false / invalid / default are all distinct
21+
// ---------------------------------------------------------------------------
22+
23+
void test_true_is_valid(void) {
24+
Nullable<bool> b = true;
25+
TEST_ASSERT_TRUE(b.is_valid());
26+
TEST_ASSERT_TRUE(static_cast<bool>(b));
27+
TEST_ASSERT_TRUE(b.value());
28+
}
29+
30+
void test_false_is_valid(void) {
31+
// The bug #883 fixes: a valid `false` must be distinguishable from invalid.
32+
Nullable<bool> b = false;
33+
TEST_ASSERT_TRUE(b.is_valid());
34+
TEST_ASSERT_FALSE(static_cast<bool>(b));
35+
TEST_ASSERT_FALSE(b.value());
36+
}
37+
38+
void test_invalid_is_not_valid(void) {
39+
Nullable<bool> b = Nullable<bool>::invalid();
40+
TEST_ASSERT_FALSE(b.is_valid());
41+
// Documented contract: value()/operator bool() read the stored value even when
42+
// invalid (an invalid Nullable<bool> reads as false); check is_valid() first.
43+
TEST_ASSERT_FALSE(b.value());
44+
TEST_ASSERT_FALSE(static_cast<bool>(b));
45+
}
46+
47+
void test_default_is_invalid(void) {
48+
// No value yet -> unknown, not a valid false.
49+
Nullable<bool> b;
50+
TEST_ASSERT_FALSE(b.is_valid());
51+
}
52+
53+
// ---------------------------------------------------------------------------
54+
// Copy assignment carries the validity flag, not just the value
55+
// ---------------------------------------------------------------------------
56+
57+
void test_copy_assignment_preserves_validity(void) {
58+
Nullable<bool> src = false;
59+
Nullable<bool> dst;
60+
dst = src;
61+
TEST_ASSERT_TRUE(dst.is_valid());
62+
TEST_ASSERT_FALSE(dst.value());
63+
64+
dst = Nullable<bool>::invalid();
65+
TEST_ASSERT_FALSE(dst.is_valid());
66+
}
67+
68+
// ---------------------------------------------------------------------------
69+
// ptr() exposes mutable storage and marks the value valid (write-makes-valid)
70+
// ---------------------------------------------------------------------------
71+
72+
void test_ptr_write_marks_valid(void) {
73+
Nullable<bool> b; // invalid
74+
*b.ptr() = true;
75+
TEST_ASSERT_TRUE(b.is_valid());
76+
TEST_ASSERT_TRUE(b.value());
77+
}
78+
79+
// ---------------------------------------------------------------------------
80+
// JSON: valid true/false round-trip; invalid <-> null
81+
// ---------------------------------------------------------------------------
82+
83+
void test_to_json_valid_false_is_false(void) {
84+
JsonDocument doc;
85+
doc["v"] = Nullable<bool>(false);
86+
TEST_ASSERT_FALSE(doc["v"].isNull());
87+
TEST_ASSERT_FALSE(doc["v"].as<bool>());
88+
}
89+
90+
void test_to_json_valid_true_is_true(void) {
91+
JsonDocument doc;
92+
doc["v"] = Nullable<bool>(true);
93+
TEST_ASSERT_FALSE(doc["v"].isNull());
94+
TEST_ASSERT_TRUE(doc["v"].as<bool>());
95+
}
96+
97+
void test_to_json_invalid_is_null(void) {
98+
JsonDocument doc;
99+
doc["v"] = Nullable<bool>::invalid();
100+
TEST_ASSERT_TRUE(doc["v"].isNull());
101+
}
102+
103+
void test_from_json_false_is_valid(void) {
104+
JsonDocument doc;
105+
doc["v"] = false;
106+
Nullable<bool> b = doc["v"].as<Nullable<bool>>();
107+
TEST_ASSERT_TRUE(b.is_valid());
108+
TEST_ASSERT_FALSE(b.value());
109+
}
110+
111+
void test_from_json_true_is_valid(void) {
112+
JsonDocument doc;
113+
doc["v"] = true;
114+
Nullable<bool> b = doc["v"].as<Nullable<bool>>();
115+
TEST_ASSERT_TRUE(b.is_valid());
116+
TEST_ASSERT_TRUE(b.value());
117+
}
118+
119+
void test_from_json_null_is_invalid(void) {
120+
JsonDocument doc;
121+
doc["v"] = nullptr;
122+
Nullable<bool> b = doc["v"].as<Nullable<bool>>();
123+
TEST_ASSERT_FALSE(b.is_valid());
124+
}
125+
126+
// ---------------------------------------------------------------------------
127+
// The generic caller contract: RepeatExpiring<bool>::repeat_function() emits
128+
// `this->get().invalid()` on expiry (transforms/repeat.h). Pin that the
129+
// expression yields an invalid bool. (RepeatExpiring itself needs the event
130+
// loop and is exercised on-target in test/system/test_nullable.)
131+
// ---------------------------------------------------------------------------
132+
133+
void test_invalid_of_value_is_invalid(void) {
134+
Nullable<bool> last = true;
135+
Nullable<bool> expired = last.invalid();
136+
TEST_ASSERT_FALSE(expired.is_valid());
137+
}
138+
139+
int main(int, char**) {
140+
UNITY_BEGIN();
141+
RUN_TEST(test_true_is_valid);
142+
RUN_TEST(test_false_is_valid);
143+
RUN_TEST(test_invalid_is_not_valid);
144+
RUN_TEST(test_default_is_invalid);
145+
RUN_TEST(test_copy_assignment_preserves_validity);
146+
RUN_TEST(test_ptr_write_marks_valid);
147+
RUN_TEST(test_to_json_valid_false_is_false);
148+
RUN_TEST(test_to_json_valid_true_is_true);
149+
RUN_TEST(test_to_json_invalid_is_null);
150+
RUN_TEST(test_from_json_false_is_valid);
151+
RUN_TEST(test_from_json_true_is_valid);
152+
RUN_TEST(test_from_json_null_is_invalid);
153+
RUN_TEST(test_invalid_of_value_is_invalid);
154+
return UNITY_END();
155+
}

test/system/test_nullable/nullable_test.cpp

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@
99
* sentinel (e.g. float -> -1e9, uint8_t -> 0xff). A value is "invalid" only
1010
* when it equals that sentinel. A default-constructed Nullable holds T{} (0),
1111
* which is therefore VALID for types whose sentinel differs from 0 (int,
12-
* float, uint8_t). Nullable<bool> is unsupported and fails to compile (bool
13-
* has no spare sentinel value); see #883.
12+
* float, uint8_t). Nullable<bool> is a flag-based specialization: true, false,
13+
* and invalid are all distinct (see test/native/test_nullable_bool and #883).
1414
*/
1515

1616
#include <Arduino.h>
1717

18+
#include "sensesp/transforms/repeat.h"
1819
#include "sensesp/types/nullable.h"
1920
#include "unity.h"
2021

@@ -115,6 +116,30 @@ void test_nullable_from_json_value_is_valid() {
115116
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 12.0f, n.value());
116117
}
117118

119+
// ---------------------------------------------------------------------------
120+
// Nullable<bool>: flag-based, so false is valid and distinct from invalid
121+
// ---------------------------------------------------------------------------
122+
123+
void test_nullable_bool_tristate() {
124+
NullableBool t = true;
125+
NullableBool f = false;
126+
NullableBool inv = NullableBool::invalid();
127+
TEST_ASSERT_TRUE(t.is_valid());
128+
TEST_ASSERT_TRUE(f.is_valid());
129+
TEST_ASSERT_FALSE(inv.is_valid());
130+
TEST_ASSERT_FALSE(f.value());
131+
}
132+
133+
// RepeatExpiring<bool> is the #883 use case: its output type is Nullable<bool>
134+
// and repeat_function() emits this->get().invalid() on expiry. Instantiating it
135+
// compiles that emit path for bool; here we also check the valid path.
136+
void test_repeat_expiring_bool() {
137+
RepeatExpiring<bool> repeat(1000, 5000);
138+
repeat.set(false);
139+
TEST_ASSERT_TRUE(repeat.get().is_valid());
140+
TEST_ASSERT_FALSE(repeat.get().value());
141+
}
142+
118143
// ---------------------------------------------------------------------------
119144
// Test runner
120145
// ---------------------------------------------------------------------------
@@ -133,6 +158,8 @@ void setup() {
133158
RUN_TEST(test_nullable_to_json_valid_serializes_value);
134159
RUN_TEST(test_nullable_from_json_null_is_invalid);
135160
RUN_TEST(test_nullable_from_json_value_is_valid);
161+
RUN_TEST(test_nullable_bool_tristate);
162+
RUN_TEST(test_repeat_expiring_bool);
136163

137164
UNITY_END();
138165
}

0 commit comments

Comments
 (0)