From 7735d7f0cbbacb81c0e4d6f59f49ac120619bceb Mon Sep 17 00:00:00 2001 From: Alex Hope-O'Connor Date: Mon, 4 May 2026 22:03:43 +1000 Subject: [PATCH] Portal customization API v2, SPA hardening, and test cleanup - Group portal state into brand/pages/actions/layout structs; portal* API - Bootstrap JSON: nested brand/context/pages/actions/layout; param kind - Handlers: PROGMEM default CSS when no override/append; dynamic CSS path - PortalAppJS: portal timeout countdown; safe WiFi scan DOM; serialized scan-status polling; fetch error handling; wm:view-changed on redirects - CSS: scan list icon alignment; wm-status for timeout - Tests: remove low-value/no-op tests; trim fragile JS substring checks --- README.md | 104 +++++- lib/WiFiManager/include/WiFiManager.h | 151 ++++++-- lib/WiFiManager/include/WiFiManagerHandlers.h | 8 +- lib/WiFiManager/include/templates/CSS.h | 12 +- .../include/templates/PortalAppJS.h | 232 +++++++++--- lib/WiFiManager/include/templates/RootShell.h | 8 +- lib/WiFiManager/src/WiFiManager.cpp | 198 ++++++++--- lib/WiFiManager/src/WiFiManagerHandlers.cpp | 332 +++++++++++++----- test/test_wifimanager/test_main.cpp | 21 +- test/test_wifimanager/test_main.h | 19 +- .../tests/test_advanced_options.cpp | 41 ++- .../tests/test_api_json_and_captive.cpp | 18 + test/test_wifimanager/tests/test_basic.cpp | 8 +- .../tests/test_configuration.cpp | 24 +- .../tests/test_error_conditions.cpp | 5 +- .../tests/test_integration.cpp | 6 +- .../tests/test_parameter_values.cpp | 8 +- .../tests/test_parameters.cpp | 22 +- .../tests/test_root_render_lifecycle.cpp | 85 ++++- test/test_wifimanager/tests/test_stress.cpp | 2 +- .../tests/test_template_rendering.cpp | 51 +++ .../tests/test_wifi_scanning.cpp | 10 +- 22 files changed, 1039 insertions(+), 326 deletions(-) diff --git a/README.md b/README.md index 9664714..4b7facd 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,10 @@ This fork currently includes the following architectural improvements: - **Captive portal redirect handling** retained while removing duplicate legacy UI architecture. - **Data-first JSON generation** for portal APIs, including info/device/about data, instead of HTML-to-JSON parsing. - **Capability-driven UI flags** in bootstrap/API payloads so features like info, update, erase, and action visibility can be controlled by backend state. +- **Portal bootstrap contract v2**: nested `brand`, `context`, `pages`, `actions`, `layout`, `extraHomeCards`; Wi-Fi meta params include `kind` (`field` | `html`) for first-class custom HTML parameters. - **SPA-native feedback UX** using in-DOM dialog/toast behavior rather than page-based action flows. -- **Request-scoped shell rendering**: the root portal page is built for each `GET /` from `WM_ROOT_SHELL_TEMPLATE` using a fresh placeholder registry. Shell inputs are only `%PAGE_TITLE%`, `%STYLES%`, `%BOOTSTRAP_JSON%`, and `%PORTAL_APP_JS%` — filled in `WiFiManagerHandlers` from WiFiManager state and embedded assets (not from a server-wide template registry). -- **Customization via WiFiManager APIs** (`setTitle`, `setShowInfo`, `setParametersEmbeddedInWifi`, bootstrap-facing flags, etc.), not by exposing placeholder-registry mutation to consumers. +- **Request-scoped shell rendering**: the root portal page is built for each `GET /` from `WM_ROOT_SHELL_TEMPLATE` using a fresh placeholder registry. Shell inputs are `%PAGE_TITLE%`, `%STYLES%`, `%BOOTSTRAP_JSON%`, `%PORTAL_APP_JS%`, and `%PORTAL_APPEND_JS%` — filled in `WiFiManagerHandlers` from WiFiManager state and embedded assets (not from a server-wide template registry). +- **Customization via WiFiManager `portal*` APIs** (`portalSetBrandTitle`, `portalSetPageInfoVisible`, `portalSetLayoutParamsLocation`, `portalAddParameter`, asset hooks, etc.) and JSON under `/api/...`, not by exposing placeholder-registry mutation to consumers. - A clearer separation between: - shell rendering (handlers + SPA bootstrap) - JSON API responses @@ -40,6 +41,105 @@ This fork currently includes the following architectural improvements: - OTA handling - Updated tests focused on the **shell contract**, **bootstrap payloads**, and **API JSON shapes** rather than removed legacy portal pages. +## Portal Customization Boundary + +Stable, supported portal customization is intentionally scoped: + +- `portalSetBrand*` for title, intro text, and logo SVG, plus `portalSetContextIdentityText(...)` for the user-facing runtime identity string on the home view. +- `portalSetPage*`, `portalSetAction*`, `portalSetLayout*`, and `portalSetBehavior*` for built-in portal capabilities and runtime behavior. +- `portalAddParameter(...)` for first-class custom parameters, including raw HTML blocks inside parameter-rendering surfaces (`#/wifi` or `#/setup`). +- `portalAddInfoSection(...)` and `portalAddHomeCard(...)` for structured extra content rendered by the built-in SPA. +- `portalAppendCss(...)`, `portalOverrideCss(...)`, and `portalAppendJs(...)` for light theming and enhancement hooks. + +Not part of the stable API: + +- arbitrary HTML injection into home/info/nav/shell +- replacing built-in SPA routing or action flow +- depending on undocumented DOM IDs or route internals +- treating `include/templates/*` as a supported consumer override surface + +Appended JS should enhance rather than replace the built-in SPA. The documented hook contract is: + +- `wm:ready` with `detail.boot` +- `wm:view-changed` with `detail.route` + +If a consumer needs custom live widgets, new primary navigation concepts, or new backend-to-frontend workflows, that is considered **fork territory** rather than portal customization. + +## Portal Customization Example + +```cpp +WiFiManager wm; + +wm.portalSetBrandTitle("Solar Battery Monitor Setup"); +wm.portalSetContextIdentityText("Solar Battery Monitor"); +wm.portalSetBrandHomeIntro( + "Connect your monitor to WiFi, then review battery and inverter settings." +); +wm.portalSetBrandLogoSvg( + "" +); + +wm.portalSetPageInfoVisible(true); +wm.portalSetPageUpdateVisible(false); +wm.portalSetActionEraseVisible(false); +wm.portalSetActionRestartVisible(true); +wm.portalSetLayoutParamsLocation(PortalParamsLocation::SetupPage); + +wm.portalSetBehaviorCaptivePortalEnabled(true); +wm.portalSetBehaviorConnectOnSave(true); +wm.portalSetBehaviorExitAllowed(true); + +wm.portalSetFieldPasswordPlaceholderMode(PortalPasswordPlaceholderMode::Masked); +wm.portalSetFieldStaticIpVisibility(PortalFieldVisibility::Auto); +wm.portalSetFieldStaticDnsVisibility(PortalFieldVisibility::Auto); + +WiFiManagerParameter mqttHost( + "mqtt_host", + "MQTT host", + "broker.local", + 64, + "placeholder='broker.local'" +); +wm.portalAddParameter(&mqttHost); + +// Raw HTML remains first-class for custom parameters, but only inside the +// parameter-rendering surfaces (#/wifi or #/setup), not arbitrary portal regions. +WiFiManagerParameter gpsHelp( + "
" + "

GPS options below apply only when a GPS module is connected.

" + "
" +); +wm.portalAddParameter(&gpsHelp); + +PortalInfoSection battery; +battery.id = "battery"; +battery.title = "Battery"; +battery.items.push_back({"soc", "State of charge", "84%"}); +battery.items.push_back({"voltage", "Voltage", "13.2V"}); +wm.portalAddInfoSection(battery); + +PortalHomeCard solar; +solar.id = "solar"; +solar.title = "Solar summary"; +solar.kind = PortalHomeCardKind::KeyValue; +solar.items.push_back({"pv", "PV input", "420W"}); +solar.items.push_back({"load", "Load", "180W"}); +wm.portalAddHomeCard(solar); + +wm.portalAppendCss( + ".wm-brand-logo svg{width:40px;height:40px;display:block;}" + ".wm-hero-intro{max-width:42ch;}" +); + +wm.portalAppendJs( + "document.addEventListener('wm:ready', function(e){" + " console.log('Portal booted', e.detail.boot);" + "});" +); +``` + ## Dependencies This fork depends on **DFTE** ([Device Framework Template Engine](https://github.com/alexhopeoconnor/DFTE)) and **ESP32Async/ESPAsyncWebServer**. diff --git a/lib/WiFiManager/include/WiFiManager.h b/lib/WiFiManager/include/WiFiManager.h index a8edd8f..3e2d8c9 100644 --- a/lib/WiFiManager/include/WiFiManager.h +++ b/lib/WiFiManager/include/WiFiManager.h @@ -200,6 +200,74 @@ class WiFiManagerHandlers; class WiFiManagerDfteLogger; class WiFiManagerLogSink; +// --------------------------------------------------------------------------- +// Portal customization (v2 contract: nested JSON; all entry points use portal* API) +// --------------------------------------------------------------------------- +enum class PortalParamsLocation : uint8_t { WiFiPage = 0, SetupPage = 1 }; + +enum class PortalPasswordPlaceholderMode : uint8_t { Hidden = 0, Masked = 1, Actual = 2 }; + +enum class PortalFieldVisibility : uint8_t { Hidden = 0, Auto = 1, Always = 2 }; + +enum class PortalHomeCardKind : uint8_t { Text = 0, Callout = 1, KeyValue = 2 }; + +struct PortalKeyValueItem { + String key; + String label; + String value; +}; + +struct PortalInfoSection { + String id; + String title; + std::vector items; +}; + +struct PortalHomeCard { + String id; + String title; + PortalHomeCardKind kind = PortalHomeCardKind::KeyValue; + String text; + std::vector items; +}; + +struct PortalBrandState { + String title = "WiFiManager"; + String identityTextOverride; + String homeIntro; + String logoSvg; +}; + +struct PortalPageState { + bool infoVisible = true; + bool updateVisible = true; + bool setupVisible = true; +}; + +struct PortalActionState { + bool eraseVisible = true; + bool restartVisible = true; + bool exitVisible = true; + bool closeCaptiveVisible = true; + bool backVisible = false; +}; + +struct PortalLayoutState { + /** When true, custom parameters render on the Wi-Fi page; when false, only on #/setup. */ + bool paramsOnWifiPage = true; +}; + +struct PortalAssetState { + String appendedCss; + String overriddenCss; + String appendedJs; +}; + +struct PortalStructuredExtrasState { + std::vector infoSections; + std::vector homeCards; +}; + class WiFiManager { public: @@ -322,7 +390,7 @@ class WiFiManager bool erase(bool opt); //adds a custom parameter, returns false on failure - bool addParameter(WiFiManagerParameter *p); + bool portalAddParameter(WiFiManagerParameter *p); //returns the list of Parameters WiFiManagerParameter** getParameters(); @@ -421,12 +489,6 @@ class WiFiManager //if true, always show static dns, esle only show if set via setSTAStaticIPConfig void setShowDnsFields(boolean alwaysShow); - // toggle showing the saved wifi password in wifi form, could be a security issue. - void setShowPassword(boolean show); - - //if false, disable captive portal redirection - void setCaptivePortalEnable(boolean enabled); - //if false, timeout captive portal even if a STA client connected to softAP (false), suggest disabling if captiveportal is open void setAPClientCheck(boolean enabled); @@ -449,15 +511,6 @@ class WiFiManager bool setHostname(const char * hostname); bool setHostname(String hostname); - // show erase wifi onfig button on info page, true - void setShowInfoErase(boolean enabled); - - // show OTA upload button on info page - void setShowInfoUpdate(boolean enabled); - - // show Info nav / info view (portal SPA); default true - void setShowInfo(boolean enabled); - // set ap channel void setWiFiAPChannel(int32_t channel); @@ -467,18 +520,46 @@ class WiFiManager // clean connect, always disconnect before connecting void setCleanConnect(bool enable); // default false - // set the webapp title, default WiFiManager - void setTitle(String title); + // ---- Portal (all customization entry points use the portal* prefix) ---- + void portalSetBrandTitle(const String& title); + void portalSetContextIdentityText(const String& identityText); + void portalSetBrandHomeIntro(const String& text); + void portalSetBrandLogoSvg(const String& svgMarkup); - /** - * Where custom parameters (`WiFiManagerParameter`) appear in the portal SPA (one placement only). - * - * - true (default): parameters render on the WiFi route (`#/wifi`) together with SSID/password. - * - false: parameters render only on the Setup route (`#/setup`); they are not duplicated on WiFi. - * - * Bootstrap exposes the same flag as `features.paramsInWifi`. - */ - void setParametersEmbeddedInWifi(boolean embedded); + void portalSetPageInfoVisible(bool visible); + void portalSetPageUpdateVisible(bool visible); + void portalSetPageSetupVisible(bool visible); + + void portalSetActionEraseVisible(bool visible); + void portalSetActionRestartVisible(bool visible); + void portalSetActionExitVisible(bool visible); + void portalSetActionCloseCaptiveVisible(bool visible); + void portalSetActionBackVisible(bool visible); + + void portalSetLayoutParamsLocation(PortalParamsLocation location); + + void portalSetBehaviorCaptivePortalEnabled(bool enabled); + void portalSetBehaviorConnectOnSave(bool enabled); + void portalSetBehaviorExitAllowed(bool allowed); + void portalSetBehaviorConnectTimeoutSeconds(unsigned long seconds); + void portalSetBehaviorPortalTimeoutSeconds(unsigned long seconds); + void portalSetBehaviorAutoReconnect(bool enabled); + void portalSetBehaviorApClientCheck(bool enabled); + void portalSetBehaviorWebClientCheck(bool enabled); + + void portalSetFieldPasswordPlaceholderMode(PortalPasswordPlaceholderMode mode); + void portalSetFieldStaticIpVisibility(PortalFieldVisibility visibility); + void portalSetFieldStaticDnsVisibility(PortalFieldVisibility visibility); + + void portalClearParameters(); + void portalAddInfoSection(const PortalInfoSection& section); + void portalClearInfoSections(); + void portalAddHomeCard(const PortalHomeCard& card); + void portalClearHomeCards(); + + void portalAppendCss(const String& css); + void portalOverrideCss(const String& css); + void portalAppendJs(const String& js); // get last connection result, including autoconnect and portal credential-save attempts uint8_t getLastConxResult(); @@ -706,7 +787,7 @@ class WiFiManager int _staShowStaticFields = 0; // ternary 1=always show static ip fields, 0=only if set, -1=never(cannot change ips via web!) int _staShowDns = 0; // ternary 1=always show dns, 0=only if set, -1=never(cannot change dns via web!) boolean _removeDuplicateAPs = true; // remove dup aps from wifiscan - boolean _showPassword = false; // show or hide saved password on wifi form, might be a security issue! + PortalPasswordPlaceholderMode _portalPasswordPlaceholderMode = PortalPasswordPlaceholderMode::Masked; boolean _shouldBreakAfterConfig = false; // stop configportal on save failure boolean _enableCaptivePortal = true; // enable captive portal redirection boolean _userpersistent = true; // users preffered persistence to restore @@ -714,15 +795,17 @@ class WiFiManager boolean _apClientCheck = false; // keep cp alive if ap have station boolean _webClientCheck = true; // keep cp alive if web have client boolean _scanDispOptions = false; // show percentage in scans not icons - boolean _paramsInWifi = true; // if false, custom params only on #/setup (see setParametersEmbeddedInWifi) - boolean _showInfo = true; // show Info in portal SPA - boolean _showInfoErase = true; // info page erase button - boolean _showInfoUpdate = true; // info page update button - boolean _showBack = false; // show back button boolean _enableConfigPortal = true; // FOR autoconnect - start config portal if autoconnect failed boolean _disableConfigPortal = true; // FOR autoconnect - stop config portal if cp wifi save String _hostname = ""; // hostname for esp8266 for dhcp, and or MDNS - String _title = "WiFiManager"; // app title - default WiFiManager + + // Grouped portal presentation / customization (see portal* setters; JSON in v2 bootstrap) + PortalBrandState _portalBrand; + PortalPageState _portalPages; + PortalActionState _portalActions; + PortalLayoutState _portalLayout; + PortalAssetState _portalAssets; + PortalStructuredExtrasState _portalStructured; // internal options diff --git a/lib/WiFiManager/include/WiFiManagerHandlers.h b/lib/WiFiManager/include/WiFiManagerHandlers.h index b57f671..f0ebcda 100644 --- a/lib/WiFiManager/include/WiFiManagerHandlers.h +++ b/lib/WiFiManager/include/WiFiManagerHandlers.h @@ -20,8 +20,7 @@ #define WM_TEMPLATE_REGISTRY_CAPACITY 16 #endif -// Portal UI customization is driven by WiFiManager state and helpers such as setTitle(), setShowInfo(), -// setParametersEmbeddedInWifi(), setShowInfoUpdate(), etc., surfaced through bootstrap/API JSON — not by mutating template +// Portal UI customization is driven by WiFiManager `portal*` APIs and JSON (/api/...) — not by mutating template // placeholder registries. // ----------------------------------------------------------------------------------------------- @@ -106,11 +105,14 @@ class WiFiManagerHandlers { void appendPortalJsonStaticFields(String& json, bool& first); void appendPortalJsonCustomParams(String& json, bool& first); + String composePortalStylesheet() const; + void appendPortalExtraInfoSectionsJson(String& json, bool& first); + void appendPortalExtraHomeCardsJson(String& json, bool& first); + void appendJsonKvItem(String& json, bool& first, const char* key, const String& label, const String& value); void appendOneInfoItemForId(String& json, bool& first, const char* id); void appendInfoSectionFromIds(String& json, const char* const* ids, size_t count, bool& first); - void appendPortalUiFeatureFlagsJson(String& json); void appendApiInfoActionsJson(String& json); static void sendApiJson(AsyncWebServerRequest *request, int code, const String& json); diff --git a/lib/WiFiManager/include/templates/CSS.h b/lib/WiFiManager/include/templates/CSS.h index 6f7aaa5..20e45ca 100644 --- a/lib/WiFiManager/include/templates/CSS.h +++ b/lib/WiFiManager/include/templates/CSS.h @@ -70,6 +70,9 @@ const char CSS_STYLE[] PROGMEM = ""); + + if (css.startsWith(openTag)) { + css.remove(0, openTag.length()); + } + if (css.endsWith(closeTag)) { + css.remove(css.length() - closeTag.length()); + } + return css; +} + +const char* portalHomeCardKindJson(PortalHomeCardKind k) { + switch (k) { + case PortalHomeCardKind::Text: + return "text"; + case PortalHomeCardKind::Callout: + return "callout"; + case PortalHomeCardKind::KeyValue: + default: + return "kv"; + } +} + template AsyncWebServerResponse* beginTemplateResponse(AsyncWebServerRequest* request, const std::shared_ptr& bundle, @@ -73,41 +102,123 @@ AsyncWebServerResponse* beginTemplateResponse(AsyncWebServerRequest* request, ); } -// Shell contract (must match templates/RootShell.h — customize UI via WiFiManager APIs + bootstrap JSON, not registries): -// %PAGE_TITLE% -> document title (from WiFiManager title state) -// %STYLES% -> embedded portal CSS -// %BOOTSTRAP_JSON% -> initial SPA runtime payload -// %PORTAL_APP_JS% -> embedded SPA source +// Shell contract (must match templates/RootShell.h — customize UI via WiFiManager portal* APIs + bootstrap JSON, not registries): +// %PAGE_TITLE% -> document title (from WiFiManager title state) +// %STYLES% -> portal CSS (embedded default + portalAppendCss / portalOverrideCss) +// %BOOTSTRAP_JSON% -> initial SPA runtime payload +// %PORTAL_APP_JS% -> embedded SPA source +// %PORTAL_APPEND_JS% -> consumer hook script (portalAppendJs) struct PortalShellRenderBundle { String bootstrapJson; String pageTitleStatic; + String stylesStatic; + String appendJsStatic; PlaceholderRegistry registry; TemplateContext context; - DynamicTemplateDescriptor bootstrapDescriptor; - DynamicTemplateDescriptor pageTitleDescriptor; + DynamicDataDescriptor bootstrapDescriptor; + DynamicDataDescriptor pageTitleDescriptor; + DynamicDataDescriptor stylesDescriptor; + DynamicDataDescriptor appendJsDescriptor; PortalShellRenderBundle() - : registry(WM_TEMPLATE_REGISTRY_CAPACITY), bootstrapDescriptor{}, pageTitleDescriptor{} {} + : registry(WM_TEMPLATE_REGISTRY_CAPACITY), + bootstrapDescriptor{}, + pageTitleDescriptor{}, + stylesDescriptor{}, + appendJsDescriptor{} {} }; -void populatePortalShellBundle(PortalShellRenderBundle& bundle, - const String& pageTitle, - const String& bootstrapJson) { - bundle.pageTitleStatic = pageTitle; - bundle.bootstrapJson = bootstrapJson; - bundle.registry.registerProgmemData("%STYLES%", CSS_STYLE); - bundle.registry.registerProgmemData("%PORTAL_APP_JS%", PORTAL_APP_JS); - configureDynamicStringDescriptor(bundle.pageTitleDescriptor, bundle.pageTitleStatic); - bundle.registry.registerDynamicTemplate("%PAGE_TITLE%", &bundle.pageTitleDescriptor); - configureDynamicStringDescriptor(bundle.bootstrapDescriptor, bundle.bootstrapJson); - bundle.registry.registerDynamicTemplate("%BOOTSTRAP_JSON%", &bundle.bootstrapDescriptor); -} - } // namespace WiFiManagerHandlers::WiFiManagerHandlers(WiFiManager* wm) : _wm(wm) {} +String WiFiManagerHandlers::composePortalStylesheet() const { + String css; + if (_wm != nullptr && _wm->_portalAssets.overriddenCss.length() > 0) { + css = _wm->_portalAssets.overriddenCss; + } else { + css = stripStyleWrapper(readProgmemString(CSS_STYLE)); + } + + if (_wm != nullptr && _wm->_portalAssets.appendedCss.length() > 0) { + css += _wm->_portalAssets.appendedCss; + } + + String out = F(""); + return out; +} + +void WiFiManagerHandlers::appendPortalExtraInfoSectionsJson(String& json, bool& first) { + if (_wm == nullptr) { + return; + } + for (const auto& sec : _wm->_portalStructured.infoSections) { + if (!first) { + json += F(","); + } + first = false; + json += F("{\"id\":\""); + jsonAppendEscaped(json, sec.id); + json += F("\",\"title\":\""); + jsonAppendEscaped(json, sec.title); + json += F("\",\"items\":["); + bool firstItem = true; + for (const auto& it : sec.items) { + if (!firstItem) { + json += F(","); + } + firstItem = false; + json += F("{\"key\":\""); + jsonAppendEscaped(json, it.key); + json += F("\",\"label\":\""); + jsonAppendEscaped(json, it.label); + json += F("\",\"value\":\""); + jsonAppendEscaped(json, it.value); + json += F("\"}"); + } + json += F("]}"); + } +} + +void WiFiManagerHandlers::appendPortalExtraHomeCardsJson(String& json, bool& first) { + if (_wm == nullptr) { + return; + } + for (const auto& card : _wm->_portalStructured.homeCards) { + if (!first) { + json += F(","); + } + first = false; + json += F("{\"id\":\""); + jsonAppendEscaped(json, card.id); + json += F("\",\"title\":\""); + jsonAppendEscaped(json, card.title); + json += F("\",\"kind\":\""); + json += portalHomeCardKindJson(card.kind); + json += F("\",\"text\":\""); + jsonAppendEscaped(json, card.text); + json += F("\",\"items\":["); + bool firstItem = true; + for (const auto& it : card.items) { + if (!firstItem) { + json += F(","); + } + firstItem = false; + json += F("{\"key\":\""); + jsonAppendEscaped(json, it.key); + json += F("\",\"label\":\""); + jsonAppendEscaped(json, it.label); + json += F("\",\"value\":\""); + jsonAppendEscaped(json, it.value); + json += F("\"}"); + } + json += F("]}"); + } +} + void WiFiManagerHandlers::collectVisibleScanResults(std::vector& networks) { networks.clear(); networks.reserve(_wm->_scanResultsCache.size()); @@ -197,7 +308,7 @@ void WiFiManagerHandlers::appendPortalJsonCustomParams(String& json, bool& first first = false; if (p->getID() != nullptr) { String pid = String(p->getID()); - json += F("{\"name\":\""); + json += F("{\"kind\":\"field\",\"name\":\""); jsonAppendEscaped(json, pid); json += F("\",\"id\":\""); jsonAppendEscaped(json, pid); @@ -219,7 +330,7 @@ void WiFiManagerHandlers::appendPortalJsonCustomParams(String& json, bool& first } json += F("}"); } else { - json += F("{\"html\":\""); + json += F("{\"kind\":\"html\",\"html\":\""); jsonAppendEscaped(json, String(p->getCustomHTML())); json += F("}"); } @@ -560,9 +671,25 @@ void WiFiManagerHandlers::handleRoot(AsyncWebServerRequest *request) { // - Populate one request-scoped PlaceholderRegistry with shell defaults + request payloads. // - Render WM_ROOT_SHELL_TEMPLATE. auto bundle = std::make_shared(); - populatePortalShellBundle(*bundle, - _wm ? _wm->_title : String(), - buildPortalBootstrapJson()); + const bool hasCustomStyles = _wm != nullptr && + (_wm->_portalAssets.overriddenCss.length() > 0 || _wm->_portalAssets.appendedCss.length() > 0); + bundle->pageTitleStatic = _wm ? _wm->_portalBrand.title : String(); + bundle->bootstrapJson = buildPortalBootstrapJson(); + bundle->appendJsStatic = _wm ? _wm->_portalAssets.appendedJs : String(); + bundle->registry.registerProgmemData("%PORTAL_APP_JS%", PORTAL_APP_JS); + if (hasCustomStyles) { + bundle->stylesStatic = composePortalStylesheet(); + configureDynamicStringDescriptor(bundle->stylesDescriptor, bundle->stylesStatic); + bundle->registry.registerDynamicData("%STYLES%", &bundle->stylesDescriptor); + } else { + bundle->registry.registerProgmemData("%STYLES%", CSS_STYLE); + } + configureDynamicStringDescriptor(bundle->appendJsDescriptor, bundle->appendJsStatic); + bundle->registry.registerDynamicData("%PORTAL_APPEND_JS%", &bundle->appendJsDescriptor); + configureDynamicStringDescriptor(bundle->pageTitleDescriptor, bundle->pageTitleStatic); + bundle->registry.registerDynamicData("%PAGE_TITLE%", &bundle->pageTitleDescriptor); + configureDynamicStringDescriptor(bundle->bootstrapDescriptor, bundle->bootstrapJson); + bundle->registry.registerDynamicData("%BOOTSTRAP_JSON%", &bundle->bootstrapDescriptor); request->send(beginTemplateResponse(request, bundle, WM_ROOT_SHELL_TEMPLATE)); } @@ -634,7 +761,7 @@ void WiFiManagerHandlers::applyWifiAndParamsFromRequest(AsyncWebServerRequest *r _wm->_presavewificallback(); } - if (_wm->_paramsInWifi) { + if (_wm->_portalLayout.paramsOnWifiPage) { doParamSave(requestArgs); } } @@ -821,38 +948,20 @@ void WiFiManagerHandlers::sendApiJson(AsyncWebServerRequest *request, int code, request->send(response); } -void WiFiManagerHandlers::appendPortalUiFeatureFlagsJson(String& json) { - const bool portalRunning = _wm->configPortalActive || _wm->webPortalActive; - json += F("\"showInfo\":"); - json += _wm->_showInfo ? F("true") : F("false"); - json += F(",\"showUpdate\":"); - json += _wm->_showInfoUpdate ? F("true") : F("false"); - json += F(",\"showErase\":"); - json += _wm->_showInfoErase ? F("true") : F("false"); - json += F(",\"paramsInWifi\":"); - json += _wm->_paramsInWifi ? F("true") : F("false"); - json += F(",\"showRestart\":"); - json += portalRunning ? F("true") : F("false"); - json += F(",\"showExitPortal\":"); - json += (_wm->_allowExit && portalRunning) ? F("true") : F("false"); - json += F(",\"showCloseCaptive\":"); - json += (_wm->_enableCaptivePortal && _wm->configPortalActive) ? F("true") : F("false"); -} - void WiFiManagerHandlers::appendApiInfoActionsJson(String& json) { const bool portalRunning = _wm->configPortalActive || _wm->webPortalActive; json += F("\"showUpdate\":"); - json += _wm->_showInfoUpdate ? F("true") : F("false"); + json += _wm->_portalPages.updateVisible ? F("true") : F("false"); json += F(",\"showErase\":"); - json += _wm->_showInfoErase ? F("true") : F("false"); + json += _wm->_portalActions.eraseVisible ? F("true") : F("false"); json += F(",\"showBack\":"); - json += _wm->_showBack ? F("true") : F("false"); + json += _wm->_portalActions.backVisible ? F("true") : F("false"); json += F(",\"showRestart\":"); - json += portalRunning ? F("true") : F("false"); + json += (_wm->_portalActions.restartVisible && portalRunning) ? F("true") : F("false"); json += F(",\"showExitPortal\":"); - json += (_wm->_allowExit && portalRunning) ? F("true") : F("false"); + json += (_wm->_portalActions.exitVisible && _wm->_allowExit && portalRunning) ? F("true") : F("false"); json += F(",\"showCloseCaptive\":"); - json += (_wm->_enableCaptivePortal && _wm->configPortalActive) ? F("true") : F("false"); + json += (_wm->_portalActions.closeCaptiveVisible && _wm->_enableCaptivePortal && _wm->configPortalActive) ? F("true") : F("false"); } String WiFiManagerHandlers::buildPortalBootstrapJson() { @@ -882,38 +991,87 @@ String WiFiManagerHandlers::buildPortalBootstrapJson() { stateStr = "idle"; break; } + + const bool portalRunning = _wm->configPortalActive || _wm->webPortalActive; String json; - reservePage(json, 512); - json += F("{"); + reservePage(json, 1200); + json += F("{\"contractVersion\":2"); + json += F(",\"brand\":{"); json += F("\"title\":\""); - jsonAppendEscaped(json, _wm->_title); - json += F("\",\"subtitle\":\""); + jsonAppendEscaped(json, _wm->_portalBrand.title); + json += F("\",\"homeIntro\":\""); + jsonAppendEscaped(json, _wm->_portalBrand.homeIntro); + json += F("\",\"logoSvg\":\""); + jsonAppendEscaped(json, _wm->_portalBrand.logoSvg); + json += F("\"}"); + json += F(",\"context\":{"); + json += F("\"portalActive\":"); + json += portalRunning ? F("true") : F("false"); + json += F(",\"portalTimeoutSecondsRemaining\":"); { - String sub; - if (_wm->configPortalActive) { - sub = _wm->_apName; - } else { - sub = _wm->getWiFiHostname() + " - " + WiFi.localIP().toString(); + unsigned long timeoutRemainingSeconds = 0; + if (_wm->configPortalActive && _wm->_configPortalTimeout > 0) { + const unsigned long now = millis(); + const unsigned long timeoutAt = _wm->_configPortalStart + _wm->_configPortalTimeout; + if (timeoutAt > now) { + timeoutRemainingSeconds = (timeoutAt - now + 999UL) / 1000UL; + } } - jsonAppendEscaped(json, sub); + json += String(timeoutRemainingSeconds); } - json += F("\",\"portalActive\":"); - json += _wm->configPortalActive ? F("true") : F("false"); - json += F(",\"features\":{"); - appendPortalUiFeatureFlagsJson(json); - json += F("},\"showBack\":"); - json += _wm->_showBack ? F("true") : F("false"); - json += F(",\"scan\":{\"state\":\""); - json += stateStr; - json += F("\",\"count\":"); - json += String((unsigned int)networks.size()); - json += F("},\"initialStatus\":\""); + json += F(",\"identityText\":\""); + { + String idText; + if (_wm->_portalBrand.identityTextOverride.length() > 0) { + idText = _wm->_portalBrand.identityTextOverride; + } else if (_wm->configPortalActive) { + idText = _wm->_apName; + } else { + idText = _wm->getWiFiHostname() + " - " + WiFi.localIP().toString(); + } + jsonAppendEscaped(json, idText); + } + json += F("\",\"statusSummary\":\""); { String st; buildPlainStatusSummary(st); jsonAppendEscaped(json, st); } + json += F("\",\"scan\":{\"state\":\""); + json += stateStr; + json += F("\",\"count\":"); + json += String((unsigned int)networks.size()); + json += F("}}"); + json += F(",\"pages\":{"); + json += F("\"wifi\":{\"visible\":true}"); + json += F(",\"setup\":{\"visible\":"); + json += (!_wm->_portalLayout.paramsOnWifiPage && _wm->_portalPages.setupVisible) ? F("true") : F("false"); + json += F("}"); + json += F(",\"info\":{\"visible\":"); + json += _wm->_portalPages.infoVisible ? F("true") : F("false"); + json += F("}"); + json += F(",\"update\":{\"visible\":"); + json += _wm->_portalPages.updateVisible ? F("true") : F("false"); + json += F("}}"); + json += F(",\"actions\":{"); + json += F("\"erase\":{\"visible\":"); + json += _wm->_portalActions.eraseVisible ? F("true") : F("false"); + json += F("},\"restart\":{\"visible\":"); + json += (_wm->_portalActions.restartVisible && portalRunning) ? F("true") : F("false"); + json += F("},\"exitPortal\":{\"visible\":"); + json += (_wm->_portalActions.exitVisible && _wm->_allowExit && portalRunning) ? F("true") : F("false"); + json += F("},\"closeCaptive\":{\"visible\":"); + json += (_wm->_portalActions.closeCaptiveVisible && _wm->_enableCaptivePortal && _wm->configPortalActive) ? F("true") : F("false"); + json += F("},\"back\":{\"visible\":"); + json += _wm->_portalActions.backVisible ? F("true") : F("false"); + json += F("}}"); + json += F(",\"layout\":{\"paramsLocation\":\""); + json += _wm->_portalLayout.paramsOnWifiPage ? F("wifi") : F("setup"); json += F("\"}"); + bool first = true; + json += F(",\"extraHomeCards\":["); + appendPortalExtraHomeCardsJson(json, first); + json += F("]}"); return json; } @@ -1019,10 +1177,19 @@ void WiFiManagerHandlers::handleApiWifiScan(AsyncWebServerRequest *request) { String WiFiManagerHandlers::buildApiWifiMetaJson() { String ssidPlaceholder = _wm->WiFi_SSID(); String passwordPlaceholder = ""; - if (_wm->_showPassword) { - passwordPlaceholder = _wm->WiFi_psk(); - } else if (_wm->WiFi_psk() != "") { - passwordPlaceholder = F("********"); + switch (_wm->_portalPasswordPlaceholderMode) { + case PortalPasswordPlaceholderMode::Actual: + passwordPlaceholder = _wm->WiFi_psk(); + break; + case PortalPasswordPlaceholderMode::Masked: + if (_wm->WiFi_psk() != "") { + passwordPlaceholder = F("********"); + } + break; + case PortalPasswordPlaceholderMode::Hidden: + default: + passwordPlaceholder = ""; + break; } String json = F("{\"wifiFields\":["); @@ -1035,11 +1202,11 @@ String WiFiManagerHandlers::buildApiWifiMetaJson() { appendPortalJsonStaticFields(json, first); json += F("],\"params\":["); first = true; - if (_wm->_paramsInWifi && _wm->getParametersCount() > 0) { + if (_wm->_portalLayout.paramsOnWifiPage && _wm->getParametersCount() > 0) { appendPortalJsonCustomParams(json, first); } json += F("],\"actions\":{\"canRefreshScan\":true,\"showBack\":"); - json += _wm->_showBack ? F("true") : F("false"); + json += _wm->_portalActions.backVisible ? F("true") : F("false"); json += F("}}"); return json; } @@ -1096,7 +1263,7 @@ String WiFiManagerHandlers::buildApiParamsGetJson() { bool first = true; appendPortalJsonCustomParams(json, first); json += F("],\"actions\":{\"showBack\":"); - json += _wm->_showBack ? F("true") : F("false"); + json += _wm->_portalActions.backVisible ? F("true") : F("false"); json += F("}}"); return json; } @@ -1156,6 +1323,9 @@ String WiFiManagerHandlers::buildApiInfoJson() { first = true; static const char *const aboutIds[] = {"aboutver", "aboutarduinover", "aboutsdkver", "aboutdate"}; appendInfoSectionFromIds(json, aboutIds, sizeof(aboutIds) / sizeof(aboutIds[0]), first); + json += F("],\"extraSections\":["); + first = true; + appendPortalExtraInfoSectionsJson(json, first); json += F("],\"actions\":{"); appendApiInfoActionsJson(json); json += F("}}"); diff --git a/test/test_wifimanager/test_main.cpp b/test/test_wifimanager/test_main.cpp index 9813457..3a6226f 100644 --- a/test/test_wifimanager/test_main.cpp +++ b/test/test_wifimanager/test_main.cpp @@ -8,7 +8,6 @@ TestCase tests[] = { // Basic tests TEST_ENTRY(test_basic_wifimanager_instantiation), TEST_ENTRY(test_reset_settings), - TEST_ENTRY(test_disconnect), // JSON API shape (buildApiInfoJson is large — run with fresh heap, before stress tests) TEST_ENTRY(test_captive_redirect_host_rule), TEST_ENTRY(test_api_wifi_meta_json_shape), @@ -17,9 +16,7 @@ TestCase tests[] = { TEST_ENTRY(test_api_status_json_shape), // Configuration tests - TEST_ENTRY(test_configuration_setters), TEST_ENTRY(test_set_and_get_hostname), - TEST_ENTRY(test_wifi_ap_configuration_setters), TEST_ENTRY(test_get_default_ap_name), TEST_ENTRY(test_get_wifi_status_string), TEST_ENTRY(test_get_mode_string), @@ -37,19 +34,18 @@ TestCase tests[] = { TEST_ENTRY(test_config_portal_already_active), TEST_ENTRY(test_get_config_portal_ssid), TEST_ENTRY(test_bootstrap_json_portal_feature_flags), + TEST_ENTRY(test_bootstrap_json_contract_v2), TEST_ENTRY(test_bootstrap_json_snapshot_consistency), TEST_ENTRY(test_root_render_interleaved_context_isolation), // Non-blocking tests TEST_ENTRY(test_nonblocking_process), TEST_ENTRY(test_client_check_setters), - TEST_ENTRY(test_nonblocking_timeout_behavior), TEST_ENTRY(test_process_required_for_timeout), // Callback tests TEST_ENTRY(test_ap_callback), TEST_ENTRY(test_web_server_callback), - TEST_ENTRY(test_callback_registration), TEST_ENTRY(test_multiple_callbacks), // Parameter tests @@ -64,21 +60,11 @@ TestCase tests[] = { // Static IP tests TEST_ENTRY(test_set_ap_static_ip_config), - TEST_ENTRY(test_sta_static_ip_configuration), TEST_ENTRY(test_ap_static_ip_application), - // Advanced options tests - TEST_ENTRY(test_connection_behavior_options), - TEST_ENTRY(test_ui_display_options), - TEST_ENTRY(test_ui_customization), - TEST_ENTRY(test_debug_soft_ap_config), - TEST_ENTRY(test_debug_platform_info), - // WiFi connection tests TEST_ENTRY(test_autoconnect_fallback_to_portal), TEST_ENTRY(test_connectwifi_ssid_not_found), - TEST_ENTRY(test_connectwifi_retry_count), - TEST_ENTRY(test_connectwifi_timeout_setting), TEST_ENTRY(test_connection_state_transitions), TEST_ENTRY(test_autoconnect_with_timeout), @@ -94,10 +80,6 @@ TestCase tests[] = { TEST_ENTRY(test_dns_server_lifecycle_cycles), // Error condition tests - TEST_ENTRY(test_invalid_ap_password_too_short), - TEST_ENTRY(test_invalid_ap_password_too_long), - TEST_ENTRY(test_empty_ssid), - TEST_ENTRY(test_very_long_ssid), TEST_ENTRY(test_connection_failure_handling), TEST_ENTRY(test_resource_cleanup_after_error), TEST_ENTRY(test_multiple_rapid_start_stop), @@ -120,6 +102,7 @@ TestCase tests[] = { // Template rendering tests TEST_ENTRY(test_shell_template_renders_core_placeholders), + TEST_ENTRY(test_shell_template_renders_dynamic_styles_with_percent_values), // State transition tests TEST_ENTRY(test_portal_to_connected_transition), diff --git a/test/test_wifimanager/test_main.h b/test/test_wifimanager/test_main.h index b283072..d4c8262 100644 --- a/test/test_wifimanager/test_main.h +++ b/test/test_wifimanager/test_main.h @@ -18,12 +18,9 @@ struct TestCase { // Basic tests void test_basic_wifimanager_instantiation(); void test_reset_settings(); -void test_disconnect(); // Configuration tests -void test_configuration_setters(); void test_set_and_get_hostname(); -void test_wifi_ap_configuration_setters(); void test_get_default_ap_name(); void test_get_wifi_status_string(); void test_get_mode_string(); @@ -41,19 +38,18 @@ void test_config_portal_multiple_start_stop(); void test_config_portal_already_active(); void test_get_config_portal_ssid(); void test_bootstrap_json_portal_feature_flags(); +void test_bootstrap_json_contract_v2(); void test_bootstrap_json_snapshot_consistency(); void test_root_render_interleaved_context_isolation(); // Non-blocking tests void test_nonblocking_process(); void test_client_check_setters(); -void test_nonblocking_timeout_behavior(); void test_process_required_for_timeout(); // Callback tests void test_ap_callback(); void test_web_server_callback(); -void test_callback_registration(); void test_multiple_callbacks(); // Parameter tests @@ -68,21 +64,13 @@ void test_parameter_placeholder(); // Static IP tests void test_set_ap_static_ip_config(); -void test_sta_static_ip_configuration(); void test_ap_static_ip_application(); // Advanced options tests -void test_connection_behavior_options(); -void test_ui_display_options(); -void test_ui_customization(); -void test_debug_soft_ap_config(); -void test_debug_platform_info(); // WiFi connection tests void test_autoconnect_fallback_to_portal(); void test_connectwifi_ssid_not_found(); -void test_connectwifi_retry_count(); -void test_connectwifi_timeout_setting(); void test_connection_state_transitions(); void test_autoconnect_with_timeout(); @@ -98,10 +86,6 @@ void test_dns_server_cleanup(); void test_dns_server_lifecycle_cycles(); // Error condition tests -void test_invalid_ap_password_too_short(); -void test_invalid_ap_password_too_long(); -void test_empty_ssid(); -void test_very_long_ssid(); void test_connection_failure_handling(); void test_resource_cleanup_after_error(); void test_multiple_rapid_start_stop(); @@ -124,6 +108,7 @@ void test_scan_generation_invalidated_on_reset(); // Template rendering tests void test_shell_template_renders_core_placeholders(); +void test_shell_template_renders_dynamic_styles_with_percent_values(); // API JSON + captive helper tests void test_captive_redirect_host_rule(); diff --git a/test/test_wifimanager/tests/test_advanced_options.cpp b/test/test_wifimanager/tests/test_advanced_options.cpp index 8b69382..6962483 100644 --- a/test/test_wifimanager/tests/test_advanced_options.cpp +++ b/test/test_wifimanager/tests/test_advanced_options.cpp @@ -27,6 +27,18 @@ void test_connection_behavior_options() { wm.setEnableConfigPortal(false); wm.setDisableConfigPortal(true); wm.setDisableConfigPortal(false); + wm.portalSetBehaviorConnectOnSave(true); + wm.portalSetBehaviorConnectOnSave(false); + wm.portalSetBehaviorExitAllowed(true); + wm.portalSetBehaviorExitAllowed(false); + wm.portalSetBehaviorConnectTimeoutSeconds(15); + wm.portalSetBehaviorPortalTimeoutSeconds(20); + wm.portalSetBehaviorAutoReconnect(true); + wm.portalSetBehaviorAutoReconnect(false); + wm.portalSetBehaviorApClientCheck(true); + wm.portalSetBehaviorApClientCheck(false); + wm.portalSetBehaviorWebClientCheck(true); + wm.portalSetBehaviorWebClientCheck(false); wm.preloadWiFi("TestSSID", "TestPassword"); // All setters executed without crash @@ -42,16 +54,27 @@ void test_ui_display_options() { WiFiManager wm; // Test display toggles - wm.setShowPassword(true); - wm.setShowPassword(false); + wm.portalSetFieldPasswordPlaceholderMode(PortalPasswordPlaceholderMode::Actual); + wm.portalSetFieldPasswordPlaceholderMode(PortalPasswordPlaceholderMode::Masked); + wm.portalSetFieldPasswordPlaceholderMode(PortalPasswordPlaceholderMode::Hidden); wm.setScanDispPerc(true); wm.setScanDispPerc(false); - wm.setShowInfoErase(true); - wm.setShowInfoErase(false); - wm.setShowInfoUpdate(true); - wm.setShowInfoUpdate(false); - wm.setParametersEmbeddedInWifi(true); - wm.setParametersEmbeddedInWifi(false); + wm.portalSetActionEraseVisible(true); + wm.portalSetActionEraseVisible(false); + wm.portalSetActionRestartVisible(true); + wm.portalSetActionRestartVisible(false); + wm.portalSetActionExitVisible(true); + wm.portalSetActionExitVisible(false); + wm.portalSetActionCloseCaptiveVisible(true); + wm.portalSetActionCloseCaptiveVisible(false); + wm.portalSetPageUpdateVisible(true); + wm.portalSetPageUpdateVisible(false); + wm.portalSetPageSetupVisible(true); + wm.portalSetPageSetupVisible(false); + wm.portalSetLayoutParamsLocation(PortalParamsLocation::WiFiPage); + wm.portalSetLayoutParamsLocation(PortalParamsLocation::SetupPage); + wm.portalSetActionBackVisible(true); + wm.portalSetActionBackVisible(false); // All setters executed without crash TEST_ASSERT_TRUE_MESSAGE(true, "UI display options executed without crash"); @@ -66,7 +89,7 @@ void test_ui_customization() { WiFiManager wm; // Minimal customization retained: title - wm.setTitle("MyDevice"); + wm.portalSetBrandTitle("MyDevice"); // All setters executed without crash TEST_ASSERT_TRUE_MESSAGE(true, "UI customization options executed without crash"); diff --git a/test/test_wifimanager/tests/test_api_json_and_captive.cpp b/test/test_wifimanager/tests/test_api_json_and_captive.cpp index 092667b..762ae77 100644 --- a/test/test_wifimanager/tests/test_api_json_and_captive.cpp +++ b/test/test_wifimanager/tests/test_api_json_and_captive.cpp @@ -29,6 +29,14 @@ void test_api_wifi_meta_json_shape() { TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"actions\""))); TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"canRefreshScan\":true"))); + WiFiManagerParameter fieldParam("mqtt_host", "MQTT host", "broker.local", 64, "placeholder='broker.local'"); + WiFiManagerParameter htmlParam("

GPS settings...

"); + wm.portalAddParameter(&fieldParam); + wm.portalAddParameter(&htmlParam); + j = handlers.buildApiWifiMetaJson(); + TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"kind\":\"field\""))); + TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"kind\":\"html\""))); + Serial.println("[TEST] WiFi meta JSON shape test completed successfully"); } @@ -37,6 +45,12 @@ void test_api_info_json_shape() { WiFiManager wm; WiFiManagerHandlers handlers(&wm); + PortalInfoSection battery; + battery.id = "battery"; + battery.title = "Battery"; + battery.items.push_back({"soc", "State of charge", "84%"}); + battery.items.push_back({"voltage", "Voltage", "13.2V"}); + wm.portalAddInfoSection(battery); String j = handlers.buildApiInfoJson(); const char* p = j.c_str(); // Shape checks only: "status" is a nested object (},\n"device" not ],"device"); require @@ -48,6 +62,10 @@ void test_api_info_json_shape() { TEST_ASSERT_NOT_NULL(strstr(p, "\"status\":")); TEST_ASSERT_NOT_NULL(strstr(p, "\"connected\":")); TEST_ASSERT_NOT_NULL(strstr(p, "},\"device\":[")); + TEST_ASSERT_NOT_NULL(strstr(p, "\"extraSections\":")); + TEST_ASSERT_NOT_NULL(strstr(p, "\"id\":\"battery\"")); + TEST_ASSERT_NOT_NULL(strstr(p, "\"title\":\"Battery\"")); + TEST_ASSERT_NOT_NULL(strstr(p, "\"label\":\"State of charge\"")); TEST_ASSERT_NOT_NULL(strstr(p, "\"showUpdate\":")); Serial.println("[TEST] API info JSON shape test completed successfully"); diff --git a/test/test_wifimanager/tests/test_basic.cpp b/test/test_wifimanager/tests/test_basic.cpp index aef1ec8..1494f50 100644 --- a/test/test_wifimanager/tests/test_basic.cpp +++ b/test/test_wifimanager/tests/test_basic.cpp @@ -9,10 +9,7 @@ void test_basic_wifimanager_instantiation() { // Test default constructor WiFiManager wm; - - // Verify instance created (basic sanity check) - TEST_ASSERT_TRUE_MESSAGE(true, "WiFiManager instance created successfully"); - + // Test that we can call basic methods without crashing String defaultName = wm.getDefaultAPName(); TEST_ASSERT_GREATER_THAN(0, defaultName.length()); @@ -69,9 +66,6 @@ void test_disconnect() { // Can be called multiple times safely (void)wm.disconnect(); - // Verify no crash (results may be true/false depending on WiFi state) - TEST_ASSERT_TRUE_MESSAGE(true, "disconnect() executed without crash"); - Serial.println("[TEST] disconnect() test completed successfully"); } diff --git a/test/test_wifimanager/tests/test_configuration.cpp b/test/test_wifimanager/tests/test_configuration.cpp index ac0d550..48ebf33 100644 --- a/test/test_wifimanager/tests/test_configuration.cpp +++ b/test/test_wifimanager/tests/test_configuration.cpp @@ -36,26 +36,18 @@ void test_configuration_setters() { wm.setShowDnsFields(false); // Test portal behavior - wm.setCaptivePortalEnable(true); - wm.setCaptivePortalEnable(false); + wm.portalSetBehaviorCaptivePortalEnabled(true); + wm.portalSetBehaviorCaptivePortalEnabled(false); - // Test custom title (no getter available, but we can verify it doesn't crash) - wm.setTitle("TestTitle"); - - // Verify some settings can be retrieved (if getters exist) - // Note: Not all setters have getters, but we verify they don't crash - // All setters executed without crash - TEST_ASSERT_TRUE_MESSAGE(true, "All configuration setters executed without crash"); - - // Verify setters can be called multiple times with different values + // Test custom title path + wm.portalSetBrandTitle("TestTitle"); + + // Call setters multiple times with different values. wm.setConfigPortalTimeout(60); wm.setConnectTimeout(30); wm.setHttpPort(8080); wm.setMinimumSignalQuality(50); - // Verify no crash after multiple calls - TEST_ASSERT_TRUE_MESSAGE(true, "Configuration setters can be called multiple times"); - Serial.println("[TEST] Configuration setters test completed successfully"); } @@ -76,7 +68,6 @@ void test_set_and_get_hostname() { TEST_ASSERT_FALSE_MESSAGE(wm.setHostname("trailing-"), "Hostnames cannot end with a hyphen"); (void)wm.getWiFiHostname(); - TEST_ASSERT_TRUE_MESSAGE(true, "getWiFiHostname() executed after hostname validation checks"); Serial.println("[TEST] setHostname() and getWiFiHostname() test completed successfully"); } @@ -108,9 +99,6 @@ void test_wifi_ap_configuration_setters() { wm.setWiFiAutoReconnect(true); wm.setWiFiAutoReconnect(false); - // All setters executed without crash - TEST_ASSERT_TRUE_MESSAGE(true, "All WiFi AP configuration setters executed without crash"); - Serial.println("[TEST] WiFi AP configuration setters test completed successfully"); } diff --git a/test/test_wifimanager/tests/test_error_conditions.cpp b/test/test_wifimanager/tests/test_error_conditions.cpp index d457237..587a32e 100644 --- a/test/test_wifimanager/tests/test_error_conditions.cpp +++ b/test/test_wifimanager/tests/test_error_conditions.cpp @@ -172,10 +172,7 @@ void test_multiple_rapid_start_stop() { wm.process(); delay(10); } - - // Should not crash - TEST_ASSERT_TRUE_MESSAGE(true, "Multiple rapid start/stop calls handled without crash"); - + // Final state should be stopped TEST_ASSERT_FALSE(wm.getConfigPortalActive()); diff --git a/test/test_wifimanager/tests/test_integration.cpp b/test/test_wifimanager/tests/test_integration.cpp index ddaf56f..eed5e29 100644 --- a/test/test_wifimanager/tests/test_integration.cpp +++ b/test/test_wifimanager/tests/test_integration.cpp @@ -70,7 +70,7 @@ void test_parameter_add_and_retrieve() { // 1. Add parameters WiFiManagerParameter param("server", "Server", "default", 40); - wm.addParameter(¶m); + wm.portalAddParameter(¶m); TEST_ASSERT_EQUAL(1, wm.getParametersCount()); @@ -135,8 +135,8 @@ void test_portal_with_parameters_and_infrastructure() { // Add parameters WiFiManagerParameter p1("server", "Server", "192.168.1.1", 40); WiFiManagerParameter p2("port", "Port", "1883", 6); - wm.addParameter(&p1); - wm.addParameter(&p2); + wm.portalAddParameter(&p1); + wm.portalAddParameter(&p2); // Start portal wm.startConfigPortal("TestAP"); diff --git a/test/test_wifimanager/tests/test_parameter_values.cpp b/test/test_wifimanager/tests/test_parameter_values.cpp index 33a335e..a0efd2a 100644 --- a/test/test_wifimanager/tests/test_parameter_values.cpp +++ b/test/test_wifimanager/tests/test_parameter_values.cpp @@ -44,7 +44,7 @@ void test_parameter_value_persistence() { WiFiManager wm; WiFiManagerParameter param("server", "Server", "default", 40); - wm.addParameter(¶m); + wm.portalAddParameter(¶m); // Set value param.setValue("persisted_value", 40); @@ -87,9 +87,9 @@ void test_multiple_parameters_different_values() { WiFiManagerParameter p2("port", "Port", "1883", 6); WiFiManagerParameter p3("key", "Key", "default_key", 32); - wm.addParameter(&p1); - wm.addParameter(&p2); - wm.addParameter(&p3); + wm.portalAddParameter(&p1); + wm.portalAddParameter(&p2); + wm.portalAddParameter(&p3); // Set different values p1.setValue("10.0.0.1", 40); diff --git a/test/test_wifimanager/tests/test_parameters.cpp b/test/test_wifimanager/tests/test_parameters.cpp index bb676f5..b7fb2e5 100644 --- a/test/test_wifimanager/tests/test_parameters.cpp +++ b/test/test_wifimanager/tests/test_parameters.cpp @@ -20,7 +20,7 @@ void test_create_parameter() { } void test_add_parameter() { - Serial.println("[TEST] Testing addParameter()..."); + Serial.println("[TEST] Testing portalAddParameter()..."); WiFiManager wm; @@ -28,16 +28,18 @@ void test_add_parameter() { WiFiManagerParameter param2("param2", "Parameter 2", "value2", 40); // Add parameters - bool result1 = wm.addParameter(¶m1); - bool result2 = wm.addParameter(¶m2); + bool result1 = wm.portalAddParameter(¶m1); + bool result2 = wm.portalAddParameter(¶m2); TEST_ASSERT_TRUE(result1); TEST_ASSERT_TRUE(result2); // Verify parameters are stored TEST_ASSERT_EQUAL(2, wm.getParametersCount()); + wm.portalClearParameters(); + TEST_ASSERT_EQUAL(0, wm.getParametersCount()); - Serial.println("[TEST] addParameter() test completed successfully"); + Serial.println("[TEST] portalAddParameter() test completed successfully"); } void test_get_parameters() { @@ -49,9 +51,9 @@ void test_get_parameters() { WiFiManagerParameter param2("param2", "Parameter 2", "value2", 40); WiFiManagerParameter param3("param3", "Parameter 3", "value3", 40); - wm.addParameter(¶m1); - wm.addParameter(¶m2); - wm.addParameter(¶m3); + wm.portalAddParameter(¶m1); + wm.portalAddParameter(¶m2); + wm.portalAddParameter(¶m3); // Get count int count = wm.getParametersCount(); @@ -121,9 +123,9 @@ void test_multiple_parameters() { WiFiManagerParameter p2("mqtt_port", "MQTT Port", "1883", 6); WiFiManagerParameter p3("api_key", "API Key", "default_key", 32); - wm.addParameter(&p1); - wm.addParameter(&p2); - wm.addParameter(&p3); + wm.portalAddParameter(&p1); + wm.portalAddParameter(&p2); + wm.portalAddParameter(&p3); TEST_ASSERT_EQUAL(3, wm.getParametersCount()); diff --git a/test/test_wifimanager/tests/test_root_render_lifecycle.cpp b/test/test_wifimanager/tests/test_root_render_lifecycle.cpp index d1f8a25..d664d70 100644 --- a/test/test_wifimanager/tests/test_root_render_lifecycle.cpp +++ b/test/test_wifimanager/tests/test_root_render_lifecycle.cpp @@ -19,7 +19,7 @@ size_t dynamicStringLengthGetter(const char* data, void* /*userData*/) { return data ? strlen(data) : 0; } -void configureDescriptor(DynamicTemplateDescriptor& descriptor, String& value) { +void configureDescriptor(DynamicDataDescriptor& descriptor, String& value) { descriptor.getter = &dynamicStringGetter; descriptor.getLength = &dynamicStringLengthGetter; descriptor.userData = &value; @@ -62,20 +62,77 @@ void test_bootstrap_json_portal_feature_flags() { WiFiManagerHandlers handlers(&wm); String idleBootstrap = handlers.buildPortalBootstrapJson(); - TEST_ASSERT_EQUAL(-1, idleBootstrap.indexOf(F("\"showCloseCaptive\":true"))); + TEST_ASSERT_EQUAL(-1, idleBootstrap.indexOf(F("\"closeCaptive\":{\"visible\":true"))); wm.wmTestSetPortalActive(true); - wm.setCaptivePortalEnable(true); + wm.portalSetBehaviorCaptivePortalEnabled(true); String apBootstrap = handlers.buildPortalBootstrapJson(); - TEST_ASSERT_NOT_EQUAL(-1, apBootstrap.indexOf(F("\"showCloseCaptive\":true"))); - TEST_ASSERT_NOT_EQUAL(-1, apBootstrap.indexOf(F("\"showExitPortal\":true"))); + TEST_ASSERT_NOT_EQUAL(-1, apBootstrap.indexOf(F("\"closeCaptive\":{\"visible\":true"))); + TEST_ASSERT_NOT_EQUAL(-1, apBootstrap.indexOf(F("\"exitPortal\":{\"visible\":true"))); wm.wmTestSetPortalActive(false); Serial.println("[TEST] Bootstrap portal feature flags test completed successfully"); } +void test_bootstrap_json_contract_v2() { + Serial.println("[TEST] Testing bootstrap JSON v2 contract..."); + + WiFiManager wm; + WiFiManagerHandlers handlers(&wm); + + wm.portalSetBrandTitle("Solar Battery Monitor Setup"); + wm.portalSetContextIdentityText("Solar Battery Monitor"); + wm.portalSetBrandHomeIntro("Connect this device to WiFi and finish setup."); + wm.portalSetBrandLogoSvg(""); + wm.portalSetPageInfoVisible(true); + wm.portalSetPageUpdateVisible(false); + wm.portalSetPageSetupVisible(true); + wm.portalSetActionEraseVisible(false); + wm.portalSetActionBackVisible(true); + wm.portalSetLayoutParamsLocation(PortalParamsLocation::SetupPage); + wm.setConfigPortalTimeout(90); + + PortalHomeCard card; + card.id = "solar"; + card.title = "Solar summary"; + card.kind = PortalHomeCardKind::KeyValue; + card.items.push_back({"pv", "PV input", "420W"}); + wm.portalAddHomeCard(card); + + String j = handlers.buildPortalBootstrapJson(); + TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"contractVersion\":2"))); + TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"brand\":{"))); + TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"title\":\"Solar Battery Monitor Setup\""))); + TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"homeIntro\":\"Connect this device to WiFi and finish setup.\""))); + TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"logoSvg\":\"\""))); + TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"context\":{"))); + TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"identityText\":\"Solar Battery Monitor\""))); + TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"pages\":{"))); + TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"setup\":{\"visible\":true}"))); + TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"actions\":{"))); + TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"erase\":{\"visible\":false}"))); + TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"back\":{\"visible\":true}"))); + TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"layout\":{\"paramsLocation\":\"setup\"}"))); + TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"extraHomeCards\":["))); + TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"id\":\"solar\""))); + TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"kind\":\"kv\""))); + + wm.startWebPortal(); + j = handlers.buildPortalBootstrapJson(); + TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"portalActive\":true"))); + wm.stopWebPortal(); + + wm.wmTestSetPortalActive(true); + j = handlers.buildPortalBootstrapJson(); + TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"portalTimeoutSecondsRemaining\":"))); + TEST_ASSERT_EQUAL(-1, j.indexOf(F("\"portalTimeoutSecondsRemaining\":0"))); + wm.wmTestSetPortalActive(false); + + Serial.println("[TEST] Bootstrap JSON v2 contract test completed successfully"); +} + void test_bootstrap_json_snapshot_consistency() { Serial.println("[TEST] Testing bootstrap JSON snapshot consistency..."); @@ -106,32 +163,34 @@ void test_root_render_interleaved_context_isolation() { registryA.registerProgmemData("%STYLES%", kEmptyTemplateChunk); registryA.registerProgmemData("%PAGE_TITLE%", kTestTitle); + registryA.registerProgmemData("%PORTAL_APPEND_JS%", kEmptyTemplateChunk); registryB.registerProgmemData("%STYLES%", kEmptyTemplateChunk); registryB.registerProgmemData("%PAGE_TITLE%", kTestTitle); + registryB.registerProgmemData("%PORTAL_APPEND_JS%", kEmptyTemplateChunk); String bootstrapA = F("{\"ctx\":\"A\"}"); String appJsA = F("// shell A"); String bootstrapB = F("{\"ctx\":\"B\"}"); String appJsB = F("// shell B"); - DynamicTemplateDescriptor bootstrapDescriptorA{}; - DynamicTemplateDescriptor appJsDescriptorA{}; - DynamicTemplateDescriptor bootstrapDescriptorB{}; - DynamicTemplateDescriptor appJsDescriptorB{}; + DynamicDataDescriptor bootstrapDescriptorA{}; + DynamicDataDescriptor appJsDescriptorA{}; + DynamicDataDescriptor bootstrapDescriptorB{}; + DynamicDataDescriptor appJsDescriptorB{}; configureDescriptor(bootstrapDescriptorA, bootstrapA); configureDescriptor(appJsDescriptorA, appJsA); configureDescriptor(bootstrapDescriptorB, bootstrapB); configureDescriptor(appJsDescriptorB, appJsB); - TEST_ASSERT_TRUE_MESSAGE(registryA.registerDynamicTemplate("%BOOTSTRAP_JSON%", &bootstrapDescriptorA), + TEST_ASSERT_TRUE_MESSAGE(registryA.registerDynamicData("%BOOTSTRAP_JSON%", &bootstrapDescriptorA), "Registry A bootstrap placeholder should register"); - TEST_ASSERT_TRUE_MESSAGE(registryA.registerDynamicTemplate("%PORTAL_APP_JS%", &appJsDescriptorA), + TEST_ASSERT_TRUE_MESSAGE(registryA.registerDynamicData("%PORTAL_APP_JS%", &appJsDescriptorA), "Registry A app JS placeholder should register"); - TEST_ASSERT_TRUE_MESSAGE(registryB.registerDynamicTemplate("%BOOTSTRAP_JSON%", &bootstrapDescriptorB), + TEST_ASSERT_TRUE_MESSAGE(registryB.registerDynamicData("%BOOTSTRAP_JSON%", &bootstrapDescriptorB), "Registry B bootstrap placeholder should register"); - TEST_ASSERT_TRUE_MESSAGE(registryB.registerDynamicTemplate("%PORTAL_APP_JS%", &appJsDescriptorB), + TEST_ASSERT_TRUE_MESSAGE(registryB.registerDynamicData("%PORTAL_APP_JS%", &appJsDescriptorB), "Registry B app JS placeholder should register"); TemplateContext contextA; diff --git a/test/test_wifimanager/tests/test_stress.cpp b/test/test_wifimanager/tests/test_stress.cpp index 6642ef9..28e04f2 100644 --- a/test/test_wifimanager/tests/test_stress.cpp +++ b/test/test_wifimanager/tests/test_stress.cpp @@ -112,7 +112,7 @@ void test_multiple_parameters_stress() { sprintf(id, "param%d", i); sprintf(label, "Parameter %d", i); params[i] = new WiFiManagerParameter(id, label, "default", 40); - wm.addParameter(params[i]); + wm.portalAddParameter(params[i]); } TEST_ASSERT_EQUAL(20, wm.getParametersCount()); diff --git a/test/test_wifimanager/tests/test_template_rendering.cpp b/test/test_wifimanager/tests/test_template_rendering.cpp index 7af1cea..b128be5 100644 --- a/test/test_wifimanager/tests/test_template_rendering.cpp +++ b/test/test_wifimanager/tests/test_template_rendering.cpp @@ -2,6 +2,7 @@ #include #include #include "templates/RootShell.h" +#include "templates/PortalAppJS.h" #include "../test_main.h" namespace { @@ -27,6 +28,22 @@ const char kEmpty[] PROGMEM = ""; const char kDocTitle[] PROGMEM = "Config ESP"; const char kBootstrapJson[] PROGMEM = "{\"title\":\"Test\"}"; const char kPortalAppJs[] PROGMEM = "console.log('portal');"; +const char kPortalAppendJs[] PROGMEM = ""; + +const char* dynamicStringGetter(void* userData) { + const auto* value = static_cast(userData); + return value ? value->c_str() : ""; +} + +size_t dynamicStringLengthGetter(const char* data, void* /*userData*/) { + return data ? strlen(data) : 0; +} + +void configureDescriptor(DynamicDataDescriptor& descriptor, String& value) { + descriptor.getter = &dynamicStringGetter; + descriptor.getLength = &dynamicStringLengthGetter; + descriptor.userData = &value; +} } // namespace @@ -38,6 +55,7 @@ void test_shell_template_renders_core_placeholders() { registry.registerProgmemData("%PAGE_TITLE%", kDocTitle); registry.registerProgmemData("%BOOTSTRAP_JSON%", kBootstrapJson); registry.registerProgmemData("%PORTAL_APP_JS%", kPortalAppJs); + registry.registerProgmemData("%PORTAL_APPEND_JS%", kPortalAppendJs); String output = renderTemplate(WM_ROOT_SHELL_TEMPLATE, registry); @@ -51,3 +69,36 @@ void test_shell_template_renders_core_placeholders() { Serial.println("[TEST] Portal shell template rendering test completed successfully"); } + +void test_shell_template_renders_dynamic_styles_with_percent_values() { + Serial.println("[TEST] Testing portal shell dynamic styles placeholder with percent values..."); + + PlaceholderRegistry registry(8); + String styles = F(""); + String title = F("Config ESP"); + String bootstrap = F("{\"title\":\"Test\"}"); + String appendJs = F(""); + DynamicDataDescriptor stylesDescriptor{}; + DynamicDataDescriptor titleDescriptor{}; + DynamicDataDescriptor bootstrapDescriptor{}; + DynamicDataDescriptor appendJsDescriptor{}; + + configureDescriptor(stylesDescriptor, styles); + configureDescriptor(titleDescriptor, title); + configureDescriptor(bootstrapDescriptor, bootstrap); + configureDescriptor(appendJsDescriptor, appendJs); + + TEST_ASSERT_TRUE(registry.registerDynamicData("%STYLES%", &stylesDescriptor)); + TEST_ASSERT_TRUE(registry.registerDynamicData("%PAGE_TITLE%", &titleDescriptor)); + TEST_ASSERT_TRUE(registry.registerDynamicData("%BOOTSTRAP_JSON%", &bootstrapDescriptor)); + TEST_ASSERT_TRUE(registry.registerProgmemData("%PORTAL_APP_JS%", kPortalAppJs)); + TEST_ASSERT_TRUE(registry.registerDynamicData("%PORTAL_APPEND_JS%", &appendJsDescriptor)); + + String output = renderTemplate(WM_ROOT_SHELL_TEMPLATE, registry); + + TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("")); + TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("Config ESP")); + TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("{\"title\":\"Test\"}")); + + Serial.println("[TEST] Dynamic styles placeholder percent rendering test completed successfully"); +} diff --git a/test/test_wifimanager/tests/test_wifi_scanning.cpp b/test/test_wifimanager/tests/test_wifi_scanning.cpp index 6b70848..2327560 100644 --- a/test/test_wifimanager/tests/test_wifi_scanning.cpp +++ b/test/test_wifimanager/tests/test_wifi_scanning.cpp @@ -57,7 +57,7 @@ void test_scan_status_checking() { TEST_ASSERT_EQUAL(static_cast(2), wm.getScanResults().size()); TEST_ASSERT_EQUAL_STRING("Office", wm.getScanResults()[0].ssid.c_str()); #else - TEST_ASSERT_TRUE_MESSAGE(true, "UNIT_TEST helpers unavailable"); + TEST_IGNORE_MESSAGE("UNIT_TEST helpers unavailable"); #endif Serial.println("[TEST] Cached scan snapshot state test completed successfully"); @@ -79,7 +79,7 @@ void test_scan_completion_wait() { TEST_ASSERT_FALSE_MESSAGE(wm.hasValidScanResults(), "Timed out scans should not leave cached results marked valid"); #else - TEST_ASSERT_TRUE_MESSAGE(true, "UNIT_TEST helpers unavailable"); + TEST_IGNORE_MESSAGE("UNIT_TEST helpers unavailable"); #endif Serial.println("[TEST] Scan timeout transition test completed successfully"); @@ -101,7 +101,7 @@ void test_scan_cancels_when_connect_pending() { TEST_ASSERT_FALSE_MESSAGE(wm.getScanSnapshot().schedulePending, "Connect flow should cancel any in-flight async scan"); #else - TEST_ASSERT_TRUE_MESSAGE(true, "UNIT_TEST helpers unavailable"); + TEST_IGNORE_MESSAGE("UNIT_TEST helpers unavailable"); #endif Serial.println("[TEST] Scan cancellation during connect test completed successfully"); @@ -122,7 +122,7 @@ void test_scan_cancels_when_lifecycle_blocked() { TEST_ASSERT_FALSE_MESSAGE(wm.getScanSnapshot().schedulePending, "Lifecycle blocking should leave scan engine idle"); #else - TEST_ASSERT_TRUE_MESSAGE(true, "UNIT_TEST helpers unavailable"); + TEST_IGNORE_MESSAGE("UNIT_TEST helpers unavailable"); #endif Serial.println("[TEST] Scan cancellation during lifecycle block test completed successfully"); @@ -144,7 +144,7 @@ void test_scan_generation_invalidated_on_reset() { TEST_ASSERT_FALSE_MESSAGE(wm.getScanSnapshot().completionPending, "Resetting scan state should clear pending completion callbacks"); #else - TEST_ASSERT_TRUE_MESSAGE(true, "UNIT_TEST helpers unavailable"); + TEST_IGNORE_MESSAGE("UNIT_TEST helpers unavailable"); #endif Serial.println("[TEST] Scan generation invalidation on reset test completed successfully");