From 746a2cc4aabead7c36da8fe12b2b73dab2dc3fef Mon Sep 17 00:00:00 2001 From: Mike Eberlein Date: Sun, 10 May 2026 22:48:16 -0400 Subject: [PATCH] start of testing and adding all display support --- .claude/settings.local.json | 7 + README.md | 36 +++ score_screenshot.png | Bin 0 -> 1626 bytes settings.png | Bin 0 -> 2423 bytes src/c/golf_score.c | 132 +++++++--- tests/.gitignore | 1 + tests/compare.py | 159 ++++++++++++ tests/run_ui_test.py | 501 ++++++++++++++++++++++++++++++++++++ tests/test_platforms.sh | 124 +++++++++ 9 files changed, 928 insertions(+), 32 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 README.md create mode 100644 score_screenshot.png create mode 100644 settings.png create mode 100644 tests/.gitignore create mode 100644 tests/compare.py create mode 100644 tests/run_ui_test.py create mode 100755 tests/test_platforms.sh diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..8175907 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:developer.repebble.com)" + ] + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..a1e39e2 --- /dev/null +++ b/README.md @@ -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 # 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: diff --git a/score_screenshot.png b/score_screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..bd61fc4a5ab5e1b67e4753bd2319c31b2a0f76e4 GIT binary patch literal 1626 zcmcJQdsLEl7{`rXOw^p070YG5iRHFdX4dkOh=n1Zj&7RUjM8b8ot%-Em4Zp{cHFUP z@RB<_R;XP(-7GyvQ#1pzrBFKGgOO$!Wa2GCCWweH;Mh+8tkpXEV}Crq=lT7f-}jHt z^ZYI+K98d;cJ_95aBx^mjUgx4z0_`MCrA7E^s~%|4i1hhspP1g`F!mYSr%dQ2gP4} z#d|PaA;^P?2~n|!c^?8lrnQIP+U9n3C{iDK2kV)9YTrR(xbine^}Nm6)!l4rp2Joo zRgt>y0_jUvyEm?fDvELUKHUNCPs0Scc$Fkq{97s|ulh?dr&cGyHhec!qnOl2?AJ{R z@h{6HoI!@#AbtQeWYbPnWoSaVDRcr?cXF>rbg_OuX*GOO!BJ-D3}v@_lA!K6W?dMx zF~v1w(FT2C@Z=hpYkf>=t|=Wl0Md|1{TZ50fex-s{q=?EPzR8(1y)KBSGCk2d{Xo+ zIMI9>>dwCr95`ErB2JbUUeeC2C74hGXSzaJZ%PV)J4@4&z}giAn2Yn@?Mj=Od^k7N z*FRvGl_5cM=G1J=K&y5fyO9YQi8d+rwrP-(imuU0u=;E0ef(5#z<;O-i#0o+v=9T4 zL3bwr1CieBU>|`(E6^UZ*oZ7&oplRqUWx9y4|s05HO^eu%^N#_OCQxdJBq>W5|#V5 z9@C|(+TsySn7--vXfO&nI#3Jvl@KulGGD?t6*j~eJir6mpKVY^Fp~q;4 z80Lw?8)2bDV5_^k2zohg9TAMo-U)Q7(Ia^VGK>7M19(24TpR@4o;K2j6!2WRH=26#%0A=>g2!@Kyie$QJR zFQ**;=!M?xElqo!@U`|nTv)(nZ>HTkZ2{gAK!fZZ{<%2%o$IQonL|4J z=25#mnf+7KQIiHw!?{?{_P(aT9+)BvW^A1F&gyhC3vLBA zJd?8ZH!V~-4+-Fmy@T-8aZZr>vaOAweIC+u?gjUl#hB!CtDGS@2xEa9A51a{>|gmR zB!m4R9NoOG5Rwz%tv~{of%%vW8l7s~|JTJg-@AVTwkuE+4GdQN{rTGy1}n79 zmQ9Z{>%(CkeqUGg+&J+fKEIg6;ZvF#ZS#ApYmXGws)oB98|I$%`D=mq;XP zjDL@=371_&ce<)&nQikXgl#KQO+O~oo5~uCwu4-v%A}z61XiLvWT5RTRA$NT8ard% zJ{h6o)t@&OUgH~s#Xm$2TP6vo3vNU_IZqNXX3qAZmpG!y3{g9Q(X%Y$sdtRV+{AA$ zW2`6WC`hag7+?kWa(fS|7EqK|*4O|l(uSXt)thbRj7sa6Z6U96kbdcm(PaNK9H`N8 K=(onkY@8QnJ8EQ|h-0ArR0E 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 literal 0 HcmV?d00001 diff --git a/src/c/golf_score.c b/src/c/golf_score.c index 34cb06a..ac848ab 100644 --- a/src/c/golf_score.c +++ b/src/c/golf_score.c @@ -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); - 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), - GTextOverflowModeTrailingEllipsis, 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); + } } // --------------------------------------------------------------------------- @@ -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, diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 0000000..750e145 --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1 @@ +screenshots/ diff --git a/tests/compare.py b/tests/compare.py new file mode 100644 index 0000000..948aa7b --- /dev/null +++ b/tests/compare.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +""" +compare.py + +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 _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]} ") + sys.exit(2) + sys.exit(compare(sys.argv[1], sys.argv[2])) diff --git a/tests/run_ui_test.py b/tests/run_ui_test.py new file mode 100644 index 0000000..fb7d1da --- /dev/null +++ b/tests/run_ui_test.py @@ -0,0 +1,501 @@ +#!/usr/bin/env python3 +""" +run_ui_test.py [--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]} [--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()) diff --git a/tests/test_platforms.sh b/tests/test_platforms.sh new file mode 100755 index 0000000..edb9ec9 --- /dev/null +++ b/tests/test_platforms.sh @@ -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