Arabesque: Rebecca, Adrift

This commit is contained in:
firestarsdog
2026-10-25 14:33:26 -04:00
parent d6fe10f60b
commit 0c842da32e
8 changed files with 467 additions and 5 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

@@ -4,6 +4,7 @@ import pyray as rl
from openpilot.selfdrive.ui.layouts.arabesque.theme import DEFAULT_THEME
from openpilot.selfdrive.ui.layouts.arabesque.tools.files import Files
from openpilot.selfdrive.ui.layouts.arabesque.tools.rebecca import Rebecca
from openpilot.selfdrive.ui.layouts.arabesque.tools.terminal import Terminal
from openpilot.system.ui.lib.application import gui_app, MouseEvent, MousePos, TextAlignment
from openpilot.system.ui.lib.multilang import tr
@@ -34,7 +35,7 @@ class ArabesqueLayout(Widget):
self._menu_button = rl.Rectangle(rect.x + 24 * s, rect.y + 8 * s, 88 * s, 88 * s)
self._exit_button = rl.Rectangle(rect.x + 24 * s, rect.y + 116 * s, 456 * s, 116 * s)
self._back_button = rl.Rectangle(rect.x + rect.width - 296 * s, rect.y + 8 * s, 272 * s, 88 * s)
self._dock = dock = rl.Rectangle(rect.x + (rect.width - 600 * s) / 2, rect.y + rect.height - 272 * s, 600 * s, 240 * s)
self._dock = dock = rl.Rectangle(rect.x + (rect.width - 840 * s) / 2, rect.y + rect.height - 272 * s, 840 * s, 240 * s)
self._dock_shelf = rl.Rectangle(dock.x, dock.y + 178 * s, dock.width, 10 * s)
self._dock_top = (rl.Vector2(dock.x + 36 * s, dock.y + 118 * s), rl.Vector2(dock.x, self._dock_shelf.y),
rl.Vector2(dock.x + dock.width, self._dock_shelf.y), rl.Vector2(dock.x + dock.width - 36 * s, dock.y + 118 * s))
@@ -43,6 +44,7 @@ class ArabesqueLayout(Widget):
self._launchers = (
(rl.Rectangle(dock.x + 60 * s, dock.y, 240 * s, 200 * s), "Terminal", lambda: Terminal()),
(rl.Rectangle(dock.x + 300 * s, dock.y, 240 * s, 200 * s), "Files", lambda: Files(self._permitted)),
(rl.Rectangle(dock.x + 540 * s, dock.y, 240 * s, 200 * s), "Rebecca", lambda: Rebecca()),
)
self._tool_rect = rl.Rectangle(rect.x, self._bar.y + self._bar.height, rect.width, rect.height - self._bar.height)
@@ -150,10 +152,16 @@ class ArabesqueLayout(Widget):
rl.draw_line_ex(rl.Vector2(icon.x - 32 * s, icon.y - 24 * s), rl.Vector2(icon.x - 8 * s, icon.y), 6 * s, tint)
rl.draw_line_ex(rl.Vector2(icon.x - 8 * s, icon.y), rl.Vector2(icon.x - 32 * s, icon.y + 24 * s), 6 * s, tint)
rl.draw_line_ex(rl.Vector2(icon.x + 8 * s, icon.y + 24 * s), rl.Vector2(icon.x + 36 * s, icon.y + 24 * s), 6 * s, tint)
else:
elif name == "Files":
rl.draw_rectangle_rounded(rl.Rectangle(icon.x - 32 * s, icon.y - 26 * s, 30 * s, 18 * s), 0.3, 8, tint)
rl.draw_rectangle_rounded(rl.Rectangle(icon.x - 36 * s, icon.y - 16 * s, 72 * s, 44 * s), 0.2, 8, tint)
rl.draw_rectangle_rounded(rl.Rectangle(icon.x - 29 * s, icon.y - 9 * s, 58 * s, 30 * s), 0.2, 8, theme.background)
else:
rl.draw_circle_v(rl.Vector2(icon.x - 19 * s, icon.y - 6 * s), 20 * s, tint)
rl.draw_circle_v(rl.Vector2(icon.x + 19 * s, icon.y - 6 * s), 20 * s, tint)
for offset in (-19, 19):
rl.draw_circle_v(rl.Vector2(icon.x + offset * s, icon.y - 8 * s), 11 * s, theme.background)
rl.draw_line_ex(rl.Vector2(icon.x - 8 * s, icon.y + 25 * s), rl.Vector2(icon.x + 8 * s, icon.y + 25 * s), 3 * s, tint)
gui_label(rl.Rectangle(icon.x - 96 * s, icon.y + 68 * s, 192 * s, 36 * s),
tr(name), round(28 * s), theme.text, theme.font_weight, alignment=TextAlignment.CENTER)
@@ -0,0 +1,209 @@
"""One frog, one button, bounded scenery; Arabesque owns the window and input."""
from importlib.resources import as_file
from pathlib import Path
import random
import pyray as rl
from openpilot.common.swaglog import cloudlog
from openpilot.selfdrive.ui.layouts.arabesque.theme import DEFAULT_THEME as THEME
from openpilot.system.ui.lib.application import ASSETS_DIR, gui_app, TextAlignment
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import Widget, device
from openpilot.system.ui.widgets.label import gui_label
_STEP = 1 / 60
_SCORE_FILE = Path(__file__).with_name('rebecca-score.txt')
_INK = rl.Color(118, 98, 139, 255)
_STONE = rl.Color(169, 139, 153, 255)
_CREAM = rl.Color(239, 221, 183, 255)
_CORAL = rl.Color(222, 145, 136, 255)
_FIELD = rl.Color(118, 98, 139, 70)
class Rebecca(Widget):
def __init__(self):
super().__init__()
self._sprite = None
self._sounds, self._owns_audio = {}, False
self._best, self._notice = 0, ''
self._pressed_button = False
self._rng = random.Random()
self._reset()
self._state = 'ready'
def _reset(self):
self._state = 'flying'
self._y, self._vy, self._vx = 208.0, -160.0, 150.0
self._distance = self._elapsed = self._accumulator = self._cooldown = 0.0
self._objects = []
self._next, self._encounter, self._corridor = 0.0, 0, 208.0
self._record = False
def show_event(self):
super().show_event()
self._sprite = gui_app.texture('arabesque/rebecca.png', 256, 256)
try:
self._best = max(0, int(_SCORE_FILE.read_text(encoding='ascii')))
except (FileNotFoundError, ValueError):
self._best = 0
except OSError:
self._notice = tr('Best score unavailable')
try:
self._owns_audio = not rl.is_audio_device_ready()
if self._owns_audio:
rl.init_audio_device()
if rl.is_audio_device_ready():
for name in ('flap', 'loss', 'best'):
with as_file(ASSETS_DIR.joinpath('arabesque', name + '.wav')) as path:
sound = rl.load_sound(str(path))
if sound.frameCount:
self._sounds[name] = sound
rl.set_sound_volume(sound, 0.35)
except (OSError, RuntimeError) as error:
cloudlog.warning(f'Rebecca audio unavailable: {error}')
def hide_event(self):
self._save_best()
for sound in self._sounds.values():
rl.stop_sound(sound)
rl.unload_sound(sound)
self._sounds.clear()
if self._owns_audio and rl.is_audio_device_ready():
rl.close_audio_device()
self._owns_audio = False
self._state, self._accumulator = 'ready', 0.0
super().hide_event()
def _play(self, name):
if name in self._sounds:
rl.play_sound(self._sounds[name])
def _save_best(self):
score = int(self._distance / 10)
if score > self._best:
try:
# shortcut: no power-loss durability; add fsync if records must survive abrupt power cuts.
temporary = _SCORE_FILE.with_suffix('.tmp')
temporary.write_text(f'{score}\n', encoding='ascii')
temporary.replace(_SCORE_FILE)
self._best, self._record = score, True
except OSError:
self._notice = tr('Best score could not be saved')
def _update_layout_rects(self):
rect = self._rect
self._scale = s = min(rect.width / 1080, rect.height / 488)
self._ox = rect.x + (rect.width - 1080 * s) / 2
self._oy = rect.y + (rect.height - 488 * s) / 2
self._button = rl.Rectangle(rect.x, rect.y + rect.height - 72 * s, rect.width, 72 * s)
def _handle_mouse_press(self, pos):
self._pressed_button = rl.check_collision_point_rec(pos, self._button)
if not self._pressed_button or self._cooldown:
return
if self._state != 'flying':
self._reset()
self._vy = -160.0
self._play('flap')
def _spawn(self):
self._corridor = max(156, min(260, self._corridor + self._rng.uniform(-60, 60)))
x, y = self._distance + 1140, self._corridor
kind = self._encounter % 3
if kind == 0 and len(self._objects) <= 4:
self._objects.extend((0, x, y + offset, self._rng.uniform(44, 54)) for offset in (-120, 120))
elif len(self._objects) < 6:
kind = 2 if kind == 2 and not any(o[0] == 2 for o in self._objects) else 1
self._objects.append((kind, x, self._rng.choice((76, 340)), 26))
self._encounter += 1
self._next = self._distance + 440
def _update_state(self):
if not self.enabled or not device.awake:
self._accumulator = 0.0
return
dt = max(0.0, min(rl.get_frame_time(), 4 * _STEP))
self._cooldown = max(0.0, self._cooldown - dt)
if self._state != 'flying':
return
self._accumulator += dt
while self._accumulator >= _STEP and self._state == 'flying':
self._accumulator -= _STEP
self._step()
def _step(self):
ax, ay = (min(200, 150 + self._distance / 1600) - self._vx) * 3, 330.0
for kind, x, y, _radius in self._objects:
dx, dy = x - self._distance - 230, y - self._y
d2 = dx * dx + dy * dy
if kind == 2 and d2 < 185 ** 2:
pull = min(4.5, 90000 / (d2 + 6400))
ax, ay = ax + dx * pull, ay + dy * pull
self._vx = max(90, min(240, self._vx + ax * _STEP))
self._vy = max(-240, min(260, self._vy + ay * _STEP))
self._distance += self._vx * _STEP
self._y += self._vy * _STEP
self._elapsed += _STEP
self._objects = [o for o in self._objects if o[1] - self._distance > -200]
if self._distance >= self._next:
self._spawn()
if not 30 < self._y < 386 or any(
(x - self._distance - 230) ** 2 + (y - self._y) ** 2 < (radius + 30) ** 2 for _, x, y, radius in self._objects
):
self._state, self._cooldown = 'lost', 0.4
self._save_best()
self._play('loss')
if self._record:
self._play('best')
def _label(self, x, y, width, height, text, size=18, color=_CREAM):
s = self._scale
gui_label(rl.Rectangle(self._ox + x * s, self._oy + y * s, width * s, height * s),
text, round(size * s), color)
def _render(self, rect):
s, ox, oy = self._scale, self._ox, self._oy
rl.draw_rectangle_rec(rect, THEME.background)
for i in range(32):
x = (i * 137.51 - self._distance * (0.12 + i % 3 * 0.08)) % 1080
y = 24 + i * 83 % 360
rl.draw_circle_v(rl.Vector2(ox + x * s, oy + y * s), (0.7 + i % 3 * 0.35) * s, _INK if i % 4 else _CREAM)
streak = self._elapsed % 11
if 0 < streak < 0.7:
x, y = ox + (1000 - streak * 400) * s, oy + (45 + streak * 110) * s
rl.draw_line_ex(rl.Vector2(x, y), rl.Vector2(x + 36 * s, y - 10 * s), s, _CREAM)
for kind, x, y, radius in self._objects:
center = rl.Vector2(ox + (x - self._distance) * s, oy + y * s)
r = radius * s
if kind == 0:
rl.draw_poly(center, 7, r, x % 360, _STONE)
rl.draw_circle_v(rl.Vector2(center.x - r * 0.25, center.y - r * 0.22), r * 0.23, _INK)
rl.draw_circle_v(rl.Vector2(center.x + r * 0.32, center.y + r * 0.3), r * 0.12, _INK)
elif kind == 1:
rl.draw_line_ex(rl.Vector2(center.x - r, center.y), rl.Vector2(center.x + r, center.y), 2 * s, _CREAM)
for offset in (-0.75, 0.75):
rl.draw_rectangle_rec(rl.Rectangle(center.x + offset * r - 8 * s, center.y - 13 * s, 16 * s, 26 * s), _INK)
rl.draw_poly(center, 4, 12 * s, 0, _CREAM)
else:
rl.draw_ring(center, 184 * s, 185 * s, 0, 360, 48, _FIELD)
rl.draw_ring(center, 32 * s, 34 * s, 0, 360, 32, _CORAL)
rl.draw_ring(center, 26 * s, 27 * s, 0, 360, 32, _CREAM)
rl.draw_circle_v(center, r, THEME.background)
if self._sprite is not None:
height = 96 * self._sprite.height / self._sprite.width
rl.draw_texture_pro(self._sprite, rl.Rectangle(0, 0, self._sprite.width, self._sprite.height),
rl.Rectangle(ox + 182 * s, oy + (self._y - height / 2) * s, 96 * s, height * s),
rl.Vector2(0, 0), 0, rl.WHITE)
self._label(24, 12, 400, 36, f'{int(self._distance / 10)} m', 24)
self._label(810, 12, 246, 36, tr('Best') + f' {max(self._best, int(self._distance / 10))} m', color=_INK)
if self._state != 'flying':
self._label(440, 142, 600, 64, tr('Rebecca, Adrift') if self._state == 'ready' else tr('Still unbothered.'), 36)
caption = tr('Tap to Float. Dodge everything.') if self._state == 'ready' else tr('New best!') if self._record else tr('Space is rude. Try again.')
self._label(444, 208, 580, 48, caption, color=THEME.secondary_text)
if self._notice:
self._label(24, 368, 1032, 40, self._notice, 16, _CORAL)
rl.draw_rectangle_rec(self._button, THEME.pressed if self._pressed_button and self.is_pressed and self.enabled else THEME.control)
label = {'ready': tr('Start floating'), 'flying': tr('Float'), 'lost': tr('Again')}[self._state]
gui_label(self._button, label, round(24 * s), THEME.background, alignment=TextAlignment.CENTER)
+36 -3
View File
@@ -110,8 +110,8 @@ def test_layout_anchors_and_touch_targets(shell, bounds):
assert dock.y + dock.height < rect.y + rect.height
assert dock.width >= 44 and dock.height >= 44
frame(shell)
captions = [call.args[0] for call in desktop.gui_label.call_args_list if call.args[1] in ('Terminal', 'Files')]
assert len(captions) == 2
captions = [call.args[0] for call in desktop.gui_label.call_args_list if call.args[1] in ('Terminal', 'Files', 'Rebecca')]
assert len(captions) == 3
for (target, _, _), caption in zip(shell._launchers, captions, strict=True):
icon_x, icon_y = target.x + target.width / 2, dock.y + 72 * shell._scale
assert caption.x + caption.width / 2 == pytest.approx(icon_x)
@@ -279,7 +279,7 @@ def test_files_has_its_own_launcher_and_blank_shelf_does_not_launch(shell, probe
shell.set_rect(rl.Rectangle(0, 0, 2160, 1080))
create = Mock(return_value=probe)
monkeypatch.setattr(desktop, 'Files', create)
terminal_target, files_target = (item[0] for item in shell._launchers)
terminal_target, files_target = (item[0] for item in shell._launchers[:2])
# A gesture crossing launcher boundaries must not start either tool.
frame(shell, event(terminal_target.x + 20, terminal_target.y + 20, pressed=True))
frame(shell, event(files_target.x + 20, files_target.y + 20, released=True))
@@ -296,6 +296,39 @@ def test_files_has_its_own_launcher_and_blank_shelf_does_not_launch(shell, probe
assert shell._active_tool is None
def test_rebecca_launcher_uses_shared_lifecycle(shell, probe, monkeypatch):
create = Mock(return_value=probe)
monkeypatch.setattr(desktop, 'Rebecca', create)
shell.set_rect(rl.Rectangle(0, 0, 2160, 1080))
tap(shell, shell._launchers[2][0])
assert shell._active_tool is probe and not gui_app.mouse_events
create.assert_called_once_with()
probe.show_event.assert_called_once()
frame(shell)
assert desktop.gui_label.call_args_list[-2].args[1] == 'Rebecca'
tap(shell, shell._back_button)
probe.hide_event.assert_called_once()
def test_rebecca_menu_pauses_and_dismissal_does_not_flap(shell, monkeypatch):
game = desktop.Rebecca()
monkeypatch.setattr(game, 'show_event', Mock())
monkeypatch.setattr(game, '_render', Mock())
monkeypatch.setattr(rl, 'get_frame_time', lambda: 1 / 60)
shell.set_rect(rl.Rectangle(0, 0, 2160, 1080))
shell._open_tool('Rebecca', game)
game._reset()
frame(shell)
tap(shell, shell._menu_button)
frozen = game._distance, game._y, game._vy
frame(shell)
tap(shell, game._button)
assert not shell._menu_open
assert (game._distance, game._y, game._vy) == frozen
frame(shell)
assert game._distance > frozen[0]
@pytest.fixture
def main(shell, monkeypatch):
ui = NS(started=False, is_body=False, add_on_body_changed_callbacks=Mock())
@@ -0,0 +1,212 @@
"""Gameplay, local score files and lifecycle checks without a window or audio device."""
from pathlib import Path
from types import SimpleNamespace as NS
from unittest.mock import Mock
import wave
import pyray as rl
import pytest
from openpilot.selfdrive.ui.layouts.arabesque.tools import rebecca
from openpilot.system.ui.lib.application import gui_app, MouseEvent, MousePos
@pytest.fixture
def game(monkeypatch, tmp_path):
import openpilot.system.ui.widgets as widgets
monkeypatch.setattr(widgets, 'device', NS(awake=True))
monkeypatch.setattr(rebecca, 'device', NS(awake=True))
monkeypatch.setattr(gui_app, '_mouse_events', [])
monkeypatch.setattr(gui_app, '_show_touches', False)
monkeypatch.setattr(gui_app, 'texture', Mock(return_value=NS(width=256, height=235)))
monkeypatch.setattr(rebecca, 'gui_label', Mock())
score_file = tmp_path / 'rebecca-score.txt'
score_file.write_text('12\n', encoding='ascii')
monkeypatch.setattr(rebecca, '_SCORE_FILE', score_file)
for name in ('init_audio_device', 'close_audio_device', 'load_sound', 'set_sound_volume', 'play_sound', 'stop_sound',
'unload_sound', 'draw_rectangle_rec', 'draw_circle_v', 'draw_poly', 'draw_ring', 'draw_line_ex', 'draw_texture_pro'):
monkeypatch.setattr(rl, name, Mock())
monkeypatch.setattr(rl, 'is_audio_device_ready', lambda: True)
monkeypatch.setattr(rl, 'get_frame_time', lambda: 1 / 60)
rl.load_sound.side_effect = lambda path: NS(frameCount=100, name=Path(path).stem)
view = rebecca.Rebecca()
view.set_rect(rl.Rectangle(0, 104, 2160, 976))
view.show_event()
yield view
view.hide_event()
def press(game, pos, slot=0):
gui_app._mouse_events = [MouseEvent(pos, slot, True, False, True, 1.0)]
game.render()
def test_only_primary_button_press_starts_and_flaps(game):
assert game._best == 12
press(game, MousePos(500, 400))
assert game._state == 'ready'
pos = MousePos(1000, 1000)
press(game, pos, slot=1)
assert game._state == 'ready'
press(game, pos)
assert game._state == 'flying' and game._vy == -160
game._vy = 100
press(game, pos)
assert game._vy == -160
assert rebecca._SCORE_FILE.read_text() == '12\n'
def test_fixed_steps_match_across_frame_rates_and_cap_stalls(game, monkeypatch):
states = []
for fps in (20, 60):
game._reset()
monkeypatch.setattr(rl, 'get_frame_time', lambda fps=fps: 1 / fps)
for _ in range(fps // 2):
game._update_state()
states.append((game._y, game._vy, game._distance))
assert states[0] == pytest.approx(states[1])
monkeypatch.setattr(rl, 'get_frame_time', lambda: 30)
game._reset()
game._update_state()
assert game._elapsed == pytest.approx(4 / 60)
def test_disabled_or_sleeping_simulation_does_not_catch_up(game, monkeypatch):
game._reset()
game._accumulator = 0.01
game.set_enabled(False)
game._update_state()
assert game._distance == 0 and game._accumulator == 0
game.set_enabled(True)
monkeypatch.setattr(rebecca.device, 'awake', False)
game._update_state()
assert game._distance == 0
monkeypatch.setattr(rebecca.device, 'awake', True)
game._update_state()
assert game._distance == pytest.approx(2.5)
@pytest.mark.parametrize('dx,dy', [(0, -100), (0, 100), (-100, 60), (100, 60)])
def test_black_holes_pull_in_both_axes(game, dx, dy):
game._reset()
game._vy = 0
game._next = float('inf')
game._objects = [(2, 230 + dx, 208 + dy, 26)]
game._step()
assert (game._vy - 330 / 60) * dy > 0
if dx:
assert (game._vx - 150) * dx > 0
def test_collision_freezes_scene_and_debounces_restart(game):
game._reset()
game._distance = 200
game._objects = [(0, 430, 208, 50)]
game._step()
assert game._state == 'lost'
frozen = game._distance, game._y
game._update_state()
press(game, MousePos(1000, 1000))
assert game._state == 'lost' and (game._distance, game._y) == frozen
for _ in range(25):
game._update_state()
press(game, MousePos(1000, 1000))
assert game._state == 'flying' and game._distance == 0
def test_scenery_is_bounded_and_old_objects_expire(game):
for _ in range(100):
game._spawn()
assert len(game._objects) <= 6
assert sum(o[0] == 2 for o in game._objects) <= 1
assert 156 <= game._corridor <= 260
game._objects = [(0, -300, 60, 50)]
game._next = float('inf')
game._step()
assert not game._objects
def test_only_new_records_are_saved_including_exit_mid_run(game):
game._distance = 150
game.hide_event()
assert rebecca._SCORE_FILE.read_text() == '15\n'
modified = rebecca._SCORE_FILE.stat().st_mtime_ns
game._distance = 100
game._save_best()
assert rebecca._SCORE_FILE.stat().st_mtime_ns == modified
assert game._best == 15
assert rl.unload_sound.call_count == 3
rl.close_audio_device.assert_not_called() # A preexisting device belongs to its original owner.
def test_storage_failure_preserves_previous_record_and_keeps_game_playable(game, monkeypatch):
monkeypatch.setattr(Path, 'replace', Mock(side_effect=OSError('unavailable')))
game._distance = 200
game._save_best()
assert game._notice and game._best == 12
assert rebecca._SCORE_FILE.read_text() == '12\n'
press(game, MousePos(1000, 1000))
assert game._state == 'flying'
def test_record_survives_reopening(game):
game._distance = 240
game.hide_event()
reopened = rebecca.Rebecca()
reopened.show_event()
assert reopened._best == 24
reopened.hide_event()
@pytest.mark.parametrize('contents', [None, '', 'not a score', '-50', '\N{SNOWMAN}', '9' * 5000])
def test_missing_or_invalid_score_defaults_to_zero(game, contents):
if contents is None:
rebecca._SCORE_FILE.unlink()
else:
rebecca._SCORE_FILE.write_text(contents, encoding='utf-8')
game.show_event()
assert game._best == 0
press(game, MousePos(1000, 1000))
assert game._state == 'flying'
def test_unreadable_score_keeps_game_playable(game, monkeypatch):
monkeypatch.setattr(Path, 'read_text', Mock(side_effect=OSError('unavailable')))
game.show_event()
assert game._notice
press(game, MousePos(1000, 1000))
assert game._state == 'flying'
def test_audio_unavailable_is_silent_and_owned_audio_is_released(game, monkeypatch):
game.hide_event()
monkeypatch.setattr(rl, 'is_audio_device_ready', lambda: False)
game.show_event()
assert not game._sounds
game._play('flap')
game.hide_event()
monkeypatch.setattr(rl, 'is_audio_device_ready', Mock(side_effect=[False, True, True]))
game.show_event()
game.hide_event()
rl.close_audio_device.assert_called_once()
@pytest.mark.parametrize('bounds', [(0, 104, 2160, 976), (45, 100, 1280, 620), (0, 104, 2560, 976)])
def test_full_width_button_and_world_fit(game, bounds):
game.set_rect(rl.Rectangle(*bounds))
rect, button, s = game.rect, game._button, game._scale
assert button.x == rect.x and button.width == rect.width
assert button.y + button.height == pytest.approx(rect.y + rect.height)
assert game._ox >= rect.x and game._ox + 1080 * s <= rect.x + rect.width + .01
assert game._oy >= rect.y and game._oy + 416 * s <= button.y + .01
assert button.height >= 44
def test_audio_assets_are_tiny_mono_pcm():
folder = Path(__file__).resolve().parents[2] / 'assets' / 'arabesque'
assert sum(path.stat().st_size for path in folder.glob('*.wav')) < 11 * 1024
for name in ('flap', 'loss', 'best'):
with wave.open(str(folder / (name + '.wav'))) as sound:
assert (sound.getnchannels(), sound.getsampwidth(), sound.getframerate()) == (1, 2, 16000)
assert sound.getnframes() <= 3200