Harden empty review validation and lease ranking
CI / tests-and-images (pull_request) Successful in 2m19s
Runner smoke test / runner-smoke (pull_request) Successful in 2s

This commit is contained in:
codex committed 2026-11-03 01:09:01 +03:00
1 parent aa6fabe970
commit 726b1ee745
7 files changed
+166 -30

No files matched your search

+8 -1
View File
@@ -283,8 +283,15 @@ class Api:
raise ApiError(400, "overall_comment is too long")
if not isinstance(submitted, list):
raise ApiError(400, "results must be an array")
if len(submitted) > MAX_PAGE_SIZE:
raise ApiError(400, f"results must not exceed {MAX_PAGE_SIZE} items")
if any(not isinstance(item, dict) for item in submitted):
raise ApiError(400, "review result must be an object")
ranks = [item.get("rank") for item in submitted]
if any(type(rank) is not int for rank in ranks):
raise ApiError(400, "review result rank is invalid")
by_rank = {item["rank"]: item for item in snapshot["results"]}
if len(submitted) != len(by_rank) or {item.get("rank") for item in submitted if isinstance(item, dict)} != set(by_rank):
if len(ranks) != len(set(ranks)) or len(submitted) != len(by_rank) or set(ranks) != set(by_rank):
raise ApiError(400, "all search results must be reviewed exactly once")
if not snapshot["results"] and not overall_comment.strip():
raise ApiError(400, "overall_comment is required for an empty search result")
+18 -9
View File
@@ -31,9 +31,10 @@ LEASE_TERM_ALIAS = re.compile(
)
LEASE_TERM_ALIAS_EXCLUSIONS = re.compile(
r"\b(?:квартир\w*|жил\w*|дом\w*|помещен\w*|земл\w*|участк\w*|"
r"подна[её]м\w*|субаренд\w*|лизинг\w*|труд\w*|работник\w*)\b",
r"поднайм\w*|подна[её]м\w*|субаренд\w*|лизинг\w*|труд\w*|работник\w*)\b",
re.IGNORECASE,
)
LEASE_TERM_PHRASE_BOOST = 12
COMPANY_REGISTRATION_DOCUMENTS = (("230044970", 3000), ("667", 2000), ("4", 1000))
TAX_PROFIT_DOCUMENTS = (("112340", 3000),)
BOILERPLATE_PREFIXES = {
@@ -74,18 +75,22 @@ def relaxed_query_text(language: str, query: str) -> str:
return value
def lease_term_variant(query: str) -> str | None:
if LEASE_TERM_ALIAS.search(query) and not LEASE_TERM_ALIAS_EXCLUSIONS.search(query):
return LEASE_TERM_ALIAS.sub(r"\1 имущественного найма", query)
return None
def query_variants(language: str, query: str) -> list[str]:
variants = [query]
for alias, expansions in LEGAL_FORM_ALIASES[language].items():
pattern = re.compile(rf"(?<!\w){re.escape(alias)}(?!\w)", re.IGNORECASE)
if pattern.search(query):
variants.extend(pattern.sub(expansion, query) for expansion in expansions)
if (
language == "ru"
and LEASE_TERM_ALIAS.search(query)
and not LEASE_TERM_ALIAS_EXCLUSIONS.search(query)
):
variants.append(LEASE_TERM_ALIAS.sub(r"\1 имущественного найма", query))
if language == "ru":
alias = lease_term_variant(query)
if alias:
variants.append(alias)
return list(dict.fromkeys(variants))
@@ -187,9 +192,13 @@ def build_search_query(language: str, query: str) -> dict:
"boost": 0.1,
}
})
lease_variant = lease_term_variant(query) if language == "ru" else None
phrase_matches = [
{"match_phrase": {f"text_{language}": {"query": variant, "boost": 3}}}
for variant in query_variants(language, query)
{"match_phrase": {f"text_{language}": {
"query": variant,
"boost": LEASE_TERM_PHRASE_BOOST if variant == lease_variant else 3,
}}}
for variant in variants
]
planned = {"should": [*full_text, *phrase_matches, *relaxed_text, *intent_clauses], "minimum_should_match": 1}
return {"bool": planned}
+10
View File
@@ -37,5 +37,15 @@ class SearchApiOpenSearchTest(unittest.TestCase):
request_json(f"{base_url}/{index}", "DELETE", None, "application/json")
@unittest.skipUnless(os.getenv("AKYLDASH_OPENSEARCH_URL"), "set AKYLDASH_OPENSEARCH_URL to run against local OpenSearch")
class SearchRankingOpenSearchTest(unittest.TestCase):
def test_generic_lease_query_ranks_general_civil_code_first(self):
base_url = os.environ["AKYLDASH_OPENSEARCH_URL"].rstrip("/")
index = os.getenv("AKYLDASH_OPENSEARCH_INDEX", "akyldash-fragments-v2")
api = Api(base_url, index, Path("."))
result = api.search({"q": ["расторжение договора аренды"], "language": ["ru"], "page_size": ["10"]})
self.assertEqual(result["results"][0]["code"], "5")
if __name__ == "__main__":
unittest.main()
+6 -1
View File
@@ -5,7 +5,7 @@ from pathlib import Path
from unittest.mock import patch
from search.evaluate_relevance import evaluate, load_queries
from search.query import COMPANY_REGISTRATION_DOCUMENTS, TAX_PROFIT_DOCUMENTS, build_search_body, query_variants
from search.query import COMPANY_REGISTRATION_DOCUMENTS, LEASE_TERM_PHRASE_BOOST, TAX_PROFIT_DOCUMENTS, build_search_body, query_variants
class SearchRelevanceTest(unittest.TestCase):
@@ -58,17 +58,22 @@ class SearchRelevanceTest(unittest.TestCase):
clauses = body["query"]["bool"]["must"]["bool"]["should"]
full_text = [item["multi_match"]["query"] for item in clauses if "multi_match" in item and item["multi_match"].get("operator") == "AND"]
self.assertEqual(full_text, variants)
phrase_matches = [item["match_phrase"]["text_ru"] for item in clauses if "match_phrase" in item]
self.assertEqual([item["boost"] for item in phrase_matches], [3, LEASE_TERM_PHRASE_BOOST])
for query in (
"аренда квартиры",
"расторжение договора аренды квартиры",
"договор аренды земельного участка",
"договор поднайма",
"договор аренды с правом поднайма",
"наем работника",
"договор лизинга",
):
self.assertEqual(query_variants("ru", query), [query])
self.assertEqual(query_variants("ky", "договор аренды"), ["договор аренды"])
self.assertEqual(
query_variants("ru", "договору аренды"),
["договору аренды", "договору имущественного найма"],
+43
View File
@@ -18,6 +18,49 @@ class SearchReviewTest(unittest.TestCase):
with self.assertRaisesRegex(ApiError, "exactly once"):
api.handle("POST", "/search-reviews", {"review_token": result["review_token"], "reviewer": "Юрист", "results": [{"rank": 1, "code": "7", "rating": 3}]})
def test_invalid_rank_types_and_sets_are_rejected_as_bad_requests(self):
with tempfile.TemporaryDirectory() as temporary:
api = Api("http://opensearch:9200", "current", Path(temporary), review_secret=b"test-secret")
response = {"hits": {"hits": [
{"_source": {"document_code": "7", "edition_code": "10", "document_name_ru": "Закон"}},
{"_source": {"document_code": "8", "edition_code": "11", "document_name_ru": "Кодекс"}},
]}}
with patch("search.api.request_json", return_value=response):
result = api.handle("GET", "/search?q=test&language=ru&page_size=2")[1]
token = result["review_token"]
malformed = [
{"rank": True, "code": "7", "rating": 3},
{"rank": [1], "code": "7", "rating": 3},
{"rank": {"value": 1}, "code": "7", "rating": 3},
{"rank": None, "code": "7", "rating": 3},
{"rank": "1", "code": "7", "rating": 3},
{"rank": 1.0, "code": "7", "rating": 3},
None,
]
for item in malformed:
with self.subTest(item=item):
with self.assertRaises(ApiError) as raised:
api.handle("POST", "/search-reviews", {"review_token": token, "reviewer": "Юрист", "results": [item]})
self.assertEqual(raised.exception.status, 400)
for submitted in (
[],
[{"rank": 1, "code": "7", "rating": 3}, {"rank": 1, "code": "7", "rating": 3}],
[{"rank": 1, "code": "7", "rating": 3}, {"rank": 3, "code": "8", "rating": 3}],
[{"rank": 1, "code": "7", "rating": 3}],
{"rank": 1, "code": "7", "rating": 3},
):
with self.subTest(submitted=submitted):
with self.assertRaises(ApiError) as raised:
api.handle("POST", "/search-reviews", {"review_token": token, "reviewer": "Юрист", "results": submitted})
self.assertEqual(raised.exception.status, 400)
oversized = [{"rank": 1, "code": "7", "rating": 3}] * 101
with self.assertRaises(ApiError) as raised:
api.handle("POST", "/search-reviews", {"review_token": token, "reviewer": "Юрист", "results": oversized})
self.assertEqual(raised.exception.status, 400)
def test_saves_signed_search_snapshot_and_rejects_tampering(self):
with tempfile.TemporaryDirectory() as temporary:
api = Api("http://opensearch:9200", "current", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
@@ -34,9 +34,12 @@ MRR, полноту ответа или юридическую правильн
## Подтверждённые классы ошибок
14 коротких документов прочитаны целиком; по ним сохранено 79 оценок пар
«запрос—документ» в 71 сессии: 59 нерелевантных и 20 косвенно полезных.
Неоценённые результаты не считались ошибками.
14 коротких документов прочитаны целиком; Codex (ИИ) подготовил по ним 79
предварительных оценок пар «запрос—документ» в 71 сессии: 59 помечены как
нерелевантные и 20 как косвенно полезные. Это тематическая AI-разметка для
триажа, а не заключение юриста или независимая экспертная проверка. Она не
подтверждает полноту правового ответа. Неоценённые результаты не считались
ошибками.
| Тема | Наблюдавшийся неверный приоритет | Вывод |
|---|---:|---|
@@ -76,6 +79,19 @@ Backend теперь сохраняет исходную строку и доб
поиска. Фактический top-k после изменения и влияние на соседние запросы требуют
отдельного A/B прогона; кодовый тест проверяет только варианты и DSL.
В read-only пробе предыдущей версии `aa6fabe` для общего запроса код 5 поднялся
с позиции за пределами top 10 на #5, но нерелевантный индивидуальный акт 135066
оставался первым. Поэтому новая ревизия усиливает только `match_phrase` для
синтетического варианта с юридическим термином (`boost=12`); исходная фраза
оставляет исходный вес 3. Это проверяемая гипотеза, а не подтверждённая починка:
текущий commit нужно повторно прогнать на изолированном индексе. Критерий
приёмки: для общего запроса код 5 — top 1; для явной формулировки «имущественный
наём» он остаётся top 1; для запросов о жилом помещении, земельном участке,
поднайме, трудовых отношениях, международном арбитраже и кыргызских запросов
расширение отсутствует, а контрольная выдача не ухудшается относительно
baseline. Вес не должен подниматься автоматически, если этот критерий не
выполняется.
## Пустые выдачи в лаборатории
Исходный контракт блокировал сохранение шести подписанных пустых снимков:
@@ -87,7 +103,7 @@ API отвергал `results: []`, а интерфейс читал поля и
аутентификация не меняются.
Существующая отдельная база локальной лаборатории содержала 7 494 сохранённых
сессии, 79 экспертных оценок и 55 516 диагностических комментариев; 74 715
сессии, 79 AI-оценок и 55 516 диагностических комментариев; 74 715
оценок результатов остались пустыми намеренно. Integrity check прошёл,
порядок кодов и редакций baseline не менялся. Эти сохранённые оценки не
переписывались; шесть пустых сессий должны быть размечены отдельно после
@@ -104,11 +120,19 @@ API отвергал `results: []`, а интерфейс читал поля и
| Земельный кодекс КР от 18.07.2025 № 149 | 230029465 | 34746 от 18.07.2025 |
| Об охране здоровья граждан КР от 12.01.2024 № 14 | 230000918 | 47553 от 23.01.2026 |
В индексе присутствуют прежние отменённые трудовой и земельный кодексы; нельзя
переименовать или поднять их как замену отсутствующим актуальным нормам.
Подготовленные для отдельной проверки точные RU/KY тексты — 3 документа и
8 934 фрагмента — пока не составляют полного корпуса и не подключены к
работающему индексу. Вложения и история редакций отдельно не подтверждены.
Это разрыв между нормализованными документами и индексом, а не полное
отсутствие актов в локальном архиве: в `data/minjust-normalized` уже есть
canonical-каталоги всех трёх документов, manifest сообщает 209 958 успешно
обработанных документов. Однако трудовой акт там отстаёт от официальной
редакции 57548 от 28.07.2026 (последняя подготовленная — № 60 от 23.06.2026);
земельный акт нормализован до редакции № 10 от 18.07.2025; для акта о здоровье
последняя подготовленная — № 60 от 23.01.2026. В индекс эти три canonical ID не
попали. Прежние отменённые трудовой и земельный кодексы нельзя переименовать
или поднять как замену актуальным нормам.
Для отдельной проверки подготовлены точные официальные RU/KY редакции: 3
документа и 8 934 фрагмента. Это добавление пока не подключено к индексу или
API. Вложения и полная история новых canonical ID отдельно не подтверждены.
Закрытие пробела требует отдельного контролируемого обновления данных:
@@ -116,19 +140,32 @@ API отвергал `results: []`, а интерфейс читал поля и
исходный ответ, хеши и provenance в отдельном staging-каталоге.
2. Проверить полноту обеих языковых версий, ссылки на редакции, историю и
вложения; не импортировать метаданные с ненужными персональными полями.
3. Нормализовать отдельно от текущего корпуса и проверить counts/хеши; затем
пересобрать полный версионный индекс из базового корпуса и дополнений.
4. На изолированном стенде проверить наличие актуальных canonical ID, открытие
документа/редакции через API, активность статуса, RU/KY поиск и контрольные
запросы к прежним источникам. Не применять поправку как прямую запись в
действующий индекс и не переключать alias до полного сравнения.
3. Нормализовать документы отдельно и проверить exact-count/hash. Для
ограниченного дополнения собрать отдельный версионный индекс только из этих
актов; не пересобирать и не перезаписывать индекс из 42,6 млн фрагментов.
4. На изолированном стенде подключить read-only представление трёх новых
каталогов к API `data_root` и искать через выражение
`текущий-индекс,supplement-index`. URL-кодирование запятой для такого
выражения проверено read-only на API. Проверить наличие canonical ID,
открытие новой редакции RU/KY, статус и даты, collapse по документу, а также
контрольную выдачу по старым индексам. Не создавать записи в основном
индексе и не менять alias до принятия результата.
5. Публикация требует отдельного владельческого разрешения и проверенного
rollback. Эта PR не загружает документы, не меняет индекс или production.
## Что остаётся проверить
- Root-owned изолированный прогон на 8081 должен проверить реальный top-k новой
терминологической ветки и регрессии на контрольных примерах.
терминологической ветки и baseline-сравнение контрольных примеров.
- Целевой read-only integration gate, который не создаёт и не удаляет индекс;
прогон выполняет root на выделенном стенде:
```bash
AKYLDASH_OPENSEARCH_URL=http://127.0.0.1:8081 \
AKYLDASH_OPENSEARCH_INDEX=akyldash-fragments-v2 \
PYTHONPATH=backend python3 -m unittest \
backend.test_search_api_opensearch.SearchRankingOpenSearchTest.test_generic_lease_query_ranks_general_civil_code_first -v
```
- Полный benchmark после изменения нужен для нового snapshot и сравнения; он
выполняется отдельно от исходной сбора 7 500 выдач.
- Актуальные три акта требуют отдельной проверки полноты/лицензии/истории и
+28 -3
View File
@@ -8,7 +8,7 @@ const root = path.join(repo, 'backend/search/static/fonts');
const {docs, reviewDocs, catalogs, passwordChangeAccount, ordinaryAccount, article, articleKy} = require('./fixtures.cjs');
fs.mkdirSync(artifacts, {recursive: true});
let fixtureAccount = passwordChangeAccount;
const fixtureState = {users:[{username:'fixture-user',role:'Пользователь',can_manage_users:false,must_change_password:true,last_login_at:null,first_login_at:1780000000,password_reset_at:null}],passwordChanges:0,userCreates:0,passwordResets:0,userDeletes:0,reviewSaves:0,lastReviewRatings:[]};
const fixtureState = {users:[{username:'fixture-user',role:'Пользователь',can_manage_users:false,must_change_password:true,last_login_at:null,first_login_at:1780000000,password_reset_at:null}],passwordChanges:0,userCreates:0,passwordResets:0,userDeletes:0,reviewSaves:0,lastReviewRatings:[],lastReviewSubmission:null,rejectNextReviewSave:false};
let failNextEdition = false; let delayNextEdition = false; let delayNextPublicMetadata = false; let failReviewDocumentB = false; let delayReviewDocumentA = false; let delayNextReviewSearch = false;
(async()=>{
const browser = await chromium.launch({headless:true});
@@ -21,7 +21,7 @@ let failNextEdition = false; let delayNextEdition = false; let delayNextPublicMe
const p=url.pathname;
if(p==='/auth/me') return route.fulfill({json:{account:fixtureAccount}});
if(p==='/search/filters') return route.fulfill({json:catalogs});
if(p==='/search-reviews' && req.method()==='POST') { fixtureState.reviewSaves++; fixtureState.lastReviewRatings=req.postDataJSON().results.map((result)=>({code:result.code,rating:result.rating})); return route.fulfill({json:{id:42}}); }
if(p==='/search-reviews' && req.method()==='POST') { const submission=req.postDataJSON(); fixtureState.reviewSaves++; fixtureState.lastReviewSubmission=submission; fixtureState.lastReviewRatings=submission.results.map((result)=>({code:result.code,rating:result.rating})); if(fixtureState.rejectNextReviewSave){fixtureState.rejectNextReviewSave=false;return route.fulfill({status:400,json:{error:'Fixture review save error'}});} return route.fulfill({status:201,json:{id:42}}); }
if(p==='/auth/change-password' && req.method()==='POST') { fixtureState.passwordChanges++; return route.fulfill({json:{account:fixtureAccount}}); }
if(p==='/admin/users' && req.method()==='GET') return route.fulfill({json:{users:fixtureState.users}});
if(p==='/admin/users' && req.method()==='POST') { const body=req.postDataJSON(); fixtureState.userCreates++; fixtureState.users.push({username:body.username,role:'Пользователь',can_manage_users:false,must_change_password:true,last_login_at:null,first_login_at:1780000000,password_reset_at:null}); return route.fulfill({status:201,json:{username:body.username}}); }
@@ -30,7 +30,7 @@ let failNextEdition = false; let delayNextEdition = false; let delayNextPublicMe
const deleteMatch=p.match(/^\/admin\/users\/([^/]+)$/);
if(deleteMatch && req.method()==='DELETE') { fixtureState.userDeletes++; const username=decodeURIComponent(deleteMatch[1]); fixtureState.users=fixtureState.users.filter((candidate)=>candidate.username!==username); return route.fulfill({json:{}}); }
if(p==='/search') {
if(url.searchParams.has('page_size')&&!url.searchParams.has('sort')) { if(delayNextReviewSearch){delayNextReviewSearch=false;await new Promise(resolve=>setTimeout(resolve,300));} return route.fulfill({json:{results:reviewDocs,has_next:false,review_token:'fixture-review-token'}}); }
if(url.searchParams.has('page_size')&&!url.searchParams.has('sort')) { if(delayNextReviewSearch){delayNextReviewSearch=false;await new Promise(resolve=>setTimeout(resolve,300));} if(url.searchParams.get('q')==='empty-review-fixture') return route.fulfill({json:{results:[],has_next:false,review_token:'fixture-signed-empty-snapshot-token'}}); return route.fulfill({json:{results:reviewDocs,has_next:false,review_token:'fixture-review-token'}}); }
return route.fulfill(url.searchParams.get('q')==='service-error' ? {status:503,json:{error:'Fixture search error'}} : {json:{results:url.searchParams.get('q')==='not-found'?[]:docs,has_next:false,review_token:'fixture-review-token'}});
}
if(p==='/documents/doc-2/editions') return route.fulfill({json:{available_languages:[],editions:[]}});
@@ -85,6 +85,31 @@ let failNextEdition = false; let delayNextEdition = false; let delayNextPublicMe
const labGeometry=await page.evaluate(()=>{const rect=s=>document.querySelector(s).getBoundingClientRect(),action=rect('body > .review__actions'),workspace=rect('.review__workspace'),footer=rect('body > footer.review__header');return {headingLeft:rect('#route-heading').left,helpRight:rect('#tutorial-open').right,subtitleLeft:rect('#route-description').left,workspaceActionGap:action.top-workspace.bottom,actionFooterGap:footer.top-action.bottom,actionsHeight:action.height,footerHeight:footer.height};});
const tutorialScreens={}; for(const [name,viewport] of [['desktop',{width:1280,height:800}],['mobile',{width:390,height:844}]]){await page.setViewportSize(viewport); await page.locator('#tutorial-open').click(); tutorialScreens[name]=[]; for(const target of ['#query','#results','#document-panel','#save']){const selector=name==='mobile'&&target==='#document-panel'?'#document-heading':target;const geometry=await page.evaluate((selector)=>{const dialog=document.querySelector('#tutorial-dialog'),target=document.querySelector(selector),d=dialog.getBoundingClientRect(),t=target.getBoundingClientRect(),style=getComputedStyle(dialog),targetStyle=getComputedStyle(target);return {step:document.querySelector('#tutorial-progress').textContent,width:d.width,height:d.height,overlaps:d.left<t.right&&d.right>t.left&&d.top<t.bottom&&d.bottom>t.top,font:style.fontFamily,titleFont:getComputedStyle(document.querySelector('#tutorial-heading')).fontFamily,background:style.backgroundColor,targetDisplay:targetStyle.display,targetInViewport:t.bottom>0&&t.top<innerHeight,target:{left:t.left,top:t.top,right:t.right,bottom:t.bottom}}},selector);if(geometry.targetDisplay==='none'||geometry.target.right===geometry.target.left||geometry.target.bottom===geometry.target.top||!geometry.targetInViewport) throw new Error(`Tutorial target is not visible at ${name}: ${JSON.stringify(geometry)}`);if(geometry.overlaps) throw new Error(`Tutorial overlaps ${target} at ${name}: ${JSON.stringify(geometry)}`);if(geometry.width>320||!geometry.font.includes('Noto Sans')||!geometry.titleFont.includes('Noto Serif')||!geometry.background.includes('0.92')) throw new Error(`Tutorial style mismatch at ${name}: ${JSON.stringify(geometry)}`);tutorialScreens[name].push(geometry);if(target==='#query') await page.screenshot({path:path.join(artifacts,`review-tour-${name}.png`),fullPage:false});if(target!=='#save') await page.locator('#tutorial-next').press('Enter');}await page.locator('#tutorial-next').press('Enter');}
await page.setViewportSize({width:1280,height:800}); await page.locator('#query').fill('налог на прибыль'); delayNextReviewSearch=true; await page.locator('#search-form button[type=submit]').click(); await page.waitForFunction(()=>document.querySelector('#status').classList.contains('review__status--busy')); const labLoadingStatus=await page.locator('#status').evaluate(el=>({height:el.getBoundingClientRect().height,role:el.getAttribute('role'),busy:el.classList.contains('review__status--busy')})); await page.locator('.review__result').first().waitFor(); const successStatus=await page.locator('#status').evaluate(el=>({height:el.getBoundingClientRect().height,role:el.getAttribute('role'),live:el.getAttribute('aria-live'),success:el.classList.contains('review__status--success')})); await page.locator('.review__result-title').first().click(); await page.locator('#document-body h2').first().waitFor(); const compactResult=await page.locator('.review__result--selected').evaluate(el=>({padding:getComputedStyle(el).padding,titleTarget:el.querySelector('.review__result-title').getBoundingClientRect().height,ratingTarget:el.querySelector('.review__rating label').getBoundingClientRect().height,metaMargin:getComputedStyle(el.querySelector('.review__meta')).marginBlockStart})); const reviewSaveEnabled=await page.locator('#save').isEnabled(); const commentDisclosure=page.locator('.review__comment-disclosure').first(); const commentStartsCollapsed=!(await commentDisclosure.evaluate(e=>e.open)); await commentDisclosure.locator('summary').click(); await commentDisclosure.locator('textarea').fill('Фиктивный комментарий для проверки черновика'); await page.locator('input[name="rating-0"][value="2"]').check(); await page.waitForFunction(()=>document.querySelector('#draft-status').textContent==='Черновик сохранён на этом устройстве.'); await page.locator('.review__result-title').first().click(); await page.locator('#document-body h2').first().waitFor(); await page.locator('#document-body').evaluate(body=>{for(let i=0;i<160;i++){const p=document.createElement('p');p.textContent='Эталонный длинный документ в безопасном browser fixture. '.repeat(5);body.append(p);}}); await page.setViewportSize({width:1280,height:720}); const reviewScroll=await page.locator('#document-panel').evaluate(panel=>({height:panel.getBoundingClientRect().height,client:panel.clientHeight,scroll:panel.scrollHeight,viewport:innerHeight,internalScroll:panel.scrollHeight>panel.clientHeight,saveAfter:Boolean(panel.compareDocumentPosition(document.querySelector('#save'))&Node.DOCUMENT_POSITION_FOLLOWING),footerAfterSave:Boolean(document.querySelector('#save').compareDocumentPosition(document.querySelector('body > footer.review__header'))&Node.DOCUMENT_POSITION_FOLLOWING)})); await page.locator('#save').scrollIntoViewIfNeeded(); const saveViewport=await page.locator('#save').evaluate(el=>{const r=el.getBoundingClientRect();const footer=document.querySelector('body > footer.review__header').getBoundingClientRect();return {top:r.top,bottom:r.bottom,visible:r.top>=0&&r.bottom<=innerHeight,footerVisible:footer.top>=0&&footer.bottom<=innerHeight}}); await page.screenshot({path:path.join(artifacts,'lab-save-footer-1280x720.png'),fullPage:false}); await page.locator('#save').click(); await page.waitForFunction(()=>document.querySelector('#status').textContent.includes('Оценка сохранена')); const reviewFlow={reviewSaveEnabled,commentStartsCollapsed,commentExpanded:await commentDisclosure.evaluate(e=>e.open),draftStatus:await page.locator('#draft-status').textContent(),reviewScroll,saveViewport,reviewSaved:await page.locator('#status').textContent()};
const nonEmptyReviewSubmission=fixtureState.lastReviewSubmission;
if(!nonEmptyReviewSubmission||nonEmptyReviewSubmission.results.length!==2||nonEmptyReviewSubmission.review_token!=='fixture-review-token') throw new Error(`Non-empty review flow changed: ${JSON.stringify(nonEmptyReviewSubmission)}`);
await page.locator('#query').fill('empty-review-fixture');
await page.locator('#search-form button[type=submit]').click();
await page.locator('.review__empty').waitFor();
const emptyPrompt=await page.evaluate(()=>({buttonText:document.querySelector('#save').textContent,buttonEnabled:!document.querySelector('#save').disabled,ariaRequired:document.querySelector('#overall-comment').getAttribute('aria-required'),placeholder:document.querySelector('#overall-comment').placeholder,emptyState:document.querySelector('#results').textContent.includes('Результатов пока нет')}));
await page.screenshot({path:path.join(artifacts,'empty-review-required-comment.png'),fullPage:false});
const attemptsBeforeInvalid=fixtureState.reviewSaves;
await page.locator('#save').focus(); await page.keyboard.press('Enter');
await page.waitForFunction(()=>document.querySelector('#status').textContent.includes('Добавьте комментарий к пустой выдаче'));
const invalidEmptyComment=await page.evaluate(()=>({focus:document.activeElement.id,message:document.querySelector('#status').textContent,role:document.querySelector('#status').getAttribute('role'),live:document.querySelector('#status').getAttribute('aria-live')}));
const invalidRequestCount=fixtureState.reviewSaves-attemptsBeforeInvalid;
await page.locator('#overall-comment').fill('Пустая выдача проверена в синтетическом browser fixture.');
fixtureState.rejectNextReviewSave=true;
await page.locator('#save').focus(); await page.keyboard.press('Enter');
await page.waitForFunction(()=>document.querySelector('#status').textContent.includes('Не удалось сохранить'));
const failedEmptySubmission=fixtureState.lastReviewSubmission;
const failedSaveFeedback=await page.locator('#status').evaluate(el=>({message:el.textContent,role:el.getAttribute('role'),live:el.getAttribute('aria-live')}));
await page.locator('#save').focus(); await page.keyboard.press('Enter');
await page.waitForFunction(()=>document.querySelector('#status').textContent.includes('Оценка сохранена'));
const emptyReviewSubmission=fixtureState.lastReviewSubmission;
const emptyReviewFlow={emptyPrompt,invalidEmptyComment,invalidRequestCount,failedSaveFeedback,failedSubmission:failedEmptySubmission,savedSubmission:emptyReviewSubmission,feedback:await page.locator('#status').textContent()};
await page.screenshot({path:path.join(artifacts,'empty-review-saved.png'),fullPage:false});
console.log('emptyReviewFlow',JSON.stringify(emptyReviewFlow));
if(!emptyPrompt.buttonEnabled||emptyPrompt.buttonText!=='Сохранить пустую выдачу'||emptyPrompt.ariaRequired!=='true'||!emptyPrompt.placeholder.includes('обязательно')||!emptyPrompt.emptyState||invalidEmptyComment.focus!=='overall-comment'||invalidEmptyComment.role!=='alert'||invalidRequestCount!==0||failedSaveFeedback.role!=='alert'||failedSaveFeedback.live!=='assertive'||failedEmptySubmission.review_token!=='fixture-signed-empty-snapshot-token'||failedEmptySubmission.results.length!==0||!failedEmptySubmission.overall_comment||emptyReviewSubmission.review_token!=='fixture-signed-empty-snapshot-token'||emptyReviewSubmission.results.length!==0||emptyReviewSubmission.overall_comment!=='Пустая выдача проверена в синтетическом browser fixture.'||!emptyReviewFlow.feedback.includes('Оценка сохранена')) throw new Error(`Empty review browser contract failed: ${JSON.stringify(emptyReviewFlow)}`);
await page.locator('#query').fill('document-switch-fixture'); await page.locator('#search-form button[type=submit]').click(); await page.waitForFunction(()=>document.querySelector('.review__result-title span:last-child')?.textContent==='Fixture document A');
await page.locator('.review__comment-disclosure').first().locator('summary').click(); await page.locator('.review__comment').first().fill('Fixture-only note for result A.'); const fixtureRating=page.locator('input[name="rating-0"][value="2"]'); await fixtureRating.check(); const ratingBeforeDocumentSwitch=await fixtureRating.isChecked();
await page.locator('.review__result-title').nth(0).click(); await page.locator('#document-body h2').filter({hasText:'Fixture document A'}).waitFor();