# 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: 3597/dev/merchant-preset-settlement-info.spec.ts >> [3597][dev] [QA] 가맹점 프리셋 수익 정산정보 및 생성 모달 프리셋 적용 검증 >> 가맹점 프리셋 생성/수정 화면에서 수익 정산정보 항목을 입력하고 저장할 수 있는지 확인 / 수익 정산정보 항목에 정산방식, 최소정산금, 정산주기, 휴일정산, 입금계좌적요, 은행명, 계좌번호, 예금주가 보이는지 확인 / 정산 시작시간과 정산 종료시간이 수익 정산정보 영역이 아니라 기존 정산 제한 영역에 분리되어 보이는지 확인
- Location: src/qa/scenarios/merchantPresetSettlementInfo.ts:45:5

# Error details

```
Error: 수익 정산정보 항목이 모두 보여야 합니다.

expect(received).toHaveLength(expected)

Expected length: 0
Received length: 1
Received array:  ["입금계좌적요"]
```

# Test source

```ts
  1   | import { expect, test, type Browser, type BrowserContext, type Locator, type Page, type TestInfo } from "@playwright/test";
  2   | import fs from "node:fs";
  3   | import { isLoginPage, loginWithCredentials } from "../auth";
  4   | import { loadChecklist, loadIssueMetadata } from "../checklist";
  5   | import { getRuntimeQaEnv, type RuntimeQaEnv } from "../env";
  6   | import type { QaEnvironment, QaPageDefinition } from "../types";
  7   | import { buildPageUrl } from "../url";
  8   |
  9   | test.use({ trace: "off", video: "off", screenshot: "off" });
  10  |
  11  | interface ScenarioOptions {
  12  |   issueId: string;
  13  |   environment: QaEnvironment;
  14  | }
  15  |
  16  | interface TraceOptions {
  17  |   testInfo: TestInfo;
  18  |   pageName: string;
  19  |   role: string;
  20  |   suffix: string;
  21  | }
  22  |
  23  | interface FieldValue {
  24  |   label: string;
  25  |   value: string;
  26  |   tagName: string;
  27  |   editable: boolean;
  28  | }
  29  |
  30  | const ADMIN_ROLE = "ADMIN";
  31  | const SETTLEMENT_SECTION_LABEL = "수익 정산정보";
  32  | const SETTLEMENT_FIELDS = ["정산방식", "최소정산금", "정산주기", "휴일정산", "입금계좌적요", "은행명", "계좌번호", "예금주"];
  33  | const SEPARATED_LIMIT_FIELDS = ["정산 시작시간", "정산 종료시간"];
  34  |
  35  | export function runMerchantPresetSettlementInfoSuite(options: ScenarioOptions): void {
  36  |   const metadata = loadIssueMetadata(options.issueId);
  37  |   const checklist = loadChecklist(options.issueId, options.environment);
  38  |   const merchantPage = findPageDefinition(checklist.pages, "가맹점 관리");
  39  |   const runtime = getRuntimeQaEnv(options.environment, "admin", ADMIN_ROLE);
  40  |   const blockReason = getRuntimeBlockReason(runtime);
  41  |
  42  |   test.describe(`[${options.issueId}][${options.environment}] ${metadata.subject}`, () => {
  43  |     test.skip(!!blockReason, blockReason);
  44  |
  45  |     test(`${checklist.checklist[0]} / ${checklist.checklist[1]} / ${checklist.checklist[2]}`, async ({ browser }, testInfo) => {
  46  |       test.setTimeout(120_000);
  47  |       await withTracedPage(browser, runtime, testInfo, merchantPage.name, ADMIN_ROLE, "preset-settlement-fields", async (page) => {
  48  |         await openTargetPage(page, runtime, merchantPage, /가맹점|상호|프리셋|사업자/);
  49  |         const opened = await openCreateSurface(page, /프리셋|수익\s*정산정보|가맹점/);
  50  |         test.skip(!opened, "BLOCKED: 가맹점 생성 모달을 열 수 없습니다.");
  51  |         const applyResult = await applyPresetIfPossible(page);
  52  |
  53  |         const bodyText = await visibleBodyText(page);
  54  |         const missingFields = await findMissingVisibleFieldLabels(page, SETTLEMENT_FIELDS);
  55  |         const missingLimitFields = await findMissingVisibleFieldLabels(page, SEPARATED_LIMIT_FIELDS);
  56  |         const saveVisible = await hasVisibleButton(page, /저장|수정|생성|등록/);
  57  |         const sectionText = extractSectionSnippet(bodyText, SETTLEMENT_SECTION_LABEL, 900);
  58  |         const limitSnippet = extractSectionSnippet(bodyText, "정산 제한", 700);
  59  |         const limitFieldsInsideSettlement = SEPARATED_LIMIT_FIELDS.filter((label) => sectionText.includes(label));
  60  |
  61  |         await attachEvidenceLog(testInfo, merchantPage.name, ADMIN_ROLE, "preset-settlement-fields", [
  62  |           "가맹점 생성 모달의 프리셋 적용 화면에서 수익 정산정보 항목과 저장 가능 상태를 확인했습니다.",
  63  |           `가맹점 생성 모달 열림: ${opened ? "예" : "아니오"}`,
  64  |           `프리셋 적용 동작: ${applyResult}`,
  65  |           `저장/등록 버튼 노출: ${saveVisible ? "예" : "아니오"}`,
  66  |           `수익 정산정보 필수 항목: ${SETTLEMENT_FIELDS.join(", ")}`,
  67  |           `미노출 항목: ${missingFields.join(", ") || "-"}`,
  68  |           `정산 제한 영역 필드: ${SEPARATED_LIMIT_FIELDS.join(", ")}`,
  69  |           `정산 제한 필드 미노출: ${missingLimitFields.join(", ") || "-"}`,
  70  |           `수익 정산정보 영역 안에 잘못 포함된 정산 제한 필드: ${limitFieldsInsideSettlement.join(", ") || "-"}`,
  71  |           `수익 정산정보 영역 일부: ${sectionText || "-"}`,
  72  |           `정산 제한 영역 일부: ${limitSnippet || "-"}`,
  73  |           `화면 일부: ${evidenceSnippet(bodyText, 2200)}`
  74  |         ]);
  75  |         await attachPageScreenshot(page, testInfo, merchantPage.name, ADMIN_ROLE, "preset-settlement-fields");
  76  |
  77  |         expect(bodyText, "수익 정산정보 영역이 보여야 합니다.").toContain(SETTLEMENT_SECTION_LABEL);
> 78  |         expect(missingFields, "수익 정산정보 항목이 모두 보여야 합니다.").toHaveLength(0);
      |                                                          ^ Error: 수익 정산정보 항목이 모두 보여야 합니다.
  79  |         expect(saveVisible, "가맹점 프리셋 생성/수정 화면에는 저장 가능한 버튼이 보여야 합니다.").toBe(true);
  80  |         expect(missingLimitFields, "정산 시작/종료시간은 기존 정산 제한 영역 필드로 보여야 합니다.").toHaveLength(0);
  81  |         expect(limitFieldsInsideSettlement, "정산 시작/종료시간은 수익 정산정보 영역 안에 있으면 안 됩니다.").toHaveLength(0);
  82  |       });
  83  |     });
  84  |
  85  |     test(`${checklist.checklist[3]} / ${checklist.checklist[4]} / ${checklist.checklist[5]}`, async ({ browser }, testInfo) => {
  86  |       test.setTimeout(150_000);
  87  |       await withTracedPage(browser, runtime, testInfo, merchantPage.name, ADMIN_ROLE, "merchant-create-preset-apply", async (page) => {
  88  |         await openTargetPage(page, runtime, merchantPage, /가맹점|상호|프리셋|사업자/);
  89  |         const opened = await openCreateSurface(page, /프리셋|수익\s*정산정보|가맹점/);
  90  |         test.skip(!opened, "BLOCKED: 가맹점 생성 모달을 열 수 없습니다.");
  91  |
  92  |         const applyResult = await applyPresetIfPossible(page);
  93  |         const afterApply = await collectFieldValues(page, SETTLEMENT_FIELDS);
  94  |         const afterApplyNonEmpty = afterApply.filter((field) => field.value).length;
  95  |         const editableTarget = afterApply.find((field) => field.editable && field.value);
  96  |         let restoreEvidence = "수정 가능한 수익 정산정보 값을 찾지 못해 재적용 복원 검증은 값 채움 상태만 확인했습니다.";
  97  |         let restoredValue = "";
  98  |
  99  |         if (editableTarget) {
  100 |           await overwriteFieldValue(page, editableTarget.label, "QA3597");
  101 |           const changed = await readFieldValue(page, editableTarget.label);
  102 |           await applyPresetIfPossible(page);
  103 |           restoredValue = await readFieldValue(page, editableTarget.label);
  104 |           restoreEvidence = `${editableTarget.label} 값을 '${changed}'로 변경 후 같은 프리셋을 다시 적용했을 때 '${restoredValue}'로 복원되는지 확인했습니다.`;
  105 |           expect(restoredValue, "같은 프리셋을 다시 선택하면 수정한 값이 프리셋 값으로 복원되어야 합니다.").toBe(editableTarget.value);
  106 |         }
  107 |
  108 |         await page.waitForTimeout(700);
  109 |         const finalValues = await collectFieldValues(page, SETTLEMENT_FIELDS);
  110 |         const finalMismatches = afterApply
  111 |           .filter((field) => field.value)
  112 |           .filter((field) => finalValues.find((finalValue) => finalValue.label === field.label)?.value !== field.value)
  113 |           .map((field) => field.label);
  114 |         const bodyText = await visibleBodyText(page);
  115 |
  116 |         await attachEvidenceLog(testInfo, merchantPage.name, ADMIN_ROLE, "merchant-create-preset-apply", [
  117 |           "가맹점 생성 모달에서 프리셋 불러오기 후 수익 정산정보 값이 채워지고 유지되는지 확인했습니다.",
  118 |           `프리셋 적용 동작: ${applyResult}`,
  119 |           `프리셋 적용 후 수익 정산정보 값: ${formatFieldValues(afterApply)}`,
  120 |           `값이 채워진 항목 수: ${afterApplyNonEmpty}`,
  121 |           restoreEvidence,
  122 |           `최종 유지 불일치 항목: ${finalMismatches.join(", ") || "-"}`,
  123 |           `화면 일부: ${evidenceSnippet(bodyText, 2200)}`
  124 |         ]);
  125 |         await attachPageScreenshot(page, testInfo, merchantPage.name, ADMIN_ROLE, "merchant-create-preset-apply");
  126 |
  127 |         expect(bodyText, "가맹점 생성 모달에 수익 정산정보 영역이 보여야 합니다.").toContain(SETTLEMENT_SECTION_LABEL);
  128 |         expect(afterApplyNonEmpty, "프리셋 적용 후 수익 정산정보 입력칸 값이 채워져야 합니다.").toBeGreaterThanOrEqual(4);
  129 |         expect(finalMismatches, "프리셋 적용 후 생성 모달 안에서 수익 정산정보 값이 유지되어야 합니다.").toHaveLength(0);
  130 |       });
  131 |     });
  132 |   });
  133 | }
  134 |
  135 | async function openTargetPage(page: Page, runtimeEnv: RuntimeQaEnv, pageDefinition: QaPageDefinition, expectedText: RegExp): Promise<void> {
  136 |   const targetUrl = buildPageUrl(runtimeEnv.baseUrl!, pageDefinition, runtimeEnv.tenantId!);
  137 |   await openAuthenticatedUrl(page, runtimeEnv, targetUrl);
  138 |   if (!(await bodyMatches(page, expectedText))) {
  139 |     await openByMenuText(page, pageDefinition.name, expectedText);
  140 |   }
  141 |   await expect(page.locator("body"), `${pageDefinition.name} 화면이 표시되어야 합니다.`).toContainText(expectedText, {
  142 |     timeout: 20_000
  143 |   });
  144 | }
  145 |
  146 | async function openAuthenticatedUrl(page: Page, runtimeEnv: RuntimeQaEnv, targetUrl: string): Promise<void> {
  147 |   if (!runtimeEnv.baseUrl) {
  148 |     throw new Error("baseUrl이 필요합니다.");
  149 |   }
  150 |   if (!runtimeEnv.storageState) {
  151 |     await loginWithCredentials(page, runtimeEnv);
  152 |   }
  153 |
  154 |   await page.goto(targetUrl, { waitUntil: "domcontentloaded" });
  155 |   await waitForNavigationToSettle(page);
  156 |
  157 |   if (await isLoginPage(page, runtimeEnv)) {
  158 |     await loginWithCredentials(page, runtimeEnv);
  159 |     await page.goto(targetUrl, { waitUntil: "domcontentloaded" });
  160 |     await waitForNavigationToSettle(page);
  161 |   }
  162 |
  163 |   expect(await isLoginPage(page, runtimeEnv), `${runtimeEnv.application} 업무 화면 진입 전 로그인 화면을 벗어나야 합니다.`).toBe(false);
  164 | }
  165 |
  166 | async function openByMenuText(page: Page, menuText: string, expectedText: RegExp): Promise<void> {
  167 |   const links = [
  168 |     page.getByRole("link", { name: new RegExp(menuText.replace(/\s+/g, "\\s*")) }),
  169 |     page.getByText(menuText, { exact: true })
  170 |   ];
  171 |   for (const link of links) {
  172 |     const count = await link.count().catch(() => 0);
  173 |     for (let index = 0; index < count; index += 1) {
  174 |       const candidate = link.nth(index);
  175 |       if (!(await candidate.isVisible({ timeout: 500 }).catch(() => false))) {
  176 |         continue;
  177 |       }
  178 |       await candidate.click();
```