← 01 THINKHow does he reason?

The screen said everything was fine

One filter had been dead for 163 days. No error, no empty state. Some bugs cannot be verified from what the page renders.

Organisation identifiers, hosts and accounts, ticket numbers and member data are excluded. Listing numbers are synthetic test values. The structure and the code patterns are exactly as they are.

The guard looked like this.

if (filters.isLIVE !== 'all') params.isLIVE = filters.isLIVE;

The name the server accepts is is_live. This API takes both snake_case and camelCase, and the camelCase of is_live is isLive. isLIVE is neither.

git log -S says it had that name from the commit that introduced it. 163 days.

Why nobody noticed

There are 19 places in the list query that assign a value, and only this one departed from the convention.

searchUserType  contractType  buildingType  roomType  naverStatus
houseCDate  saleNumber  hidxNumber  …          ← ordinary camelCase
verification_type                              ← snake_case, matching the API docs
isLIVE                                         ← neither

Two conventions mixed in one file is visible when you read it. A third convention is not. It does not look like a typo; it looks like one consistent style of its own.

But a wrong name does not by itself explain 163 days. The screen had its own reason for staying quiet.

This server does not reject an unknown query parameter with a 400. It ignores it silently. The response is then the unfiltered list. And on screen, "the filter did not apply" and "everything matches the filter" are indistinguishable.

On screen — the two are the same40 rowsfilter ignored40 rowsall of them matchno way to tell them apartOutgoing query — they differisLIVE=NOT_LIVE → server discardsis_live=NOT_LIVE → server readsonly provable hereClick LIVE, see the same list, conclude they must all be LIVE.That conclusion held for 163 days — because only the screen was checked.

So the test went on the wire rather than on the screen.

const q = new URLSearchParams(listQueries[listQueries.length - 1] ?? '');
expect(q.get('is_live')).toBe('NOT_LIVE');
expect(q.has('isLIVE')).toBe(false);   // and that the old name is not still there

Requests are intercepted, the query strings collected, and read directly. The screen is not looked at.

The second line matters more than the first. Assert only that the new name goes out, and adding the correct name without removing the old one still passes. The server ignores what it does not know, so it works in that state, and the next person learns that both names have to be kept.

A test is only a test after you have seen it fail

For everything fixed today, the fix was deliberately reverted first to watch the test fail. Seven times.

Expected 1             Received 0             the detail table has no previous-number row
Expected "2000-01-01"  Received "2026-08-04"  the date range narrowed to one month
Expected "9100"        Received null          the identifier never left on the click path
Expected "9100"        Received null          the value never reached the input
Expected "created"     Received "hidx"        searching reset the sort
Expected disabled      Received enabled       a value the server rejects was clickable
Expected "NOT_LIVE"    Received null          the filter sent no parameter

The habit paid twice today.

Once it caught someone else's bug. Expected "2000-01-01" Received "2026-08-04" — that date had been sitting in the first URL I was handed, and I had not connected it. Reverting produced the same value again.

Once it caught a bug in my own test. Reverting the sort test made it fail at the wrong point.

await expect.poll(() => listQueries.length).toBeGreaterThan(1);   // wrong

If the initial load issues two requests, this poll passes the instant after the click and reads the stale last query. The search case captured the count before clicking; only the sort case did not.

A passing test proves nothing. Only a test you have watched fail proves anything. And watching it fail is what exposes the defects in the test itself.

A test that outlives its feature argues for the wrong side

Told that "the filter is empty when you arrive by link", I added an identifier input to the filter panel. Passed, committed.

Then, checking for side effects, I found this.

2026-06-01  removed the identifier input field from the all-listings search

I had revived a field that was deliberately deleted three months ago. The reason was simple — the listing number IS that identifier. The same value was being taken in two inputs, and that commit had cleaned up the duplication.

Part of why I missed it was on the repository's side. An e2e spec asserting that the field is displayed was still there. It was not cleaned up when the field was removed. The code said "gone" and the test said "must be present", and the suite it lived in had not been running for a stretch because of a wiring gap, so nobody saw the contradiction.

The final fix was not to add an input at all. Putting the incoming value into the existing listing-number filter shows it in that field, where it can also be cleared. The problem went away without another input.

Before adding something, check whether it was ever deleted. git log -S "<the exact symbol>" -- <file> is one line.

A sentinel is billed in full to the code that does not know it is special

"All" in the date filter was implemented like this.

export const DATE_RANGE_OPTIONS = [
  { value: '2000-01-01', label: 'All' },   // a date older than any record, as the lower bound
  { value: '1m', label: 'Within a month' },
];

The option values mix a relative token with an absolute date, and the first line of the conversion function special-cases this one value. The two sibling screens put value: '' in the same place and send no parameter at all. Only this screen was the exception.

This repository uses a sentinel in the other direction too — no time limit is stored as 9999-12-31. And the trace of that already going wrong is in the code: a timeline comment records an axis range breaking because something believed an unlimited end date was a real one.

A sentinel costs nothing in the code that knows it is special, and is billed in full to the code that does not. "All" should not be a magic value; it should be the absence of a parameter.

This was also half of the arrive-by-link bug. Coming in by identifier, the date default 1m came along, and older records were filtered out to zero. The targets are by definition old, so it was a combination that failed almost always.

When a display string travels on the wire

The status-change API takes a Korean label in code, not a numeric code.

POST  …/naver-status     body { "code": "검증실패" }
allowed  등록완료 / 등록취소 / 등록실패 / 검증실패
anything else → 400 INVALID_INPUT_VALUE

But the code-to-label table existed in two places: the filter options in the constants file, and a local map inside the column definitions. The values matched, so nothing had gone wrong yet.

When display labels diverge, usually only the words on screen differ. But this label is the value that goes to the API. Fix one side and you get a 400. The local map was deleted and folded into the single constant.

When a string is both a display value and a wire value, it gets exactly one definition.

Buttons that looked clickable and did nothing

The status-change UI needed nothing new. The section and the button component were already there, and the component already accepted an onChange prop. Nobody was passing one, so it was display-only.

Every value was a <button> with cursor-pointer and a hover style. Clicking did nothing.

Wiring it up, changeable was added so that only the values the server accepts are enabled and the rest render disabled. The point is that the server accepts fewer values than the UI displays — 3 of 8 for one status, 4 of 9 for the other. The current value is disabled too: sending the same value again is a round trip that changes nothing.

The screen said everything was fine

The seven things fixed today shared one property. No error, no empty state, no spinner. It compiled and the console was clean. The screen said everything was fine, and every time, it looked right.

So every rule left behind is about what counts as evidence.

  • Verify filters and search from the outgoing query. On a server that silently ignores unknown parameters, what renders is not evidence
  • Revert the fix and watch the test fail. Otherwise you do not know what that test protects
  • Check the deletion history before adding. Thirty seconds would have prevented reviving a three-month-old decision
  • Delete a feature's tests when you delete the feature. What is left argues for the wrong side later
  • Express "all" as the absence of a parameter
  • A display string that is also a wire value gets one definition

What there is no evidence for yet

Mocked tests prove the front-end path only. Four things still need a real call.

ItemState of the evidence
The three allowed values for the visibility changeNo API docs. Only a body sample
A third status value in the filterNot in the documented list of allowed values
Whether the listing-number parameter accepts the identifierOnly the prose description
The previous-number field in the list responseNot in the response field table — the server may need to add it

Only the status change is documented down to the allowed values and the 400 response, so its evidence is solid. The other four work now, and I have not confirmed from documentation why they work. That is its own version of the screen saying everything is fine.

testingverificationstate-managementadmin

Record

First committed 2026.09.04, and untouched since.

  • 15830c5Content: 화면은 정상이라고 말했다 — 네 판

The full build record →