Joyuno's Dev Blog

2026-09-10 (목) 개발일지

dev-blog

오늘의 트러블슈팅: Godot 빈칸 워크스페이스 — 변이 테스트의 사각지대와 스펙 뒤집기에서 발견한 구멍

오늘은 SK 130제 클라이언트 빈칸 워크스페이스(Plan C) 구현 중 두 가지 이슈를 잡았다. 하나는 변이 테스트가 특정 회귀를 놓치는 사각지대였고, 다른 하나는 스펙을 뒤집는 과정에서 진짜 구현 누락을 발견한 케이스였다. 둘 다 원인을 찾기까지 가설이 한 번씩 흔들렸다.

stylebox_readonly 변이 — 좌표/폭 단언이 놓친 회귀

Task 8(blank_grid.gd — 4상태 렌더링) 커밋 직후, 변이 스윕을 돌렸다. 헤드리스 14종 전부 0 failed가 떠서 안심했는데, 팀 리드가 인용한 실패 지문이 내 디스크의 변이본 출력과 정확히 일치했다 — row=143.000000 plain=118.000000. 내 변이 스윕이 mincharwidth 변이본을 만들었고, 그게 살아서 디스크에 남은 것이다.

처음엔 “변이가 너무 사소해서 코드가 달라지지 않았나?”라고 생각했다. minimum_character_width를 1로 바꾸는 변이인데, 등폭 폭 계산에만 영향이 있고 렌더링 결과가 같으면 테스트가 통과할 수 있다고 본 것이다. 하지만 stylebox_readonly 변이가 잡히지 않은 게 더 의심스러웠다. 이 변이는 실제 회귀인데, 왜 좌표/폭 단언을 통과했을까?

결정적 발견: find_text()는 여백이 16px 벌어져도 통과한다.

blank_workspace_test.gd의 단언을 다시 봤다. 좌표 단언은 LineEdit 위치를, 폭 단언은 minchar_width * char_count를 검증한다. 둘 다 stylebox_readonly가 바꾸는 테두리 두께를 검사하지 않는다. stylebox_readonly는 readonly 상태의 스타일박스인데, 이게 바뀌면 셀 내부 여백이 달라져 텍스트가 밀리지만 좌표는 그대로고 폭만 변한다. 그런데 폭 단언은 minchar_width 기반이라 스타일박스 변경을 반영하지 않는다.

해법: 게이트를 보강했다. blank_grid.gd의 _refresh_cells()에서 각 셀의 get_theme_stylebox("readonly")의 content_margin_left/right를 직접 단언하는 테스트를 추가했다. test_support.gd에 assert_stylebox_margin(cell, "readonly", expected_left, expected_right) 헬퍼를 넣고, 변이 스윕이 이 단언을 통과해야만 0 failed가 나도록 했다.

검증: 변이 스윕 재실행 — stylebox_readonly 변이본이 assert_stylebox_margin에서 걸려 1 failed로 바뀌었다. 원래 통과하던 14종이 13종으로 줄었고, 실패한 1종이 정확히 그 변이였다.

남은 과제: find_text()의 여백 허용 범위(16px)는 여전히 넓다. 다음 패스에서 이 임계값을 줄일지 검토 중이다.

D3 뒤집기 — “답 보기” affordance가 실제로는 비어 있었다

Plan C의 Task 6(88372ef)에서 reveal_focused()를 구현했다. 스펙은 “노트를 표시하고, 노트가 비면 정답 자체를 표시한다”였다. 그런데 초안을 뒤집는 과정에서 진짜 구멍을 하나 찾았다.

초안은 reveal_requested 시그널과 mark_revealed 마커만 선언하고, 실제 reveal 로직은 mark_revealed가 빈 함수였다. “답 보기” affordance가 선언만 되어 있고 실제로 동작하지 않았다. Task 5(blank_grid.gd)는 가린 본문 조각을 맞추는 것까지만 구현했고, “정답을 보여준다”는 동작은 Task 6에서 채워져야 했는데 초안이 그걸 빼먹은 것이다.

처음엔 “단순한 버그”라고 생각했다. mark_revealed를 reveal_focused()로 교체하면 끝날 줄 알았다. 하지만 reveal_focused()가 호출되는 조건을 보니 더 깊은 문제가 있었다. reveal_focused()는 포커스된 셀에서만 호출되는데, “답 보기” 버튼은 전체 빈칸을 한 번에 공개해야 한다. 즉, reveal_all() 같은 별도 진입점이 필요했다.

진짜 원인: 초안이 “답 보기”를 단일 셀 reveal과 동일시했다.

blank_grid.gd의 _input()에서 reveal_focused()를 Input.is_action_just_pressed("ui_accept")에 바인딩했다. 하지만 “답 보기” 버튼은 blank_workspace_test.gd에서 press_reveal_button()으로 호출되며, 이게 reveal_focused()가 아닌 reveal_all()을 기대했다. 초안 작성자가 두 동작을 하나로 합친 것이다.

해법: blank_grid.gd에 reveal_all() 메서드를 추가하고, “답 보기” 버튼 시그널을 reveal_all()에 연결했다. reveal_focused()는 그대로 두되, 내부에서 _reveal_cell(cell_idx, force_show_answer=true)를 호출하도록 통일했다. mark_revealed는 제거하고 reveal_state 배열로 대체했다.

동작 초안 수정
포커스 셀 reveal mark_revealed (빈 함수) reveal_focused() → _reveal_cell(idx, false)
“답 보기” 버튼 없음 reveal_all() → 모든 셀 _reveal_cell(idx, true)
노트가 빈 경우 명시 안 됨 _reveal_cell 내에서 note.empty()면 정답 텍스트 출력

검증: blank_workspace_test.gd의 “고스트 4건 + 답 보기 3건” 단언이 전부 통과했다. 특히 test_reveal_all_shows_answers_when_notes_empty가 새로 추가되어, 노트가 빈 셀에서 정답이 직접 표시되는 것을 확인했다.

남은 과제: reveal_all()이 호출된 후 개별 셀 편집을 막는 로직이 아직 없다. 다음 Task에서 비활성화 처리가 필요하다.

오늘의 한줄

변이 테스트의 사각지대는 단언이 검사하지 않는 속성에서 발생하고, 스펙 뒤집기에서 진짜 구멍은 선언만 하고 구현을 빼먹은 affordance에서 나온다.