git/orchard/main
tests/test_render.py
564 lines20 KB last change raw · history
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
"""Renderer tests.

The load-bearing property is that no screen ever emits a line wider than the
terminal. When that fails the terminal wraps the line, every column below it
shifts, and the display becomes unreadable -- so it is checked at every width,
with real data and with degenerate data.
"""

from __future__ import annotations

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from rich.cells import cell_len  # noqa: E402

from updater import icons  # noqa: E402
from updater.audit import Change, Severity, audit  # noqa: E402
from updater.aur import AurPackage  # noqa: E402
from updater.buildgraph import build_graph  # noqa: E402
from updater.procwatch import BuildState  # noqa: E402
from updater.render import (  # noqa: E402
    MIN_WIDTH,
    AurRow,
    CheckRow,
    Columns,
    FeedEntry,
    RepoRow,
    Review,
    Screen,
    Step,
    Totals,
    VcsRow,
    audit_screen,
    build_screen,
    review_screen,
)
from updater.render.audit import diff_lines, summary_parts  # noqa: E402
from updater.srcinfo import SrcInfo, parse_pkgbuild, parse_srcinfo  # noqa: E402
from updater.theme import Theme  # noqa: E402

THEME = Theme.default()
GLYPHS = icons.glyphs("unicode")
WIDTHS = (MIN_WIDTH, 72, 88, 100, 120, 160)


def assert_fits(lines, width: int) -> None:
    for i, line in enumerate(lines):
        got = cell_len(line.plain)
        assert got <= width, f"line {i} is {got} cells at width {width}: {line.plain!r}"


# --------------------------------------------------------------------------
# fixtures
# --------------------------------------------------------------------------


def sample_review(*, rows: int = 4, blocked: bool = False) -> Review:
    return Review(
        meta="2026-09-28 16:52 · arch linux · rolling",
        repo=[
            RepoRow("U", "linux", "6.16.7.arch1-1", "6.16.8.arch1-1", "1.4 MiB"),
            RepoRow("U", "mesa", "1:25.2.2-1", "1:25.2.3-1", "142.2 MiB"),
            RepoRow("N", "rebuild-detector", "-", "5-1", "12 KiB"),
            RepoRow("R", "linux-kepler", "6.15.9-1", "-", "-310 MiB"),
        ][:rows],
        repo_total=184,
        repo_download=1258291200,
        repo_delta=503316480,
        aur=[
            AurRow("U", "helium-browser-bin", "0.9.2-1", "0.9.3-1", "PKGBUILD +3 -1"),
            AurRow(
                "!!" if blocked else "!",
                "enca-bin",
                "1.19-4",
                "1.19-5",
                "adopted by a new maintainer",
                loud=True,
                details=[
                    "maintainer changed  (Felp98 -> mkolb)",
                    ".install adds: systemctl enable --now encad",
                ],
            ),
            AurRow("U", "termux-language-server", "0.1.12-1", "0.1.13-1"),
        ],
        aur_total=23,
        aur_build=21,
        vcs=[
            VcsRow("helix-git", "r9021 → r9044"),
            VcsRow("mangowm-git", "a1b2c → e9f01"),
            VcsRow("noctalia-git", "r1204 → r1288"),
        ],
        checks=[
            CheckRow(True, "arch-audit", "no CVEs among pending upgrades"),
            CheckRow(True, "news", "no unread items affecting this update"),
            CheckRow(False, "pacnew", "2 unresolved config files"),
        ],
        steps=[
            Step("1", "pacman -Su", "184 packages · 1.2 GiB download"),
            Step("2", "paru -Sua", "21 packages built from source"),
            Step("3", "restart", "12 services affected"),
        ],
    )


def sample_graph():
    infos = {
        "top": SrcInfo(pkgbase="top", pkgver="1", pkgrel="1", depends=["mid"]),
        "mid": SrcInfo(pkgbase="mid", pkgver="1", pkgrel="1", depends=["base"]),
        "base": SrcInfo(pkgbase="base", pkgver="1", pkgrel="1"),
        "free": SrcInfo(pkgbase="free", pkgver="2", pkgrel="1"),
    }
    return build_graph(infos, set(infos))


def sample_report(blocked: bool = True):
    body = "sudo make install" if blocked else "make"
    change = Change(
        package="demo",
        old_srcinfo=parse_srcinfo(
            "pkgbase = demo\n\tpkgver = 1.0\n\tpkgrel = 1\n"
            "\tsource = https://x.invalid/v1.tar.gz\n"
            "\tsha256sums = aaaa\n\npkgname = demo\n"
        ),
        new_srcinfo=parse_srcinfo(
            "pkgbase = demo\n\tpkgver = 1.1\n\tpkgrel = 1\n"
            "\tsource = https://y.invalid/v2.tar.gz\n"
            "\tsha256sums = SKIP\n\npkgname = demo\n"
        ),
        old_pkgbuild=parse_pkgbuild("pkgver=1.0\nbuild() {\n  make\n}\n"),
        new_pkgbuild=parse_pkgbuild(f"pkgver=1.1\nbuild() {{\n  {body}\n}}\n"),
    )
    return audit(change)


# --------------------------------------------------------------------------
# the width invariant, everywhere
# --------------------------------------------------------------------------


def test_review_fits_at_every_width():
    for width in WIDTHS:
        lines = review_screen(sample_review(), theme=THEME, glyphs=GLYPHS, width=width)
        assert_fits(lines, width)
        assert lines


def test_build_dashboard_fits_at_every_width():
    for width in WIDTHS:
        lines = build_screen(
            graph=sample_graph(),
            theme=THEME,
            glyphs=GLYPHS,
            width=width,
            feed=[FeedEntry("base", "1-1", 41.0)],
            states=[BuildState("mid", "cargo", 12.0)],
            completed={"base"},
            running={"mid"},
            failed=set(),
            totals=Totals(
                total=4,
                elapsed=252,
                download_bytes=12_400_000,
                download_rate=3_100_000,
                download_remaining=8_200_000,
                download_eta=3,
            ),
            parallelism="16 cores · 8 workers",
        )
        assert_fits(lines, width)


def test_audit_screen_fits_at_every_width():
    for width in WIDTHS:
        lines = audit_screen(
            sample_report(),
            theme=THEME,
            glyphs=GLYPHS,
            width=width,
            old_text="pkgver=1.0\nbuild() {\n  make\n}\n",
            new_text="pkgver=1.1\nbuild() {\n  sudo make install\n}\n",
        )
        assert_fits(lines, width)


def test_the_actual_terminal_width_never_overflows():
    """Whatever the tool is run in, the lines it emits fit."""
    import shutil

    width = shutil.get_terminal_size((100, 40)).columns
    for lines in (
        review_screen(sample_review(), theme=THEME, glyphs=GLYPHS, width=width),
        build_screen(graph=sample_graph(), theme=THEME, glyphs=GLYPHS, width=width),
        audit_screen(sample_report(), theme=THEME, glyphs=GLYPHS, width=width),
    ):
        assert_fits(lines, width)


# --------------------------------------------------------------------------
# degenerate input must not crash
# --------------------------------------------------------------------------


def test_every_screen_handles_completely_empty_data():
    empty_review = Review()
    lines = review_screen(empty_review, theme=THEME, glyphs=GLYPHS, width=100)
    assert lines

    lines = build_screen(
        graph=build_graph({}, set()), theme=THEME, glyphs=GLYPHS, width=100, totals=Totals()
    )
    assert any("nothing finished" in line.plain for line in lines)

    lines = audit_screen(audit(Change(package="x")), theme=THEME, glyphs=GLYPHS, width=100)
    assert lines


def test_absurdly_long_names_and_versions_are_truncated_not_wrapped():
    review = Review(repo=[RepoRow("U", "a" * 300, "1" * 120, "2" * 120, "1.0 MiB")], repo_total=1)
    for width in WIDTHS:
        assert_fits(review_screen(review, theme=THEME, glyphs=GLYPHS, width=width), width)


def test_below_minimum_width_columns_are_clamped_not_corrupted():
    lines = review_screen(sample_review(), theme=THEME, glyphs=GLYPHS, width=10)
    assert_fits(lines, MIN_WIDTH)


def test_every_icon_tier_renders_within_the_grid():
    """Nerd Font glyphs must not be wider than their geometric equivalents."""
    for tier in ("nerd", "unicode", "ascii"):
        glyphs = icons.glyphs(tier)
        lines = build_screen(
            graph=sample_graph(),
            theme=THEME,
            glyphs=glyphs,
            width=100,
            feed=[FeedEntry("base", "1-1", 5.0)],
            completed={"base"},
            states=[BuildState("mid", "cargo", 1.0)],
        )
        assert_fits(lines, 100)


# --------------------------------------------------------------------------
# semantics the screen is supposed to convey
# --------------------------------------------------------------------------


def test_dashboard_toggle_changes_the_output():
    kwargs = {
        "graph": sample_graph(),
        "theme": THEME,
        "glyphs": GLYPHS,
        "width": 100,
        "feed": [FeedEntry("base", "1-1", 5.0)],
        "completed": {"base"},
    }
    full = build_screen(dashboard=True, **kwargs)
    ledger = build_screen(dashboard=False, **kwargs)
    assert len(full) > len(ledger)
    assert any("BUILDING" in l.plain.upper() for l in full)
    assert not any("BUILDING" in l.plain.upper() for l in ledger)
    # the ledger still shows what finished: it is a feed, not a downgrade
    assert any("base" in l.plain for l in ledger)


def test_feed_preserves_order_so_the_newest_lands_at_the_bottom():
    feed = [
        FeedEntry("first", "1", 1.0),
        FeedEntry("second", "1", 2.0),
        FeedEntry("third", "1", 3.0),
    ]
    lines = build_screen(graph=sample_graph(), theme=THEME, glyphs=GLYPHS, width=100, feed=feed)
    positions = [
        next(i for i, l in enumerate(lines) if name in l.plain)
        for name in ("first", "second", "third")
    ]
    assert positions == sorted(positions), "feed order was not preserved"


def test_shared_dependency_is_marked_rather_than_duplicated():
    infos = {
        "top": SrcInfo(pkgbase="top", pkgver="1", pkgrel="1", depends=["left", "right"]),
        "left": SrcInfo(pkgbase="left", pkgver="1", pkgrel="1", depends=["shared"]),
        "right": SrcInfo(pkgbase="right", pkgver="1", pkgrel="1", depends=["shared"]),
        "shared": SrcInfo(pkgbase="shared", pkgver="1", pkgrel="1"),
    }
    graph = build_graph(infos, set(infos))
    lines = build_screen(graph=graph, theme=THEME, glyphs=GLYPHS, width=100)
    marked = [l.plain for l in lines if "shared" in l.plain]
    assert any("↻" in l for l in marked), f"no shared marker found in {marked}"


def test_a_node_without_metadata_never_claims_to_be_ready():
    """Its deps are unknown, not satisfied, so "ready" would be a lie."""
    graph = build_graph(
        {"app": SrcInfo(pkgbase="app", pkgver="1", pkgrel="1", depends=["helper"])},
        {"app", "helper"},
    )
    lines = build_screen(graph=graph, theme=THEME, glyphs=GLYPHS, width=100)
    text = "\n".join(l.plain for l in lines)
    assert "helper" in text
    assert "metadata missing" in text
    helper_line = next(l.plain for l in lines if "helper" in l.plain)
    assert "ready" not in helper_line, helper_line


def test_an_unorderable_graph_is_reported_not_silently_omitted():
    graph = build_graph(
        {"app": SrcInfo(pkgbase="app", pkgver="1", pkgrel="1", depends=["helper"])},
        {"app", "helper"},
    )
    lines = build_screen(graph=graph, theme=THEME, glyphs=GLYPHS, width=100)
    text = "\n".join(l.plain for l in lines)
    assert "CANNOT BE ORDERED" in text.upper()
    assert "no metadata" in text


def test_settled_packages_are_not_listed_as_unorderable():
    """A package cannot be both in flight and waiting on missing metadata."""
    graph = build_graph(
        {"app": SrcInfo(pkgbase="app", pkgver="1", pkgrel="1", depends=["helper"])},
        {"app", "helper"},
    )
    lines = build_screen(
        graph=graph,
        theme=THEME,
        glyphs=GLYPHS,
        width=100,
        running={"app"},
        states=[BuildState("app", "cargo", 3.0)],
    )
    text = "\n".join(l.plain for l in lines)
    upper = text.upper()
    assert "CANNOT BE ORDERED" in upper, "the unorderable section was not rendered"
    # slice the original text, not the uppercased copy, or the name checks below
    # would pass vacuously
    section = text[upper.index("CANNOT BE ORDERED") :]
    assert "app" not in section, "a running package was listed as unorderable"
    assert "helper" in section, "a genuinely unknown package was dropped"


def test_failed_node_reports_what_it_blocks():
    graph = sample_graph()
    lines = build_screen(graph=graph, theme=THEME, glyphs=GLYPHS, width=100, failed={"base"})
    assert any("blocked by" in l.plain for l in lines)


def test_a_dry_run_never_renders_a_prompt_it_will_not_wait_for():
    """Showing "Apply this plan?" and then exiting reads as a crash."""
    lines = review_screen(sample_review(), theme=THEME, glyphs=GLYPHS, width=100, dry_run=True)
    text = "\n".join(l.plain for l in lines)
    assert "Apply this plan?" not in text
    assert "dry run" in text.lower()
    assert "nothing has been applied" in text
    # and it must say how to actually apply it
    assert "without --dry-run" in text


def test_an_interactive_review_is_silent_about_dry_runs():
    lines = review_screen(sample_review(), theme=THEME, glyphs=GLYPHS, width=100, dry_run=False)
    text = "\n".join(l.plain for l in lines)
    assert "dry run" not in text.lower()


def test_the_plan_screen_never_renders_a_prompt():
    """A prompt in the screen and a prompt from confirm() gave two lines."""
    for review in (sample_review(), sample_review(blocked=True)):
        lines = review_screen(review, theme=THEME, glyphs=GLYPHS, width=100)
        text = "\n".join(l.plain for l in lines)
        assert "Apply this plan?" not in text
        assert "Apply the rest" not in text
        assert "[y/N" not in text


def test_a_blocked_plan_still_warns_that_packages_need_attention():
    risky = review_screen(sample_review(blocked=True), theme=THEME, glyphs=GLYPHS, width=100)
    assert any("needs attention" in l.plain for l in risky), [
        l.plain for l in risky if "attention" in l.plain.lower()
    ]


def test_the_confirmation_prompt_is_owned_by_the_confirming_step():
    from updater.cli import Options, Run, confirm_prompt

    calm = Run(options=Options())
    assert confirm_prompt(calm) == "Apply this plan?"

    from updater.audit import Finding, Report

    risky = Run(options=Options())
    risky.audits = {
        "a": Report(
            package="a",
            diff=None,
            findings=[Finding("pipe-to-shell", Severity.BLOCK, "pipes a download into a shell")],
        )
    }
    assert "skipping 1 flagged package" in confirm_prompt(risky)


def test_a_loud_row_says_its_consequence_and_carries_evidence():
    from updater.render.review import aur_row_from

    blocked = aur_row_from("demo", "1", "2", sample_report(blocked=True))
    assert blocked.tag == "!!"
    assert blocked.note == "will be skipped"
    assert blocked.details, "a loud row must carry its evidence"

    look = aur_row_from("demo", "1", "2", sample_report(blocked=False))
    assert look.tag in ("!", "!!")


def test_single_blocked_package_is_not_referred_to_in_the_plural():
    risky = sample_review(blocked=True)
    risky.aur = [r for r in risky.aur if r.tag == "!!"]
    lines = review_screen(risky, theme=THEME, glyphs=GLYPHS, width=100)
    assert any("1 package needs attention" in l.plain for l in lines)
    assert not any("package(s)" in l.plain for l in lines)


def test_audit_verdict_drives_the_row_tag():
    from updater.render.review import aur_row_from

    clean = aur_row_from("demo", "1", "2", None)
    assert clean.tag == "U" and not clean.loud

    report = sample_report(blocked=True)
    flagged = aur_row_from("demo", "1.0", "1.1", report)
    assert flagged.tag == "!!"
    assert flagged.details, "a loud row must carry its evidence"


def test_summary_parts_are_populated_for_a_real_change():
    parts = summary_parts(sample_report())
    text = " ".join(p[0] for p in parts)
    assert "pkgver" in text and "sums" in text and "source" in text


def test_no_baseline_shows_a_reference_listing_not_a_fake_diff():
    """An all-additions diff would look like a total rewrite that never happened."""
    lines = audit_screen(
        sample_report(),
        theme=THEME,
        glyphs=GLYPHS,
        width=100,
        old_text="",
        new_text="pkgver=1.0\nbuild() {\n  make\n}\n",
        baseline=False,
    )
    text = "\n".join(l.plain for l in lines)
    assert "no baseline recorded" in text
    # check the section heading, not the substring: "different host" contains DIFF
    assert not any(l.plain.strip().upper().startswith("DIFF") for l in lines), (
        "a diff section was rendered without a baseline"
    )
    assert "shown as reference" in text
    # and no line may claim to be an addition
    assert not any(l.plain.startswith("+     ") or l.plain.startswith("+  ") for l in lines)


def test_with_a_baseline_the_diff_is_shown():
    lines = audit_screen(
        sample_report(),
        theme=THEME,
        glyphs=GLYPHS,
        width=100,
        old_text="pkgver=1.0\n",
        new_text="pkgver=1.1\n",
        baseline=True,
    )
    assert any(l.plain.strip().upper().startswith("DIFF") for l in lines)


def test_diff_lines_marks_additions_and_removals():
    rows = diff_lines("a\nb\nc\n", "a\nB\nc\n")
    markers = {m for m, _n, _t in rows}
    assert "-" in markers and "+" in markers
    added = [t for m, _n, t in rows if m == "+"]
    assert added == ["B"]


def test_aur_package_metadata_renders_without_a_network():
    """The client's shape is what the screen consumes; keep them compatible."""
    pkg = AurPackage(name="demo", version="1.0-1", maintainer="alice", votes=3)
    assert not pkg.is_orphaned and not pkg.is_flagged


def main() -> int:
    tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)]
    failed = 0
    for name, fn in tests:
        try:
            fn()
        except Exception as exc:  # noqa: BLE001
            failed += 1
            print(f"FAIL  {name}\n        {type(exc).__name__}: {exc}")
        else:
            print(f"ok    {name}")
    print(f"\n{len(tests) - failed}/{len(tests)} passed")
    return 1 if failed else 0


if __name__ == "__main__":
    raise SystemExit(main())


# --------------------------------------------------------------------------
# clipping: "fits" versus "fits by luck"
# --------------------------------------------------------------------------


def test_realistic_screens_need_no_clipping_at_comfortable_widths():
    """Wide enough that nothing should have to be thrown away."""
    for width in (88, 100, 120, 160):
        for kind in ("review", "build", "audit"):
            screen = Screen(theme=THEME, glyphs=GLYPHS, columns=Columns.for_width(width))
            if kind == "review":
                review_screen(sample_review(), theme=THEME, glyphs=GLYPHS, width=width, into=screen)
            elif kind == "build":
                build_screen(
                    graph=sample_graph(),
                    theme=THEME,
                    glyphs=GLYPHS,
                    width=width,
                    into=screen,
                    feed=[FeedEntry("base", "1-1", 41.0)],
                    states=[BuildState("mid", "cargo", 12.0)],
                    completed={"base"},
                    running={"mid"},
                    totals=Totals(total=4, elapsed=252, download_bytes=12_400_000),
                )
            else:
                audit_screen(sample_report(), theme=THEME, glyphs=GLYPHS, width=width, into=screen)
            assert screen.clipped == 0, f"{kind} clipped {screen.clipped} cell(s) at width {width}"


def test_narrow_terminals_clip_instead_of_corrupting():
    """At the minimum width clipping is expected; overflow never is."""
    for width in (MIN_WIDTH,):
        screen = Screen(theme=THEME, glyphs=GLYPHS, columns=Columns.for_width(width))
        lines = review_screen(sample_review(), theme=THEME, glyphs=GLYPHS, width=width, into=screen)
        assert_fits(lines, width)


def test_advisory_severity_decides_the_colour():
    """Fixability has its own channel; the colour is for severity.

    Using the colour for fixability made Critical and Low the same shade, which
    is the one distinction a security list exists to make.
    """
    from updater.render.review import advisory_role

    assert advisory_role("Critical") == "alert"
    assert advisory_role("High") == "alert"
    assert advisory_role("Medium") == "warn"
    assert advisory_role("Low") == "accent"
    assert advisory_role("Unknown") == "muted"
    assert advisory_role("") == "muted"
    roles = {advisory_role(s) for s in ("Critical", "Medium", "Low", "Unknown")}
    assert len(roles) == 4, roles