chore: commit remaining maintenance and model changes
This commit is contained in:
@@ -48,7 +48,10 @@ complexity_rules:
|
||||
'1':
|
||||
required: []
|
||||
suggested:
|
||||
- ACTION
|
||||
- ATOM
|
||||
- BRIEF
|
||||
- STATE
|
||||
- PURPOSE
|
||||
- C
|
||||
- COMPLEXITY
|
||||
@@ -83,7 +86,10 @@ complexity_rules:
|
||||
'2':
|
||||
required: []
|
||||
suggested:
|
||||
- ACTION
|
||||
- ATOM
|
||||
- BRIEF
|
||||
- STATE
|
||||
- PURPOSE
|
||||
- C
|
||||
- COMPLEXITY
|
||||
@@ -118,7 +124,10 @@ complexity_rules:
|
||||
'3':
|
||||
required: []
|
||||
suggested:
|
||||
- ACTION
|
||||
- ATOM
|
||||
- BRIEF
|
||||
- STATE
|
||||
- PURPOSE
|
||||
- C
|
||||
- COMPLEXITY
|
||||
@@ -153,7 +162,10 @@ complexity_rules:
|
||||
'4':
|
||||
required: []
|
||||
suggested:
|
||||
- ACTION
|
||||
- ATOM
|
||||
- BRIEF
|
||||
- STATE
|
||||
- PURPOSE
|
||||
- C
|
||||
- COMPLEXITY
|
||||
@@ -188,7 +200,10 @@ complexity_rules:
|
||||
'5':
|
||||
required: []
|
||||
suggested:
|
||||
- ACTION
|
||||
- ATOM
|
||||
- BRIEF
|
||||
- STATE
|
||||
- PURPOSE
|
||||
- C
|
||||
- COMPLEXITY
|
||||
@@ -270,6 +285,22 @@ tags:
|
||||
protected: true
|
||||
orthogonal: false
|
||||
decision_memory: false
|
||||
ACTION:
|
||||
type: string
|
||||
multiline: true
|
||||
description: 'Действие модели (model action). Документирует публичный метод модели, изменяющий состояние. Svelte 5 Model тег.'
|
||||
contract_types: []
|
||||
protected: false
|
||||
orthogonal: false
|
||||
decision_memory: false
|
||||
ATOM:
|
||||
type: string
|
||||
multiline: false
|
||||
description: 'Атом состояния модели. Документирует атомарное поле $state. Svelte 5 Model тег.'
|
||||
contract_types: []
|
||||
protected: false
|
||||
orthogonal: false
|
||||
decision_memory: false
|
||||
BRIEF:
|
||||
type: string
|
||||
multiline: true
|
||||
@@ -352,6 +383,14 @@ tags:
|
||||
protected: false
|
||||
orthogonal: false
|
||||
decision_memory: false
|
||||
STATE:
|
||||
type: string
|
||||
multiline: true
|
||||
description: 'Состояние конечного автомата UX. Документирует возможные состояния экрана. Svelte 5 Model тег.'
|
||||
contract_types: []
|
||||
protected: false
|
||||
orthogonal: false
|
||||
decision_memory: false
|
||||
STATUS:
|
||||
type: string
|
||||
multiline: false
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
# @DATA_CONTRACT Input -> OAuth2PasswordRequestForm -> Token, User
|
||||
# @INVARIANT All auth endpoints must return consistent error codes.
|
||||
# @RELATION DEPENDS_ON -> [is_adfs_configured]
|
||||
# @REVIEWED 2026-06-03
|
||||
# @TESTED true
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
# @TEST_CONTRACT: POST /api/maintenance/{id}/end -> 202 {task_id, status}
|
||||
# @TEST_CONTRACT: POST /api/maintenance/end-all -> 202 {task_id, status}
|
||||
# @TEST_EDGE: missing_tables -> 422 validation error
|
||||
# @TEST_EDGE: missing_environment_id -> 422 validation error
|
||||
# @TEST_EDGE: end_time_before_start_time -> 400 validation error
|
||||
# @TEST_EDGE: non_existent_event_end -> 404 not found
|
||||
from datetime import UTC, datetime, timedelta
|
||||
@@ -185,6 +186,27 @@ class TestStartEndpoint:
|
||||
assert response.status_code == 422
|
||||
# #endregion test_start_missing_tables
|
||||
|
||||
# #region test_start_missing_environment_id [C:2] [TYPE Function]
|
||||
# @BRIEF Missing environment_id field returns 422.
|
||||
# @TEST_EDGE: missing_environment_id -> 422 validation error
|
||||
def test_start_missing_environment_id(self, client, mock_db, mock_task_manager):
|
||||
response = client.post(
|
||||
"/api/maintenance/start",
|
||||
json={
|
||||
"tables": ["raw.sales"],
|
||||
"start_time": (datetime.now(UTC) + timedelta(hours=1)).isoformat(),
|
||||
"end_time": (datetime.now(UTC) + timedelta(hours=3)).isoformat(),
|
||||
"message": "Test maintenance",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
data = response.json()
|
||||
assert any(
|
||||
"environment_id" in str(err.get("loc", []))
|
||||
for err in (data.get("detail", []) if isinstance(data.get("detail"), list) else [])
|
||||
)
|
||||
# #endregion test_start_missing_environment_id
|
||||
|
||||
# #region test_start_end_time_before_start [C:2] [TYPE Function]
|
||||
# @BRIEF end_time before start_time returns 400.
|
||||
def test_start_end_time_before_start(self, client, mock_db, mock_task_manager):
|
||||
|
||||
@@ -1,508 +0,0 @@
|
||||
# Анализ производительности перевода: причины медлительности и план доработок
|
||||
|
||||
**Дата:** 2026-06-03 (v2 — после code review)
|
||||
**Автор:** fullstack-coder (ss-tools) + рецензент
|
||||
**Контекст:** Пользователь сообщил "Очень долго стартует перевод". По логам trace_id `8bd7ac8f` (run `4c9de39e`) проведён анализ.
|
||||
|
||||
---
|
||||
|
||||
## 1. Исходные данные
|
||||
|
||||
**Объём:** 5455 строк из Superset datasource (dataset 906, таблица `userdata.debt_comment_translations`)
|
||||
**Модель:** `qwen-flash` через `lite.ai.rusal.com/v1` (provider_type=litellm, response_format=yes)
|
||||
**Режим:** `full=False` (только новые записи, без перезаписи существующих)
|
||||
**Батчей сформировано:** 203
|
||||
|
||||
---
|
||||
|
||||
## 2. Таймлайн одного прогона (из логов)
|
||||
|
||||
| Время | Событие | Длительность | Симптом |
|
||||
|-------|---------|--------------|---------|
|
||||
| `14:34:39` | Run стартовал | — | |
|
||||
| `14:34:40` | Данные загружены (5455 строк) | ~1s | ✅ |
|
||||
| `14:34:40` | "Processing 203 batches" | — | |
|
||||
| `14:34:40.430` | **LLM request:** prompt_len=145062 | **~1m47s** | ⚠️ |
|
||||
| `14:36:27` | `finish_reason=length` — ответ обрезан | | ❌ |
|
||||
| `14:36:27` | Splitting → 2 батча | | |
|
||||
| `14:36:27` | prompt_len=101330 | **~1m40s** | ⚠️ |
|
||||
| `14:38:06` | `finish_reason=length` | | ❌ |
|
||||
| `14:38:06` | Splitting → ещё 2 батча | | |
|
||||
| `14:38:06` | prompt_len=25826 | **~40s** | ✅ stop |
|
||||
| `14:38:47` | prompt_len=76479 | **~1m39s** | ⚠️ |
|
||||
| `14:40:26` | `finish_reason=length` | | ❌ |
|
||||
| ... | каскад продолжается | | |
|
||||
|
||||
**Оценка общего времени:** >10-15 минут на 5455 строк.
|
||||
|
||||
---
|
||||
|
||||
## 3. ⚠️ Важное ограничение анализа: prompt_len — это символы или токены?
|
||||
|
||||
**В логах нет прямого указания, что `prompt_len=145062` — токены.** Формат логирования (`prompt_len=145062`) без указания единиц измерения не позволяет утверждать, что это именно токены. Это могут быть символы.
|
||||
|
||||
**До любых правок требуется:**
|
||||
|
||||
Для 10-20 реальных батчей залогировать:
|
||||
|
||||
| Поле | Источник | Зачем |
|
||||
|------|----------|-------|
|
||||
| `chars` | `len(prompt)` | Длина в символах |
|
||||
| `estimated_input_tokens` | `estimate_token_budget()` | Текущая оценка |
|
||||
| `provider_prompt_tokens` | `response.usage.prompt_tokens` | Реальные токены входа |
|
||||
| `provider_completion_tokens` | `response.usage.completion_tokens` | Реальные токены выхода |
|
||||
| `provider_total_tokens` | `response.usage.total_tokens` | Сумма |
|
||||
| `max_tokens` | Параметр запроса | Сколько просили |
|
||||
| `context_window_resolved` | Что использовали как контекст | 64000 или другое |
|
||||
| `max_output_tokens_resolved` | Что использовали как лимит выхода | |
|
||||
| `rows_in_batch` | `len(batch_rows)` | |
|
||||
| `target_languages_count` | `len(target_languages)` | |
|
||||
| `finish_reason` | Из ответа API | stop / length / error |
|
||||
| `response_rows_recovered` | Сколько строк распарсили | Для recovery |
|
||||
|
||||
**Вывод:** Все гипотезы ниже основаны на косвенных признаках. Без логов usage токенов от провайдера (response.usage) некоторые причины остаются недоказанными. Добавление этих логов — **P0, первый шаг**.
|
||||
|
||||
---
|
||||
|
||||
## 4. Первопричины (по степени вероятности)
|
||||
|
||||
### 4.1. Batch sizing недооценивает output budget (основная гипотеза)
|
||||
|
||||
`finish_reason=length` с вероятностью >90% означает не "вход не влез во входной контекст", а **"модель упёрлась в max_tokens при генерации ответа"**.
|
||||
|
||||
Каждый батч содержит N строк. Для каждой строки модель должна вернуть JSON с переводами на каждый из target_languages. Если target_languages_count > 1, то **выход растёт линейно**, а batch sizing учитывает это только грубой оценкой.
|
||||
|
||||
**Файл:** `backend/src/plugins/translate/_token_budget.py`
|
||||
|
||||
Текущие константы для оценки выхода:
|
||||
|
||||
```python
|
||||
OUTPUT_PER_ROW_PER_LANG = 120 # токенов на строку перевода на один язык
|
||||
JSON_OVERHEAD_PER_ROW = 50 # JSON-обвязка на строку
|
||||
REASONING_OVERHEAD = 2000 # CoT overhead
|
||||
MAX_OUTPUT_HEADROOM = 3000 # запас
|
||||
```
|
||||
|
||||
Для 128 строк × 2 языка:
|
||||
```
|
||||
нужно = 128 × 2 × 120 + 128 × 50 + 2000 + 3000 = 40560 токенов
|
||||
```
|
||||
|
||||
Если `max_output_tokens = 16384` (default), то батч гарантированно обрежется.
|
||||
И в логе мы видим `finish_reason=length` на батчах > 50-60 строк.
|
||||
|
||||
**Следствие:** Проблема не (только) в CJK-токенизации, а в том, что **батч-сайзер упаковывает слишком много строк относительно output лимита**.
|
||||
|
||||
### 4.2. CJK-оценка токенов входа — дополнительный фактор
|
||||
|
||||
**Файл:** `backend/src/plugins/translate/_token_budget.py:89-108`
|
||||
|
||||
```python
|
||||
cjk_tokens = cjk_count / 1.5 # 1.5 chars/token
|
||||
other_tokens = other_count / 2.2 # 2.2 chars/token
|
||||
```
|
||||
|
||||
Если `prompt_len` в логах — символы, а не токены, то при 60% CJK-символов:
|
||||
- Оценка: 145062 / 1.5 ≈ 96708 токенов
|
||||
- Реальность (Qwen): может быть ~120000+ токенов
|
||||
|
||||
То есть вход недооценивается на 20-30%, и "съедает" часть output budget.
|
||||
|
||||
**Вывод:** CJK-оценка — вторичный фактор. Первичный — output budget.
|
||||
|
||||
### 4.3. PROVIDER_DEFAULTS не содержит модели qwen-flash
|
||||
|
||||
**Файл:** `backend/src/plugins/translate/_token_budget.py:32-39`
|
||||
|
||||
```python
|
||||
PROVIDER_DEFAULTS = {
|
||||
"gpt-4o-mini": {"context_window": 128000, "max_output_tokens": 16384},
|
||||
"gpt-4o": {"context_window": 128000, "max_output_tokens": 16384},
|
||||
"o1-mini": {"context_window": 128000, "max_output_tokens": 65536},
|
||||
"claude-3-5-sonnet": {"context_window": 200000, "max_output_tokens": 8192},
|
||||
"deepseek-v4-flash": {"context_window": 64000, "max_output_tokens": 8192},
|
||||
"default": {"context_window": 64000, "max_output_tokens": 16384},
|
||||
}
|
||||
```
|
||||
|
||||
Когда модель не найдена:
|
||||
- `context_window = 64000` (default)
|
||||
- `max_output_tokens = 16384` (default)
|
||||
- `available_input_budget = 64000 - 16384 = 47616`
|
||||
|
||||
Если `qwen-flash` на самом деле поддерживает 128K контекст и 8K вывод — бюджет по входу может быть недооценён, а бюджет по выходу переоценён.
|
||||
|
||||
### 4.4. Каскад finish_reason=length умножает проблему
|
||||
|
||||
**Файл:** `backend/src/plugins/translate/_llm_call.py:85-96, 190-233`
|
||||
|
||||
```python
|
||||
if finish_reason == "length" and len(batch_rows) >= 2:
|
||||
if _recursion_depth < MAX_RETRIES_PER_BATCH: # = 3
|
||||
return self._split_and_retry(...) # binary split
|
||||
|
||||
def _split_and_retry(self, ...):
|
||||
mid = len(batch_rows) // 2
|
||||
left = self.call_llm_for_batch(..., rows[:mid], depth + 1)
|
||||
right = self.call_llm_for_batch(..., rows[mid:], depth + 1)
|
||||
```
|
||||
|
||||
**Проблема:** Бинарное деление **не спасает частичный результат**. Даже если модель вернула 80 из 100 строк валидного JSON — они теряются, и обе половины перезапрашиваются с нуля.
|
||||
|
||||
Если truncation случается на 3+ уровнях рекурсии — 1 батч превращается в 7+ LLM-вызовов.
|
||||
|
||||
---
|
||||
|
||||
## 5. План доработок
|
||||
|
||||
### 5.0. [P0] Измерить → потом править
|
||||
|
||||
Без реальных цифр любое изменение — гадание.
|
||||
|
||||
**Добавить в `_llm_http.py` сбор usage от провайдера и логирование:**
|
||||
|
||||
```python
|
||||
# После ответа API:
|
||||
usage = response.get("usage", {})
|
||||
log("llm_http", "REFLECT", "LLM usage stats", {
|
||||
"prompt_tokens": usage.get("prompt_tokens"),
|
||||
"completion_tokens": usage.get("completion_tokens"),
|
||||
"total_tokens": usage.get("total_tokens"),
|
||||
"finish_reason": finish_reason,
|
||||
"max_tokens": max_tokens,
|
||||
"rows": len(batch_rows),
|
||||
"chars": len(prompt),
|
||||
})
|
||||
```
|
||||
|
||||
Для 10-20 реальных батчей собрать статистику и **только после этого** принимать решения о коэффициентах.
|
||||
|
||||
### 5.1. [P0] Учитывать output budget при расчёте размера батча
|
||||
|
||||
**Проблема:** Сейчас output budget учитывается, но недостаточно жёстко.
|
||||
**Файл:** `backend/src/plugins/translate/_token_budget.py:160-176`
|
||||
|
||||
```python
|
||||
def _apply_output_aware_batch_sizing(safe_size, num_languages, max_output_tokens):
|
||||
while safe_size > 0:
|
||||
needed_output = (
|
||||
safe_size * num_languages * OUTPUT_PER_ROW_PER_LANG
|
||||
+ safe_size * JSON_OVERHEAD_PER_ROW
|
||||
+ REASONING_OVERHEAD + MAX_OUTPUT_HEADROOM
|
||||
)
|
||||
if needed_output <= max_output_tokens:
|
||||
break
|
||||
safe_size -= 1
|
||||
return safe_size
|
||||
```
|
||||
|
||||
**Улучшение:** Сделать output budget **первичным** ограничителем, а input budget — вторичным:
|
||||
|
||||
```python
|
||||
def _compute_max_rows_by_output(max_output_tokens, num_languages):
|
||||
"""Сколько строк влезет в max_output_tokens."""
|
||||
overhead = REASONING_OVERHEAD + MAX_OUTPUT_HEADROOM
|
||||
per_row = num_languages * OUTPUT_PER_ROW_PER_LANG + JSON_OVERHEAD_PER_ROW
|
||||
if per_row <= 0:
|
||||
return 20
|
||||
available = max_output_tokens - overhead
|
||||
if available <= 0:
|
||||
return 1
|
||||
return max(available // per_row, 1)
|
||||
```
|
||||
|
||||
И в `_batch_sizer.py:auto_size_batches()`:
|
||||
|
||||
```python
|
||||
max_rows_by_output = _compute_max_rows_by_output(max_output_tokens_val, num_languages)
|
||||
|
||||
# Брать минимум из всех ограничений:
|
||||
max_rows = min(
|
||||
max_rows_by_input_budget,
|
||||
max_rows_by_output,
|
||||
absolute_hard_cap, # safety net
|
||||
job.batch_size or inf, # user preference
|
||||
)
|
||||
```
|
||||
|
||||
### 5.2. [P0] Вынести context_window / max_output_tokens в настройки провайдера
|
||||
|
||||
#### 5.2.1. Модель БД
|
||||
|
||||
**Файл:** `backend/src/models/llm.py`
|
||||
|
||||
```python
|
||||
class LLMProvider(Base):
|
||||
# ... существующие поля ...
|
||||
context_window = Column(
|
||||
Integer, nullable=True, default=None,
|
||||
comment="Total context window in tokens. NULL = fallback to PROVIDER_DEFAULTS",
|
||||
)
|
||||
max_output_tokens = Column(
|
||||
Integer, nullable=True, default=None,
|
||||
comment="Max output tokens. NULL = fallback to PROVIDER_DEFAULTS",
|
||||
)
|
||||
```
|
||||
|
||||
Nullable → обратная совместимость.
|
||||
|
||||
#### 5.2.2. Safe cap
|
||||
|
||||
Даже если пользователь ввёл значения — применяется верхняя граница:
|
||||
|
||||
```python
|
||||
PROVIDER_SAFE_CAP = 256000 # абсолютный максимум
|
||||
|
||||
effective_context_window = min(
|
||||
provider.context_window or PROVIDER_DEFAULTS.get(model, default)["context_window"],
|
||||
PROVIDER_SAFE_CAP,
|
||||
)
|
||||
effective_max_output_tokens = min(
|
||||
provider.max_output_tokens or PROVIDER_DEFAULTS.get(model, default)["max_output_tokens"],
|
||||
effective_context_window, # не может быть больше контекста
|
||||
)
|
||||
```
|
||||
|
||||
#### 5.2.3. Pydantic схема
|
||||
|
||||
**Файл:** `backend/src/plugins/llm_analysis/models.py`
|
||||
|
||||
```python
|
||||
class LLMProviderConfig(BaseModel):
|
||||
# ... существующие поля ...
|
||||
context_window: int | None = Field(
|
||||
None, ge=1000, le=256000,
|
||||
description="Context window in tokens. Leave blank for auto.",
|
||||
)
|
||||
max_output_tokens: int | None = Field(
|
||||
None, ge=256,
|
||||
description="Max output tokens. Must be less than context_window.",
|
||||
)
|
||||
```
|
||||
|
||||
#### 5.2.4. Сервисный слой
|
||||
|
||||
**Файл:** `backend/src/services/llm_provider.py`
|
||||
|
||||
```python
|
||||
# create_provider
|
||||
db_provider = LLMProvider(
|
||||
...
|
||||
context_window=config.context_window,
|
||||
max_output_tokens=config.max_output_tokens,
|
||||
)
|
||||
|
||||
# update_provider
|
||||
db_provider.context_window = config.context_window
|
||||
db_provider.max_output_tokens = config.max_output_tokens
|
||||
|
||||
# Новый хелпер для batch sizing:
|
||||
def get_provider_token_config(self, provider_id: str) -> dict:
|
||||
provider = self.get_provider(provider_id)
|
||||
if not provider:
|
||||
return {"model": None, "context_window": None, "max_output_tokens": None}
|
||||
return {
|
||||
"model": provider.default_model or "gpt-4o-mini",
|
||||
"context_window": provider.context_window,
|
||||
"max_output_tokens": provider.max_output_tokens,
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.2.5. Интеграция в batch sizing
|
||||
|
||||
**Файл:** `backend/src/plugins/translate/_batch_proc.py:208-247`
|
||||
**Файл:** `backend/src/plugins/translate/_batch_sizer.py:70-218`
|
||||
|
||||
В обоих местах заменить:
|
||||
```python
|
||||
# Было:
|
||||
provider_info = resolve_provider_model(job)
|
||||
estimate_token_budget(provider_info=provider_info)
|
||||
|
||||
# Стало:
|
||||
config = LLMProviderService(db).get_provider_token_config(job.provider_id)
|
||||
estimate_token_budget(
|
||||
provider_info=config["model"],
|
||||
context_window=config["context_window"], # приоритет над provider_info
|
||||
max_output_tokens=config["max_output_tokens"], # приоритет над provider_info
|
||||
)
|
||||
```
|
||||
|
||||
#### 5.2.6. PROVIDER_DEFAULTS — остаётся fallback
|
||||
|
||||
```python
|
||||
def estimate_token_budget(..., context_window=None, max_output_tokens=None, provider_info=None):
|
||||
# Если явно переданы — используем их
|
||||
# Если оба None — смотрим PROVIDER_DEFAULTS
|
||||
# Если и там нет — DEFAULT_...
|
||||
```
|
||||
|
||||
#### 5.2.7. Svelte UI
|
||||
|
||||
**Файл:** `frontend/src/lib/components/llm/ProviderConfig.svelte`
|
||||
|
||||
- Collapsible "Advanced: Token Limits"
|
||||
- Два number input: context_window, max_output_tokens
|
||||
- Placeholder: "Auto-detected. Override only if you know the provider's real limits."
|
||||
- Валидация на клиенте
|
||||
|
||||
#### 5.2.8. Alembic миграция
|
||||
|
||||
Новая миграция: add columns `context_window`, `max_output_tokens` to `llm_providers`.
|
||||
|
||||
### 5.3. [P0] Консервативный tokenizer estimate + единый safety factor
|
||||
|
||||
**Файл:** `backend/src/plugins/translate/_token_budget.py`
|
||||
|
||||
```python
|
||||
# Поправить коэффициенты (разумные значения, точные — после замера):
|
||||
CJK_RATIO = 1.0 # было 1.5
|
||||
OTHER_RATIO = 1.8 # было 2.2
|
||||
|
||||
# Единый safety factor (один, не размазанный):
|
||||
INPUT_SAFETY_FACTOR = 0.75 # 75% от расчётного бюджета
|
||||
OUTPUT_SAFETY_FACTOR = 0.70 # 70% от расчётного output-бюджета
|
||||
```
|
||||
|
||||
**Важно:** Эти цифры — стартовые. После сбора `usage.prompt_tokens` / `usage.completion_tokens` их надо откалибровать по реальным данным.
|
||||
|
||||
### 5.4. [P1] Retry only missing rows после partial response
|
||||
|
||||
**Текущий код:** `backend/src/plugins/translate/_llm_call.py:190-233` — binary split, теряет все уже переведённые строки.
|
||||
|
||||
**Улучшение:** При `finish_reason=length`:
|
||||
1. Попытаться распарсить ответ (`_recover_truncated_rows` в `_llm_parse.py:95-115`)
|
||||
2. Сохранить успешно переведённые строки
|
||||
3. Ретраить **только** те строки, которых не хватает
|
||||
|
||||
```python
|
||||
if finish_reason == "length":
|
||||
recovered = _recover_truncated_rows(llm_response, len(batch_rows), finish_reason)
|
||||
saved_rows = []
|
||||
missing_rows = []
|
||||
if recovered and recovered.get("rows"):
|
||||
# Распределить: какие строки удалось перевести, какие — нет
|
||||
parsed_ids = set(r.get("row_id") for r in recovered["rows"])
|
||||
for row in batch_rows:
|
||||
if str(row.get("row_index")) in parsed_ids:
|
||||
saved_rows.append(row)
|
||||
else:
|
||||
missing_rows.append(row)
|
||||
|
||||
if missing_rows and len(missing_rows) < len(batch_rows) * 0.95:
|
||||
# Есть существенный прогресс → ретраим только missing
|
||||
self._persist_partial(batch_rows, saved_rows, batch_id, run_id, ...)
|
||||
return self._retry_missing(job, run_id, missing_rows, dict_matches, ...)
|
||||
else:
|
||||
# Прогресса нет → binary split
|
||||
return self._split_and_retry(...)
|
||||
```
|
||||
|
||||
**Эффект:** Если из 100 строк вернулось 80 — ретраим только 20, а не 100.
|
||||
|
||||
### 5.5. [P1] Dynamic row cap (вместо фиксированного 50)
|
||||
|
||||
**Файл:** `backend/src/plugins/translate/_batch_sizer.py:148-166`
|
||||
|
||||
```python
|
||||
# Вычислить max_rows по output:
|
||||
output_per_row = num_languages * OUTPUT_PER_ROW_PER_LANG + JSON_OVERHEAD_PER_ROW
|
||||
available_output = max_output_tokens - REASONING_OVERHEAD - MAX_OUTPUT_HEADROOM
|
||||
max_rows_by_output = max(available_output // output_per_row, 1) if output_per_row > 0 else 20
|
||||
|
||||
# Вычислить max_rows по input:
|
||||
max_rows_by_input = per_batch_budget // average_row_tokens
|
||||
|
||||
# Итоговый лимит:
|
||||
ABSOLUTE_HARD_CAP = 50 # safety net, не основное ограничение
|
||||
max_rows = min(max_rows_by_output, max_rows_by_input, ABSOLUTE_HARD_CAP)
|
||||
```
|
||||
|
||||
### 5.6. [P2] Self-calibration per run
|
||||
|
||||
После первого `finish_reason=length` в рамках одного run_id:
|
||||
- Посчитать реальное `actual_ratio = actual_tokens / estimated_tokens`
|
||||
- Склировать batch sizing для следующих батчей
|
||||
- Сбросить при новом run_id
|
||||
|
||||
---
|
||||
|
||||
## 6. Итоговые приоритеты
|
||||
|
||||
| # | Что | Файлы | Почему |
|
||||
|---|-----|-------|--------|
|
||||
| **P0** | Добавить usage-логи от провайдера | `_llm_http.py`, `_llm_call.py` | Без данных нельзя обосновать изменения |
|
||||
| **P0** | Output budget как первичный ограничитель | `_token_budget.py`, `_batch_sizer.py` | `finish_reason=length` — это чаще про выход, а не про вход |
|
||||
| **P0** | Консервативный tokenizer + safety factor | `_token_budget.py` | Быстро снижает truncation |
|
||||
| **P0** | Provider-level context_window / max_output_tokens | model + schema + service + routes + UI + migration | Нужно для неизвестных моделей |
|
||||
| **P1** | Retry only missing rows после truncation | `_llm_call.py`, `_llm_parse.py` | Сохраняет частичный результат |
|
||||
| **P1** | Dynamic row cap (output-aware) | `_batch_sizer.py` | Точнее, чем фиксированные 50 строк |
|
||||
| **P2** | Self-calibration per run/provider | `_batch_sizer.py`, `_llm_call.py` | Адаптация под модель |
|
||||
|
||||
---
|
||||
|
||||
## 7. Метрики успеха
|
||||
|
||||
После внедрения:
|
||||
|
||||
| Метрика | Цель | Как измерить |
|
||||
|---------|------|-------------|
|
||||
| `finish_reason=length` | < 1% LLM вызовов | Из логов |
|
||||
| Среднее число LLM вызовов на батч | ≤ 1.1 | total_calls / total_batches |
|
||||
| p95 длительность батча | < 90s | Из timing-логов |
|
||||
| Общее время на 5455 строк | ≤ 8 min | Из run duration |
|
||||
| successful_rows / requested_rows | ≥ 99.5% | Из records |
|
||||
| Malformed JSON rate | < 0.5% | Из parse failures |
|
||||
|
||||
---
|
||||
|
||||
## 8. Перед внедрением — замерить
|
||||
|
||||
Собрать для 10-20 батчей (разный размер, разное количество языков):
|
||||
|
||||
| Поле | Как получить |
|
||||
|------|-------------|
|
||||
| characters | `len(prompt)` |
|
||||
| estimated_input_tokens | `_estimate_tokens_for_text()` |
|
||||
| actual_prompt_tokens | `response.usage.prompt_tokens` |
|
||||
| actual_completion_tokens | `response.usage.completion_tokens` |
|
||||
| finish_reason | Из ответа |
|
||||
| rows | `len(batch_rows)` |
|
||||
| languages | `len(target_languages)` |
|
||||
| response_rows_count | После парсинга |
|
||||
|
||||
На этих данных:
|
||||
1. Посчитать `actual_ratio = actual_prompt_tokens / estimated_tokens` — точный CJK-коэффициент
|
||||
2. Посчитать `output_per_row_actual = actual_completion_tokens / rows / languages` — точный output per row
|
||||
|
||||
Только после этого фиксировать константы в коде.
|
||||
|
||||
---
|
||||
|
||||
## 9. PROVIDER_DEFAULTS — схема fallback (для справки)
|
||||
|
||||
```
|
||||
Пользователь указал context_window в UI?
|
||||
→ да: используем (с safe cap)
|
||||
→ нет: PROVIDER_DEFAULTS.get(model_name)?
|
||||
→ да: используем
|
||||
→ нет: DEFAULT_CONTEXT_WINDOW / DEFAULT_MAX_OUTPUT_TOKENS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Текущие константы _token_budget.py (для справки)
|
||||
|
||||
| Константа | Значение | Описание |
|
||||
|-----------|----------|----------|
|
||||
| `DEFAULT_CONTEXT_WINDOW` | 64000 | |
|
||||
| `DEFAULT_MAX_OUTPUT_TOKENS` | 16384 | |
|
||||
| `REASONING_OVERHEAD` | 2000 | |
|
||||
| `OUTPUT_PER_ROW_PER_LANG` | 120 | |
|
||||
| `JSON_OVERHEAD_PER_ROW` | 50 | |
|
||||
| `PROMPT_BASE_TOKENS` | 600 | |
|
||||
| `DICT_TOKENS_PER_ENTRY` | 20 | |
|
||||
| `DICT_TOKENS_MAX` | 5000 | |
|
||||
| `CHARS_PER_TOKEN_MIXED` | 2.2 | |
|
||||
| `MIN_MAX_TOKENS` | 4096 | |
|
||||
| `MAX_OUTPUT_HEADROOM` | 3000 | |
|
||||
@@ -12,9 +12,10 @@ const BASE = '/maintenance';
|
||||
|
||||
// #region startMaintenance [C:2] [TYPE Function] [SEMANTICS maintenance, start, event]
|
||||
// @BRIEF Start a maintenance event.
|
||||
// @PRE params contains at minimum { tables, start_time }.
|
||||
// @PRE params contains at minimum { environment_id, tables, start_time }.
|
||||
// @POST Returns { task_id, maintenance_id, status }.
|
||||
// @SIDE_EFFECT Creates a maintenance event on the backend.
|
||||
// @DATA_CONTRACT Input: { environment_id: string, tables: string[], start_time: string, end_time?: string, message?: string } -> Output: { task_id: string, maintenance_id: string, status: string }
|
||||
// @RELATION DEPENDS_ON -> [requestApi]
|
||||
export async function startMaintenance<T = unknown>(params: Record<string, unknown>): Promise<T> {
|
||||
return requestApi<T>(`${BASE}/start`, 'POST', params);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { SvelteDate } from "svelte/reactivity";
|
||||
import { addToast } from "$lib/toasts.svelte.js";
|
||||
import { t } from "$lib/i18n/index.svelte.js";
|
||||
import HelpTooltip from "$lib/ui/HelpTooltip.svelte";
|
||||
import { environmentContextStore } from "$lib/stores/environmentContext.svelte.js";
|
||||
|
||||
/** @type {{ onSuccess?: () => void }} */
|
||||
let { onSuccess = () => {} } = $props();
|
||||
@@ -104,7 +105,15 @@ import { SvelteDate } from "svelte/reactivity";
|
||||
|
||||
isSubmitting = true;
|
||||
try {
|
||||
const params = {
|
||||
const envId = environmentContextStore.value?.selectedEnvId;
|
||||
if (!envId) {
|
||||
addToast($t.maintenance?.no_environment || "No environment selected. Please select an environment first.", "error");
|
||||
isSubmitting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const params: Record<string, unknown> = {
|
||||
environment_id: envId,
|
||||
tables: [trimmedTable],
|
||||
start_time: startTime ? new SvelteDate(startTime).toISOString() : new SvelteDate().toISOString(),
|
||||
};
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
// @ACTION runBackupTask() — Starts backup task for this dashboard.
|
||||
// @ACTION goBack() — Navigates back to dashboard list.
|
||||
// @ACTION openDataset(id) — Navigates to dataset detail.
|
||||
// @RELATION DEPENDS_ON -> [api]
|
||||
// @RELATION DEPENDS_ON -> [ApiModule]
|
||||
// @RELATION DEPENDS_ON -> [GitStatusModel]
|
||||
// @RELATION CALLS -> [log]
|
||||
// @RELATION CALLS -> [CotLogger]
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { ROUTES } from '$lib/routes';
|
||||
|
||||
@@ -25,10 +25,10 @@
|
||||
// @ACTION handleGitPull(dashboard) — Pulls from remote.
|
||||
// @ACTION handleGitPush(dashboard) — Pushes to remote.
|
||||
// @ACTION handleBulkBackup() — Executes bulk backup.
|
||||
// @RELATION DEPENDS_ON -> [api]
|
||||
// @RELATION DEPENDS_ON -> [ApiModule]
|
||||
// @RELATION CALLS -> [gitService]
|
||||
// @RELATION BINDS_TO -> [environmentContextStore]
|
||||
// @RELATION CALLS -> [log]
|
||||
// @RELATION CALLS -> [CotLogger]
|
||||
|
||||
import { SvelteSet, SvelteDate } from "svelte/reactivity";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
// @ACTION navigateToDashboard() — Navigates to linked dashboard detail.
|
||||
// @ACTION goBack() — Navigates back to dataset list.
|
||||
// @ACTION getColumnTypeClass() — Returns Tailwind class for column type chip.
|
||||
// @RELATION DEPENDS_ON -> [api]
|
||||
// @RELATION CALLS -> [log]
|
||||
// @RELATION DEPENDS_ON -> [ApiModule]
|
||||
// @RELATION CALLS -> [CotLogger]
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { ROUTES } from '$lib/routes';
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
// @ACTION handleBulkGenerateDocs() — Runs bulk generate-docs task.
|
||||
// @ACTION selectDataset(id) — Opens detail preview panel.
|
||||
// @ACTION closeDetail() — Closes detail preview panel.
|
||||
// @RELATION DEPENDS_ON -> [api]
|
||||
// @RELATION CALLS -> [log]
|
||||
// @RELATION DEPENDS_ON -> [ApiModule]
|
||||
// @RELATION CALLS -> [CotLogger]
|
||||
// @INVARIANT DECOMPOSITION GATE: 20+ atoms. If model exceeds 400 lines or 40 methods, split into submodels.
|
||||
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
// @ACTION loadDictionary() / loadEntries() — Fetch data.
|
||||
// @ACTION saveEntry() / addEntry() / deleteEntry() — CRUD.
|
||||
// @ACTION importDictionary() / previewImport() — Import flow.
|
||||
// @RELATION DEPENDS_ON -> [api]
|
||||
// @RELATION CALLS -> [log]
|
||||
// @RELATION DEPENDS_ON -> [ApiModule]
|
||||
// @RELATION CALLS -> [CotLogger]
|
||||
|
||||
import { api } from '$lib/api.js';
|
||||
import { addToast } from '$lib/toasts.svelte.js';
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
// @ACTION openScreenshot(path) — Opens screenshot blob URL in new tab.
|
||||
// @ACTION cleanupBlobs() — Revokes all screenshot blob URLs.
|
||||
// @ACTION refresh() — Re-runs loadReport().
|
||||
// @RELATION DEPENDS_ON -> [api]
|
||||
// @RELATION CALLS -> [log]
|
||||
// @RELATION DEPENDS_ON -> [ApiModule]
|
||||
// @RELATION CALLS -> [CotLogger]
|
||||
|
||||
import { api } from '$lib/api.js';
|
||||
import { log } from '$lib/cot-logger';
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// @ACTION openDetail(run) / closeDetail() — Detail panel management.
|
||||
// @ACTION applyFilters() — Reset pagination + reload.
|
||||
// @ACTION handleCancelRun/Retry/Download — Run-level actions.
|
||||
// @RELATION DEPENDS_ON -> [translateApi]
|
||||
// @RELATION DEPENDS_ON -> [TranslateApi]
|
||||
|
||||
import { addToast } from '$lib/toasts.svelte.js';
|
||||
import { _, getT } from '$lib/i18n/index.svelte.js';
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
// @ACTION loadInitialData() — Loads environments, LLM providers, dictionaries, and if editing, job data.
|
||||
// @ACTION saveJob() — PUT/POST job configuration.
|
||||
// @ACTION handleTriggerRun(full) — Starts translation run.
|
||||
// @RELATION DEPENDS_ON -> [api]
|
||||
// @RELATION CALLS -> [log]
|
||||
// @RELATION DEPENDS_ON -> [ApiModule]
|
||||
// @RELATION CALLS -> [CotLogger]
|
||||
// @INVARIANT DECOMPOSITION GATE: 50+ atoms, ~400 lines. Model serves as single source of truth for all form state.
|
||||
// @RATIONALE Methods are NOT arrow class fields — they rely on `this` to mutate state. When passed as props
|
||||
// to child components (e.g. onTriggerRun={m.handleTriggerRun}), the binding is lost and `this`
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// @INVARIANT Screenshot blob URLs are cached per (dashboardId, tabIndex) key. Revoked when cache key is overwritten.
|
||||
// @ACTION toggleDashboard(id) / toggleLogsSent(key) / toggleTaskLogs(key) — Collapsible section toggles.
|
||||
// @ACTION loadScreenshot(path, key) — Loads screenshot blob from storage API.
|
||||
// @RELATION DEPENDS_ON -> [api]
|
||||
// @RELATION DEPENDS_ON -> [ApiModule]
|
||||
|
||||
import { api } from '$lib/api.js';
|
||||
import type { TaskLogEntry } from '$lib/api.js';
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
// @ACTION handleDelete(id) — Deletes task with atomic list removal.
|
||||
// @ACTION handleToggleStatus(task) — Toggles is_active.
|
||||
// @ACTION goToPage(p) — Navigates to page.
|
||||
// @RELATION DEPENDS_ON -> [api]
|
||||
// @RELATION CALLS -> [log]
|
||||
// @RELATION DEPENDS_ON -> [ApiModule]
|
||||
// @RELATION CALLS -> [CotLogger]
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { ROUTES } from '$lib/routes';
|
||||
|
||||
@@ -16,6 +16,28 @@ describe('MaintenanceApi URL construction', () => {
|
||||
expect(mockRequestApi).toHaveBeenCalledWith('/maintenance/start', 'POST', expect.any(Object));
|
||||
});
|
||||
|
||||
it('startMaintenance → forwards environment_id in params', async () => {
|
||||
mockRequestApi.mockResolvedValue({ task_id: 't-1', maintenance_id: 'm-1', status: 'pending' });
|
||||
await startMaintenance({
|
||||
environment_id: 'env-prod',
|
||||
tables: ['raw.sales'],
|
||||
start_time: '2026-05-21T22:00:00Z',
|
||||
});
|
||||
expect(mockRequestApi).toHaveBeenCalledWith(
|
||||
'/maintenance/start',
|
||||
'POST',
|
||||
expect.objectContaining({ environment_id: 'env-prod' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('startMaintenance → rejects without environment_id (backend returns 422)', async () => {
|
||||
mockRequestApi.mockRejectedValue({ status: 422, message: 'Field required' });
|
||||
await expect(startMaintenance({
|
||||
tables: ['raw.sales'],
|
||||
start_time: '2026-05-21T22:00:00Z',
|
||||
})).rejects.toBeTruthy();
|
||||
});
|
||||
|
||||
it('endMaintenance → POST /maintenance/{id}/end', async () => {
|
||||
mockRequestApi.mockResolvedValue({ task_id: 't-1', status: 'pending' });
|
||||
await endMaintenance('ev-abc');
|
||||
|
||||
180
frontend/tests/maintenance-form.test.ts
Normal file
180
frontend/tests/maintenance-form.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
// #region MaintenanceFormTests [C:2] [TYPE Module] [SEMANTICS test, component, maintenance, start, form, environment_id]
|
||||
// @BRIEF Component tests for StartMaintenanceForm — verifies environment_id is included in submission.
|
||||
// @RELATION BINDS_TO -> [StartMaintenanceForm]
|
||||
// @TEST_CONTRACT: Submit includes environment_id from environmentContextStore
|
||||
// @TEST_EDGE: No environment selected -> error toast, no API call
|
||||
// @TEST_EDGE: Environment selected -> API call includes environment_id
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
|
||||
// ── Hoisted mutable state (before vi.mock calls, hoisted to top) ──
|
||||
|
||||
const { mockStartMaintenance, mockAddToast, mockEnvStore } = vi.hoisted(() => {
|
||||
// Mutable env store state — tests can change this before render
|
||||
let _selectedEnvId = 'env-prod';
|
||||
|
||||
const mockEnvStoreObj = {
|
||||
value: {
|
||||
get selectedEnvId() { return _selectedEnvId; },
|
||||
},
|
||||
subscribe: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
mockStartMaintenance: vi.fn(),
|
||||
mockAddToast: vi.fn(),
|
||||
mockEnvStore: {
|
||||
obj: mockEnvStoreObj,
|
||||
setSelectedEnvId(id: string) { _selectedEnvId = id; },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// ── Module mocks ──────────────────────────────────────────────
|
||||
|
||||
vi.mock('$lib/api/maintenance.js', () => ({
|
||||
startMaintenance: mockStartMaintenance,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/toasts.svelte.js', () => ({
|
||||
addToast: mockAddToast,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/stores/environmentContext.svelte.js', () => ({
|
||||
environmentContextStore: mockEnvStore.obj,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/i18n/index.svelte.js', () => {
|
||||
const subscribers = new Set();
|
||||
const tData = {
|
||||
maintenance: {
|
||||
start_maintenance: 'Start Maintenance',
|
||||
start_maintenance_description: 'Schedule a maintenance window',
|
||||
start_now: 'Start Maintenance',
|
||||
starting: 'Starting...',
|
||||
start_success: 'Maintenance started. Task: {task_id}',
|
||||
start_error: 'Failed to start maintenance',
|
||||
table_name: 'Table Name',
|
||||
table_required: 'Table name is required',
|
||||
table_name_placeholder: 'e.g. raw.sales',
|
||||
table_help: 'Enter the table name',
|
||||
start_time_label: 'Start Time',
|
||||
start_time_help: 'When maintenance begins',
|
||||
end_time_label: 'End Time (optional)',
|
||||
end_time_help: 'When maintenance ends',
|
||||
message_label: 'Message (optional)',
|
||||
message_placeholder: 'Scheduled data mart refresh',
|
||||
template_label: 'Quick Template',
|
||||
template_none: 'Custom',
|
||||
template_daily_refresh: 'Daily Refresh',
|
||||
template_daily_refresh_desc: '4h ETL window',
|
||||
template_schema_migration: 'Schema Migration',
|
||||
template_schema_migration_desc: '2h schema change',
|
||||
template_emergency: 'Emergency',
|
||||
template_emergency_desc: '1h critical fix',
|
||||
template_extended: 'Extended',
|
||||
template_extended_desc: '8h large refresh',
|
||||
no_environment: 'No environment selected. Please select an environment first.',
|
||||
},
|
||||
common: {},
|
||||
};
|
||||
return {
|
||||
t: {
|
||||
subscribe(run: (_value: unknown) => void) {
|
||||
subscribers.add(run);
|
||||
run(tData);
|
||||
return () => subscribers.delete(run);
|
||||
},
|
||||
},
|
||||
_: (key: string) => key,
|
||||
getT: () => ({}),
|
||||
};
|
||||
});
|
||||
|
||||
// ── Imports (after mocks) ─────────────────────────────────────
|
||||
|
||||
import StartMaintenanceForm from '$lib/components/StartMaintenanceForm.svelte';
|
||||
|
||||
describe('StartMaintenanceForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset to default: environment selected
|
||||
mockEnvStore.setSelectedEnvId('env-prod');
|
||||
});
|
||||
|
||||
// #region test_submits_with_environment_id [C:2] [TYPE Function]
|
||||
// @BRIEF Submit with environment selected includes environment_id in API call.
|
||||
// @TEST_EDGE: Environment selected -> API call includes environment_id
|
||||
it('submits with environment_id from context store', async () => {
|
||||
mockStartMaintenance.mockResolvedValue({
|
||||
task_id: 'task-1',
|
||||
maintenance_id: 'm-1',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
render(StartMaintenanceForm);
|
||||
|
||||
// Fill in table name
|
||||
const tableInput = screen.getByPlaceholderText('e.g. raw.sales');
|
||||
await fireEvent.input(tableInput, { target: { value: 'raw.my_table' } });
|
||||
|
||||
// Click the submit button (use role to avoid heading match)
|
||||
const submitButton = screen.getByRole('button', { name: /Start Maintenance/i });
|
||||
await fireEvent.click(submitButton);
|
||||
|
||||
// Verify startMaintenance was called with environment_id
|
||||
expect(mockStartMaintenance).toHaveBeenCalledTimes(1);
|
||||
const callParams = mockStartMaintenance.mock.calls[0][0];
|
||||
expect(callParams).toHaveProperty('environment_id', 'env-prod');
|
||||
expect(callParams).toHaveProperty('tables');
|
||||
expect(callParams.tables).toContain('raw.my_table');
|
||||
});
|
||||
// #endregion test_submits_with_environment_id
|
||||
|
||||
// #region test_validation_fails_without_environment [C:2] [TYPE Function]
|
||||
// @BRIEF No environment selected shows error toast and does not call API.
|
||||
// @TEST_EDGE: No environment selected -> error toast, no API call
|
||||
it('shows error toast when no environment is selected', async () => {
|
||||
// Set empty environment
|
||||
mockEnvStore.setSelectedEnvId('');
|
||||
|
||||
render(StartMaintenanceForm);
|
||||
|
||||
// Fill in table name
|
||||
const tableInput = screen.getByPlaceholderText('e.g. raw.sales');
|
||||
await fireEvent.input(tableInput, { target: { value: 'raw.my_table' } });
|
||||
|
||||
// Click the submit button
|
||||
const submitButton = screen.getByRole('button', { name: /Start Maintenance/i });
|
||||
await fireEvent.click(submitButton);
|
||||
|
||||
// API should NOT have been called
|
||||
expect(mockStartMaintenance).not.toHaveBeenCalled();
|
||||
|
||||
// Error toast should have been shown with the missing environment message
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining('No environment selected'),
|
||||
'error',
|
||||
);
|
||||
});
|
||||
// #endregion test_validation_fails_without_environment
|
||||
|
||||
// #region test_validation_fails_without_table [C:2] [TYPE Function]
|
||||
// @BRIEF Empty table name shows inline validation, no API call.
|
||||
it('shows validation error when table name is empty', async () => {
|
||||
render(StartMaintenanceForm);
|
||||
|
||||
// Click Submit without filling table name
|
||||
const submitButton = screen.getByRole('button', { name: /Start Maintenance/i });
|
||||
await fireEvent.click(submitButton);
|
||||
|
||||
// API should NOT have been called
|
||||
expect(mockStartMaintenance).not.toHaveBeenCalled();
|
||||
|
||||
// Inline validation error should be visible
|
||||
expect(screen.getByText('Table name is required')).toBeTruthy();
|
||||
});
|
||||
// #endregion test_validation_fails_without_table
|
||||
});
|
||||
// #endregion MaintenanceFormTests
|
||||
Reference in New Issue
Block a user