34 Commits
Author SHA1 Message Date
andrew 91b7002061 Merge origin/main into main (reconcile unrelated histories, keep 48x32 dog + 50% overlay) 2026-09-10 16:05:41 +03:00
andrew 04cac794ea Dog loader: 48x32 detail and lighter overlay (50%) 2026-09-10 16:04:19 +03:00
andrew 65b5b45389 docs: smaller dog, no paw trail 2026-09-10 14:24:19 +03:00
andrew 3baa8be47f docs: smaller dog, no paw trail 2026-09-10 14:24:18 +03:00
andrew c3b432252b tests: 32x22 art 2026-09-10 14:24:18 +03:00
andrew 9db4064a26 Dog loader: drop paw trail 2026-09-10 14:24:17 +03:00
andrew 1a45ac5257 Halve dog art to 32x22 2026-09-10 14:24:17 +03:00
andrew d1384e8603 Dog loader: halve size (32x22) and drop the paw trail 2026-09-10 14:23:08 +03:00
andrew 3cc3a06b75 docs: GIF dog loader 2026-09-10 14:13:28 +03:00
andrew b595d003bd docs: GIF dog loader 2026-09-10 14:13:24 +03:00
andrew 835b82cb79 tests: dog_art frames + animation 2026-09-10 14:13:24 +03:00
andrew b66bb92548 Dog loader: truecolor GIF frames + paw trail 2026-09-10 14:13:23 +03:00
andrew 063b8bbaab Add embedded GIF dog animation frames 2026-09-10 14:13:23 +03:00
andrew e8b1a2e83f Dog loader: render real GIF frames as truecolor half-blocks
- add ui/dog_art.py: 12 frames from 236205.gif (64x42, 128-color
  palette, transparent background), zlib+base64 embedded
- DogLoader draws half-block truecolor cells over the dimmed dashboard,
  transparent background, paw trail 🐾, 40 ms/frame (GIF speed)
- full-screen overlay + input blocking preserved
- tests: frame data + animation; docs updated
2026-09-10 14:12:11 +03:00
andrew baff464dc0 docs: changelog for full-screen dog loader 2026-09-10 13:30:44 +03:00
andrew e4cb33b35e docs: full-screen loading indicator 2026-09-10 13:30:43 +03:00
andrew 53d9ad653a tests: full-screen coverage + input blocking 2026-09-10 13:30:42 +03:00
andrew e33b2237a8 Full-screen dog loader overlay: dim background, panel, input block 2026-09-10 13:30:41 +03:00
andrew d7d146e586 Full-screen dog loader overlay with dim background and input blocking
- DogLoader now covers the whole window (dock top + _loading layer),
  dims the dashboard behind it ($background 80%), draws a centered
  rounded panel with a running ASCII dog and a 🐾 paw trail
- block keyboard bindings (check_action) and drop focus while refreshing;
  restore focus to models table afterwards
- removed the docked progress line (covered by the overlay)
- docs updated; tests: full-screen coverage + input blocking
2026-09-10 13:30:00 +03:00
andrew 74c135a823 tests: assert built-in loading indicator stays off 2026-09-10 13:00:53 +03:00
andrew 36d2f5aac6 Fix: rename loading -> refreshing (avoid built-in dots indicator) 2026-09-10 13:00:52 +03:00
andrew 148e0a9391 Fix: use 'refreshing' reactive to avoid Textual built-in loading dots
Widget.loading is a Textual built-in reactive that swaps the widget for the
default dots spinner. Our dashboard used the same name, so setting it True
activated the built-in indicator (dots) instead of the dog loader. Renamed
to 'refreshing' and assert app.screen.loading stays False in tests.
2026-09-10 13:00:36 +03:00
andrew 8c374ea4e8 chore: close task
closes #1
2026-09-10 11:59:26 +03:00
andrew cc52595c0b docs: add loading indicator check to final checklist
Closes #1
2026-09-10 11:56:55 +03:00
andrew adcb36059d CHANGELOG: v1.0.4 dog loader
Closes #1
2026-09-10 11:55:51 +03:00
andrew 9f25d37b6e Add dog ASCII animation on statistics refresh (task #1)
- DogLoader widget: ASCII dog animating while data loads, shown as a
  centered overlay and a docked progress line on the dashboard
- shown on timer auto-refresh and manual refresh (r)
- bump version to 1.0.4; CHANGELOG + README updated
- tests: hidden when idle, shown during refresh, frame animation
2026-09-10 11:51:14 +03:00
andrew 60caaf1d29 Add DogLoader tests 2026-09-10 11:51:02 +03:00
andrew f6a155718f Add DogLoader animation on refresh (task #1) 2026-09-10 11:51:02 +03:00
andrew d9b1edde96 v1.0.4: bump version 2026-09-10 11:51:01 +03:00
andrew 59bbfe15d2 v1.0.4: bump version 2026-09-10 11:51:01 +03:00
andrew 471eb4b638 v1.0.4: document dog loader 2026-09-10 11:51:00 +03:00
andrew 74dfc03c6a v1.0.4: add dog loader entry 2026-09-10 11:50:59 +03:00
andrew 6caa021824 Fix issues found by CI on Gitea runner
- cli.py: remove redundant cast to str (mypy strict, click 8.5+ types prompt);
  annotate prompt result explicitly
- test_storage.py: use now-relative timestamps instead of hardcoded 2026-08
  dates so history-filter tests do not rot over time
- add Gitea Actions CI workflow (.gitea/workflows/ci.yml)
2026-09-03 12:03:14 +03:00
andrew f95223b88b Initial import: selectel-ml-tui TUI for Selectel AI-router token usage 2026-09-02 16:38:03 +03:00
11 changed files with 416 additions and 13 deletions
+3
View File
@@ -22,3 +22,6 @@ credentials
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Local lockfile (not published)
uv.lock
+22
View File
@@ -2,6 +2,27 @@
Все заметные изменения проекта `selectel-ml-tui`. Все заметные изменения проекта `selectel-ml-tui`.
## [1.0.4] — 2026-09-03
Анимация-«собачка» при обновлении статистики.
### Добавлено
- Экран **Dashboard**: полноэкранный индикатор загрузки — анимированная
**собачка из GIF** (12 кадров, 40 мс, 48×32), отрисованная truecolor
half-block'ами (▀) с прозрачным фоном поверх затемнённого дашборда
(`$background 50%`).
- На время загрузки ввод блокируется (горячие клавиши и фокус), после
завершения — возвращается.
- Показывается при автообновлении по таймеру и при ручном обновлении (`r`);
скрывается по завершении загрузки.
### Исправлено
- Индикатор больше не конфликтует со встроенным `Widget.loading` Textual
(ранее из-за одноимённого reactive показывались встроенные «точки»).
### Примечание
- Реализует задачу #1 («Новая загрузка обновления статистики»).
## [1.0.3] — 2026-08-19 ## [1.0.3] — 2026-08-19
Полноценный вывод данных из Gateway API ИИ-роутера (IAM). Полноценный вывод данных из Gateway API ИИ-роутера (IAM).
@@ -86,6 +107,7 @@
- ruff (flake8/isort/format), mypy strict — без ошибок. - ruff (flake8/isort/format), mypy strict — без ошибок.
- Подсказка при первом запуске без токена, FAQ и troubleshooting в README. - Подсказка при первом запуске без токена, FAQ и troubleshooting в README.
[1.0.4]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.4
[1.0.3]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.3 [1.0.3]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.3
[1.0.2]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.2 [1.0.2]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.2
[1.0.1]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.1 [1.0.1]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.1
+1
View File
@@ -12,6 +12,7 @@ TUI-приложение (Python 3.10+) для отображения потре
- Детализация метрик конкретной модели (Enter в таблице моделей) - Детализация метрик конкретной модели (Enter в таблице моделей)
- Офлайн-режим: при недоступности API показывает последние данные из кэша - Офлайн-режим: при недоступности API показывает последние данные из кэша
- Автообновление по интервалу из конфигурации (по умолчанию 60 с) - Автообновление по интервалу из конфигурации (по умолчанию 60 с)
- Полноэкранный индикатор загрузки: анимированная собачка (truecolor)
- Экспорт истории в CSV/JSON, диагностика окружения (`doctor`) - Экспорт истории в CSV/JSON, диагностика окружения (`doctor`)
## Интерфейс ## Интерфейс
+2 -1
View File
@@ -29,7 +29,8 @@
| 9 | Ресайз окна | интерфейс перерисовывается, нет обрезки | | 9 | Ресайз окна | интерфейс перерисовывается, нет обрезки |
| 10 | Клавиши `r/h/s/q`, `Esc` | навигация работает | | 10 | Клавиши `r/h/s/q`, `Esc` | навигация работает |
| 11 | Русский текст | корректное отображение (UTF-8) | | 11 | Русский текст | корректное отображение (UTF-8) |
| 12 | `doctor` | все `[OK]`, пути существуют | | 12 | Индикатор загрузки | при обновлении — анимация-собачка и строка прогресса |
| 13 | `doctor` | все `[OK]`, пути существуют |
## Отметка выполнения ## Отметка выполнения
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "selectel-ml-tui" name = "selectel-ml-tui"
version = "1.0.3" version = "1.0.4"
description = "TUI-приложение для отображения потребления ИИ-роутера Selectel" description = "TUI-приложение для отображения потребления ИИ-роутера Selectel"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
+1 -1
View File
@@ -1,3 +1,3 @@
"""TUI-приложение для отображения потребления ИИ-роутера Selectel.""" """TUI-приложение для отображения потребления ИИ-роутера Selectel."""
__version__ = "1.0.3" __version__ = "1.0.4"
+130 -4
View File
@@ -9,12 +9,16 @@ from __future__ import annotations
from typing import Any from typing import Any
from textual import work from rich.text import Text
from textual import on, work
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.binding import Binding from textual.binding import Binding
from textual.containers import Horizontal, Vertical from textual.containers import Horizontal, Vertical
from textual.css.query import NoMatches
from textual.events import InputEvent
from textual.reactive import reactive from textual.reactive import reactive
from textual.screen import Screen from textual.screen import Screen
from textual.timer import Timer
from textual.widgets import ( from textual.widgets import (
Button, Button,
DataTable, DataTable,
@@ -40,6 +44,7 @@ from selectel_ml_tui.models import (
) )
from selectel_ml_tui.services import DashboardData, DataService from selectel_ml_tui.services import DashboardData, DataService
from selectel_ml_tui.storage import Storage from selectel_ml_tui.storage import Storage
from selectel_ml_tui.ui import dog_art
PERIOD_OPTIONS = (7, 30, 90) PERIOD_OPTIONS = (7, 30, 90)
@@ -50,6 +55,89 @@ def _mask_token(value: str) -> str:
return f"{value[:4]}…({len(value)})" return f"{value[:4]}…({len(value)})"
class DogLoader(Static):
"""Полноэкранный индикатор загрузки: анимация собачки из GIF.
Рисуется truecolor half-block'ами (2 пикселя на знакоместо), фон
прозрачный — просвечивает затемнённый дашборд. Показывается на время
обновления и блокирует ввод.
"""
INTERVAL = dog_art.FRAME_MS / 1000
frame = reactive(0)
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._timer: Timer | None = None
self._frames: list[bytes] | None = None
@property
def running(self) -> bool:
"""Идёт ли анимация."""
return self._timer is not None
@property
def frame_count(self) -> int:
return dog_art.FRAME_COUNT
def start(self) -> None:
"""Запустить анимацию (идемпотентно)."""
if self._timer is None:
self._timer = self.set_interval(self.INTERVAL, self._next_frame)
def stop(self) -> None:
"""Остановить анимацию (идемпотентно)."""
if self._timer is not None:
self._timer.pause()
self._timer = None
def _next_frame(self) -> None:
self.frame = (self.frame + 1) % dog_art.FRAME_COUNT
def _get_frames(self) -> list[bytes]:
if self._frames is None:
self._frames = dog_art.decode_frames()
return self._frames
def render(self) -> Any:
frames = self._get_frames()
data = frames[self.frame % len(frames)]
width, px_height = dog_art.WIDTH, dog_art.PX_HEIGHT
palette = dog_art.PALETTE
text = Text(justify="center", no_wrap=True)
for y in range(0, px_height, 2):
for x in range(width):
top = data[y * width + x]
bottom = data[(y + 1) * width + x] if y + 1 < px_height else 0
if top == 0 and bottom == 0:
text.append(" ")
elif top == 0:
r, g, b = palette[bottom]
text.append("▄", style=f"#{r:02x}{g:02x}{b:02x}")
elif bottom == 0:
r, g, b = palette[top]
text.append("▀", style=f"#{r:02x}{g:02x}{b:02x}")
else:
rt, gt, bt = palette[top]
rb, gb, bb = palette[bottom]
text.append(
"▀",
style=f"#{rt:02x}{gt:02x}{bt:02x} on #{rb:02x}{gb:02x}{bb:02x}",
)
if y + 2 < px_height:
text.append("\n")
return text
@on(InputEvent)
def _block_input(self, event: InputEvent) -> None:
"""Перехватить ввод, пока показывается индикатор загрузки."""
if self.has_class("visible"):
event.stop()
event.prevent_default()
class ConsumptionBars(Static): class ConsumptionBars(Static):
"""Unicode-бар-график потребления (столбцы).""" """Unicode-бар-график потребления (столбцы)."""
@@ -87,7 +175,7 @@ class DashboardScreen(Screen[Any]):
] ]
data = reactive(DashboardData()) data = reactive(DashboardData())
loading = reactive(False) refreshing = reactive(False)
def __init__( def __init__(
self, self,
@@ -112,6 +200,7 @@ class DashboardScreen(Screen[Any]):
yield Static("", id="metrics-panel", classes="panel") yield Static("", id="metrics-panel", classes="panel")
yield Static("По моделям", id="models-title") yield Static("По моделям", id="models-title")
yield DataTable(id="models-table") yield DataTable(id="models-table")
yield DogLoader(id="dog-loader")
yield Footer() yield Footer()
def on_mount(self) -> None: def on_mount(self) -> None:
@@ -120,13 +209,37 @@ class DashboardScreen(Screen[Any]):
@work(exclusive=True, group="refresh") @work(exclusive=True, group="refresh")
async def _refresh(self) -> None: async def _refresh(self) -> None:
self.loading = True self.refreshing = True
try: try:
self.data = await self._service.refresh() self.data = await self._service.refresh()
except Exception as exc: # noqa: BLE001 - показываем toast при ошибке except Exception as exc: # noqa: BLE001 - показываем toast при ошибке
self.notify(f"Ошибка обновления: {exc}", severity="error") self.notify(f"Ошибка обновления: {exc}", severity="error")
finally: finally:
self.loading = False self.refreshing = False
def watch_refreshing(self, refreshing: bool) -> None:
"""Показать/скрыть полноэкранную собачку при обновлении."""
try:
loader = self.query_one("#dog-loader", DogLoader)
except NoMatches: # watcher может сработать до монтирования детей
return
if refreshing:
loader.add_class("visible")
loader.start()
self.set_focus(None)
else:
loader.remove_class("visible")
loader.stop()
try:
self.query_one("#models-table", DataTable).focus()
except NoMatches: # pragma: no cover - на всякий случай
pass
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
"""Заблокировать горячие клавиши на время обновления."""
if self.refreshing:
return False
return super().check_action(action, parameters)
def watch_data(self, data: DashboardData) -> None: def watch_data(self, data: DashboardData) -> None:
status = "offline" if data.offline else "online" status = "offline" if data.offline else "online"
@@ -550,6 +663,19 @@ class SelectelMLTUI(App[Any]):
padding: 1 2; padding: 1 2;
border: round $primary; border: round $primary;
} }
#dog-loader {
display: none;
dock: top;
layer: _loading;
width: 100%;
height: 100%;
background: $background 50%;
content-align: center middle;
text-align: center;
}
#dog-loader.visible {
display: block;
}
""" """
def __init__( def __init__(
+131
View File
@@ -0,0 +1,131 @@
"""Кадры анимации индикатора-собачки (авто-сгенерировано, не редактировать).
Источник: 236205.gif; квантование в палитру, фон прозрачный (индекс 0).
Кадр хранится как 48x32 индексов палитры (по строке).
"""
from __future__ import annotations
import base64
import zlib
WIDTH = 48
PX_HEIGHT = 32
FRAME_COUNT = 12
FRAME_MS = 40
# Индекс 0 — прозрачный фон; остальные — RGB кадров GIF.
PALETTE: tuple[tuple[int, int, int], ...] = (
(0,0,0), (30,24,46), (38,32,52), (40,33,52),
(40,34,53), (40,36,54), (120,0,12), (42,36,55),
(44,36,55), (44,39,56), (127,0,14), (43,41,60),
(47,41,58), (47,43,60), (50,43,59), (50,44,60),
(53,43,59), (85,26,45), (47,46,63), (51,45,60),
(50,45,61), (51,46,62), (52,46,61), (50,47,63),
(51,46,63), (52,46,62), (53,46,62), (52,47,62),
(53,47,62), (49,48,65), (52,47,63), (53,47,63),
(48,49,66), (52,47,64), (53,48,63), (54,47,63),
(53,47,64), (52,48,64), (55,47,63), (53,48,64),
(54,48,64), (53,48,65), (45,51,70), (54,49,64),
(54,48,65), (55,49,64), (54,49,65), (55,49,65),
(56,49,65), (55,50,66), (56,50,66), (56,51,67),
(54,51,69), (66,46,64), (50,52,75), (56,52,70),
(128,17,35), (113,25,47), (58,55,74), (59,55,74),
(56,56,77), (64,56,73), (56,61,91), (70,64,77),
(60,62,89), (62,63,87), (137,31,49), (73,69,82),
(61,69,101), (70,74,98), (92,76,90), (76,80,102),
(93,86,95), (98,94,103), (149,67,88), (178,50,78),
(105,103,108), (143,95,102), (113,111,118), (119,120,126),
(143,117,118), (137,128,127), (138,138,134), (226,69,121),
(174,132,129), (150,142,143), (226,88,128), (152,150,150),
(199,135,136), (159,159,153), (176,158,158), (181,171,160),
(202,162,150), (183,182,169), (211,174,164), (196,194,177),
(228,181,167), (231,191,171), (201,205,188), (222,201,176),
(246,192,173), (215,214,194), (250,197,176), (204,214,211),
(254,200,178), (254,204,182), (220,224,201), (249,212,186),
(232,229,188), (238,222,189), (230,230,208), (214,226,232),
(233,234,206), (238,237,203), (228,234,220), (230,237,219),
(242,243,206), (240,241,220), (253,252,200), (229,242,238),
(243,246,227), (243,251,223), (251,252,216), (253,253,223),
(255,255,229), (245,251,245), (251,253,242), (255,255,239),
)
_DATA_B64 = (
"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"
"sab/AIrxKUs="
)
def decode_frames() -> list[bytes]:
"""Вернуть список кадров: каждый — 48*32 байт индексов палитры."""
raw = zlib.decompress(base64.b64decode(_DATA_B64))
size = WIDTH * PX_HEIGHT
return [raw[i * size:(i + 1) * size] for i in range(FRAME_COUNT)]
+120 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import datetime as dt import datetime as dt
from decimal import Decimal from decimal import Decimal
@@ -19,7 +20,8 @@ from selectel_ml_tui.models import (
) )
from selectel_ml_tui.services import DashboardData from selectel_ml_tui.services import DashboardData
from selectel_ml_tui.storage import Storage from selectel_ml_tui.storage import Storage
from selectel_ml_tui.ui.app import SelectelMLTUI from selectel_ml_tui.ui import dog_art
from selectel_ml_tui.ui.app import DogLoader, SelectelMLTUI
class FakeService: class FakeService:
@@ -34,6 +36,20 @@ class FakeService:
return self.data return self.data
class BlockingService:
"""Сервис-заглушка: блокирует refresh до снятия события."""
def __init__(self, data: DashboardData, release: asyncio.Event) -> None:
self.data = data
self.release = release
self.started = asyncio.Event()
async def refresh(self) -> DashboardData:
self.started.set()
await self.release.wait()
return self.data
def _dashboard_data() -> DashboardData: def _dashboard_data() -> DashboardData:
return DashboardData( return DashboardData(
month_consumption=Consumption.model_validate( month_consumption=Consumption.model_validate(
@@ -294,3 +310,106 @@ async def test_keys_screen(tmp_path) -> None:
await pilot.press("escape") await pilot.press("escape")
await pilot.pause() await pilot.pause()
assert app.screen.__class__.__name__ == "DashboardScreen" assert app.screen.__class__.__name__ == "DashboardScreen"
async def test_dog_loader_hidden_when_idle(tmp_path) -> None:
app = _make_app(tmp_path)
async with app.run_test() as pilot:
await pilot.pause()
loader = app.screen.query_one("#dog-loader", DogLoader)
assert not loader.has_class("visible")
assert loader.display is False
assert not loader.running
# встроенный loading Textual не должен активироваться (иначе — точки)
assert app.screen.loading is False
async def test_dog_loader_shown_during_refresh(tmp_path) -> None:
release = asyncio.Event()
settings = Settings(refresh_interval=3600)
creds = Credentials(static_token="tok")
storage = Storage(tmp_path / "cache.db")
service = BlockingService(_dashboard_data(), release)
app = SelectelMLTUI(
settings=settings,
credentials=creds,
storage=storage,
service=service, # type: ignore[arg-type]
)
async with app.run_test() as pilot:
await service.started.wait()
await pilot.pause()
loader = app.screen.query_one("#dog-loader", DogLoader)
assert loader.has_class("visible")
assert loader.display is True
assert loader.running
assert app.screen.loading is False
# overlay покрывает весь экран
assert loader.region == app.screen.region
release.set()
await pilot.pause(0.1)
assert not loader.has_class("visible")
assert loader.display is False
assert not loader.running
async def test_input_blocked_during_refresh(tmp_path) -> None:
release = asyncio.Event()
settings = Settings(refresh_interval=3600)
creds = Credentials(static_token="tok")
storage = Storage(tmp_path / "cache.db")
service = BlockingService(_dashboard_data(), release)
app = SelectelMLTUI(
settings=settings,
credentials=creds,
storage=storage,
service=service, # type: ignore[arg-type]
)
async with app.run_test() as pilot:
await service.started.wait()
await pilot.pause()
# во время загрузки горячие клавиши заблокированы
await pilot.press("h")
await pilot.pause()
assert app.screen.__class__.__name__ == "DashboardScreen"
release.set()
await pilot.pause(0.1)
# после загрузки навигация снова работает
await pilot.press("h")
await pilot.pause()
assert app.screen.__class__.__name__ == "HistoryScreen"
async def test_dog_loader_animates(tmp_path) -> None:
app = _make_app(tmp_path)
async with app.run_test() as pilot:
await pilot.pause()
loader = app.screen.query_one("#dog-loader", DogLoader)
loader.add_class("visible")
loader.start()
assert loader.running
assert loader.frame_count == dog_art.FRAME_COUNT
first_frame = loader.frame
first = str(loader.render())
await pilot.pause(0.3)
assert loader.frame != first_frame
assert str(loader.render()) != first
loader.stop()
assert not loader.running
def test_dog_art_frames() -> None:
frames = dog_art.decode_frames()
assert len(frames) == dog_art.FRAME_COUNT
size = dog_art.WIDTH * dog_art.PX_HEIGHT
assert all(len(f) == size for f in frames)
# увеличенный размер и чётная высота для half-block
assert (dog_art.WIDTH, dog_art.PX_HEIGHT) == (48, 32)
assert dog_art.PX_HEIGHT % 2 == 0
# индексы палитры в допустимом диапазоне, фон = 0
assert all(b < len(dog_art.PALETTE) for f in frames for b in f)
assert dog_art.FRAME_MS == 40
# кадры различаются (анимация)
assert len({bytes(f) for f in frames}) > 1