diff --git a/phpbb-test-board/README.md b/phpbb-test-board/README.md index 06298f5..b828864 100644 --- a/phpbb-test-board/README.md +++ b/phpbb-test-board/README.md @@ -160,6 +160,8 @@ Saves PNG screenshots of an extension's or style's pages with [Playwright](https `phpbbmodders/seenotes` needs `--seed` with seed-forum's standard fixtures; it then runs seed-forum's `bin/seed-seenotes-fixtures.php` from the same folder, which gives the members reasons, notes and warnings. Reasons show only to staff, so every shot is taken as the admin: a post's mini-profile with the reason, a profile with the reason and the Moderator notes link, a post by a member without a reason with the notes button, the MCP post details notice, MCP → Moderator notes, ACP → Manage users → Moderator notes, and the ACP reasons and settings pages (in full). +`phpbbmodders/translationhelper` needs `--seed` with seed-forum's standard fixtures; it then runs seed-forum's `bin/seed-translationhelper-fixtures.php` (a test Swedish language and the `seedforum/translatable` fixture extension), saves a partial translation through the extension, and runs `bin/translationhelper-upgrade-fixture.php` to replace the fixture with 1.1.0. It saves the extensions page with the "put back" notice, the editor with a string whose English changed, the Older English versions page and the settings, all in full. The fixture files are removed again afterwards. + `phpbbmodders/prunenotifications` needs `--seed` with seed-forum's standard fixtures. It makes three members dormant, with last visits two to five years ago and 1,840 unread notifications between them, gives the admin 12, then saves the ACP page with the counts and settings (in full), the Prune now confirmation, and the message after every batch has run and the table has been optimized. `phpbbmodders/topicclaim` needs `--seed` with seed-forum's standard fixtures, for its `forum_moderator` user. That user claims the busiest topic with a note, and the admin claims another topic in the same forum. Claims show only to moderators, so every shot is taken as the admin: the forum's topic list, the claim notice on the topic page, the claim page (with take over, transfer and release), MCP → Claimed topics and the ACP settings. diff --git a/phpbb-test-board/feature_checks.py b/phpbb-test-board/feature_checks.py index 53d0f2f..736aca9 100755 --- a/phpbb-test-board/feature_checks.py +++ b/phpbb-test-board/feature_checks.py @@ -15,6 +15,8 @@ """ import argparse import hashlib +import html +import io import json import re import secrets @@ -22,6 +24,7 @@ import subprocess import sys import time +import zipfile from pathlib import Path from typing import Callable @@ -1632,7 +1635,136 @@ def follow_refresh(body: str) -> tuple: b.purge_cache() +TH_EXT = "thcheck/demo" +TH_LANG_HEAD = (" str: + """A PHP single-quoted string literal, so nothing in the text is interpolated.""" + return "'" + text.replace("\\", "\\\\").replace("'", "\\'") + "'" + + +def th_fixture_php(version: str, entries: str) -> str: + """PHP that (re)creates the thcheck/demo fixture extension at a version, deleting the old folder first + the way an administrator replaces an extension, and adds a test "sv" language when the board lacks one.""" + composer = json.dumps({"name": TH_EXT, "type": "phpbb-extension", "version": version, + "license": "GPL-2.0-only", "authors": [{"name": "feature check"}], + "require": {"php": ">=7.2"}, + "extra": {"display-name": "Translation Check Fixture", + "soft-require": {"phpbb/phpbb": ">=3.3.0"}}}) + lang_file = TH_LANG_HEAD + entries + "]);\n" + ext_php = "isDir() ? rmdir($f) : unlink($f); } rmdir($dir); }" + "mkdir($dir . '/language/en', 0777, true);" + f"file_put_contents($dir . '/composer.json', {php_str(composer)});" + f"file_put_contents($dir . '/ext.php', {php_str(ext_php)});" + f"file_put_contents($dir . '/language/en/common.php', {php_str(lang_file)});" + "chmod($dir . '/language', 0777);" + "if (!is_dir('language/sv')) { $src = 'language/en/'; mkdir('language/sv', 0777);" + " $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST);" + " foreach ($it as $f) { $t = 'language/sv/' . substr($f->getPathname(), strlen($src)); $f->isDir() ? mkdir($t, 0777, true) : copy($f, $t); }" + " file_put_contents('language/sv/iso.txt', \"Swedish\\nSvenska (test copy)\\nfeature check\\n\"); }" + "$r = $db->sql_query(\"SELECT lang_id FROM \" . LANG_TABLE . \" WHERE lang_iso = 'sv'\");" + "if (!$db->sql_fetchfield('lang_id')) { $db->sql_query('INSERT INTO ' . LANG_TABLE . ' ' . $db->sql_build_array('INSERT'," + " ['lang_iso' => 'sv', 'lang_dir' => 'sv', 'lang_english_name' => 'Swedish', 'lang_local_name' => 'Svenska (test copy)', 'lang_author' => 'feature check'])); }" + "echo 'fixture ok';" + ) + + +def check_translationhelper(b: Board) -> None: + """phpbbmodders/translationhelper: translate a fixture extension in the ACP (tricky text stays text), then + replace the extension's folder with a new version as an upgrade does: the saved translation is put back + from store/, a notice says so, and the string whose English changed shows its old and new English.""" + out = b.php(th_fixture_php("1.0.0", "\t'TH_WELCOME' => 'Welcome, %s!',\n\t'TH_RULES' => 'Read the rules.',\n" + "\t'TH_POSTS' => [1 => '%d post', 2 => '%d posts'],\n")) + ok("fixture ok" in out, "the fixture extension and test language are set up") + b.purge_cache() + admin = requests.Session() + sid = b.login(admin, acp=True) + acp = f"{b.base}/adm/index.php?i=-phpbbmodders-translationhelper-acp-main_module&mode=extensions&lang=sv&sid={sid}" + + overview = admin.get(acp, timeout=30).text + ok("Translation Check Fixture" in overview, "the extensions page lists the fixture extension") + + page_url = f"{acp}&action=edit&ext={TH_EXT}&file=common.php" + page = admin.get(page_url, timeout=30).text + action, fields = form_fields(page, "translationhelper_edit") + keys = {name: html.unescape(value) for name, value in fields.items() if name.startswith("key[")} + ok(len(keys) == 4, f"the editor shows the four untranslated strings ({len(keys)})") + texts = { + '["TH_WELCOME"]': "Välkommen, %s!", + '["TH_RULES"]': "Läs reglerna. It's \\ ", + '["TH_POSTS",1]': "%d inlägg", + '["TH_POSTS",2]': "%d inlägg", + } + data = {name: value for name, value in fields.items() if not name.startswith("key[")} + for name, path_key in keys.items(): + data[name] = path_key + data["text" + name[3:]] = texts.get(path_key, "") + data["save"] = "Save translation" + body = post_back(admin, f"{b.base}/adm", page_url, action, data) + ok("saved and written to the board" in body, "saving writes the translation to the board") + + read = ("$lang = []; include 'ext/thcheck/demo/language/sv/common.php'; echo json_encode($lang);") + written = b.php(read).strip().splitlines() + lang = json.loads(written[-1]) if written else {} + ok(lang.get("TH_RULES") == texts['["TH_RULES"]'] and lang.get("TH_POSTS", {}).get("2") == "%d inlägg", + "the written language file gives back exactly what was typed") + saved = b.php("echo is_file('store/phpbbmodders_translationhelper/translations/thcheck/demo/sv.json') ? 'yes' : 'no';") + ok(saved.strip().endswith("yes"), "a copy of the translation is saved in store/") + + # Older English versions: a release ZIP, then loose language files, in the one upload field + versions_url = f"{acp}&action=versions&ext={TH_EXT}" + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as archive: + archive.writestr("demo-0.9.0/composer.json", json.dumps({"name": TH_EXT, "type": "phpbb-extension"})) + archive.writestr("demo-0.9.0/language/en/common.php", TH_LANG_HEAD + "\t'TH_WELCOME' => 'Hello, %s!',\n]);\n") + for label, files in ( + ("0.9.0", [("english_files[]", ("demo-0.9.0.zip", zip_buffer.getvalue(), "application/zip"))]), + ("0.8.0", [("english_files[]", ("common.php", TH_LANG_HEAD + "\t'TH_WELCOME' => 'Hi, %s!',\n]);\n", "text/plain")), + ("english_files[]", ("info.php", TH_LANG_HEAD + "\t'TH_INFO' => 'Info',\n]);\n", "text/plain"))]), + ): + action, fields = form_fields(admin.get(versions_url, timeout=30).text, "translationhelper_upload") + time.sleep(1.1) + body = admin.post(f"{b.base}/adm/" + action.lstrip("./"), data={**fields, "version": label, "upload": "Upload"}, + files=files, timeout=30).text + added = b.sql("SELECT COUNT(*) FROM phpbb_translationhelper_snapshots s JOIN phpbb_translationhelper_strings t " + "ON t.snapshot_id = s.snapshot_id WHERE s.ext_name = ? AND s.ext_version = ? " + "AND s.snapshot_source = 'upload'", (TH_EXT, label)) + expected = 1 if label == "0.9.0" else 2 + ok("were added" in body and added and int(added[0][0]) == expected, + f"uploading {'a release ZIP' if label == '0.9.0' else 'two language files'} adds version {label}") + + # Upgrade: the folder is replaced by 1.1.0, which changes one English string and has no Swedish files + b.php(th_fixture_php("1.1.0", "\t'TH_WELCOME' => 'Welcome back, %s!',\n\t'TH_RULES' => 'Read the rules.',\n" + "\t'TH_POSTS' => [1 => '%d post', 2 => '%d posts'],\n\t'TH_NEW' => 'New string',\n")) + gone = b.php("echo is_file('ext/thcheck/demo/language/sv/common.php') ? 'there' : 'gone';") + ok(gone.strip().endswith("gone"), "the upgrade removed the Swedish file") + overview = admin.get(acp, timeout=30).text + ok("translation put back for Translation Check Fixture" in overview, "the extensions page says it was put back") + restored = b.php(read).strip().splitlines() + lang = json.loads(restored[-1]) if restored else {} + ok(lang.get("TH_WELCOME") == "Välkommen, %s!" and lang.get("TH_NEW") == "New string", + "the translation is back, with English for the new string") + page = admin.get(page_url, timeout=30).text + ok("English changed" in page and "Welcome, %s!" in page and "Welcome back, %s!" in page, + "the changed string shows its old and new English") + ok("<b>rules</b>" in admin.get(page_url + "&filter=all", timeout=30).text, + "English text is shown escaped, not as HTML") + + # The fixture extension and its saved translation are files, which a database reset does not undo + b.php("foreach (['ext/thcheck', 'store/phpbbmodders_translationhelper/translations/thcheck'] as $dir) {" + "if (!is_dir($dir)) { continue; }" + "$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);" + "foreach ($it as $f) { $f->isDir() ? rmdir($f) : unlink($f); } rmdir($dir); }") + + CHECKS = { + "phpbbmodders/translationhelper": check_translationhelper, "phpbbmodders/prunenotifications": check_prunenotifications, "phpbbmodders/feedlinks": check_feedlinks, "phpbbmodders/topicclaim": check_topicclaim, diff --git a/phpbb-test-board/screenshots.py b/phpbb-test-board/screenshots.py index d9066ba..b0baddc 100755 --- a/phpbb-test-board/screenshots.py +++ b/phpbb-test-board/screenshots.py @@ -1341,6 +1341,64 @@ def public_directory(p) -> None: shoot.shot("autodbbackup-update-directory-error", acp, admin=True, full_page=True, prepare=public_directory) +def shots_translationhelper(board: Board, shoot: Shooter, args: argparse.Namespace) -> None: + """phpbbmodders/translationhelper: the extensions page right after an extension upgrade (with + the "put back" notice), the editor showing a string whose English changed, the older English + versions page, and the settings. + + Needs seed-forum's fixtures: run with --seed and seed-forum's + bin/seed-standard-fixtures.php. seed-forum's bin/seed-translationhelper-fixtures.php, + next to it, adds a test Swedish language and the seedforum/translatable 1.0.0 + fixture extension; a translation is then saved through the extension, and + bin/translationhelper-upgrade-fixture.php replaces the fixture with 1.1.0 the way an + upgrade does. + """ + if not args.seed: + raise RuntimeError("run with --seed and seed-forum's bin/seed-standard-fixtures.php") + seed_bin = Path(args.seed).resolve().parent + try: + _translationhelper_shots(board, shoot, seed_bin) + finally: + # The fixtures are files, which the board's database reset does not undo + for leftover in ("ext/seedforum", "language/sv", "store/phpbbmodders_translationhelper"): + shutil.rmtree(board.root / leftover, ignore_errors=True) + + +def _translationhelper_shots(board: Board, shoot: Shooter, seed_bin: Path) -> None: + """The work of shots_translationhelper(), whose caller removes the fixture files afterwards.""" + for script in ("seed-translationhelper-fixtures.php",): + done = subprocess.run(["php", "-d", "opcache.enable_cli=0", str(seed_bin / script), str(board.root)], + capture_output=True, text=True) + if done.returncode != 0: + raise RuntimeError(f"{script} failed: {(done.stderr or done.stdout).strip()[-300:]}") + board.purge_cache() + texts = { + '["TRANSLATABLE_QUOTE"]': "Det är en citerad text", + '["TRANSLATABLE_HTML"]': "Läs reglerna först.", + '["TRANSLATABLE_POSTS",1]': "%d inlägg", + '["TRANSLATABLE_POSTS",2]': "%d inlägg", + } + saved = board.php("$m = $phpbb_container->get('phpbbmodders.translationhelper.manager');" + "$m->take_snapshots(true);" + f"$p = $m->save_file('seedforum/translatable', 'sv', 'common.php', json_decode({json.dumps(json.dumps(texts))}, true), [], 2);" + "echo $p ? 'problems' : 'saved';", check=True) + if "saved" not in saved: + raise RuntimeError(f"saving the fixture translation failed: {saved.strip()[-300:]}") + upgraded = subprocess.run(["php", str(seed_bin / "translationhelper-upgrade-fixture.php"), str(board.root)], + capture_output=True, text=True) + if upgraded.returncode != 0: + raise RuntimeError(f"upgrade fixture failed: {(upgraded.stderr or upgraded.stdout).strip()[-300:]}") + + acp = "adm/index.php?i=-phpbbmodders-translationhelper-acp-main_module&mode={mode}&sid={{sid}}" + ext = acp.format(mode="extensions") + "&lang=sv" + shoot.shot("translationhelper-extensions", ext, admin=True, full_page=True) + shoot.shot("translationhelper-editor", ext + "&action=edit&ext=seedforum%2Ftranslatable&file=common.php", + admin=True, full_page=True) + shoot.shot("translationhelper-versions", ext + "&action=versions&ext=seedforum%2Ftranslatable", + admin=True, full_page=True) + shoot.shot("translationhelper-settings", acp.format(mode="settings"), admin=True, full_page=True) + + def shots_seenotes(board: Board, shoot: Shooter, args: argparse.Namespace) -> None: """phpbbmodders/seenotes: the reason line next to a post and on a profile, the notes button next to a post by a member without a reason, the MCP post notice, MCP → Moderator notes, and @@ -1434,6 +1492,7 @@ def run_prune(p) -> None: SHOTS = { + "phpbbmodders/translationhelper": shots_translationhelper, "phpbbmodders/prunenotifications": shots_prunenotifications, "phpbbmodders/seenotes": shots_seenotes, "phpbbmodders/adduser": shots_adduser,