бейдж упоминаний в чат-листе, прямая навигация по @mention, тап на аватарку → профиль, RequestChats на UIKit
This commit is contained in:
846
tools/voice_recording_parity_checker.py
Executable file
846
tools/voice_recording_parity_checker.py
Executable file
@@ -0,0 +1,846 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import dataclasses
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
BASELINE_PATH = ROOT / "docs" / "voice-recording-parity-baseline.json"
|
||||
|
||||
CONSTANT_SPECS = [
|
||||
{
|
||||
"id": "hold_threshold",
|
||||
"severity": "P0",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift",
|
||||
"pattern": r"static let holdThreshold: TimeInterval = ([0-9.]+)",
|
||||
"expected": "0.19",
|
||||
"telegram_file": "Telegram-iOS/submodules/TelegramUI/Components/ChatTextInputMediaRecordingButton/Sources/ChatTextInputMediaRecordingButton.swift",
|
||||
"telegram_pattern": r"timeout: 0\.19",
|
||||
},
|
||||
{
|
||||
"id": "cancel_distance_threshold",
|
||||
"severity": "P0",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift",
|
||||
"pattern": r"static let cancelDistanceThreshold: CGFloat = (-?[0-9.]+)",
|
||||
"expected": "-150",
|
||||
"telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m",
|
||||
"telegram_pattern": r"distanceX < -150\.0f",
|
||||
},
|
||||
{
|
||||
"id": "cancel_haptic_threshold",
|
||||
"severity": "P0",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift",
|
||||
"pattern": r"static let cancelHapticThreshold: CGFloat = (-?[0-9.]+)",
|
||||
"expected": "-100",
|
||||
"telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m",
|
||||
"telegram_pattern": r"distanceX < -100\.0",
|
||||
},
|
||||
{
|
||||
"id": "lock_distance_threshold",
|
||||
"severity": "P0",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift",
|
||||
"pattern": r"static let lockDistanceThreshold: CGFloat = (-?[0-9.]+)",
|
||||
"expected": "-110",
|
||||
"telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m",
|
||||
"telegram_pattern": r"distanceY < -110\.0f",
|
||||
},
|
||||
{
|
||||
"id": "lock_haptic_threshold",
|
||||
"severity": "P0",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift",
|
||||
"pattern": r"static let lockHapticThreshold: CGFloat = (-?[0-9.]+)",
|
||||
"expected": "-60",
|
||||
"telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m",
|
||||
"telegram_pattern": r"distanceY < -60\.0",
|
||||
},
|
||||
{
|
||||
"id": "velocity_gate",
|
||||
"severity": "P0",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift",
|
||||
"pattern": r"static let velocityGate: CGFloat = (-?[0-9.]+)",
|
||||
"expected": "-400",
|
||||
"telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m",
|
||||
"telegram_pattern": r"velocity\.x < -400\.0f|velocity\.y < -400\.0f",
|
||||
},
|
||||
{
|
||||
"id": "lockness_divisor",
|
||||
"severity": "P1",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift",
|
||||
"pattern": r"static let locknessDivisor: CGFloat = ([0-9.]+)",
|
||||
"expected": "105",
|
||||
"telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m",
|
||||
"telegram_pattern": r"fabs\(_targetTranslation\) / 105\.0f",
|
||||
},
|
||||
{
|
||||
"id": "drag_normalize_divisor",
|
||||
"severity": "P1",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift",
|
||||
"pattern": r"static let dragNormalizeDivisor: CGFloat = ([0-9.]+)",
|
||||
"expected": "300",
|
||||
"telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m",
|
||||
"telegram_pattern": r"\(-distanceX\) / 300\.0f|\(-distanceY\) / 300\.0f",
|
||||
},
|
||||
{
|
||||
"id": "cancel_transform_threshold",
|
||||
"severity": "P1",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift",
|
||||
"pattern": r"static let cancelTransformThreshold: CGFloat = ([0-9.]+)",
|
||||
"expected": "8",
|
||||
"telegram_file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift",
|
||||
"telegram_pattern": r"VoiceRecordingParityMath\.shouldApplyCancelTransform\(translation\)",
|
||||
},
|
||||
{
|
||||
"id": "send_accessibility_hit_size",
|
||||
"severity": "P1",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift",
|
||||
"pattern": r"static let sendAccessibilityHitSize: CGFloat = ([0-9.]+)",
|
||||
"expected": "120",
|
||||
"telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m",
|
||||
"telegram_pattern": r"VoiceOver\.Recording\.StopAndPreview",
|
||||
},
|
||||
{
|
||||
"id": "min_trim_formula",
|
||||
"severity": "P0",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift",
|
||||
"pattern": r"max\(1\.0, 56\.0 \* duration / max\(waveformWidth, 1\)\)",
|
||||
"expected": r"max\(1\.0, 56\.0 \* duration / max\(waveformWidth, 1\)\)",
|
||||
"telegram_file": "Telegram-iOS/submodules/TelegramUI/Components/Chat/ChatRecordingPreviewInputPanelNode/Sources/ChatRecordingPreviewInputPanelNode.swift",
|
||||
"telegram_pattern": r"max\(1\.0, 56\.0 \* audio\.duration / waveformBackgroundFrame\.size\.width\)",
|
||||
"raw_match": True,
|
||||
},
|
||||
]
|
||||
|
||||
FLOW_SPEC = {
|
||||
"severity": "P0",
|
||||
"state_file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingFlowTypes.swift",
|
||||
"expected_states": [
|
||||
"idle",
|
||||
"armed",
|
||||
"recordingUnlocked",
|
||||
"recordingLocked",
|
||||
"waitingForPreview",
|
||||
"draftPreview",
|
||||
],
|
||||
"required_transitions": [
|
||||
{
|
||||
"id": "armed",
|
||||
"severity": "P1",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/ComposerView.swift",
|
||||
"snippet": "setRecordingFlowState(.armed)",
|
||||
},
|
||||
{
|
||||
"id": "recordingUnlocked",
|
||||
"severity": "P1",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/ComposerView.swift",
|
||||
"snippet": "setRecordingFlowState(.recordingUnlocked)",
|
||||
},
|
||||
{
|
||||
"id": "recordingLocked",
|
||||
"severity": "P1",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/ComposerView.swift",
|
||||
"snippet": "setRecordingFlowState(.recordingLocked)",
|
||||
},
|
||||
{
|
||||
"id": "waitingForPreview",
|
||||
"severity": "P1",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/ComposerView.swift",
|
||||
"snippet": "setRecordingFlowState(.waitingForPreview)",
|
||||
},
|
||||
{
|
||||
"id": "draftPreview",
|
||||
"severity": "P1",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/ComposerView.swift",
|
||||
"snippet": "setRecordingFlowState(.draftPreview)",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
ACCESSIBILITY_SPECS = [
|
||||
{
|
||||
"id": "mic_button",
|
||||
"severity": "P1",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/ComposerView.swift",
|
||||
"snippet": "micButton.accessibilityLabel = \"Voice message\"",
|
||||
},
|
||||
{
|
||||
"id": "stop_area",
|
||||
"severity": "P1",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/ComposerView.swift",
|
||||
"snippet": "button.accessibilityIdentifier = \"voice.recording.stopArea\"",
|
||||
},
|
||||
{
|
||||
"id": "lock_stop",
|
||||
"severity": "P1",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift",
|
||||
"snippet": "stopButton.accessibilityLabel = \"Stop recording\"",
|
||||
},
|
||||
{
|
||||
"id": "slide_to_cancel",
|
||||
"severity": "P1",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift",
|
||||
"snippet": "cancelContainer.accessibilityLabel = \"Slide left to cancel recording\"",
|
||||
},
|
||||
{
|
||||
"id": "preview_send",
|
||||
"severity": "P1",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift",
|
||||
"snippet": "sendButton.accessibilityLabel = \"Send recording\"",
|
||||
},
|
||||
{
|
||||
"id": "preview_record_more",
|
||||
"severity": "P1",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift",
|
||||
"snippet": "recordMoreButton.accessibilityLabel = \"Record more\"",
|
||||
},
|
||||
{
|
||||
"id": "preview_delete",
|
||||
"severity": "P1",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift",
|
||||
"snippet": "deleteButton.accessibilityIdentifier = \"voice.preview.delete\"",
|
||||
},
|
||||
{
|
||||
"id": "preview_play_pause",
|
||||
"severity": "P1",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift",
|
||||
"snippet": "playButton.accessibilityIdentifier = \"voice.preview.playPause\"",
|
||||
},
|
||||
{
|
||||
"id": "preview_waveform",
|
||||
"severity": "P1",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift",
|
||||
"snippet": "waveformContainer.accessibilityIdentifier = \"voice.preview.waveform\"",
|
||||
},
|
||||
]
|
||||
|
||||
GEOMETRY_SPECS = [
|
||||
{
|
||||
"id": "overlay_inner_diameter",
|
||||
"severity": "P1",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingOverlay.swift",
|
||||
"pattern": r"private let innerDiameter: CGFloat = ([0-9.]+)",
|
||||
"expected": "110",
|
||||
"telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m",
|
||||
"telegram_pattern": r"innerCircleRadius = 110\.0f",
|
||||
},
|
||||
{
|
||||
"id": "overlay_outer_diameter",
|
||||
"severity": "P1",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingOverlay.swift",
|
||||
"pattern": r"private let outerDiameter: CGFloat = ([0-9.]+)",
|
||||
"expected": "160",
|
||||
"telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m",
|
||||
"telegram_pattern": r"outerCircleRadius = innerCircleRadius \+ 50\.0f",
|
||||
},
|
||||
{
|
||||
"id": "panel_dot_x",
|
||||
"severity": "P1",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift",
|
||||
"pattern": r"private let dotX: CGFloat = ([0-9.]+)",
|
||||
"expected": "5",
|
||||
},
|
||||
{
|
||||
"id": "panel_timer_x",
|
||||
"severity": "P1",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift",
|
||||
"pattern": r"private let timerX: CGFloat = ([0-9.]+)",
|
||||
"expected": "40",
|
||||
},
|
||||
{
|
||||
"id": "panel_dot_size",
|
||||
"severity": "P1",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift",
|
||||
"pattern": r"private let dotSize: CGFloat = ([0-9.]+)",
|
||||
"expected": "10",
|
||||
},
|
||||
{
|
||||
"id": "lock_panel_width",
|
||||
"severity": "P1",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift",
|
||||
"pattern": r"private let panelWidth: CGFloat = ([0-9.]+)",
|
||||
"expected": "40",
|
||||
"telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m",
|
||||
"telegram_pattern": r"CGRectMake\(0\.0f, 0\.0f, 40\.0f, 72\.0f\)",
|
||||
},
|
||||
{
|
||||
"id": "lock_panel_full_height",
|
||||
"severity": "P1",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift",
|
||||
"pattern": r"private let panelFullHeight: CGFloat = ([0-9.]+)",
|
||||
"expected": "72",
|
||||
"telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m",
|
||||
"telegram_pattern": r"CGRectMake\(0\.0f, 0\.0f, 40\.0f, 72\.0f\)",
|
||||
},
|
||||
{
|
||||
"id": "lock_panel_locked_height",
|
||||
"severity": "P1",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift",
|
||||
"pattern": r"private let panelLockedHeight: CGFloat = ([0-9.]+)",
|
||||
"expected": "40",
|
||||
},
|
||||
{
|
||||
"id": "lock_vertical_offset",
|
||||
"severity": "P1",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift",
|
||||
"pattern": r"private let verticalOffset: CGFloat = ([0-9.]+)",
|
||||
"expected": "122",
|
||||
"telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m",
|
||||
"telegram_pattern": r"centerPoint\.y - 122\.0f",
|
||||
},
|
||||
]
|
||||
|
||||
ANIMATION_SPECS = [
|
||||
{
|
||||
"id": "preview_play_to_pause_frames",
|
||||
"severity": "P1",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift",
|
||||
"snippet": "playPauseAnimationView.play(fromFrame: 0, toFrame: 41, loopMode: .playOnce)",
|
||||
},
|
||||
{
|
||||
"id": "preview_pause_to_play_frames",
|
||||
"severity": "P1",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift",
|
||||
"snippet": "playPauseAnimationView.play(fromFrame: 41, toFrame: 83, loopMode: .playOnce)",
|
||||
},
|
||||
{
|
||||
"id": "overlay_spring_timing",
|
||||
"severity": "P2",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingOverlay.swift",
|
||||
"snippet": "UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.55",
|
||||
},
|
||||
{
|
||||
"id": "lock_stop_delay",
|
||||
"severity": "P2",
|
||||
"file": "Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift",
|
||||
"snippet": "UIView.animate(withDuration: 0.25, delay: 0.56, options: [.curveEaseOut])",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Finding:
|
||||
severity: str
|
||||
kind: str
|
||||
layer: str
|
||||
item_id: str
|
||||
expected: str
|
||||
actual: str
|
||||
delta: str
|
||||
evidence: str
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"severity": self.severity,
|
||||
"kind": self.kind,
|
||||
"layer": self.layer,
|
||||
"id": self.item_id,
|
||||
"expected": self.expected,
|
||||
"actual": self.actual,
|
||||
"delta": self.delta,
|
||||
"evidence": self.evidence,
|
||||
}
|
||||
|
||||
|
||||
def read_text(path: pathlib.Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def sha256_file(path: pathlib.Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
while True:
|
||||
chunk = f.read(1024 * 64)
|
||||
if not chunk:
|
||||
break
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def parse_image_size(path: pathlib.Path) -> Optional[Tuple[int, int]]:
|
||||
if path.suffix.lower() not in {".png", ".jpg", ".jpeg"}:
|
||||
return None
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["/usr/bin/sips", "-g", "pixelWidth", "-g", "pixelHeight", str(path)],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
width = None
|
||||
height = None
|
||||
for line in proc.stdout.splitlines():
|
||||
if "pixelWidth:" in line:
|
||||
width = int(line.split(":", 1)[1].strip())
|
||||
if "pixelHeight:" in line:
|
||||
height = int(line.split(":", 1)[1].strip())
|
||||
if width is None or height is None:
|
||||
return None
|
||||
return (width, height)
|
||||
|
||||
|
||||
def parse_lottie_meta(path: pathlib.Path) -> Dict[str, Any]:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return {
|
||||
"fps": data.get("fr"),
|
||||
"ip": data.get("ip"),
|
||||
"op": data.get("op"),
|
||||
"width": data.get("w"),
|
||||
"height": data.get("h"),
|
||||
}
|
||||
|
||||
|
||||
def discover_asset_manifest() -> Dict[str, Any]:
|
||||
imagesets: List[Dict[str, Any]] = []
|
||||
assets_root = ROOT / "Rosetta" / "Assets.xcassets"
|
||||
for imageset in sorted(assets_root.glob("VoiceRecording*.imageset")):
|
||||
files = []
|
||||
for file_path in sorted(imageset.iterdir()):
|
||||
if file_path.name == "Contents.json":
|
||||
continue
|
||||
file_info: Dict[str, Any] = {
|
||||
"name": file_path.name,
|
||||
"sha256": sha256_file(file_path),
|
||||
}
|
||||
size = parse_image_size(file_path)
|
||||
if size is not None:
|
||||
file_info["size"] = {"width": size[0], "height": size[1]}
|
||||
files.append(file_info)
|
||||
imagesets.append(
|
||||
{
|
||||
"id": imageset.name.replace(".imageset", ""),
|
||||
"severity": "P1",
|
||||
"path": str(imageset.relative_to(ROOT)),
|
||||
"files": files,
|
||||
}
|
||||
)
|
||||
|
||||
lottie: List[Dict[str, Any]] = []
|
||||
lottie_root = ROOT / "Rosetta" / "Resources" / "Lottie"
|
||||
for lottie_file in sorted(lottie_root.glob("voice_*.json")):
|
||||
lottie.append(
|
||||
{
|
||||
"id": lottie_file.stem,
|
||||
"severity": "P1",
|
||||
"path": str(lottie_file.relative_to(ROOT)),
|
||||
"sha256": sha256_file(lottie_file),
|
||||
"meta": parse_lottie_meta(lottie_file),
|
||||
}
|
||||
)
|
||||
|
||||
return {"imagesets": imagesets, "lottie": lottie}
|
||||
|
||||
|
||||
def parse_flow_states(path: pathlib.Path) -> List[str]:
|
||||
text = read_text(path)
|
||||
return re.findall(r"case\s+([A-Za-z_][A-Za-z0-9_]*)", text)
|
||||
|
||||
|
||||
def build_baseline() -> Dict[str, Any]:
|
||||
return {
|
||||
"version": 1,
|
||||
"generated_at": dt.datetime.now(dt.timezone.utc).isoformat(),
|
||||
"constants": CONSTANT_SPECS,
|
||||
"flow": {
|
||||
**FLOW_SPEC,
|
||||
"actual_states": parse_flow_states(ROOT / FLOW_SPEC["state_file"]),
|
||||
},
|
||||
"accessibility": ACCESSIBILITY_SPECS,
|
||||
"geometry": GEOMETRY_SPECS,
|
||||
"animations": ANIMATION_SPECS,
|
||||
"assets": discover_asset_manifest(),
|
||||
}
|
||||
|
||||
|
||||
def parse_actual_constant(text: str, pattern: str, raw_match: bool) -> Optional[str]:
|
||||
if raw_match:
|
||||
return pattern if re.search(pattern, text) else None
|
||||
m = re.search(pattern, text)
|
||||
if not m:
|
||||
return None
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def run_checker(baseline: Dict[str, Any]) -> Dict[str, Any]:
|
||||
findings: List[Finding] = []
|
||||
|
||||
# constants
|
||||
for spec in baseline.get("constants", []):
|
||||
file_path = ROOT / spec["file"]
|
||||
text = read_text(file_path)
|
||||
raw_match = bool(spec.get("raw_match", False))
|
||||
actual = parse_actual_constant(text, spec["pattern"], raw_match)
|
||||
expected = spec["expected"]
|
||||
if actual is None:
|
||||
findings.append(
|
||||
Finding(
|
||||
severity=spec["severity"],
|
||||
kind="missing_pattern",
|
||||
layer="constants",
|
||||
item_id=spec["id"],
|
||||
expected=str(expected),
|
||||
actual="missing",
|
||||
delta="pattern_not_found",
|
||||
evidence=spec["file"],
|
||||
)
|
||||
)
|
||||
elif str(actual) != str(expected):
|
||||
findings.append(
|
||||
Finding(
|
||||
severity=spec["severity"],
|
||||
kind="value_mismatch",
|
||||
layer="constants",
|
||||
item_id=spec["id"],
|
||||
expected=str(expected),
|
||||
actual=str(actual),
|
||||
delta=f"{actual} != {expected}",
|
||||
evidence=spec["file"],
|
||||
)
|
||||
)
|
||||
|
||||
telegram_file_raw = spec.get("telegram_file")
|
||||
telegram_pattern = spec.get("telegram_pattern")
|
||||
if telegram_file_raw and telegram_pattern:
|
||||
telegram_file = ROOT / telegram_file_raw
|
||||
telegram_text = read_text(telegram_file)
|
||||
if re.search(telegram_pattern, telegram_text):
|
||||
continue
|
||||
findings.append(
|
||||
Finding(
|
||||
severity=spec["severity"],
|
||||
kind="telegram_reference_missing",
|
||||
layer="constants",
|
||||
item_id=spec["id"],
|
||||
expected=telegram_pattern,
|
||||
actual="missing",
|
||||
delta="telegram_evidence_not_found",
|
||||
evidence=telegram_file_raw,
|
||||
)
|
||||
)
|
||||
|
||||
# geometry
|
||||
for spec in baseline.get("geometry", []):
|
||||
file_path = ROOT / spec["file"]
|
||||
text = read_text(file_path)
|
||||
actual = parse_actual_constant(text, spec["pattern"], bool(spec.get("raw_match", False)))
|
||||
expected = spec["expected"]
|
||||
if actual is None:
|
||||
findings.append(
|
||||
Finding(
|
||||
severity=spec["severity"],
|
||||
kind="missing_pattern",
|
||||
layer="geometry",
|
||||
item_id=spec["id"],
|
||||
expected=str(expected),
|
||||
actual="missing",
|
||||
delta="pattern_not_found",
|
||||
evidence=spec["file"],
|
||||
)
|
||||
)
|
||||
elif str(actual) != str(expected):
|
||||
findings.append(
|
||||
Finding(
|
||||
severity=spec["severity"],
|
||||
kind="value_mismatch",
|
||||
layer="geometry",
|
||||
item_id=spec["id"],
|
||||
expected=str(expected),
|
||||
actual=str(actual),
|
||||
delta=f"{actual} != {expected}",
|
||||
evidence=spec["file"],
|
||||
)
|
||||
)
|
||||
|
||||
telegram_file_raw = spec.get("telegram_file")
|
||||
telegram_pattern = spec.get("telegram_pattern")
|
||||
if telegram_file_raw and telegram_pattern:
|
||||
telegram_file = ROOT / telegram_file_raw
|
||||
telegram_text = read_text(telegram_file)
|
||||
if re.search(telegram_pattern, telegram_text):
|
||||
continue
|
||||
findings.append(
|
||||
Finding(
|
||||
severity=spec["severity"],
|
||||
kind="telegram_reference_missing",
|
||||
layer="geometry",
|
||||
item_id=spec["id"],
|
||||
expected=telegram_pattern,
|
||||
actual="missing",
|
||||
delta="telegram_evidence_not_found",
|
||||
evidence=telegram_file_raw,
|
||||
)
|
||||
)
|
||||
|
||||
# flow
|
||||
flow = baseline["flow"]
|
||||
state_file = ROOT / flow["state_file"]
|
||||
actual_states = parse_flow_states(state_file)
|
||||
expected_states = flow["expected_states"]
|
||||
if actual_states != expected_states:
|
||||
findings.append(
|
||||
Finding(
|
||||
severity=flow["severity"],
|
||||
kind="state_machine_mismatch",
|
||||
layer="flow",
|
||||
item_id="flow_states",
|
||||
expected=",".join(expected_states),
|
||||
actual=",".join(actual_states),
|
||||
delta="state_list_diff",
|
||||
evidence=flow["state_file"],
|
||||
)
|
||||
)
|
||||
|
||||
for transition in flow.get("required_transitions", []):
|
||||
transition_file = ROOT / transition["file"]
|
||||
transition_text = read_text(transition_file)
|
||||
if transition["snippet"] not in transition_text:
|
||||
findings.append(
|
||||
Finding(
|
||||
severity=transition["severity"],
|
||||
kind="transition_missing",
|
||||
layer="flow",
|
||||
item_id=transition["id"],
|
||||
expected=transition["snippet"],
|
||||
actual="missing",
|
||||
delta="snippet_not_found",
|
||||
evidence=transition["file"],
|
||||
)
|
||||
)
|
||||
|
||||
# accessibility
|
||||
for spec in baseline.get("accessibility", []):
|
||||
file_path = ROOT / spec["file"]
|
||||
text = read_text(file_path)
|
||||
if spec["snippet"] not in text:
|
||||
findings.append(
|
||||
Finding(
|
||||
severity=spec["severity"],
|
||||
kind="accessibility_missing",
|
||||
layer="accessibility",
|
||||
item_id=spec["id"],
|
||||
expected=spec["snippet"],
|
||||
actual="missing",
|
||||
delta="snippet_not_found",
|
||||
evidence=spec["file"],
|
||||
)
|
||||
)
|
||||
|
||||
# animation snippets
|
||||
for spec in baseline.get("animations", []):
|
||||
file_path = ROOT / spec["file"]
|
||||
text = read_text(file_path)
|
||||
if spec["snippet"] not in text:
|
||||
findings.append(
|
||||
Finding(
|
||||
severity=spec["severity"],
|
||||
kind="animation_snippet_missing",
|
||||
layer="animations",
|
||||
item_id=spec["id"],
|
||||
expected=spec["snippet"],
|
||||
actual="missing",
|
||||
delta="snippet_not_found",
|
||||
evidence=spec["file"],
|
||||
)
|
||||
)
|
||||
|
||||
# assets/imagesets
|
||||
assets = baseline.get("assets", {})
|
||||
for spec in assets.get("imagesets", []):
|
||||
imageset_path = ROOT / spec["path"]
|
||||
if not imageset_path.exists():
|
||||
findings.append(
|
||||
Finding(
|
||||
severity=spec["severity"],
|
||||
kind="asset_missing",
|
||||
layer="assets",
|
||||
item_id=spec["id"],
|
||||
expected=spec["path"],
|
||||
actual="missing",
|
||||
delta="imageset_missing",
|
||||
evidence=spec["path"],
|
||||
)
|
||||
)
|
||||
continue
|
||||
for file_spec in spec.get("files", []):
|
||||
file_path = imageset_path / file_spec["name"]
|
||||
if not file_path.exists():
|
||||
findings.append(
|
||||
Finding(
|
||||
severity=spec["severity"],
|
||||
kind="asset_file_missing",
|
||||
layer="assets",
|
||||
item_id=f"{spec['id']}/{file_spec['name']}",
|
||||
expected=file_spec["name"],
|
||||
actual="missing",
|
||||
delta="file_missing",
|
||||
evidence=str(imageset_path.relative_to(ROOT)),
|
||||
)
|
||||
)
|
||||
continue
|
||||
actual_sha = sha256_file(file_path)
|
||||
if actual_sha != file_spec["sha256"]:
|
||||
findings.append(
|
||||
Finding(
|
||||
severity=spec["severity"],
|
||||
kind="asset_hash_mismatch",
|
||||
layer="assets",
|
||||
item_id=f"{spec['id']}/{file_spec['name']}",
|
||||
expected=file_spec["sha256"],
|
||||
actual=actual_sha,
|
||||
delta="sha256_mismatch",
|
||||
evidence=str(file_path.relative_to(ROOT)),
|
||||
)
|
||||
)
|
||||
expected_size = file_spec.get("size")
|
||||
if expected_size is not None:
|
||||
actual_size = parse_image_size(file_path)
|
||||
if actual_size is None:
|
||||
findings.append(
|
||||
Finding(
|
||||
severity=spec["severity"],
|
||||
kind="asset_size_missing",
|
||||
layer="assets",
|
||||
item_id=f"{spec['id']}/{file_spec['name']}",
|
||||
expected=f"{expected_size['width']}x{expected_size['height']}",
|
||||
actual="unknown",
|
||||
delta="size_unavailable",
|
||||
evidence=str(file_path.relative_to(ROOT)),
|
||||
)
|
||||
)
|
||||
else:
|
||||
if actual_size != (expected_size["width"], expected_size["height"]):
|
||||
findings.append(
|
||||
Finding(
|
||||
severity=spec["severity"],
|
||||
kind="asset_size_mismatch",
|
||||
layer="assets",
|
||||
item_id=f"{spec['id']}/{file_spec['name']}",
|
||||
expected=f"{expected_size['width']}x{expected_size['height']}",
|
||||
actual=f"{actual_size[0]}x{actual_size[1]}",
|
||||
delta="size_mismatch",
|
||||
evidence=str(file_path.relative_to(ROOT)),
|
||||
)
|
||||
)
|
||||
|
||||
# assets/lottie
|
||||
for spec in assets.get("lottie", []):
|
||||
lottie_path = ROOT / spec["path"]
|
||||
if not lottie_path.exists():
|
||||
findings.append(
|
||||
Finding(
|
||||
severity=spec["severity"],
|
||||
kind="lottie_missing",
|
||||
layer="assets",
|
||||
item_id=spec["id"],
|
||||
expected=spec["path"],
|
||||
actual="missing",
|
||||
delta="lottie_missing",
|
||||
evidence=spec["path"],
|
||||
)
|
||||
)
|
||||
continue
|
||||
actual_sha = sha256_file(lottie_path)
|
||||
if actual_sha != spec["sha256"]:
|
||||
findings.append(
|
||||
Finding(
|
||||
severity=spec["severity"],
|
||||
kind="lottie_hash_mismatch",
|
||||
layer="assets",
|
||||
item_id=spec["id"],
|
||||
expected=spec["sha256"],
|
||||
actual=actual_sha,
|
||||
delta="sha256_mismatch",
|
||||
evidence=spec["path"],
|
||||
)
|
||||
)
|
||||
actual_meta = parse_lottie_meta(lottie_path)
|
||||
expected_meta = spec.get("meta", {})
|
||||
for key in ["fps", "ip", "op", "width", "height"]:
|
||||
if expected_meta.get(key) != actual_meta.get(key):
|
||||
findings.append(
|
||||
Finding(
|
||||
severity=spec["severity"],
|
||||
kind="lottie_meta_mismatch",
|
||||
layer="assets",
|
||||
item_id=f"{spec['id']}.{key}",
|
||||
expected=str(expected_meta.get(key)),
|
||||
actual=str(actual_meta.get(key)),
|
||||
delta=f"{key}_mismatch",
|
||||
evidence=spec["path"],
|
||||
)
|
||||
)
|
||||
|
||||
finding_dicts = [f.to_dict() for f in findings]
|
||||
counts = {"P0": 0, "P1": 0, "P2": 0, "P3": 0}
|
||||
for f in findings:
|
||||
counts[f.severity] = counts.get(f.severity, 0) + 1
|
||||
|
||||
return {
|
||||
"generated_at": dt.datetime.now(dt.timezone.utc).isoformat(),
|
||||
"summary": {
|
||||
"total": len(findings),
|
||||
"by_severity": counts,
|
||||
},
|
||||
"findings": finding_dicts,
|
||||
}
|
||||
|
||||
|
||||
def severity_rank(severity: str) -> int:
|
||||
order = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
|
||||
return order.get(severity, 99)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Voice recording parity checker")
|
||||
parser.add_argument("--baseline", default=str(BASELINE_PATH))
|
||||
parser.add_argument("--emit-baseline", action="store_true")
|
||||
parser.add_argument("--report-json", action="store_true")
|
||||
parser.add_argument("--pretty", action="store_true")
|
||||
parser.add_argument("--fail-on", default="P1", choices=["P0", "P1", "P2", "P3", "none"])
|
||||
args = parser.parse_args()
|
||||
|
||||
baseline_path = pathlib.Path(args.baseline)
|
||||
|
||||
if args.emit_baseline:
|
||||
baseline = build_baseline()
|
||||
baseline_path.write_text(json.dumps(baseline, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
if args.report_json:
|
||||
print(json.dumps({"baseline_written": str(baseline_path.relative_to(ROOT))}, ensure_ascii=False))
|
||||
else:
|
||||
print(f"Baseline written: {baseline_path}")
|
||||
return 0
|
||||
|
||||
if not baseline_path.exists():
|
||||
print(f"Baseline not found: {baseline_path}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
|
||||
report = run_checker(baseline)
|
||||
|
||||
if args.report_json or args.pretty:
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2 if args.pretty else None))
|
||||
else:
|
||||
total = report["summary"]["total"]
|
||||
counts = report["summary"]["by_severity"]
|
||||
print(f"findings={total} P0={counts.get('P0', 0)} P1={counts.get('P1', 0)} P2={counts.get('P2', 0)} P3={counts.get('P3', 0)}")
|
||||
|
||||
if args.fail_on != "none":
|
||||
threshold = severity_rank(args.fail_on)
|
||||
for finding in report["findings"]:
|
||||
if severity_rank(finding["severity"]) <= threshold:
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user