start of testing and adding all display support
This commit is contained in:
parent
3a68e40ada
commit
746a2cc4aa
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"WebFetch(domain:developer.repebble.com)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
36
README.md
Normal file
36
README.md
Normal 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>
|
||||||
BIN
score_screenshot.png
Normal file
BIN
score_screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
BIN
settings.png
Normal file
BIN
settings.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
|
|
@ -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) {
|
static void canvas_update_proc(Layer *layer, GContext *ctx) {
|
||||||
GRect bounds = layer_get_bounds(layer);
|
GRect bounds = layer_get_bounds(layer);
|
||||||
int w = bounds.size.w;
|
int w = bounds.size.w;
|
||||||
|
int h = bounds.size.h;
|
||||||
HoleScore *hole = &s_scores[s_current_hole];
|
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_context_set_fill_color(ctx, GColorWhite);
|
||||||
graphics_fill_rect(ctx, bounds, 0, GCornerNone);
|
graphics_fill_rect(ctx, bounds, 0, GCornerNone);
|
||||||
|
|
||||||
// Header
|
// Header bar
|
||||||
graphics_context_set_fill_color(ctx, GColorJaegerGreen);
|
graphics_context_set_fill_color(ctx, hdr_fill);
|
||||||
graphics_fill_rect(ctx, GRect(0, 0, w, 44), 0, GCornerNone);
|
graphics_fill_rect(ctx, GRect(0, 0, w, header_h), 0, GCornerNone);
|
||||||
|
|
||||||
snprintf(s_hole_buf, sizeof(s_hole_buf), "HOLE %d / %d",
|
snprintf(s_hole_buf, sizeof(s_hole_buf), "HOLE %d / %d",
|
||||||
s_current_hole + 1, MAX_HOLES);
|
s_current_hole + 1, MAX_HOLES);
|
||||||
graphics_context_set_text_color(ctx, GColorWhite);
|
graphics_context_set_text_color(ctx, GColorWhite);
|
||||||
graphics_draw_text(ctx, s_hole_buf,
|
graphics_draw_text(ctx, s_hole_buf, label_font,
|
||||||
fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD),
|
GRect(side, (header_h - label_h) / 2, w - side * 2, label_h),
|
||||||
GRect(0, 12, w, 22),
|
|
||||||
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
|
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
|
||||||
|
|
||||||
// Strokes
|
// Strokes label
|
||||||
graphics_context_set_text_color(ctx, GColorDarkGray);
|
graphics_context_set_text_color(ctx, GColorDarkGray);
|
||||||
graphics_draw_text(ctx, "STROKES",
|
graphics_draw_text(ctx, "STROKES", label_font,
|
||||||
fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD),
|
GRect(side, label1_y, w - side * 2, label_h),
|
||||||
GRect(0, 50, w, 22),
|
|
||||||
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
|
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
|
||||||
|
|
||||||
|
// Strokes count
|
||||||
snprintf(s_stroke_buf, sizeof(s_stroke_buf), "%d", hole->strokes);
|
snprintf(s_stroke_buf, sizeof(s_stroke_buf), "%d", hole->strokes);
|
||||||
graphics_context_set_text_color(ctx, GColorBlack);
|
graphics_context_set_text_color(ctx, GColorBlack);
|
||||||
graphics_draw_text(ctx, s_stroke_buf,
|
graphics_draw_text(ctx, s_stroke_buf, count_font,
|
||||||
fonts_get_system_font(FONT_KEY_BITHAM_42_BOLD),
|
GRect(side, count1_y, w - side * 2, count_h),
|
||||||
GRect(0, 70, w, 58),
|
|
||||||
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
|
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
|
||||||
|
|
||||||
// Divider
|
// Divider
|
||||||
graphics_context_set_stroke_color(ctx, GColorLightGray);
|
graphics_context_set_stroke_color(ctx, div_col);
|
||||||
graphics_draw_line(ctx, GPoint(20, 133), GPoint(w - 20, 133));
|
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_context_set_text_color(ctx, GColorDarkGray);
|
||||||
graphics_draw_text(ctx, "PUTTS",
|
graphics_draw_text(ctx, "PUTTS", label_font,
|
||||||
fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD),
|
GRect(side, label2_y, w - side * 2, label_h),
|
||||||
GRect(0, 139, w, 22),
|
|
||||||
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
|
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
|
||||||
|
|
||||||
|
// Putts count
|
||||||
snprintf(s_putt_buf, sizeof(s_putt_buf), "%d", hole->putts);
|
snprintf(s_putt_buf, sizeof(s_putt_buf), "%d", hole->putts);
|
||||||
graphics_context_set_text_color(ctx, GColorCobaltBlue);
|
graphics_context_set_text_color(ctx, putt_col);
|
||||||
graphics_draw_text(ctx, s_putt_buf,
|
graphics_draw_text(ctx, s_putt_buf, count_font,
|
||||||
fonts_get_system_font(FONT_KEY_BITHAM_42_BOLD),
|
GRect(side, count2_y, w - side * 2, count_h),
|
||||||
GRect(0, 158, w, 58),
|
|
||||||
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
|
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
|
||||||
|
|
||||||
// Hint bar
|
// Hint bar — omitted on round screens where the bottom bezel clips it
|
||||||
graphics_context_set_fill_color(ctx, GColorLightGray);
|
if (!round) {
|
||||||
graphics_fill_rect(ctx, GRect(0, 216, w, 12), 0, GCornerNone);
|
graphics_context_set_fill_color(ctx, COLOR_FALLBACK(GColorLightGray, GColorWhite));
|
||||||
graphics_context_set_text_color(ctx, GColorDarkGray);
|
graphics_fill_rect(ctx, GRect(0, hint_y, w, hint_h), 0, GCornerNone);
|
||||||
graphics_draw_text(ctx, "Hold +/- = putts Hold O = settings",
|
graphics_context_set_text_color(ctx, GColorDarkGray);
|
||||||
fonts_get_system_font(FONT_KEY_GOTHIC_14),
|
graphics_draw_text(ctx, "Hold +/- = putts Hold O = settings", hint_font,
|
||||||
GRect(2, 217, w - 4, 12),
|
GRect(2, hint_y, w - 4, hint_h),
|
||||||
GTextOverflowModeTrailingEllipsis, GTextAlignmentCenter, NULL);
|
GTextOverflowModeTrailingEllipsis, GTextAlignmentCenter, NULL);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -210,6 +264,8 @@ static void up_click_handler(ClickRecognizerRef recognizer, void *context) {
|
||||||
hole->strokes++;
|
hole->strokes++;
|
||||||
vibes_short_pulse();
|
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);
|
layer_mark_dirty(s_canvas_layer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -221,6 +277,8 @@ static void down_click_handler(ClickRecognizerRef recognizer, void *context) {
|
||||||
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);
|
layer_mark_dirty(s_canvas_layer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -231,6 +289,7 @@ static void select_click_handler(ClickRecognizerRef recognizer, void *context) {
|
||||||
} else {
|
} else {
|
||||||
vibes_double_pulse();
|
vibes_double_pulse();
|
||||||
}
|
}
|
||||||
|
APP_LOG(APP_LOG_LEVEL_DEBUG, "ACT:SELECT hole=%d", s_current_hole + 1);
|
||||||
save_scores();
|
save_scores();
|
||||||
layer_mark_dirty(s_canvas_layer);
|
layer_mark_dirty(s_canvas_layer);
|
||||||
}
|
}
|
||||||
|
|
@ -243,6 +302,8 @@ static void up_long_handler(ClickRecognizerRef recognizer, void *context) {
|
||||||
hole->strokes = hole->putts;
|
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);
|
layer_mark_dirty(s_canvas_layer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -251,6 +312,8 @@ static void down_long_handler(ClickRecognizerRef recognizer, void *context) {
|
||||||
if (hole->putts > 0) {
|
if (hole->putts > 0) {
|
||||||
hole->putts--;
|
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);
|
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) {
|
static void hole_picker_select_cb(int index, void *ctx) {
|
||||||
s_current_hole = index;
|
s_current_hole = index;
|
||||||
|
APP_LOG(APP_LOG_LEVEL_DEBUG, "ACT:JUMP hole=%d", index + 1);
|
||||||
save_scores();
|
save_scores();
|
||||||
schedule_return_to_main();
|
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)
|
// Settings menu callbacks (defined after the windows they push)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
static void settings_scorecard_cb(int index, void *ctx) {
|
static void settings_scorecard_cb(int index, void *ctx) {
|
||||||
|
APP_LOG(APP_LOG_LEVEL_DEBUG, "ACT:SCORECARD");
|
||||||
s_scorecard_window = window_create();
|
s_scorecard_window = window_create();
|
||||||
window_set_background_color(s_scorecard_window, GColorWhite);
|
window_set_background_color(s_scorecard_window, GColorWhite);
|
||||||
window_set_window_handlers(s_scorecard_window, (WindowHandlers){
|
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) {
|
static void settings_help_cb(int index, void *ctx) {
|
||||||
|
APP_LOG(APP_LOG_LEVEL_DEBUG, "ACT:CONTROLS");
|
||||||
s_help_window = window_create();
|
s_help_window = window_create();
|
||||||
window_set_background_color(s_help_window, GColorWhite);
|
window_set_background_color(s_help_window, GColorWhite);
|
||||||
window_set_window_handlers(s_help_window, (WindowHandlers){
|
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) {
|
static void settings_reset_cb(int index, void *ctx) {
|
||||||
|
APP_LOG(APP_LOG_LEVEL_DEBUG, "ACT:RESET");
|
||||||
memset(s_scores, 0, sizeof(s_scores));
|
memset(s_scores, 0, sizeof(s_scores));
|
||||||
s_current_hole = 0;
|
s_current_hole = 0;
|
||||||
save_scores();
|
save_scores();
|
||||||
|
|
@ -463,6 +530,7 @@ static void settings_window_unload(Window *window) {
|
||||||
}
|
}
|
||||||
|
|
||||||
static void show_settings(ClickRecognizerRef recognizer, void *context) {
|
static void show_settings(ClickRecognizerRef recognizer, void *context) {
|
||||||
|
APP_LOG(APP_LOG_LEVEL_DEBUG, "ACT:SETTINGS");
|
||||||
s_settings_window = window_create();
|
s_settings_window = window_create();
|
||||||
window_set_window_handlers(s_settings_window, (WindowHandlers){
|
window_set_window_handlers(s_settings_window, (WindowHandlers){
|
||||||
.load = settings_window_load,
|
.load = settings_window_load,
|
||||||
|
|
|
||||||
1
tests/.gitignore
vendored
Normal file
1
tests/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
screenshots/
|
||||||
159
tests/compare.py
Normal file
159
tests/compare.py
Normal 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
501
tests/run_ui_test.py
Normal 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
124
tests/test_platforms.sh
Executable 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
|
||||||
Loading…
Reference in New Issue
Block a user