From 726b1ee74578d2754dd56ab875f5c4969ad135fa Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 3 Nov 2026 01:09:01 +0300 Subject: [PATCH] Harden empty review validation and lease ranking --- backend/search/api.py | 9 ++- backend/search/query.py | 27 +++++--- backend/test_search_api_opensearch.py | 10 +++ backend/test_search_relevance.py | 7 +- backend/test_search_reviews.py | 43 ++++++++++++ .../search-relevance-findings-2026-11-03.md | 69 ++++++++++++++----- tools/frontend-visual-review/check.cjs | 31 ++++++++- 7 files changed, 166 insertions(+), 30 deletions(-) diff --git a/backend/search/api.py b/backend/search/api.py index b8207ae..6f94a21 100644 --- a/backend/search/api.py +++ b/backend/search/api.py @@ -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") diff --git a/backend/search/query.py b/backend/search/query.py index 0e9e75f..f0f936b 100644 --- a/backend/search/query.py +++ b/backend/search/query.py @@ -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"(? 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} diff --git a/backend/test_search_api_opensearch.py b/backend/test_search_api_opensearch.py index a0f6232..26b697d 100644 --- a/backend/test_search_api_opensearch.py +++ b/backend/test_search_api_opensearch.py @@ -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() diff --git a/backend/test_search_relevance.py b/backend/test_search_relevance.py index db26506..9254ef6 100644 --- a/backend/test_search_relevance.py +++ b/backend/test_search_relevance.py @@ -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", "договору аренды"), ["договору аренды", "договору имущественного найма"], diff --git a/backend/test_search_reviews.py b/backend/test_search_reviews.py index be7ef68..44178fd 100644 --- a/backend/test_search_reviews.py +++ b/backend/test_search_reviews.py @@ -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") diff --git a/docs/product/search-relevance-findings-2026-11-03.md b/docs/product/search-relevance-findings-2026-11-03.md index 263958c..67ce0d7 100644 --- a/docs/product/search-relevance-findings-2026-11-03.md +++ b/docs/product/search-relevance-findings-2026-11-03.md @@ -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 выдач. - Актуальные три акта требуют отдельной проверки полноты/лицензии/истории и diff --git a/tools/frontend-visual-review/check.cjs b/tools/frontend-visual-review/check.cjs index 46685d4..44a207a 100644 --- a/tools/frontend-visual-review/check.cjs +++ b/tools/frontend-visual-review/check.cjs @@ -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.leftt.left&&d.topt.top,font:style.fontFamily,titleFont:getComputedStyle(document.querySelector('#tutorial-heading')).fontFamily,background:style.backgroundColor,targetDisplay:targetStyle.display,targetInViewport:t.bottom>0&&t.top320||!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();