diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 958b0be..4ef8286 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ jobs: # Threshold 100: ifutil.py, keelcli.py, keelbanner.py, dbscreen.py, # wgcli.py, wgscreen.py, keelfirstboot.py and the Instance menu # entries, the database mode, overlay and Keel Cloud screens - # included, measure 100 percent (646 tests, lines and branches). The + # included, measure 100 percent (682 tests, lines and branches). The # package list names the modules with tests; a module is appended as # its tests land (COVERAGE.md plan) and the threshold is only ever # raised (decision 0006). Baseline before the first test: 0 percent. diff --git a/COVERAGE.md b/COVERAGE.md index a689729..642999c 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -319,6 +319,26 @@ api_key: skip`, a key file reference) to a real `keel spec validate`. lose the TKLBAM and TurnKey Hub footer and gain one that checks neither is shown and that the screen runs no command. +## Measured on 2026-10-01 with the maintainer's art in tiers + +| File | Tests | Stmts | Branches | Cover | +|------|-------|-------|----------|-------| +| keelbanner.py | 92 in tests/test_keelbanner.py, up from 60: the tiers wide, full, small and none by width and by height on synthetic block character marks, the locale (LC_ALL, LC_CTYPE, LANG, spelled every way glibc accepts, and an empty setting skipped), the two ladders and their paths, `available` (the backtitle and the shadow, measured with dialog on trixie), `text_rows` (dialog's wrap of a real IPv6 usage line), `box_width`, and `read` of a UTF-8 file and of one that is not | 80 | 22 | 100 percent, 0 missed, 0 partial | +| confconsole.py | `TestUsageMark` has 12, up from 8: the box never taller than the room, 80 by 24 dual stack keeping the screen whole, the UTF-8 ladder in a UTF-8 locale, the wide mark widening the box, and the C locale never drawing a UTF-8 mark | | | not measured, see above; `usage` and `Console.msgbox` have 0 missed lines | + +Total 682 tests with a keel 0.11 at hand (677 passed and 5 skipped +without one), 100 percent over the measured package, unchanged. +Threshold in the caller: 100, unchanged. + +The tier is chosen from the room the dialog has, not from the terminal: +dialog centres the box on the whole screen, so the backtitle costs four +rows and the shadow four columns, and the box needs its text, wrapped as +dialog wraps it, and five rows of frame, the blank row +`Console._wrapper` puts above the text, and the button. The 25 row floor +of the first version is gone: on 80 by 24 a 20 row box is what fits, so +the small mark goes above a usage text of up to seven rows, and a dual +stack usage screen keeps no mark. + ## Plan to reach 90 percent per file Priority order (size: small under 30 lines of test, medium under 150, diff --git a/confconsole.py b/confconsole.py index e24964e..3faca78 100755 --- a/confconsole.py +++ b/confconsole.py @@ -276,14 +276,16 @@ def msgbox( button_label: str = "ok", autosize: bool = False, height: int | None = None, + width: int | None = None, ) -> str: - # `height` overrides the default for a box that carries more than - # the usual text, such as the usage screen with the mark above it. + # `height` and `width` override the default for a box that carries + # more than the usual text, such as the usage screen with the mark + # above it. if autosize: text += "\n " height, width = 0, 0 else: - height, width = height or self.height, self.width + height, width = height or self.height, width or self.width v = self._wrapper( "msgbox", text, height, width, title=title, ok_label=button_label @@ -663,28 +665,38 @@ def usage(self) -> str: f"Usage started - hostname: {hostname} ipv6: {ipv6_addr}" f" ip: {ip_addr}" ) - # The mark above the usage text, when the terminal has room for - # it over and above the rows the screen already uses: the box - # grows by what the mark takes, so not one line of what the - # appliance already says is lost, and the mark is dropped to the - # small one and then to nothing before that happens. The mark is - # centred on the columns the box leaves inside its frame, the - # same width keelbanner measures it against; the usage text keeps + # The mark above the usage text, the largest tier (wide, full, + # small) the dialog has room for on this screen over and above + # the rows the text needs, so not one line of what the appliance + # already says is lost: a smaller tier, and then none, is taken + # before that happens. The UTF-8 tiers in a UTF-8 locale, the + # ASCII ones otherwise. The box widens to a mark wider than it + # and grows by the rows the mark takes; the mark is centred on the + # columns the box leaves inside its frame, the usage text keeps # its own left margin. - height = self.height - rows, cols = keelbanner.terminal_size() - cols = min(cols, self.width) - mark = keelbanner.choose(rows, cols, height) + height, width = self.height, self.width + room_rows, room_cols = keelbanner.available( + *keelbanner.terminal_size() + ) + inside = keelbanner.inner_width(min(width, room_cols)) + used = keelbanner.text_rows(text, inside) + keelbanner.BOX_CHROME + mark = keelbanner.choose( + room_rows, room_cols, used, keelbanner.marks(os.environ) + ) if mark is not None: - centred = keelbanner.center(mark, keelbanner.inner_width(cols)) + width = keelbanner.box_width(mark, width, room_cols) + centred = keelbanner.center(mark, keelbanner.inner_width(width)) text = keelbanner.above(text, centred) - height += keelbanner.added_rows(mark) + height = max( + used + keelbanner.added_rows(mark), min(height, room_rows) + ) retcode = self.console.msgbox( f"{hostname} appliance services", text, button_label=default_button_label, height=height, + width=width, ) if retcode is not self.OK: diff --git a/debian/changelog b/debian/changelog index 1f76429..de2de47 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,20 @@ +confconsole (2.2.3+keel9) trixie; urgency=low + + * Usage screen: the maintainer's console art in tiers. keelbanner picks + the largest that the dialog has room for, in the order wide (the mark + with the KEEL LINUX lettering, the tagline and KEELLINUX.ORG), full, + small, none, from the files keel-core installs in /etc/keel. + * The room is measured as dialog uses it: the screen less the backtitle + and the shadow, and the box's own text wrapped at its width plus its + frame and button. The box widens for a mark wider than it and grows + by the mark's rows, never past the room, so the addresses keep every + line. The 24 row floor and the fixed 25 row box of the first version + no longer decide. + * UTF-8 tiers when the locale dialog inherits is UTF-8 (LC_ALL, + LC_CTYPE, LANG), the ASCII ones otherwise. + + -- Marcos Méndez Thu, 01 Oct 2026 21:00:00 +0000 + confconsole (2.2.3+keel8) trixie; urgency=low * keelfirstboot.py, the first boot screens handbook decision 0020 diff --git a/keelbanner.py b/keelbanner.py index 9dffc6c..56ea1e9 100644 --- a/keelbanner.py +++ b/keelbanner.py @@ -1,42 +1,65 @@ """The Keel mark above the usage screen. The console is a brand surface: an operator meets a Keel appliance on the -container console or over SSH long before any web page. The mark is -installed by the core overlay as ``/etc/keel/banner.txt`` and -``/etc/keel/banner-small.txt``, the same two files the login banner -(``/etc/update-motd.d/00-keel-banner``) reads, both plain ASCII with no -colour escape so a serial console and a recovery shell render them. +container console or over SSH long before any web page. The mark is the +maintainer's console art, installed by the core overlay in ``/etc/keel``, +the same files the login banner (``/etc/update-motd.d/00-keel-banner``) +reads: a wide mark with the KEEL LINUX lettering, the tagline and +KEELLINUX.ORG, a full mark and a small one. The wide mark is drawn in +UTF-8 block and shade characters only; the full and the small mark come +in UTF-8 and in plain ASCII. None of them carries a colour escape. How many rows and columns a mark takes is whatever the installed files carry: the art is drawn in the overlay and redrawn there, and nothing -here assumes a size. This module measures the file it reads, decides which -of the two marks, if either, goes above the usage text, centres it on the -width the dialog leaves inside its frame and puts it there. It opens no -dialog and formats no address: the usage text is rendered by -``confconsole.render_usage``, is left as it is and is never centred. +here assumes a size. This module measures the file it reads, decides +which tier, if any, goes above the usage text, in the order wide, full, +small, none, from the room the dialog has on the screen, and centres it +on the width of the box. It opens no dialog and formats no address: the +usage text is rendered by ``confconsole.render_usage``, is left as it is +and is never centred. The rule the module enforces is the identity's: the mark never costs the -screen a line of what it already says, so it is dropped to the small one, -and then to nothing, rather than pushing the addresses out of the box. +screen a line of what it already says, so a smaller tier is taken, and +then none, rather than pushing the addresses out of the box. """ +import os import shutil +import textwrap +from collections.abc import Mapping +MARK_WIDE = "/etc/keel/banner-wide.txt" +MARK_UTF8 = "/etc/keel/banner-utf8.txt" +MARK_SMALL_UTF8 = "/etc/keel/banner-small-utf8.txt" MARK = "/etc/keel/banner.txt" MARK_SMALL = "/etc/keel/banner-small.txt" -MARKS = (MARK, MARK_SMALL) -# Under this the usage screen alone is already tight and the mark is -# dropped: the addresses win. +# The two ladders, largest first. There is no ASCII wide mark. +MARKS_UTF8 = (MARK_WIDE, MARK_UTF8, MARK_SMALL_UTF8) +MARKS_ASCII = (MARK, MARK_SMALL) + MIN_ROWS = 24 DEFAULT_COLS = 80 # One blank line between the mark and the first line of the usage text. SEPARATOR_ROWS = 1 -# The dialog frame and its padding, left and right, top and bottom. +# The dialog frame and its padding, left and right. FRAME = 4 +# The rows of a message box that are not text: the top border, the blank +# row Console._wrapper puts above every text, the rule above the button, +# the button and the bottom border. +BOX_CHROME = 5 + +# What the screen keeps from the box. dialog centres a box on the whole +# screen, so the backtitle and the rule under it, two rows at the top, +# cost two rows at the bottom too, and the shadow, two columns on the +# right, costs two on the left. Measured with dialog 1.3 on trixie: on +# 24 rows a box of 20 is the tallest drawn clear of the backtitle. +SCREEN_ROWS = 4 +SCREEN_COLS = 4 + def terminal_size() -> tuple[int, int]: """The terminal as (rows, columns), 24 by 80 when it does not say.""" @@ -44,14 +67,44 @@ def terminal_size() -> tuple[int, int]: return size.lines, size.columns +def available(rows: int, cols: int) -> tuple[int, int]: + """The rows and columns a dialog box may take on a terminal of + `rows` by `cols`, the backtitle and the shadow kept clear.""" + return max(0, rows - SCREEN_ROWS), max(0, cols - SCREEN_COLS) + + +def is_utf8(environ: Mapping[str, str]) -> bool: + """Whether the locale of `environ` is UTF-8: LC_ALL, then LC_CTYPE, + then LANG, the first set and not empty, as the C library reads them. + + This is the environment the dialog child inherits. Python run in the + C locale with LANG=C coerces LC_CTYPE to C.UTF-8 (PEP 538), and dialog + then does draw UTF-8, so the UTF-8 marks are right there too; LC_ALL=C + turns the coercion off and gets the ASCII ones. + """ + for name in ("LC_ALL", "LC_CTYPE", "LANG"): + value = environ.get(name, "") + if value: + codeset = value.partition(".")[2].partition("@")[0].lower() + return codeset in ("utf-8", "utf8") + return False + + +def marks(environ: Mapping[str, str]) -> tuple[str, ...]: + """The ladder for the locale of `environ`, largest first.""" + return MARKS_UTF8 if is_utf8(environ) else MARKS_ASCII + + def mark_size(mark: str) -> tuple[int, int]: - """The rows and columns an ASCII mark occupies, (0, 0) when empty.""" + """The rows and columns a mark occupies, (0, 0) when empty. Every + character of the art, a block or a shade as much as a "#", is one + column wide.""" lines = mark.splitlines() return len(lines), max((len(line) for line in lines), default=0) def inner_width(cols: int) -> int: - """The columns a terminal of `cols` leaves inside the dialog frame. + """The columns a box `cols` wide leaves inside its frame. One definition, used by `fits` to decide whether a mark is too wide and by the caller to centre it, so the width the mark is measured @@ -60,10 +113,28 @@ def inner_width(cols: int) -> int: return cols - FRAME +def text_rows(text: str, width: int) -> int: + """The rows `text` takes inside a box whose inside is `width` wide: + dialog wraps a longer line at a space, and breaks a word longer than + the whole width.""" + rows = 0 + for line in text.split("\n"): + rows += max(1, len(textwrap.wrap(line, width))) + return rows + + +def box_width(mark: str, width: int, room: int) -> int: + """The width of a box of `width` columns once it holds `mark`: wide + enough for the mark inside its frame, never wider than `room`.""" + return min(room, max(width, mark_size(mark)[1] + FRAME)) + + def fits(mark: str, rows: int, cols: int, used_rows: int) -> bool: - """Whether `mark` fits above a box of `used_rows` rows in a terminal - of `rows` by `cols`, the blank line between them counted. An empty - file never fits, so a truncated mark costs the screen nothing.""" + """Whether `mark` fits above a box of `used_rows` rows in a room of + `rows` by `cols` (`available`), the blank line between them counted. + The box may widen to the room, so the mark needs only its own + columns and the frame. An empty file never fits, so a truncated mark + costs the screen nothing.""" mark_rows, mark_cols = mark_size(mark) if mark_rows == 0: return False @@ -77,9 +148,9 @@ def read(path: str) -> str | None: is not an error: the appliance was built before the overlay carried the mark, or an operator removed it.""" try: - with open(path) as fob: + with open(path, encoding="utf-8") as fob: return fob.read() - except OSError: + except (OSError, UnicodeDecodeError): return None @@ -87,19 +158,22 @@ def choose( rows: int, cols: int, used_rows: int, - paths: tuple[str, ...] = MARKS, + paths: tuple[str, ...] | None = None, reader=None, ) -> str | None: - """The first mark of `paths` that fits, or None. - - `paths` is ordered largest first, so a tall terminal gets the full - mark, a shorter one the small mark and a terminal under `MIN_ROWS` - none at all, whatever its width. `reader` defaults to `read`, looked - up when the call is made and not when this function was defined, so a - test can replace it on the module. + """The first mark of `paths` that fits a room of `rows` by `cols` + above a box of `used_rows` rows, or None. + + `rows` and `cols` are the room the dialog has (`available`), not the + terminal. `paths` is ordered largest first and defaults to the ladder + of the locale (`marks`), so the widest terminal gets the wide mark, a + narrower or shorter one the full mark, then the small mark, and a + terminal with room for none of them none at all. `reader` defaults to + `read`, looked up when the call is made and not when this function + was defined, so a test can replace it on the module. """ - if rows < MIN_ROWS: - return None + if paths is None: + paths = marks(os.environ) if reader is None: reader = read for path in paths: diff --git a/tests/test_confconsole_usage.py b/tests/test_confconsole_usage.py index 70b0948..9e4b360 100644 --- a/tests/test_confconsole_usage.py +++ b/tests/test_confconsole_usage.py @@ -50,11 +50,45 @@ BOX_ROWS = 25 BOX_COLS = 65 +# The rows of the usage text of an IPv6 only adapter: the IPv6 half of +# DEFAULT_TEMPLATE, every line of which fits the box unwrapped. +V6_TEXT_ROWS = 5 + + +def terminal_for(mark: str, text_rows: int = V6_TEXT_ROWS) -> tuple: + """The shortest terminal, 100 columns wide, whose dialog has room for + `mark` above a usage text of `text_rows` rows.""" + rows = ( + keelbanner.added_rows(mark) + + text_rows + + keelbanner.BOX_CHROME + + keelbanner.SCREEN_ROWS + ) + return rows, 100 + + +# The rows of the dual stack text: both halves and the blank row between. +DUAL_TEXT_ROWS = 11 + # A terminal with room for each mark above that box, and one too narrow # for either of them. -TALL_TERMINAL = (BOX_ROWS + keelbanner.added_rows(FULL_MARK) + 5, 100) -SHORT_TERMINAL = (BOX_ROWS + keelbanner.added_rows(SMALL_MARK), 100) -NARROW_TERMINAL = (TALL_TERMINAL[0], SMALL_MARK_COLS + keelbanner.FRAME - 1) +TALL_TERMINAL = (terminal_for(FULL_MARK, DUAL_TEXT_ROWS)[0] + 5, 100) +SHORT_TERMINAL = terminal_for(SMALL_MARK) +NARROW_TERMINAL = ( + TALL_TERMINAL[0], + SMALL_MARK_COLS + keelbanner.FRAME + keelbanner.SCREEN_COLS - 1, +) + + +def box_height(mark: str, terminal: tuple, text_rows=V6_TEXT_ROWS) -> int: + """The height of the box with `mark` above the text: what the mark and + the text need, and never less than the box's own height when the + screen has room for it.""" + room = terminal[0] - keelbanner.SCREEN_ROWS + needed = ( + keelbanner.added_rows(mark) + text_rows + keelbanner.BOX_CHROME + ) + return max(needed, min(BOX_ROWS, room)) def indent_of(mark_cols: int) -> str: @@ -312,6 +346,10 @@ def fake_path(name): # a test host has and what the screen looked like before the mark. state["terminal"] = (24, 80) state["marks"] = {} + # The C locale, so the ASCII ladder, unless a test says otherwise. + for name in ("LC_ALL", "LC_CTYPE"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("LANG", "C") monkeypatch.setattr( confconsole.keelbanner, "terminal_size", lambda: state["terminal"] ) @@ -323,10 +361,21 @@ def fake_path(name): def install_marks(state, full=FULL_MARK, small=SMALL_MARK): - """Put the two marks where the core overlay installs them.""" + """Put the two ASCII marks where the core overlay installs them.""" state["marks"] = {keelbanner.MARK: full, keelbanner.MARK_SMALL: small} +def install_utf8_marks(state, wide, full, small): + """Put the three UTF-8 marks where the core overlay installs them, and + the two ASCII ones beside them, as the overlay does.""" + install_marks(state) + state["marks"].update({ + keelbanner.MARK_WIDE: wide, + keelbanner.MARK_UTF8: full, + keelbanner.MARK_SMALL_UTF8: small, + }) + + def make_usage_console(tc, advanced=True): console = tc() console.advanced_enabled = advanced @@ -481,9 +530,10 @@ def test_a_tall_terminal_gets_the_full_mark_above_the_services( ) assert lines[FULL_MARK_ROWS] == "" assert lines[FULL_MARK_ROWS + 1] == f"Web: http://[{V6}]" - assert console.console.msgbox_kwargs["height"] == BOX_ROWS + ( - keelbanner.added_rows(FULL_MARK) + assert console.console.msgbox_kwargs["height"] == box_height( + FULL_MARK, TALL_TERMINAL ) + assert console.console.msgbox_kwargs["width"] == BOX_COLS def test_the_mark_is_centred_and_the_usage_text_stays_left_aligned( self, tc, usage_env @@ -544,25 +594,112 @@ def test_a_shorter_terminal_falls_back_to_the_small_mark( SMALL_MARK_COLS, SMALL_MARK_ROWS ) assert lines[SMALL_MARK_ROWS + 1] == f"Web: http://[{V6}]" - assert console.console.msgbox_kwargs["height"] == BOX_ROWS + ( - keelbanner.added_rows(SMALL_MARK) + assert console.console.msgbox_kwargs["height"] == box_height( + SMALL_MARK, SHORT_TERMINAL ) - def test_80_by_24_keeps_the_usage_screen_whole(self, tc, usage_env): - # Arrange + def test_the_box_never_grows_past_the_room_on_the_screen( + self, tc, usage_env + ): + # Arrange: room for the full mark and not one row more usage_env["net"]["ipv6conf"] = (V6, "64") - usage_env["terminal"] = (24, 80) + usage_env["terminal"] = terminal_for(FULL_MARK) install_marks(usage_env) console = make_usage_console(tc) # Act console.usage() + # Assert + room = usage_env["terminal"][0] - keelbanner.SCREEN_ROWS + text = console.console.calls[-1][2] + assert text.splitlines()[:FULL_MARK_ROWS] == centred_rows( + FULL_MARK_COLS, FULL_MARK_ROWS + ) + assert console.console.msgbox_kwargs["height"] == room + + def test_80_by_24_dual_stack_keeps_the_usage_screen_whole( + self, tc, usage_env + ): + # Arrange: eleven rows of usage text and both fixtures installed; + # the room on 24 rows holds the text, and a small mark of five + # rows does not fit above it + usage_env["net"]["ipconf"] = (V4, "255.255.255.0", "192.0.2.1", []) + usage_env["net"]["ipv6conf"] = (V6, "64") + usage_env["terminal"] = (24, 80) + install_marks(usage_env, small="\n".join(["#"] * 5) + "\n") + console = make_usage_console(tc) + + # Act + console.usage() + # Assert text = console.console.calls[-1][2] assert "#" not in text assert text.splitlines()[0] == f"Web: http://[{V6}]" assert console.console.msgbox_kwargs["height"] == BOX_ROWS + assert console.console.msgbox_kwargs["width"] == BOX_COLS + + def test_a_utf8_locale_takes_the_utf8_ladder( + self, tc, usage_env, monkeypatch + ): + # Arrange + monkeypatch.setenv("LANG", "C.UTF-8") + wide = "\n".join(["█" * 90] * 3) + "\n" + full = "\n".join(["▒" * 20] * 3) + "\n" + small = "░\n" + usage_env["net"]["ipv6conf"] = (V6, "64") + usage_env["terminal"] = (40, 80) + install_utf8_marks(usage_env, wide, full, small) + console = make_usage_console(tc) + + # Act + console.usage() + + # Assert: the wide one is too wide for 80 columns, so the full one + lines = console.console.calls[-1][2].splitlines() + assert lines[0] == indent_of(20) + "▒" * 20 + assert "#" not in "\n".join(lines) + + def test_the_wide_mark_widens_the_box_and_is_centred_in_it( + self, tc, usage_env, monkeypatch + ): + # Arrange: a wide mark wider than the usual box, a terminal with + # room for it + monkeypatch.setenv("LANG", "en_US.UTF-8") + wide_cols = BOX_COLS + 30 + wide = "\n".join(["█" * wide_cols] * 4) + "\n" + usage_env["net"]["ipv6conf"] = (V6, "64") + usage_env["terminal"] = (45, 160) + install_utf8_marks(usage_env, wide, "▒\n", "░\n") + console = make_usage_console(tc) + + # Act + console.usage() + + # Assert: the box is the mark and its frame, and the mark fills it + width = console.console.msgbox_kwargs["width"] + assert width == wide_cols + keelbanner.FRAME + lines = console.console.calls[-1][2].splitlines() + assert lines[:4] == ["█" * wide_cols] * 4 + assert lines[5] == f"Web: http://[{V6}]" + + def test_the_c_locale_never_draws_a_utf8_mark(self, tc, usage_env): + # Arrange: every mark installed, the C locale of the fixture + usage_env["net"]["ipv6conf"] = (V6, "64") + usage_env["terminal"] = (50, 200) + install_utf8_marks(usage_env, "█\n", "▒\n", "░\n") + console = make_usage_console(tc) + + # Act + console.usage() + + # Assert + text = console.console.calls[-1][2] + assert text.isascii() + assert text.splitlines()[0] == indent_of(FULL_MARK_COLS) + ( + "#" * FULL_MARK_COLS + ) def test_an_appliance_without_the_mark_files_is_unchanged( self, tc, usage_env diff --git a/tests/test_keelbanner.py b/tests/test_keelbanner.py index 563ff4f..a5c1d4b 100644 --- a/tests/test_keelbanner.py +++ b/tests/test_keelbanner.py @@ -367,8 +367,6 @@ def test_one_row_or_one_column_short_and_the_usage_screen_wins( paths = (str(path),) tall = rows_for(rows, keelbanner) wide = cols + keelbanner.FRAME - # the room, not the floor under the usage screen, is what decides - assert tall - 1 >= keelbanner.MIN_ROWS # Act and Assert: it fits exactly, and neither one row nor one # column less leaves the usage text a line shorter @@ -384,12 +382,28 @@ def test_reads_the_installed_mark(self, keelbanner, marks): def test_a_missing_file_is_not_an_error(self, keelbanner, tmp_path): assert keelbanner.read(str(tmp_path / "absent")) is None + def test_reads_a_utf8_mark_whatever_the_locale( + self, keelbanner, tmp_path + ): + path = tmp_path / "banner-utf8.txt" + path.write_bytes("█ █\n▀▀▀\n".encode()) + + assert keelbanner.read(str(path)) == "█ █\n▀▀▀\n" + + def test_a_file_that_is_not_utf8_is_skipped(self, keelbanner, tmp_path): + path = tmp_path / "banner.txt" + path.write_bytes(b"\xff\xfe#\n") + + assert keelbanner.read(str(path)) is None + class TestChoose: - def test_a_terminal_under_24_rows_gets_no_mark(self, keelbanner, marks): - rows = keelbanner.MIN_ROWS - 1 + """`choose` is given the room the dialog has on the screen + (`available`), not the terminal, and the rows the box needs without + a mark.""" - assert keelbanner.choose(rows, 80, USAGE_ROWS, marks) is None + def test_no_room_above_the_box_gets_no_mark(self, keelbanner, marks): + assert keelbanner.choose(USAGE_ROWS, 80, USAGE_ROWS, marks) is None def test_a_tall_terminal_gets_the_full_mark(self, keelbanner, marks): rows = rows_for(MARK_ROWS, keelbanner) @@ -399,10 +413,9 @@ def test_a_tall_terminal_gets_the_full_mark(self, keelbanner, marks): def test_the_ladder_is_full_then_small_then_none_as_it_shrinks( self, keelbanner, marks ): - # Arrange: the shortest terminal each mark needs + # Arrange: the shortest room each mark needs full = rows_for(MARK_ROWS, keelbanner) small = rows_for(SMALL_ROWS, keelbanner) - assert small - 1 >= keelbanner.MIN_ROWS # Act ladder = [ @@ -425,10 +438,31 @@ def test_a_terminal_too_narrow_falls_back_the_same_way( assert keelbanner.choose(rows, cols, USAGE_ROWS, marks) == MARK_SMALL assert keelbanner.choose(rows, cols - 1, USAGE_ROWS, marks) is None - def test_an_80_by_24_terminal_keeps_the_usage_screen_whole( + def test_an_80_by_24_terminal_keeps_a_25_row_box_whole( self, keelbanner, marks ): - assert keelbanner.choose(24, 80, USAGE_ROWS, marks) is None + rows, cols = keelbanner.available(24, 80) + + assert keelbanner.choose(rows, cols, USAGE_ROWS, marks) is None + + def test_the_paths_default_to_the_ladder_of_the_locale( + self, keelbanner, monkeypatch + ): + # Arrange: every mark installed, a UTF-8 locale + monkeypatch.setenv("LANG", "C.UTF-8") + monkeypatch.delenv("LC_ALL", raising=False) + monkeypatch.delenv("LC_CTYPE", raising=False) + installed = {path: MARK_SMALL for path in keelbanner.MARKS_UTF8} + installed.update({path: MARK for path in keelbanner.MARKS_ASCII}) + rows = rows_for(MARK_ROWS, keelbanner) + + # Act + chosen = keelbanner.choose( + rows, 80, USAGE_ROWS, reader=installed.get + ) + + # Assert: the UTF-8 ladder was read, and only it + assert chosen == MARK_SMALL def test_a_mark_that_is_not_installed_is_skipped(self, keelbanner): chosen = keelbanner.choose( @@ -452,15 +486,184 @@ def test_no_mark_installed_at_all(self, keelbanner): assert chosen is None - def test_the_default_paths_are_the_ones_the_overlay_installs( + def test_the_paths_are_the_ones_the_core_overlay_installs( self, keelbanner ): - assert keelbanner.MARKS == ( + assert keelbanner.MARKS_UTF8 == ( + "/etc/keel/banner-wide.txt", + "/etc/keel/banner-utf8.txt", + "/etc/keel/banner-small-utf8.txt", + ) + assert keelbanner.MARKS_ASCII == ( "/etc/keel/banner.txt", "/etc/keel/banner-small.txt", ) +# Three synthetic tiers drawn in block characters, ragged, each smaller +# than the one before in both directions: a stand-in for the wide, the +# full and the small mark whose sizes are this file's own. +TIER_WIDE = "\n".join(["█" * 40] * 6 + ["▀" * 9]) + "\n" +TIER_FULL = "\n".join(["▒" * 12] * 5) + "\n" +TIER_SMALL = "\n".join(["░" * 5] * 2) + "\n" +TIERS = {"wide": TIER_WIDE, "full": TIER_FULL, "small": TIER_SMALL} + + +class TestTiers: + """The largest tier the dialog has room for, in the order wide, full, + small, none, decided by width and by height alike.""" + + def ladder(self, keelbanner, rows, cols, used_rows=USAGE_ROWS): + chosen = keelbanner.choose( + rows, cols, used_rows, tuple(TIERS), TIERS.get + ) + names = [name for name, mark in TIERS.items() if mark == chosen] + return names[0] if names else None + + def test_a_block_character_is_one_column(self, keelbanner): + assert keelbanner.mark_size("██\n▀\n") == (2, 2) + assert keelbanner.mark_size(TIER_WIDE) == (7, 40) + + def test_room_for_everything_takes_the_wide_tier(self, keelbanner): + rows = rows_for(7, keelbanner) + + assert self.ladder(keelbanner, rows, 40 + keelbanner.FRAME) == "wide" + + def test_one_column_short_of_the_wide_tier_takes_the_full_one( + self, keelbanner + ): + rows = rows_for(7, keelbanner) + + assert self.ladder(keelbanner, rows, 40 + keelbanner.FRAME - 1) == ( + "full" + ) + + def test_one_row_short_of_the_wide_tier_takes_the_full_one( + self, keelbanner + ): + rows = rows_for(7, keelbanner) - 1 + + assert self.ladder(keelbanner, rows, 200) == "full" + + def test_then_the_small_tier_then_none(self, keelbanner): + assert self.ladder(keelbanner, 60, 12 + keelbanner.FRAME - 1) == ( + "small" + ) + assert self.ladder(keelbanner, rows_for(2, keelbanner), 200) == ( + "small" + ) + assert self.ladder(keelbanner, rows_for(2, keelbanner) - 1, 200) is ( + None + ) + assert self.ladder(keelbanner, 60, 5 + keelbanner.FRAME - 1) is None + + def test_a_taller_usage_text_pushes_the_choice_down(self, keelbanner): + rows = rows_for(7, keelbanner) + + assert self.ladder(keelbanner, rows, 200, USAGE_ROWS) == "wide" + assert self.ladder(keelbanner, rows, 200, USAGE_ROWS + 1) == "full" + + +class TestLocale: + """dialog draws a character past ASCII only in a UTF-8 locale, so the + UTF-8 marks go with a UTF-8 locale and the ASCII ones with any + other. The locale is the one the dialog child inherits: LC_ALL, then + LC_CTYPE, then LANG.""" + + @pytest.mark.parametrize( + "environ", + [ + {"LANG": "C.UTF-8"}, + {"LANG": "en_US.utf8"}, + {"LANG": "de_DE.UTF-8@euro"}, + {"LANG": "C", "LC_CTYPE": "C.UTF-8"}, + {"LANG": "C", "LC_ALL": "pt_BR.UTF-8"}, + ], + ) + def test_a_utf8_locale(self, keelbanner, environ): + assert keelbanner.is_utf8(environ) is True + assert keelbanner.marks(environ) == keelbanner.MARKS_UTF8 + + @pytest.mark.parametrize( + "environ", + [ + {}, + {"LANG": "C"}, + {"LANG": "POSIX"}, + {"LANG": "en_US.ISO-8859-1"}, + {"LANG": "en_US.UTF-8", "LC_ALL": "C"}, + {"LANG": "en_US.UTF-8", "LC_CTYPE": "C"}, + {"LANG": "", "LC_ALL": "", "LC_CTYPE": ""}, + ], + ) + def test_any_other_locale(self, keelbanner, environ): + assert keelbanner.is_utf8(environ) is False + assert keelbanner.marks(environ) == keelbanner.MARKS_ASCII + + def test_an_empty_setting_is_skipped_for_the_next(self, keelbanner): + environ = {"LC_ALL": "", "LC_CTYPE": "", "LANG": "C.UTF-8"} + + assert keelbanner.is_utf8(environ) is True + + +class TestAvailable: + """The room the dialog has on the screen. dialog centres a box on the + whole screen, so the backtitle and the rule under it at the top cost + the same rows at the bottom, and the shadow on the right the same + columns on the left.""" + + def test_the_screen_less_the_backtitle_and_the_shadow(self, keelbanner): + assert keelbanner.available(24, 80) == ( + 24 - keelbanner.SCREEN_ROWS, + 80 - keelbanner.SCREEN_COLS, + ) + + def test_measured_on_dialog_at_80_by_24(self, keelbanner): + # A box of 20 rows is the tallest dialog draws clear of the + # backtitle on 24 rows, measured with dialog 1.3 on trixie. + assert keelbanner.available(24, 80) == (20, 76) + + def test_never_negative(self, keelbanner): + assert keelbanner.available(2, 3) == (0, 0) + + +class TestTextRows: + """The rows a text takes in a box whose inside is `width` wide: dialog + wraps a line longer than that at a space.""" + + def test_one_row_per_short_line(self, keelbanner): + assert keelbanner.text_rows("a\nbb\n\nccc", 10) == 4 + + def test_a_long_line_wraps_at_a_space(self, keelbanner): + assert keelbanner.text_rows("aaaa bbbb cccc", 9) == 2 + + def test_a_word_longer_than_the_width_is_broken(self, keelbanner): + assert keelbanner.text_rows("x" * 25, 10) == 3 + + def test_the_wrapped_usage_line_of_a_real_address(self, keelbanner): + line = ( + "Admin: https://[2804:710:d0:5:e0fc:fc60:e690:1c25]" + "/wp-admin/" + ) + + assert keelbanner.text_rows(line, 61) == 2 + assert keelbanner.text_rows(line, len(line)) == 1 + + +class TestBoxWidth: + def test_a_mark_narrower_than_the_box_keeps_its_width(self, keelbanner): + assert keelbanner.box_width(TIER_SMALL, 65, 76) == 65 + + def test_a_wider_mark_widens_the_box_by_its_frame(self, keelbanner): + assert keelbanner.box_width(TIER_WIDE, 30, 76) == ( + 40 + keelbanner.FRAME + ) + + def test_never_wider_than_the_room(self, keelbanner): + assert keelbanner.box_width(TIER_WIDE, 65, 50) == 50 + assert keelbanner.box_width(TIER_SMALL, 65, 50) == 50 + + class TestAbove: def test_the_mark_then_one_blank_line_then_the_text(self, keelbanner): # Arrange