화면이 편해졌다는 말을 검증하려면 어떤 입력 뒤에 어떤 상태가 남아야 하는지 구체적으로 말할 수 있어야 한다. 나는 저장·조회·반복 입력을 각각 다른 함수로 나누되 같은 합성 패키지에서 실행할 수 있도록 정리했다.
이 모듈은 실제 ERP나 브라우저 화면을 조작하지 않는다. 요청 응답과 입력 행동을 값으로 전달하면 새 상태를 돌려준다. 아래 기록은 로컬 테스트에서 생성한 고정 합성 결과이며, 실제 네트워크와 키보드 검수를 대신하지 않는다.
저장 확인과 최신 초안을 분리한다
save_state에는 입력 내용, 입력 revision, 확인된 revision, 요청 순서와 대기 요청을 따로 둔다. begin_save가 그 순간의 초안을 복사하고 save_reply가 요청 식별과 성공 여부를 확인한다. 응답 처리기는 초안 자체를 바꾸지 않는다. 두 번째 성공 뒤 첫 번째 응답이 도착해도 최신 확인 순서를 되돌리지 않는다.
아직 보내지 않은 수정은 dirty로 남고 실패는 확인 버전을 올리지 않는다. 서버 버전 역행도 감지하지만 실제 저장소의 순서를 고치지는 않는다. 영구 대기 목록, 로그인, 재시도와 서버 충돌 처리는 구현하지 않았다. 상태 계산만으로 전체 저장 서비스가 완성된 것처럼 말하지 않기 위해 입력 전제와 책임을 구분했다.
조회 성공과 빈 결과를 별도로 해석한다
initial과 loading 다음에 정상 결과는 ready, empty_initial 또는 empty_filtered로 나뉜다. error와 denied는 빈 결과의 변형이 아니다. 이전 행이 있던 새로고침이 실패하면 stale로 남기며 권한 거절은 그 행을 지운다. 예전 generation의 응답은 최신 조회 상태를 바꾸지 않는다. 실제 요청을 취소하지 않고 응답 적용 여부를 계산하는 코드다.
조건 변경 후 남은 행은 이전 조건의 자료일 수 있다. 실제 화면은 이전 기준을 별도로 표시하거나 목록을 숨기는 정책을 결정해야 한다. 이 예제는 상태 이름과 합성 ID만 다루므로 화면 문구와 권한 API를 제공하지 않는다. 이전 자료를 보존하는 선택이 현재 필터 성공이라는 오해로 이어지지 않게 적용해야 한다.
포커스를 움직일 목표를 값으로 만든다
order_action은 input, search, escape, choose, enter와 add를 받는다. 선택된 품목 다음에는 수량을 목표로 하고 잘못된 입력은 같은 위치에 남긴다. 검색 취소는 단어를 지우지 않는다. 현재 행이 유효할 때만 새 행을 추가하고 이전 행의 값을 보존한다. 행과 필드가 명시적이어서 순서와 보존 여부를 테스트할 수 있다.
여기서 목표는 실제 DOM 포커스가 아니다. 네이티브 Tab 순서, 한글 조합과 Enter, 검색 결과 방향키, 오류 읽기와 포커스 복귀는 연결할 UI에서 추가로 구현하고 시험해야 한다. 수량과 행 수 제한도 작은 합성 모델의 규칙이지 실제 업체 요구를 대표하지 않는다. 고정된 실험 범위와 재사용 시 바꿀 결정을 함께 남긴다.
코드와 구현 살펴보기
독립 합성 전체 구현
"""Pure synthetic interaction contracts. No network, storage or browser access."""
from copy import deepcopy
def save_state():
return {'draft':'','revision':0,'acked_revision':0,'next':1,'applied_sequence':0,'server_version':0,'pending':{}}
def edit(state,text):
if not isinstance(text,str) or len(text)>500: raise ValueError('text')
out=deepcopy(state);out['draft']=text;out['revision']+=1;return out
def begin_save(state):
out=deepcopy(state);token=out['next'];out['next']+=1
request={'revision':out['revision'],'text':out['draft']}
out['pending'][token]=request
return out,{'sequence':token,**request}
def save_reply(state,sequence,ok,server_version=None):
if type(sequence) is not int or sequence not in state['pending']:raise ValueError('unknown request')
if type(ok) is not bool:raise ValueError('result')
if ok and (type(server_version) is not int or server_version<1):raise ValueError('server version')
out=deepcopy(state);request=out['pending'].pop(sequence)
# Acknowledgement is separate from draft text. A late response cannot replace input.
if ok and sequence>out['applied_sequence']:
if server_version<out['server_version']:raise ValueError('server version regression')
out['applied_sequence']=sequence;out['server_version']=server_version
out['acked_revision']=max(out['acked_revision'],request['revision'])
return out
def save_status(state):
return {'dirty':state['revision']>state['acked_revision'],'pending':len(state['pending']),'server_version':state['server_version']}
def load_state():
return {'generation':0,'status':'initial','rows':[],'filtered':False,'retry':False}
def begin_load(state,filtered=False):
if type(filtered) is not bool:raise ValueError('filtered')
out=deepcopy(state);out.update(generation=out['generation']+1,status='loading',filtered=filtered,retry=False);return out
def load_reply(state,generation,result,rows=None):
if type(generation) is not int or generation<1 or generation>state['generation']:raise ValueError('generation')
if generation!=state['generation']:return deepcopy(state)
if result not in ('ok','denied','error'):raise ValueError('result')
out=deepcopy(state)
if result=='denied':out.update(status='denied',rows=[],retry=False)
elif result=='error':out.update(status='stale' if out['rows'] else 'error',retry=True)
else:
if not isinstance(rows,list) or len(rows)>100 or any(not isinstance(x,str) or len(x)>40 for x in rows):raise ValueError('rows')
out.update(rows=list(rows),status='ready' if rows else ('empty_filtered' if out['filtered'] else 'empty_initial'),retry=False)
return out
def order_state():
return {'rows':[{'item':'','quantity':''}],'row':0,'field':'item','popup':False,'message':''}
def valid(field,value):
if field=='item':return isinstance(value,str) and 1<=len(value.strip())<=40
return isinstance(value,str) and value.isascii() and value.isdecimal() and 1<=int(value)<=99
def order_action(state,action,value=None):
out=deepcopy(state);row=out['rows'][out['row']];field=out['field'];out['message']=''
if action=='input':
if not isinstance(value,str) or len(value)>100:raise ValueError('input')
row[field]=value
elif action=='search':
if field!='item':raise ValueError('search target')
out['popup']=True
elif action=='escape':out['popup']=False
elif action=='choose':
if not out['popup'] or not valid('item',value):raise ValueError('choice')
row['item']=value;out.update(popup=False,field='quantity')
elif action=='enter':
if out['popup']:out['message']='choose_or_close'
elif not valid(field,row[field]):out['message']='invalid_'+field
elif field=='item':out['field']='quantity'
elif out['row']+1<len(out['rows']):out.update(row=out['row']+1,field='item')
else:out['message']='row_complete'
elif action=='add':
if len(out['rows'])>=20 or any(not valid(k,row[k]) for k in ('item','quantity')):out['message']='complete_current_row'
else:out['rows'].append({'item':'','quantity':''});out.update(row=len(out['rows'])-1,field='item',popup=False)
else:raise ValueError('action')
return out세 기록과 열일곱 테스트를 확인했다
로컬 테스트는 역순 저장 응답, 미전송 수정, 실패와 반복 응답, 빈 결과와 오류, 오래된 조회, 검색 취소와 잘못된 수량을 확인한다. 전체 17개가 통과했고 ZIP을 새 폴더에 풀어 다시 실행했다. 기록 선택기는 늦은 저장, 실패한 갱신과 새 행 목표의 세 결과를 보여 준다. 단순히 성공 표시를 띄우는 대신 남은 상태를 읽을 수 있게 했다.
이전 상태 객체를 복사해서 새 상태를 만들므로 호출 전후를 비교할 수 있다. 다만 호출자가 임의로 상태를 손상시키는 모든 경우를 검증하는 데이터 저장소는 아니다. 신뢰할 프로그램 안에서 규칙을 연결하는 순수 모델이다. 전달받은 상태의 출처, 실제 권한과 서버 일관성은 별도로 정해야 한다. 테스트 범위를 운영 보장으로 확대하지 않는다.
적용은 상태 모델과 실제 동작을 연결하는 일이다
기존 화면에 붙일 때는 어떤 사용자 행동이 어떤 함수 입력이 되는지부터 정한다. 응답을 초안에 대입하는 오래된 처리기와 새 모델이 동시에 동작하면 검사한 규칙도 깨질 수 있다. 상태 문구, 실제 입력 요소와 서버 버전을 하나씩 연결한 뒤 지연 응답과 키보드 행동을 실화면에서 확인해야 한다.
패키지에는 구현, 테스트, 실행기, 고정 기록과 안내가 포함된다. 업체 자료가 없는 독립 합성 코드로 수정과 재사용이 가능하다. 나는 완성된 화면을 그대로 가져가라는 방식보다 적용할 책임과 미확인 부분을 함께 전달하는 모듈을 남기고 싶었다. 방문자는 코드를 읽고 자기 업무의 입력 흐름을 더 구체적으로 정할 수 있다.