start of testing and adding all display support

This commit is contained in:
2026-05-10 22:48:16 -04:00
parent 3a68e40ada
commit 746a2cc4aa
9 changed files with 928 additions and 32 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"WebFetch(domain:developer.repebble.com)"
]
}
}
+36
View File
@@ -0,0 +1,36 @@
# 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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+98 -30
View File
@@ -137,68 +137,122 @@ static void load_scores(void) {
}
// ---------------------------------------------------------------------------
// Main canvas draw
// 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
graphics_context_set_fill_color(ctx, GColorJaegerGreen);
graphics_fill_rect(ctx, GRect(0, 0, w, 44), 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,
fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD),
GRect(0, 12, w, 22),
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
// Strokes label
graphics_context_set_text_color(ctx, GColorDarkGray);
graphics_draw_text(ctx, "STROKES",
fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD),
GRect(0, 50, w, 22),
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,
fonts_get_system_font(FONT_KEY_BITHAM_42_BOLD),
GRect(0, 70, w, 58),
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, GColorLightGray);
graphics_draw_line(ctx, GPoint(20, 133), GPoint(w - 20, 133));
graphics_context_set_stroke_color(ctx, div_col);
graphics_draw_line(ctx,
GPoint(20 + side, divider_y),
GPoint(w - 20 - side, divider_y));
// Putts
// Putts label
graphics_context_set_text_color(ctx, GColorDarkGray);
graphics_draw_text(ctx, "PUTTS",
fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD),
GRect(0, 139, w, 22),
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, GColorCobaltBlue);
graphics_draw_text(ctx, s_putt_buf,
fonts_get_system_font(FONT_KEY_BITHAM_42_BOLD),
GRect(0, 158, w, 58),
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
graphics_context_set_fill_color(ctx, GColorLightGray);
graphics_fill_rect(ctx, GRect(0, 216, w, 12), 0, GCornerNone);
// 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",
fonts_get_system_font(FONT_KEY_GOTHIC_14),
GRect(2, 217, w - 4, 12),
graphics_draw_text(ctx, "Hold +/- = putts Hold O = settings", hint_font,
GRect(2, hint_y, w - 4, hint_h),
GTextOverflowModeTrailingEllipsis, GTextAlignmentCenter, NULL);
}
}
// ---------------------------------------------------------------------------
@@ -210,6 +264,8 @@ static void up_click_handler(ClickRecognizerRef recognizer, void *context) {
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);
}
@@ -221,6 +277,8 @@ static void down_click_handler(ClickRecognizerRef recognizer, void *context) {
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);
}
@@ -231,6 +289,7 @@ static void select_click_handler(ClickRecognizerRef recognizer, void *context) {
} 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);
}
@@ -243,6 +302,8 @@ static void up_long_handler(ClickRecognizerRef recognizer, void *context) {
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);
}
@@ -251,6 +312,8 @@ static void down_long_handler(ClickRecognizerRef recognizer, void *context) {
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);
}
@@ -332,6 +395,7 @@ static void schedule_return_to_main(void) {
// ---------------------------------------------------------------------------
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();
}
@@ -382,6 +446,7 @@ static void hole_picker_window_unload(Window *window) {
// 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){
@@ -401,6 +466,7 @@ static void settings_jump_cb(int index, void *ctx) {
}
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){
@@ -411,6 +477,7 @@ static void settings_help_cb(int index, void *ctx) {
}
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();
@@ -463,6 +530,7 @@ static void settings_window_unload(Window *window) {
}
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,
+1
View File
@@ -0,0 +1 @@
screenshots/
+159
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
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
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