업데이트 뒤 탭 수가 같다는 것만으로 작업이 보존됐다고 말하기 어려웠다. 계정 연결이나 초안이 달라져도 개수는 같을 수 있기 때문이다. 실제 보존 검수에서 가져온 질문을 작은 합성 스냅샷 비교기로 만들었다. 원래 앱 자료를 수집하지 않고 선언된 두 자료의 차이만 읽는다.
처음부터 작은 비교 계약을 만든다
입력은 tabs·projects·settings·payload 네 묶음으로 제한한다. 탭에는 프로젝트·프로필·위치 별칭과 고정 여부·초안 SHA-256을 넣는다. 실제 URL이나 원고를 받지 않고 제공된 별칭과 지문을 비교한다. 원래 자료를 알아서 익명화하는 함수는 아니다.
프로젝트는 기본 프로필, 설정은 언어와 패널 폭, payload는 파일 별칭과 제공된 해시를 갖는다. 알 수 없는 필드와 잘못된 형식은 거절한다. 제목·로딩·시각은 이 계약 바깥이다. 원래 앱의 모든 상태가 보존됐다는 결론이 아니라 어떤 투영을 비교했는지 분명히 하는 것이 목적이다. 실제 적용에서는 신뢰할 수 있는 수집기와 비교 정책을 먼저 정해야 한다.
같은 개수의 다른 탭은 보존이 아니다
tabs와projects·payload는 식별자 집합부터 비교한다. tab_a가 사라지고tab_b가 생기면 개수가 같아도 실패다. 받아들인 각 필드도 모두 비교하므로 프로필만 바뀌거나 초안 지문만 달라져도 차이로 남는다. 같은 화면처럼 보인다는 인상으로 연결 관계를 대신하지 않는다.
자료의 dict 순서는 식별자가 아니다. 같은 항목을 다른 순서로 전달해도 통과한다. 반대로 실제 탭 표시 순서를 보존해야 하는 앱이라면 그 순서를 계약에 추가하고 시험해야 한다. 이 예제가 원래 프로그램의 표시 순서까지 검사한다고 쓰지 않는다. 포함한 것과 제외한 것을 함께 읽을 수 있어야 작은 모듈을 잘 재사용할 수 있다.
의도한 변화도 앞뒤 값이 정확히 맞아야 한다
예상한 이동은 section·id·field와before·after로 선언한다. 실제 두 값이 정확히 맞을 때만 accepted_intended_changes에 남긴다. 시작값이 틀리거나 변화가 일어나지 않았다면 통과시키지 않는다. 아무 변화나 허용하는 ignore 목록으로 쓰지 않은 것이다.
payload 코드가 바뀌는 업데이트도 정확한 이전·이후 해시를 지정해 비교할 수 있다. 하지만 그 계획이 다른 프로필 변경까지 숨기지는 않는다. 식별자가 사라진 경우는 필드 변화 계획으로 덮지 못한다. 계획은 결과를 본 다음 무제한으로 설명하는 면제권이 아니라 변경 전에 정할 비교 기준이어야 한다. 자동으로 업데이트를 적용하는 기능은 이 모듈에 없다.
코드와 구현 살펴보기
독립 합성 전체 구현
"""Compare declared synthetic snapshots. Never collect or restore application data."""
from copy import deepcopy
import re
def alias(x):
return type(x) is str and re.fullmatch(r'[a-z][a-z0-9_-]{0,39}',x) is not None
def digest(x):
return type(x) is str and re.fullmatch(r'[0-9a-f]{64}',x) is not None
def snapshot(raw):
if type(raw) is not dict or set(raw)!={'tabs','projects','settings','payload'}:raise ValueError('SNAPSHOT_FIELDS')
d=deepcopy(raw)
for section in ['tabs','projects','payload']:
rows=d[section]
if type(rows) is not dict or len(rows)>100 or not all(alias(k) for k in rows):raise ValueError('ROW_IDS')
for r in d['tabs'].values():
if type(r) is not dict or set(r)!={'project','profile','location','pinned','draft_sha'}:raise ValueError('TAB_FIELDS')
if not all(alias(r[k]) for k in ['project','profile','location']) or type(r['pinned']) is not bool or not digest(r['draft_sha']):raise ValueError('TAB_VALUE')
if r['project'] not in d['projects']:raise ValueError('PROJECT_REFERENCE')
for r in d['projects'].values():
if type(r) is not dict or set(r)!={'default_profile'} or (r['default_profile'] is not None and not alias(r['default_profile'])):raise ValueError('PROJECT_FIELDS')
if type(d['settings']) is not dict or set(d['settings'])!={'locale','panel_width'}:raise ValueError('SETTINGS_FIELDS')
s=d['settings']
if type(s['locale']) is not str or s['locale'] not in {'ko','en'} or type(s['panel_width']) is not int or not 200<=s['panel_width']<=1200:raise ValueError('SETTINGS_VALUE')
if not all(digest(v) for v in d['payload'].values()):raise ValueError('PAYLOAD_DIGEST')
return d
def flatten(d):
rows={}
for section in ['tabs','projects']:
for id,r in d[section].items():
for field,value in r.items():rows[(section,id,field)]=value
for id,value in d['payload'].items():rows[('payload',id,'sha256')]=value
for field,value in d['settings'].items():rows[('settings','app',field)]=value
return rows
def compare(before, after, intended_changes=None):
"""All accepted fields compare. An intended change must match both exact values."""
a,b=snapshot(before),snapshot(after)
expected=intended_changes if intended_changes is not None else []
if type(expected) is not list or len(expected)>100:raise ValueError('CHANGES_LIST')
aa,bb=flatten(a),flatten(b);changes={}
for row in expected:
if type(row) is not dict or set(row)!={'section','id','field','before','after'}:raise ValueError('CHANGE_FIELDS')
key=(row['section'],row['id'],row['field'])
if not all(type(v) is str for v in key) or key not in aa or key not in bb:raise ValueError('CHANGE_TARGET')
if key in changes:raise ValueError('DUPLICATE_CHANGE')
if type(row['before']) is not type(aa[key]) or type(row['after']) is not type(bb[key]):raise ValueError('CHANGE_TYPE')
if row['before']==row['after']:raise ValueError('UNCHANGED_PLAN')
changes[key]=(deepcopy(row['before']),deepcopy(row['after']))
differences=[];accepted=[];unused=[]
for section in ['tabs','projects','payload']:
if set(a[section])!=set(b[section]):differences.append({'kind':'identity_set_changed','section':section,'removed':sorted(set(a[section])-set(b[section])),'added':sorted(set(b[section])-set(a[section]))})
for key in sorted(set(aa)&set(bb)):
if aa[key]!=bb[key]:
item=dict(section=key[0],id=key[1],field=key[2],before=aa[key],after=bb[key])
if key in changes and changes[key]==(aa[key],bb[key]):accepted.append(item)
else:differences.append(dict(kind='unexpected_value_change',**item))
for key,values in changes.items():
if aa[key]==bb[key] or values!=(aa[key],bb[key]):unused.append(dict(section=key[0],id=key[1],field=key[2]))
return dict(preservation_matches_declared_projection=not differences and not unused,
differences=differences,accepted_intended_changes=accepted,unused_or_mismatched_plans=unused,
actual_authentication_verified=False,installer_executed=False,durable_backup_restored=False,
arbitrary_data_redacted=False,excluded_from_contract=['title','loading','timestamps'])
통과해도 로그인과 복구 성공은 False다
스냅샷이 같아도 실제 인증이 유효한지는 별도다. 예제의 actual_authentication_verified·installer_executed·durable_backup_restored는 항상 False다. SHA 문자열이 같다는 비교도 실제 방문자 파일을 읽어 지문을 계산한 검사가 아니다. 입력을 제공한 쪽의 신뢰가 더 필요하다.
독립 시험30개는 같은 개수의 식별자 교체, 계정·초안 변경, 의도한 이동과 빗나간 계획, payload 누락·변경과 잘못된 입력을 다룬다. 다섯 기록은 실제 로컬 비교 실행에서 만들었고 아래 선택기는 그 결과만 읽는다. 시험을 ZIP 추출 뒤에도 실행하며 원래 브라우저를 설치하거나 계정을 읽지 않는다. 작은 비교의 성공을 더 큰 작업의 성공으로 옮기지 않았다.
비교 기준까지 함께 넘겨야 다른 곳에서 쓸 수 있다
코드와 시험, 기록 생성기를 수정하고 재사용할 수 있다. 실제 앱의 자료 구조에 맞춰 필드를 늘리되 원본·복사본·동적 값·의도한 변화의 의미를 먼저 정해야 한다. 단순히 비교가 통과하도록 필드를 빼면 재사용이 아니라 질문을 없애는 일이 된다.
이 예제에는 자료 수집·URL 정규화·비밀 제거·실제 로그인·설치·백업·복원이 없다. 원래 보존 검수의 모든 도구를 복제한 것도 아니다. 어떤 자료를 같은 것으로 보았고 어떤 차이는 허용했는지를 명시하는 기준을 공유한다. 다음 담당자가 결과만이 아니라 비교의 이유까지 읽을 수 있도록 만드는 것이 내가 원한 범용성이다.