Aria 2.0.0
C++23 MVVM framework (C++20 minimum) — reactive, coroutine-first, ABI-layered
Loading...
Searching...
No Matches
adapter_conformance.hpp
Go to the documentation of this file.
1#pragma once
2
3// ============================================================================
4// binding/testing/adapter_conformance.hpp
5// ----------------------------------------------------------------------------
6// A shared contract test battery for every `IViewAdapter` implementation.
7//
8// Why this exists
9// ---------------
10// Aria's selling point is "one ViewModel, many platforms". That only
11// holds if every adapter behaves **identically** for the small number
12// of operations `BindingEngine` actually exercises. When a new adapter
13// is written (AppKit, UIKit, JNI, WASM, headless-test, ...), the
14// platform author wants a single drop-in file that pins down the
15// contract -- not a fresh translation of the Qt tests into the local
16// idiom.
17//
18// How to use it
19// -------------
20// Implement a `Harness` class that knows how to build adapter + view
21// pairs on your platform, and knows how to simulate "a user typed /
22// toggled / clicked this widget". Then in your platform test target:
23//
24// TEST_CASE("MyAdapter conforms: text two-way") {
25// MyHarness h;
26// aria::binding::testing::conformance::run_text_two_way(h);
27// }
28// ... (one TEST_CASE per public entry point below)
29//
30// Harness concept (duck-typed -- no inheritance required)
31// -------------------------------------------------------
32// struct Harness {
33// // Return an owning adapter. Called once per TEST_CASE.
34// std::shared_ptr<IViewAdapter> make_adapter();
35//
36// // Return an owning view (a container the harness manages).
37// // The returned pair MUST be valid together for the lifetime
38// // of the returned handle -- destroying the handle must
39// // destroy the underlying native widget, so view-destroy
40// // semantics can be tested.
41// //
42// // `ViewHandle` is any RAII-holding-a-unique_ptr-or-similar
43// // that exposes `.view()` → `IView&`. Default-construct it
44// // via:
45// // auto h = harness.make_text_view();
46// // IView& v = h.view();
47// //
48// // (the concrete type stays a template parameter, so each
49// // platform can return its own RAII wrapper).
50// auto make_text_view();
51// auto make_bool_view();
52// auto make_int_view();
53// auto make_double_view();
54// auto make_click_view();
55//
56// // Simulate "a user typed `text` into this widget".
57// void user_type(IView& v, std::string text);
58// // Simulate "a user toggled this checkbox to `b`".
59// void user_toggle(IView& v, bool b);
60// // Simulate "a user set this spinbox/slider to `n`".
61// void user_set_int(IView& v, int n);
62// // Simulate "a user set this double spinbox to `d`".
63// void user_set_double(IView& v, double d);
64// // Simulate "a user clicked this button".
65// void user_click(IView& v);
66// };
67//
68// Every `run_*` function below executes doctest `CHECK` macros, so it
69// must be called from *inside* a TEST_CASE; the bundled CHECKs will be
70// reported against that case.
71// ============================================================================
72
73#include <doctest/doctest.h>
74
77#include "aria/property.hpp"
78
79#include <memory>
80#include <string>
81#include <utility>
82
84
85// ---------------------------------------------------------------------------
86// Small helper: construct a BindingEngine whose adapter came from the
87// harness. Every run_* function opens with this and tears it down via
88// normal RAII.
89// ---------------------------------------------------------------------------
90template<class Harness>
91[[nodiscard]] inline BindingEngine make_engine(Harness& h) {
92 return BindingEngine(h.make_adapter());
93}
94
95// ═══════════════════════════════════════════════════════════════════════
96// Text
97// ═══════════════════════════════════════════════════════════════════════
98
105template<class Harness>
106inline void run_text_two_way(Harness& h) {
107 auto vh = h.make_text_view();
108 IView& v = vh.view();
109 auto adapter = h.make_adapter();
110
111 adapter->set_text(v, "hello");
112 CHECK(adapter->get_text(v) == "hello");
113
114 int hits = 0;
115 std::string last;
116 auto sub = adapter->on_text_changed(v, [&](std::string_view sv) {
117 ++hits;
118 last = std::string(sv);
119 });
120
121 h.user_type(v, "world");
122 CHECK(hits == 1);
123 CHECK(last == "world");
124
125 // Releasing the subscription must detach the slot.
126 sub.release();
127 h.user_type(v, "after-release");
128 CHECK(hits == 1);
129}
130
132template<class Harness>
133inline void run_text_engine_two_way(Harness& h) {
134 auto engine = make_engine(h);
135 Property<std::string> p("alpha");
136
137 auto vh = h.make_text_view();
138 IView& v = vh.view();
139 engine.bind_text(p, v);
140
141 CHECK(engine.adapter().get_text(v) == "alpha");
142
143 p = "beta";
144 CHECK(engine.adapter().get_text(v) == "beta");
145
146 h.user_type(v, "gamma");
147 CHECK(p.get() == "gamma");
148}
149
150// ═══════════════════════════════════════════════════════════════════════
151// Bool
152// ═══════════════════════════════════════════════════════════════════════
153
154template<class Harness>
155inline void run_bool_two_way(Harness& h) {
156 auto vh = h.make_bool_view();
157 IView& v = vh.view();
158 auto adapter = h.make_adapter();
159
160 adapter->set_bool(v, true);
161 CHECK(adapter->get_bool(v));
162
163 int hits = 0;
164 bool last = false;
165 auto sub = adapter->on_bool_changed(v, [&](bool b) { ++hits; last = b; });
166
167 h.user_toggle(v, false);
168 CHECK(hits == 1);
169 CHECK_FALSE(last);
170}
171
172template<class Harness>
173inline void run_bool_engine_two_way(Harness& h) {
174 auto engine = make_engine(h);
175 Property<bool> p(false);
176
177 auto vh = h.make_bool_view();
178 IView& v = vh.view();
179 engine.bind_bool(p, v);
180
181 CHECK_FALSE(engine.adapter().get_bool(v));
182
183 p = true;
184 CHECK(engine.adapter().get_bool(v));
185
186 h.user_toggle(v, false);
187 CHECK_FALSE(p.get());
188}
189
190// ═══════════════════════════════════════════════════════════════════════
191// Int
192// ═══════════════════════════════════════════════════════════════════════
193
194template<class Harness>
195inline void run_int_two_way(Harness& h) {
196 auto vh = h.make_int_view();
197 IView& v = vh.view();
198 auto adapter = h.make_adapter();
199
200 adapter->set_int(v, 42);
201 CHECK(adapter->get_int(v) == 42);
202
203 int last = -1;
204 auto sub = adapter->on_int_changed(v, [&](int n) { last = n; });
205 h.user_set_int(v, 7);
206 CHECK(last == 7);
207}
208
209// ═══════════════════════════════════════════════════════════════════════
210// Double
211// ═══════════════════════════════════════════════════════════════════════
212
213template<class Harness>
214inline void run_double_two_way(Harness& h) {
215 auto vh = h.make_double_view();
216 IView& v = vh.view();
217 auto adapter = h.make_adapter();
218
219 adapter->set_double(v, 3.25);
220 CHECK(adapter->get_double(v) == doctest::Approx(3.25));
221
222 double last = -1.0;
223 auto sub = adapter->on_double_changed(v, [&](double d) { last = d; });
224 h.user_set_double(v, 7.5);
225 CHECK(last == doctest::Approx(7.5));
226}
227
228// ═══════════════════════════════════════════════════════════════════════
229// Int64 / UInt64 / Float — optional conformance for adapters whose
230// host widgets natively speak wider/narrower numeric types. Adapters
231// that forward int64/uint64/float through int/double (the default
232// strategy for Qt / AppKit / UIKit) can reuse their existing
233// make_int_view / make_double_view and the round-trip just checks the
234// cast path is lossless for in-range values.
235// ═══════════════════════════════════════════════════════════════════════
236
237template<class Harness>
238inline void run_int64_two_way_via_int_view(Harness& h) {
239 auto vh = h.make_int_view();
240 IView& v = vh.view();
241 auto adapter = h.make_adapter();
242
243 adapter->set_int64(v, 1'000'000);
244 CHECK(adapter->get_int64(v) == 1'000'000);
245}
246
247template<class Harness>
248inline void run_uint64_two_way_via_int_view(Harness& h) {
249 auto vh = h.make_int_view();
250 IView& v = vh.view();
251 auto adapter = h.make_adapter();
252
253 adapter->set_uint64(v, 123'456u);
254 CHECK(adapter->get_uint64(v) == 123'456u);
255}
256
257template<class Harness>
258inline void run_float_two_way_via_double_view(Harness& h) {
259 auto vh = h.make_double_view();
260 IView& v = vh.view();
261 auto adapter = h.make_adapter();
262
263 adapter->set_float(v, 2.5f);
264 // Widen explicitly: `Approx` holds a double, so comparing a float
265 // against it promotes implicitly, and -Wdouble-promotion flags that
266 // from inside doctest's comparison template. See the same note in
267 // test_binding_readonly_source.cpp.
268 CHECK(static_cast<double>(adapter->get_float(v)) == doctest::Approx(2.5));
269}
270
271// ═══════════════════════════════════════════════════════════════════════
272// Click
273// ═══════════════════════════════════════════════════════════════════════
274
275template<class Harness>
276inline void run_click(Harness& h) {
277 auto vh = h.make_click_view();
278 IView& v = vh.view();
279 auto adapter = h.make_adapter();
280
281 int hits = 0;
282 auto sub = adapter->on_click(v, [&]() { ++hits; });
283
284 h.user_click(v);
285 h.user_click(v);
286 CHECK(hits == 2);
287
288 sub.release();
289 h.user_click(v);
290 CHECK(hits == 2); // released subscription must not fire
291}
292
293// ═══════════════════════════════════════════════════════════════════════
294// Command + enabled
295// ═══════════════════════════════════════════════════════════════════════
296
297template<class Harness>
298inline void run_command_enabled(Harness& h) {
299 auto engine = make_engine(h);
300
301 int n = 0;
302 Property<bool> gate(false);
303 Command<> cmd(
304 [&]() { ++n; },
305 [&]() { return gate.get(); }
306 );
307
308 auto vh = h.make_click_view();
309 IView& v = vh.view();
310 engine.bind_command(cmd, v);
311
312 // Predicate was false at bind time → click is blocked either by
313 // enabled=false (BindingEngine will have set it) or by the
314 // predicate itself; either way the action must not run.
315 h.user_click(v);
316 CHECK(n == 0);
317
318 // Flip the gate. Command<>'s internal Effect must push the change
319 // to the button's enabled state via the adapter.
320 gate = true;
321 h.user_click(v);
322 CHECK(n == 1);
323
324 gate = false;
325 h.user_click(v);
326 CHECK(n == 1);
327}
328
329// ═══════════════════════════════════════════════════════════════════════
330// View destruction safety
331// ═══════════════════════════════════════════════════════════════════════
332
338template<class Harness>
339inline void run_view_destroy_safety(Harness& h) {
340 auto engine = make_engine(h);
341
342 Property<std::string> survivor("alive");
343 auto survivor_vh = h.make_text_view();
344 engine.bind_text(survivor, survivor_vh.view());
345
346 Property<std::string> dying("x");
347 {
348 auto dying_vh = h.make_text_view();
349 engine.bind_text(dying, dying_vh.view());
350 CHECK(engine.adapter().get_text(dying_vh.view()) == "x");
351
352 dying = "y";
353 CHECK(engine.adapter().get_text(dying_vh.view()) == "y");
354 // dying_vh goes out of scope; the IView must fire on_destroy.
355 }
356
357 // Writing to `dying` after the view is gone MUST NOT crash.
358 // (ASan/UBSan will flag a regression.)
359 dying = "after-death";
360
361 // Unaffected view keeps working.
362 survivor = "still here";
363 CHECK(engine.adapter().get_text(survivor_vh.view()) == "still here");
364}
365
366// ═══════════════════════════════════════════════════════════════════════
367// Convenience: run the entire battery with a single call.
368//
369// Most platform authors want this; they can hand-pick the above if a
370// particular widget flavour is not supported natively (e.g. a console
371// adapter has no double input).
372// ═══════════════════════════════════════════════════════════════════════
373template<class Harness>
385
386} // namespace aria::binding::testing::conformance
Definition command.hpp:154
BindingEngine: connects ViewModel properties to platform views via an adapter.
Definition binding_engine.hpp:103
BindingEngine(std::shared_ptr< IViewAdapter > adapter)
Convenience constructor: no dispatcher, Direct policy.
Abstract platform widget reference.
Definition view_adapter.hpp:28
Definition property.hpp:103
T get() const
Auto-tracked read.
Definition property.hpp:138
Definition adapter_conformance.hpp:83
void run_all(Harness &h)
Definition adapter_conformance.hpp:374
void run_text_engine_two_way(Harness &h)
Two-way binding via BindingEngine: VM → View and View → VM both work.
Definition adapter_conformance.hpp:133
void run_command_enabled(Harness &h)
Definition adapter_conformance.hpp:298
void run_bool_two_way(Harness &h)
Definition adapter_conformance.hpp:155
void run_uint64_two_way_via_int_view(Harness &h)
Definition adapter_conformance.hpp:248
void run_view_destroy_safety(Harness &h)
When the native view dies before the BindingEngine, property writes that would otherwise reach the ad...
Definition adapter_conformance.hpp:339
void run_text_two_way(Harness &h)
VM → View → VM round trip on a text widget.
Definition adapter_conformance.hpp:106
void run_bool_engine_two_way(Harness &h)
Definition adapter_conformance.hpp:173
void run_int_two_way(Harness &h)
Definition adapter_conformance.hpp:195
void run_click(Harness &h)
Definition adapter_conformance.hpp:276
BindingEngine make_engine(Harness &h)
Definition adapter_conformance.hpp:91
void run_float_two_way_via_double_view(Harness &h)
Definition adapter_conformance.hpp:258
void run_double_two_way(Harness &h)
Definition adapter_conformance.hpp:214
void run_int64_two_way_via_int_view(Harness &h)
Definition adapter_conformance.hpp:238