Skip to content

Commit 8a0e218

Browse files
authored
refactor: fixtures for UI testing (#144)
Refactors to use services that let you swap live vs mock implementations for things like system clock, watch settings, etc. so we can produce static UI for screenshots and UI testing.
1 parent d1c01eb commit 8a0e218

18 files changed

Lines changed: 577 additions & 312 deletions

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ IP=192.168.1.100
88
# Enable extra heap debug logs in Pebble C builds
99
# ENABLE_MEMORY_LOGGING=1
1010

11+
# Load deterministic app-state data from fixtures/<name>.json.
12+
# Fixture files define watch facts, Clay render settings, and weather payloads.
13+
# FIXTURE=readme
14+
1115

1216
##################################
1317
# Supabase environment variables #

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
!.release-please-manifest.json
99
build
1010
src/pkjs/dev-config.js
11+
src/pkjs/active-fixture.generated.js
1112
screenshot/**/*
1213
!screenshot/snowy-red.png
1314
!screenshot/steel-black.png
@@ -18,4 +19,4 @@ screenshot/**/*
1819
node_modules
1920
package.json
2021
!opencode.jsonc
21-
!AGENTS.md
22+
!AGENTS.md

CONTRIBUTING.md

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ If you use Supabase GitHub sync/branching, Supabase can auto-apply migrations an
113113

114114
If you want the extra Pebble heap debug logs, set `ENABLE_MEMORY_LOGGING=1` in your `.env` before building or installing. This is independent of the dev/release package profile.
115115

116+
For deterministic emulator UI, set `FIXTURE=<name>` in `.env` before building or installing. Fixture files live in `fixtures/<name>.json` and define the watch facts and weather payload used by local builds.
117+
116118
Release notification copy (optional “what’s new” toast on upgrade) lives in `release-notifications.json`, keyed by the exact `version` string from the template (e.g. `"1.26.0"`). `prepare-package` copies only the entry for the version being built into `package.json`; versions with no key ship without a notification.
117119

118120
If you want to regenerate `package.json` without building:
@@ -200,7 +202,13 @@ mise screenshot-phone -- screenshot/my-capture.png
200202
```
201203

202204
## Config
203-
You can create `src/pkjs/dev-config.js` to override Clay keys and local dev behavior.
205+
Local dev config has three layers:
206+
207+
- Use `.env` to choose the local mode or scenario, such as `FIXTURE=readme`.
208+
- Use `fixtures/*.json` for committed, deterministic UI state: watch facts, Clay render settings, weather payloads, and other data that should make emulator screenshots reproducible.
209+
- Use `src/pkjs/dev-config.js` for uncommitted behavior testing, including preloaded Clay settings when you are exercising real app flows instead of deterministic fixture UI.
210+
211+
When a fixture is active, prefer `claySettings` in the fixture for render-affecting Clay values. `dev-config.js` remains useful for local-only behavior switches and non-fixture testing.
204212

205213
Example:
206214

@@ -244,29 +252,28 @@ Notes:
244252
- Remove the key (or comment it out) when testing normal upgrade behavior.
245253
- This is local-only dev behavior and is not written into Clay settings.
246254

247-
### Mock weather (emulator/dev)
248-
249-
Use these keys in `src/pkjs/dev-config.js`:
255+
### Fixtures (emulator/dev)
250256

251-
- `provider = 'mock'` enables the mock provider.
252-
- `mockCity` sets the city label independently.
253-
- `mockScenario` selects the active built-in scenario.
257+
Set `FIXTURE=<name>` in `.env` to load deterministic app-state data from `fixtures/<name>.json`.
258+
Fixtures currently support:
254259

255-
Scenario data is tracked in git inside `src/pkjs/weather/mock.js` (`MOCK_SCENARIOS`).
260+
- `watch.now`: local date/time fields used for C-rendered time/date UI.
261+
- `watchSettings.timeFormat`: watch-level time display preference, `"12h"` or `"24h"`.
262+
- `claySettings`: Clay-compatible settings keyed by `messageKey`, such as `"axisTimeFormat": "12h"`. Color settings use Pebble SDK color constants like `"GColorFolly"` from the Rebble color definitions: https://developer.rebble.io/docs/c/Graphics/Graphics_Types/Color_Definitions/
263+
- `weather.city`: weather status city label.
264+
- `weather.currentTemp`: current temperature in Fahrenheit.
265+
- `weather.startHour`: local hour for the first forecast entry; fixture prep converts it to the runtime forecast timestamp.
266+
- `weather.temps`: hourly Fahrenheit forecast values.
267+
- `weather.precipPct`: hourly precipitation percentages, 0-100.
268+
- `weather.sunEvents`: the next two sun events as local fields, e.g. `{ "type": "sunset", "dayOffset": 0, "hour": 20, "minute": 10 }`.
256269

257-
Minimal shape:
270+
Minimal `.env`:
258271

259-
```javascript
260-
module.exports.provider = 'mock';
261-
module.exports.mockCity = 'New York, NY';
262-
module.exports.mockScenario = 'clearMorning';
272+
```bash
273+
FIXTURE=readme
263274
```
264275

265-
Notes:
266-
267-
- If `mockScenario` is missing/invalid, the app falls back to the first built-in scenario.
268-
- To add/edit scenarios, update `MOCK_SCENARIOS` in `src/pkjs/weather/mock.js`.
269-
- `startEpoch` and `sunEvents[].epoch` should be coherent in emulator local time (the graph and shading use watch localtime).
276+
Fixture data is tracked in git inside `fixtures/`.
270277

271278
### Emulator time overrides (applied automatically)
272279

@@ -286,9 +293,7 @@ Reset behavior when keys are removed:
286293
- `emuTimeFormat` defaults to `24h`
287294
- `emuTime` fallback order is:
288295
1. explicit `emuTime` in `dev-config.js`
289-
2. `emuTime` of active mock scenario (when `provider = 'mock'`, if present)
290-
3. `startEpoch` of active mock scenario (when `provider = 'mock'`)
291-
4. current host time
296+
2. current host time
292297

293298
## Upgrading pebble-tool
294299

fixtures/readme.json

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{
2+
"watch": {
3+
"now": {
4+
"year": 2026,
5+
"month": 5,
6+
"day": 14,
7+
"hour": 12,
8+
"minute": 34,
9+
"second": 0
10+
}
11+
},
12+
"watchSettings": {
13+
"timeFormat": "12h"
14+
},
15+
"claySettings": {
16+
"axisTimeFormat": "12h",
17+
"colorSaturday": "GColorFolly",
18+
"colorSunday": "GColorFolly",
19+
"colorUSFederal": "GColorFolly"
20+
},
21+
"weather": {
22+
"city": "Chicago",
23+
"currentTemp": 52,
24+
"startHour": 12,
25+
"temps": [52, 53, 54, 56, 57, 57, 56, 54, 52, 50, 48, 47, 46, 45, 45, 46, 47, 48, 49, 50, 50, 51, 51, 52],
26+
"precipPct": [6, 6, 6, 6, 7, 8, 12, 18, 28, 40, 52, 62, 66, 60, 48, 34, 22, 14, 9, 7, 6, 5, 5, 5],
27+
"sunEvents": [
28+
{
29+
"type": "sunset",
30+
"dayOffset": 0,
31+
"hour": 20,
32+
"minute": 10
33+
},
34+
{
35+
"type": "sunrise",
36+
"dayOffset": 1,
37+
"hour": 5,
38+
"minute": 28
39+
}
40+
]
41+
}
42+
}

scripts/build.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ fi
1515

1616
scripts/ensure-pebble-sdk.sh
1717
mise run prepare-package -- "$profile"
18+
node scripts/prepare-fixture.js
1819
pebble build "$@"
1920

2021
if [[ "$profile" == "dev" ]]; then

scripts/install-emulator.sh

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,26 +11,6 @@ read_dev_config_value() {
1111
node -e 'try { var cfg = require("./src/pkjs/dev-config.js"); var key = process.argv[1]; if (!Object.prototype.hasOwnProperty.call(cfg, key) || cfg[key] === null || typeof cfg[key] === "undefined") { process.exit(2); } process.stdout.write(String(cfg[key])); } catch (e) { process.exit(2); }' "$key"
1212
}
1313

14-
read_mock_scenario_time() {
15-
node -e 'try {
16-
var cfg = require("./src/pkjs/dev-config.js");
17-
if (cfg.provider !== "mock") { process.exit(2); }
18-
var MockProvider = require("./src/pkjs/weather/mock.js");
19-
var scenarios = MockProvider.SCENARIOS || {};
20-
var names = Object.keys(scenarios);
21-
if (!names.length) { process.exit(2); }
22-
var selected = cfg.mockScenario;
23-
var scenarioName = Object.prototype.hasOwnProperty.call(scenarios, selected) ? selected : names[0];
24-
var scenario = scenarios[scenarioName] || {};
25-
if (typeof scenario.emuTime === "string" || typeof scenario.emuTime === "number") {
26-
process.stdout.write(String(scenario.emuTime));
27-
process.exit(0);
28-
}
29-
if (typeof scenario.startEpoch !== "number") { process.exit(2); }
30-
process.stdout.write(String(scenario.startEpoch));
31-
} catch (e) { process.exit(2); }'
32-
}
33-
3414
apply_emulator_overrides() {
3515
local emu_time_format
3616
local emu_time
@@ -50,8 +30,6 @@ apply_emulator_overrides() {
5030

5131
if emu_time="$(read_dev_config_value emuTime)"; then
5232
:
53-
elif emu_time="$(read_mock_scenario_time)"; then
54-
:
5533
else
5634
emu_time="$(date +%s)"
5735
fi

scripts/prepare-fixture.js

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
#!/usr/bin/env node
2+
3+
const fs = require('fs');
4+
const path = require('path');
5+
6+
const fixtureName = (process.env.FIXTURE || '').trim();
7+
const outputPath = path.join('src', 'pkjs', 'active-fixture.generated.js');
8+
9+
function fail(message) {
10+
console.error(message);
11+
process.exit(1);
12+
}
13+
14+
function writeFixtureModule(value) {
15+
fs.writeFileSync(
16+
outputPath,
17+
'module.exports = ' + JSON.stringify(value, null, 2) + ';\n'
18+
);
19+
}
20+
21+
/**
22+
* Fail unless a fixture time field is an integer in range.
23+
*
24+
* @param {string} pathLabel Human-readable fixture path.
25+
* @param {number} value Time field value.
26+
* @param {number} min Minimum allowed value.
27+
* @param {number} max Maximum allowed value.
28+
*/
29+
function assertIntInRange(pathLabel, value, min, max) {
30+
if (!Number.isInteger(value) || value < min || value > max) {
31+
fail(pathLabel + ' must be an integer from ' + min + '-' + max);
32+
}
33+
}
34+
35+
/**
36+
* Convert local fixture fields anchored to watch.now into Unix seconds.
37+
*
38+
* @param {Object} watchNow Fixture watch.now value.
39+
* @param {Object} overrides Local date/time overrides.
40+
* @returns {number} Unix seconds.
41+
*/
42+
function dateFromWatchNow(watchNow, overrides) {
43+
const date = new Date(
44+
watchNow.year,
45+
watchNow.month - 1,
46+
watchNow.day + (overrides.dayOffset || 0),
47+
overrides.hour || 0,
48+
overrides.minute || 0,
49+
overrides.second || 0,
50+
0
51+
);
52+
53+
return Math.floor(date.getTime() / 1000);
54+
}
55+
56+
/**
57+
* Normalize readable fixture weather fields into the runtime shape.
58+
*
59+
* @param {Object} fixture Parsed fixture.
60+
*/
61+
function normalizeWeather(fixture) {
62+
const watchNow = fixture && fixture.watch && fixture.watch.now;
63+
const weather = fixture && fixture.weather;
64+
65+
if (!watchNow || !weather) {
66+
return;
67+
}
68+
69+
if (typeof weather.startHour === 'number') {
70+
assertIntInRange('weather.startHour', weather.startHour, 0, 23);
71+
weather.startEpoch = dateFromWatchNow(watchNow, {
72+
dayOffset: weather.startDayOffset || 0,
73+
hour: weather.startHour,
74+
minute: 0,
75+
second: 0,
76+
});
77+
delete weather.startHour;
78+
delete weather.startDayOffset;
79+
}
80+
81+
if (Array.isArray(weather.sunEvents)) {
82+
weather.sunEvents = weather.sunEvents.map((event) => {
83+
if (typeof event.epoch === 'number') {
84+
return event;
85+
}
86+
87+
assertIntInRange('weather.sunEvents.hour', event.hour, 0, 23);
88+
assertIntInRange('weather.sunEvents.minute', event.minute || 0, 0, 59);
89+
return {
90+
type: event.type,
91+
epoch: dateFromWatchNow(watchNow, {
92+
dayOffset: event.dayOffset || 0,
93+
hour: event.hour,
94+
minute: event.minute || 0,
95+
second: event.second || 0,
96+
}),
97+
};
98+
});
99+
}
100+
}
101+
102+
if (fixtureName === '') {
103+
writeFixtureModule(null);
104+
process.exit(0);
105+
}
106+
107+
if (!/^[a-z0-9][a-z0-9-]*$/.test(fixtureName)) {
108+
fail('FIXTURE must be a fixture slug like "readme" or "rainy-night"');
109+
}
110+
111+
const fixturePath = path.join('fixtures', fixtureName + '.json');
112+
if (!fs.existsSync(fixturePath)) {
113+
fail('Fixture not found: ' + fixturePath);
114+
}
115+
116+
const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
117+
fixture.name = fixtureName;
118+
normalizeWeather(fixture);
119+
writeFixtureModule(fixture);

src/c/appendix/config.c

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
#include "persist.h"
33
#include "math.h"
44
#include "memory_log.h"
5+
#include "c/services/watch_services.h"
56

67
Config *g_config;
78

@@ -64,7 +65,7 @@ int config_localize_temp(int temp_f) {
6465
}
6566

6667
int config_format_time(char *s, size_t maxsize, const struct tm * tm_p) {
67-
int res = strftime(s, maxsize, clock_is_24h_style() ? "%H:%M" : "%I:%M", tm_p);
68+
int res = strftime(s, maxsize, watch_services_clock_is_24h_style() ? "%H:%M" : "%I:%M", tm_p);
6869
if (!g_config->time_lead_zero) {
6970
// Remove leading zero if configured as such
7071
if (s[0] == '0')
@@ -86,9 +87,8 @@ int config_axis_hour(int hour) {
8687
int config_n_today() {
8788
// Returns the index of the calendar box that holds today's date
8889

89-
time_t today = time(NULL);
90-
struct tm *tm_today = localtime(&today);
91-
int wday = tm_today->tm_wday;
90+
struct tm tm_today = watch_services_localtime();
91+
int wday = tm_today.tm_wday;
9292
// Offset if user wants to start the week on monday
9393
wday = g_config->start_mon ? (wday + 6) % 7 : wday;
9494
// Offset if user wants to show the previous week first

src/c/layers/calendar_layer.c

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#include "calendar_layer.h"
22
#include "c/appendix/config.h"
33
#include "c/appendix/memory_log.h"
4+
#include "c/services/watch_services.h"
45
#include <time.h>
56

67
#define NUM_WEEKS 3
@@ -65,11 +66,10 @@ static struct tm relative_tm(int days_from_today)
6566
/* Get a time structure for n days from today (only accurate to the day)
6667
Use this function to avoid edge cases from daylight savings time
6768
*/
68-
time_t timestamp = time(NULL);
69-
struct tm *local_time = localtime(&timestamp);
69+
struct tm base_time = watch_services_localtime();
7070
// Set arbitrary hour so there's no daylight savings rounding error:
71-
local_time->tm_hour = 5;
72-
timestamp = mktime(local_time) + days_from_today * SECONDS_PER_DAY;
71+
base_time.tm_hour = 5;
72+
time_t timestamp = mktime(&base_time) + days_from_today * SECONDS_PER_DAY;
7373
struct tm *result = localtime(&timestamp);
7474
struct tm out = *result;
7575
return out;

src/c/layers/calendar_status_layer.c

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
#include "battery_layer.h"
33
#include "c/appendix/config.h"
44
#include "c/appendix/memory_log.h"
5+
#include "c/services/watch_services.h"
56

67
#define BATTERY_W 29
78
#define BATTERY_H 10
@@ -190,9 +191,8 @@ void status_icons_refresh() {
190191
}
191192

192193
void calendar_status_layer_refresh() {
193-
time_t now = time(NULL);
194-
struct tm *tm_now = localtime(&now);
195-
strftime(s_calendar_month_text, sizeof(s_calendar_month_text), "%b %Y", tm_now);
194+
struct tm tm_now = watch_services_localtime();
195+
strftime(s_calendar_month_text, sizeof(s_calendar_month_text), "%b %Y", &tm_now);
196196
status_icons_refresh();
197197
}
198198

0 commit comments

Comments
 (0)