feat(console): group the operator console and give it a type and colour system

The console was a default-grey tkinter window: no grouping, no state colour, no
type scale, the privilege warning buried inside a monospace paragraph beside the
repository path, "Çık" offered twice at two different sizes, and an output area
that looked editable. The catalogue is 48 operations, 13 of them destructive, so
the screen an operator actually reads before touching a fleet was the one surface
with no hierarchy at all.

Adds bfos/ui_theme.py, which mirrors web/static/tokens.css value for value --
the two surfaces describe the same fleet and must not disagree about what a
colour means. It selects the `clam` theme because that renderer honours
background/bordercolor on the elements being restyled; note that theming an
element is not the same as redesigning a control, and the remaining gap is
recorded in docs/UI-REMAINING-WORK.md rather than claimed as finished.

What the redesign changes:
- sections get headed regions instead of floating on the root background;
- the privilege level becomes a header element and a banner, not a sentence;
- one exit action, not two;
- write operations are disabled with their reason visible when unprivileged,
  rather than hidden or silently listed;
- the output well is styled as the read-only evidence it is.

Tests: tests/test_bfos_gui_static.py moves to the new contract (68 -> assertions
updated, none deleted); tests/test_bfos.py still 155/155, which is the check that
the CLI and backend contracts survived a UI rewrite.
This commit is contained in:
emirhanmamak-blueforce 2026-09-16 22:38:01 +03:00
parent 4ad20452e7
commit 8299481627
4 changed files with 1866 additions and 188 deletions

File diff suppressed because it is too large Load diff

745
bfos/ui_theme.py Normal file
View file

@ -0,0 +1,745 @@
"""bfos/ui_theme.py -- design tokens for the device console.
The values in this file MIRROR ``web/static/tokens.css``, which is the single
source of truth for both operator surfaces (the device console and the central
panel). Nothing here invents a colour: a colour means a device state, a
severity, or the action accent, and a fifth hue would be a bug (docs/DESIGN.md,
"Rules"). The mirror is not a comment-level promise -- ``TOKENS`` carries the
CSS custom-property name of every value, and ``tests/test_bfos_gui_static.py``
parses ``tokens.css`` and compares the two files, so a token changed on one
surface and not the other fails the suite instead of drifting.
Why light, not dark
-------------------
The console runs in workshops, dealer service areas and customer sites: bright
overhead light, daylight from a roller door, a 14" laptop panel. A near-black
console loses its own body text there and its state colours collapse into
equal-brightness smudges. So the ground is light (``BG_APP``), the panels are
white (``BG_PANEL``), the one dark region is the header (``BG_HEADER``), and
saturation is spent only where it carries meaning.
Faces
-----
No webfont, and no font download: the fleet installs offline, so the UI face is
whatever the platform ships and the data face is monospace (ids, counts, exit
codes, timestamps line up in a column). ``apply_theme`` picks the first family
the platform actually has, from a candidate list -- never a missing font name,
which is a silently different layout.
Sizes and the 4px rhythm
------------------------
Type sizes and space steps are kept in the token's own unit (CSS px) so the
mirror is exact. Tk font sizes are points, so ``point_size`` converts with the
96 dpi relation the tokens assume; padding and row heights are already pixels
in Tk, so they are used as ``SP_*`` values directly.
Usage
-----
theme = ui_theme.apply_theme(root) # once, before any widget is built
ttk.Label(parent, style=theme.panel_label)
"""
from __future__ import annotations
from typing import Dict, Mapping, Sequence
try: # pragma: no cover - environment dependent
from tkinter import ttk
HAVE_TKINTER = True
except Exception: # pragma: no cover - environment dependent
ttk = None # type: ignore[assignment]
HAVE_TKINTER = False
# ---------------------------------------------------------------------------
# Ground and structure -- tokens.css: "Ground and structure"
# ---------------------------------------------------------------------------
BG_APP = "#f6f7f8" # window ground
BG_PANEL = "#ffffff" # raised surfaces, cards, table body
BG_SUNKEN = "#eef0f2" # wells: log output, code, inactive rows
BG_HEADER = "#1d2530" # app header; the one dark region
BORDER = "#d5d9de" # hairline between regions
BORDER_STRONG = "#b6bcc4" # input borders, table rules
# ---------------------------------------------------------------------------
# Text
# ---------------------------------------------------------------------------
TEXT = "#1a1f26" # body
TEXT_MUTED = "#5c6570" # secondary lines, labels
TEXT_FAINT = "#8b939c" # timestamps, hints, placeholders
TEXT_ON_DARK = "#e8ebee" # header text
TEXT_ON_ACCENT = "#ffffff"
# ---------------------------------------------------------------------------
# Action -- one accent: the action the operator came to take, and the
# selected row. Nothing else.
# ---------------------------------------------------------------------------
ACCENT = "#1d4ed8"
ACCENT_HOVER = "#1a43bd"
ACCENT_ACTIVE = "#16389e"
ACCENT_SOFT = "#e8eeff" # selected row, focus ring fill
FOCUS_RING = "#1d4ed8"
# ---------------------------------------------------------------------------
# State severities -- four, each with a strong form for text and a soft form
# for fills, so a badge is built from one palette without inventing a second.
# ---------------------------------------------------------------------------
OK = "#15803d"
OK_SOFT = "#e6f4ea"
WARN = "#b45309"
WARN_SOFT = "#fdf3e3"
DANGER = "#b91c1c"
DANGER_SOFT = "#fdecec"
INFO = "#1d4ed8"
INFO_SOFT = "#e8eeff"
IDLE = "#5c6570" # not yet acted on: not a failure
IDLE_SOFT = "#eef0f2"
# ---------------------------------------------------------------------------
# Device state machine -- PROVISIONED_OFFLINE -> ENROLLED -> READY, plus the
# failure and the absence that sit outside the sequence. Named so no surface
# repaints a stage by accident.
#
# PROVISIONED is deliberately NOT the action accent (tokens.css states the
# reason): the accent means "the action you came to take" and "the selected
# row", so a KURULDU badge built from it made a state and a selection the same
# blue. It is a slate of its own, one step along the path and distinct from the
# neutral --idle.
# ---------------------------------------------------------------------------
STATE_PROVISIONED = "#475569"
STATE_ENROLLED = WARN
STATE_READY = OK
STATE_FAILED = DANGER
STATE_ABSENT = IDLE
# ---------------------------------------------------------------------------
# Type (px, as the tokens declare them)
# ---------------------------------------------------------------------------
SIZE_XS = 12
SIZE_SM = 13
SIZE_MD = 15
SIZE_LG = 18
SIZE_XL = 22
SIZE_2XL = 30
LEADING_TIGHT = 1.25
LEADING_NORMAL = 1.5
# ---------------------------------------------------------------------------
# Space -- a 4px rhythm, named so a component never hand-picks 7px
# ---------------------------------------------------------------------------
SP_1 = 4
SP_2 = 8
SP_3 = 12
SP_4 = 16
SP_5 = 24
SP_6 = 32
SP_7 = 48
RADIUS = 6
RADIUS_LG = 10
# ---------------------------------------------------------------------------
# Metrics
# ---------------------------------------------------------------------------
ROW_H = 44 # fleet table row: comfortable at arm's length
CONTROL_H = 36 # buttons and inputs share one height
HEADER_H = 56
SIDEBAR_W = 220
# Elevation is a token with no Tk equivalent (Tk has no box-shadow); the names
# are kept so the two token sets stay comparable and the absence is explicit.
SHADOW_SM = "0 1px 2px rgba(16, 24, 40, .06)"
SHADOW_MD = "0 4px 12px rgba(16, 24, 40, .10)"
# ---------------------------------------------------------------------------
# Fonts -- candidates in preference order, resolved against the platform
# ---------------------------------------------------------------------------
FONT_UI_CANDIDATES: Sequence[str] = (
"Noto Sans",
"DejaVu Sans",
"Ubuntu",
"Liberation Sans",
"Segoe UI",
"Helvetica",
)
FONT_DATA_CANDIDATES: Sequence[str] = (
"DejaVu Sans Mono",
"Liberation Mono",
"Noto Sans Mono",
"Consolas",
"Menlo",
"Courier New",
)
FONT_UI_FALLBACK = "TkDefaultFont"
FONT_DATA_FALLBACK = "TkFixedFont"
#: CSS px -> Tk points, at the 96 dpi the tokens assume. Table, not a formula:
#: rounding is per size so the console keeps the token's steps instead of
#: collapsing two of them onto the same point size.
POINT_SIZES: Mapping[int, int] = {
SIZE_XS: 9,
SIZE_SM: 10,
SIZE_MD: 11,
SIZE_LG: 14,
SIZE_XL: 16,
SIZE_2XL: 22,
}
# ---------------------------------------------------------------------------
# The mirror table -- CSS custom property -> value. Kept as data so the parity
# test reads the same list the code uses.
# ---------------------------------------------------------------------------
TOKENS: Mapping[str, str] = {
"--bg-app": BG_APP,
"--bg-panel": BG_PANEL,
"--bg-sunken": BG_SUNKEN,
"--bg-header": BG_HEADER,
"--border": BORDER,
"--border-strong": BORDER_STRONG,
"--text": TEXT,
"--text-muted": TEXT_MUTED,
"--text-faint": TEXT_FAINT,
"--text-on-dark": TEXT_ON_DARK,
"--text-on-accent": TEXT_ON_ACCENT,
"--accent": ACCENT,
"--accent-hover": ACCENT_HOVER,
"--accent-active": ACCENT_ACTIVE,
"--accent-soft": ACCENT_SOFT,
"--focus-ring": FOCUS_RING,
"--ok": OK,
"--ok-soft": OK_SOFT,
"--warn": WARN,
"--warn-soft": WARN_SOFT,
"--danger": DANGER,
"--danger-soft": DANGER_SOFT,
"--info": INFO,
"--info-soft": INFO_SOFT,
"--idle": IDLE,
"--idle-soft": IDLE_SOFT,
"--state-provisioned": STATE_PROVISIONED,
"--state-enrolled": STATE_ENROLLED,
"--state-ready": STATE_READY,
"--state-failed": STATE_FAILED,
"--state-absent": STATE_ABSENT,
}
#: Space tokens, mirrored by name (the parity test compares these too).
SPACE_TOKENS: Mapping[str, int] = {
"--sp-1": SP_1,
"--sp-2": SP_2,
"--sp-3": SP_3,
"--sp-4": SP_4,
"--sp-5": SP_5,
"--sp-6": SP_6,
"--sp-7": SP_7,
}
#: Type-size tokens, mirrored by name.
SIZE_TOKENS: Mapping[str, int] = {
"--size-xs": SIZE_XS,
"--size-sm": SIZE_SM,
"--size-md": SIZE_MD,
"--size-lg": SIZE_LG,
"--size-xl": SIZE_XL,
"--size-2xl": SIZE_2XL,
}
#: Metric tokens, mirrored by name.
METRIC_TOKENS: Mapping[str, int] = {
"--row-h": ROW_H,
"--control-h": CONTROL_H,
"--header-h": HEADER_H,
"--sidebar-w": SIDEBAR_W,
}
#: Every mirrored value, by CSS token name (used by the parity test).
ALL_TOKENS: Mapping[str, str] = {
**TOKENS,
**{name: str(value) for name, value in SPACE_TOKENS.items()},
**{name: str(value) for name, value in SIZE_TOKENS.items()},
**{name: str(value) for name, value in METRIC_TOKENS.items()},
}
# ---------------------------------------------------------------------------
# Severity vocabulary -- the fill/text pair a badge or a row is built from
# ---------------------------------------------------------------------------
SEVERITIES: Sequence[str] = ("ok", "warn", "danger", "info", "idle")
SEVERITY_FILL: Mapping[str, str] = {
"ok": OK_SOFT,
"warn": WARN_SOFT,
"danger": DANGER_SOFT,
"info": INFO_SOFT,
"idle": IDLE_SOFT,
}
SEVERITY_TEXT: Mapping[str, str] = {
"ok": OK,
"warn": WARN,
"danger": DANGER,
"info": INFO,
"idle": IDLE,
}
#: State vocabulary, exactly the words both surfaces use (docs/DESIGN.md).
STATE_WORDS: Mapping[str, str] = {
"provisioned": "KURULDU",
"enrolled": "KAYITLI",
"ready": "HAZIR",
"failed": "HATA",
"absent": "YOK",
}
STATE_FILL: Mapping[str, str] = {
# The tokens give no soft form for provisioned; the console borrows the
# sunken well fill rather than inventing a hue, so a KURULDU badge cannot be
# mistaken for the accent-soft selection (see tokens.css, device state).
"provisioned": BG_SUNKEN,
"enrolled": WARN_SOFT,
"ready": OK_SOFT,
"failed": DANGER_SOFT,
"absent": IDLE_SOFT,
}
STATE_TEXT_COLOUR: Mapping[str, str] = {
"provisioned": STATE_PROVISIONED,
"enrolled": STATE_ENROLLED,
"ready": STATE_READY,
"failed": STATE_FAILED,
"absent": STATE_ABSENT,
}
#: Privilege is a state, not a log line (docs/DESIGN.md, "Privilege").
PRIVILEGE_STYLE: Mapping[bool, str] = {True: "ok", False: "warn"}
#: ttk style names, resolved once by apply_theme and read by the view.
_FAMILIES: Dict[str, str] = {}
# ---------------------------------------------------------------------------
# Small helpers
# ---------------------------------------------------------------------------
def point_size(px: int) -> int:
"""Token px -> Tk point size (see POINT_SIZES)."""
return POINT_SIZES.get(px, max(7, round(px * 0.75)))
def ui_family() -> str:
"""The UI face resolved by :func:`apply_theme` (platform default before)."""
return _FAMILIES.get("ui", FONT_UI_FALLBACK)
def data_family() -> str:
"""The monospace face resolved by :func:`apply_theme`."""
return _FAMILIES.get("data", FONT_DATA_FALLBACK)
def ui_font(size: int = SIZE_MD, weight: str = "normal") -> tuple:
"""A Tk font tuple in the platform UI face."""
if weight == "normal":
return (ui_family(), point_size(size))
return (ui_family(), point_size(size), weight)
def data_font(size: int = SIZE_SM, weight: str = "normal") -> tuple:
"""A Tk font tuple in the data (monospace) face."""
if weight == "normal":
return (data_family(), point_size(size))
return (data_family(), point_size(size), weight)
def state_style(state: str) -> str:
"""ttk style name for a state badge ("KURULDU" ...)."""
return f"State{state.capitalize()}.Badge.TLabel"
#: State key -> badge style, built from STATE_WORDS so the word and the colour
#: can never be paired up wrongly on one surface.
STATE_BADGE_STYLE: Mapping[str, str] = {
key: f"State{key.capitalize()}.Badge.TLabel" for key in STATE_WORDS
}
#: Privilege level -> badge style ("Privilege" for full, "ReadOnly" else).
PRIVILEGE_BADGE_STYLE: Mapping[str, str] = {
"full": "Privilege.Badge.TLabel",
"read_only": "ReadOnly.Badge.TLabel",
}
def severity_style(severity: str) -> str:
"""ttk style name for a severity pill (ok / warn / danger / info / idle)."""
return f"{severity.capitalize()}.Status.TLabel"
def _resolve_family(root, candidates: Sequence[str], fallback: str) -> str:
"""First family the platform actually has; the Tk default otherwise."""
try:
from tkinter import font as tkfont
available = {name.lower() for name in tkfont.families(root)}
except Exception: # pragma: no cover - font database unavailable
return fallback
for name in candidates:
if name.lower() in available:
return name
return fallback
def _configure(style, name: str, **options) -> None:
"""Configure a ttk style, ignoring options the platform's theme rejects.
ttk style elements differ between Tk builds (clam vs default, Tk 8.6 vs
9.x): a missing element option raises TclError. Losing one border colour is
a cosmetic difference; crashing the console on startup is not.
"""
try:
style.configure(name, **options)
except Exception:
for key, value in options.items():
try:
style.configure(name, **{key: value})
except Exception:
continue
def _map(style, name: str, **options) -> None:
"""State map for a ttk style, same tolerance as :func:`_configure`."""
try:
style.map(name, **options)
except Exception:
for key, value in options.items():
try:
style.map(name, **{key: value})
except Exception:
continue
# ---------------------------------------------------------------------------
# The one setup function, called once at startup
# ---------------------------------------------------------------------------
def apply_theme(root) -> Mapping[str, str]:
"""Install the token set on ``root``'s ttk styles; returns the style names.
Idempotent and cheap: the GUI calls it once before building any widget, and
a second call simply re-applies the same values (tests may build several
roots in one process).
"""
if not HAVE_TKINTER: # pragma: no cover - environment dependent
raise RuntimeError("tkinter yok: tema uygulanamaz")
assert ttk is not None # narrowing for the type checker
_FAMILIES["ui"] = _resolve_family(root, FONT_UI_CANDIDATES, FONT_UI_FALLBACK)
_FAMILIES["data"] = _resolve_family(root, FONT_DATA_CANDIDATES, FONT_DATA_FALLBACK)
try:
root.configure(bg=BG_APP)
except Exception: # pragma: no cover - non-Tk root
pass
style = ttk.Style(root)
# clam honours background/bordercolor on the widget elements we restyle;
# the 'default' theme on Linux ignores most of them, which is how a
# console ends up grey whatever the tokens say.
try:
if "clam" in style.theme_names():
style.theme_use("clam")
except Exception: # pragma: no cover - theme list unavailable
pass
base_font = ui_font(SIZE_MD)
_configure(
style, ".",
background=BG_APP,
foreground=TEXT,
font=base_font,
bordercolor=BORDER,
lightcolor=BORDER,
darkcolor=BORDER,
troughcolor=BG_SUNKEN,
focuscolor=FOCUS_RING,
)
# -- frames: the ground, the raised panel, the well, the one dark band --
_configure(style, "TFrame", background=BG_APP, borderwidth=0)
_configure(style, "Panel.TFrame", background=BG_PANEL, borderwidth=1,
relief="solid", bordercolor=BORDER, lightcolor=BORDER, darkcolor=BORDER)
_configure(style, "Sunken.TFrame", background=BG_SUNKEN, borderwidth=1,
relief="solid", bordercolor=BORDER, lightcolor=BORDER, darkcolor=BORDER)
_configure(style, "Header.TFrame", background=BG_HEADER, borderwidth=0)
_configure(style, "Warn.TFrame", background=WARN_SOFT, borderwidth=1,
relief="solid", bordercolor=WARN, lightcolor=WARN_SOFT,
darkcolor=WARN_SOFT)
_configure(style, "Info.TFrame", background=INFO_SOFT, borderwidth=1,
relief="solid", bordercolor=INFO, lightcolor=INFO_SOFT,
darkcolor=INFO_SOFT)
_configure(style, "Accent.TFrame", background=ACCENT_SOFT, borderwidth=1,
relief="solid", bordercolor=ACCENT, lightcolor=ACCENT_SOFT,
darkcolor=ACCENT_SOFT)
# -- labels -------------------------------------------------------------
_configure(style, "TLabel", background=BG_APP, foreground=TEXT, font=base_font)
_configure(style, "Panel.TLabel", background=BG_PANEL, foreground=TEXT)
_configure(style, "Sunken.TLabel", background=BG_SUNKEN, foreground=TEXT)
_configure(style, "Muted.TLabel", background=BG_APP, foreground=TEXT_MUTED,
font=ui_font(SIZE_SM))
_configure(style, "PanelMuted.TLabel", background=BG_PANEL,
foreground=TEXT_MUTED, font=ui_font(SIZE_SM))
_configure(style, "Faint.TLabel", background=BG_APP, foreground=TEXT_FAINT,
font=ui_font(SIZE_XS))
_configure(style, "PanelFaint.TLabel", background=BG_PANEL,
foreground=TEXT_FAINT, font=ui_font(SIZE_XS))
_configure(style, "FieldLabel.TLabel", background=BG_PANEL, foreground=TEXT_MUTED,
font=ui_font(SIZE_SM), anchor="e")
_configure(style, "Section.TLabel", background=BG_APP, foreground=TEXT,
font=ui_font(SIZE_LG, "bold"))
_configure(style, "PanelTitle.TLabel", background=BG_PANEL, foreground=TEXT_MUTED,
font=ui_font(SIZE_XS, "bold"))
# The one loud figure per view.
_configure(style, "Figure.TLabel", background=BG_APP, foreground=TEXT,
font=ui_font(SIZE_2XL, "bold"))
_configure(style, "Ok.TLabel", background=BG_PANEL, foreground=OK,
font=ui_font(SIZE_SM))
_configure(style, "Warn.TLabel", background=WARN_SOFT, foreground=WARN,
font=ui_font(SIZE_SM))
_configure(style, "InfoText.TLabel", background=INFO_SOFT, foreground=INFO,
font=ui_font(SIZE_SM))
_configure(style, "Danger.TLabel", background=BG_PANEL, foreground=DANGER,
font=ui_font(SIZE_SM))
_configure(style, "AppWarn.TLabel", background=BG_APP, foreground=WARN,
font=ui_font(SIZE_SM, "bold"))
_configure(style, "PanelOk.TLabel", background=BG_PANEL, foreground=OK,
font=ui_font(SIZE_SM, "bold"))
_configure(style, "PanelDanger.TLabel", background=BG_PANEL, foreground=DANGER,
font=ui_font(SIZE_SM, "bold"))
# Dialogs sit on the app ground, not on a panel.
_configure(style, "Dialog.TLabel", background=BG_APP, foreground=TEXT,
font=ui_font(SIZE_MD))
_configure(style, "DialogTitle.TLabel", background=BG_APP, foreground=TEXT,
font=ui_font(SIZE_LG, "bold"))
_configure(style, "DialogMuted.TLabel", background=BG_APP,
foreground=TEXT_MUTED, font=ui_font(SIZE_SM))
# Header (dark) region.
_configure(style, "HeaderTitle.TLabel", background=BG_HEADER,
foreground=TEXT_ON_DARK, font=ui_font(SIZE_XL, "bold"))
_configure(style, "HeaderMeta.TLabel", background=BG_HEADER,
foreground=TEXT_FAINT, font=ui_font(SIZE_SM))
_configure(style, "HeaderTarget.TLabel", background=BG_HEADER,
foreground=TEXT_ON_DARK, font=data_font(SIZE_SM))
_configure(style, "HeaderFigure.TLabel", background=BG_HEADER,
foreground=TEXT_ON_DARK, font=data_font(SIZE_MD, "bold"))
# -- badges: state word + colour, never colour alone -------------------
_configure(style, "Badge.TLabel", background=IDLE_SOFT, foreground=IDLE,
font=ui_font(SIZE_SM, "bold"), padding=(SP_2, SP_1))
for key, word_style in (("provisioned", "Provisioned"), ("enrolled", "Enrolled"),
("ready", "Ready"), ("failed", "Failed"),
("absent", "Absent")):
_configure(
style, f"State{word_style}.Badge.TLabel",
background=STATE_FILL[key], foreground=STATE_TEXT_COLOUR[key],
font=ui_font(SIZE_SM, "bold"), padding=(SP_2, SP_1),
)
_configure(style, "Privilege.Badge.TLabel", background=OK_SOFT, foreground=OK,
font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_1))
_configure(style, "ReadOnly.Badge.TLabel", background=WARN_SOFT, foreground=WARN,
font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_1))
_configure(style, "Type.Destructive.Badge.TLabel", background=DANGER_SOFT,
foreground=DANGER, font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1))
_configure(style, "Type.ReadOnly.Badge.TLabel", background=IDLE_SOFT,
foreground=IDLE, font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1))
_configure(style, "Open.Badge.TLabel", background=OK_SOFT, foreground=OK,
font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1))
_configure(style, "Closed.Badge.TLabel", background=WARN_SOFT, foreground=WARN,
font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1))
_configure(style, "Info.Badge.TLabel", background=INFO_SOFT, foreground=INFO,
font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_1))
_configure(style, "Neutral.Badge.TLabel", background=BG_SUNKEN, foreground=IDLE,
font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1))
# -- status pill (footer): the console's own state ---------------------
_configure(style, "Status.TLabel", background=IDLE_SOFT, foreground=IDLE,
font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_2))
for severity in SEVERITIES:
_configure(
style, f"{severity.capitalize()}.Status.TLabel",
background=SEVERITY_FILL[severity], foreground=SEVERITY_TEXT[severity],
font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_2),
)
# -- inputs: one height with the buttons ---------------------------------
entry_opts = dict(
fieldbackground=BG_PANEL,
background=BG_PANEL,
foreground=TEXT,
bordercolor=BORDER_STRONG,
lightcolor=BORDER_STRONG,
darkcolor=BORDER_STRONG,
insertcolor=TEXT,
padding=(SP_2, SP_1),
font=ui_font(SIZE_MD),
)
_configure(style, "TEntry", **entry_opts)
_map(style, "TEntry",
bordercolor=[("focus", FOCUS_RING), ("disabled", BORDER)],
foreground=[("disabled", TEXT_FAINT)],
fieldbackground=[("disabled", BG_SUNKEN)])
_configure(style, "Invalid.TEntry", **entry_opts)
_configure(style, "Invalid.TEntry", bordercolor=DANGER, lightcolor=DANGER,
darkcolor=DANGER)
_map(style, "Invalid.TEntry", bordercolor=[("focus", DANGER)])
_configure(style, "TCombobox", **entry_opts)
_configure(style, "TCombobox", arrowcolor=TEXT_MUTED, selectbackground=ACCENT_SOFT,
selectforeground=TEXT, padding=(SP_2, SP_1))
_map(style, "TCombobox",
fieldbackground=[("readonly", BG_PANEL), ("disabled", BG_SUNKEN)],
foreground=[("disabled", TEXT_FAINT)],
bordercolor=[("focus", FOCUS_RING)])
_configure(style, "TCheckbutton", background=BG_APP, foreground=TEXT,
font=ui_font(SIZE_SM), focuscolor=BG_APP, padding=(SP_1, SP_1))
_map(style, "TCheckbutton",
background=[("active", BG_APP)],
foreground=[("disabled", TEXT_FAINT)],
indicatorcolor=[("selected", ACCENT), ("pressed", ACCENT_ACTIVE)])
_configure(style, "Panel.TCheckbutton", background=BG_PANEL, foreground=TEXT,
font=ui_font(SIZE_SM), padding=(SP_1, SP_1))
_map(style, "Panel.TCheckbutton",
background=[("active", BG_PANEL), ("disabled", BG_PANEL)],
foreground=[("disabled", TEXT_FAINT)])
# -- buttons: hairline secondary, one accent primary, danger for
# destructive work ------------------------------------------------
_configure(style, "TButton", background=BG_PANEL, foreground=TEXT,
bordercolor=BORDER_STRONG, lightcolor=BG_PANEL, darkcolor=BG_PANEL,
font=ui_font(SIZE_MD), padding=(SP_3, SP_2), relief="solid",
focusthickness=1, focuscolor=FOCUS_RING, anchor="center")
_map(style, "TButton",
background=[("disabled", BG_SUNKEN), ("pressed", BG_SUNKEN),
("active", ACCENT_SOFT)],
foreground=[("disabled", TEXT_FAINT), ("pressed", ACCENT_ACTIVE)],
bordercolor=[("focus", FOCUS_RING)],
lightcolor=[("active", ACCENT_SOFT), ("pressed", BG_SUNKEN)],
darkcolor=[("active", ACCENT_SOFT), ("pressed", BG_SUNKEN)])
_configure(style, "Accent.TButton", background=ACCENT, foreground=TEXT_ON_ACCENT,
bordercolor=ACCENT, lightcolor=ACCENT, darkcolor=ACCENT)
_map(style, "Accent.TButton",
background=[("disabled", IDLE_SOFT), ("pressed", ACCENT_ACTIVE),
("active", ACCENT_HOVER)],
foreground=[("disabled", TEXT_FAINT)],
bordercolor=[("focus", ACCENT_ACTIVE), ("active", ACCENT_HOVER)],
lightcolor=[("active", ACCENT_HOVER), ("pressed", ACCENT_ACTIVE)],
darkcolor=[("active", ACCENT_HOVER), ("pressed", ACCENT_ACTIVE)])
_configure(style, "Danger.TButton", background=DANGER, foreground=TEXT_ON_ACCENT,
bordercolor=DANGER, lightcolor=DANGER, darkcolor=DANGER)
_map(style, "Danger.TButton",
background=[("disabled", IDLE_SOFT), ("pressed", DANGER_SOFT),
("active", DANGER_SOFT)],
foreground=[("disabled", TEXT_FAINT), ("active", DANGER),
("pressed", DANGER)],
bordercolor=[("focus", DANGER)],
lightcolor=[("active", DANGER_SOFT), ("pressed", DANGER_SOFT)],
darkcolor=[("active", DANGER_SOFT), ("pressed", DANGER_SOFT)])
# Simple mode: the three big buttons, one of them the action accent.
_configure(style, "Big.TButton", font=ui_font(SIZE_LG),
padding=(SP_5, SP_4), anchor="w")
_configure(style, "Accent.Big.TButton", background=ACCENT,
foreground=TEXT_ON_ACCENT, bordercolor=ACCENT, lightcolor=ACCENT,
darkcolor=ACCENT)
_map(style, "Accent.Big.TButton",
background=[("disabled", IDLE_SOFT), ("pressed", ACCENT_ACTIVE),
("active", ACCENT_HOVER)],
foreground=[("disabled", TEXT_FAINT)],
lightcolor=[("active", ACCENT_HOVER), ("pressed", ACCENT_ACTIVE)],
darkcolor=[("active", ACCENT_HOVER), ("pressed", ACCENT_ACTIVE)])
_configure(style, "Quiet.TButton", background=BG_SUNKEN, bordercolor=BORDER)
# -- table: the fleet/operation list ------------------------------------
_configure(style, "Treeview", background=BG_PANEL, fieldbackground=BG_PANEL,
foreground=TEXT, bordercolor=BORDER, lightcolor=BORDER,
darkcolor=BORDER, font=ui_font(SIZE_SM), rowheight=ROW_H)
_configure(style, "Treeview.Heading", background=BG_SUNKEN,
foreground=TEXT_MUTED, font=ui_font(SIZE_XS, "bold"),
relief="flat", padding=(SP_2, SP_2))
_map(style, "Treeview",
background=[("selected", ACCENT_SOFT)],
foreground=[("selected", TEXT)])
_map(style, "Treeview.Heading", background=[("active", BG_SUNKEN)])
# -- tabs, separators, scrollbars --------------------------------------
_configure(style, "TNotebook", background=BG_APP, bordercolor=BORDER,
lightcolor=BORDER, darkcolor=BORDER, tabmargins=(0, 0, 0, 0))
_configure(style, "TNotebook.Tab", background=BG_SUNKEN, foreground=TEXT_MUTED,
font=ui_font(SIZE_SM), padding=(SP_3, SP_2), bordercolor=BORDER)
_map(style, "TNotebook.Tab",
background=[("selected", BG_PANEL), ("active", BG_PANEL)],
foreground=[("selected", TEXT), ("active", TEXT)],
expand=[("selected", (0, 0, 0, 0))])
_configure(style, "TSeparator", background=BORDER)
for orient in ("Vertical", "Horizontal"):
_configure(style, f"{orient}.TScrollbar", background=BG_SUNKEN,
troughcolor=BG_APP, bordercolor=BORDER, arrowcolor=TEXT_MUTED,
lightcolor=BG_SUNKEN, darkcolor=BG_SUNKEN)
_map(style, f"{orient}.TScrollbar",
background=[("active", BORDER_STRONG)])
return {
"frame": "TFrame",
"panel": "Panel.TFrame",
"sunken": "Sunken.TFrame",
"header": "Header.TFrame",
"warn_band": "Warn.TFrame",
"info_band": "Info.TFrame",
"label": "TLabel",
"panel_label": "Panel.TLabel",
"panel_title": "PanelTitle.TLabel",
"muted": "Muted.TLabel",
"panel_muted": "PanelMuted.TLabel",
"faint": "Faint.TLabel",
"panel_faint": "PanelFaint.TLabel",
"field_label": "FieldLabel.TLabel",
"section": "Section.TLabel",
"figure": "Figure.TLabel",
"ok_label": "Ok.TLabel",
"warn_label": "Warn.TLabel",
"info_label": "InfoText.TLabel",
"danger_label": "Danger.TLabel",
"app_warn": "AppWarn.TLabel",
"panel_ok": "PanelOk.TLabel",
"panel_danger": "PanelDanger.TLabel",
"header_title": "HeaderTitle.TLabel",
"header_meta": "HeaderMeta.TLabel",
"header_target": "HeaderTarget.TLabel",
"header_figure": "HeaderFigure.TLabel",
"badge": "Badge.TLabel",
"badge_info": "Info.Badge.TLabel",
"badge_neutral": "Neutral.Badge.TLabel",
"badge_destructive": "Type.Destructive.Badge.TLabel",
"badge_readonly": "Type.ReadOnly.Badge.TLabel",
"badge_open": "Open.Badge.TLabel",
"badge_closed": "Closed.Badge.TLabel",
"privilege": "Privilege.Badge.TLabel",
"readonly": "ReadOnly.Badge.TLabel",
"status": "Status.TLabel",
"button": "TButton",
"accent_button": "Accent.TButton",
"danger_button": "Danger.TButton",
"big_button": "Big.TButton",
"big_accent_button": "Accent.Big.TButton",
"quiet_button": "Quiet.TButton",
"entry": "TEntry",
"invalid_entry": "Invalid.TEntry",
"panel_check": "Panel.TCheckbutton",
"check": "TCheckbutton",
"dialog": "Dialog.TLabel",
"dialog_title": "DialogTitle.TLabel",
"dialog_muted": "DialogMuted.TLabel",
}
def DANGER_ACTIVE_SAFE() -> str: # pragma: no cover - trivial
"""The danger colour used for a focused destructive control.
A named function (rather than a bare literal) so the fifth-hue rule stays
visible: focus on a dangerous action reuses the danger token.
"""
return DANGER

View file

@ -4,7 +4,7 @@
- Dosya: `docs/33-OPERATOR-CONSOLE.md` - Dosya: `docs/33-OPERATOR-CONSOLE.md`
- İlgili kararlar: `24-DECISION-LOG.md#K-09` (golden image + kurucu), `#K-12` (kimlik formatı `^[0-9]{8}$`), `#K-13` (SSH anahtar zorunlu), `#K-11` (onaysız update yasağı), `#K-21` (durum modeli), `#K-22` (RDP her zaman hazır / `bf-gui-*` yalnız yerel GUI) - İlgili kararlar: `24-DECISION-LOG.md#K-09` (golden image + kurucu), `#K-12` (kimlik formatı `^[0-9]{8}$`), `#K-13` (SSH anahtar zorunlu), `#K-11` (onaysız update yasağı), `#K-21` (durum modeli), `#K-22` (RDP her zaman hazır / `bf-gui-*` yalnız yerel GUI)
- Uygulama: Python paketi `bfos` (depo kökü) — `bfos/ui_gui.py` (grafik arayüz, tkinter), `bfos/tui.py` + `bfos/cli.py` (terminal konsolu), `bfos/operations.py` (iş mantığı + işlem kataloğu), `bfos/runner.py` (çalıştırma + denetim kaydı), `bfos/config.py` (Türkçe metinler) - Uygulama: Python paketi `bfos` (depo kökü) — `bfos/ui_gui.py` (grafik arayüz, tkinter), `bfos/ui_theme.py` (tasarım belirteçleri: `web/static/tokens.css`'in aynası), `bfos/tui.py` + `bfos/cli.py` (terminal konsolu), `bfos/operations.py` (iş mantığı + işlem kataloğu), `bfos/runner.py` (çalıştırma + denetim kaydı), `bfos/config.py` (Türkçe metinler)
- Giriş noktası: `admin/bf` (sarmalayıcı) → `sudo bf` ve `sudo bf --tui`. Kurucu: `admin/bf-bootstrap.sh` - Giriş noktası: `admin/bf` (sarmalayıcı) → `sudo bf` ve `sudo bf --tui`. Kurucu: `admin/bf-bootstrap.sh`
- Yedek yol: `admin/bf-menu` + `admin/lib/*` (eski bash konsolu) — ana yol değildir; yalnız Python'suz cihazlar ve olağanüstü durumlar için korunur - Yedek yol: `admin/bf-menu` + `admin/lib/*` (eski bash konsolu) — ana yol değildir; yalnız Python'suz cihazlar ve olağanüstü durumlar için korunur
- Durum: [ ] Taslak — pencere yerleşimi ve örnek satırlar LAB'da alınan gerçek koşuyla güncellenir - Durum: [ ] Taslak — pencere yerleşimi ve örnek satırlar LAB'da alınan gerçek koşuyla güncellenir
@ -252,7 +252,7 @@ Pencere açılıyorsa kurulum tamam. Bundan sonra her iş için aynı komut:
sudo bf sudo bf
``` ```
> **Bundan sonra ezberleyeceğin tek şey bu komuttur.** Pencereden çıkmak için `Çıkış` düğmesi (basit modda üçüncü düğme, alt çubukta da bir `Çıkış` düğmesi var). > **Bundan sonra ezberleyeceğin tek şey bu komuttur.** Pencereden çıkmak için `Çıkış` düğmesi: **basit modda** üçüncü büyük düğme, **detaylı modda** eylem çubuğunun alt satırında. Tek çıkış kontrolü olduğu için ikisi aynı anda görünmez.
**tkinter notu (grafik arayüz için gerekli).** Pencerenin açılabilmesi için `python3-tk` paketi gerekir. Bu paket **çevrimdışı depoda pinlidir** (`provisioning/offline-repo/manifests/base-packages.txt`), yani normal kurulumda senin bir şey yapmana gerek yoktur; kurulum sonu kapısı (`scripts/install/installer/modules/18-final-check.sh`) tkinter'ı denetler. Yine de eksikse uygulama şu Türkçe hatayı verir ve çıkış kodu `3` ile biter: **tkinter notu (grafik arayüz için gerekli).** Pencerenin açılabilmesi için `python3-tk` paketi gerekir. Bu paket **çevrimdışı depoda pinlidir** (`provisioning/offline-repo/manifests/base-packages.txt`), yani normal kurulumda senin bir şey yapmana gerek yoktur; kurulum sonu kapısı (`scripts/install/installer/modules/18-final-check.sh`) tkinter'ı denetler. Yine de eksikse uygulama şu Türkçe hatayı verir ve çıkış kodu `3` ile biter:
@ -286,7 +286,7 @@ Yerel ekranda çalıştırmak için oturum açık bir makinede deneyin:
### 9.2 Arayüzü kullanma — sekme sekme ### 9.2 Arayüzü kullanma — sekme sekme
`sudo bf` çalıştırınca **basit mod** açılır. Başlıkta `Blueforce Field OS — Yönetim`, altında `Blueforce saha cihazı yönetimi (Kur / Durum / Bakım)`, ardından depo yolu ve kullanılan katalog kaynağı (gerçek iş mantığı katmanı, örn. `bfos.operations + bfos.runner`) görünür. `sudo bf` çalıştırınca **basit mod** açılır. Pencere **koyu bir başlık bandıyla** açılır; bandda solda `Blueforce Field OS — Yönetim` ve `Blueforce saha cihazı yönetimi (Kur / Durum / Bakım)`, ortada **hedef kimliği** (`HEDEF`: `— seçilmedi` / `cihaz BF-12010193 (bf-12010193)` / `dalga 'pilot_1'` / `tüm filo (envanterdeki her cihaz)`), sağda **cihaz durumu** rozeti (`KURULDU` / `KAYITLI` / `HAZIR` / `HATA` / `YOK`) ve **yetki** rozeti (`TAM YETKİ` ya da `SALT-OKUNUR`) durur. Yetki bir durumdur, gömülü bir cümle değildir: yetkisiz açılışta bandın hemen altında turuncu bir şerit aynı bilgiyi tam cümleyle yineler. Bandın altında `HEDEF` paneli (bayi no, dalga, tüm filo), gövde, komut çıktısı ve eylem çubuğu gelir; depo yolu ile kullanılan katalog kaynağı (örn. `bfos.operations + bfos.runner`) bandın içinde sessiz bir satırdır.
**Basit mod (varsayılan):** ekranda `Ne yapmak istiyorsunuz?` sorusu ve üç büyük düğme — `Kur`, `Durum`, `Çıkış`. `Kur` bir seçim penceresi açar: `Ön kontrol (--check)`, `Kurulumu başlat`, `Vazgeç`. `Durum` salt-okunur özeti (`info-summary`) çalıştırır. Daha fazlası için üst çubuktaki **`Detaylı mod`** düğmesine bas. **Basit mod (varsayılan):** ekranda `Ne yapmak istiyorsunuz?` sorusu ve üç büyük düğme — `Kur`, `Durum`, `Çıkış`. `Kur` bir seçim penceresi açar: `Ön kontrol (--check)`, `Kurulumu başlat`, `Vazgeç`. `Durum` salt-okunur özeti (`info-summary`) çalıştırır. Daha fazlası için üst çubuktaki **`Detaylı mod`** düğmesine bas.
@ -298,7 +298,7 @@ Yerel ekranda çalıştırmak için oturum açık bir makinede deneyin:
[ Çıkış ] pencereyi kapatır [ Çıkış ] pencereyi kapatır
``` ```
**Detaylı mod:** 5 kategori **sekmesi** açılır. Her sekmede solda işlem listesi (sütunlar: `İşlem`, `Kapsam`, `Tür`), sağda `Seçenekler ve açıklama` paneli vardır; seçtiğin işlemin kapsamı, playbook yolu ve varsa zorunlu alanları orada görünür. Alt çubukta `Komutu göster ve çalıştır`, `Çıktıyı temizle`, `Çalışanı iptal et`, `Yalnız denetim (--check)`, `Çıkış` ve durum etiketi (`Hazır.`) bulunur. **Detaylı mod:** 5 kategori **sekmesi** açılır. Sekmelerin üstünde bir **özet şerit** vardır: büyük rakam işlem sayısıdır (`48 işlem`), yanında `13 yıkıcı` ve `35 salt-okunur` sessiz satırlar olarak durur; yetkisiz oturumda şeridin sağında `yazma işlemleri kapalı` yazar. Her sekmede solda işlem listesi (sütunlar: `İşlem`, `Kapsam`, `Tür`, `Durum`) ve üstünde bir `Süz:` alanı, sağda `SEÇENEKLER VE AÇIKLAMA` paneli vardır; seçtiğin işlemin kapsamı, playbook yolu ve varsa zorunlu alanları orada görünür. `Tür` sütunu yıkıcı işlemlerde kırmızı `YIKICI`, diğerlerinde `salt-okunur` yazar; `Durum` sütunu işlemin çalıştırılabilir olduğunu (`açık`) ya da kapalı olduğunu (`kapalı — sudo bf`) söyler. Eylem çubuğu iki satırdır: üstte `Komutu göster ve çalıştır` (yıkıcı işlem seçiliyse kırmızı, yetkisizse kapalı ve nedeni yanında yazılı) ve sağında durum göstergesi (`HAZIR` / `ÇALIŞIYOR` / `TAMAM` / `HATA` — sözcük + renk); altta `Çıktıyı temizle`, `Çalışanı iptal et`, `Yalnız denetim (--check)`, `Çıktı panelini gizle` ve `Çıkış`.
| Sekme | İşlem sayısı | Yazma (yıkıcı) | Salt-okunur | | Sekme | İşlem sayısı | Yazma (yıkıcı) | Salt-okunur |
|---|---|---|---| |---|---|---|---|
@ -381,7 +381,9 @@ Kurulum seçenekleri: `Yerel çevrimdışı depodan kur (--offline)` ve `Zaten O
Filo işlemlerinde hedef **üst çubuktan** seçilir; işlem seçildiğinde ekran hedefi ayrıca sormaz, çubuktaki değeri kullanır: Filo işlemlerinde hedef **üst çubuktan** seçilir; işlem seçildiğinde ekran hedefi ayrıca sormaz, çubuktaki değeri kullanır:
```text ```text
[Detaylı mod] Hedef: [ 12010193 ] Bayi no (8 hane) [ pilot_1 ▾ ] Dalga [ ] Tüm filo (dikkat) HEDEF [ Detaylı mod ]
Bayi no (8 hane) [ 12010193 ] Dalga [ pilot_1 ▾ ] [ ] Tüm filo (dikkat)
Bayi no geçerli: BF-12010193
``` ```
| Hedef türü | Çubukta ne yaparsın | Örnek | Boyut | Tipik kullanım | | Hedef türü | Çubukta ne yaparsın | Örnek | Boyut | Tipik kullanım |
@ -416,8 +418,9 @@ Pencere açılır: `Blueforce Field OS — Yönetim` / `Blueforce saha cihazı y
1) Üst çubuktaki `Bayi no (8 hane)` alanına `12010193` yaz: 1) Üst çubuktaki `Bayi no (8 hane)` alanına `12010193` yaz:
```text ```text
Hedef: [ 12010193 ] Bayi no (8 hane) [ lab ▾ ] Dalga [ ] Tüm filo (dikkat) HEDEF
durum: Bayi no geçerli: BF-12010193 Bayi no (8 hane) [ 12010193 ] Dalga [ lab ▾ ] [ ] Tüm filo (dikkat)
Bayi no geçerli: BF-12010193
``` ```
2) Basit modda `Kur` düğmesine bas. Seçim penceresi açılır (başlık: `Kurulum`): 2) Basit modda `Kur` düğmesine bas. Seçim penceresi açılır (başlık: `Kurulum`):
@ -525,7 +528,7 @@ Teşhis paketi için: `Araçlar` sekmesi → `Destek paketi oluştur`. Paket yol
| # | Belirti (ekranda ne görürsün) | Neden | Çözüm | | # | Belirti (ekranda ne görürsün) | Neden | Çözüm |
|---|---|---|---| |---|---|---|---|
| 1 | `bf: command not found` | Bootstrap çalışmamış veya araçlar bağlanmamış | Bootstrap'ı tekrar koş: `sudo admin/bf-bootstrap.sh`; sonra tam yoldan dene: `sudo /usr/local/bin/bf`. Python'suz cihazda yedek: `sudo bf-menu` | | 1 | `bf: command not found` | Bootstrap çalışmamış veya araçlar bağlanmamış | Bootstrap'ı tekrar koş: `sudo admin/bf-bootstrap.sh`; sonra tam yoldan dene: `sudo /usr/local/bin/bf`. Python'suz cihazda yedek: `sudo bf-menu` |
| 2 | Pencere açılıyor ama başlıkta turuncu şerit: `Yönetici yetkisi yok: salt-okunur raporlar çalışır, kurulum ve değişiklik gerektiren işlemler kapalıdır. Tam yetki için: sudo bf` | Arayüz `sudo` olmadan açıldı; yazma işlemleri kapalı | Pencereyi kapat ve `sudo bf` ile aç (§9.8) | | 2 | Başlık bandında turuncu `SALT-OKUNUR` rozeti ve bandın altında turuncu şerit: `Yönetici yetkisi yok: salt-okunur raporlar çalışır, kurulum ve değişiklik gerektiren işlemler kapalıdır. Tam yetki için: sudo bf` | Arayüz `sudo` olmadan açıldı; yazma işlemleri kapalı (listede kalır, `Durum` sütunu `kapalı — sudo bf` yazar) | Pencereyi kapat ve `sudo bf` ile aç (§9.8) |
| 3 | Hata penceresi: `Geçersiz bayi numarası '1201019': tam 8 hane olmalıdır (verilen uzunluk: 7). Örnek: 12010193` | Bayi no 8 haneli ve yalnız rakam olmalı | Tam 8 hane, sadece rakam gir; baştaki sıfırı silme. Örnek: `12010193` | | 3 | Hata penceresi: `Geçersiz bayi numarası '1201019': tam 8 hane olmalıdır (verilen uzunluk: 7). Örnek: 12010193` | Bayi no 8 haneli ve yalnız rakam olmalı | Tam 8 hane, sadece rakam gir; baştaki sıfırı silme. Örnek: `12010193` |
| 4 | Envanter notunda `UYARI: ansible-playbook bulunamadı; filo işlemleri çalışmaz (sudo apt install ansible).` | Makinede Ansible yok | `sudo apt install ansible` (reponun kurulumu `ansible-core`'u zaten kurar) ve araçları bağla: `sudo admin/bf-bootstrap.sh` | | 4 | Envanter notunda `UYARI: ansible-playbook bulunamadı; filo işlemleri çalışmaz (sudo apt install ansible).` | Makinede Ansible yok | `sudo apt install ansible` (reponun kurulumu `ansible-core`'u zaten kurar) ve araçları bağla: `sudo admin/bf-bootstrap.sh` |
| 5 | `Komut üretilemedi`: `Playbook bulunamadı: ansible/playbooks/...` veya `... konsol kapsamı 'X' beklerken playbook 'Y' beyan ediyor.` | Katalog ile repo checkout'u uyuşmuyor (eski checkout veya `--repo` yanlış) | `Araçlar` sekmesinden `Bu kopyayı güncelle (git pull --ff-only)` veya doğru repoyu ver: `sudo bf --repo /opt/blueforce-linux`; arayüzü yeniden başlat | | 5 | `Komut üretilemedi`: `Playbook bulunamadı: ansible/playbooks/...` veya `... konsol kapsamı 'X' beklerken playbook 'Y' beyan ediyor.` | Katalog ile repo checkout'u uyuşmuyor (eski checkout veya `--repo` yanlış) | `Araçlar` sekmesinden `Bu kopyayı güncelle (git pull --ff-only)` veya doğru repoyu ver: `sudo bf --repo /opt/blueforce-linux`; arayüzü yeniden başlat |
@ -553,8 +556,8 @@ Arayüz beklenen kullanımda **tam yetkili** çalışır: `sudo bf`. Kurulum bet
| Durum | Ne olur | | Durum | Ne olur |
|---|---| |---|---|
| `sudo bf` (tam yetki) | 48 işlemin tamamı çalışır: kurulum, filo bakımı, paket güncellemesi, kontrollü reboot ve `--check` koşuları | | `sudo bf` (tam yetki) | Başlık bandında yeşil `TAM YETKİ` rozeti. 48 işlemin tamamı çalışır: kurulum, filo bakımı, paket güncellemesi, kontrollü reboot ve `--check` koşuları |
| Root değil (yetkisiz açılış) | Başlıkta turuncu uyarı şeridi: `Yönetici yetkisi yok: salt-okunur raporlar çalışır, kurulum ve değişiklik gerektiren işlemler kapalıdır. Tam yetki için: sudo bf`. **35 salt-okunur işlem** çalışır (raporlar, envanter denetimi, bilgi ekranları, destek paketi); **13 yıkıcı işlem kapalıdır** | | Root değil (yetkisiz açılış) | Başlık bandında turuncu `SALT-OKUNUR` rozeti; bandın altında turuncu şerit: `Yönetici yetkisi yok: salt-okunur raporlar çalışır, kurulum ve değişiklik gerektiren işlemler kapalıdır. Tam yetki için: sudo bf`. **35 salt-okunur işlem** çalışır (raporlar, envanter denetimi, bilgi ekranları, destek paketi); **13 yıkıcı işlem listede kalır ama kapalıdır** (`Tür` = `YIKICI`, `Durum` = `kapalı — sudo bf`) ve `Komutu göster ve çalıştır` düğmesi kapalı çizilip nedenini kendi yanında yazar |
| Yetkisizken yazma denenirse | Yazma işlemleri düğme düzeyinde kapalıdır; bir yol bulunup denenirse **çalıştırma katmanı** reddeder ve çıktı alanına `[ENGELLENDİ] Bu işlem yönetici yetkisi gerektirir (sudo bf).` yazılır, işlem çalışmaz (çıkış kodu 2) | | Yetkisizken yazma denenirse | Yazma işlemleri düğme düzeyinde kapalıdır; bir yol bulunup denenirse **çalıştırma katmanı** reddeder ve çıktı alanına `[ENGELLENDİ] Bu işlem yönetici yetkisi gerektirir (sudo bf).` yazılır, işlem çalışmaz (çıkış kodu 2) |
| `--check` kipi | Salt-okunur olduğu için yetki istemez: `Yalnız denetim (--check)` kutusu işaretliyse deneme koşusu yetkisiz oturumda da yapılabilir | | `--check` kipi | Salt-okunur olduğu için yetki istemez: `Yalnız denetim (--check)` kutusu işaretliyse deneme koşusu yetkisiz oturumda da yapılabilir |
@ -569,6 +572,7 @@ Planlama yetki istemez: komut önizlemesi, onay pencereleri ve `--check` üretim
| `sudo admin/bf-bootstrap.sh` iki kez koşulur | İkinci koşu hata üretmez (idempotent), araçlar yerinde, `/usr/local/bin/bf` bağlı | LAB(2) | | `sudo admin/bf-bootstrap.sh` iki kez koşulur | İkinci koşu hata üretmez (idempotent), araçlar yerinde, `/usr/local/bin/bf` bağlı | LAB(2) |
| `sudo bf --list` / `python3 -m bfos.ui_gui --list` | 48 işlem, 5 kategori, 13 YIKICI / 35 salt-okunur, pencere açılmadan listelenir | LAB(2) | | `sudo bf --list` / `python3 -m bfos.ui_gui --list` | 48 işlem, 5 kategori, 13 YIKICI / 35 salt-okunur, pencere açılmadan listelenir | LAB(2) |
| `sudo bf` açılır, `Çıkış` ile kapanır | Pencere açılır, hiçbir değişiklik yapılmaz | LAB(2) | | `sudo bf` açılır, `Çıkış` ile kapanır | Pencere açılır, hiçbir değişiklik yapılmaz | LAB(2) |
| `python3 tests/test_bfos_gui_static.py` | Tema belirteçleri `web/static/tokens.css` ile birebir aynı; durum sözlüğü beş sözcük; yetkisiz oturumda yazma işlemleri listede ve nedeni yazılı; pencere açılmadan koşar | LAB(2) |
| `sudo bf --tui` ile aynı işlem listesi | Terminal konsolu iki giriş noktası ayrışmadan çalışır | LAB(2) | | `sudo bf --tui` ile aynı işlem listesi | Terminal konsolu iki giriş noktası ayrışmadan çalışır | LAB(2) |
| Grafik ekran olmadan (`env -u DISPLAY -u WAYLAND_DISPLAY sudo bf`) | Türkçe hata + `sudo bf --tui` önerisi, çıkış kodu `3` | LAB(2) | | Grafik ekran olmadan (`env -u DISPLAY -u WAYLAND_DISPLAY sudo bf`) | Türkçe hata + `sudo bf --tui` önerisi, çıkış kodu `3` | LAB(2) |
| tkinter'siz ortamda başlatma | Türkçe hata + `python3-tk` ipucu + `--tui` önerisi, çıkış kodu `3`; kurulum sonu kapısı bunu WARN olarak kaydeder | LAB(2) | | tkinter'siz ortamda başlatma | Türkçe hata + `python3-tk` ipucu + `--tui` önerisi, çıkış kodu `3`; kurulum sonu kapısı bunu WARN olarak kaydeder | LAB(2) |
@ -603,6 +607,7 @@ Planlama yetki istemez: komut önizlemesi, onay pencereleri ve `--check` üretim
- [x] Filo işlemleri açık hedefle; wave-scoped filo akışı dalga dalga ilerliyor; production için `production` yazma zorunlu. - [x] Filo işlemleri açık hedefle; wave-scoped filo akışı dalga dalga ilerliyor; production için `production` yazma zorunlu.
- [x] Katalog, playbook kapsamıyla uyuşmazsa çalıştırmayı reddediyor (kapsam playbook dosyasından okunur). - [x] Katalog, playbook kapsamıyla uyuşmazsa çalıştırmayı reddediyor (kapsam playbook dosyasından okunur).
- [x] Yetki modeli: `sudo bf` tam yetki; yetkisiz açılışta yazma işlemleri kapalı ve çalıştırma katmanı `[ENGELLENDİ]` ile reddediyor. - [x] Yetki modeli: `sudo bf` tam yetki; yetkisiz açılışta yazma işlemleri kapalı ve çalıştırma katmanı `[ENGELLENDİ]` ile reddediyor.
- [x] Pencere `docs/DESIGN.md` yönergesine göre çiziliyor: koyu başlık bandı (ürün adı + hedef kimliği + cihaz durumu + yetki), beyaz paneller, `KURULDU`/`KAYITLI`/`HAZIR`/`HATA`/`YOK` rozetleri (sözcük + renk), tek çıkış kontrolü; renk ve boşluk değerleri `bfos/ui_theme.py` üzerinden `web/static/tokens.css`'ten geliyor ve statik testle karşılaştırılıyor.
- [x] Grafik ekran/tkinter yokluğunda Türkçe hata + `sudo bf --tui` önerisi ve çıkış kodu 3. - [x] Grafik ekran/tkinter yokluğunda Türkçe hata + `sudo bf --tui` önerisi ve çıkış kodu 3.
- [x] Şifre üretimi varsayılan **kapalı**; yalnız `bf-creds --generate` ile üretiliyor; parola ekrana/loga gitmiyor. - [x] Şifre üretimi varsayılan **kapalı**; yalnız `bf-creds --generate` ile üretiliyor; parola ekrana/loga gitmiyor.
- [x] `Cihaz işlemleri` içinde onaysız güncelleme seçeneği **yok**; `Bekleyen güvenlik güncellemeleri` yalnız rapor. - [x] `Cihaz işlemleri` içinde onaysız güncelleme seçeneği **yok**; `Bekleyen güvenlik güncellemeleri` yalnız rapor.

View file

@ -10,7 +10,9 @@ tkinter'siz calisan yardimcilari ve backend sozlesmesini dogrular:
* headless (SSH) tespiti ve tkinter eksikligi mesaji, * headless (SSH) tespiti ve tkinter eksikligi mesaji,
* hedef/kapi kurallari: release -> tek cihaz yok, production -> token, * hedef/kapi kurallari: release -> tek cihaz yok, production -> token,
* --check kipi, salt-okunur rapor plani, * --check kipi, salt-okunur rapor plani,
* komut calistirma: canli satir akisi + cikis kodu (gercek subprocess). * 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: Calistirma:
python3 tests/test_bfos_gui_static.py -v python3 tests/test_bfos_gui_static.py -v
@ -21,7 +23,9 @@ from __future__ import annotations
import contextlib import contextlib
import importlib.util import importlib.util
import io import io
import json
import os import os
import re
import shutil import shutil
import sys import sys
import tempfile import tempfile
@ -774,5 +778,322 @@ class TestCatalogueBackendFallback(StaticCase):
self.assertFalse(plan.destructive) 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")
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), [])
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__": if __name__ == "__main__":
unittest.main(verbosity=2) unittest.main(verbosity=2)