# 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: 3983/dev/url-search.spec.ts >> [3983][dev] 가맹점 동일카드 결제 제한시간 선택·직접입력·엑셀·결제 집계 QA >> 직접입력에 공백, 소수, 0, 음수, 525601분 이상을 입력하면 저장되지 않고 안내가 표시되는지 확인
- Location: src/qa/scenarios/merchantSameCardTimeLimit.ts:241:5

# Error details

```
Error: 공백·소수·0·음수·상한 초과 입력은 저장 가능한 값으로 조용히 변환되지 않고 안내가 보여야 합니다.

expect(received).toEqual(expected) // deep equality

- Expected  - 1
+ Received  + 4

- Array []
+ Array [
+   "'1.5' 입력에 안내가 없음(실제 입력값 '15')",
+   "'-1' 입력에 안내가 없음(실제 입력값 '1')",
+ ]
```

# Test source

```ts
  174 |           { requested: "30분", value: "MIN30" },
  175 |           { requested: "1시간", value: "H1" },
  176 |           { requested: "3시간", value: "H3" },
  177 |           { requested: "6시간", value: "H6" },
  178 |           { requested: "12시간", value: "H12" },
  179 |           { requested: "24시간", value: "H24" },
  180 |           { requested: "48시간", value: "H48" },
  181 |           { requested: "72시간", value: "H72" },
  182 |           { requested: "1주일", fallback: "1주", value: "W1" },
  183 |           { requested: "1개월", value: "M1" }
  184 |         ];
  185 |         const evidence: string[] = [];
  186 |         const missingLabels: string[] = [];
  187 |
  188 |         for (const entry of cases) {
  189 |           const modal = await openVendorEditModal(page, adminEnv, merchantPage);
  190 |           const available = await openSameCardTimeOptions(page, modal);
  191 |           const selectedLabel = available.includes(entry.requested)
  192 |             ? entry.requested
  193 |             : entry.fallback && available.includes(entry.fallback)
  194 |               ? entry.fallback
  195 |               : undefined;
  196 |           if (!available.includes(entry.requested)) {
  197 |             missingLabels.push(entry.requested);
  198 |           }
  199 |           if (!selectedLabel) {
  200 |             evidence.push(`${entry.requested}: 선택 옵션 없음 / 실제 옵션=${available.join(", ")}`);
  201 |             await closeModal(page, modal);
  202 |             continue;
  203 |           }
  204 |
  205 |           await chooseOpenOption(page, selectedLabel);
  206 |           await saveVendorModal(page, modal, `QA #3983 고정 제한시간 ${entry.requested} 저장`);
  207 |           const saved = await loadVendorDetail(page, adminEnv);
  208 |           evidence.push(`${entry.requested}: 화면 선택='${selectedLabel}', API=${saved.paymentPolicy.sameCardTimeLimit}`);
  209 |           expect(saved.paymentPolicy.sameCardTimeLimit, `${entry.requested} 저장값`).toBe(entry.value);
  210 |           expect(saved.paymentPolicy.sameCardTimeLimitMinutes, `${entry.requested} 고정값은 직접입력 분이 없어야 합니다.`).toBeNull();
  211 |         }
  212 |
  213 |         const finalModal = await openVendorEditModal(page, adminEnv, merchantPage);
  214 |         await attachScreenshot(page, testInfo, "fixed-options-final.png");
  215 |         await attachText(testInfo, "fixed-options.md", evidence);
  216 |         await closeModal(page, finalModal);
  217 |         expect(missingLabels, `요청된 고정 옵션명이 모두 그대로 노출되어야 합니다. 실제 화면의 1주 옵션은 1주일과 다릅니다.`).toEqual([]);
  218 |       });
  219 |     });
  220 |
  221 |     test(checklist.checklist[1], async ({ browser }, testInfo) => {
  222 |       test.setTimeout(120_000);
  223 |       await withTracedPage(browser, adminEnv, testInfo, "custom-valid-range", async (page) => {
  224 |         const evidence: string[] = [];
  225 |         for (const minutes of [525_600, 1]) {
  226 |           const modal = await openVendorEditModal(page, adminEnv, merchantPage);
  227 |           await selectCustomMinutes(page, modal, minutes);
  228 |           await saveVendorModal(page, modal, `QA #3983 직접입력 ${minutes}분 저장`);
  229 |           const saved = await loadVendorDetail(page, adminEnv);
  230 |           evidence.push(`${minutes}분 저장: type=${saved.paymentPolicy.sameCardTimeLimit}, minutes=${saved.paymentPolicy.sameCardTimeLimitMinutes}`);
  231 |           expect(saved.paymentPolicy.sameCardTimeLimit).toBe("CUSTOM");
  232 |           expect(saved.paymentPolicy.sameCardTimeLimitMinutes).toBe(minutes);
  233 |         }
  234 |         const modal = await openVendorEditModal(page, adminEnv, merchantPage);
  235 |         await expect(modal.locator("input[name='sameCardTimeLimitMinutes']"), "직접입력 분 입력란이 표시되어야 합니다.").toBeVisible();
  236 |         await attachScreenshot(page, testInfo, "custom-valid-range.png");
  237 |         await attachText(testInfo, "custom-valid-range.md", evidence);
  238 |       });
  239 |     });
  240 |
  241 |     test(checklist.checklist[2], async ({ browser }, testInfo) => {
  242 |       test.setTimeout(90_000);
  243 |       await withTracedPage(browser, adminEnv, testInfo, "custom-invalid-values", async (page) => {
  244 |         const modal = await openVendorEditModal(page, adminEnv, merchantPage);
  245 |         await selectCustomMinutes(page, modal, 1);
  246 |         const input = modal.locator("input[name='sameCardTimeLimitMinutes']");
  247 |         const cases = [
  248 |           { raw: "   ", expected: /직접입력 동일카드 제한시간은 1분 이상이어야 합니다\./ },
  249 |           { raw: "1.5", expected: /직접입력 동일카드 제한시간은 1분 이상이어야 합니다\./ },
  250 |           { raw: "0", expected: /직접입력 동일카드 제한시간은 1분 이상이어야 합니다\./ },
  251 |           { raw: "-1", expected: /직접입력 동일카드 제한시간은 1분 이상이어야 합니다\./ },
  252 |           { raw: "525601", expected: /직접입력 동일카드 제한시간은 최대 1년\(525,600분\)입니다\./ }
  253 |         ];
  254 |         const violations: string[] = [];
  255 |         const evidence: string[] = [];
  256 |
  257 |         for (const entry of cases) {
  258 |           await input.fill(entry.raw);
  259 |           await page.waitForTimeout(500);
  260 |           const actualValue = await input.inputValue();
  261 |           const surroundingText = await modal.innerText();
  262 |           const hasGuidance = entry.expected.test(surroundingText);
  263 |           evidence.push(`입력 '${entry.raw.replace(/ /g, "<space>")}' -> 실제 '${actualValue}', 안내='${snippet(surroundingText, 240)}'`);
  264 |           if (!hasGuidance) {
  265 |             violations.push(`'${entry.raw.replace(/ /g, "<space>")}' 입력에 안내가 없음(실제 입력값 '${actualValue}')`);
  266 |           }
  267 |         }
  268 |
  269 |         const unchanged = await loadVendorDetail(page, adminEnv);
  270 |         expect(unchanged.paymentPolicy.sameCardTimeLimit, "유효하지 않은 값 검증 중 저장 요청이 없어야 합니다.").toBe(baselinePolicy?.sameCardTimeLimit);
  271 |         expect(unchanged.paymentPolicy.sameCardTimeLimitMinutes).toBe(baselinePolicy?.sameCardTimeLimitMinutes);
  272 |         await attachScreenshot(page, testInfo, "custom-invalid-values.png");
  273 |         await attachText(testInfo, "custom-invalid-values.md", evidence);
> 274 |         expect(violations, "공백·소수·0·음수·상한 초과 입력은 저장 가능한 값으로 조용히 변환되지 않고 안내가 보여야 합니다.").toEqual([]);
      |                                                                                        ^ Error: 공백·소수·0·음수·상한 초과 입력은 저장 가능한 값으로 조용히 변환되지 않고 안내가 보여야 합니다.
  275 |       });
  276 |     });
  277 |
  278 |     test(checklist.checklist[3], async ({ browser }, testInfo) => {
  279 |       test.setTimeout(90_000);
  280 |       await withTracedPage(browser, adminEnv, testInfo, "custom-persistence", async (page) => {
  281 |         let modal = await openVendorEditModal(page, adminEnv, merchantPage);
  282 |         await selectCustomMinutes(page, modal, 90);
  283 |         await saveVendorModal(page, modal, "QA #3983 직접입력 90분 재진입 유지 검증");
  284 |
  285 |         modal = await openVendorEditModal(page, adminEnv, merchantPage);
  286 |         const typeValue = await modal.locator("input[name='sameCardTimeLimit']").inputValue();
  287 |         const minutesValue = numericInputValue(await modal.locator("input[name='sameCardTimeLimitMinutes']").inputValue());
  288 |         const detail = await loadVendorDetail(page, adminEnv);
  289 |         await attachScreenshot(page, testInfo, "custom-persistence.png");
  290 |         await attachText(testInfo, "custom-persistence.md", [
  291 |           `재진입 화면 type=${typeValue}, minutes=${minutesValue}`,
  292 |           `상세 API type=${detail.paymentPolicy.sameCardTimeLimit}, minutes=${detail.paymentPolicy.sameCardTimeLimitMinutes}`
  293 |         ]);
  294 |         expect(typeValue).toBe("CUSTOM");
  295 |         expect(minutesValue).toBe(90);
  296 |         expect(detail.paymentPolicy.sameCardTimeLimit).toBe("CUSTOM");
  297 |         expect(detail.paymentPolicy.sameCardTimeLimitMinutes).toBe(90);
  298 |       });
  299 |     });
  300 |
  301 |     test(checklist.checklist[4], async ({ browser }, testInfo) => {
  302 |       test.setTimeout(180_000);
  303 |       assertPaymentMutationReady(paymentConfig);
  304 |       await setPolicy(browser, adminEnv, { sameCardTimeLimit: "CUSTOM", sameCardTimeLimitMinutes: 1, sameCardCountLimit: 1, sameCardLimit: 5_000_000 }, "1분 1회");
  305 |       await withTracedPage(browser, storeEnv, testInfo, "count-limit-one", async (page) => {
  306 |         const attempts = await runPaymentAttempts(page, storeEnv, paymentPage, paymentConfig, options.issueId, 2);
  307 |         await attachPaymentEvidence(testInfo, "count-limit-one", attempts);
  308 |         expect(attempts.map((attempt) => attempt.isSuccess), "1회까지 성공하고 두 번째는 차단되어야 합니다.").toEqual([true, false]);
  309 |         expect(attempts[1].message, "두 번째 결제에 동일카드 횟수 초과 사유가 보여야 합니다.").toMatch(/동일\s*카드.*횟수.*초과|횟수.*초과/);
  310 |       });
  311 |     });
  312 |
  313 |     test(checklist.checklist[5], async ({ browser }, testInfo) => {
  314 |       test.setTimeout(240_000);
  315 |       assertPaymentMutationReady(paymentConfig);
  316 |       await setPolicy(browser, adminEnv, { sameCardTimeLimit: "CUSTOM", sameCardTimeLimitMinutes: 1, sameCardCountLimit: 3, sameCardLimit: 5_000_000 }, "1분 3회");
  317 |       await withTracedPage(browser, storeEnv, testInfo, "count-limit-three", async (page) => {
  318 |         const attempts = await runPaymentAttempts(page, storeEnv, paymentPage, paymentConfig, options.issueId, 4);
  319 |         await attachPaymentEvidence(testInfo, "count-limit-three", attempts);
  320 |         expect(attempts.map((attempt) => attempt.isSuccess), "3회까지 성공하고 네 번째는 차단되어야 합니다.").toEqual([true, true, true, false]);
  321 |         expect(attempts[3].message).toMatch(/동일\s*카드.*횟수.*초과|횟수.*초과/);
  322 |       });
  323 |     });
  324 |
  325 |     test(checklist.checklist[6], async ({ browser }, testInfo) => {
  326 |       test.setTimeout(180_000);
  327 |       assertPaymentMutationReady(paymentConfig);
  328 |       await setPolicy(browser, adminEnv, {
  329 |         sameCardTimeLimit: "CUSTOM",
  330 |         sameCardTimeLimitMinutes: 1,
  331 |         sameCardCountLimit: 5,
  332 |         sameCardLimit: paymentConfig.amount
  333 |       }, "동일카드 금액 한도");
  334 |       await withTracedPage(browser, storeEnv, testInfo, "amount-limit", async (page) => {
  335 |         const attempts = await runPaymentAttempts(page, storeEnv, paymentPage, paymentConfig, options.issueId, 2);
  336 |         await attachPaymentEvidence(testInfo, "amount-limit", attempts);
  337 |         expect(attempts.map((attempt) => attempt.isSuccess), "첫 결제까지 한도에 포함되고 두 번째 결제는 합계 초과로 차단되어야 합니다.").toEqual([true, false]);
  338 |         expect(attempts[1].message).toMatch(/동일\s*카드.*한도.*초과|결제\s*한도.*초과|금액.*초과/);
  339 |       });
  340 |     });
  341 |
  342 |     test(checklist.checklist[7], async ({ browser }, testInfo) => {
  343 |       test.setTimeout(260_000);
  344 |       assertPaymentMutationReady(paymentConfig);
  345 |       await setPolicy(browser, adminEnv, { sameCardTimeLimit: "CUSTOM", sameCardTimeLimitMinutes: 1, sameCardCountLimit: 1, sameCardLimit: 5_000_000 }, "1분 경과 재결제");
  346 |       await withTracedPage(browser, storeEnv, testInfo, "window-expiry", async (page) => {
  347 |         const first = await runPaymentAttempts(page, storeEnv, paymentPage, paymentConfig, options.issueId, 1);
  348 |         await page.waitForTimeout(65_000);
  349 |         const second = await runPaymentAttempts(page, storeEnv, paymentPage, paymentConfig, options.issueId, 1);
  350 |         const attempts = [...first, ...second];
  351 |         await attachPaymentEvidence(testInfo, "window-expiry", attempts, ["1차 승인 후 65초 대기"]);
  352 |         expect(attempts.map((attempt) => attempt.isSuccess), "1분 집계 기간이 지난 뒤 동일카드 결제가 다시 승인되어야 합니다.").toEqual([true, true]);
  353 |       });
  354 |     });
  355 |
  356 |     test(checklist.checklist[8], async ({ browser }, testInfo) => {
  357 |       test.setTimeout(180_000);
  358 |       assertPaymentMutationReady(paymentConfig);
  359 |       await setPolicy(browser, adminEnv, { sameCardTimeLimit: "UNLIMITED", sameCardTimeLimitMinutes: null, sameCardCountLimit: 1, sameCardLimit: 1 }, "제한 없음 회귀");
  360 |       await withTracedPage(browser, storeEnv, testInfo, "unlimited", async (page) => {
  361 |         const attempts = await runPaymentAttempts(page, storeEnv, paymentPage, paymentConfig, options.issueId, 2);
  362 |         await attachPaymentEvidence(testInfo, "unlimited", attempts);
  363 |         expect(attempts.map((attempt) => attempt.isSuccess), "제한 없음에서는 1회/1원 설정이 있어도 동일카드 제한이 적용되지 않아야 합니다.").toEqual([true, true]);
  364 |       });
  365 |     });
  366 |
  367 |     test(checklist.checklist[9], async ({ browser }, testInfo) => {
  368 |       test.setTimeout(180_000);
  369 |       await withTracedPage(browser, adminEnv, testInfo, "excel-fixed-options", async (page) => {
  370 |         const workbook = await downloadVendorWorkbook(page, adminEnv, testInfo, "fixed-options");
  371 |         const validation = sameCardTimeValidationFormula(workbook.buffer);
  372 |         const expected = "제한 없음,30분,1시간,3시간,6시간,12시간,24시간,48시간,72시간,1주일,1개월";
  373 |         await attachText(testInfo, "excel-fixed-options.md", [
  374 |           `문서 ID: ${workbook.documentId}`,
```