Compare commits

...

No commits in common. "main" and "master" have entirely different histories.
main ... master

16 changed files with 1837 additions and 5 deletions

View File

@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"WebFetch(domain:developer.repebble.com)"
]
}
}

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
build/
.lock-waf_linux_build

20
LICENSE
View File

@ -1,9 +1,21 @@
MIT License MIT License
Copyright (c) 2026 meberlein Copyright (c) 2026 Mike Eberlein
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,2 +1,36 @@
# PebbleGolfScore # golf_score
A Pebble watchapp/watchface written in C using the Pebble SDK.
## Building & running
```sh
pebble build # build for all targetPlatforms
pebble install --emulator emery # install on the emery emulator
pebble install --phone <ip> # install to a paired phone
```
## Target platforms
`targetPlatforms` in `package.json` controls which watches you build for. The
modern Pebble hardware is **emery** (Pebble Time 2), **gabbro** (Pebble Round
2), and **flint** (Pebble 2 Duo); the original Pebble platforms (aplite,
basalt, chalk, diorite) are included by default for backwards compatibility.
## Project layout
```
src/c/ C source for the watchapp
src/pkjs/ PebbleKit JS (phone-side) source, if any
worker_src/c/ Background worker source, if any
resources/ Images, fonts, and other bundled resources
package.json Project metadata (UUID, platforms, resources, message keys)
wscript Build rules — usually no need to edit
```
By default this project is configured as a watchapp. To make it a watchface,
set `pebble.watchapp.watchface` to `true` in `package.json`.
## Documentation
Full SDK docs, tutorials, and API reference: <https://developer.repebble.com>

311
SPEC.md Normal file
View File

@ -0,0 +1,311 @@
# Golf Score — Application Specification
**Platform:** Pebble Time 2 (Emery, 200×228 px, color)
**Source:** `src/c/golf_score.c`
**Build output:** `build/golf_score.pbw`
---
## 1. Purpose
A single-file Pebble watchapp for tracking golf strokes and putts across an 18-hole round. The design prioritises one-handed glanceability and minimal button presses during play, with correction and navigation features accessible via long presses.
---
## 2. Data Model
### Core types
```c
typedef struct {
int8_t strokes; // total shots on this hole, range [0, 99]
int8_t putts; // putts on this hole, range [0, strokes]
} HoleScore;
static HoleScore s_scores[MAX_HOLES]; // MAX_HOLES = 18
static int s_current_hole = 0; // index into s_scores, range [0, 17]
```
`int8_t` is used for both fields because:
- The practical maximum (99) fits in a signed byte.
- `sizeof(HoleScore)` = 2 bytes, so `sizeof(s_scores)` = 36 bytes — small enough that the entire array is written atomically as a single `persist_write_data` blob (no partial-update complexity).
### Invariant
`putts <= strokes` at all times. Every mutation point enforces this:
| Operation | Enforcement |
|-----------|-------------|
| DOWN (1 stroke) | If `putts > strokes` after decrement, `putts = strokes` |
| UP long (+1 putt) | If `putts` would exceed `strokes`, `strokes` is raised to match |
| DOWN long (1 putt) | Putts floored at 0; strokes unchanged |
The invariant was chosen to reflect real golf (putts are a subset of strokes), while keeping the enforcement logic local to each handler rather than centralised — each handler knows exactly which field it owns.
### Why putts are tracked separately from strokes
Many apps conflate them. Keeping a separate putt count per hole lets the player review putting performance on the scorecard (total putts is a standard handicap metric) without requiring a separate mode. The trade-off is a slightly more complex button map (long-press for putts), which is acceptable because putts happen far less frequently than stroke taps during a hole.
---
## 3. Persistence
```c
#define PERSIST_KEY_SCORES 0
#define PERSIST_KEY_HOLE 1
```
| Key | API | Written |
|-----|-----|---------|
| 0 | `persist_write_data` / `persist_read_data` | Entire `s_scores[18]` blob (36 bytes) |
| 1 | `persist_write_int` / `persist_read_int` | `s_current_hole` |
**Write points:** after every hole advance (SELECT), after jump-to-hole, after reset, and in `main()` after `app_event_loop()` returns (app exit). Stroke/putt increments during a hole are **not** written immediately to avoid flash wear from rapid taps.
**Read point:** once, at the top of `main()` before the window is pushed, so the first render already has the restored state.
**Design decision — no per-keypress save:** Pebble's persistent storage is flash-backed and has a finite write endurance. Saving on every UP/DOWN tap would burn through write cycles quickly during an 18-hole round. Losing a single stroke on a crash is an acceptable trade-off; losing an entire hole's worth of scores after a hole advance (which does save) is not.
**Design decision — blob vs. individual keys:** Writing the whole array as one blob is simpler than 18×2 individual keys and means the scores are always consistent (either the whole round is there or none of it is). It also fits comfortably within the 256-byte per-value maximum (`sizeof(s_scores)` = 36 bytes).
---
## 4. Window Architecture
### Window stack hierarchy
```
[main_window] ← always at the bottom; never popped by the app
└─ [settings_window] ← pushed by long SELECT on main
├─ [scorecard_window] ← pushed by "View Scorecard"
├─ [hole_picker_window] ← pushed by "Jump to Hole"
└─ [help_window] ← pushed by "Controls"
```
`main_window` is the only window created at startup and never destroyed during normal operation. All other windows are created on demand and destroy themselves in their `unload` handler (see §4.2).
### 4.1 Window lifecycle pattern
Every secondary window follows the same pattern:
```c
// Push (caller):
s_foo_window = window_create();
window_set_window_handlers(s_foo_window, (WindowHandlers){
.load = foo_window_load,
.unload = foo_window_unload,
});
window_stack_push(s_foo_window, true);
// Unload (self-cleanup):
static void foo_window_unload(Window *window) {
// destroy child layers first
window_destroy(s_foo_window);
s_foo_window = NULL;
}
```
Setting the pointer to `NULL` after `window_destroy` prevents dangling pointer use and makes it safe to check `if (s_foo_window)` elsewhere.
**Why self-destroy in unload?** The Pebble OS calls `unload` before the window is fully removed from the stack, giving the app a guaranteed cleanup point regardless of whether the window was popped by BACK, by `window_stack_pop`, or by `window_stack_remove`. Centralising cleanup in `unload` means no caller needs to remember to free anything.
### 4.2 Return-to-main after settings actions
After "Jump to Hole" or "Reset Round" complete, the app needs to pop multiple windows (hole picker + settings, or just settings) and return to main. This cannot be done synchronously inside a `SimpleMenuLayer` select callback because the callback fires while the layer's own event processing is still on the call stack — destroying the window mid-callback would corrupt the stack frame.
**Solution: 50 ms deferred timer**
```c
static void return_to_main_cb(void *data) {
s_return_timer = NULL;
Window *top;
while ((top = window_stack_get_top_window()) != NULL && top != s_main_window) {
window_stack_pop(false);
}
layer_mark_dirty(s_canvas_layer);
}
static void schedule_return_to_main(void) {
if (s_return_timer) app_timer_cancel(s_return_timer);
s_return_timer = app_timer_register(50, return_to_main_cb, NULL);
}
```
The 50 ms delay ensures the select callback has returned and the event loop has fully processed before any window is destroyed. The while loop pops whatever is above main without needing to know the exact stack depth, which makes it robust to future navigation changes.
Cancelling any existing timer before registering a new one prevents double-pops if an action is somehow triggered twice.
---
## 5. Main Window — Visual Layout
Screen: 200 × 228 px (no system status bar; app owns full screen).
```
y 043 GColorJaegerGreen fill (header)
"HOLE N / 18" GOTHIC_18_BOLD white centered (y=12, h=22)
y 4449 gap
y 5071 "STROKES" GOTHIC_18_BOLD dark-gray centered
y 70127 stroke count BITHAM_42_BOLD black centered
y 133 divider line GColorLightGray x=[20, w-20]
y 139160 "PUTTS" GOTHIC_18_BOLD dark-gray centered
y 158215 putt count BITHAM_42_BOLD GColorCobaltBlue centered
y 216227 hint bar GColorLightGray fill
hint text GOTHIC_14 dark-gray trailing-ellipsis centered
```
**Design decisions:**
- **Custom Layer instead of TextLayers:** The main screen is drawn by a single `LayerUpdateProc` (`canvas_update_proc`) rather than a collection of TextLayers. This avoids the overhead of multiple layer objects, keeps all layout constants in one place, and gives full control over drawing order (background → header fill → text, all in one pass).
- **BITHAM_42_BOLD for counts:** The largest readable number font available as a system font. At 42 pt the count is legible at a glance without raising the watch, which is the primary use case (hand on club, quick look).
- **Strokes black, putts cobalt blue:** Colour-coding the two counters avoids misreading at a glance. Blue was chosen for putts as it is visually subordinate to black, reflecting that putts are a subset metric.
- **Hint bar at bottom:** The 12 px hint bar is small enough to not compete with the counts but visible enough to remind new users about the long-press bindings. `GTextOverflowModeTrailingEllipsis` ensures it never wraps.
- **No status bar:** `StatusBarLayer` would consume 20 px and show a clock the player already has on the watch face. Reclaiming that space for larger number fonts was the right trade-off for a glance-use app.
---
## 6. Button Map
### Main window
| Button | Type | Action | Vibration |
|--------|------|--------|-----------|
| UP | Short | `strokes++` (max 99) | Short pulse |
| DOWN | Short | `strokes--` (floor 0); auto-clamp `putts` | None |
| SELECT | Short | Advance to next hole; save | Short pulse (double on hole 18) |
| UP | Long 700 ms | `putts++`; auto-raise `strokes` if needed | None |
| DOWN | Long 700 ms | `putts--` (floor 0) | None |
| SELECT | Long 700 ms | Push settings window | None |
**Why no vibration on DOWN?** A correction is often a recovery from a mis-tap. Adding a vibe would feel punishing and draw attention on the course. The asymmetry (vibe on increment, silent on decrement) also gives tactile confirmation that a stroke was successfully counted without looking.
**Why 700 ms long-press threshold?** The Pebble default is 500 ms. 700 ms was chosen to reduce accidental long-press triggers during normal play — a player tapping UP repeatedly at pace will not accidentally trigger the putt increment.
**Why long SELECT for settings rather than BACK?** The BACK button's system behaviour (pop window / exit app) cannot be reliably overridden for long press. Using SELECT long-press is the standard Pebble pattern for secondary actions.
### Settings window
Handled by `SimpleMenuLayer`, which wires UP/DOWN to scroll and SELECT to activate. BACK pops the window via system default.
### Scorecard and Help windows
`ScrollLayer` wires UP/DOWN to scroll. BACK pops via system default.
### Hole picker window
`SimpleMenuLayer` as above. Selecting a row calls `hole_picker_select_cb`, which sets `s_current_hole`, saves, and schedules the return-to-main timer.
---
## 7. Settings Window
Implemented as a `SimpleMenuLayer` with one section and four items:
| Index | Title | Subtitle | Effect |
|-------|-------|----------|--------|
| 0 | View Scorecard | — | Push scorecard window |
| 1 | Jump to Hole | Go back to any hole | Push hole picker window |
| 2 | Reset Round | Clears all scores | Zero all scores, reset to hole 1, save, double vibe, return to main |
| 3 | Controls | Button cheatsheet | Push help window |
`SimpleMenuSection` and `SimpleMenuItem` arrays are `static` module-level variables. `SimpleMenuLayer` does not deep-copy the arrays — if they were stack-allocated in `settings_window_load` they would be freed before the layer uses them. Static allocation is the correct pattern here.
Items are initialised in `settings_window_load` rather than at file scope because `SimpleMenuItem.callback` is a function pointer that cannot be a compile-time constant in C89/C90 (though it can in C99). Initialising in load keeps the code compatible across SDK versions.
---
## 8. Hole Picker Window
`SimpleMenuLayer` with one section of 18 rows, pre-scrolled to `s_current_hole`.
Row content is built in `hole_picker_window_load`:
```
Title: "> Hole N" (current hole)
" Hole N" (all others)
Subtitle: "X str / Y ptt" (if played: strokes > 0 OR putts > 0)
"not played" (otherwise)
```
**Why `> ` prefix instead of a colour or bold?** `SimpleMenuLayer` does not expose per-row styling. A text prefix is the only reliable way to mark the current hole within the system widget. Two leading spaces on non-current rows maintain visual alignment.
**Why rebuild titles/subtitles in `window_load` rather than keeping them live?** The data only needs to be accurate when the window opens. Keeping 18×(16+24) = 720 bytes of string buffers permanently resident but only occasionally accurate is wasteful. Building on load is cheap (one pass over 18 `HoleScore` structs) and guarantees the picker reflects current scores even if the user edits a hole and reopens settings.
Title/subtitle buffers are `static` for the same reason as settings items — `SimpleMenuLayer` holds pointers into them.
---
## 9. Scorecard Window
A `ScrollLayer` containing a single `TextLayer`. Content is built by `build_scorecard()` into `s_scorecard_buf[700]`.
**Buffer sizing:** 18 rows × ~18 chars + header (30 chars) + footer (20 chars) + current-hole marker (2 chars × 1 row) ≈ 370 chars. 700 bytes gives comfortable headroom.
The current hole is marked with a trailing ` <` on its row so the player can orient themselves without counting.
The content height for the `ScrollLayer` is measured with `graphics_text_layout_get_content_size` at load time using the same font and bounding width as the `TextLayer`. This keeps the scroll range exactly tight to the rendered text — no arbitrary magic number for content height.
The scorecard is **read-only**. Reset was moved to the Settings menu to enforce a clear separation: viewing data is non-destructive and accessible, mutating data requires an explicit settings action.
---
## 10. Help Window
A `ScrollLayer` + `TextLayer` displaying `HELP_TEXT`, a `static const char *const` string literal compiled into flash. No heap allocation is needed for the string itself — `text_layer_set_text` holds a pointer to the literal directly.
The pattern is identical to the scorecard window but simpler: no `build_*` function, no dynamic buffer, no current-state dependency.
---
## 11. Static Variable Strategy
All window pointers, layer pointers, and string buffers are file-scope `static`. This is the idiomatic Pebble C pattern for several reasons:
1. **Pebble heap is small.** Stack frames are limited; large char arrays on the stack risk overflow.
2. **Layer pointers must outlive the function that creates them.** `window_load` builds the UI, but the layers live until `window_unload`. File-scope statics have the right lifetime automatically.
3. **`SimpleMenuLayer` does not copy its data.** Section and item arrays must remain valid for the lifetime of the layer — static storage guarantees this.
The downside is that only one instance of each secondary window can exist at a time, which is enforced by the navigation structure (settings is modal; its children are modal within it).
---
## 12. Source File Organisation
Functions are ordered bottom-up by dependency so that no forward declarations are needed:
```
1. Persistence (save_scores, load_scores)
2. Main canvas draw (canvas_update_proc)
3. Main window button handlers
4. Scorecard window (build_scorecard, load, unload)
5. Return-to-main timer (return_to_main_cb, schedule_return_to_main)
6. Hole picker window (select_cb, load, unload)
7. Settings callbacks (scorecard_cb, jump_cb, help_cb, reset_cb)
— defined after the windows they push
8. Settings window (load, unload, show_settings)
9. Main window click config and lifecycle
10. main()
```
This ordering means that every called function is defined before its caller, eliminating the need for a header file or forward declarations.
---
## 13. Memory Footprint (Emery)
| Region | Size |
|--------|------|
| `s_scores` | 36 bytes |
| String buffers (hole_buf, stroke_buf, putt_buf) | 40 bytes |
| `s_scorecard_buf` | 700 bytes |
| `s_hole_titles` | 288 bytes (18 × 16) |
| `s_hole_subtitles` | 432 bytes (18 × 24) |
| `HELP_TEXT` | ~380 bytes (flash, not RAM) |
| Total static RAM (approximate) | ~1.5 KB |
| SDK-reported free heap | 127 KB |
The app is extremely light on memory. The largest single allocation is `s_scorecard_buf` at 700 bytes. All secondary window layers are allocated in their `load` handlers and freed in `unload`, so only one secondary window's layer objects are in heap at any time.

42
package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "golf_score",
"author": "Mike Eberlein",
"version": "1.0.0",
"description": "Track golf strokes and putts across an 18-hole round.",
"keywords": ["pebble-app", "golf", "sports"],
"dependencies": {},
"pebble": {
"displayName": "Golf Score",
"uuid": "73037cc5-ba10-41ed-b93f-f55a102774a9",
"sdkVersion": "3",
"enableMultiJS": true,
"targetPlatforms": [
"basalt",
"chalk",
"diorite",
"emery",
"flint",
"gabbro"
],
"watchapp": {
"watchface": false
},
"resources": {
"media": [
{
"type": "png",
"menuIcon": true,
"name": "APP_ICON_SMALL",
"file": "images/app_icon_small.png",
"targetPlatforms": ["basalt", "chalk", "diorite", "emery", "flint", "gabbro"]
},
{
"type": "png",
"name": "APP_ICON_LARGE",
"file": "images/app_icon_large.png",
"targetPlatforms": ["basalt", "chalk", "diorite", "emery", "flint", "gabbro"]
}
]
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 B

BIN
score_screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

585
src/c/golf_score.c Normal file
View File

@ -0,0 +1,585 @@
#include <pebble.h>
#define MAX_HOLES 18
#define PERSIST_KEY_SCORES 0
#define PERSIST_KEY_HOLE 1
typedef struct {
int8_t strokes;
int8_t putts;
} HoleScore;
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
static HoleScore s_scores[MAX_HOLES];
static int s_current_hole = 0;
// ---------------------------------------------------------------------------
// Main window
// ---------------------------------------------------------------------------
static Window *s_main_window;
static Layer *s_canvas_layer;
static char s_hole_buf[24];
static char s_stroke_buf[8];
static char s_putt_buf[8];
// ---------------------------------------------------------------------------
// Scorecard window
// ---------------------------------------------------------------------------
static Window *s_scorecard_window;
static ScrollLayer *s_scroll_layer;
static TextLayer *s_scorecard_layer;
static char s_scorecard_buf[700];
// ---------------------------------------------------------------------------
// Settings window
// ---------------------------------------------------------------------------
static Window *s_settings_window;
static SimpleMenuLayer *s_settings_menu_layer;
static SimpleMenuSection s_settings_section;
static SimpleMenuItem s_settings_items[4];
// ---------------------------------------------------------------------------
// Hole picker window
// ---------------------------------------------------------------------------
static Window *s_hole_picker_window;
static SimpleMenuLayer *s_hole_picker_layer;
static SimpleMenuSection s_hole_section;
static SimpleMenuItem s_hole_items[MAX_HOLES];
static char s_hole_titles[MAX_HOLES][16];
static char s_hole_subtitles[MAX_HOLES][24];
// ---------------------------------------------------------------------------
// Help / cheatsheet window
// ---------------------------------------------------------------------------
static Window *s_help_window;
static ScrollLayer *s_help_scroll_layer;
static TextLayer *s_help_text_layer;
static const char *const HELP_TEXT =
"BUTTON GUIDE\n"
"\n"
"UP +1 stroke\n"
"DOWN -1 stroke\n"
"CENTER Next hole\n"
"\n"
"Hold UP +1 putt\n"
"Hold DN -1 putt\n"
"Hold CTR Settings\n"
"\n"
"SETTINGS MENU\n"
"\n"
"View Scorecard\n"
" All 18 holes\n"
"\n"
"Jump to Hole\n"
" Return to any\n"
" previous hole\n"
"\n"
"Reset Round\n"
" Clear all scores\n"
"\n"
"Controls\n"
" This screen";
static void help_window_load(Window *window) {
Layer *root = window_get_root_layer(window);
GRect bounds = layer_get_bounds(root);
GFont font = fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD);
GSize text_size = graphics_text_layout_get_content_size(
HELP_TEXT, font,
GRect(0, 0, bounds.size.w - 8, 3000),
GTextOverflowModeWordWrap, GTextAlignmentLeft);
int content_h = text_size.h + 20;
s_help_scroll_layer = scroll_layer_create(bounds);
scroll_layer_set_content_size(s_help_scroll_layer, GSize(bounds.size.w, content_h));
scroll_layer_set_click_config_onto_window(s_help_scroll_layer, window);
layer_add_child(root, scroll_layer_get_layer(s_help_scroll_layer));
s_help_text_layer = text_layer_create(GRect(4, 4, bounds.size.w - 8, content_h));
text_layer_set_text(s_help_text_layer, HELP_TEXT);
text_layer_set_font(s_help_text_layer, font);
text_layer_set_overflow_mode(s_help_text_layer, GTextOverflowModeWordWrap);
text_layer_set_background_color(s_help_text_layer, GColorClear);
scroll_layer_add_child(s_help_scroll_layer, text_layer_get_layer(s_help_text_layer));
}
static void help_window_unload(Window *window) {
text_layer_destroy(s_help_text_layer);
scroll_layer_destroy(s_help_scroll_layer);
window_destroy(s_help_window);
s_help_window = NULL;
}
// Timer used to pop back to main after a settings action completes
static AppTimer *s_return_timer = NULL;
// ---------------------------------------------------------------------------
// Persistence
// ---------------------------------------------------------------------------
static void save_scores(void) {
persist_write_data(PERSIST_KEY_SCORES, s_scores, sizeof(s_scores));
persist_write_int(PERSIST_KEY_HOLE, s_current_hole);
}
static void load_scores(void) {
if (persist_exists(PERSIST_KEY_SCORES)) {
persist_read_data(PERSIST_KEY_SCORES, s_scores, sizeof(s_scores));
}
if (persist_exists(PERSIST_KEY_HOLE)) {
int h = persist_read_int(PERSIST_KEY_HOLE);
s_current_hole = (h >= 0 && h < MAX_HOLES) ? h : 0;
}
}
// ---------------------------------------------------------------------------
// Main canvas draw — layout scales to all supported screen sizes:
// Emery 200×228 rect color (large=true)
// Basalt 144×168 rect color
// Flint 144×168 rect color
// Gabbro 144×168 rect color
// Diorite 144×168 rect B&W (COLOR_FALLBACK used throughout)
// Chalk 180×180 round color (round=true, side inset, no hint bar)
// ---------------------------------------------------------------------------
static void canvas_update_proc(Layer *layer, GContext *ctx) {
GRect bounds = layer_get_bounds(layer);
int w = bounds.size.w;
int h = bounds.size.h;
HoleScore *hole = &s_scores[s_current_hole];
// Platform characteristics
bool large = (h >= 220); // Emery only
bool round = PBL_IF_ROUND_ELSE(true, false); // Chalk only
// Side inset keeps text clear of Chalk's circular bezel
int side = round ? 16 : 0;
// Count font: 42pt on Emery, 34pt medium numbers everywhere else
GFont count_font = fonts_get_system_font(
large ? FONT_KEY_BITHAM_42_BOLD : FONT_KEY_BITHAM_34_MEDIUM_NUMBERS);
GFont label_font = fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD);
GFont hint_font = fonts_get_system_font(FONT_KEY_GOTHIC_14);
// Layout — exact values for Emery; proportional for everything else
int header_h, label_h, count_h, hint_h;
int label1_y, count1_y, divider_y, label2_y, count2_y, hint_y;
if (large) {
header_h = 44; label_h = 22; count_h = 58; hint_h = 12;
label1_y = 50;
count1_y = 70;
divider_y = 133;
label2_y = 139;
count2_y = 158;
hint_y = 216;
} else {
// Scale proportionally to screen height.
// Add top padding on round screens to clear the worst bezel clip zone.
int top_pad = round ? 8 : 0;
header_h = h * 28 / 168 + top_pad;
label_h = 18;
count_h = h * 40 / 168;
hint_h = 10;
label1_y = header_h + 2;
count1_y = label1_y + label_h + 2;
divider_y = count1_y + count_h + 3;
label2_y = divider_y + 3;
count2_y = label2_y + label_h + 2;
hint_y = h - hint_h - (round ? 8 : 0);
}
// B&W-safe colours
GColor hdr_fill = COLOR_FALLBACK(GColorJaegerGreen, GColorBlack);
GColor div_col = COLOR_FALLBACK(GColorLightGray, GColorDarkGray);
GColor putt_col = COLOR_FALLBACK(GColorCobaltBlue, GColorBlack);
// Background
graphics_context_set_fill_color(ctx, GColorWhite);
graphics_fill_rect(ctx, bounds, 0, GCornerNone);
// Header bar
graphics_context_set_fill_color(ctx, hdr_fill);
graphics_fill_rect(ctx, GRect(0, 0, w, header_h), 0, GCornerNone);
snprintf(s_hole_buf, sizeof(s_hole_buf), "HOLE %d / %d",
s_current_hole + 1, MAX_HOLES);
graphics_context_set_text_color(ctx, GColorWhite);
graphics_draw_text(ctx, s_hole_buf, label_font,
GRect(side, (header_h - label_h) / 2, w - side * 2, label_h),
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
// Strokes label
graphics_context_set_text_color(ctx, GColorDarkGray);
graphics_draw_text(ctx, "STROKES", label_font,
GRect(side, label1_y, w - side * 2, label_h),
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
// Strokes count
snprintf(s_stroke_buf, sizeof(s_stroke_buf), "%d", hole->strokes);
graphics_context_set_text_color(ctx, GColorBlack);
graphics_draw_text(ctx, s_stroke_buf, count_font,
GRect(side, count1_y, w - side * 2, count_h),
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
// Divider
graphics_context_set_stroke_color(ctx, div_col);
graphics_draw_line(ctx,
GPoint(20 + side, divider_y),
GPoint(w - 20 - side, divider_y));
// Putts label
graphics_context_set_text_color(ctx, GColorDarkGray);
graphics_draw_text(ctx, "PUTTS", label_font,
GRect(side, label2_y, w - side * 2, label_h),
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
// Putts count
snprintf(s_putt_buf, sizeof(s_putt_buf), "%d", hole->putts);
graphics_context_set_text_color(ctx, putt_col);
graphics_draw_text(ctx, s_putt_buf, count_font,
GRect(side, count2_y, w - side * 2, count_h),
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
// Hint bar — omitted on round screens where the bottom bezel clips it
if (!round) {
graphics_context_set_fill_color(ctx, COLOR_FALLBACK(GColorLightGray, GColorWhite));
graphics_fill_rect(ctx, GRect(0, hint_y, w, hint_h), 0, GCornerNone);
graphics_context_set_text_color(ctx, GColorDarkGray);
graphics_draw_text(ctx, "Hold +/- = putts Hold O = settings", hint_font,
GRect(2, hint_y, w - 4, hint_h),
GTextOverflowModeTrailingEllipsis, GTextAlignmentCenter, NULL);
}
}
// ---------------------------------------------------------------------------
// Main window — button handlers
// ---------------------------------------------------------------------------
static void up_click_handler(ClickRecognizerRef recognizer, void *context) {
HoleScore *hole = &s_scores[s_current_hole];
if (hole->strokes < 99) {
hole->strokes++;
vibes_short_pulse();
}
APP_LOG(APP_LOG_LEVEL_DEBUG, "ACT:UP hole=%d str=%d ptt=%d",
s_current_hole + 1, hole->strokes, hole->putts);
layer_mark_dirty(s_canvas_layer);
}
static void down_click_handler(ClickRecognizerRef recognizer, void *context) {
HoleScore *hole = &s_scores[s_current_hole];
if (hole->strokes > 0) {
hole->strokes--;
if (hole->putts > hole->strokes) {
hole->putts = hole->strokes;
}
}
APP_LOG(APP_LOG_LEVEL_DEBUG, "ACT:DOWN hole=%d str=%d ptt=%d",
s_current_hole + 1, hole->strokes, hole->putts);
layer_mark_dirty(s_canvas_layer);
}
static void select_click_handler(ClickRecognizerRef recognizer, void *context) {
if (s_current_hole < MAX_HOLES - 1) {
s_current_hole++;
vibes_short_pulse();
} else {
vibes_double_pulse();
}
APP_LOG(APP_LOG_LEVEL_DEBUG, "ACT:SELECT hole=%d", s_current_hole + 1);
save_scores();
layer_mark_dirty(s_canvas_layer);
}
static void up_long_handler(ClickRecognizerRef recognizer, void *context) {
HoleScore *hole = &s_scores[s_current_hole];
if (hole->putts < 99) {
hole->putts++;
if (hole->putts > hole->strokes && hole->strokes < 99) {
hole->strokes = hole->putts;
}
}
APP_LOG(APP_LOG_LEVEL_DEBUG, "ACT:LONG_UP hole=%d str=%d ptt=%d",
s_current_hole + 1, hole->strokes, hole->putts);
layer_mark_dirty(s_canvas_layer);
}
static void down_long_handler(ClickRecognizerRef recognizer, void *context) {
HoleScore *hole = &s_scores[s_current_hole];
if (hole->putts > 0) {
hole->putts--;
}
APP_LOG(APP_LOG_LEVEL_DEBUG, "ACT:LONG_DOWN hole=%d str=%d ptt=%d",
s_current_hole + 1, hole->strokes, hole->putts);
layer_mark_dirty(s_canvas_layer);
}
// ---------------------------------------------------------------------------
// Scorecard window (read-only — reset lives in settings)
// ---------------------------------------------------------------------------
static void build_scorecard(void) {
int total_str = 0, total_ptt = 0;
int pos = 0;
pos += snprintf(s_scorecard_buf + pos, sizeof(s_scorecard_buf) - pos,
"SCORECARD\n\nHole Str Ptt\n");
for (int i = 0; i < MAX_HOLES; i++) {
total_str += s_scores[i].strokes;
total_ptt += s_scores[i].putts;
pos += snprintf(s_scorecard_buf + pos, sizeof(s_scorecard_buf) - pos,
" %2d %2d %2d%s\n",
i + 1, s_scores[i].strokes, s_scores[i].putts,
(i == s_current_hole) ? " <" : "");
}
snprintf(s_scorecard_buf + pos, sizeof(s_scorecard_buf) - pos,
"\nTOT %3d %3d", total_str, total_ptt);
}
static void scorecard_window_load(Window *window) {
Layer *root = window_get_root_layer(window);
GRect bounds = layer_get_bounds(root);
build_scorecard();
GFont font = fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD);
GSize text_size = graphics_text_layout_get_content_size(
s_scorecard_buf, font,
GRect(0, 0, bounds.size.w - 8, 3000),
GTextOverflowModeWordWrap, GTextAlignmentLeft);
int content_h = text_size.h + 20;
s_scroll_layer = scroll_layer_create(bounds);
scroll_layer_set_content_size(s_scroll_layer, GSize(bounds.size.w, content_h));
scroll_layer_set_click_config_onto_window(s_scroll_layer, window);
layer_add_child(root, scroll_layer_get_layer(s_scroll_layer));
s_scorecard_layer = text_layer_create(GRect(4, 4, bounds.size.w - 8, content_h));
text_layer_set_text(s_scorecard_layer, s_scorecard_buf);
text_layer_set_font(s_scorecard_layer, font);
text_layer_set_overflow_mode(s_scorecard_layer, GTextOverflowModeWordWrap);
text_layer_set_background_color(s_scorecard_layer, GColorClear);
scroll_layer_add_child(s_scroll_layer, text_layer_get_layer(s_scorecard_layer));
}
static void scorecard_window_unload(Window *window) {
text_layer_destroy(s_scorecard_layer);
scroll_layer_destroy(s_scroll_layer);
window_destroy(s_scorecard_window);
s_scorecard_window = NULL;
}
// ---------------------------------------------------------------------------
// Return-to-main helper — pops all windows above s_main_window
// ---------------------------------------------------------------------------
static void return_to_main_cb(void *data) {
s_return_timer = NULL;
Window *top;
while ((top = window_stack_get_top_window()) != NULL && top != s_main_window) {
window_stack_pop(false);
}
layer_mark_dirty(s_canvas_layer);
}
static void schedule_return_to_main(void) {
if (s_return_timer) app_timer_cancel(s_return_timer);
s_return_timer = app_timer_register(50, return_to_main_cb, NULL);
}
// ---------------------------------------------------------------------------
// Hole picker window
// ---------------------------------------------------------------------------
static void hole_picker_select_cb(int index, void *ctx) {
s_current_hole = index;
APP_LOG(APP_LOG_LEVEL_DEBUG, "ACT:JUMP hole=%d", index + 1);
save_scores();
schedule_return_to_main();
}
static void hole_picker_window_load(Window *window) {
Layer *root = window_get_root_layer(window);
GRect bounds = layer_get_bounds(root);
for (int i = 0; i < MAX_HOLES; i++) {
bool current = (i == s_current_hole);
snprintf(s_hole_titles[i], sizeof(s_hole_titles[i]),
current ? "> Hole %d" : " Hole %d", i + 1);
HoleScore *h = &s_scores[i];
if (h->strokes == 0 && h->putts == 0) {
snprintf(s_hole_subtitles[i], sizeof(s_hole_subtitles[i]), "not played");
} else {
snprintf(s_hole_subtitles[i], sizeof(s_hole_subtitles[i]),
"%d str / %d ptt", h->strokes, h->putts);
}
s_hole_items[i] = (SimpleMenuItem){
.title = s_hole_titles[i],
.subtitle = s_hole_subtitles[i],
.callback = hole_picker_select_cb,
};
}
s_hole_section = (SimpleMenuSection){
.title = "Jump to Hole",
.items = s_hole_items,
.num_items = MAX_HOLES,
};
s_hole_picker_layer = simple_menu_layer_create(bounds, window,
&s_hole_section, 1, NULL);
simple_menu_layer_set_selected_index(s_hole_picker_layer, s_current_hole, false);
layer_add_child(root, simple_menu_layer_get_layer(s_hole_picker_layer));
}
static void hole_picker_window_unload(Window *window) {
simple_menu_layer_destroy(s_hole_picker_layer);
window_destroy(s_hole_picker_window);
s_hole_picker_window = NULL;
}
// ---------------------------------------------------------------------------
// Settings menu callbacks (defined after the windows they push)
// ---------------------------------------------------------------------------
static void settings_scorecard_cb(int index, void *ctx) {
APP_LOG(APP_LOG_LEVEL_DEBUG, "ACT:SCORECARD");
s_scorecard_window = window_create();
window_set_background_color(s_scorecard_window, GColorWhite);
window_set_window_handlers(s_scorecard_window, (WindowHandlers){
.load = scorecard_window_load,
.unload = scorecard_window_unload,
});
window_stack_push(s_scorecard_window, true);
}
static void settings_jump_cb(int index, void *ctx) {
s_hole_picker_window = window_create();
window_set_window_handlers(s_hole_picker_window, (WindowHandlers){
.load = hole_picker_window_load,
.unload = hole_picker_window_unload,
});
window_stack_push(s_hole_picker_window, true);
}
static void settings_help_cb(int index, void *ctx) {
APP_LOG(APP_LOG_LEVEL_DEBUG, "ACT:CONTROLS");
s_help_window = window_create();
window_set_background_color(s_help_window, GColorWhite);
window_set_window_handlers(s_help_window, (WindowHandlers){
.load = help_window_load,
.unload = help_window_unload,
});
window_stack_push(s_help_window, true);
}
static void settings_reset_cb(int index, void *ctx) {
APP_LOG(APP_LOG_LEVEL_DEBUG, "ACT:RESET");
memset(s_scores, 0, sizeof(s_scores));
s_current_hole = 0;
save_scores();
vibes_double_pulse();
schedule_return_to_main();
}
// ---------------------------------------------------------------------------
// Settings window
// ---------------------------------------------------------------------------
static void settings_window_load(Window *window) {
Layer *root = window_get_root_layer(window);
GRect bounds = layer_get_bounds(root);
s_settings_items[0] = (SimpleMenuItem){
.title = "View Scorecard",
.callback = settings_scorecard_cb,
};
s_settings_items[1] = (SimpleMenuItem){
.title = "Jump to Hole",
.subtitle = "Go back to any hole",
.callback = settings_jump_cb,
};
s_settings_items[2] = (SimpleMenuItem){
.title = "Reset Round",
.subtitle = "Clears all scores",
.callback = settings_reset_cb,
};
s_settings_items[3] = (SimpleMenuItem){
.title = "Controls",
.subtitle = "Button cheatsheet",
.callback = settings_help_cb,
};
s_settings_section = (SimpleMenuSection){
.title = "Settings",
.items = s_settings_items,
.num_items = 4,
};
s_settings_menu_layer = simple_menu_layer_create(bounds, window,
&s_settings_section, 1, NULL);
layer_add_child(root, simple_menu_layer_get_layer(s_settings_menu_layer));
}
static void settings_window_unload(Window *window) {
simple_menu_layer_destroy(s_settings_menu_layer);
window_destroy(s_settings_window);
s_settings_window = NULL;
}
static void show_settings(ClickRecognizerRef recognizer, void *context) {
APP_LOG(APP_LOG_LEVEL_DEBUG, "ACT:SETTINGS");
s_settings_window = window_create();
window_set_window_handlers(s_settings_window, (WindowHandlers){
.load = settings_window_load,
.unload = settings_window_unload,
});
window_stack_push(s_settings_window, true);
}
// ---------------------------------------------------------------------------
// Main window — click config & lifecycle
// ---------------------------------------------------------------------------
static void click_config_provider(void *context) {
window_single_click_subscribe(BUTTON_ID_UP, up_click_handler);
window_single_click_subscribe(BUTTON_ID_DOWN, down_click_handler);
window_single_click_subscribe(BUTTON_ID_SELECT, select_click_handler);
window_long_click_subscribe(BUTTON_ID_UP, 700, up_long_handler, NULL);
window_long_click_subscribe(BUTTON_ID_DOWN, 700, down_long_handler, NULL);
window_long_click_subscribe(BUTTON_ID_SELECT, 700, show_settings, NULL);
}
static void main_window_load(Window *window) {
Layer *root = window_get_root_layer(window);
GRect bounds = layer_get_bounds(root);
s_canvas_layer = layer_create(bounds);
layer_set_update_proc(s_canvas_layer, canvas_update_proc);
layer_add_child(root, s_canvas_layer);
}
static void main_window_unload(Window *window) {
layer_destroy(s_canvas_layer);
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
int main(void) {
load_scores();
s_main_window = window_create();
window_set_click_config_provider(s_main_window, click_config_provider);
window_set_window_handlers(s_main_window, (WindowHandlers){
.load = main_window_load,
.unload = main_window_unload,
});
window_stack_push(s_main_window, true);
app_event_loop();
save_scores();
window_destroy(s_main_window);
return 0;
}

1
tests/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
screenshots/

159
tests/compare.py Normal file
View File

@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""
compare.py <baseline.png> <candidate.png>
Pixel-level comparison of two PNG screenshots.
Exit codes:
0 images are identical (or within tolerance)
1 images differ beyond tolerance
2 usage / file error
Outputs a one-line summary and, on failure, saves a diff image alongside
the candidate as <candidate>_diff.png highlighting changed pixels in red.
"""
import struct
import sys
import zlib
TOLERANCE = 10 # per-channel delta considered noise
THRESHOLD = 0.01 # max fraction of differing pixels before failure (1 %)
# ── Minimal PNG reader ────────────────────────────────────────────────────────
def _read_chunk(data, pos):
length = struct.unpack_from('>I', data, pos)[0]
tag = data[pos+4:pos+8]
body = data[pos+8:pos+8+length]
pos += 12 + length
return tag, body, pos
def load_png(path):
with open(path, 'rb') as f:
raw = f.read()
assert raw[:8] == b'\x89PNG\r\n\x1a\n', f"{path}: not a PNG"
ihdr = width = height = None
idat_chunks = []
pos = 8
while pos < len(raw):
tag, body, pos = _read_chunk(raw, pos)
if tag == b'IHDR':
width, height = struct.unpack('>II', body[:8])
bit_depth, colour_type = body[8], body[9]
assert bit_depth == 8 and colour_type == 2, \
f"{path}: only 8-bit RGB PNGs supported"
elif tag == b'IDAT':
idat_chunks.append(body)
elif tag == b'IEND':
break
assert width and height, f"{path}: no IHDR"
raw_pixels = zlib.decompress(b''.join(idat_chunks))
# Reconstruct pixels via PNG filter (only filter 0 = None is used here)
stride = width * 3
pixels = []
idx = 0
prev_row = bytes(stride)
for _ in range(height):
filt = raw_pixels[idx]; idx += 1
row = bytearray(raw_pixels[idx:idx+stride]); idx += stride
if filt == 0:
pass
elif filt == 1: # Sub
for i in range(3, stride):
row[i] = (row[i] + row[i-3]) & 0xFF
elif filt == 2: # Up
for i in range(stride):
row[i] = (row[i] + prev_row[i]) & 0xFF
elif filt == 3: # Average
for i in range(stride):
a = row[i-3] if i >= 3 else 0
row[i] = (row[i] + (a + prev_row[i]) // 2) & 0xFF
elif filt == 4: # Paeth
for i in range(stride):
a = row[i-3] if i >= 3 else 0
b = prev_row[i]
c = prev_row[i-3] if i >= 3 else 0
p = a + b - c
pa = abs(p - a); pb = abs(p - b); pc = abs(p - c)
pr = a if pa <= pb and pa <= pc else (b if pb <= pc else c)
row[i] = (row[i] + pr) & 0xFF
prev_row = bytes(row)
pixels.append([tuple(row[i:i+3]) for i in range(0, stride, 3)])
return width, height, pixels
def save_png(path, pixels):
h = len(pixels)
w = len(pixels[0])
rows = bytearray()
for row in pixels:
rows.append(0) # filter = None
for p in row:
rows += bytearray(p)
def chunk(tag, data):
crc = zlib.crc32(tag + data) & 0xffffffff
return struct.pack('>I', len(data)) + tag + data + struct.pack('>I', crc)
out = b'\x89PNG\r\n\x1a\n'
out += chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 2, 0, 0, 0))
out += chunk(b'IDAT', zlib.compress(bytes(rows), 9))
out += chunk(b'IEND', b'')
with open(path, 'wb') as f:
f.write(out)
# ── Comparison logic ──────────────────────────────────────────────────────────
def compare(baseline_path, candidate_path):
try:
bw, bh, base_px = load_png(baseline_path)
cw, ch, cand_px = load_png(candidate_path)
except Exception as e:
print(f"ERROR: {e}")
return 2
if bw != cw or bh != ch:
print(f"FAIL size mismatch: baseline={bw}×{bh} candidate={cw}×{ch}")
return 1
total = bw * bh
diff_px = 0
diff_img = [[(0, 0, 0)] * bw for _ in range(bh)]
for y in range(bh):
for x in range(bw):
br, bg, bb = base_px[y][x]
cr, cg, cb = cand_px[y][x]
if abs(br-cr) > TOLERANCE or abs(bg-cg) > TOLERANCE or abs(bb-cb) > TOLERANCE:
diff_px += 1
diff_img[y][x] = (220, 0, 0) # red highlight on diff image
else:
# Dim the matching pixel so diffs stand out
diff_img[y][x] = (cr//3, cg//3, cb//3)
frac = diff_px / total
if frac > THRESHOLD:
diff_path = candidate_path.replace('.png', '_diff.png')
save_png(diff_path, diff_img)
print(f"FAIL {diff_px}/{total} pixels differ ({frac:.2%}) "
f"— diff saved to {diff_path}")
return 1
else:
print(f"PASS {diff_px}/{total} pixels differ ({frac:.2%}) "
f"[tolerance ≤{THRESHOLD:.0%}]")
return 0
if __name__ == '__main__':
if len(sys.argv) != 3:
print(f"Usage: {sys.argv[0]} <baseline.png> <candidate.png>")
sys.exit(2)
sys.exit(compare(sys.argv[1], sys.argv[2]))

501
tests/run_ui_test.py Normal file
View File

@ -0,0 +1,501 @@
#!/usr/bin/env python3
"""
run_ui_test.py <platform> <shots_dir> <baselines_dir> [--update]
Drives Golf Score through a full interaction sequence on the Pebble emulator
via the QEMU HMP monitor (sendkey), captures a screenshot at each step, and
verifies APP_LOG output for expected state changes.
Log capture restarts fresh for each step so it does not hold a persistent
connection that would block pebble screenshot after ~60 s.
Known emulator limitation
QEMU's `sendkey key hold_ms` always delivers a momentary press regardless of
hold_ms the Pebble click system's 700 ms long-press threshold is never
reached. Steps that depend on long-press are executed (firing the short-press
handler instead) and annotated "EMULATOR: long press → short press" in output.
Log verification is skipped for those steps; screenshots are still captured to
confirm the display does not break.
Key mapping (confirmed via probe)
UP sendkey up
DOWN sendkey down
SELECT sendkey right
BACK sendkey left (left on main window exits the app expected)
Exit codes: 0 all pass 1 test failures 2 setup error
"""
import json, os, re, shutil, socket as _socket, struct
import subprocess, sys, time, zlib
EMULATOR_STATE = '/tmp/pb-emulator.json'
# ── Timing ────────────────────────────────────────────────────────────────────
SHORT_MS = 100 # ms normal key hold
LONG_MS = 850 # ms sent, but emulator treats as SHORT_MS (see note above)
SETTLE_S = 0.25 # s pause after short press
LONG_S = 1.1 # s pause after "long" press
DRAW_S = 0.45 # s extra render time before screenshotting
LOG_CAP_S = 2.5 # s how long to capture logs per step
# ── Pixel comparison ──────────────────────────────────────────────────────────
CHANNEL_TOL = 10
PIXEL_THRESH = 0.01
# ── QEMU HMP monitor ──────────────────────────────────────────────────────────
class Monitor:
def __init__(self, port):
self.s = _socket.socket()
self.s.settimeout(5)
self.s.connect(('localhost', port))
self._drain()
def _drain(self):
buf = b''
self.s.settimeout(0.3)
try:
while True:
buf += self.s.recv(4096)
except OSError:
pass
self.s.settimeout(5)
def _send(self, cmd):
self.s.sendall((cmd + '\n').encode())
time.sleep(0.15)
self._drain()
def press(self, key, long=False):
hold = LONG_MS if long else SHORT_MS
self._send(f'sendkey {key} {hold}')
time.sleep(LONG_S if long else SETTLE_S)
def close(self):
try:
self.s.close()
except OSError:
pass
# ── Per-step log capture ──────────────────────────────────────────────────────
# pebble logs is started BEFORE the action and killed BEFORE the screenshot.
# This avoids the persistent-connection interference that causes pebble
# screenshot to fail after ~16 calls when a long-lived pebble-logs process
# holds the session.
def capture_logs(platform, action_fn, pattern, cap_s=LOG_CAP_S):
"""
Start `pebble logs`, run action_fn, wait cap_s, kill, return match.
Returns (found: bool|None, info: str)
None pattern was None (step skipped log check)
True pattern matched
False pattern not matched; info contains recent lines
"""
if pattern is None:
if action_fn:
action_fn()
return None, "skipped"
proc = subprocess.Popen(
['pebble', 'logs', '--emulator', platform],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
text=True, bufsize=1,
)
time.sleep(0.4) # let pebble logs connect
if action_fn:
action_fn()
time.sleep(cap_s) # let log messages arrive
proc.terminate()
try:
out, _ = proc.communicate(timeout=1.5)
except subprocess.TimeoutExpired:
proc.kill()
out, _ = proc.communicate()
lines = out.splitlines()
for line in lines:
if re.search(pattern, line):
return True, line.strip()
recent = [l.strip() for l in lines[-4:] if l.strip()]
return False, f"pattern={pattern!r} recent={recent}"
# ── Screenshot ────────────────────────────────────────────────────────────────
def capture(platform, path, retries=3):
"""Take screenshot; retry on failure (transient connection blip)."""
time.sleep(DRAW_S)
for attempt in range(retries):
r = subprocess.run(
['pebble', 'screenshot', '--emulator', platform, '--no-open', path],
capture_output=True,
)
if r.returncode == 0 and os.path.exists(path):
return True
if attempt < retries - 1:
time.sleep(1.5)
return False
# ── PNG pixel comparison ──────────────────────────────────────────────────────
def _load_png(path):
with open(path, 'rb') as f:
raw = f.read()
assert raw[:8] == b'\x89PNG\r\n\x1a\n'
pos, w, h, idats = 8, None, None, []
while pos < len(raw):
n = struct.unpack_from('>I', raw, pos)[0]
tag = raw[pos+4:pos+8]
body = raw[pos+8:pos+8+n]
pos += 12 + n
if tag == b'IHDR': w, h = struct.unpack('>II', body[:8])
elif tag == b'IDAT': idats.append(body)
elif tag == b'IEND': break
data = zlib.decompress(b''.join(idats))
stride = w * 3
px, prev, idx = [], bytes(stride), 0
for _ in range(h):
f = data[idx]; idx += 1
row = bytearray(data[idx:idx+stride]); idx += stride
if f == 1:
for i in range(3, stride): row[i] = (row[i] + row[i-3]) & 0xFF
elif f == 2:
for i in range(stride): row[i] = (row[i] + prev[i]) & 0xFF
elif f == 3:
for i in range(stride):
a = row[i-3] if i >= 3 else 0
row[i] = (row[i] + (a + prev[i]) // 2) & 0xFF
elif f == 4:
for i in range(stride):
a = row[i-3] if i >= 3 else 0; b = prev[i]
c = prev[i-3] if i >= 3 else 0; p = a + b - c
pa, pb, pc = abs(p-a), abs(p-b), abs(p-c)
pr = a if pa<=pb and pa<=pc else (b if pb<=pc else c)
row[i] = (row[i] + pr) & 0xFF
prev = bytes(row)
px.append([tuple(row[i:i+3]) for i in range(0, stride, 3)])
return w, h, px
def compare_images(a, b):
try:
aw, ah, apx = _load_png(a)
bw, bh, bpx = _load_png(b)
except Exception as e:
return False, f"load error: {e}"
if aw != bw or ah != bh:
return False, f"size {aw}×{ah} vs {bw}×{bh}"
diffs = sum(
1 for y in range(ah) for x in range(aw)
if any(abs(apx[y][x][c] - bpx[y][x][c]) > CHANNEL_TOL for c in range(3))
)
frac = diffs / (aw * ah)
return frac <= PIXEL_THRESH, f"{diffs}/{aw*ah} px differ ({frac:.2%})"
# ── Test step actions ─────────────────────────────────────────────────────────
# All action functions receive `mon` via closure from main().
# They are defined as lambdas/functions that call mon.press().
# Long presses use mon.press(key, long=True) — note emulator limitation above.
def make_steps(mon):
"""Return the full step list, closing over `mon`."""
# Short-hand helpers
def up(): mon.press('up')
def down(): mon.press('down')
def sel(): mon.press('right') # SELECT = right arrow
def back(): mon.press('left') # BACK = left arrow
def long_up(): mon.press('up', long=True)
def long_down(): mon.press('down', long=True)
def long_sel(): mon.press('right', long=True)
def reset():
long_sel() # open settings
down(); down() # → Reset Round
sel() # trigger reset
time.sleep(0.4)
def score_hole2_advance():
for _ in range(5): up()
long_up(); long_up() # putts (fires short in emulator)
sel() # advance to hole 3
def score_hole3():
up(); up(); up()
def open_scorecard():
sel() # item 0 = View Scorecard
def open_hole_picker():
down(); sel() # item 0→1 (Jump to Hole), SELECT
def jump_hole1():
sel() # select hole 1
time.sleep(0.35) # wait for return-to-main timer
def open_controls():
down(); down(); down() # item 0→3 (Controls)
sel()
def reset_from_settings():
up() # Controls→Reset Round
sel()
time.sleep(0.35)
# ── Step list ──────────────────────────────────────────────────────────────
# (step_id, description, action_fn | None, log_pattern | None)
#
# log_pattern = None for steps that either have no state change to verify
# or where the emulator limitation prevents the expected handler from firing.
EMULATOR_NOTE = " ⚠ EMULATOR: long press fires as short press (sendkey limitation)"
return [
# ── Clean start ───────────────────────────────────────────────────────
("00_reset",
"Reset round via Settings → HOLE 1 0 str 0 ptt",
reset,
r"ACT:RESET"),
("01_initial",
"Initial state: HOLE 1 STROKES 0 PUTTS 0",
None, None),
# ── Stroke counter ────────────────────────────────────────────────────
("02_up_1",
"UP → STROKES 1",
up,
r"ACT:UP hole=1 str=1 ptt=0"),
("03_up_3",
"UP × 2 → STROKES 3",
lambda: [up(), up()],
r"ACT:UP hole=1 str=3 ptt=0"),
# ── Putt counter (long press — emulator fires short press instead) ────
("04_putt_attempt",
f"Hold UP (emulator → short UP) STROKES becomes 4",
long_up,
None), # skip: emulator fires ACT:UP not ACT:LONG_UP
("05_putt_attempt2",
f"Hold UP × 2 (emulator → short UP × 2) STROKES becomes 6",
lambda: [long_up(), long_up()],
None),
# ── DOWN correction ───────────────────────────────────────────────────
("06_down_corrects",
"DOWN × 4 → STROKES 2 (correcting emulator over-count)",
lambda: [down(), down(), down(), down()],
r"ACT:DOWN hole=1 str=2 ptt=0"),
# ── Long DOWN (putt decrement — same limitation) ──────────────────────
("07_long_down_attempt",
"Hold DOWN (emulator → short DOWN) STROKES becomes 1",
long_down,
None),
# ── Correct back to known state ───────────────────────────────────────
("08_restore_state",
"UP → STROKES 2 (restore to known state for next steps)",
up,
r"ACT:UP hole=1 str=2 ptt=0"),
# ── Hole advance ──────────────────────────────────────────────────────
("09_hole_2",
"SELECT → HOLE 2 (hole 1 saved: 2 str)",
sel,
r"ACT:SELECT hole=2"),
# ── Multi-hole data for scorecard ─────────────────────────────────────
("10_hole2_scored",
"Score hole 2: UP × 5, long UP × 2 (→ 7 str), SELECT → HOLE 3",
score_hole2_advance,
r"ACT:SELECT hole=3"),
("11_hole3_scored",
"Score hole 3: UP × 3",
score_hole3,
r"ACT:UP hole=3 str=3 ptt=0"),
# ── Settings menu ─────────────────────────────────────────────────────
("12_settings",
"Hold SELECT → Settings menu",
long_sel,
r"ACT:SETTINGS"),
# ── Scorecard ─────────────────────────────────────────────────────────
("13_scorecard",
"SELECT → View Scorecard",
open_scorecard,
r"ACT:SCORECARD"),
("14_scorecard_scrolled",
"DOWN × 3 → scroll scorecard",
lambda: [down(), down(), down()],
None),
("15_back_to_settings",
"BACK → Settings menu",
back,
None),
# ── Hole picker ───────────────────────────────────────────────────────
("16_hole_picker",
"DOWN + SELECT → Jump to Hole picker (HOLE 3 pre-selected)",
open_hole_picker,
None),
("17_picker_up2",
"UP × 2 → HOLE 1 highlighted",
lambda: [up(), up()],
None),
("18_jumped_hole1",
"SELECT → jump to HOLE 1 (2 str)",
jump_hole1,
r"ACT:JUMP hole=1"),
# ── Controls / help ───────────────────────────────────────────────────
("19_settings_fresh",
"Hold SELECT → Settings (fresh open from main)",
long_sel,
r"ACT:SETTINGS"),
("20_controls",
"DOWN × 3 + SELECT → Controls cheatsheet",
open_controls,
r"ACT:CONTROLS"),
("21_controls_scrolled",
"DOWN × 2 → scroll controls",
lambda: [down(), down()],
None),
("22_back_to_settings",
"BACK → Settings (Controls highlighted)",
back,
None),
# ── Reset round ───────────────────────────────────────────────────────
("23_after_reset",
"UP + SELECT → Reset Round → HOLE 1 0 str",
reset_from_settings,
r"ACT:RESET"),
# ── Floor checks ──────────────────────────────────────────────────────
("24_stroke_floor",
"DOWN at 0 → STROKES stays 0",
down,
r"ACT:DOWN hole=1 str=0 ptt=0"),
("25_long_down_floor",
"Hold DOWN at 0 (emulator → short DOWN) STROKES stays 0",
long_down,
None),
]
# ── Runner ────────────────────────────────────────────────────────────────────
def main():
if len(sys.argv) < 4:
print(f"Usage: {sys.argv[0]} <platform> <shots_dir> <baselines_dir> [--update]")
return 2
platform = sys.argv[1]
shots_dir = sys.argv[2]
baselines_dir = sys.argv[3]
update_mode = '--update' in sys.argv
# Locate monitor port
try:
state = json.load(open(EMULATOR_STATE))
pdata = state.get(platform)
if not pdata:
print(f"ERROR: '{platform}' not in {EMULATOR_STATE}", file=sys.stderr)
return 2
sdk_ver = next(iter(pdata))
mon_port = pdata[sdk_ver]['qemu']['monitor']
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
return 2
print(f" Monitor port {mon_port} ({platform})")
try:
mon = Monitor(mon_port)
except Exception as e:
print(f" ERROR: monitor: {e}", file=sys.stderr)
return 2
os.makedirs(shots_dir, exist_ok=True)
os.makedirs(baselines_dir, exist_ok=True)
steps = make_steps(mon)
failures = []
for step_id, desc, action, log_pattern in steps:
shot = os.path.join(shots_dir, f"{platform}_{step_id}.png")
baseline = os.path.join(baselines_dir, f"{platform}_{step_id}.png")
# Capture logs and run action together; logs killed before screenshot
log_ok, log_info = capture_logs(platform, action, log_pattern)
# Screenshot (taken AFTER log process is dead — no connection conflict)
if not capture(platform, shot):
print(f" ✗ [{step_id}] screenshot failed")
failures.append(step_id)
continue
# Baseline update or comparison
if update_mode:
shutil.copy2(shot, baseline)
img_result = "baseline saved"
elif os.path.exists(baseline):
img_ok, img_result = compare_images(baseline, shot)
if not img_ok:
failures.append(step_id)
else:
img_ok, img_result = None, "no baseline"
# Determine display mark
img_failed = not update_mode and img_ok is False
log_failed = log_ok is False # None = skipped, not failure
step_failed = img_failed or log_failed
mark = "" if update_mode else ("" if step_failed else "")
print(f" {mark} [{step_id}] {desc}")
if log_ok is True:
print(f" log ✓ {log_info}")
elif log_ok is False:
print(f" log ✗ {log_info}")
failures.append(step_id)
# log_ok is None → silently skipped
if not update_mode and img_ok is not None:
img_sym = "img ✓" if img_ok else "img ✗"
print(f" {img_sym} {img_result}")
elif update_mode:
print(f" {img_result}")
mon.close()
failures = list(dict.fromkeys(failures)) # deduplicate
if failures:
print(f"\n {len(failures)} failure(s): {', '.join(failures)}")
return 1
return 0
if __name__ == '__main__':
sys.exit(main())

124
tests/test_platforms.sh Executable file
View File

@ -0,0 +1,124 @@
#!/usr/bin/env bash
# test_platforms.sh
#
# Builds the app, runs it in each platform emulator, drives a full
# interaction sequence via the QEMU monitor, and compares screenshots
# against stored baselines.
#
# Usage:
# ./tests/test_platforms.sh # compare against baselines
# ./tests/test_platforms.sh --update # overwrite baselines with current output
#
# Requirements: pebble SDK on PATH, python3
set -euo pipefail
REPO="$(cd "$(dirname "$0")/.." && pwd)"
TESTS="$REPO/tests"
BASELINES="$TESTS/baselines"
SHOTS="$TESTS/screenshots"
PLATFORMS=(basalt chalk diorite emery flint gabbro)
# `pebble install` starts the emulator, waits for boot, installs, then exits.
# 120 s covers even a first-ever cold start.
INSTALL_TIMEOUT=120
# Seconds to wait after install for the app to fully render before testing.
RENDER_WAIT=4
UPDATE_FLAG=""
if [[ "${1:-}" == "--update" ]]; then
UPDATE_FLAG="--update"
fi
# ── Helpers ───────────────────────────────────────────────────────────────────
log() { echo " $*"; }
pass() { echo "$*"; }
fail() { echo "$*"; FAILURES+=("$*"); }
kill_emulators() {
pebble kill --force 2>/dev/null || true
sleep 1
}
# ── Build ─────────────────────────────────────────────────────────────────────
echo ""
echo "═══════════════════════════════════════════"
echo " Golf Score — platform test suite"
echo "═══════════════════════════════════════════"
echo ""
echo "Building..."
cd "$REPO"
pebble build 2>&1 | tail -3
echo ""
mkdir -p "$SHOTS" "$BASELINES"
FAILURES=()
# ── Per-platform loop ─────────────────────────────────────────────────────────
for PLATFORM in "${PLATFORMS[@]}"; do
echo "── $PLATFORM ─────────────────────────────────"
INSTALL_LOG="$SHOTS/${PLATFORM}_install.log"
kill_emulators
# Install — runs foreground (no --logs, no --vnc).
# pebble install starts the emulator, waits for boot, installs, then exits.
# The QEMU monitor port is written to /tmp/pb-emulator.json during startup.
log "Installing (timeout ${INSTALL_TIMEOUT}s)..."
if timeout "$INSTALL_TIMEOUT" \
pebble install --emulator "$PLATFORM" "$REPO/build/golf_score.pbw" \
> "$INSTALL_LOG" 2>&1; then
pass "Installed"
else
EXIT=$?
MSG="install failed (exit $EXIT)"
[[ $EXIT -eq 124 ]] && MSG="install timed out after ${INSTALL_TIMEOUT}s"
fail "$PLATFORM: $MSG — see $INSTALL_LOG"
kill_emulators
continue
fi
log "Waiting ${RENDER_WAIT}s for initial render..."
sleep "$RENDER_WAIT"
# Run the full interaction + screenshot sequence
log "Running UI test sequence..."
UI_EXIT=0
python3 "$TESTS/run_ui_test.py" \
"$PLATFORM" "$SHOTS" "$BASELINES" $UPDATE_FLAG || UI_EXIT=$?
if [[ $UI_EXIT -eq 0 ]]; then
if [[ -n "$UPDATE_FLAG" ]]; then
pass "Baselines updated"
else
pass "All steps passed"
fi
elif [[ $UI_EXIT -eq 2 ]]; then
fail "$PLATFORM: UI test setup error (monitor connection?)"
else
fail "$PLATFORM: one or more UI steps failed"
fi
kill_emulators
echo ""
done
# ── Summary ───────────────────────────────────────────────────────────────────
echo "═══════════════════════════════════════════"
if [[ ${#FAILURES[@]} -eq 0 ]]; then
echo " All platforms passed"
echo "═══════════════════════════════════════════"
exit 0
else
echo " ${#FAILURES[@]} failure(s):"
for F in "${FAILURES[@]}"; do echo "$F"; done
echo "═══════════════════════════════════════════"
exit 1
fi

54
wscript Normal file
View File

@ -0,0 +1,54 @@
#
# This file is the default set of rules to compile a Pebble application.
#
# Feel free to customize this to your needs.
#
import os.path
top = '.'
out = 'build'
def options(ctx):
ctx.load('pebble_sdk')
def configure(ctx):
"""
This method is used to configure your build. ctx.load(`pebble_sdk`) automatically configures
a build for each valid platform in `targetPlatforms`. Platform-specific configuration: add your
change after calling ctx.load('pebble_sdk') and make sure to set the correct environment first.
Universal configuration: add your change prior to calling ctx.load('pebble_sdk').
"""
ctx.load('pebble_sdk')
def build(ctx):
ctx.load('pebble_sdk')
build_worker = os.path.exists('worker_src')
binaries = []
cached_env = ctx.env
for platform in ctx.env.TARGET_PLATFORMS:
ctx.env = ctx.all_envs[platform]
ctx.set_group(ctx.env.PLATFORM_NAME)
app_elf = '{}/pebble-app.elf'.format(ctx.env.BUILD_DIR)
ctx.pbl_build(source=ctx.path.ant_glob('src/c/**/*.c'), target=app_elf, bin_type='app')
if build_worker:
worker_elf = '{}/pebble-worker.elf'.format(ctx.env.BUILD_DIR)
binaries.append({'platform': platform, 'app_elf': app_elf, 'worker_elf': worker_elf})
ctx.pbl_build(source=ctx.path.ant_glob('worker_src/c/**/*.c'),
target=worker_elf,
bin_type='worker')
else:
binaries.append({'platform': platform, 'app_elf': app_elf})
ctx.env = cached_env
ctx.set_group('bundle')
ctx.pbl_bundle(binaries=binaries,
js=ctx.path.ant_glob(['src/pkjs/**/*.js',
'src/pkjs/**/*.json',
'src/common/**/*.js']),
js_entry_file='src/pkjs/index.js')