#!/usr/bin/env python3 """bfos.ui_gui -- statik testler (stdlib unittest, pencere ACMAZ). Bu testler GUI'yi baslatmaz ve hicbir Tk penceresi olusturmaz; yalnizca tkinter'siz calisan yardimcilari ve backend sozlesmesini dogrular: * bayi no dogrulamasi (^[0-9]{8}$) ve Turkce hata metinleri, * komut onizleme metni (calistirilacak komut onizlemede GORUNUR), * yikici islem siniflandirmasi (katalog + playbook ile capraz kontrol), * headless (SSH) tespiti ve tkinter eksikligi mesaji, * hedef/kapi kurallari: release -> tek cihaz yok, production -> token, * --check kipi, salt-okunur rapor plani, * komut calistirma: canli satir akisi + cikis kodu (gercek subprocess), * tasarim sozlesmesi: tema token'lari web/static/tokens.css ile birebir ayni, durum sozlugu (KURULDU/KAYITLI/HAZIR/HATA/YOK) ve yetki kapisi gorunurlugu. Calistirma: python3 tests/test_bfos_gui_static.py -v """ from __future__ import annotations import contextlib import importlib.util import io import json import os import re import shutil import sys import tempfile import textwrap import unittest from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] UI_GUI_PATH = REPO_ROOT / "bfos" / "ui_gui.py" def _load_ui_gui(): """ui_gui.py'yi paket __init__'ini calistirmadan yukler (izole import).""" if not UI_GUI_PATH.is_file(): raise unittest.SkipTest(f"bulunamadi: {UI_GUI_PATH}") if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) name = "bfos_ui_gui_static_test" spec = importlib.util.spec_from_file_location(name, UI_GUI_PATH) assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) # dataclasses kayitli bir modul bekler; bu kayit zorunludur. sys.modules[name] = module spec.loader.exec_module(module) return module ui = _load_ui_gui() INVENTORY_YAML = textwrap.dedent( """ # gecici test envanteri (gercek cihaz degil) all: children: blueforce: children: lab: hosts: bf-12010101: ansible_host: 10.42.0.11 pilot_1: hosts: bf-12010193: ansible_host: 10.42.1.11 production: hosts: bf-12010999: ansible_host: 10.42.9.11 """ ).strip() + "\n" class StaticCase(unittest.TestCase): """Tum testler icin ortak kurulum: gecici envanter + backend.""" tmpdir: tempfile.TemporaryDirectory @classmethod def setUpClass(cls) -> None: cls.tmpdir = tempfile.TemporaryDirectory(prefix="bfos-gui-test-") cls.inventory = Path(cls.tmpdir.name) / "hosts.yml" cls.inventory.write_text(INVENTORY_YAML, encoding="utf-8") cls.ctx = ui.GuiContext.detect(inventory=str(cls.inventory)) cls.backend, cls.backend_note = ui.load_backend(cls.ctx) @classmethod def tearDownClass(cls) -> None: cls.tmpdir.cleanup() # -- kucuk yardimcilar ------------------------------------------------- def operation(self, operation_id: str): for op in self.backend.list_operations(): if op.id == operation_id: return op self.fail(f"katalogda islem yok: {operation_id}") def available_fleet_operation(self, scope: str = "simple"): """Playbook'u DISKTE olan bir filo islemi (depo es zamanli degisebilir).""" for op in self.backend.list_operations(): if op.is_fleet and op.scope == scope and op.playbook and \ ui.playbook_path(REPO_ROOT, op.playbook).is_file(): return op self.skipTest(f"kullanilabilir {scope} kapsamli filo islemi yok") SAMPLE_VALUES = { "script": "/opt/blueforce/bin/deneme.sh", "packages": "docker-ce=5:27.0.3-1~ubuntu.24.04~noble", "service": "docker", "only": "01-precheck", "grace": "300", "log_lines": "500", } def valid_options(self, operation) -> dict: """Zorunlu secenekler icin gecerli ornek degerlerle sozluk uretir.""" options: dict = {} for spec in operation.options: value = self.SAMPLE_VALUES.get(spec.name, spec.default) if spec.required and not value: self.skipTest(f"{operation.id}: '{spec.name}' icin ornek deger yok") options[spec.name] = value return options def dealer_target(self, dealer: str = "12010193"): return ui.Target("dealer", dealer) def group_target(self, group: str = "pilot_1"): return ui.Target("group", group) class TestDealerValidation(StaticCase): def test_valid_dealer_numbers(self) -> None: for value in ("12010193", "00000000", "99999999"): self.assertTrue(ui.validate_dealer(value), value) self.assertIsNone(ui.dealer_error(value), value) def test_invalid_dealer_numbers(self) -> None: for value in ("", "1201019", "120101933", "1201019a", "1201 0193", " 12010193", "12010193 ", "bf-12010193", "12345678"): self.assertFalse(ui.validate_dealer(value), repr(value)) self.assertIsNotNone(ui.dealer_error(value), repr(value)) def test_error_message_is_turkish_and_actionable(self) -> None: message = ui.dealer_error("1201019") self.assertIn("8 hane", message) self.assertIn("1201019", message) self.assertIn("12010193", message) # ornek deger self.assertIn("rakam", ui.dealer_error("abcdefgh")) def test_dealer_derived_names(self) -> None: self.assertEqual(ui.dealer_to_host("12010193"), "bf-12010193") self.assertEqual(ui.dealer_to_device_id("12010193"), "BF-12010193") self.assertEqual(ui.host_to_dealer("bf-12010193"), "12010193") self.assertIsNone(ui.host_to_dealer("sunucu-1")) def test_dealer_host_matches_repo_conventions(self) -> None: # docs/02: hostname bf-<8 hane>, insan kimligi BF-<8 hane> self.assertEqual(ui.HOST_PATTERN.pattern, r"^bf-[0-9]{8}$") self.assertEqual(ui.DEALER_PATTERN.pattern, r"^[0-9]{8}$") class TestHeadlessAndTkinter(StaticCase): def test_detects_headless_without_display(self) -> None: message = ui.headless_error({}) self.assertIsNotNone(message) self.assertIn("DISPLAY", message) self.assertIn("SSH", message) # Grafik arayüz açılamadığında önerilen yol aynı uygulamanın terminal # konsoludur; bash menü artık yedek (bkz. TERMINAL_MENU_HINT). self.assertIn("bf --tui", message) self.assertIn("python3 -m bfos.ui_gui", message) def test_display_and_wayland_are_accepted(self) -> None: self.assertIsNone(ui.headless_error({"DISPLAY": ":0"})) self.assertIsNone(ui.headless_error({"WAYLAND_DISPLAY": "wayland-0"})) self.assertIsNone(ui.headless_error({"DISPLAY": ":99", "WAYLAND_DISPLAY": "wayland-9"})) def test_tkinter_missing_message_mentions_package(self) -> None: message = ui.tkinter_missing_message("No module named '_tkinter'") self.assertIn("python3-tk", message) self.assertIn("apt", message) self.assertIn("bf --tui", message) self.assertIn("_tkinter", message) def test_window_start_error_is_turkish(self) -> None: message = ui.window_start_error(RuntimeError("no display name and no $DISPLAY")) self.assertIn("Pencere açılamadı", message) self.assertIn("ssh -X", message) self.assertIn("bf --tui", message) def test_import_does_not_create_a_window(self) -> None: # tkinter varsa bile modul import'u bir Tk koku olusturmamalidir: # BfGui yalnizca acikca verilen bir root ile kurulabilir. self.assertIsInstance(ui.HAVE_TKINTER, bool) import inspect signature = inspect.signature(ui.BfGui.__init__) self.assertIn("root", signature.parameters) # Yardimcilar tkinter olmadan da cagrilabilir. self.assertIsNone(ui.dealer_error("12010193")) def test_main_list_prints_catalogue_without_a_window(self) -> None: buffer = io.StringIO() with contextlib.redirect_stdout(buffer): code = ui.main(["--list", "--inventory", str(self.inventory)]) self.assertEqual(code, 0) output = buffer.getvalue() self.assertIn("işlem kataloğu", output) for category in ui.CATEGORIES: self.assertIn(category, output) def test_main_reports_headless_with_exit_3(self) -> None: env = {k: v for k, v in os.environ.items() if k not in ("DISPLAY", "WAYLAND_DISPLAY")} stderr = io.StringIO() import unittest.mock as mock with mock.patch.dict(os.environ, env, clear=True), \ contextlib.redirect_stderr(stderr): code = ui.main([]) self.assertEqual(code, 3) # Aynı uygulamanın terminal konsoluna yönlendirmeli (bf --tui). self.assertIn("bf --tui", stderr.getvalue()) class TestRootGate(StaticCase): """Tam yetkili GUI: yazma işlemleri root ister, raporlar her zaman açık. Katalogun kendi `destructive` işareti ölçüt alınır (bkz. ui_gui _normalize_operation); bu yüzden testler katalogdan örnek seçer, sabit isimlere bağlanmaz. """ def test_is_root_reflects_the_effective_uid(self) -> None: original = os.geteuid try: os.geteuid = lambda: 0 self.assertTrue(ui.is_root()) self.assertTrue(ui.write_operations_enabled()) os.geteuid = lambda: 1000 self.assertFalse(ui.is_root()) self.assertFalse(ui.write_operations_enabled()) finally: os.geteuid = original def test_destructive_operations_require_root(self) -> None: writes = [op for op in self.backend.list_operations() if op.destructive] self.assertTrue(writes, "katalogda yazma işlemi yok; kapı test edilemez") for op in writes: self.assertTrue(op.needs_root, f"{op.id} yıkıcı ama needs_root değil") def test_read_only_operations_do_not_require_root(self) -> None: reports = [op for op in self.backend.list_operations() if not op.destructive] self.assertTrue(reports, "katalogda salt-okunur işlem yok") frozen = [op.id for op in reports if op.needs_root] self.assertEqual([], frozen, "salt-okunur işlemler root istememeli: " + ", ".join(frozen)) def test_notice_names_the_command_to_get_privileges(self) -> None: self.assertIn("sudo bf", ui.ROOT_REQUIRED_NOTICE) self.assertIn("sudo bf", ui.NON_ROOT_DISABLED_HINT) def test_run_plan_refuses_a_root_plan_without_root(self) -> None: """Son savunma hattı: çalıştırma tek noktadan geçer.""" plan = ui.CommandPlan( commands=(("true",),), destructive=True, requires_root=True, operation_id="install-local", ) original = os.geteuid try: os.geteuid = lambda: 1000 result = ui.run_plan(plan) finally: os.geteuid = original self.assertFalse(result.ok) self.assertEqual(2, result.exit_code) self.assertIn("yönetici yetkisi", result.output.lower()) def test_run_plan_allows_a_root_plan_when_root(self) -> None: plan = ui.CommandPlan( commands=(("true",),), destructive=True, requires_root=True, operation_id="install-local", ) original = os.geteuid try: os.geteuid = lambda: 0 result = ui.run_plan(plan) finally: os.geteuid = original self.assertTrue(result.ok, result.output) class TestSimpleModeLayout(StaticCase): def test_three_big_buttons_in_simple_mode(self) -> None: self.assertEqual(ui.SIMPLE_MODE_BUTTONS, ("Kur", "Durum", "Çıkış")) self.assertEqual(ui.SIMPLE_MODE_LABEL, "Detaylı mod") def test_window_title_is_turkish(self) -> None: self.assertEqual(ui.WINDOW_TITLE, "Blueforce Field OS — Yönetim") def test_five_category_tabs(self) -> None: self.assertEqual( ui.CATEGORIES, ("Kurulum", "Cihaz işlemleri", "Merkez hazırlığı", "Araçlar", "Cihaz bilgisi"), ) class TestCatalogue(StaticCase): def test_operations_have_unique_ids_and_known_categories(self) -> None: operations = self.backend.list_operations() self.assertGreaterEqual(len(operations), 21) ids = [op.id for op in operations] self.assertEqual(len(ids), len(set(ids)), "islem id'leri tekil olmali") for op in operations: self.assertIn(op.category, ui.CATEGORIES, op.id) self.assertTrue(op.label.strip(), op.id) def test_every_category_has_at_least_one_operation(self) -> None: for category in ui.CATEGORIES: with self.subTest(category=category): self.assertTrue( [op for op in self.backend.list_operations() if op.category == category], f"{category} kategorisi bos", ) def test_fleet_operation_playbooks_match_declared_scope(self) -> None: """Konsol tablosu playbook'la uyusmali; playbook yoksa CALISTIRMA reddedilir. (bf_playbook_check + fail-closed kuralinin statik karsiligi.) """ checked = 0 missing: list[str] = [] for op in self.backend.list_operations(): if not op.is_fleet: continue book = ui.playbook_path(REPO_ROOT, op.playbook) if not book.is_file(): # Eksik playbook SESSIZ gecilemez: komut uretimi reddedilmeli. missing.append(f"{op.id} ({op.playbook})") with self.subTest(operation=op.id): with self.assertRaises(ui.CommandError): self.backend.build_command(op, self.group_target()) continue with self.subTest(operation=op.id): self.assertEqual( ui.playbook_scope(book), op.scope, f"{op.playbook}: tablo '{op.scope}' derken playbook " f"'{ui.playbook_scope(book)}' beyan ediyor", ) checked += 1 self.assertGreaterEqual(checked, 15) if missing: print(f"\n[UYARI] bu checkout'ta playbook dosyası eksik: {', '.join(missing)}" " -> ilgili işlemler fail-closed olarak reddediliyor") def test_every_ansible_playbook_is_covered_or_known(self) -> None: """Katalog disinda kalan playbook varsa gorunur olmali (sessiz kapsam disi yok).""" catalogue = {op.playbook for op in self.backend.list_operations() if op.is_fleet} on_disk = {p.name for p in (REPO_ROOT / "ansible" / "playbooks").glob("bf-*.yml")} self.assertEqual(on_disk - catalogue, set()) def test_operation_ids_match_admin_bf_menu_catalogue(self) -> None: """id'ler admin/bf-menu tablosuyla ayni olmali (iki giris noktasi ayrisamaz).""" menu = (REPO_ROOT / "admin" / "bf-menu").read_text(encoding="utf-8") for op in self.backend.list_operations(): if op.is_fleet: with self.subTest(operation=op.id): self.assertIn(f'"{op.id}|', menu) class TestDestructiveClassification(StaticCase): YIKICI = ("rdp-restart", "rustdesk-restart", "wireguard-restart", "service-restart", "gui-off", "gui-on", "run-script", "reboot", "deploy-update", "install-local", "install-resume", "install-module", "tools-git-pull") SALT_OKUNUR = ("ping", "uptime", "disk", "docker", "packages", "security-updates", "provisioning-status", "enrollment-status", "fieldos-version", "remote-channels", "offline-ready", "collect-logs", "install-preflight", "install-state", "install-log", "central-targets", "info-summary", "tools-release", "tools-support-bundle") def test_destructive_ids_are_flagged(self) -> None: flagged = set(ui.destructive_operations(self.backend.list_operations())) for operation_id in self.YIKICI: with self.subTest(operation=operation_id): self.assertIn(operation_id, flagged) self.assertTrue(ui.operation_is_destructive(operation_id)) def test_read_only_ids_are_not_flagged(self) -> None: flagged = set(ui.destructive_operations(self.backend.list_operations())) for operation_id in self.SALT_OKUNUR: with self.subTest(operation=operation_id): self.assertNotIn(operation_id, flagged) self.assertFalse(ui.operation_is_destructive(operation_id)) def test_destructive_flag_reaches_the_plan(self) -> None: plan = self.backend.build_command(self.operation("reboot"), self.group_target(), {"grace": "300"}) self.assertTrue(plan.destructive) plan = self.backend.build_command(self.operation("ping"), self.group_target()) self.assertFalse(plan.destructive) def test_unknown_operation_is_not_destructive(self) -> None: self.assertFalse(ui.operation_is_destructive("boyle-bir-islem-yok")) class TestCommandPreview(StaticCase): def test_fleet_preview_shows_the_exact_command(self) -> None: operation = self.available_fleet_operation() target = self.dealer_target() plan = self.backend.build_command(operation, target, self.valid_options(operation)) preview = ui.preview_text(plan, operation=operation, target=target) self.assertTrue(plan.lines, "en az bir komut satiri uretilmeli") for line in plan.lines: self.assertIn(line, preview) self.assertIn("ansible-playbook", preview) self.assertIn("--limit", preview) self.assertIn("bf-12010193", preview) self.assertIn(operation.playbook, preview) self.assertIn("Çalıştırılacak komut", preview) def test_preview_marks_destructive_operations(self) -> None: operation = self.operation("deploy-update") plan = self.backend.build_command( operation, self.group_target(), {"packages": "docker-ce=5:27.0.3-1~ubuntu.24.04~noble"}, ) preview = ui.preview_text(plan, operation=operation) self.assertIn("UYARI", preview) self.assertIn("YIKICI", preview) self.assertIn("update_packages=", preview) def test_preview_lists_target_and_scope_in_turkish(self) -> None: operation = self.operation("provisioning-status") target = self.group_target() plan = self.backend.build_command(operation, target) preview = ui.preview_text(plan, operation=operation, target=target) self.assertIn("dalga", preview.lower()) self.assertIn("dalga kapılı", preview) def test_preview_of_report_only_plan_says_nothing_runs(self) -> None: operation = self.operation("central-targets") plan = self.backend.build_command(operation, None) self.assertTrue(plan.is_report_only) preview = ui.preview_text(plan, operation=operation) self.assertIn("komut çalıştırmaz", preview) self.assertNotIn("Çalıştırılacak komut(lar)", preview) def test_check_mode_is_visible_in_command_and_preview(self) -> None: operation = self.available_fleet_operation() target = self.group_target() plan = self.backend.build_command( operation, target, self.valid_options(operation), check_mode=True ) self.assertTrue(plan.check_mode) self.assertIn("--check", " ".join(plan.lines)) preview = ui.preview_text(plan, operation=operation, target=target) self.assertIn("--check", preview) self.assertIn("değişiklik uygulanmaz", preview) def test_options_change_the_previewed_command(self) -> None: operation = self.operation("disk") options = {spec.name: "75" for spec in operation.options if "disk" in spec.name} if not options: self.skipTest("disk isleminin esik secenegi yok") plan = self.backend.build_command(operation, self.group_target(), options) self.assertIn("disk_warn_pct=75", " ".join(plan.lines)) def test_invalid_option_is_refused_with_turkish_message(self) -> None: operation = self.operation("service-restart") name = "service" if any(s.name == "service" for s in operation.options) else "service_name" with self.assertRaises(ui.CommandError) as ctx: self.backend.build_command(operation, self.group_target(), {name: "kritik-servis"}) self.assertIn("izinli", str(ctx.exception).lower()) def test_missing_required_option_is_refused(self) -> None: operation = self.operation("deploy-update") name = "packages" if any(s.name == "packages" for s in operation.options) \ else "update_packages" with self.assertRaises(ui.CommandError): self.backend.build_command(operation, self.group_target(), {name: ""}) class TestGatesAndTargets(StaticCase): def test_wave_operation_requires_wave_token(self) -> None: # A wave-scoped READ-ONLY report asks for no gate token -- the bash # console behaves the same way (see bfos/operations.confirmation_plan: # only release-gated runs and the production wave ask for a written # token). What must hold is that the built command carries the wave # scope and the confirmation flag, so the playbook's own gate accepts it. steps = self.backend.gate_steps(self.operation("provisioning-status"), self.group_target()) tokens = [s.token for s in steps if s.kind == "token"] self.assertEqual([], tokens, "a read-only wave report must not ask for a gate token") plan = self.backend.build_command(self.operation("provisioning-status"), self.group_target(), {}, {}) argv = list(plan.commands[0]) self.assertIn("operation_wave=pilot_1", argv) self.assertIn("operation_confirmed=true", argv) def test_release_gated_operation_requires_wave_token(self) -> None: # The gate that must never disappear: reboot/deploy-update are # release-gated, so the wave name has to be typed before anything runs. steps = self.backend.gate_steps(self.operation("reboot"), self.group_target()) tokens = [s.token for s in steps if s.kind == "token"] self.assertIn("pilot_1", tokens) def test_destructive_operation_requires_second_approval(self) -> None: steps = self.backend.gate_steps(self.operation("gui-off"), self.group_target()) self.assertTrue([s for s in steps if s.kind == "approval"], steps) def test_production_wave_requires_production_token(self) -> None: steps = self.backend.gate_steps(self.operation("gui-off"), self.group_target("production")) tokens = [s.token for s in steps if s.kind == "token"] self.assertIn("production", tokens) unlocks = {name for step in steps for name in step.unlocks} self.assertIn("production_override", unlocks) def test_release_scope_refuses_single_device_and_fleet(self) -> None: operation = self.operation("reboot") with self.assertRaises(ui.CommandError) as ctx: self.backend.build_command(operation, self.dealer_target(), {"grace": "300"}) self.assertIn("dalga", str(ctx.exception).lower()) with self.assertRaises(ui.CommandError): self.backend.build_command(operation, ui.Target("all", "all"), {"grace": "300"}) def test_wave_scope_refuses_a_non_wave_group(self) -> None: operation = self.operation("gui-on") with self.assertRaises(ui.CommandError): self.backend.build_command(operation, self.group_target("blueforce")) def test_wave_scope_on_a_dealer_resolves_the_wave(self) -> None: plan = self.backend.build_command(self.operation("gui-off"), self.dealer_target()) line = " ".join(plan.lines) self.assertIn("operation_wave=pilot_1", line) self.assertIn("operation_confirmed=true", line) self.assertIn("bf-12010193", line) def test_unknown_dealer_outside_inventory_is_refused(self) -> None: operation = self.available_fleet_operation() with self.assertRaises(ui.CommandError) as ctx: self.backend.build_command(operation, self.dealer_target("12010000")) self.assertIn("12010000", str(ctx.exception)) def test_invalid_dealer_never_builds_a_command(self) -> None: operation = self.available_fleet_operation() with self.assertRaises(ui.CommandError): self.backend.build_command(operation, self.dealer_target("123")) def test_grants_change_the_gated_command(self) -> None: operation = self.operation("reboot") target = self.group_target("production") options = {"grace": "120"} if not any(s.name == "grace" for s in operation.options): options = {"reboot_grace_seconds": "120"} draft = self.backend.build_command(operation, target, options) final = self.backend.build_command( operation, target, options, grants={"wave_gate_confirmed": True, "production_override": True, "allow_production_reboot": True}, ) joined = " ".join(final.lines) self.assertIn("allow_production_reboot=true", joined) self.assertNotIn("allow_production_reboot=true", " ".join(draft.lines)) self.assertNotEqual(draft.lines, final.lines) def test_no_gate_flag_is_ever_added_silently(self) -> None: """Onaysiz hicbir komut kapi bayragi tasimamali (fail-closed).""" checked = 0 for operation in self.backend.list_operations(): if not operation.is_fleet or operation.scope == "simple": continue target = self.group_target("production" if operation.scope == "release" else "pilot_1") options = self.valid_options(operation) try: plan = self.backend.build_command(operation, target, options) except ui.CommandError: # Playbook eksik/kapi reddi: komut URETILMEDI, bu da guvenli. continue with self.subTest(operation=operation.id): joined = " ".join(plan.lines) for flag in ("wave_gate_confirmed=true", "production_override=true", "allow_production_reboot=true"): self.assertNotIn(flag, joined, f"{operation.id}: onaysiz kapi bayragi uretildi") checked += 1 self.assertGreaterEqual(checked, 1) class TestRunnerStreaming(StaticCase): def test_run_command_streams_lines_and_returns_exit_code(self) -> None: lines: list[str] = [] result = ui.run_command( [sys.executable, "-c", "print('merhaba'); print('dunya')"], on_line=lines.append, ) self.assertEqual(result.exit_code, 0) self.assertTrue(result.ok) self.assertEqual(lines, ["merhaba", "dunya"]) self.assertIn("merhaba", result.output) def test_run_command_reports_nonzero_exit_code(self) -> None: result = ui.run_command([sys.executable, "-c", "import sys; sys.exit(3)"]) self.assertEqual(result.exit_code, 3) self.assertFalse(result.ok) def test_missing_command_gives_turkish_message(self) -> None: result = ui.run_command(["boyle-bir-komut-yok-12345"]) self.assertEqual(result.exit_code, 127) self.assertFalse(result.ok) self.assertIn("bulunamadı", result.output) self.assertIn("bf-bootstrap", result.output) def test_run_plan_stops_at_the_first_failure(self) -> None: plan = ui.CommandPlan( commands=( (sys.executable, "-c", "import sys; print('ilk'); sys.exit(4)"), (sys.executable, "-c", "print('IKINCI-CALISMAMALI')"), ) ) lines: list[str] = [] result = ui.run_plan(plan, on_line=lines.append) self.assertEqual(result.exit_code, 4) self.assertFalse(result.ok) self.assertIn("ilk", lines) self.assertNotIn("IKINCI-CALISMAMALI", lines) def test_run_plan_executes_nothing_for_a_report_plan(self) -> None: plan = ui.CommandPlan(report_lines=("satır 1", "satır 2")) lines: list[str] = [] result = ui.run_plan(plan, on_line=lines.append) self.assertTrue(result.ok) self.assertEqual(lines, ["satır 1", "satır 2"]) self.assertEqual(result.exit_code, 0) def test_dry_run_plan_does_not_execute(self) -> None: plan = ui.CommandPlan( commands=((sys.executable, "-c", "print('CALISMAMALI')"),), dry_run=True, ) lines: list[str] = [] result = ui.run_plan(plan, on_line=lines.append) self.assertTrue(result.ok) # Yalnizca "[deneme] " satiri yazilir; komutun CIKTISI yazilmaz. self.assertTrue(lines) self.assertTrue(all(line.startswith("[deneme] ") for line in lines), lines) self.assertNotIn("CALISMAMALI", result.output) self.assertIn("[deneme]", result.output) class TestInventoryHelpers(StaticCase): def test_parses_groups_and_hosts(self) -> None: groups = ui.parse_inventory(self.inventory) self.assertEqual(groups.get("pilot_1"), ["bf-12010193"]) self.assertEqual(groups.get("lab"), ["bf-12010101"]) self.assertEqual(groups.get("production"), ["bf-12010999"]) def test_wave_list_follows_rollout_order(self) -> None: groups = ui.parse_inventory(self.inventory) self.assertEqual(ui.waves_in_inventory(groups), ("lab", "pilot_1", "production")) self.assertEqual(ui.WAVES, ("lab", "pilot_1", "pilot_2", "wave_1", "wave_2", "production")) def test_device_wave_lookup(self) -> None: groups = ui.parse_inventory(self.inventory) self.assertEqual(ui.device_wave("bf-12010193", groups), "pilot_1") self.assertIsNone(ui.device_wave("bf-99999999", groups)) def test_missing_inventory_does_not_crash(self) -> None: self.assertEqual(ui.parse_inventory(Path("/yok/boyle/bir/dosya.yml")), {}) def test_missing_machine_inventory_is_reported_in_turkish(self) -> None: backend = ui.CatalogueBackend(ui.GuiContext(repo_root=REPO_ROOT, inventory=None)) note = backend.inventory_note() self.assertIn("envanter", note.lower()) self.assertIn("hosts.yml", note) def test_inventory_note_survives_and_warns_without_ansible(self) -> None: """Not uretimi hicbir durumda patlamamali (eksik import regresyonu).""" for backend in ( ui.CatalogueBackend(ui.GuiContext(repo_root=REPO_ROOT, inventory=self.inventory)), self.backend, ): with self.subTest(backend=type(backend).__name__): note = backend.inventory_note() self.assertIn("Envanter", note) if shutil.which("ansible-playbook") is None: self.assertIn("ansible-playbook bulunamadı", note) def test_dealer_wave_gate_requires_inventory(self) -> None: backend = ui.CatalogueBackend(ui.GuiContext(repo_root=REPO_ROOT, inventory=None)) problem = backend.validate_target( ui.operation_by_id("gui-off") or ui.OPERATIONS[0], self.dealer_target() ) self.assertIsNotNone(problem) class TestCatalogueBackendFallback(StaticCase): """Gercek moduller yoksa da ayni kurallar gecerli olmali (ayna backend).""" @classmethod def setUpClass(cls) -> None: super().setUpClass() cls.fallback = ui.CatalogueBackend(cls.ctx) def test_fallback_has_the_same_fleet_operations(self) -> None: fleet = {op.id for op in self.fallback.list_operations() if op.is_fleet} self.assertGreaterEqual(len(fleet), 21) self.assertIn("ping", fleet) self.assertIn("deploy-update", fleet) def test_fallback_builds_the_same_core_arguments(self) -> None: operation = None for candidate in self.fallback.list_operations(): if candidate.is_fleet and candidate.scope == "simple" and candidate.playbook \ and ui.playbook_path(REPO_ROOT, candidate.playbook).is_file(): operation = candidate break if operation is None: self.skipTest("kullanilabilir basit kapsamli filo islemi yok") options = {spec.name: self.SAMPLE_VALUES[spec.name] for spec in operation.options if spec.name in self.SAMPLE_VALUES} plan = self.fallback.build_command(operation, self.dealer_target(), options) joined = " ".join(plan.lines) self.assertIn("ansible-playbook", joined) self.assertIn(f"-i {self.ctx.inventory}", joined) self.assertIn("--limit bf-12010193", joined) def test_fallback_uses_the_same_destructive_table(self) -> None: destructive = set(ui.destructive_operations(self.fallback.list_operations())) self.assertEqual(destructive, set(ui.destructive_operations())) def test_fallback_refuses_missing_inventory(self) -> None: backend = ui.CatalogueBackend(ui.GuiContext(repo_root=REPO_ROOT, inventory=None)) with self.assertRaises(ui.CommandError) as ctx: backend.build_command(ui.operation_by_id("uptime"), self.dealer_target()) self.assertIn("envanter", str(ctx.exception).lower()) def test_fallback_refuses_scope_mismatch_with_the_playbook(self) -> None: """Tablo playbook'la celisirse komut URETILMEZ (bf_playbook_check kurali).""" wrong = ui.Operation( id="uptime", label="Yanlış kapsam", category="Cihaz işlemleri", destructive=False, scope="wave", playbook="bf-uptime.yml", ) with self.assertRaises(ui.CommandError) as ctx: self.fallback.build_command(wrong, self.group_target()) self.assertIn("bf-uptime.yml", str(ctx.exception)) self.assertIn("kapsam", str(ctx.exception).lower()) def test_fallback_installs_only_with_a_valid_dealer(self) -> None: installer = REPO_ROOT / ui.INSTALLER_REL if not installer.is_file(): self.skipTest("kurulum betigi bu checkout'ta yok") plan = self.fallback.build_command( ui.operation_by_id("install-preflight"), self.dealer_target() ) joined = " ".join(plan.lines) self.assertIn("--dealer-id 12010193", joined) self.assertIn("--check", joined) self.assertFalse(plan.destructive) # --------------------------------------------------------------------------- # Tasarim sozlesmesi (docs/DESIGN.md + web/static/tokens.css) # --------------------------------------------------------------------------- def _srgb_channel(value: float) -> float: return value / 12.92 if value <= 0.03928 else ((value + 0.055) / 1.055) ** 2.4 def relative_luminance(colour: str) -> float: """WCAG bagil parlaklik (hex token rengi).""" r, g, b = (int(colour[index:index + 2], 16) / 255 for index in (1, 3, 5)) return 0.2126 * _srgb_channel(r) + 0.7152 * _srgb_channel(g) + 0.0722 * _srgb_channel(b) def contrast_ratio(first: str, second: str) -> float: """Iki token rengi arasindaki WCAG kontrast orani.""" a, b = relative_luminance(first), relative_luminance(second) lighter, darker = max(a, b), min(a, b) return (lighter + 0.05) / (darker + 0.05) class TestThemeMirrorsTokensCss(StaticCase): """bfos/ui_theme.py, web/static/tokens.css'in BIREBIR aynasi olmali. Iki yuzey (cihaz konsolu + merkez panel) ayni sozlugu konusur; bir tarafta degistirilip digerinde unutulan bir token sessiz bir ayrisma olurdu. Bu yuzden aynalama testtir (docs/DESIGN.md, "Rules"). """ CSS_PATH = REPO_ROOT / "web" / "static" / "tokens.css" #: Tk'de karsiligi olmayan token'lar (golge, yazi tipi listesi, satir #: yuksekligi orani, yuvarlaklik): konsol bunlari kullanmaz, ama CSS'te #: kalirlar cunku merkez panel kullanir. NOT_MIRRORED = ( "--font-ui", "--font-data", "--leading-tight", "--leading-normal", "--shadow-sm", "--shadow-md", "--radius", "--radius-lg", ) @classmethod def setUpClass(cls) -> None: super().setUpClass() cls.theme = ui.theme cls.css = cls._parse_tokens(cls.CSS_PATH.read_text(encoding="utf-8")) @staticmethod def _parse_tokens(text: str) -> dict: """`--ad: deger;` ciftlerini toplar, var(--x) referanslarini cozer.""" raw = { match.group(1): match.group(2).strip() for match in re.finditer(r"(--[a-z0-9-]+)\s*:\s*([^;]+);", text) } resolved = {} for name, value in raw.items(): reference = re.fullmatch(r"var\((--[a-z0-9-]+)\)", value) resolved[name] = raw.get(reference.group(1), value) if reference else value return resolved def test_the_mirror_source_exists(self) -> None: self.assertTrue(self.CSS_PATH.is_file(), str(self.CSS_PATH)) self.assertTrue(self.css, "tokens.css icinde token bulunamadi") def test_every_mirrored_value_equals_the_css(self) -> None: for name, value in self.theme.ALL_TOKENS.items(): with self.subTest(token=name): self.assertIn(name, self.css, f"{name} tokens.css'te yok") css_value = self.css[name] if css_value.endswith("px"): css_value = css_value[:-2] self.assertEqual(value, css_value.strip(), f"{name}: ui_theme.py ile tokens.css ayrismis") def test_the_colour_palette_is_fully_mirrored(self) -> None: """CSS'teki her RENK tokeninin Python karsiligi olmali (fazla renk = hata).""" css_colours = {name for name, value in self.css.items() if value.startswith("#")} python_colours = {name for name, value in self.theme.TOKENS.items() if value.startswith("#")} self.assertEqual(css_colours, python_colours) self.assertTrue(css_colours) def test_state_and_severity_pairs_have_readable_contrast(self) -> None: """Durum renkleri gunesli atolyede okunmali; rozet sozcugu de yaninda.""" self.assertGreaterEqual(contrast_ratio(self.theme.TEXT, self.theme.BG_APP), 7.0) self.assertGreaterEqual(contrast_ratio(self.theme.TEXT, self.theme.BG_PANEL), 7.0) self.assertGreaterEqual(contrast_ratio(self.theme.TEXT_MUTED, self.theme.BG_PANEL), 4.5) self.assertGreaterEqual(contrast_ratio(self.theme.TEXT_FAINT, self.theme.BG_PANEL), 3.0) self.assertGreaterEqual(contrast_ratio(self.theme.TEXT_ON_DARK, self.theme.BG_HEADER), 7.0) self.assertGreaterEqual(contrast_ratio(self.theme.TEXT_ON_ACCENT, self.theme.ACCENT), 4.5) # En dusuk cift "yesil zemin + yesil yazi" (4.42:1); esik bunun altina inmez. for key, word in self.theme.STATE_WORDS.items(): with self.subTest(state=word): self.assertGreaterEqual( contrast_ratio(self.theme.STATE_TEXT_COLOUR[key], self.theme.STATE_FILL[key]), 4.4) for severity in self.theme.SEVERITIES: with self.subTest(severity=severity): self.assertGreaterEqual( contrast_ratio(self.theme.SEVERITY_TEXT[severity], self.theme.SEVERITY_FILL[severity]), 4.4) def test_fonts_resolve_to_platform_families_without_downloads(self) -> None: """Webfont yok: aday listesi platformda VAR OLAN bir yuzeye duser.""" self.assertTrue(self.theme.FONT_UI_CANDIDATES) self.assertTrue(self.theme.FONT_DATA_CANDIDATES) self.assertTrue(self.theme.FONT_UI_FALLBACK) self.assertTrue(self.theme.FONT_DATA_FALLBACK) for family in (list(self.theme.FONT_UI_CANDIDATES) + list(self.theme.FONT_DATA_CANDIDATES)): with self.subTest(family=family): self.assertNotIn("/", family) self.assertNotIn(".ttf", family.lower()) self.assertNotIn("http", family.lower()) def test_px_tokens_convert_to_tk_point_sizes(self) -> None: sizes = (self.theme.SIZE_XS, self.theme.SIZE_SM, self.theme.SIZE_MD, self.theme.SIZE_LG, self.theme.SIZE_XL, self.theme.SIZE_2XL) for size in sizes: with self.subTest(size=size): self.assertLess(self.theme.point_size(size), size) self.assertGreaterEqual(self.theme.point_size(size), 7) # Siralama korunur: buyuk token daha buyuk puan uretir (esitlik yok). points = [self.theme.point_size(size) for size in sizes] self.assertEqual(points, sorted(points)) self.assertEqual(len(set(points)), len(points)) def test_the_space_rhythm_is_the_committed_one(self) -> None: self.assertEqual( [self.theme.SP_1, self.theme.SP_2, self.theme.SP_3, self.theme.SP_4, self.theme.SP_5, self.theme.SP_6, self.theme.SP_7], [4, 8, 12, 16, 24, 32, 48], ) self.assertEqual(self.theme.ROW_H, 44) self.assertEqual(self.theme.HEADER_H, 56) class TestStateVocabulary(StaticCase): """Durum sozlugu: TEK sozcuk, renk tek basina anlam tasimaz.""" def test_the_five_words_are_exactly_the_committed_ones(self) -> None: self.assertEqual(ui.DEVICE_STATES, ("KURULDU", "KAYITLI", "HAZIR", "HATA", "YOK")) self.assertEqual(set(ui.DEVICE_STATES), set(ui.theme.STATE_WORDS.values())) def test_every_word_has_its_own_badge_style(self) -> None: styles = [ui.state_badge_style(word) for word in ui.DEVICE_STATES] self.assertEqual(len(set(styles)), len(styles), styles) for style in styles: self.assertTrue(style.endswith(".Badge.TLabel"), style) for key, style in ui.theme.STATE_BADGE_STYLE.items(): with self.subTest(state=key): self.assertEqual(style, ui.state_badge_style(ui.theme.STATE_WORDS[key])) def test_every_word_has_its_own_text_colour(self) -> None: self.assertEqual(len(set(ui.theme.STATE_TEXT_COLOUR.values())), len(ui.DEVICE_STATES)) def test_badge_fills_come_from_the_token_set(self) -> None: """Dolgu renkleri token kumesinden gelir; uydurulmus renk yok.""" allowed = set(ui.theme.TOKENS.values()) | set(ui.theme.SEVERITY_FILL.values()) for key, fill in ui.theme.STATE_FILL.items(): with self.subTest(state=key): self.assertIn(fill, allowed) self.assertIn(ui.theme.STATE_TEXT_COLOUR[key], allowed) def test_provisioned_is_not_the_action_accent(self) -> None: """KURULDU ile secili satir ayni maviye boyanamaz (tokens.css).""" self.assertNotEqual(ui.theme.STATE_TEXT_COLOUR["provisioned"], ui.theme.ACCENT) self.assertNotEqual(ui.theme.STATE_FILL["provisioned"], ui.theme.ACCENT_SOFT) def test_unknown_word_falls_back_to_a_neutral_badge(self) -> None: self.assertEqual(ui.state_badge_style("BOYLE-BIR-DURUM-YOK"), ui.theme.STATE_BADGE_STYLE["absent"]) def test_the_state_machine_maps_to_one_word(self) -> None: cases = ( ({}, "YOK"), ({"phase": "PROVISIONED_OFFLINE"}, "KURULDU"), ({"phase": "PROVISIONED_OFFLINE", "enrollment_status": "ENROLLED"}, "KAYITLI"), ({"fleet_status": "READY"}, "HAZIR"), ({"phase": "FAILED"}, "HATA"), ({"enrollment_status": "NOT_ENROLLED"}, "KURULDU"), ) for values, expected in cases: with self.subTest(values=values): self.assertEqual(ui.state_word_from_values(values), expected) def test_missing_or_broken_state_file_is_the_absent_state(self) -> None: self.assertEqual(ui.device_state_word("/yok/boyle/bir/state.json"), "YOK") self.assertEqual(ui.read_state_values("/yok/boyle/bir/state.json"), {}) with tempfile.TemporaryDirectory() as tmp: broken = Path(tmp) / "state.json" broken.write_text("{bu json degil", encoding="utf-8") self.assertEqual(ui.device_state_word(str(broken)), "YOK") def test_state_json_is_read_through_a_whitelist(self) -> None: """Jeton ve anahtar alanlari ekrana/loga ASLA okunmaz (K-14).""" with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "state.json" path.write_text(json.dumps({ "phase": "READY", "device_id": "BF-12010193", "enrollment_token": "SEKRET-JETON", "private_key": "-----BEGIN PRIVATE KEY-----", }), encoding="utf-8") values = ui.read_state_values(str(path)) self.assertEqual(ui.device_state_word(str(path)), "HAZIR") self.assertIn("device_id", values) self.assertNotIn("enrollment_token", values) self.assertNotIn("private_key", values) self.assertNotIn("SEKRET-JETON", json.dumps(values)) self.assertNotIn("BEGIN PRIVATE KEY", json.dumps(values)) class TestPrivilegeIsVisible(StaticCase): """Yetki bir durumdur: yazma islemleri gizlenmez, KAPATILIR ve nedeni yazar.""" def test_privilege_levels_have_wording_and_a_badge(self) -> None: self.assertEqual(ui.privilege_level(True), ui.PRIVILEGE_FULL) self.assertEqual(ui.privilege_level(False), ui.PRIVILEGE_READ_ONLY) self.assertEqual("SALT-OKUNUR", ui.privilege_word(ui.PRIVILEGE_READ_ONLY)) self.assertEqual("TAM YETKİ", ui.privilege_word(ui.PRIVILEGE_FULL)) self.assertEqual(set(ui.theme.PRIVILEGE_BADGE_STYLE), {ui.PRIVILEGE_FULL, ui.PRIVILEGE_READ_ONLY}) def test_unprivileged_writes_stay_in_the_list_with_a_reason(self) -> None: writes = [op for op in self.backend.list_operations() if op.needs_root] reports = [op for op in self.backend.list_operations() if not op.needs_root] self.assertTrue(writes, "katalogda yazma islemi yok; kapi test edilemez") original = os.geteuid try: os.geteuid = lambda: 1000 for op in writes: with self.subTest(operation=op.id): self.assertEqual(ui.write_blocked_reason(op), ui.NON_ROOT_DISABLED_HINT) self.assertTrue(ui.operation_is_write(op)) for op in reports: with self.subTest(operation=op.id): self.assertIsNone(ui.write_blocked_reason(op)) self.assertFalse(ui.operation_is_write(op)) os.geteuid = lambda: 0 for op in writes: with self.subTest(operation=op.id): self.assertIsNone(ui.write_blocked_reason(op)) finally: os.geteuid = original def test_the_blocked_reason_names_the_command_to_get_privileges(self) -> None: self.assertIn("sudo bf", ui.NON_ROOT_DISABLED_HINT) self.assertIn("yönetici", ui.NON_ROOT_DISABLED_HINT.lower()) class TestConsoleLayoutContract(StaticCase): """Olculen sorunlar geri gelmesin: kaynak duzeyinde baglayici kurallar.""" @classmethod def setUpClass(cls) -> None: super().setUpClass() cls.source = UI_GUI_PATH.read_text(encoding="utf-8") cls.surfaces_source = (REPO_ROOT / "bfos" / "ui_surfaces.py").read_text( encoding="utf-8" ) def test_install_and_portal_are_separate_surfaces(self) -> None: for needle in ( "class InstallerSurface(SurfaceFrame):", "class RemotePortalSurface(SurfaceFrame):", "def launch_surface(", "--install", "--portal", ): with self.subTest(needle=needle): source = self.surfaces_source if needle.startswith("class") or needle.startswith("def") else self.source self.assertIn(needle, source) def test_legacy_full_flag_opens_the_portal_surface(self) -> None: self.assertIn('args.portal or args.full', self.source) def test_surfaces_share_backend_but_not_view_state(self) -> None: self.assertIn("They share only the backend contract", self.surfaces_source) self.assertIn("self.selected_host", self.surfaces_source) self.assertIn("self.dealer_var", self.surfaces_source) self.assertIn("ModePicker", self.surfaces_source) def test_active_dealer_preference_is_stable_and_changeable(self) -> None: from bfos.preferences import clear_dealer, load_preferences, save_dealer path = Path(self.tmpdir.name) / "blueforce-gui.json" save_dealer("12010193", path) self.assertEqual(load_preferences(path)["active_user"], "bf-12010193") save_dealer("12010999", path) self.assertEqual(load_preferences(path)["active_user"], "bf-12010999") clear_dealer(path) self.assertEqual(load_preferences(path), {}) def test_the_view_has_no_hardcoded_colour(self) -> None: """Renk token'dir, literal degil (docs/DESIGN.md kural 1).""" self.assertEqual(re.findall(r"#[0-9a-fA-F]{3,8}\b", self.source), []) widgets_path = REPO_ROOT / "bfos" / "ui_widgets.py" widgets_source = widgets_path.read_text(encoding="utf-8") self.assertEqual( re.findall(r"#[0-9a-fA-F]{3,8}\b", widgets_source), []) def test_window_starts_compact_and_has_small_screen_floor(self) -> None: self.assertIn('root.geometry("1180x720")', self.source) self.assertIn('root.minsize(860, 620)', self.source) self.assertIn('OutputPane(output_area, self.styles, height=5', self.source) def test_overview_and_sidebar_adapt_to_narrow_widths(self) -> None: for needle in ( 'if event.width < 900:', 'columns = 2 if event.width < 1120 else 4', 'self._overview_card_frames', ): with self.subTest(needle=needle): self.assertIn(needle, self.source) def test_there_is_exactly_one_exit_control(self) -> None: """'Cik' tek kontrol: ya basit modda ya alt cubukta, ayni anda degil.""" self.assertEqual(self.source.count('text="Çıkış"'), 1) self.assertIn("Çıkış", ui.SIMPLE_MODE_BUTTONS) for needle in ("self.exit_button.pack(side=\"right\")", "self.exit_button.pack_forget()"): with self.subTest(needle=needle): self.assertIn(needle, self.source) def test_the_header_carries_the_privilege_element(self) -> None: """Yetki gomulu bir cumle degil, basliktaki gorunur bir rozet.""" for needle in ("PRIVILEGE_BADGE_STYLE", "self.privilege_badge", 'text="Yetki"', "ROOT_REQUIRED_NOTICE", 'style=self.styles["warn_band"]'): with self.subTest(needle=needle): self.assertIn(needle, self.source) def test_the_header_carries_the_device_state_and_target(self) -> None: for needle in ("self.state_badge", "self.header_target", "device_state_word()", "state_badge_style("): with self.subTest(needle=needle): self.assertIn(needle, self.source) def test_write_operations_are_disabled_not_hidden(self) -> None: for needle in ('state="disabled"', "BLOCKED_LABEL", "AVAILABLE_LABEL", "write_blocked_reason"): with self.subTest(needle=needle): self.assertIn(needle, self.source) def test_the_status_strip_is_a_state_pill_not_a_plain_label(self) -> None: self.assertIn("theme.severity_style", self.source) self.assertNotIn('text="Hazır."', self.source) def test_the_output_area_is_a_read_only_well(self) -> None: for needle in ('state="disabled"', "BG_SUNKEN", "data_font", 'cursor="arrow"'): with self.subTest(needle=needle): self.assertIn(needle, self.source) def test_the_view_reads_its_values_from_the_theme_module(self) -> None: self.assertIn("ui_theme as theme", self.source) self.assertTrue((REPO_ROOT / "bfos" / "ui_theme.py").is_file()) if __name__ == "__main__": unittest.main(verbosity=2)