ladder: rung 3 -- polls - #91
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Split out of application-ladder (originally bundled with pastebin/ bookmarks and the shared foundation in #41) into its own PR against the rung-0 foundation (#88). Includes the pool-migration fix folded in during review of the combined branch: - PollModel (this ladder's BRIDGE_MODEL_KEY-shared/keyed model) acquires connections from Lightweight::GlobalDataMapperPool() per execute() call rather than holding one for its own lifetime (WithMapper removed); applyVotes() (private helper, called from within execute() bodies that don't need to share its connection) makes its own independent acquisition. Verified standalone against the ladder-foundation base: configures and builds with -DMORPH_LADDER_RUNGS=polls and no other rung present. Full suite passes: 557 assertions in 68 test cases (SQLite default). Spec-citation and test-type-name lints clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
aad753d to
f02ff48
Compare
…tring types Per docs/superpowers/specs/2026-08-11-strong-storage-types-design.md item 3's polls inventory (spec file since removed from the tree by 2851376, recovered from git history for this change). poll_entity.hpp: - PollRecord::title -> Light::SqlAnsiString<kMaxTitleBytes> (200) - OptionRecord::label -> Light::SqlAnsiString<kMaxOptionLabelBytes> (100) - VoteRecord/CommentRecord/VoteHistoryRecord::participantName -> Light::SqlAnsiString<kMaxParticipantNameBytes> (80) - CommentRecord::body -> Light::SqlAnsiString<kMaxCommentBytes> (500) - VoteHistoryRecord::previousVotesJson -> Light::SqlMaxDynamicAnsiString (serialized JSON, unbounded) - PollEventRecord::kind -> Light::SqlAnsiString<32> (short internal tag, no existing DTO constant) - PollEventRecord::summary -> Light::SqlMaxDynamicAnsiString (free text, no existing bound) pollId/adminToken/participantToken are untouched -- already SqlAnsiString<kTokenBytes> from an earlier pass. poll_model.cpp: added a templated textOf(SqlAnsiString<N>) plus a SqlMaxDynamicAnsiString overload (mirroring pastebin::PasteModel's textOf() precedent) and wrapped every entity->DTO read of these fields in it; DTO->entity writes needed no changes since Light::Field's assignment operator already accepts anything constructible into the field's value type. Added five static_asserts pinning each bounded field's SqlAnsiString capacity to its DTO-level kMax*Bytes constant (matching pastebin's kMaxSyntaxBytes static_assert), so title/label/ participantName/body's storage capacity and validate() bound can never drift apart silently. kind/summary have no DTO constant to pin against and are left unasserted, per the spec's own note on those two fields. schema.cpp: poll_events.kind's DDL widens from Varchar(16) to Varchar(32) and summary from Varchar(200) to Text(), to match the entity's new capacities (kind's entity comment already documented 32 as the chosen size; summary is now genuinely unbounded like previous_votes_json's existing Text() column). test_polls_schema.cpp and every other polls test file needed no changes: all *Record field assignments in these tests use string literals, which construct implicitly into SqlAnsiString<N>/SqlMaxDynamicAnsiString the same way they did into plain std::string, and its two Field::Value() comparisons against string literals resolve through SqlFixedString's operator==(string_view) overload. Verified via the existing build/polls tree (MORPH_LADDER_RUNGS=polls, MSVC/Ninja, Debug), reconfigured and rebuilt incrementally: full rebuild succeeded and all 68 ladder-polls-labeled ctest cases pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds “polls” (application ladder rung 3) as a full end-to-end example app: a shared/keyed PollModel backed by SQLite/Lightweight, a standalone server, Qt/QML GUI (including WASM client), and a comprehensive Catch2 test suite exercising shared-instance lifecycle, DTO validation, schema, presenter/bridge wiring, and event polling.
Changes:
- Introduces the polls domain model (
PollModel) keyed bypollId, including DTOs, entities, migration, and authorization shape. - Adds runnable surfaces: standalone server (
ladder_polls_server), QML module + GUI adapter layer, and a WASM GUI entry point. - Adds extensive unit/integration tests covering DB schema, shared-instance behavior, presenter + QML bridge wiring, and GUI smoke loading.
Reviewed changes
Copilot reviewed 40 out of 40 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| examples/polls/CMakeLists.txt | Rung build wiring and WASM server URL define. |
| examples/polls/gui/qml/Main.qml | Main application shell + landing + navigation. |
| examples/polls/gui/qml/CreatePollView.qml | Organizer create-poll screen (hand-rolled options editor). |
| examples/polls/gui/qml/VoteView.qml | Vote screen, schema-driven actions, and live event log display. |
| examples/polls/gui_lib/poll_forms_controller.hpp | Single shared-handler controller for poll actions + schema allow-list. |
| examples/polls/gui_lib/poll_forms_controller.cpp | Controller method implementations over one AllowShared handler. |
| examples/polls/gui_lib/poll_presenter.hpp | Presenter wiring over two handlers (creator + shared/attached handler). |
| examples/polls/gui_lib/poll_presenter.cpp | Presenter action routing and error reporting. |
| examples/polls/gui_lib/poll_qml_bridges.hpp | QML-facing adapter (PollBridge) + event poller wiring. |
| examples/polls/gui_lib/poll_qml_bridges.cpp | DTO↔QVariant conversion + bridge method implementations. |
| examples/polls/gui_lib/poll_schemas.hpp | Schema JSON for schema-driven actions. |
| examples/polls/gui_wasm/main_wasm.cpp | WASM client entry: remote context, URL ?poll= parsing, initial props. |
| examples/polls/include/polls/app/app.hpp | Server-side bootstrap wrapper (RemoteServer + action log + limits). |
| examples/polls/src/app/app.cpp | App implementation + limit policy + action log lifecycle. |
| examples/polls/include/polls/auth/polls_authorizer.hpp | Unconditionally permissive authorizer (per rung design). |
| examples/polls/src/auth/polls_authorizer.cpp | Authorizer hook implementations. |
| examples/polls/include/polls/core/errors.hpp | Domain exception hierarchy for polls. |
| examples/polls/include/polls/core/types.hpp | Strong ID types, vote choice enum, and shared constants. |
| examples/polls/include/polls/db/database.hpp | DB bootstrap API (setup) for production server. |
| examples/polls/src/db/schema.cpp | Lightweight migration + schema creation. |
| examples/polls/include/polls/db/poll_entity.hpp | Lightweight entity records (poll/options/votes/comments/history/events). |
| examples/polls/include/polls/dto/poll_dto.hpp | Poll actions/results DTOs + validation + glaze metadata. |
| examples/polls/include/polls/dto/vote_dto.hpp | Vote/comment/finalize/undo DTOs + validation helpers. |
| examples/polls/include/polls/dto/event_dto.hpp | Event log DTOs + cursor validation. |
| examples/polls/include/polls/models/poll_model.hpp | PollModel public API + bridge registration + keying. |
| examples/polls/include/polls/units.hpp | Unit system + Count quantity for tallies. |
| examples/polls/src/server/main.cpp | Standalone server process bootstrap + env config + graceful shutdown. |
| examples/polls/tests/test_app.cpp | App boot smoke test via simulated remote backend. |
| examples/polls/tests/test_gui_qml_smoke.cpp | Offscreen QML engine-load smoke tests. |
| examples/polls/tests/test_poll_dto.cpp | DTO validation + result structure tests. |
| examples/polls/tests/test_vote_event_dto.cpp | Vote/event DTO validation tests. |
| examples/polls/tests/test_polls_types.cpp | Strong-id / error-type / constant sanity tests. |
| examples/polls/tests/test_polls_schema.cpp | Schema migration + entity round-trip + unique index enforcement tests. |
| examples/polls/tests/test_polls_authorizer.cpp | Authorizer “admits everything” contract tests. |
| examples/polls/tests/test_poll_presenter.cpp | Presenter action wiring across backend-mode matrix. |
| examples/polls/tests/test_poll_qml_bridges.cpp | QML-bridge surface and end-to-end adapter wiring tests. |
| examples/polls/tests/test_shared_instance_lifecycle.cpp | Shared-instance attach/lifetime/poisoning + rate-limit deadline recovery tests. |
Suppressed comments (3)
examples/polls/gui/qml/VoteView.qml:204
- Same issue as above: this updates
pickson both the checked and unchecked transitions. Only update whencheckedis true so the selected RadioButton is the one that persists.
text: "If need be"
enabled: page.state && !page.state.finalized
ButtonGroup.group: choiceGroup
checked: page.pickFor(optionRow.modelData.id) === "IfNeedBe"
onToggled: page.setPick(optionRow.modelData.id, "IfNeedBe")
examples/polls/gui/qml/VoteView.qml:211
- Same issue as above:
toggledfires when unchecking too, so this can overwrite the selection after the user picked a different value. Updatepicksonly when the button becomes checked.
text: "No"
enabled: page.state && !page.state.finalized
ButtonGroup.group: choiceGroup
checked: page.pickFor(optionRow.modelData.id) === "No"
onToggled: page.setPick(optionRow.modelData.id, "No")
examples/polls/include/polls/core/types.hpp:61
- Same documentation issue as
OptionId:PollEventId::operator*()cannot be UB — it just returns the stored integer. Adjust the comment to match the real contract (0 means “not entered”; check hasValue() when needed).
/// @brief Unchecked access to the engaged value (UB when empty, exactly
/// like `std::optional::operator*`).
/// @return The engaged value.
[[nodiscard]] constexpr std::int64_t operator*() const { return value; }
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
cpp-review finding: - schema.cpp: previous_votes_json/summary use NVarchar(0), not Text() -- both back a SqlMaxDynamicAnsiString entity field (genuinely unbounded), and Text() encodes no such contract; matches pastebin's own NVarchar(0) precedent for unbounded storage and the fix already applied to bookmarks' equivalent Text()-vs-entity-capacity mismatch (PR #90) Copilot findings: - VoteView.qml: each vote RadioButton's onToggled fired unconditionally, including on the checked->unchecked transition Qt's shared ButtonGroup triggers on the sibling that just lost the selection -- so clicking one choice could have its own setPick() call overwritten right back by the previously-checked button's own unconditional handler, depending on which of the two `toggled` signals QML fires second. Guarded all three (Yes/If need be/No) on `if (checked)`. - types.hpp: OptionId/PollEventId::operator*()'s doc comment claimed "UB when empty, exactly like std::optional::operator*" -- both wrap a plain std::int64_t with 0 as their own sentinel, not a std::optional, so reading either when "empty" just returns 0, never UB. Rewrote both comments to state the real contract (Copilot's suppressed-comments list named PollEventId only; OptionId carries the identical copy-pasted comment one type up in the same file, so fixed both). Verified: full ladder_polls_tests suite (68 cases) passes against SQLite; the 1 unrelated ctest-reported failure (QtWebSocketBackend concurrent dispatch, untouched by this diff) is the same console em-dash encoding artifact in ctest's -R/-I re-invocation seen on PR #90, not a real failure.
Resolves examples/kanban/README.md's design questions in writing, per the ladder's own discipline rule. Covers steps 1-5+7 (steps 6/8 stay deferred, per the README's own scoping): - Exactly-once (MoveTaskPosition): generalizes bookmarks::ImportBookmarks' client-op-id + server-side applied-ops-ledger pattern, storing the full serialized GetBoardResult (not a placeholder) so a replaying client reconciles against the real outcome. - Strand ordering / WIP limits / position renumbering: rely entirely on the framework's existing strand-per-instance guarantee (verified against docs/spec/core/shared_instances.md); no new locking. - Per-project RBAC: in-model requireRole() check mirroring PollModel::requireAdmin(), not an IAuthorizer interface change -- resolved via an analysis agent, grounded in shared_instances.md's and security.md's own already-written positions. - Activity stream: derived from IActionLog::entries(entityKey) -- no new storage. - Offline: composes SqliteOfflineQueue/SyncWorker/ReconnectCoordinator as-is; verified (not assumed) that a reconnect flap cannot preempt an in-progress replay. Two testkit-scope findings, verified by file-existence checks: - action_driver.hpp/process_pool.hpp/offline_rig.hpp are rung 4's own obligation per examples/TESTING.md's ownership table (confirmed none exist yet). - client_pool.hpp/convergence.hpp were TESTING.md's documented rung-3 obligation but polls (merged, PR #91) never built them -- absorbed into this rung's scope since kanban's own convergence DoD item needs them regardless of original ownership. Framework gap filed and cross-referenced: morph#112 (IOfflineQueue has no depth bound or overflow policy), verified against offline_queue.hpp/sqlite_offline_queue.hpp/file_offline_queue.hpp.
What
Rung 3 of the application ladder: polls, anchored on Rallly. Split out of #41 into its own PR, targeting #88's
ladder-foundationbranch since it depends on the shared testkit/CMake infra there.Draft until #88 merges — retarget to
masterand un-draft once it does.Contents
examples/polls/in full:PollModel(this ladder'sBRIDGE_MODEL_KEY-shared/keyed model), DTOs, entity, schema migration, authorizer, server, Qt/QML GUI, and tests — 40 files.Includes a fix folded in during review of the combined branch:
PollModelacquires connections fromLightweight::GlobalDataMapperPool()perexecute()call rather than holding one for its own lifetime (WithMapperremoved);applyVotes()(a private helper called from withinexecute()bodies that don't need to share its connection) makes its own independent acquisition.Verification
Configured and built standalone against the
ladder-foundationbase with-DMORPH_LADDER_RUNGS=polls(no other rung present). Full suite passes: 557 assertions in 68 test cases (SQLite default). Spec-citation and test-type-name lints clean.