from pathlib import Path import json import sqlite3 from html.parser import HTMLParser import tinycss2 ROOT = Path(__file__).resolve().parents[1] class IdCollector(HTMLParser): def __init__(self): super().__init__() self.ids = [] def handle_starttag(self, _tag, attrs): attrs = dict(attrs) if "id" in attrs: self.ids.append(attrs["id"]) def js_bundle_source(): js_dir = ROOT / "src/web/js" names = [line.strip() for line in (js_dir / "manifest.txt").read_text().splitlines() if line.strip() and not line.lstrip().startswith("#")] assert len(names) >= 10 assert len(names) == len(set(names)) return names, "\n".join((js_dir / name).read_text() for name in names) def test_schema_and_response_templates(): conn = sqlite3.connect(":memory:") migrations = sorted(ROOT.joinpath("migrations").glob("V*.sql")) for migration in migrations: conn.executescript(migration.read_text()) assert int(migrations[-1].name[1:4]) == 46 display_columns = {row[1] for row in conn.execute("pragma table_info(display_preferences)")} response_columns = {row[1] for row in conn.execute("pragma table_info(response_templates)")} assert "interface_skin" in display_columns assert "preset_scale_unlocked" in display_columns assert "format" in response_columns conn.execute("insert into response_templates(name,body,format) values (?,?,?)", ("Text", "Hello", "text")) conn.execute("insert into response_templates(name,body,format) values (?,?,?)", ("HTML", "Hello", "html")) assert conn.execute("select count(*) from response_templates").fetchone()[0] == 2 def test_html_contract(): source = ROOT.joinpath("src/web/index.html").read_text() parser = IdCollector() parser.feed(source) duplicates = {value for value in parser.ids if parser.ids.count(value) > 1} assert not duplicates assert "folder-drawer-header" in source assert "#icon-arrow-left" in source assert "pref-interface-skin" in source assert "compose-response-template" in source assert 'data-default-theme="__RUSTBOX_DEFAULT_THEME__"' in source assert 'id="folder-quota"' in source assert 'id="folder-quota-bar"' in source assert 'id="reader-to"' in source and 'aria-expanded="false"' in source for control_id in ["message-details-menu", "message-transfer-menu", "reader-move-action", "reader-copy-action", "compose-mode"]: assert f'id="{control_id}"' in source assert "data-appearance-preset" not in source for value in ["rustbox", "roundcube-classic", "roundcube-larry", "roundcube-elastic", "clean", "gmail", "proton", "zoho", "outlook", "minimal", "graphite", "high-contrast", "fastmail", "apple-mail", "yahoo", "hey", "nord"]: assert f'value="{value}"' in source def test_roundcube_palette_contract(): classic_elastic = (ROOT / "src/web/css/55-roundcube-skins.css").read_text().lower() extra_styles = (ROOT / "src/web/css/56-interface-styles.css").read_text().lower() for css in [classic_elastic, extra_styles]: errors = [item for item in tinycss2.parse_stylesheet(css, skip_comments=False, skip_whitespace=False) if item.type == "error"] assert not errors for token in ["#f6f6f6", "#f2f2f2", "#cc3333", "#929292", "#37beff", "#2f3a3f", "#21292c", "#c5d1d3", "#4d6066", "#374549"]: assert token in classic_elastic for selector in [ 'roundcube-classic"][data-theme="light', 'roundcube-classic"][data-theme="dark', 'roundcube-elastic"][data-theme="light', 'roundcube-elastic"][data-theme="dark', ]: assert selector in classic_elastic for selector in ["roundcube-larry", "clean", "gmail", "proton", "zoho", "outlook", "minimal", "graphite", "high-contrast"]: assert f'data-interface-skin="{selector}"' in extra_styles manifest = (ROOT / "src/web/css/manifest.txt").read_text() assert "55-roundcube-skins.css" in manifest assert "56-interface-styles.css" in manifest def test_modular_js_contract(): assert not (ROOT / "src/web/app.js").exists() names, app = js_bundle_source() assert names[0] == "00-bootstrap.js" assert names[-1] == "95-bindings-boot.js" assert "interfaceStyleProfiles" in app assert '"roundcube-classic"' in app assert '"roundcube-larry"' in app assert '"roundcube-elastic"' in app assert "loadResponseTemplates" in app assert "insertSavedResponse" in app assert "loadQuota();" in app assert "toggleReaderAddressExpansion" in app assert "translateScopedUiText" in app assert "presetScaleUnlocked" in app assert "positionToolbarMenu" in app assert "progressToast" in app assert "appearancePresets" not in app assert "applyAppearancePreset" not in app assert "src/web/js/manifest.txt" in (ROOT / "README.md").read_text() build = (ROOT / "build.rs").read_text() assert 'root.join("src/web/js")' in build assert 'out.join("rustbox-ui.js")' in build assert 'include_bytes!(concat!(env!("OUT_DIR"), "/rustbox-ui.js"))' in (ROOT / "src/ui.rs").read_text() def test_backend_skin_contract_and_version(): db_source = (ROOT / "src/db/display_preferences.rs").read_text() for value in ["rustbox", "roundcube-classic", "roundcube-larry", "roundcube-elastic", "clean", "gmail", "proton", "zoho", "outlook", "minimal", "graphite", "high-contrast", "fastmail", "apple-mail", "yahoo", "hey", "nord"]: assert f'"{value}"' in db_source cargo = (ROOT / "Cargo.toml").read_text() assert 'version = "0.0.3"' in cargo config = (ROOT / "src/config.rs").read_text() assert "pub default_theme: String" in config assert '"system" | "dark" | "light"' in config ui = (ROOT / "src/ui.rs").read_text() assert 'replace("__RUSTBOX_DEFAULT_THEME__", &config.default_theme)' in ui assert "V044__interface_skin.sql" in (ROOT / "src/db/pool.rs").read_text() assert "V045__response_template_format.sql" in (ROOT / "src/db/pool.rs").read_text() assert "V046__preset_scale_unlock.sql" in (ROOT / "src/db/pool.rs").read_text() for path in ROOT.glob("src/web/i18n/*.json"): json.loads(path.read_text()) def test_mobile_003_contract(): responsive = (ROOT / "src/web/css/60-responsive.css").read_text() assert '.sieve-builder .checkbox-row input[type="checkbox"]' in responsive assert ".dialog > .dialog-card" in responsive assert "grid-template-rows: auto minmax(0, 1fr)" in responsive assert ".calendar-toolbar" in responsive assert ".message-meta.addresses-expanded" in responsive pl = json.loads((ROOT / "src/web/i18n/pl.json").read_text()) assert len(pl.get("_text", {})) >= 450 for key in ["Rule enabled", "Stop after this rule", "Calendar", "Message headers", "Message source", "Forward as attachment", "Show headers", "Show source", "Write a message"]: assert key in pl["_text"] for key in ["reply", "reply_all", "forward", "message_details", "move_copy", "move_to", "copy_to"]: assert key in pl if __name__ == "__main__": test_schema_and_response_templates() test_html_contract() test_roundcube_palette_contract() test_modular_js_contract() test_backend_skin_contract_and_version() test_mobile_003_contract() print("roundcube-skin-contract: OK")