Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91b7002061 | ||
|
|
04cac794ea | ||
|
|
65b5b45389 | ||
|
|
3baa8be47f | ||
|
|
c3b432252b | ||
|
|
9db4064a26 | ||
|
|
1a45ac5257 | ||
|
|
d1384e8603 | ||
|
|
3cc3a06b75 | ||
|
|
b595d003bd | ||
|
|
835b82cb79 | ||
|
|
b66bb92548 | ||
|
|
063b8bbaab | ||
|
|
e8b1a2e83f | ||
|
|
baff464dc0 | ||
|
|
e4cb33b35e | ||
|
|
53d9ad653a | ||
|
|
e33b2237a8 | ||
|
|
d7d146e586 | ||
|
|
74c135a823 | ||
|
|
36d2f5aac6 | ||
|
|
148e0a9391 | ||
|
|
8c374ea4e8 | ||
|
|
cc52595c0b | ||
|
|
adcb36059d | ||
|
|
9f25d37b6e | ||
|
|
60caaf1d29 | ||
|
|
f6a155718f | ||
|
|
d9b1edde96 | ||
|
|
59bbfe15d2 | ||
|
|
471eb4b638 | ||
|
|
74dfc03c6a | ||
|
|
6caa021824 | ||
|
|
f95223b88b |
@@ -22,3 +22,6 @@ credentials
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Local lockfile (not published)
|
||||||
|
uv.lock
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ TUI-приложение (Python 3.10+) для отображения потре
|
|||||||
- Детализация метрик конкретной модели (Enter в таблице моделей)
|
- Детализация метрик конкретной модели (Enter в таблице моделей)
|
||||||
- Офлайн-режим: при недоступности API показывает последние данные из кэша
|
- Офлайн-режим: при недоступности API показывает последние данные из кэша
|
||||||
- Автообновление по интервалу из конфигурации (по умолчанию 60 с)
|
- Автообновление по интервалу из конфигурации (по умолчанию 60 с)
|
||||||
|
- Полноэкранный индикатор загрузки: анимированная собачка (truecolor)
|
||||||
- Экспорт истории в CSV/JSON, диагностика окружения (`doctor`)
|
- Экспорт истории в CSV/JSON, диагностика окружения (`doctor`)
|
||||||
|
|
||||||
## Интерфейс
|
## Интерфейс
|
||||||
|
|||||||
@@ -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
@@ -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,3 +1,3 @@
|
|||||||
"""TUI-приложение для отображения потребления ИИ-роутера Selectel."""
|
"""TUI-приложение для отображения потребления ИИ-роутера Selectel."""
|
||||||
|
|
||||||
__version__ = "1.0.3"
|
__version__ = "1.0.4"
|
||||||
|
|||||||
@@ -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__(
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user