# Instructions

- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.

# Test info

- Name: 4147/dev/privacy-masking-regression.spec.ts >> [4147][dev] [재QA] 개인정보 마스킹 및 원문 조회 역할별 회귀 검증 >> [ADMIN] 총판관리 / 수정 모달 인증 전 잠금 및 PIN 해제 후 활성화
- Location: src/qa/scenarios/privacyMaskingRegression.ts:105:11

# Error details

```
Error: 총판관리 수정 버튼은 PIN 마스킹 해제 후 활성화되어야 합니다.

총판관리 수정 버튼은 PIN 마스킹 해제 후 활성화되어야 합니다.

expect(received).toBe(expected) // Object.is equality

Expected: true
Received: false

Call Log:
- Timeout 10000ms exceeded while waiting on the predicate
```

# Test source

```ts
  319 |       const surface = (await dialog.count()) > 0 ? dialog : page.locator("body");
  320 |       const detailText = normalize(await surface.innerText().catch(() => ""));
  321 |       if (optionalMasking || countMaskCharacters(detailText) > 0) {
  322 |         return { button, surface };
  323 |       }
  324 |       await closeSurface(page, surface);
  325 |       continue;
  326 |     }
  327 |     await page.keyboard.press("Escape").catch(() => undefined);
  328 |   }
  329 |   return undefined;
  330 | }
  331 |
  332 | async function expandMasterAccountRows(page: Page): Promise<void> {
  333 |   const root = await interactionRoot(page);
  334 |   for (let depth = 0; depth < 3; depth += 1) {
  335 |     const chevrons = root.locator("svg.lucide-chevron-right:visible");
  336 |     const count = await chevrons.count();
  337 |     if (count === 0) return;
  338 |     let clicked = false;
  339 |     for (let index = 0; index < Math.min(count, 8); index += 1) {
  340 |       const button = chevrons.nth(index).locator("xpath=ancestor::button[1]");
  341 |       if (!(await button.isVisible().catch(() => false))) continue;
  342 |       if (!(await isActiveElement(button))) continue;
  343 |       await domClick(button);
  344 |       await page.waitForTimeout(500);
  345 |       clicked = true;
  346 |       break;
  347 |     }
  348 |     if (!clicked) return;
  349 |   }
  350 | }
  351 |
  352 | async function verifyEditMasking(
  353 |   page: Page,
  354 |   runtime: RuntimeQaEnv,
  355 |   definition: PrivacyPageDefinition
  356 | ): Promise<{ maskedInputs: number; disabledBefore: boolean; enabledAfter: boolean }> {
  357 |   const preferredIndex = await findBestEditCandidateIndex(page);
  358 |   const opened = await openEditModal(page, preferredIndex);
  359 |   if (!opened) {
  360 |     const bodyText = normalize(await page.locator("body").innerText());
  361 |     if (EMPTY_TEXT.test(bodyText) || !(await hasActionableRows(page))) {
  362 |       blocked(`${definition.name}은 기간 확장 후에도 수정 모달 검증 데이터가 없습니다.`);
  363 |     }
  364 |     throw new Error(`${definition.name} 목록에 데이터가 있지만 수정 버튼 또는 수정 모달을 찾지 못했습니다.`);
  365 |   }
  366 |
  367 |   const modalText = normalize(await opened.surface.innerText());
  368 |   expect(modalText, `${definition.name} 수정 모달에 마스킹 해제 기능이 있어야 합니다.`).toMatch(/마스킹 해제/);
  369 |
  370 |   const inputs = opened.surface.locator("input, textarea");
  371 |   const inputCount = await inputs.count();
  372 |   let maskedInputs = 0;
  373 |   for (let index = 0; index < inputCount; index += 1) {
  374 |     const input = inputs.nth(index);
  375 |     const value = await input.inputValue().catch(() => "");
  376 |     if (!/[＊*•]/.test(value)) continue;
  377 |     maskedInputs += 1;
  378 |   }
  379 |
  380 |   if (maskedInputs === 0 && !definition.optionalMasking) {
  381 |     await closeSurface(page, opened.surface);
  382 |     blocked(`${definition.name} 수정 모달에 수정 차단을 검증할 마스킹 입력값이 없습니다.`);
  383 |   }
  384 |
  385 |   const submit = await findEditSubmitButton(opened.surface);
  386 |   if (!submit) {
  387 |     await closeSurface(page, opened.surface);
  388 |     throw new Error(`${definition.name} 수정 모달에서 수정 또는 저장 버튼을 찾지 못했습니다.`);
  389 |   }
  390 |   const disabledBefore = await isControlDisabled(submit);
  391 |   expect(disabledBefore, `${definition.name} 수정 버튼은 마스킹 해제 전에 disabled 상태여야 합니다.`).toBe(true);
  392 |
  393 |   const unmask = await firstVisible(opened.surface.getByRole("button", { name: /^마스킹 해제$/ }));
  394 |   if (!unmask) {
  395 |     await closeSurface(page, opened.surface);
  396 |     throw new Error(`${definition.name} 수정 모달에서 마스킹 해제 버튼을 찾지 못했습니다.`);
  397 |   }
  398 |   await domClick(unmask);
  399 |   const pin = await findPinInput(page, 10_000);
  400 |   if (!pin) {
  401 |     await closeSurface(page, opened.surface);
  402 |     throw new Error(`${definition.name} 수정 모달 마스킹 해제 후 PIN 인증창이 표시되지 않았습니다.`);
  403 |   }
  404 |   await enterPin(pin, runtime.twoFactorCode ?? "secret-redacted");
  405 |   const pinRoot = await pinConfirmationRoot(page, pin);
  406 |   const confirm = await firstVisible(pinRoot.locator("button:visible").filter({ hasText: /^(확인|인증)$/ }), 5_000);
  407 |   if (!confirm) {
  408 |     await closeSurface(page, opened.surface);
  409 |     throw new Error(`${definition.name} 수정 모달 PIN 확인 버튼을 찾지 못했습니다.`);
  410 |   }
  411 |   await expect(confirm, `${definition.name} 수정 모달 PIN 확인 버튼이 활성화되어야 합니다.`).toBeEnabled();
  412 |   await domClick(confirm);
  413 |   await expect.poll(async () => {
  414 |     const candidate = await findEditSubmitButton(opened.surface);
  415 |     return candidate ? !(await isControlDisabled(candidate)) : false;
  416 |   }, {
  417 |     message: `${definition.name} 수정 버튼은 PIN 마스킹 해제 후 활성화되어야 합니다.`,
  418 |     timeout: 10_000
> 419 |   }).toBe(true);
      |      ^ Error: 총판관리 수정 버튼은 PIN 마스킹 해제 후 활성화되어야 합니다.
  420 |
  421 |   const refreshedSubmit = await findEditSubmitButton(opened.surface);
  422 |   if (!refreshedSubmit) {
  423 |     await closeSurface(page, opened.surface);
  424 |     throw new Error(`${definition.name} PIN 인증 후 수정 또는 저장 버튼을 다시 찾지 못했습니다.`);
  425 |   }
  426 |   const enabledAfter = !(await isControlDisabled(refreshedSubmit));
  427 |   await closeSurface(page, opened.surface);
  428 |   return { maskedInputs, disabledBefore, enabledAfter };
  429 | }
  430 |
  431 | async function openEditModal(page: Page, preferredIndex?: number): Promise<{ surface: Locator } | undefined> {
  432 |   const root = await interactionRoot(page);
  433 |   const editCandidates = root.locator(
  434 |     "button[aria-label*='수정'], button[title*='수정'], button:has(svg.lucide-pencil), [role='button']:has(svg.lucide-pencil), button:has(svg.lucide-book-open), button:has(svg.lucide-book), svg.lucide-pencil, svg.lucide-book-open, svg.lucide-book"
  435 |   );
  436 |   const count = Math.min(await editCandidates.count(), 20);
  437 |   const indexes = preferredIndex === undefined
  438 |     ? Array.from({ length: count }, (_, index) => index)
  439 |     : [preferredIndex];
  440 |   for (const index of indexes) {
  441 |     const candidate = await clickableCandidate(editCandidates.nth(index));
  442 |     if (!(await candidate.isVisible().catch(() => false))) continue;
  443 |     if (!(await isActiveElement(candidate))) continue;
  444 |     await candidate.click({ force: true });
  445 |     await page.waitForTimeout(700);
  446 |     const unmask = await firstVisible(page.getByRole("button", { name: /^마스킹 해제$/ }));
  447 |     if (unmask) {
  448 |       const dialog = unmask.locator("xpath=ancestor::*[@role='dialog'][1]");
  449 |       return { surface: (await dialog.count()) > 0 ? dialog : page.locator("body") };
  450 |     }
  451 |     await page.keyboard.press("Escape").catch(() => undefined);
  452 |   }
  453 |   return undefined;
  454 | }
  455 |
  456 | async function findBestEditCandidateIndex(page: Page): Promise<number | undefined> {
  457 |   const root = await interactionRoot(page);
  458 |   const candidates = root.locator(
  459 |     "button[aria-label*='수정'], button[title*='수정'], button:has(svg.lucide-pencil), [role='button']:has(svg.lucide-pencil), button:has(svg.lucide-book-open), button:has(svg.lucide-book), svg.lucide-pencil, svg.lucide-book-open, svg.lucide-book"
  460 |   );
  461 |   const count = Math.min(await candidates.count(), 12);
  462 |   let bestIndex: number | undefined;
  463 |   let bestScore = Number.NEGATIVE_INFINITY;
  464 |   for (let index = 0; index < count; index += 1) {
  465 |     const candidate = await clickableCandidate(candidates.nth(index));
  466 |     if (!(await candidate.isVisible().catch(() => false))) continue;
  467 |     if (!(await isActiveElement(candidate))) continue;
  468 |     await candidate.click({ force: true });
  469 |     await page.waitForTimeout(500);
  470 |     const unmask = await firstVisible(page.getByRole("button", { name: /^마스킹 해제$/ }));
  471 |     if (!unmask) {
  472 |       await page.keyboard.press("Escape").catch(() => undefined);
  473 |       continue;
  474 |     }
  475 |     const dialog = unmask.locator("xpath=ancestor::*[@role='dialog'][1]");
  476 |     const surface = (await dialog.count()) > 0 ? dialog : page.locator("body");
  477 |     const inputs = surface.locator("input:visible, textarea:visible");
  478 |     const inputCount = await inputs.count();
  479 |     let nonEmpty = 0;
  480 |     let empty = 0;
  481 |     for (let inputIndex = 0; inputIndex < inputCount; inputIndex += 1) {
  482 |       const input = inputs.nth(inputIndex);
  483 |       const value = normalize(await input.inputValue().catch(() => ""));
  484 |       if (value) nonEmpty += 1;
  485 |       else empty += 1;
  486 |     }
  487 |     const score = nonEmpty * 10 - empty * 20;
  488 |     if (score > bestScore) {
  489 |       bestScore = score;
  490 |       bestIndex = index;
  491 |     }
  492 |     await closeSurface(page, surface);
  493 |     await page.waitForTimeout(200);
  494 |   }
  495 |   return bestIndex;
  496 | }
  497 |
  498 | async function clickableCandidate(candidate: Locator): Promise<Locator> {
  499 |   const tag = await candidate.evaluate((element) => element.tagName.toLowerCase()).catch(() => "");
  500 |   if (tag !== "svg") return candidate;
  501 |   const button = candidate.locator("xpath=ancestor::button[1]");
  502 |   return (await button.count()) > 0 ? button : candidate;
  503 | }
  504 |
  505 | async function findEditSubmitButton(surface: Locator): Promise<Locator | undefined> {
  506 |   return firstVisible(
  507 |     surface.locator("button:visible").filter({ hasText: /^(수정|저장)$/ }),
  508 |     3_000
  509 |   );
  510 | }
  511 |
  512 | async function isControlDisabled(control: Locator): Promise<boolean> {
  513 |   if (await control.isDisabled().catch(() => false)) return true;
  514 |   return control.evaluate((element) => {
  515 |     const target = element as HTMLButtonElement;
  516 |     return target.disabled || element.getAttribute("aria-disabled") === "true" || element.hasAttribute("disabled");
  517 |   }).catch(() => false);
  518 | }
  519 |
```