fix: harden firmware NVS persistence, WDT, and 304 epd_sleep
Three bugs fixed: - NVS img_id now written before epd_init/draw; new draw_needed flag in NVS survives power-loss mid-refresh so next boot re-draws from LittleFS instead of showing stale content - epd_sleep() now only called when display was initialized this cycle, preventing a 60 s wait_busy() timeout on every 304 poll - esp_task_wdt_reset() added to wait_busy() loop so the ~20 s 6-color refresh no longer triggers the task watchdog Also extracts normal_operation into operation.h template and adds a native PlatformIO test suite (16 tests) covering the full response matrix. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <cstring>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <algorithm>
|
||||
#include <sstream>
|
||||
#include <cctype>
|
||||
|
||||
// Minimal String class that mimics Arduino's String for native tests
|
||||
struct String {
|
||||
std::string _s;
|
||||
|
||||
String() {}
|
||||
String(const char* s) : _s(s ? s : "") {}
|
||||
String(const std::string& s) : _s(s) {}
|
||||
String(int v) { _s = std::to_string(v); }
|
||||
String(unsigned long v) { _s = std::to_string(v); }
|
||||
String(long long v) { _s = std::to_string(v); }
|
||||
String(unsigned long long v) { _s = std::to_string(v); }
|
||||
|
||||
const char* c_str() const { return _s.c_str(); }
|
||||
size_t length() const { return _s.size(); }
|
||||
bool isEmpty() const { return _s.empty(); }
|
||||
bool empty() const { return _s.empty(); }
|
||||
|
||||
int toInt() const { return _s.empty() ? 0 : std::stoi(_s); }
|
||||
|
||||
String substring(size_t from) const { return String(_s.substr(from)); }
|
||||
String substring(size_t from, size_t to) const { return String(_s.substr(from, to - from)); }
|
||||
|
||||
void replace(const char* from, const char* to_str) {
|
||||
std::string result;
|
||||
std::string f(from), t(to_str);
|
||||
size_t pos = 0, found;
|
||||
while ((found = _s.find(f, pos)) != std::string::npos) {
|
||||
result += _s.substr(pos, found - pos);
|
||||
result += t;
|
||||
pos = found + f.size();
|
||||
}
|
||||
result += _s.substr(pos);
|
||||
_s = result;
|
||||
}
|
||||
|
||||
void toUpperCase() {
|
||||
for (char& c : _s) c = (char)toupper((unsigned char)c);
|
||||
}
|
||||
|
||||
bool operator==(const String& o) const { return _s == o._s; }
|
||||
bool operator==(const char* o) const { return _s == o; }
|
||||
bool operator!=(const String& o) const { return _s != o._s; }
|
||||
bool operator!=(const char* o) const { return _s != o; }
|
||||
|
||||
String operator+(const String& o) const { return String(_s + o._s); }
|
||||
String operator+(const char* o) const { return String(_s + o); }
|
||||
String& operator+=(const String& o) { _s += o._s; return *this; }
|
||||
String& operator+=(const char* o) { _s += o; return *this; }
|
||||
|
||||
// Allow use as map key
|
||||
operator std::string() const { return _s; }
|
||||
|
||||
// toString() for IP-like objects that have it
|
||||
String toString() const { return *this; }
|
||||
};
|
||||
|
||||
inline String operator+(const char* a, const String& b) { return String(std::string(a) + b._s); }
|
||||
inline String operator+(const std::string& a, const String& b) { return String(a + b._s); }
|
||||
|
||||
// Controllable millis and digitalRead for timeout / button tests
|
||||
extern uint32_t g_millis_value;
|
||||
extern int g_digital_read_value;
|
||||
|
||||
#ifndef LOW
|
||||
#define LOW 0
|
||||
#define HIGH 1
|
||||
#endif
|
||||
|
||||
inline unsigned long millis() { return g_millis_value += 10; }
|
||||
inline void delay(unsigned long) {}
|
||||
inline void pinMode(int, int) {}
|
||||
inline int digitalRead(int) { return g_digital_read_value; }
|
||||
|
||||
// Color constants (from config.h)
|
||||
#define COLOR_YELLOW 0x2
|
||||
#define COLOR_RED 0x3
|
||||
|
||||
// Serial mock
|
||||
struct SerialMock {
|
||||
void begin(int) {}
|
||||
void println(const String&) {}
|
||||
void println(const char*) {}
|
||||
void println(int) {}
|
||||
void print(const String&) {}
|
||||
void print(const char*) {}
|
||||
void flush() {}
|
||||
} Serial;
|
||||
|
||||
// strtoull is available from <cstdlib> on native
|
||||
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
enum class DNSReplyCode { NoError };
|
||||
struct DNSServer {
|
||||
void setErrorReplyCode(DNSReplyCode) {}
|
||||
void start(uint16_t, const char*, const std::string&) {}
|
||||
void processNextRequest() {}
|
||||
void stop() {}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
// Minimal FS.h stub for native unit tests
|
||||
// Provides the fs::File type that Arduino's FS library normally defines.
|
||||
|
||||
#include "Arduino.h"
|
||||
#include <string>
|
||||
#include <cstdint>
|
||||
|
||||
namespace fs {
|
||||
|
||||
struct File {
|
||||
std::string* _buf = nullptr;
|
||||
bool _valid = false;
|
||||
bool _write = false;
|
||||
size_t _pos = 0;
|
||||
|
||||
explicit operator bool() const { return _valid; }
|
||||
void close() { _valid = false; }
|
||||
size_t write(const uint8_t* data, size_t len) {
|
||||
if (_buf && _write) { _buf->append((const char*)data, len); return len; }
|
||||
return 0;
|
||||
}
|
||||
int read() {
|
||||
if (_buf && _pos < _buf->size()) return (uint8_t)(*_buf)[_pos++];
|
||||
return -1;
|
||||
}
|
||||
size_t size() { return _buf ? _buf->size() : 0; }
|
||||
};
|
||||
|
||||
} // namespace fs
|
||||
@@ -0,0 +1,47 @@
|
||||
#pragma once
|
||||
#include "Arduino.h"
|
||||
#include "LittleFS.h"
|
||||
#include "WiFiClientSecure.h"
|
||||
#include <map>
|
||||
|
||||
// Global test state for inspecting behavior
|
||||
extern int g_http_get_code;
|
||||
extern std::map<std::string, std::string> g_http_response_headers;
|
||||
extern std::map<std::string, std::string> g_http_request_headers;
|
||||
extern bool g_http_end_called;
|
||||
extern std::string g_http_body;
|
||||
|
||||
struct MockHTTPClient {
|
||||
bool _ended = false;
|
||||
|
||||
void begin(WiFiClientSecure&, const String& url) {}
|
||||
|
||||
void addHeader(const char* name, const String& value) {
|
||||
g_http_request_headers[name] = value._s;
|
||||
}
|
||||
|
||||
void collectHeaders(const char** headers, int count) {}
|
||||
|
||||
int GET() {
|
||||
_ended = false;
|
||||
return g_http_get_code;
|
||||
}
|
||||
|
||||
String header(const char* name) {
|
||||
if (_ended) return String("");
|
||||
auto it = g_http_response_headers.find(name);
|
||||
return it != g_http_response_headers.end() ? String(it->second) : String("");
|
||||
}
|
||||
|
||||
size_t writeToStream(File* f) {
|
||||
if (f && *f) {
|
||||
f->write((const uint8_t*)g_http_body.data(), g_http_body.size());
|
||||
}
|
||||
return g_http_body.size();
|
||||
}
|
||||
|
||||
void end() {
|
||||
_ended = true;
|
||||
g_http_end_called = true;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
#include "Arduino.h"
|
||||
#include <map>
|
||||
#include <vector>
|
||||
|
||||
struct File {
|
||||
std::string* _buf = nullptr;
|
||||
bool _valid = false;
|
||||
bool _write = false;
|
||||
size_t _pos = 0;
|
||||
|
||||
explicit operator bool() const { return _valid; }
|
||||
void close() { _valid = false; }
|
||||
size_t write(const uint8_t* data, size_t len) {
|
||||
if (_buf && _write) { _buf->append((const char*)data, len); return len; }
|
||||
return 0;
|
||||
}
|
||||
int read() {
|
||||
if (_buf && _pos < _buf->size()) return (uint8_t)(*_buf)[_pos++];
|
||||
return -1;
|
||||
}
|
||||
size_t size() { return _buf ? _buf->size() : 0; }
|
||||
};
|
||||
|
||||
struct LittleFSClass {
|
||||
std::map<std::string, std::string> files;
|
||||
|
||||
bool begin(bool) { return true; }
|
||||
|
||||
File open(const char* path, const char* mode, bool create = false) {
|
||||
File f;
|
||||
f._valid = true;
|
||||
f._write = (mode[0] == 'w');
|
||||
f._buf = &files[path];
|
||||
if (f._write) f._buf->clear();
|
||||
f._pos = 0;
|
||||
return f;
|
||||
}
|
||||
} LittleFS;
|
||||
@@ -0,0 +1,35 @@
|
||||
#pragma once
|
||||
#include "Arduino.h"
|
||||
#include <map>
|
||||
|
||||
// Shared sequence counter — incremented by each instrumented mock call
|
||||
extern int g_call_seq;
|
||||
extern int g_prefs_putint_seq; // sequence position of last putInt call
|
||||
|
||||
struct Preferences {
|
||||
std::map<std::string, int32_t> ints;
|
||||
std::map<std::string, std::string> strings;
|
||||
bool _open = false;
|
||||
|
||||
void begin(const char*, bool) { _open = true; }
|
||||
void end() { _open = false; }
|
||||
|
||||
int32_t getInt(const char* key, int32_t def = 0) {
|
||||
auto it = ints.find(key);
|
||||
return it != ints.end() ? it->second : def;
|
||||
}
|
||||
void putInt(const char* key, int32_t val) {
|
||||
ints[key] = val;
|
||||
// Record the sequence of the FIRST putInt call (ordering test uses this
|
||||
// to verify NVS is written before epd_draw_image_from_file).
|
||||
if (g_prefs_putint_seq < 0) g_prefs_putint_seq = g_call_seq;
|
||||
g_call_seq++;
|
||||
}
|
||||
|
||||
String getString(const char* key, const char* def = "") {
|
||||
auto it = strings.find(key);
|
||||
return it != strings.end() ? String(it->second) : String(def);
|
||||
}
|
||||
void putString(const char* key, const String& val) { strings[key] = val._s; }
|
||||
void clear() { ints.clear(); strings.clear(); }
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
#pragma once
|
||||
struct SPISettings { SPISettings(uint32_t, uint8_t, uint8_t) {} };
|
||||
struct SPIClass {
|
||||
void begin(int,int,int,int) {}
|
||||
void beginTransaction(SPISettings) {}
|
||||
} SPI;
|
||||
#define MSBFIRST 1
|
||||
#define SPI_MODE0 0
|
||||
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
struct WebServer {
|
||||
WebServer(int) {}
|
||||
void on(const char*, void(*)()) {}
|
||||
void on(const char*, int, void(*)()) {}
|
||||
void onNotFound(void(*)()) {}
|
||||
void begin() {}
|
||||
void handleClient() {}
|
||||
void stop() {}
|
||||
bool hasArg(const char*) { return false; }
|
||||
std::string arg(const char*) { return ""; }
|
||||
void send(int, const char*, const char*) {}
|
||||
void send_P(int, const char*, const char*) {}
|
||||
void sendHeader(const char*, const char*) {}
|
||||
};
|
||||
#define HTTP_GET 0
|
||||
#define HTTP_POST 1
|
||||
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
#include "Arduino.h"
|
||||
#define WIFI_STA 0
|
||||
#define WIFI_AP 1
|
||||
#define WL_CONNECTED 3
|
||||
|
||||
extern int g_wifi_status;
|
||||
|
||||
struct WiFiClass {
|
||||
String macAddress() { return String("1C:C3:AB:D1:91:F8"); }
|
||||
int status() { return g_wifi_status; }
|
||||
void mode(int) {}
|
||||
void begin(const char*, const char*) {}
|
||||
void disconnect(bool) {}
|
||||
bool softAP(const char*) { return true; }
|
||||
String softAPIP() { return String("192.168.4.1"); }
|
||||
String localIP() { return String("192.168.1.100"); }
|
||||
} WiFi;
|
||||
@@ -0,0 +1,4 @@
|
||||
#pragma once
|
||||
struct WiFiClientSecure {
|
||||
void setInsecure() {}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
// Mirror of src/config.h for use in native unit tests.
|
||||
// Values must match src/config.h so test assertions stay consistent.
|
||||
#define APP_BASE_URL "https://pictureframe.edholm.me"
|
||||
#define NVS_NAMESPACE "pf"
|
||||
#define NVS_KEY_SSID "ssid"
|
||||
#define NVS_KEY_PASS "pass"
|
||||
#define NVS_KEY_IMG_ID "img_id"
|
||||
#define NVS_KEY_DRAW_NEEDED "draw"
|
||||
#define IMAGE_PATH "/img.bin"
|
||||
#define FETCH_INTERVAL_MS 60000ULL
|
||||
#define WIFI_TIMEOUT_MS 30000
|
||||
#define RESET_HOLD_MS 5000
|
||||
#define AP_IP "192.168.4.1"
|
||||
#define PIN_CS 5
|
||||
#define PIN_DC 17
|
||||
#define PIN_RST 16
|
||||
#define PIN_BUSY 4
|
||||
#define PIN_SCK 18
|
||||
#define PIN_MOSI 23
|
||||
#define PIN_BTN_RESET 0
|
||||
// Color constants (also defined in Arduino mock, repeated here for clarity)
|
||||
#define COLOR_BLACK 0x0
|
||||
#define COLOR_WHITE 0x1
|
||||
#define COLOR_YELLOW 0x2
|
||||
#define COLOR_RED 0x3
|
||||
#define COLOR_BLUE 0x5
|
||||
#define COLOR_GREEN 0x6
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
#include "Arduino.h"
|
||||
|
||||
// Call counters for assertions
|
||||
extern int g_epd_init_count;
|
||||
extern int g_epd_sleep_count;
|
||||
extern int g_epd_draw_image_count;
|
||||
extern int g_epd_fill_count;
|
||||
extern int g_epd_fill_last_color;
|
||||
extern int g_epd_draw_setup_count;
|
||||
|
||||
inline void epd_init() { g_epd_init_count++; }
|
||||
inline void epd_sleep() { g_epd_sleep_count++; }
|
||||
inline void epd_draw_image_from_file(File& f) { g_epd_draw_image_count++; }
|
||||
inline void epd_fill(int color) { g_epd_fill_count++; g_epd_fill_last_color = color; }
|
||||
inline void epd_draw_ap_screen(void*) {}
|
||||
inline void epd_draw_setup_screen(void*) { g_epd_draw_setup_count++; }
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
#include "Arduino.h"
|
||||
|
||||
// Shared sequence counter — incremented by each instrumented mock call
|
||||
extern int g_call_seq;
|
||||
extern int g_epd_draw_seq; // sequence position of last epd_draw_image_from_file call
|
||||
|
||||
// Call counters for assertions
|
||||
extern int g_epd_init_count;
|
||||
extern int g_epd_sleep_count;
|
||||
extern int g_epd_draw_image_count;
|
||||
extern int g_epd_fill_count;
|
||||
extern int g_epd_fill_last_color;
|
||||
extern int g_epd_draw_setup_count;
|
||||
|
||||
inline void epd_init() { g_epd_init_count++; }
|
||||
inline void epd_sleep() { g_epd_sleep_count++; }
|
||||
inline void epd_draw_image_from_file(File& f) {
|
||||
g_epd_draw_image_count++;
|
||||
if (g_epd_draw_seq < 0) g_epd_draw_seq = g_call_seq;
|
||||
g_call_seq++;
|
||||
}
|
||||
inline void epd_fill(int color) { g_epd_fill_count++; g_epd_fill_last_color = color; }
|
||||
inline void epd_draw_ap_screen(void*) {}
|
||||
inline void epd_draw_setup_screen(void*) { g_epd_draw_setup_count++; }
|
||||
@@ -0,0 +1,8 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
extern uint64_t g_sleep_us;
|
||||
extern bool g_deep_sleep_started;
|
||||
|
||||
inline void esp_sleep_enable_timer_wakeup(uint64_t us) { g_sleep_us = us; }
|
||||
inline void esp_deep_sleep_start() { g_deep_sleep_started = true; }
|
||||
@@ -0,0 +1,7 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <cstddef>
|
||||
struct QRCode { int size; };
|
||||
inline size_t qrcode_getBufferSize(int) { return 128; }
|
||||
inline void qrcode_initText(QRCode* qr, uint8_t*, int, int, const char*) { qr->size = 21; }
|
||||
#define ECC_LOW 0
|
||||
@@ -0,0 +1,248 @@
|
||||
#include <unity.h>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <cstdint>
|
||||
#include <cctype>
|
||||
|
||||
// Include mocks first — they shadow system/Arduino headers.
|
||||
// -iquote test/mocks ensures quoted includes from test_main find mocks first.
|
||||
// operation.h uses #ifdef UNIT_TEST to pick epd_mock.h and esp_sleep.h.
|
||||
#include "Arduino.h"
|
||||
#include "WiFi.h"
|
||||
#include "WiFiClientSecure.h"
|
||||
#include "Preferences.h"
|
||||
#include "LittleFS.h"
|
||||
#include "epd_mock.h"
|
||||
#include "esp_sleep.h"
|
||||
#include "HTTPClient.h"
|
||||
#include "SPI.h"
|
||||
#include "WebServer.h"
|
||||
#include "DNSServer.h"
|
||||
#include "qrcode.h"
|
||||
#include "config.h"
|
||||
|
||||
// Define globals referenced as extern in the mock headers
|
||||
int g_http_get_code;
|
||||
std::map<std::string, std::string> g_http_response_headers;
|
||||
std::map<std::string, std::string> g_http_request_headers;
|
||||
bool g_http_end_called;
|
||||
std::string g_http_body;
|
||||
|
||||
int g_epd_init_count, g_epd_sleep_count, g_epd_draw_image_count;
|
||||
int g_epd_fill_count, g_epd_fill_last_color, g_epd_draw_setup_count;
|
||||
|
||||
uint64_t g_sleep_us;
|
||||
bool g_deep_sleep_started;
|
||||
|
||||
// Globals for new mocks
|
||||
int g_show_setup_qr_count;
|
||||
uint32_t g_millis_value;
|
||||
int g_digital_read_value;
|
||||
int g_wifi_status;
|
||||
|
||||
// Ordering / sequencing globals (shared with Preferences.h and epd_mock.h)
|
||||
int g_call_seq = 0;
|
||||
int g_prefs_putint_seq = -1;
|
||||
int g_epd_draw_seq = -1;
|
||||
|
||||
// Include the template under test AFTER all mocks are defined.
|
||||
// operation.h with UNIT_TEST defined will include "epd_mock.h" and "esp_sleep.h"
|
||||
// via -iquote test/mocks path (real src/epd.h is never pulled in).
|
||||
#include "../../src/operation.h"
|
||||
|
||||
// Test fixtures
|
||||
static Preferences prefs;
|
||||
static MockHTTPClient http;
|
||||
|
||||
void reset_state() {
|
||||
g_http_get_code = 200;
|
||||
g_http_response_headers.clear();
|
||||
g_http_request_headers.clear();
|
||||
g_http_end_called = false;
|
||||
g_http_body = "TESTDATA";
|
||||
g_epd_init_count = g_epd_sleep_count = g_epd_draw_image_count = 0;
|
||||
g_epd_fill_count = g_epd_fill_last_color = g_epd_draw_setup_count = 0;
|
||||
g_sleep_us = 0;
|
||||
g_deep_sleep_started = false;
|
||||
g_show_setup_qr_count = 0;
|
||||
g_millis_value = 0;
|
||||
g_digital_read_value = HIGH; // button not pressed by default
|
||||
g_wifi_status = WL_CONNECTED; // connected by default
|
||||
prefs.clear();
|
||||
LittleFS.files.clear();
|
||||
http._ended = false;
|
||||
g_call_seq = 0;
|
||||
g_prefs_putint_seq = -1;
|
||||
g_epd_draw_seq = -1;
|
||||
}
|
||||
|
||||
void setUp() { reset_state(); }
|
||||
void tearDown() {}
|
||||
|
||||
// FW-01: 200 response — file written, epd_draw called, NVS saved, deep sleep started
|
||||
void test_fw01_200_response_happy_path() {
|
||||
// Use an interval < FETCH_INTERVAL_MS so server value is honored
|
||||
g_http_response_headers["X-Image-Id"] = "42";
|
||||
g_http_response_headers["X-Interval-Ms"] = "30000";
|
||||
g_http_body = "BINDATA";
|
||||
|
||||
normal_operation_impl(String("1C:C3:AB:D1:91:F8"), http, String("https://test/api/device/mac/image"), prefs);
|
||||
|
||||
TEST_ASSERT_EQUAL(1, g_epd_draw_image_count);
|
||||
TEST_ASSERT_EQUAL(42, prefs.getInt("img_id", -1));
|
||||
TEST_ASSERT_EQUAL_UINT64(30000ULL * 1000ULL, g_sleep_us);
|
||||
TEST_ASSERT_TRUE(g_deep_sleep_started);
|
||||
TEST_ASSERT_FALSE(LittleFS.files[IMAGE_PATH].empty());
|
||||
}
|
||||
|
||||
// FW-02: REGRESSION — headers must be read BEFORE http.end(), otherwise newId is empty
|
||||
void test_fw02_headers_read_before_end_regression() {
|
||||
g_http_response_headers["X-Image-Id"] = "99";
|
||||
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
||||
// If newId was read after end(), NVS img_id would remain -1
|
||||
TEST_ASSERT_EQUAL(99, prefs.getInt("img_id", -1));
|
||||
}
|
||||
|
||||
// FW-03: 304 — no epd draw, no init, deep sleep started
|
||||
void test_fw03_304_no_redraw() {
|
||||
g_http_get_code = 304;
|
||||
// Use an interval < FETCH_INTERVAL_MS so server value is honored
|
||||
g_http_response_headers["X-Interval-Ms"] = "30000";
|
||||
|
||||
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
||||
|
||||
TEST_ASSERT_EQUAL(0, g_epd_init_count);
|
||||
TEST_ASSERT_EQUAL(0, g_epd_draw_image_count);
|
||||
TEST_ASSERT_TRUE(g_deep_sleep_started);
|
||||
TEST_ASSERT_EQUAL_UINT64(30000ULL * 1000ULL, g_sleep_us);
|
||||
}
|
||||
|
||||
// FW-04: 204 — show_setup_qr called exactly once
|
||||
void test_fw04_204_shows_setup_qr() {
|
||||
g_http_get_code = 204;
|
||||
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
||||
TEST_ASSERT_EQUAL(1, g_show_setup_qr_count);
|
||||
TEST_ASSERT_EQUAL(0, g_epd_draw_image_count);
|
||||
}
|
||||
|
||||
// FW-05: 404 — show_setup_qr called exactly once
|
||||
void test_fw05_404_shows_setup_qr() {
|
||||
g_http_get_code = 404;
|
||||
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
||||
TEST_ASSERT_EQUAL(1, g_show_setup_qr_count);
|
||||
TEST_ASSERT_EQUAL(0, g_epd_draw_image_count);
|
||||
}
|
||||
|
||||
// FW-06: other error — epd_fill yellow
|
||||
void test_fw06_error_fills_yellow() {
|
||||
g_http_get_code = 500;
|
||||
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
||||
TEST_ASSERT_EQUAL(1, g_epd_fill_count);
|
||||
TEST_ASSERT_EQUAL(COLOR_YELLOW, g_epd_fill_last_color);
|
||||
}
|
||||
|
||||
// FW-07: NVS has saved img_id → X-Current-Image-Id header sent
|
||||
void test_fw07_current_image_id_sent_when_saved() {
|
||||
prefs.ints["img_id"] = 99;
|
||||
g_http_response_headers["X-Image-Id"] = "99";
|
||||
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
||||
TEST_ASSERT_EQUAL_STRING("99", g_http_request_headers["X-Current-Image-Id"].c_str());
|
||||
}
|
||||
|
||||
// FW-08: NVS img_id = -1 (default) → X-Current-Image-Id NOT sent
|
||||
void test_fw08_no_current_image_id_when_default() {
|
||||
// prefs has no img_id — getInt returns -1
|
||||
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
||||
TEST_ASSERT_TRUE(g_http_request_headers.find("X-Current-Image-Id") == g_http_request_headers.end());
|
||||
}
|
||||
|
||||
// FW-09: server interval < FETCH_INTERVAL_MS → server value used
|
||||
void test_fw09_server_interval_honored() {
|
||||
g_http_response_headers["X-Interval-Ms"] = "30000";
|
||||
g_http_response_headers["X-Image-Id"] = "1";
|
||||
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
||||
TEST_ASSERT_EQUAL_UINT64(30000ULL * 1000ULL, g_sleep_us);
|
||||
}
|
||||
|
||||
// FW-10: server interval > FETCH_INTERVAL_MS → capped at ceiling
|
||||
void test_fw10_server_interval_capped() {
|
||||
g_http_response_headers["X-Interval-Ms"] = "999999999";
|
||||
g_http_response_headers["X-Image-Id"] = "1";
|
||||
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
||||
TEST_ASSERT_EQUAL_UINT64(FETCH_INTERVAL_MS * 1000ULL, g_sleep_us);
|
||||
}
|
||||
|
||||
// FW-11: no X-Interval-Ms → default ceiling used
|
||||
void test_fw11_default_interval_when_absent() {
|
||||
g_http_response_headers["X-Image-Id"] = "1";
|
||||
// no X-Interval-Ms set
|
||||
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
||||
TEST_ASSERT_EQUAL_UINT64(FETCH_INTERVAL_MS * 1000ULL, g_sleep_us);
|
||||
}
|
||||
|
||||
// FW-14: 304 — epd_sleep NOT called (display already in hardware deep sleep)
|
||||
void test_fw14_304_skips_epd_sleep() {
|
||||
g_http_get_code = 304;
|
||||
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
||||
TEST_ASSERT_EQUAL(0, g_epd_sleep_count);
|
||||
TEST_ASSERT_EQUAL(0, g_epd_init_count);
|
||||
}
|
||||
|
||||
// FW-15: 200 — NVS img_id saved BEFORE epd_draw_image_from_file; draw_needed cleared after
|
||||
void test_fw15_nvs_saved_before_epd_draw_and_flag_cleared() {
|
||||
g_http_response_headers["X-Image-Id"] = "42";
|
||||
g_http_body = "BINDATA";
|
||||
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
||||
TEST_ASSERT_TRUE_MESSAGE(g_prefs_putint_seq < g_epd_draw_seq,
|
||||
"NVS putInt must be called before epd_draw_image_from_file");
|
||||
TEST_ASSERT_EQUAL(42, prefs.getInt("img_id", -1));
|
||||
TEST_ASSERT_EQUAL(1, g_epd_draw_image_count);
|
||||
TEST_ASSERT_EQUAL(0, prefs.getInt("draw", -1));
|
||||
}
|
||||
|
||||
// FW-16: 304 with draw_needed=1 (interrupted draw) — re-draws from LittleFS and clears flag
|
||||
void test_fw16_304_with_draw_needed_redraws() {
|
||||
prefs.ints["img_id"] = 42;
|
||||
prefs.ints["draw"] = 1;
|
||||
g_http_get_code = 304;
|
||||
LittleFS.files[IMAGE_PATH] = "IMGDATA";
|
||||
|
||||
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
||||
|
||||
TEST_ASSERT_EQUAL(1, g_epd_init_count);
|
||||
TEST_ASSERT_EQUAL(1, g_epd_draw_image_count);
|
||||
TEST_ASSERT_EQUAL(1, g_epd_sleep_count);
|
||||
TEST_ASSERT_EQUAL(0, prefs.getInt("draw", -1));
|
||||
}
|
||||
|
||||
// FW-12/13: AP SSID derivation via ap_ssid_from_mac()
|
||||
void test_fw12_ap_ssid_from_mac_aabbcc() {
|
||||
String ssid = ap_ssid_from_mac(String("AA:BB:CC:DD:EE:FF"));
|
||||
TEST_ASSERT_EQUAL_STRING("PictureFrame-EEFF", ssid.c_str());
|
||||
}
|
||||
|
||||
void test_fw13_ap_ssid_from_real_mac() {
|
||||
String ssid = ap_ssid_from_mac(String("1C:C3:AB:D1:91:F8"));
|
||||
TEST_ASSERT_EQUAL_STRING("PictureFrame-91F8", ssid.c_str());
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_fw01_200_response_happy_path);
|
||||
RUN_TEST(test_fw02_headers_read_before_end_regression);
|
||||
RUN_TEST(test_fw03_304_no_redraw);
|
||||
RUN_TEST(test_fw04_204_shows_setup_qr);
|
||||
RUN_TEST(test_fw05_404_shows_setup_qr);
|
||||
RUN_TEST(test_fw06_error_fills_yellow);
|
||||
RUN_TEST(test_fw07_current_image_id_sent_when_saved);
|
||||
RUN_TEST(test_fw08_no_current_image_id_when_default);
|
||||
RUN_TEST(test_fw09_server_interval_honored);
|
||||
RUN_TEST(test_fw10_server_interval_capped);
|
||||
RUN_TEST(test_fw11_default_interval_when_absent);
|
||||
RUN_TEST(test_fw12_ap_ssid_from_mac_aabbcc);
|
||||
RUN_TEST(test_fw13_ap_ssid_from_real_mac);
|
||||
RUN_TEST(test_fw14_304_skips_epd_sleep);
|
||||
RUN_TEST(test_fw15_nvs_saved_before_epd_draw_and_flag_cleared);
|
||||
RUN_TEST(test_fw16_304_with_draw_needed_redraws);
|
||||
return UNITY_END();
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
#include <unity.h>
|
||||
#include <cstdint>
|
||||
#include <cctype>
|
||||
|
||||
// Include mocks first
|
||||
#include "Arduino.h"
|
||||
#include "WiFi.h"
|
||||
#include "Preferences.h"
|
||||
#include "config.h"
|
||||
|
||||
// Define all extern globals required by mock headers
|
||||
uint32_t g_millis_value;
|
||||
int g_digital_read_value;
|
||||
int g_wifi_status;
|
||||
|
||||
// operation.h uses g_show_setup_qr_count under UNIT_TEST
|
||||
int g_show_setup_qr_count;
|
||||
|
||||
// Include the functions under test
|
||||
#include "../../src/operation.h"
|
||||
|
||||
void reset_state() {
|
||||
g_millis_value = 0;
|
||||
g_digital_read_value = HIGH; // button not pressed
|
||||
g_wifi_status = WL_CONNECTED;
|
||||
g_show_setup_qr_count = 0;
|
||||
}
|
||||
|
||||
void setUp() { reset_state(); }
|
||||
void tearDown() {}
|
||||
|
||||
// ── FW-14: attempt_wifi returns true when WiFi connects immediately ───────────
|
||||
void test_fw14_attempt_wifi_returns_true_on_connect() {
|
||||
g_wifi_status = WL_CONNECTED;
|
||||
bool result = attempt_wifi("myssid", "mypass");
|
||||
TEST_ASSERT_TRUE(result);
|
||||
}
|
||||
|
||||
// ── FW-15: attempt_wifi returns false after timeout ───────────────────────────
|
||||
// millis() auto-increments by 10 on each call; after enough iterations the
|
||||
// elapsed time exceeds WIFI_TIMEOUT_MS (30000 ms).
|
||||
void test_fw15_attempt_wifi_returns_false_on_timeout() {
|
||||
g_wifi_status = 0; // never WL_CONNECTED
|
||||
g_millis_value = 0;
|
||||
bool result = attempt_wifi("myssid", "mypass");
|
||||
TEST_ASSERT_FALSE(result);
|
||||
}
|
||||
|
||||
// ── FW-16: loop() state-machine (WiFi-credential submission path) ─────────────
|
||||
// This test is deferred: loop() orchestrates DNS, WebServer, and WiFi
|
||||
// together in a single function, making it impractical to unit-test without
|
||||
// a larger integration harness. The provisioning behavior is covered at the
|
||||
// integration / hardware level.
|
||||
// Placeholder: always passes as a reminder.
|
||||
void test_fw16_loop_state_machine_deferred() {
|
||||
TEST_PASS_MESSAGE("FW-16 deferred: loop() state machine requires integration harness");
|
||||
}
|
||||
|
||||
// ── FW-17: check_reset_button returns true when button held past threshold ────
|
||||
// g_digital_read_value = LOW keeps the while-loop spinning; millis()
|
||||
// auto-increments by 10 per call and will exceed RESET_HOLD_MS (5000 ms).
|
||||
void test_fw17_reset_button_held_returns_true() {
|
||||
g_digital_read_value = LOW;
|
||||
g_millis_value = 0;
|
||||
bool result = check_reset_button();
|
||||
TEST_ASSERT_TRUE(result);
|
||||
}
|
||||
|
||||
// ── FW-18: check_reset_button returns false when button not pressed ───────────
|
||||
void test_fw18_reset_button_not_pressed_returns_false() {
|
||||
g_digital_read_value = HIGH; // button not pressed — loop exits immediately
|
||||
bool result = check_reset_button();
|
||||
TEST_ASSERT_FALSE(result);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_fw14_attempt_wifi_returns_true_on_connect);
|
||||
RUN_TEST(test_fw15_attempt_wifi_returns_false_on_timeout);
|
||||
RUN_TEST(test_fw16_loop_state_machine_deferred);
|
||||
RUN_TEST(test_fw17_reset_button_held_returns_true);
|
||||
RUN_TEST(test_fw18_reset_button_not_pressed_returns_false);
|
||||
return UNITY_END();
|
||||
}
|
||||
Reference in New Issue
Block a user