feat: add in-reader search and restructure documentation

- Add RDEPUBReaderSearchBarView with animated show/hide, keyword
  navigation, and match counting integrated into the reader controller
- Restructure docs: replace scattered design docs with consolidated
  BUSINESS_LOGIC.md and UML_CLASS_DIAGRAMS.md; update ARCHITECTURE.md
- Add SearchTests and FanrenParseTimeTest; enhance LargeBookOnDemandTests
- Add scripts/run_ui_regression.sh and summarize_ui_results.py for
  automated UI test execution and reporting

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-06-05 17:34:50 +08:00
co-authored by Claude Opus 4.7
parent d20196ee34
commit 0e7c0577e3
37 changed files with 4941 additions and 7621 deletions
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env bash
set -u
set -o pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PROJECT_PATH="${PROJECT_PATH:-$ROOT_DIR/ReadViewDemo/ReadViewDemo.xcodeproj}"
SCHEME_NAME="${SCHEME_NAME:-ReadViewDemo}"
CONFIGURATION="${CONFIGURATION:-Debug}"
SIMULATOR_NAME="${SIMULATOR_NAME:-iPhone 16 Plus}"
DESTINATION="${DESTINATION:-platform=iOS Simulator,name=${SIMULATOR_NAME}}"
RESULTS_ROOT="${RESULTS_ROOT:-$ROOT_DIR/.artifacts/ui-tests}"
DERIVED_DATA_PATH="${DERIVED_DATA_PATH:-$RESULTS_ROOT/DerivedData}"
ONLY_TESTING="${ONLY_TESTING:-}"
TEST_PLAN="${TEST_PLAN:-}"
TEST_TYPE_ARG="${1:-}"
TEST_TYPE="${TEST_TYPE:-${TEST_TYPE_ARG:-quick}}"
TIMESTAMP="$(date +"%Y%m%d-%H%M%S")"
RUN_DIR="$RESULTS_ROOT/$TIMESTAMP"
XCRESULT_PATH="$RUN_DIR/ReadViewDemoUITests.xcresult"
RAW_LOG_PATH="$RUN_DIR/xcodebuild.log"
REPORT_MD_PATH="$RUN_DIR/UI-Test-Report.md"
REPORT_JSON_PATH="$RUN_DIR/ui-test-results.json"
REPORT_TXT_PATH="$RUN_DIR/summary.txt"
SUMMARY_SCRIPT="$ROOT_DIR/scripts/summarize_ui_results.py"
TARGET_NAME="ReadViewDemoUITests"
QUICK_TESTS=(
BookmarkManagementTests
BookmarkTests
DisplayTypeTests
ErrorAndEdgeCaseTests
HighlightsManagementTests
LocationPersistenceTests
PageNavigationTests
ReaderAnnotationExtendedTests
ReaderAnnotationTests
ReaderOpenCloseTests
ReaderToolbarTests
SearchTests
SelectionMenuTests
SettingsEffectTests
SettingsExtendedTests
SettingsPanelTests
TOCInteractionTests
TableOfContentsTests
ToolbarStateTests
)
LARGE_BOOK_TESTS=(
ConfigurableWindowTests
ConcurrentParsingTests
LargeBookOnDemandTests
)
PERFORMANCE_TESTS=(
FanrenParseTimeTest
MetadataParseBenchmarkTests
)
mkdir -p "$RUN_DIR" "$DERIVED_DATA_PATH"
append_only_testing_items() {
local raw_list="$1"
local item
IFS=',' read -r -a ONLY_TESTING_ITEMS <<< "$raw_list"
for item in "${ONLY_TESTING_ITEMS[@]}"; do
local trimmed_item
trimmed_item="$(echo "$item" | xargs)"
if [[ -n "$trimmed_item" ]]; then
XCODEBUILD_ARGS+=(-only-testing:"$trimmed_item")
fi
done
}
apply_test_type_filter() {
local selected_type="$1"
local test_name
case "$selected_type" in
quick)
for test_name in "${QUICK_TESTS[@]}"; do
XCODEBUILD_ARGS+=(-only-testing:"$TARGET_NAME/$test_name")
done
;;
large-book)
for test_name in "${LARGE_BOOK_TESTS[@]}"; do
XCODEBUILD_ARGS+=(-only-testing:"$TARGET_NAME/$test_name")
done
;;
performance)
for test_name in "${PERFORMANCE_TESTS[@]}"; do
XCODEBUILD_ARGS+=(-only-testing:"$TARGET_NAME/$test_name")
done
;;
all)
;;
*)
echo "Unsupported TEST_TYPE: $selected_type" >&2
echo "Supported values: quick, large-book, performance, all" >&2
exit 2
;;
esac
}
echo "UI regression run"
echo " project: $PROJECT_PATH"
echo " scheme: $SCHEME_NAME"
echo " configuration:$CONFIGURATION"
echo " destination: $DESTINATION"
echo " test type: $TEST_TYPE"
echo " results: $RUN_DIR"
XCODEBUILD_ARGS=(
xcodebuild
test
-project "$PROJECT_PATH"
-scheme "$SCHEME_NAME"
-configuration "$CONFIGURATION"
-destination "$DESTINATION"
-resultBundlePath "$XCRESULT_PATH"
-derivedDataPath "$DERIVED_DATA_PATH"
)
if [[ -n "$TEST_PLAN" ]]; then
XCODEBUILD_ARGS+=(-testPlan "$TEST_PLAN")
fi
if [[ -n "$ONLY_TESTING" ]]; then
append_only_testing_items "$ONLY_TESTING"
else
apply_test_type_filter "$TEST_TYPE"
fi
set +e
"${XCODEBUILD_ARGS[@]}" 2>&1 | tee "$RAW_LOG_PATH"
TEST_EXIT_CODE=${PIPESTATUS[0]}
set -e
python3 "$SUMMARY_SCRIPT" \
--xcresult "$XCRESULT_PATH" \
--raw-log "$RAW_LOG_PATH" \
--output-md "$REPORT_MD_PATH" \
--output-json "$REPORT_JSON_PATH" \
--output-txt "$REPORT_TXT_PATH" \
--test-type "$TEST_TYPE" \
--xcodebuild-exit-code "$TEST_EXIT_CODE"
echo
cat "$REPORT_TXT_PATH"
echo
echo "Artifacts"
echo " summary: $REPORT_TXT_PATH"
echo " report: $REPORT_MD_PATH"
echo " json: $REPORT_JSON_PATH"
echo " xcresult: $XCRESULT_PATH"
echo " raw log: $RAW_LOG_PATH"
exit "$TEST_EXIT_CODE"
+465
View File
@@ -0,0 +1,465 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from collections import Counter, defaultdict
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@dataclass
class FailureItem:
test_name: str
target_name: str
category: str
summary: str
failure_text: str
test_identifier: str | None = None
def run_json_command(command: list[str]) -> dict[str, Any] | list[Any] | None:
try:
completed = subprocess.run(
command,
check=True,
capture_output=True,
text=True,
)
except (OSError, subprocess.CalledProcessError):
return None
stdout = completed.stdout.strip()
if not stdout:
return None
try:
return json.loads(stdout)
except json.JSONDecodeError:
return None
def compact_text(text: str) -> str:
return re.sub(r"\s+", " ", text).strip()
def summarize_failure_text(text: str) -> str:
compact = compact_text(text)
if not compact:
return "No failure message captured"
for delimiter in (" - ", ". ", "\n", ""):
if delimiter in compact:
head = compact.split(delimiter, 1)[0].strip()
if len(head) >= 12:
return head[:220]
return compact[:220]
def classify_failure(text: str, test_name: str) -> str:
haystack = f"{test_name} {text}".lower()
if any(token in haystack for token in [
"timed out",
"timeout",
"failed to fulfill",
"waitfordemoreaderstate",
"waitforexistence",
"等待",
"超时",
]):
return "Timeout"
if any(token in haystack for token in [
"no matches found",
"failed to find",
"does not exist",
"existence",
"not hittable",
"不存在",
"未出现",
]):
return "Element Missing"
if any(token in haystack for token in [
"simulator",
"lost connection",
"test runner exited",
"application state",
"quit unexpectedly",
"connection interrupted",
"xcodebuild",
]):
return "Environment"
if any(token in haystack for token in [
"page",
"pagination",
"bookpagemap",
"parsems",
"readerstate",
"location",
"页码",
"分页",
"位置",
]):
return "Navigation/State"
if any(token in haystack for token in [
"xctassert",
"assertion",
"xctfail",
"failed:",
]):
return "Assertion Failure"
return "Functional Failure"
def normalize_root_cause(text: str) -> str:
compact = summarize_failure_text(text).lower()
compact = re.sub(r"\b\d+\b", "<n>", compact)
compact = re.sub(r"'[^']+'", "'<value>'", compact)
compact = re.sub(r'"[^"]+"', '"<value>"', compact)
return compact[:180]
def load_summary_from_xcresult(xcresult_path: Path) -> dict[str, Any] | None:
return run_json_command([
"xcrun",
"xcresulttool",
"get",
"test-results",
"summary",
"--path",
str(xcresult_path),
"--compact",
])
def load_tests_from_xcresult(xcresult_path: Path) -> dict[str, Any] | None:
return run_json_command([
"xcrun",
"xcresulttool",
"get",
"test-results",
"tests",
"--path",
str(xcresult_path),
"--compact",
])
def flatten_failed_test_nodes(node: dict[str, Any], out: list[dict[str, Any]]) -> None:
node_type = node.get("nodeType")
result = node.get("result")
if node_type == "Test Case" and result == "Failed":
out.append(node)
for child in node.get("children", []) or []:
flatten_failed_test_nodes(child, out)
def extract_failures(
summary: dict[str, Any] | None,
tests_payload: dict[str, Any] | None,
raw_log_path: Path | None,
) -> list[FailureItem]:
failures: list[FailureItem] = []
raw_failures = None
if summary:
raw_failures = summary.get("testFailures")
if isinstance(raw_failures, dict):
raw_failures = [raw_failures]
if isinstance(raw_failures, list):
for item in raw_failures:
if not isinstance(item, dict):
continue
test_name = item.get("testName") or "Unknown test"
target_name = item.get("targetName") or "Unknown target"
failure_text = item.get("failureText") or ""
failures.append(FailureItem(
test_name=test_name,
target_name=target_name,
category=classify_failure(failure_text, test_name),
summary=summarize_failure_text(failure_text),
failure_text=compact_text(failure_text),
test_identifier=item.get("testIdentifierString") or item.get("testIdentifierURL"),
))
if failures:
return dedupe_failures(failures)
if tests_payload:
failed_nodes: list[dict[str, Any]] = []
for node in tests_payload.get("testNodes", []) or []:
flatten_failed_test_nodes(node, failed_nodes)
for node in failed_nodes:
test_name = node.get("name") or "Unknown test"
details = node.get("details") or ""
failures.append(FailureItem(
test_name=test_name,
target_name="Unknown target",
category=classify_failure(details, test_name),
summary=summarize_failure_text(details),
failure_text=compact_text(details),
test_identifier=node.get("nodeIdentifier") or node.get("nodeIdentifierURL"),
))
if failures:
return dedupe_failures(failures)
if raw_log_path and raw_log_path.exists():
log_text = raw_log_path.read_text(encoding="utf-8", errors="replace")
matches = re.findall(
r"Test Case '-\[(.*?)\]' failed .*?\n(.*?)(?=\nTest Case '-\[|\Z)",
log_text,
flags=re.S,
)
for test_name, block in matches:
failure_text = compact_text(block)
failures.append(FailureItem(
test_name=test_name,
target_name="Unknown target",
category=classify_failure(failure_text, test_name),
summary=summarize_failure_text(failure_text),
failure_text=failure_text,
))
return dedupe_failures(failures)
def dedupe_failures(failures: list[FailureItem]) -> list[FailureItem]:
deduped: dict[tuple[str, str], FailureItem] = {}
for failure in failures:
key = (failure.test_name, failure.summary)
deduped.setdefault(key, failure)
return list(deduped.values())
def build_root_causes(failures: list[FailureItem]) -> list[dict[str, Any]]:
grouped: dict[str, list[FailureItem]] = defaultdict(list)
for failure in failures:
grouped[normalize_root_cause(failure.failure_text)].append(failure)
ranked = sorted(
grouped.items(),
key=lambda item: (-len(item[1]), item[0]),
)
result = []
for normalized_text, items in ranked[:5]:
result.append({
"summary": items[0].summary,
"category": items[0].category,
"count": len(items),
"tests": [failure.test_name for failure in items[:5]],
})
return result
def build_report_payload(
args: argparse.Namespace,
summary: dict[str, Any] | None,
tests_payload: dict[str, Any] | None,
failures: list[FailureItem],
) -> dict[str, Any]:
device_summaries = []
if summary:
devices_and_configurations = summary.get("devicesAndConfigurations")
if isinstance(devices_and_configurations, dict):
devices_and_configurations = [devices_and_configurations]
if isinstance(devices_and_configurations, list):
for item in devices_and_configurations:
if not isinstance(item, dict):
continue
device = item.get("device") or {}
config = item.get("testPlanConfiguration") or {}
device_summaries.append({
"device_name": device.get("deviceName"),
"model_name": device.get("modelName"),
"os_version": device.get("osVersion"),
"configuration_name": config.get("configurationName"),
"passed_tests": item.get("passedTests"),
"failed_tests": item.get("failedTests"),
})
categories = Counter(failure.category for failure in failures)
payload = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"xcodebuild_exit_code": args.xcodebuild_exit_code,
"test_type": args.test_type,
"artifacts": {
"xcresult": str(Path(args.xcresult).resolve()) if args.xcresult else None,
"raw_log": str(Path(args.raw_log).resolve()) if args.raw_log else None,
},
"summary": {
"title": summary.get("title") if summary else None,
"environment_description": summary.get("environmentDescription") if summary else None,
"result": summary.get("result") if summary else ("Failed" if failures else "Passed"),
"total_test_count": summary.get("totalTestCount") if summary else None,
"passed_tests": summary.get("passedTests") if summary else None,
"failed_tests": summary.get("failedTests") if summary else len(failures),
"skipped_tests": summary.get("skippedTests") if summary else None,
"devices": device_summaries,
},
"failure_categories": dict(sorted(categories.items(), key=lambda item: (-item[1], item[0]))),
"top_root_causes": build_root_causes(failures),
"failures": [asdict(failure) for failure in failures],
"token_saving_notes": [
"Raw xcodebuild output is intentionally omitted from this report.",
"Use the xcresult or raw log paths only when a single failure needs deeper inspection.",
"Failures are deduplicated by test name and compact summary.",
],
}
if tests_payload and not payload["summary"]["total_test_count"]:
flat_failed_nodes: list[dict[str, Any]] = []
for node in tests_payload.get("testNodes", []) or []:
flatten_failed_test_nodes(node, flat_failed_nodes)
payload["summary"]["failed_tests"] = len(flat_failed_nodes)
return payload
def render_markdown(payload: dict[str, Any]) -> str:
summary = payload["summary"]
lines = [
"# UI Test Report",
"",
"## Snapshot",
"",
f"- Test type: `{payload.get('test_type')}`",
f"- Result: `{summary.get('result')}`",
f"- xcodebuild exit code: `{payload['xcodebuild_exit_code']}`",
f"- Total tests: `{summary.get('total_test_count')}`",
f"- Passed: `{summary.get('passed_tests')}`",
f"- Failed: `{summary.get('failed_tests')}`",
f"- Skipped: `{summary.get('skipped_tests')}`",
]
if summary.get("environment_description"):
lines.append(f"- Environment: `{summary['environment_description']}`")
artifacts = payload["artifacts"]
lines.extend([
f"- xcresult: `{artifacts.get('xcresult')}`",
f"- raw log: `{artifacts.get('raw_log')}`",
"",
])
if payload["failure_categories"]:
lines.extend([
"## Failure Categories",
"",
])
for category, count in payload["failure_categories"].items():
lines.append(f"- `{category}`: {count}")
lines.append("")
if payload["top_root_causes"]:
lines.extend([
"## Top Root Causes",
"",
])
for item in payload["top_root_causes"]:
lines.append(f"- `{item['category']}` x{item['count']}: {item['summary']}")
lines.append("")
lines.extend([
"## Failed Tests",
"",
])
if payload["failures"]:
for failure in payload["failures"]:
lines.append(
f"- `{failure['test_name']}` [{failure['category']}] {failure['summary']}"
)
else:
lines.append("- None")
lines.append("")
lines.extend([
"## Token-Saving Notes",
"",
])
for note in payload["token_saving_notes"]:
lines.append(f"- {note}")
lines.append("")
return "\n".join(lines)
def render_text_summary(payload: dict[str, Any]) -> str:
summary = payload["summary"]
total = summary.get("total_test_count")
passed = summary.get("passed_tests")
failed = summary.get("failed_tests")
skipped = summary.get("skipped_tests")
lines = [
"UI test summary",
f" type: {payload.get('test_type')}",
f" result: {summary.get('result')}",
f" tests: total={total} passed={passed} failed={failed} skipped={skipped}",
]
if payload["failure_categories"]:
category_text = ", ".join(
f"{name}={count}" for name, count in payload["failure_categories"].items()
)
lines.append(f" categories: {category_text}")
if payload["top_root_causes"]:
top = payload["top_root_causes"][0]
lines.append(f" top root cause: {top['summary']} ({top['count']} tests)")
return "\n".join(lines)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Summarize UI test xcresult output into a low-token report.")
parser.add_argument("--xcresult", help="Path to .xcresult bundle", default="")
parser.add_argument("--raw-log", help="Path to xcodebuild raw log", default="")
parser.add_argument("--output-md", required=True, help="Markdown report output path")
parser.add_argument("--output-json", required=True, help="JSON report output path")
parser.add_argument("--output-txt", required=True, help="Plain-text summary output path")
parser.add_argument("--test-type", default="unknown", help="Selected UI test group")
parser.add_argument("--xcodebuild-exit-code", type=int, required=True, help="Exit code from xcodebuild")
return parser.parse_args()
def main() -> int:
args = parse_args()
xcresult_path = Path(args.xcresult) if args.xcresult else None
raw_log_path = Path(args.raw_log) if args.raw_log else None
summary = None
tests_payload = None
if xcresult_path and xcresult_path.exists():
summary = load_summary_from_xcresult(xcresult_path)
tests_payload = load_tests_from_xcresult(xcresult_path)
failures = extract_failures(summary, tests_payload, raw_log_path)
payload = build_report_payload(args, summary, tests_payload, failures)
output_md = Path(args.output_md)
output_json = Path(args.output_json)
output_txt = Path(args.output_txt)
output_md.parent.mkdir(parents=True, exist_ok=True)
output_json.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
output_md.write_text(render_markdown(payload) + "\n", encoding="utf-8")
output_txt.write_text(render_text_summary(payload) + "\n", encoding="utf-8")
return 0
if __name__ == "__main__":
sys.exit(main())