initial commit
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(DocViewHeavyPdfValidation LANGUAGES CXX)
|
||||
if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux")
|
||||
message(FATAL_ERROR "This supplemental harness currently validates Linux only")
|
||||
endif()
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
set(CMAKE_AUTORCC ON)
|
||||
get_filename_component(DOCVIEW_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE)
|
||||
set(DOCVIEW_CANONICAL_BUILD "${DOCVIEW_ROOT}/build" CACHE PATH "Existing tested Release build")
|
||||
find_package(Qt6 6.11.2 EXACT REQUIRED COMPONENTS Core Gui Network Qml Quick QuickControls2 WebEngineQuick Test)
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(FONTCONFIG REQUIRED IMPORTED_TARGET fontconfig)
|
||||
pkg_check_modules(SECCOMP REQUIRED IMPORTED_TARGET libseccomp)
|
||||
pkg_check_modules(TOML REQUIRED IMPORTED_TARGET tomlplusplus)
|
||||
foreach(name docview_core docview_common docview_font_broker)
|
||||
if(NOT EXISTS "${DOCVIEW_CANONICAL_BUILD}/lib${name}.a")
|
||||
message(FATAL_ERROR "Build the canonical DocView targets first: ${name}")
|
||||
endif()
|
||||
add_library(${name} STATIC IMPORTED)
|
||||
set_target_properties(${name} PROPERTIES IMPORTED_LOCATION "${DOCVIEW_CANONICAL_BUILD}/lib${name}.a")
|
||||
endforeach()
|
||||
qt_add_executable(test_heavy_pdf test_heavy_pdf.cpp
|
||||
"${DOCVIEW_ROOT}/src/app/controller.cpp"
|
||||
"${DOCVIEW_ROOT}/src/app/pdf_canvas.cpp"
|
||||
"${DOCVIEW_ROOT}/src/web/web_profile.cpp"
|
||||
"${DOCVIEW_ROOT}/resources.qrc")
|
||||
target_include_directories(test_heavy_pdf PRIVATE "${DOCVIEW_ROOT}/src")
|
||||
target_link_libraries(test_heavy_pdf PRIVATE docview_core docview_font_broker docview_common
|
||||
PkgConfig::FONTCONFIG PkgConfig::SECCOMP PkgConfig::TOML Qt6::Core Qt6::Gui Qt6::Network Qt6::Quick
|
||||
Qt6::Qml Qt6::QuickControls2 Qt6::WebEngineQuick Qt6::Test)
|
||||
# Use exactly the recorded canonical workers; do not rebuild parser binaries.
|
||||
foreach(worker docview-pdf-worker docview-archive-worker)
|
||||
add_custom_command(TARGET test_heavy_pdf POST_BUILD
|
||||
COMMAND "${CMAKE_COMMAND}" -E create_symlink
|
||||
"${DOCVIEW_CANONICAL_BUILD}/${worker}" "$<TARGET_FILE_DIR:test_heavy_pdf>/${worker}")
|
||||
endforeach()
|
||||
@@ -0,0 +1,21 @@
|
||||
# 高解像度・高密度PDFのGUI検証
|
||||
|
||||
本番Controller・QML・PdfCanvasと既存Releaseビルドのcore/common/font brokerライブラリを使う、Linux専用の追加試験。PDF/Archive workerは既存の実行ファイルへ接続し、模擬workerや保護無効化を使わない。通常のCTestや製品バイナリーを変更せず、別の実行ファイルとしてビルドする。
|
||||
|
||||
```sh
|
||||
cmake -S tests/heavy_pdf -B build-heavy-pdf -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DDOCVIEW_CANONICAL_BUILD="$PWD/build"
|
||||
cmake --build build-heavy-pdf --parallel 4
|
||||
python3 tests/heavy_pdf/run.py --output tests/results/heavy-pdf/new-run
|
||||
```
|
||||
|
||||
既存ビルドと同じQt 6.11.2・ツールチェーン・依存ライブラリを使う。Qt SDKを別配置している環境では`CMAKE_PREFIX_PATH`、`PATH`、`LD_LIBRARY_PATH`、`PKG_CONFIG_PATH`も既存ビルドに合わせる。別のビルド先はrunnerの`--binary`と`--canonical-build`で明示する。既存の結果フォルダーは上書きしない。
|
||||
|
||||
資料は2ページ、595×842 pt。1ページ目は4,960×7,016 RGB画像(展開後104,398,080 bytes、A4の600dpi相当)、2ページ目はalpha 0.16・Multiplyで重ねる60,000個のベクターパス。生成元はオリジナルの決定的なアルゴリズムで、実スキャンや書籍ではない。Helveticaの標準フォント参照は非埋込であり、OSの代替を使用する。
|
||||
|
||||
qpdfによる画像streamの独立復号でサイズと全画素hashを照合し、Popplerの`pdfinfo`/`pdftotext`でページと本文markerを確認する。GUIでは画像の暗いブロックと明るい紙面、多重合成したベクター領域、各ページ固有の色markerを取得画素から検査する。その後、実open RPCが未完の状態でEscを送り、実render RPCが未完の状態で別PDFへ切り替える。旧sessionの破棄、旧文書の保持、切替後の安定、原本hash不変、文書close後のcache解放、通常解像度の維持を確認する。
|
||||
|
||||
専用Xvfb 1100×760・DPR1・Qt Quick software・専用D-Busを使う。XDG保存先は一時領域で、利用者の履歴を使用しない。10ms heartbeatの最大間隔はCI停止検出用の1,500ms未満で判定し、50msの視覚応答p95を代用しない。50msごとに採るRSS合計は共有ページの重複を含む。
|
||||
|
||||
出力には原本、manifest、復号照合、QtTestログ、3画面、各操作の記録、source/library/worker hashを保存する。`docview`本体のmain関数そのものを実行する試験ではなく、同じ本番GUI部品を使う専用test executableである。GPU、実IME、物理画面、Windows、長時間リークや広範な実書籍の互換性をこの結果だけで合格にしない。[保存済み結果](../results/heavy-pdf/README.md)
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate finite, original high-resolution raster and transparent-vector pages."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import zlib
|
||||
|
||||
|
||||
def generate(path):
|
||||
path = Path(path)
|
||||
if path.exists():
|
||||
raise FileExistsError(path)
|
||||
width, height, curves = 4960, 7016, 60000
|
||||
compressor = zlib.compressobj(6)
|
||||
chunks = []
|
||||
raw_hash = hashlib.sha256()
|
||||
# Bounded row buffer; 104,398,080 decoded RGB bytes, about A4 at 600 dpi.
|
||||
paper = bytes((248, 246, 240)) * width
|
||||
text_row = b''.join(bytes((35, 40, 48)) if 260 < x < width - 260 and x % 83 < 61
|
||||
else bytes((248, 246, 240)) for x in range(width))
|
||||
for y in range(height):
|
||||
row = text_row if 500 < y < height - 500 and y % 123 < 35 else paper
|
||||
raw_hash.update(row)
|
||||
part = compressor.compress(row)
|
||||
if part:
|
||||
chunks.append(part)
|
||||
chunks.append(compressor.flush())
|
||||
image = b''.join(chunks)
|
||||
vector = [b'q 20 20 555 802 re W n /GS gs\n']
|
||||
for index in range(curves):
|
||||
x, y = 25 + (index * 37) % 490, 30 + (index * 53) % 730
|
||||
r, g, b = (index % 7) / 7, (index % 11) / 11, (index % 13) / 13
|
||||
vector.append((f'{r:.3f} {g:.3f} {b:.3f} rg {x} {y} m '
|
||||
f'{x+38} {y+67} {x+61} {y-19} {x+69} {y+22} c '
|
||||
f'{x+25} {y+48} l h f\n').encode())
|
||||
vector.append(b'Q\n0.85 0.1 0.65 rg 30 780 32 32 re f\n'
|
||||
b'BT /F1 12 Tf 80 790 Td (DOCVIEW COMPLEX 60000 TRANSPARENT PATHS) Tj ET\n')
|
||||
content = [b'q 555 0 0 782 20 20 cm /Scan Do Q\n'
|
||||
b'0.1 0.3 0.6 rg 30 780 32 32 re f\n'
|
||||
b'BT /F1 12 Tf 80 790 Td (DOCVIEW RASTER 4960 x 7016 RGB) Tj ET\n',
|
||||
b''.join(vector)]
|
||||
def stream(data, dictionary=b''):
|
||||
return dictionary + b' /Length ' + str(len(data)).encode() + b' >>\nstream\n' + data + b'\nendstream'
|
||||
objects = [b'<< /Type /Catalog /Pages 2 0 R >>',
|
||||
b'<< /Type /Pages /Count 2 /Kids [7 0 R 9 0 R] >>',
|
||||
b'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
|
||||
stream(image, f'<< /Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode'.encode()),
|
||||
b'<< /Type /ExtGState /ca 0.16 /CA 0.16 /BM /Multiply >>',
|
||||
stream(zlib.compress(content[0]), b'<< /Filter /FlateDecode'),
|
||||
b'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> /XObject << /Scan 4 0 R >> >> /Contents 6 0 R >>',
|
||||
stream(zlib.compress(content[1]), b'<< /Filter /FlateDecode'),
|
||||
b'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> /ExtGState << /GS 5 0 R >> >> /Contents 8 0 R >>']
|
||||
offsets = [0]
|
||||
with path.open('xb') as out:
|
||||
out.write(b'%PDF-1.7\n%\xe2\xe3\xcf\xd3\n')
|
||||
for index, data in enumerate(objects, 1):
|
||||
offsets.append(out.tell())
|
||||
out.write(f'{index} 0 obj\n'.encode() + data + b'\nendobj\n')
|
||||
xref = out.tell()
|
||||
out.write(f'xref\n0 {len(offsets)}\n0000000000 65535 f \n'.encode())
|
||||
for offset in offsets[1:]:
|
||||
out.write(f'{offset:010d} 00000 n \n'.encode())
|
||||
out.write(f'trailer\n<< /Root 1 0 R /Size {len(offsets)} >>\nstartxref\n{xref}\n%%EOF\n'.encode())
|
||||
return {'sha256': hashlib.sha256(path.read_bytes()).hexdigest(), 'sizeBytes': path.stat().st_size,
|
||||
'pages': 2, 'rasterWidth': width, 'rasterHeight': height, 'decodedRasterBytes': width * height * 3,
|
||||
'rasterSha256': raw_hash.hexdigest(), 'transparentPathCount': curves,
|
||||
'rasterFilter': 'FlateDecode', 'vectorAlpha': 0.16, 'vectorBlendMode': 'Multiply',
|
||||
'source': 'Original deterministic synthetic fixture; standard Helvetica is not embedded',
|
||||
'scope': 'One 600-dpi-equivalent image and one dense transparent-vector page, not a full book or real scan corpus'}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('output', type=Path)
|
||||
args = parser.parse_args()
|
||||
manifest = generate(args.output)
|
||||
args.output.with_suffix('.json').write_text(json.dumps(manifest, indent=2) + '\n')
|
||||
print(json.dumps(manifest))
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the supplemental heavy-PDF GUI test on a private X11 software display."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
from run_stress_pdf import run_observed
|
||||
from generate import generate
|
||||
|
||||
|
||||
def sha(path):
|
||||
with Path(path).open('rb') as stream:
|
||||
return hashlib.file_digest(stream, 'sha256').hexdigest()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--binary', type=Path, default=ROOT / 'build-heavy-pdf/test_heavy_pdf')
|
||||
parser.add_argument('--canonical-build', type=Path, default=ROOT / 'build')
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
parser.add_argument('--private-session', action='store_true', help=argparse.SUPPRESS)
|
||||
args = parser.parse_args()
|
||||
if args.output.exists():
|
||||
parser.error('Choose a new output directory to preserve previous evidence')
|
||||
flags = shlex.split(os.environ.get('QTWEBENGINE_CHROMIUM_FLAGS', ''))
|
||||
if os.environ.get('QTWEBENGINE_DISABLE_SANDBOX', '') not in ('', '0') or any(
|
||||
flag.split('=', 1)[0] in ('--no-sandbox', '--disable-setuid-sandbox', '--disable-seccomp-filter-sandbox') for flag in flags):
|
||||
parser.error('Sandbox-disabling settings are not allowed')
|
||||
if not args.private_session:
|
||||
env = os.environ.copy()
|
||||
for name in ['DISPLAY', 'WAYLAND_DISPLAY', 'WAYLAND_SOCKET', 'DBUS_SESSION_BUS_ADDRESS',
|
||||
'QT_SCREEN_SCALE_FACTORS', 'QT_AUTO_SCREEN_SCALE_FACTOR', 'QT_DEVICE_PIXEL_RATIO',
|
||||
'QT_SCALE_FACTOR_ROUNDING_POLICY', 'QT_ENABLE_HIGHDPI_SCALING', 'QT_QPA_PLATFORMTHEME']:
|
||||
env.pop(name, None)
|
||||
env.update(QT_QPA_PLATFORM='xcb', QT_QUICK_BACKEND='software', QT_SCALE_FACTOR='1',
|
||||
QT_FONT_DPI='96', QTWEBENGINE_CHROMIUM_FLAGS='--disable-gpu', QT_FORCE_STDERR_LOGGING='1')
|
||||
with tempfile.TemporaryDirectory(prefix='docview-heavy-session-') as private:
|
||||
bus = Path(private) / 'bus.conf'
|
||||
bus.write_text('<busconfig><type>session</type><auth>EXTERNAL</auth>'
|
||||
f'<listen>unix:tmpdir={private}</listen>'
|
||||
'<policy context="default"><allow send_destination="*"/>'
|
||||
'<allow receive_sender="*"/><allow own="*"/></policy></busconfig>')
|
||||
env['XDG_RUNTIME_DIR'] = private
|
||||
return subprocess.run(['dbus-run-session', '--config-file', str(bus), '--',
|
||||
'xvfb-run', '-a', '-s', '-screen 0 1100x760x24 -dpi 96 -nolisten tcp',
|
||||
sys.executable, str(Path(__file__).resolve()), *sys.argv[1:], '--private-session'], env=env).returncode
|
||||
output = args.output.resolve(); output.mkdir(parents=True, exist_ok=False)
|
||||
binary, build = args.binary.resolve(), args.canonical_build.resolve()
|
||||
report = {'success': False, 'os': platform.freedesktop_os_release(), 'kernel': platform.release(),
|
||||
'scope': 'Linux supplemental heavy-PDF GUI validation; not native Windows, physical display, reference hardware or long-duration acceptance'}
|
||||
watched = [binary] + [build / n for n in ['docview', 'docview-pdf-worker', 'docview-archive-worker',
|
||||
'libdocview_core.a', 'libdocview_common.a', 'libdocview_font_broker.a']]
|
||||
before = {os.path.relpath(p, ROOT): sha(p) for p in watched}
|
||||
source_files = [ROOT / 'resources.qrc']
|
||||
for folder in ['src', 'qml', 'resources', 'tests/heavy_pdf']:
|
||||
source_files += [p for p in (ROOT / folder).rglob('*') if p.is_file() and '__pycache__' not in p.parts]
|
||||
sources = {str(p.relative_to(ROOT)): sha(p) for p in sorted(set(source_files))}
|
||||
try:
|
||||
# The executable resolves workers by application directory. Verify the
|
||||
# linked paths refer to the canonical tested files before any launch.
|
||||
for worker in ['docview-pdf-worker', 'docview-archive-worker']:
|
||||
assert sha(binary.parent / worker) == sha(build / worker)
|
||||
heavy = output / 'heavy.pdf'
|
||||
manifest = generate(heavy)
|
||||
(output / 'fixture.json').write_text(json.dumps(manifest, indent=2) + '\n')
|
||||
info = subprocess.check_output(['pdfinfo', str(heavy)], text=True, timeout=15)
|
||||
(output / 'pdfinfo.txt').write_text(info)
|
||||
extracted = subprocess.check_output(['pdftotext', str(heavy), '-'], text=True, timeout=30)
|
||||
(output / 'pdftotext.txt').write_text(extracted)
|
||||
assert 'DOCVIEW RASTER 4960 x 7016 RGB' in extracted and 'DOCVIEW COMPLEX 60000 TRANSPARENT PATHS' in extracted
|
||||
# Decode the actual image object with an independent parser. Store only
|
||||
# its digest/size; the 100 MiB temporary decoded file is reclaimed.
|
||||
with tempfile.TemporaryFile() as decoded:
|
||||
subprocess.run(['qpdf', '--show-object=4', '--filtered-stream-data', str(heavy)],
|
||||
stdout=decoded, stderr=subprocess.PIPE, check=True, timeout=30)
|
||||
decoded.seek(0, 2); decoded_size = decoded.tell(); decoded.seek(0)
|
||||
decoded_hash = hashlib.file_digest(decoded, 'sha256').hexdigest()
|
||||
assert decoded_size == manifest['decodedRasterBytes'] and decoded_hash == manifest['rasterSha256']
|
||||
report['independentDecodedRaster'] = {'bytes': decoded_size, 'sha256': decoded_hash,
|
||||
'method': 'qpdf --show-object=4 --filtered-stream-data'}
|
||||
env = os.environ.copy()
|
||||
light = ROOT / 'tests/fixtures/pdf/navigation.pdf'
|
||||
env.update(DOCVIEW_HEAVY_PDF=str(heavy), DOCVIEW_LIGHT_PDF=str(light), DOCVIEW_HEAVY_OUTPUT=str(output))
|
||||
report['process'] = run_observed([str(binary)], env, output, 'test', timeout=180)
|
||||
measurement = json.loads((output / 'measurement.json').read_text()) if (output / 'measurement.json').exists() else {}
|
||||
report['success'] = report['process']['exitCode'] == 0 and measurement.get('success') is True
|
||||
report['independentFixtureMarkersVerified'] = True
|
||||
report['fixtureSha256'] = sha(heavy)
|
||||
report['measurementSha256'] = sha(output / 'measurement.json') if measurement else None
|
||||
except Exception as error:
|
||||
report['error'] = str(error)
|
||||
finally:
|
||||
report['binaries'] = before
|
||||
report['sourceSha256'] = sources
|
||||
report['binariesUnchanged'] = all(sha(ROOT / name) == value for name, value in before.items())
|
||||
report['sourcesUnchanged'] = all(sha(ROOT / name) == value for name, value in sources.items())
|
||||
report['success'] = report['success'] and report['binariesUnchanged'] and report['sourcesUnchanged']
|
||||
(output / 'report.json').write_text(json.dumps(report, indent=2) + '\n')
|
||||
print(json.dumps({'success': report['success'], 'output': str(output), 'error': report.get('error')}))
|
||||
return 0 if report['success'] else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,199 @@
|
||||
#include "app/controller.h"
|
||||
#include "app/pdf_canvas.h"
|
||||
#include "web/web_profile.h"
|
||||
#include <QCryptographicHash>
|
||||
#include <QElapsedTimer>
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QPointer>
|
||||
#include <QQmlApplicationEngine>
|
||||
#include <QQmlContext>
|
||||
#include <QSaveFile>
|
||||
#include <QScreen>
|
||||
#include <QScopeGuard>
|
||||
#include <QTemporaryDir>
|
||||
#include <QtTest>
|
||||
#include <QtWebEngineQuick>
|
||||
using namespace docview;
|
||||
|
||||
static QByteArray digest(const QString &path) {
|
||||
QFile file(path); if (!file.open(QIODevice::ReadOnly)) return {};
|
||||
QCryptographicHash hash(QCryptographicHash::Sha256);
|
||||
return hash.addData(&file) ? hash.result() : QByteArray{};
|
||||
}
|
||||
|
||||
class HeavyPdfTest : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void realHeavyPagesRemainResponsiveAndCancel() {
|
||||
const auto heavy = qEnvironmentVariable("DOCVIEW_HEAVY_PDF");
|
||||
const auto light = qEnvironmentVariable("DOCVIEW_LIGHT_PDF");
|
||||
const auto output = qEnvironmentVariable("DOCVIEW_HEAVY_OUTPUT");
|
||||
QVERIFY(!heavy.isEmpty() && !light.isEmpty() && !output.isEmpty());
|
||||
const auto beforeHeavy = digest(heavy), beforeLight = digest(light);
|
||||
QVERIFY(!beforeHeavy.isEmpty() && !beforeLight.isEmpty());
|
||||
QTemporaryDir isolated; QVERIFY(isolated.isValid());
|
||||
const auto config = isolated.filePath("config.toml");
|
||||
QFile configuration(config); QVERIFY(configuration.open(QIODevice::WriteOnly));
|
||||
configuration.write("schema_version=1\n[privacy]\nremember_position=false\nrecent_documents=0\n[performance]\nprefetch_pages=0\n");
|
||||
configuration.close();
|
||||
Controller reader;
|
||||
reader.initialize(config, false, {config, isolated.filePath("state.json"),
|
||||
isolated.filePath("cache"), isolated.filePath("logs")});
|
||||
QQmlApplicationEngine engine;
|
||||
engine.rootContext()->setContextProperty("reader", &reader);
|
||||
engine.load(QUrl("qrc:/qml/Main.qml"));
|
||||
QVERIFY(!engine.rootObjects().isEmpty());
|
||||
auto *window = qobject_cast<QQuickWindow *>(engine.rootObjects().first());
|
||||
QVERIFY(window); reader.attachWindow(window);
|
||||
window->resize(qMin(1100, window->screen()->availableGeometry().width()),
|
||||
qMin(760, window->screen()->availableGeometry().height()));
|
||||
auto *canvas = window->findChild<PdfCanvas *>(); QVERIFY(canvas);
|
||||
QVERIFY(QTest::qWaitForWindowExposed(window, 10000)); window->requestActivate();
|
||||
QTRY_COMPARE_WITH_TIMEOUT(QGuiApplication::focusWindow(), window, 5000);
|
||||
QElapsedTimer clock; clock.start(); qint64 previous = 0, longestGap = 0; int ticks = 0, peakPressure = 0;
|
||||
QTimer heartbeat;
|
||||
connect(&heartbeat, &QTimer::timeout, this, [&] {
|
||||
const auto now = clock.elapsed(); longestGap = qMax(longestGap, now - previous);
|
||||
peakPressure = qMax(peakPressure, canvas->memoryPressureLevel());
|
||||
previous = now; ++ticks;
|
||||
});
|
||||
heartbeat.start(10);
|
||||
QJsonArray phases; bool success = false;
|
||||
const auto save = qScopeGuard([&] {
|
||||
heartbeat.stop();
|
||||
QJsonObject result{{"success", success}, {"phases", phases},
|
||||
{"elapsedMs", clock.elapsed()}, {"heartbeatTicks", ticks}, {"longestHeartbeatGapMs", longestGap},
|
||||
{"maximumMemoryPressureLevel", peakPressure},
|
||||
{"qtPlatform", QGuiApplication::platformName()}, {"qtVersion", qVersion()},
|
||||
{"quickBackend", qEnvironmentVariable("QT_QUICK_BACKEND")},
|
||||
{"windowWidth", window->width()}, {"windowHeight", window->height()},
|
||||
{"devicePixelRatio", window->devicePixelRatio()},
|
||||
{"heavySha256", QString::fromLatin1(beforeHeavy.toHex())},
|
||||
{"lightSha256", QString::fromLatin1(beforeLight.toHex())},
|
||||
{"heavyUnchanged", digest(heavy) == beforeHeavy}, {"lightUnchanged", digest(light) == beforeLight},
|
||||
{"scope", "Production Controller/QML/Canvas with canonical sandboxed workers; GUI timer gaps and dispatched RPCs, not physical scanout or direct instrumentation inside PDFium"}};
|
||||
QSaveFile file(output + "/measurement.json");
|
||||
if (file.open(QIODevice::WriteOnly)) { file.write(QJsonDocument(result).toJson()); file.commit(); }
|
||||
});
|
||||
const auto ready = [&] { return reader.state() == "Ready" && canvas->viewportReady(); };
|
||||
const auto sessionFor = [&](const QString &path) -> Session * {
|
||||
for (auto *child : reader.children()) {
|
||||
auto *session = dynamic_cast<Session *>(child);
|
||||
if (session && session->source == path) return session;
|
||||
}
|
||||
return nullptr;
|
||||
};
|
||||
const auto sampleMarker = [&](int page) {
|
||||
const QImage capture = window->grabWindow();
|
||||
const QPointF logical = canvas->mapToScene(canvas->pageToCanvas(page, {46, 796}));
|
||||
const QPoint point(qRound(logical.x() * window->devicePixelRatio()),
|
||||
qRound(logical.y() * window->devicePixelRatio()));
|
||||
return capture.rect().contains(point) ? capture.pixelColor(point) : QColor();
|
||||
};
|
||||
const auto centralPixels = [&](int page) {
|
||||
const QImage capture = window->grabWindow();
|
||||
const auto point = [&](QPointF pdf) {
|
||||
return canvas->mapToScene(canvas->pageToCanvas(page, pdf)) * window->devicePixelRatio();
|
||||
};
|
||||
const QRect area = QRectF(point({80, 220}), point({480, 620})).normalized()
|
||||
.toAlignedRect().intersected(capture.rect());
|
||||
int dark = 0, paper = 0;
|
||||
for (int y = area.top(); y <= area.bottom(); y += 2)
|
||||
for (int x = area.left(); x <= area.right(); x += 2) {
|
||||
const auto color = capture.pixelColor(x, y);
|
||||
dark += color.lightness() < 80;
|
||||
paper += color.red() > 220 && color.green() > 220 && color.blue() > 220;
|
||||
}
|
||||
return QPair{dark, paper};
|
||||
};
|
||||
|
||||
// Complete both expensive pages before testing interruption: a rejected
|
||||
// document or empty canvas cannot count as a successful heavy workload.
|
||||
auto started = clock.elapsed(); reader.openPath(heavy);
|
||||
QTRY_VERIFY_WITH_TIMEOUT(ready(), 30000);
|
||||
QCOMPARE(reader.pages().size(), 2); QCOMPARE(canvas->currentPage(), 0);
|
||||
canvas->setPrefetchPages(0); canvas->fitPage();
|
||||
QTRY_VERIFY_WITH_TIMEOUT(canvas->viewportReady(), 30000);
|
||||
const auto blue = sampleMarker(0);
|
||||
QVERIFY2(blue.isValid() && blue.red() < 50 && blue.green() > 50 && blue.green() < 100 && blue.blue() > 130,
|
||||
"High-resolution page did not show its original blue marker");
|
||||
const auto rasterPixels = centralPixels(0);
|
||||
QVERIFY2(rasterPixels.first > 1000 && rasterPixels.second > 1000,
|
||||
"Decoded scan pixels and paper were not both visible in the central image area");
|
||||
QVERIFY(window->grabWindow().save(output + "/raster.png"));
|
||||
phases.append(QJsonObject{{"name", "complete-high-resolution-raster"}, {"elapsedMs", clock.elapsed() - started},
|
||||
{"pageIndex", 0}, {"originalMarkerVisible", true},
|
||||
{"darkRasterSamples", rasterPixels.first}, {"paperSamples", rasterPixels.second}});
|
||||
started = clock.elapsed(); reader.execute("nav.page", {{"page", 2}});
|
||||
QTRY_VERIFY_WITH_TIMEOUT(canvas->currentPage() == 1 && ready(), 30000);
|
||||
const auto magenta = sampleMarker(1);
|
||||
QVERIFY2(magenta.isValid() && magenta.red() > 180 && magenta.green() < 50 && magenta.blue() > 120,
|
||||
"Transparent-vector page did not show its original magenta marker");
|
||||
const auto vectorPixels = centralPixels(1);
|
||||
QVERIFY2(vectorPixels.first > 1000 && vectorPixels.second == 0,
|
||||
"Dense multiplied vector paths were absent from the central page area");
|
||||
QVERIFY(window->grabWindow().save(output + "/vector.png"));
|
||||
phases.append(QJsonObject{{"name", "complete-transparent-vectors"}, {"elapsedMs", clock.elapsed() - started},
|
||||
{"pageIndex", 1}, {"originalMarkerVisible", true},
|
||||
{"darkVectorSamples", vectorPixels.first}});
|
||||
|
||||
// Cancel after the real protected worker has accepted an open RPC.
|
||||
reader.openPath(light); QTRY_VERIFY_WITH_TIMEOUT(ready(), 15000);
|
||||
const auto retainedToken = reader.webToken();
|
||||
reader.openPath(heavy); QCOMPARE(reader.state(), "Opening");
|
||||
QTRY_VERIFY_WITH_TIMEOUT(sessionFor(heavy) && sessionFor(heavy)->worker
|
||||
&& sessionFor(heavy)->worker->activeRequestCount("open") == 1, 10000);
|
||||
QPointer<Session> cancelled = sessionFor(heavy);
|
||||
started = clock.elapsed(); QTest::keyClick(window, Qt::Key_Escape);
|
||||
QCOMPARE(reader.webToken(), retainedToken); QCOMPARE(reader.state(), "Ready");
|
||||
QTRY_VERIFY_WITH_TIMEOUT(cancelled.isNull(), 5000);
|
||||
QTRY_VERIFY_WITH_TIMEOUT(canvas->viewportReady(), 15000);
|
||||
phases.append(QJsonObject{{"name", "escape-during-open-rpc"}, {"elapsedMs", clock.elapsed() - started},
|
||||
{"activeOpenBeforeCancel", true}, {"retainedOldDocument", true}, {"oldSessionDestroyed", true}});
|
||||
|
||||
// Zoom invalidates old vector tiles. Replace while at least one new
|
||||
// render RPC is outstanding; no fake sleep worker is substituted.
|
||||
reader.openPath(heavy); QTRY_VERIFY_WITH_TIMEOUT(ready(), 30000);
|
||||
reader.execute("nav.page", {{"page", 2}});
|
||||
QTRY_VERIFY_WITH_TIMEOUT(canvas->currentPage() == 1 && ready(), 30000);
|
||||
QPointer<Session> replaced = sessionFor(heavy); QVERIFY(replaced);
|
||||
canvas->setZoom(350);
|
||||
QTRY_VERIFY_WITH_TIMEOUT(reader.benchmarkSnapshot().value("activeRenders").toInt() == 1
|
||||
&& !canvas->viewportReady(), 10000);
|
||||
const auto outstanding = canvas->pendingRenderCount();
|
||||
const auto replacedToken = replaced->token;
|
||||
started = clock.elapsed(); reader.openPath(light);
|
||||
QTRY_VERIFY_WITH_TIMEOUT(reader.webToken() != replacedToken && ready(), 15000);
|
||||
QTRY_VERIFY_WITH_TIMEOUT(replaced.isNull(), 5000);
|
||||
const auto lightToken = reader.webToken();
|
||||
QTest::qWait(300); // Allow any already-delivered obsolete Qt callbacks to run.
|
||||
QCOMPARE(reader.webToken(), lightToken); QCOMPARE(canvas->currentPage(), 0);
|
||||
QCOMPARE(reader.title(), QFileInfo(light).fileName()); QVERIFY(ready());
|
||||
QVERIFY(window->grabWindow().save(output + "/replacement.png"));
|
||||
phases.append(QJsonObject{{"name", "replace-during-vector-render"}, {"elapsedMs", clock.elapsed() - started},
|
||||
{"activeRenderBeforeReplace", true}, {"pendingRendersBeforeReplace", qint64(outstanding)},
|
||||
{"oldSessionDestroyed", true}, {"replacementStableAfter300ms", true}});
|
||||
reader.closeDocument(); QCOMPARE(reader.state(), "Empty"); QCOMPARE(canvas->cacheCostBytes(), 0);
|
||||
QVERIFY2(ticks > 0 && longestGap < 1500, qPrintable(QString("GUI heartbeat gap: %1 ms").arg(longestGap)));
|
||||
QCOMPARE(peakPressure, 0);
|
||||
QCOMPARE(digest(heavy), beforeHeavy); QCOMPARE(digest(light), beforeLight);
|
||||
success = true;
|
||||
}
|
||||
};
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
QTemporaryDir isolated; if (!isolated.isValid()) return 2;
|
||||
qputenv("XDG_CONFIG_HOME", isolated.filePath("config").toUtf8());
|
||||
qputenv("XDG_STATE_HOME", isolated.filePath("state").toUtf8());
|
||||
qputenv("XDG_CACHE_HOME", isolated.filePath("cache").toUtf8());
|
||||
registerDocumentScheme(); QtWebEngineQuick::initialize();
|
||||
QGuiApplication app(argc, argv); app.setQuitOnLastWindowClosed(false);
|
||||
app.setApplicationName("DocView heavy PDF validation"); app.setOrganizationName("DocView");
|
||||
qmlRegisterType<PdfCanvas>("DocView", 1, 0, "PdfCanvas");
|
||||
HeavyPdfTest test; return QTest::qExec(&test, argc, argv);
|
||||
}
|
||||
|
||||
#include "test_heavy_pdf.moc"
|
||||
Reference in New Issue
Block a user