Purge contacts is Archive contacts, because it archives them

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-09-16 23:26:43 -04:00
1 parent 0b1ec94cdb
commit 8076ac1081
17 files changed
+109 -103

No files matched your search

+5
View File
@@ -17,6 +17,11 @@ allowed to change behaviour, not just add to it.
## [Unreleased]
- **Purge contacts is Archive contacts**, because archiving is what it does: every contact
it takes off the device stays in MeshTerm and can be restored. The row, its screens, its
confirm button and its progress bar all say Archive, and the row wears the 💾 that
archiving one contact already wore, not the red 🗑 a deletion gets.
- **`scripts/` is sorted by handheld.** The PicoCalc's Calculinux bring-up, its two
console-font builds, the GPL-2.0 text that belongs beside the 6×8 one, and the
`xiao-radio/` firmware kit all live under `scripts/picocalc/`; the SPI bridge is
+8 -8
View File
@@ -4,7 +4,7 @@
A companion's contact table is finite, and a mesh fills it with whatever adverts happen to
arrive — so a radio left running long enough ends up holding mostly nodes heard once, in
passing, from four hops away, while there is no room left to *discover* anyone new. The
Contacts screen's bulk sweep (see :func:`~meshterm.ui.purge_screen.purge_contacts`) is how
Contacts screen's bulk sweep (see :func:`~meshterm.ui.sweep_screen.archive_contacts`) is how
that table gets its headroom back, and this module is the judgement it runs on: one score
per contact, so the sweep can take the weakest and leave the ones you would actually miss.
@@ -18,7 +18,7 @@ must not have.
contacts on a real mesh advertise no location, plenty are never overheard as a relayed
packet so have no hop count, and a channel poster whose name matches no contact cannot be
attributed at all. If "we don't know" resolved to ``0.0``, every one of those would sink to
the bottom *together*, and the sweep would purge by how much metadata a node happens to
the bottom *together*, and the sweep would archive by how much metadata a node happens to
broadcast rather than by how much it is worth. So a term that cannot be computed returns
``None`` and is filled with the **population median** of the contacts that could compute it
(:func:`_fill_unknowns`) — the contact lands exactly where an average peer would on that
@@ -45,7 +45,7 @@ but they are never swept.
came from. What this module hands a screen instead is a *rank* — the list comes back ordered,
and :attr:`~ScoredContact.percentile` places one contact in the field without a legend and
without going stale when the weights change (see :func:`percentile_rank`). What a screen draws
beside a contact is neither: the purge preview shows the :class:`ContactSignals` themselves, a
beside a contact is neither: the archive preview shows the :class:`ContactSignals` themselves, a
lane per measured kind, because the thing a reader can check against what they know is the
evidence, not this module's reading of it.
"""
@@ -69,13 +69,13 @@ PROTECT_WATCHED = "watched"
#: undone by a score. (Inbound-only traffic does *not* protect — anyone can message you.)
PROTECT_MESSAGED = "messaged"
#: Protection reason: a repeater or room server whose admin password is stored. Purging it
#: Protection reason: a repeater or room server whose admin password is stored. Archiving it
#: costs the login, which is a far larger loss than a contact slot is a gain.
PROTECT_ADMIN = "admin"
#: Protection reason: MeshTerm has never heard this contact, so every evidence term is
#: empty for a reason that says nothing about the node. It came from the device's own table
#: (or was added by hand) before this history began; sweeping it would be purging by how
#: (or was added by hand) before this history began; sweeping it would be archiving by how
#: long MeshTerm has been running rather than by anything the contact did.
PROTECT_UNOBSERVED = "unobserved"
@@ -214,9 +214,9 @@ class ScoredContact:
protection: Why this contact can never be swept, or ``None`` if it can. One of the
``PROTECT_*`` constants.
The **signals** are what a screen shows, not the score or its terms: the purge preview
The **signals** are what a screen shows, not the score or its terms: the archive preview
draws one lane per measured kind straight off :attr:`signals` (see
:mod:`~meshterm.ui.purge_screen`), so a reader auditing a sweep reads the evidence in
:mod:`~meshterm.ui.sweep_screen`), so a reader auditing a sweep reads the evidence in
the same units it was gathered in rather than a phrase this module chose for them.
"""
@@ -541,7 +541,7 @@ def sweep_candidates(ranked: Sequence[ScoredContact], keep: int) -> list[ScoredC
protections against the target instead would mean starring a node quietly deepened the
next sweep, which is the opposite of what a star is for. The picker's rungs therefore
report their own real removal counts rather than arithmetic on the table size (see
:func:`~meshterm.ui.purge_screen.purge_contacts`).
:func:`~meshterm.ui.sweep_screen.archive_contacts`).
Args:
ranked: The full ranking from :func:`rank_contacts`, strongest first.
+1 -1
View File
@@ -207,7 +207,7 @@ class DeviceState:
So the three are merged by taking the latest, here — once, at the one point every
screen fetches contacts through, so heard lanes, recency sorts, silence alerts and
the purge ladder all see the same honest value. Taking the *latest* rather than
the archive ladder all see the same honest value. Taking the *latest* rather than
preferring either side means a device time still stands whenever the firmware caught
an advert we did not record (monitoring off, app not running); we only override it
with proof of a later reception. A contact we have truly never heard stays ``None``
+1 -1
View File
@@ -135,7 +135,7 @@ class ChatTool(Tool):
# scroll nor the erase atom is written into the base: the select list splices
# each in exactly when its key would act — ←→ only while the highlighted row
# overflows, Del only on a thread that has history to lose — and the three of
# them together are what has to stay inside 72 cells (the purge preview's list
# them together are what has to stay inside 72 cells (the archive preview's list
# is the same arithmetic). `Del erase` is that budget's doing: the row already
# names the conversation, so the atom spends its cells on the verb, and `erase`
# is the word the app uses elsewhere for taking data away.
+3 -3
View File
@@ -1,9 +1,9 @@
# SPDX-License-Identifier: Apache-2.0
"""The Archived contacts list: what the sweep took off the device, and when.
A purge (see :mod:`~meshterm.ui.purge_screen`) removes a contact from the *device* — the
scarce resource — and keeps it here in full. This screen is where those contacts live: the
same sortable, filterable lane layout as the Contacts list, in its own lane set
An archive sweep (see :mod:`~meshterm.ui.sweep_screen`) removes a contact from the *device*
— the scarce resource — and keeps it here in full. This screen is where those contacts live:
the same sortable, filterable lane layout as the Contacts list, in its own lane set
(``NAME · ARCHIVED · KEY``, :data:`~meshterm.ui.contactlist.ARCHIVED_LANES`), with its own
sort ring over exactly those three columns.
+2 -2
View File
@@ -465,7 +465,7 @@ class ContactListScreen(SelectScreen):
Built on ``super()``'s lane rather than the bare default, so a contact list that
carries section headings (the Time Machine's whole-mesh lead, the Contacts screen's
purge tail) keeps the section jumps the select list promotes onto F1/F2.
archive tail) keeps the section jumps the select list promotes onto F1/F2.
"""
from .tui.fkeys import FPair
@@ -536,7 +536,7 @@ class ContactListScreen(SelectScreen):
"""The lead rows, the sort-aware column header, the contact lanes, then any tail rows.
Own-node rows lead the lanes and stay first whatever the sort: only the block
below them reorders (see :func:`_ordered`). Tail rows (a purge action, the exit
below them reorders (see :func:`_ordered`). Tail rows (an archive action, the exit
group) close the list, past every contact whatever the sort.
"""
items: list = list(self._lead)
+19 -18
View File
@@ -11,15 +11,15 @@ and backing out of that page returns to the list right where it stood. The one-s
(``meshterm contacts --sort …``) still renders the static
:func:`~meshterm.ui.widgets.contacts_table`; only the menu gets the live list.
Below the contacts sits the list's own maintenance action — **Purge contacts** — the bulk
Below the contacts sits the list's own maintenance action — **Archive contacts** — the bulk
counterpart, which exists because a companion's contact table is finite and a busy mesh
fills it with nodes heard once in passing until there is no room left to discover anyone
new. It ranks the whole table (see :mod:`~meshterm.core.contact_score`) and archives the
weakest off the device while MeshTerm keeps them; the flow, its ladder and its preview live
in :mod:`~meshterm.ui.purge_screen`. Our own node, pinned above the list, is never a
in :mod:`~meshterm.ui.sweep_screen`. Our own node, pinned above the list, is never a
candidate — it isn't in the device's contact table.
What a sweep took is never hidden: **View archived contacts** sits directly under the purge
What a sweep took is never hidden: **View archived contacts** sits directly under the archive action
action and opens the archived list (see :mod:`~meshterm.ui.archived_screen`) — its own
screen, in the same lane layout, sorted on its own ``NAME · ARCHIVED · KEY`` columns. It
earns a screen rather than a section at the foot of this one because it is a different list
@@ -42,7 +42,7 @@ from typing import TYPE_CHECKING
from .archived_screen import open_archived
from .contactlist import ContactListScreen, ContactRow
from .menus import icon_lane, marked_label
from .purge_screen import purge_contacts
from .sweep_screen import archive_contacts
from .tui import Choice, Separator
from .tui.screen import CANCEL
from .widgets import ContactsSort, contact_packets
@@ -57,13 +57,13 @@ YOU = ("you",)
#: The contact-list tail sentinels (distinct from any :class:`~meshterm.core.models.Contact`
#: and from :data:`YOU`): the sweep, and the way in to what it has already taken.
_PURGE = ("purge",)
_ARCHIVE = ("archive",)
_ARCHIVED = ("archived",)
#: Every mark the tail's maintenance rows lead with — the set the icon column is measured
#: over (see :func:`~meshterm.ui.menus.icon_lane`). Declared rather than inferred so the
#: column is one width for the whole tail whichever of the two rows a given visit draws.
_TAIL_ICONS = ("🗑", "📂")
_TAIL_ICONS = ("💾", "📂")
class ContactsScreen(ContactListScreen):
@@ -116,20 +116,21 @@ class ContactsScreen(ContactListScreen):
)
)
# The maintenance actions close the list, past every contact whatever the sort: a
# blank spacer, then the err-tinted sweep (a `…` — it opens further prompts), then
# the way in to what the sweep has already taken. The two sit together because they
# are the two halves of one idea, and the archived row is *under* the purge for the
# same reason: it is where the purge's output went.
# One icon column for both rows: 🗑 draws a single cell where 📂 draws two, so an
# unpadded mark would start the purge row's label a column left of the other's.
# blank spacer, then the sweep (a `…` — it opens further prompts), then the way in
# to what the sweep has already taken. The two sit together because they are the
# two halves of one idea, and the archived row is *under* the sweep for the same
# reason: it is where the sweep's output went. The sweep wears the single-contact
# Archive action's 💾 and no red: it archives, and red is kept for data loss.
# One icon column for both rows, measured rather than assumed, so a mark narrower
# than its neighbour can't start its row's label a column left of the other's.
lane = icon_lane(_TAIL_ICONS)
tail: list = []
if contacts:
tail = [
Separator(" "),
Choice(
title=marked_label("🗑", "Purge contacts…", "err", lane=lane),
value=_PURGE,
title=marked_label("💾", "Archive contacts…", "", lane=lane),
value=_ARCHIVE,
),
]
if archived:
@@ -160,7 +161,7 @@ async def open_contacts(
"""Open the interactive contacts list; Enter opens Node detail, Esc leaves.
The list stays pushed for the whole visit, so whichever node's detail page Enter commits
(or the purge flow, or the archived list, that the tail actions open) nests *above* it
(or the archive flow, or the archived list, that the tail actions open) nests *above* it
and Esc from there is one pop back onto the row it was opened from — same sort, same
filter, same scroll. Anything that changes which contacts the device holds — a sweep
here, a single archive, restore or delete on a detail page, or a restore made from
@@ -208,13 +209,13 @@ async def open_contacts(
chosen = await visit.result()
if chosen is CANCEL or chosen is None: # Esc
return
if chosen == _PURGE:
# The purge ladder, its preview and its confirm all float over the list,
if chosen == _ARCHIVE:
# The archive ladder, its preview and its confirm all float over the list,
# which is already the backdrop. A sweep that archived anything
# invalidates the contacts cache, so the list is re-read and rebuilt —
# the one deliberate reset in this screen's life, because the rows it was
# keeping a place in are genuinely gone from the device.
if await purge_contacts(ctx, self_key):
if await archive_contacts(ctx, self_key):
rebuild = True
break
continue
+6 -6
View File
@@ -25,7 +25,7 @@ the node itself, in full:
the rest), ``Time machine``, ``Share contact`` — a popup contact card (QR code +
``meshcore://`` link, see :func:`~meshterm.ui.config_editor.show_contact_card`),
offered whenever the node's full key is known — and, last, ``Remove contact``: the
single-contact counterpart to the Contacts list's bulk purge (see
single-contact counterpart to the Contacts list's bulk archive (see
:mod:`~meshterm.ui.contacts_screen`), dropping *this* node from the device's contact
table behind a red confirm. It is the one thing on the page that changes anything, so
it sits at the foot of the actions, and committing it closes the page — the contact it
@@ -1090,7 +1090,7 @@ async def open_node_detail(
can be archived or deleted, an archived one restored or deleted.
``manage`` is how a caller says the page is being opened to *look*, not to act. The
purge preview passes ``False``: a screen whose whole job is choosing what to archive
archive preview passes ``False``: a screen whose whole job is choosing what to archive
should not also hand out a second, singular way to archive — or a delete — from inside
its own candidate list. The rule is that management verbs belong to the list a contact
actually lives in, never to a page opened out of a list that is about to act on it
@@ -1451,7 +1451,7 @@ async def _archive_contact(ctx: AppContext, contact: Contact, self_key: str, lab
"""Confirm and archive one contact off the device; ``True`` once it is gone from the radio.
The single-contact counterpart to the bulk sweep (see
:func:`~meshterm.ui.purge_screen.purge_contacts`), doing exactly what the sweep does to
:func:`~meshterm.ui.sweep_screen.archive_contacts`), doing exactly what the sweep does to
each of its victims: remove from the device, then record it in the cross-session store
with an archive stamp so nothing is actually lost.
@@ -1515,7 +1515,7 @@ async def _restore_archived(ctx: AppContext, contact: Contact, self_key: str, la
"""Write an archived contact back onto the device; ``True`` once it is live again.
The inverse of the Contacts sweep, one contact at a time (see
:mod:`~meshterm.ui.purge_screen`). The device write comes **first** and the store's
:mod:`~meshterm.ui.sweep_screen`). The device write comes **first** and the store's
archive mark is cleared only once it succeeded — a failed write must never leave a
contact listed as live on a radio that doesn't hold it, which would be a row you cannot
message and cannot restore.
@@ -1560,7 +1560,7 @@ async def _remove_contact(ctx: AppContext, contact: Contact, self_key: str, labe
"""Confirm and drop one contact from the device; ``True`` once it is gone.
The single-contact counterpart to the Contacts list's bulk sweep (see
:func:`~meshterm.ui.purge_screen.purge_contacts`) — but a *deletion* where that one
:func:`~meshterm.ui.sweep_screen.archive_contacts`) — but a *deletion* where that one
archives: this is the way to make MeshTerm forget a node entirely, so it removes the
contact in both places, because the list a screen sees is the *union* of the two: the device's
own contact table, and the contacts MeshTerm remembers for that device (see
@@ -1641,7 +1641,7 @@ async def _remove_contact(ctx: AppContext, contact: Contact, self_key: str, labe
ctx.contact_store.forget(dev_pub, contact.public_key)
# The device's table just changed under the session cache; the list we return to re-reads.
ctx.devstate.invalidate_contacts()
# No success notice: unlike the purge — whose outcome is a count nobody could predict —
# No success notice: unlike the archive sweep — whose outcome is a count nobody could predict —
# this one is self-evident. The page closes on the contact it detailed and the list
# behind it comes back without the row, which says it better than a dialog to dismiss.
return True
@@ -25,7 +25,7 @@ the pushed Contacts list:
candidate list. It ends in the Apply/Back pair that
:func:`~meshterm.ui.menus.exit_rows` draws for staged changes, because that is what this
is: a choice with a cost on both sides, not an exit.
3. **Confirm.** An amber Cancel/Purge dialog, not the red typed gate a deletion gets: this
3. **Confirm.** An amber Cancel/Archive dialog, not the red typed gate a deletion gets: this
is reversible. Every contact keeps its key, its reception history and its transcripts,
the Archived list is one row away on the Contacts screen, and a restore is a single
write. Saving the red for what cannot be undone is what keeps the red meaning anything.
@@ -97,7 +97,7 @@ _DAY = 86400
#: The last-heard rungs, ``(label, min_age_seconds)``. A contact qualifies when its age is
#: *at least* that long; a never-heard contact is excluded here and swept only by
#: :data:`_NEVER`, so an age rung can't quietly take a contact that simply hasn't adverted
#: yet. The same ladder the age-only purge offered before this flow absorbed it.
#: yet. The same ladder the age-only sweep offered before this flow absorbed it.
AGE_RUNGS: tuple[tuple[str, int], ...] = (
("Not heard in 1 week", 7 * _DAY),
("Not heard in 1 month", 30 * _DAY),
@@ -353,12 +353,12 @@ async def _rank(ctx: AppContext, contacts: list[Contact]) -> list[ScoredContact]
if lat and lon:
self_lat, self_lon = float(lat), float(lon)
except Exception as exc: # noqa: BLE001 - an unplaced device just loses one weak term
ctx.log.debug("purge: no self position (%s); distance term stays unknown", exc)
ctx.log.debug("archive: no self position (%s); distance term stays unknown", exc)
return rank_contacts(contacts, signals, self_lat=self_lat, self_lon=self_lon)
async def purge_contacts(ctx: AppContext, self_key: str) -> int:
async def archive_contacts(ctx: AppContext, self_key: str) -> int:
"""Run the whole sweep — rank, pick a target, preview, confirm, archive. Returns the count.
Runs over the pushed Contacts list as its backdrop, so every step floats and Esc walks
@@ -488,7 +488,7 @@ def _target_screen(ranked: list[ScoredContact], sweepable: list[ScoredContact]):
items.append(Choice(title=_rung_row(label, kept, len(victims), label_w), value=value))
held = f" · {protected} protected" if protected else ""
return SelectScreen(
f"Purge contacts — {len(ranked)} known{held}",
f"Archive contacts — {len(ranked)} known{held}",
items,
prompt=(
"Archives the weakest off the device, keeping them here. Ranked on messages, "
@@ -536,7 +536,7 @@ def _preview_screen(victims: list[ScoredContact]):
from .tui import SelectScreen
return SelectScreen(
f"Purge contacts — {_count_desc(len(victims))} to archive",
f"Archive contacts — {_count_desc(len(victims))} to archive",
_preview_items(victims),
prompt="Archived contacts leave the device but stay here, with their history.",
# Neither the scroll nor the keep atom is written into the base hint: the select
@@ -602,7 +602,7 @@ async def _preview(ctx: AppContext, victims: list[ScoredContact]) -> bool:
# The title counts the victims, so it is content too and moves with them.
screen.replace_items(
_preview_items(victims),
title=f"Purge contacts — {_count_desc(len(victims))} to archive",
title=f"Archive contacts — {_count_desc(len(victims))} to archive",
)
continue
# Any other row is a contact: Enter opens its node detail page, so a reader who
@@ -640,8 +640,8 @@ async def _sweep(ctx: AppContext, self_key: str, victims: list[ScoredContact]) -
f"Archive {_count_desc(len(victims))}? They come off this device's contact list, "
"freeing space for new ones. MeshTerm keeps them — with their keys, reception "
"history and messages — under Archived contacts, and any of them can be restored.",
[("Cancel", False), ("Purge", True)],
title="Purge contacts",
[("Cancel", False), ("Archive", True)],
title="Archive contacts",
default=1,
danger=True,
):
@@ -652,7 +652,7 @@ async def _sweep(ctx: AppContext, self_key: str, victims: list[ScoredContact]) -
store = ctx.contact_store
stamp = int(time.time())
archived = failed = 0
with ctx.ui.progress("Purge contacts") as progress:
with ctx.ui.progress("Archive contacts") as progress:
task = progress.add_task("Archiving", total=len(victims))
for scored in victims:
contact = scored.contact
@@ -661,10 +661,10 @@ async def _sweep(ctx: AppContext, self_key: str, victims: list[ScoredContact]) -
except ContactNotOnDeviceError:
# Already absent from the radio: the sweep's device-side work is done, and
# archiving it here is what makes the row go away. Not a failure.
ctx.log.debug("purge: %s was not on the device; archiving ours", contact.name)
ctx.log.debug("archive: %s was not on the device; archiving ours", contact.name)
except Exception as exc: # noqa: BLE001 - one bad removal mustn't abort the sweep
failed += 1
ctx.log.debug("purge: could not remove %s: %s", contact.name, exc)
ctx.log.debug("archive: could not remove %s: %s", contact.name, exc)
continue
finally:
progress.advance(task)
@@ -681,5 +681,5 @@ async def _sweep(ctx: AppContext, self_key: str, victims: list[ScoredContact]) -
)
else:
outcome = Text("no contacts could be archived", style="err")
await ctx.ui.session.message_dialog(outcome, title="Purge contacts")
await ctx.ui.session.message_dialog(outcome, title="Archive contacts")
return archived
+2 -2
View File
@@ -165,7 +165,7 @@ def test_the_row_frames_flush_on_a_terminal_that_draws_the_glyph_wide() -> None:
assert pinned.at(edge) == "│"
def test_every_lane_of_a_purge_preview_row_lands_where_the_screen_measured_it() -> None:
def test_every_lane_of_a_archive_preview_row_lands_where_the_screen_measured_it() -> None:
"""The screen this started on: a swept contact whose name carries an emoji.
The name lane is already fitted in display cells (:func:`~meshterm.ui.menus.fit_cells`),
@@ -174,7 +174,7 @@ def test_every_lane_of_a_purge_preview_row_lands_where_the_screen_measured_it()
"""
from meshterm.core.contact_score import ContactSignals, ScoredContact
from meshterm.core.models import Contact
from meshterm.ui.purge_screen import _NAME_W, _lane_widths, _victim_row
from meshterm.ui.sweep_screen import _NAME_W, _lane_widths, _victim_row
from meshterm.ui.tui.render import render_to_ansi
key = "ab" * 32
+1 -1
View File
@@ -58,7 +58,7 @@ def test_an_unknown_signal_scores_neutral_not_zero() -> None:
location at all, plenty of nodes are never overheard as a relayed packet so carry no hop
count, and a channel poster whose name matches no contact can't be attributed. If any of
those resolved to zero, every unmeasurable contact would sink together and the sweep
would be purging by how much metadata a node happens to broadcast.
would be archiving by how much metadata a node happens to broadcast.
"""
# Two contacts identical in every measured way; one is simply unplaced and unrouted.
ranked = _by_name(
+1 -1
View File
@@ -238,7 +238,7 @@ async def test_devstate_merge_keeps_a_repeater_a_repeater(ctx) -> None:
def test_an_archived_contact_is_remembered_but_kept_out_of_the_merge(tmp_path) -> None: # noqa: ANN001
"""Archiving frees a device slot without losing the contact — and without undoing itself.
The sweep's whole shape (see :mod:`meshterm.ui.purge_screen`): the contact is deleted
The sweep's whole shape (see :mod:`meshterm.ui.sweep_screen`): the contact is deleted
from the *device*, which is the scarce resource, and kept here in full. The subtlety is
the merge — this store's normal job is to union remembered contacts back into any list
the device isn't reporting, which would put an archived contact straight back and make
+28 -28
View File
@@ -311,7 +311,7 @@ def test_contacts_screen_lists_contacts_in_the_shared_lanes() -> None:
import re
from meshterm.core.models import Contact, utcnow
from meshterm.ui.contacts_screen import _PURGE, YOU, ContactsScreen
from meshterm.ui.contacts_screen import _ARCHIVE, YOU, ContactsScreen
from meshterm.ui.tui.screen import CANCEL
contacts = [
@@ -333,8 +333,8 @@ def test_contacts_screen_lists_contacts_in_the_shared_lanes() -> None:
assert f"{7:>5}" in body # Alice's overheard packets, right-aligned in its lane
assert "never" in body # Bob has no last_seen
# Each contact row carries the Contact itself, so Enter hands the whole record on; the
# tail closes the list with the purge action (there is no exit row — Esc leaves).
assert choices[-1].value == _PURGE
# tail closes the list with the archive action (there is no exit row — Esc leaves).
assert choices[-1].value == _ARCHIVE
assert all(isinstance(c.value, Contact) for c in choices[1:-1])
# Plain arrows move the highlight without touching the sort.
@@ -354,7 +354,7 @@ def test_contacts_screen_lists_contacts_in_the_shared_lanes() -> None:
assert resolved == [highlighted, CANCEL]
def test_purge_age_rungs_split_stale_from_never_heard() -> None:
def test_archive_age_rungs_split_stale_from_never_heard() -> None:
"""An age rung takes only contacts once heard and since gone quiet; never-heard has its own.
The ladder's second section is the plain predictable operation a ranking cannot express,
@@ -364,7 +364,7 @@ def test_purge_age_rungs_split_stale_from_never_heard() -> None:
"""
from meshterm.core.contact_score import ContactSignals, ScoredContact
from meshterm.core.models import Contact
from meshterm.ui.purge_screen import _DAY, _NEVER, victims_for
from meshterm.ui.sweep_screen import _DAY, _NEVER, victims_for
def scored(name: str, days, score: float) -> ScoredContact: # noqa: ANN001
contact = Contact(name=name, public_key=f"{ord(name[0]):02x}" * 32)
@@ -396,7 +396,7 @@ def test_purge_age_rungs_split_stale_from_never_heard() -> None:
assert names(("age", 7 * _DAY)) == ["Ancient", "Old"]
async def test_purge_sweeps_under_a_progress_bar_and_reports_in_a_dialog() -> None:
async def test_archive_sweeps_under_a_progress_bar_and_reports_in_a_dialog() -> None:
"""The whole sweep, end to end: ladder → preview → amber confirm → bar → outcome dialog.
Two things the busy overlay could not do (JP, 2026-08-10). It only ever said "working",
@@ -414,8 +414,8 @@ async def test_purge_sweeps_under_a_progress_bar_and_reports_in_a_dialog() -> No
from meshterm.core.contact_score import ContactSignals
from meshterm.core.models import Contact, utcnow
from meshterm.ui.purge_screen import purge_contacts
from meshterm.ui.surface import TuiUi
from meshterm.ui.sweep_screen import archive_contacts
from meshterm.ui.tui.progress import ProgressScreen
from meshterm.ui.tui.session import TuiSession
@@ -459,7 +459,7 @@ async def test_purge_sweeps_under_a_progress_bar_and_reports_in_a_dialog() -> No
ui.dialog = _dialog # type: ignore[method-assign]
ctx = SimpleNamespace(
ui=ui,
log=logging.getLogger("test.purge"),
log=logging.getLogger("test.archive"),
repo=_Repo(),
contact_store=SimpleNamespace(
archive=lambda dev, contact, when: archived.append(contact.name)
@@ -474,12 +474,12 @@ async def test_purge_sweeps_under_a_progress_bar_and_reports_in_a_dialog() -> No
device=lambda: _device(_Device()),
)
task = asyncio.ensure_future(purge_contacts(ctx, "cc" * 32))
task = asyncio.ensure_future(archive_contacts(ctx, "cc" * 32))
# Walk past the five standing rungs into the second section and take "not heard in 1
# year", which catches all three — the standing rungs keep a share, so none of them
# ever takes a whole table.
ladder = await _step_until_screen(session, lambda s: s.title.startswith("Purge contacts"))
ladder = await _step_until_screen(session, lambda s: s.title.startswith("Archive contacts"))
body = _screen_text(ladder)
# A rung is counted in columns, and what it *keeps* — the number that has to fit the
# device — leads what it archives.
@@ -503,12 +503,12 @@ async def test_purge_sweeps_under_a_progress_bar_and_reports_in_a_dialog() -> No
bar = await _step_until_screen(session, lambda s: isinstance(s, ProgressScreen))
assert asked and asked[0]["danger"] is True
assert "destructive" not in asked[0]
assert [label for label, _v in asked[0]["buttons"]] == ["Cancel", "Purge"]
assert [label for label, _v in asked[0]["buttons"]] == ["Cancel", "Archive"]
assert "restored" in asked[0]["prompt"]
# The sweep runs under the progress dialog, which is the frontmost screen for its whole
# lifetime — so the list underneath cannot be walked while it works.
assert bar.title == "Purge contacts"
assert bar.title == "Archive contacts"
assert bar.render_body(60) # a real bar, with a real total to count down
bar.handle("down") # every key is swallowed; nothing underneath moves
@@ -538,8 +538,8 @@ async def test_sparing_a_row_shrinks_the_sweep_without_leaving_the_preview() ->
from meshterm.core.contact_score import ContactSignals, ScoredContact
from meshterm.core.models import Contact
from meshterm.ui.purge_screen import _preview
from meshterm.ui.surface import TuiUi
from meshterm.ui.sweep_screen import _preview
from meshterm.ui.tui.session import TuiSession
def scored(name: str, pct: int) -> ScoredContact:
@@ -601,8 +601,8 @@ async def test_sparing_every_row_leaves_the_sweep_with_nothing_to_do() -> None:
from meshterm.core.contact_score import ContactSignals, ScoredContact
from meshterm.core.models import Contact
from meshterm.ui.purge_screen import _preview
from meshterm.ui.surface import TuiUi
from meshterm.ui.sweep_screen import _preview
from meshterm.ui.tui.session import TuiSession
key = "aa" * 32
@@ -623,7 +623,7 @@ async def test_sparing_every_row_leaves_the_sweep_with_nothing_to_do() -> None:
@pytest.mark.parametrize("platform_name", ["regular", "picocalc"])
def test_the_purge_preview_keeps_its_cursor_in_its_box_walking_both_ways(
def test_the_archive_preview_keeps_its_cursor_in_its_box_walking_both_ways(
platform_name: str,
) -> None:
"""The preview's highlight stays inside its box however far the reader walks, and back.
@@ -639,7 +639,7 @@ def test_the_purge_preview_keeps_its_cursor_in_its_box_walking_both_ways(
from meshterm.core.contact_score import ContactSignals, ScoredContact
from meshterm.core.models import Contact
from meshterm.platforms import PICOCALC, REGULAR, set_platform
from meshterm.ui.purge_screen import _preview_screen
from meshterm.ui.sweep_screen import _preview_screen
from meshterm.ui.tui.frame import compose_dialog
platform = {"regular": REGULAR, "picocalc": PICOCALC}[platform_name]
@@ -963,19 +963,19 @@ async def test_the_list_rebuilds_when_the_detail_page_deletes_the_contact(
await task
def test_contacts_screen_tail_offers_purge_only_when_populated() -> None:
"""A populated list closes with the purge action; an empty one has no tail at all."""
def test_contacts_screen_tail_offers_archive_only_when_populated() -> None:
"""A populated list closes with the archive action; an empty one has no tail at all."""
from meshterm.core.models import Contact
from meshterm.ui.contacts_screen import _PURGE, ContactsScreen
from meshterm.ui.contacts_screen import _ARCHIVE, ContactsScreen
populated = ContactsScreen(
"Us", "cc" * 32, [Contact(name="Alice", public_key="aa" * 32)], 1, {}, _contacts_sort()
)
assert populated._choices()[-1].value == _PURGE
assert populated._choices()[-1].value == _ARCHIVE
empty = ContactsScreen("Us", "cc" * 32, [], 1, {}, _contacts_sort())
values = [c.value for c in empty._choices()]
assert _PURGE not in values # nothing to purge — no action row
assert _ARCHIVE not in values # nothing to archive — no action row
def test_the_archived_row_appears_only_when_something_is_archived() -> None:
@@ -986,7 +986,7 @@ def test_the_archived_row_appears_only_when_something_is_archived() -> None:
own screen is what keeps archiving from being something you lose track of.
"""
from meshterm.core.models import Contact
from meshterm.ui.contacts_screen import _ARCHIVED, _PURGE, ContactsScreen
from meshterm.ui.contacts_screen import _ARCHIVE, _ARCHIVED, ContactsScreen
contacts = [Contact(name="Alice", public_key="aa" * 32)]
@@ -995,16 +995,16 @@ def test_the_archived_row_appears_only_when_something_is_archived() -> None:
some = ContactsScreen("Us", "cc" * 32, contacts, 1, {}, _contacts_sort(), archived=12)
values = [c.value for c in some._choices()]
# Under the purge, because it is where the purge's output went.
assert values[-2:] == [_PURGE, _ARCHIVED]
# Under the archive action, because it is where the sweep's output went.
assert values[-2:] == [_ARCHIVE, _ARCHIVED]
row = next(c for c in some._choices() if c.value == _ARCHIVED)
assert "View archived contacts" in row.label and "12" in row.label
# It stands on its own for a device whose whole table has been swept: there is nothing
# left to purge, but there is very much something to go and look at.
# left to archive, but there is very much something to go and look at.
swept = ContactsScreen("Us", "cc" * 32, [], 1, {}, _contacts_sort(), archived=3)
values = [c.value for c in swept._choices()]
assert _PURGE not in values and values[-1] == _ARCHIVED
assert _ARCHIVE not in values and values[-1] == _ARCHIVED
async def test_archiving_one_contact_takes_it_off_the_device_and_keeps_it() -> None:
@@ -1423,7 +1423,7 @@ async def _channels(device: MockDevice) -> list[dict]:
async def test_the_preview_opens_node_pages_with_no_management_verbs(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Enter on a purge candidate opens its page to *look* at, not to act on.
"""Enter on a archive candidate opens its page to *look* at, not to act on.
The rule (JP, 2026-09-01): management verbs belong to the list a contact actually lives
in, never to a page opened out of a list that is about to act on it wholesale. A preview
@@ -1436,8 +1436,8 @@ async def test_the_preview_opens_node_pages_with_no_management_verbs(
import meshterm.ui.node_detail_screen as nd
from meshterm.core.contact_score import ContactSignals, ScoredContact
from meshterm.core.models import Contact
from meshterm.ui.purge_screen import _preview
from meshterm.ui.surface import TuiUi
from meshterm.ui.sweep_screen import _preview
from meshterm.ui.tui.session import TuiSession
opened: list[dict] = []
+1 -1
View File
@@ -187,7 +187,7 @@ def test_a_direct_message_counts_as_hearing_its_sender() -> None:
A direct message never touches the firmware's ``last_advert`` and is stored as a message
rather than an observation, so a node we actively chat with could read days stale (and be
swept by the purge ladder's quiet rungs) while talking to us. The peer prefix the wire
swept by the archive ladder's quiet rungs) while talking to us. The peer prefix the wire
addressed need not match the contact table's width, so either may be the shorter.
"""
dev = FakeDevice()
+11 -11
View File
@@ -190,7 +190,7 @@ def _contacts(cols: int, rows: int) -> Screen:
return ContactsScreen("Homestead", "cc" * 32, contacts, 1, {"aa" * 6: 7}, sort, archived=12)
def _purge_ranked(): # noqa: ANN201
def _archive_ranked(): # noqa: ANN201
"""A ranked contact table for the sweep's screens — scored by the real function.
Deliberately *not* hand-stamped percentiles. The whole point of the gallery is that a
@@ -228,25 +228,25 @@ def _purge_ranked(): # noqa: ANN201
return rank_contacts(contacts, signals)
def _purge_victims(): # noqa: ANN201
def _archive_victims(): # noqa: ANN201
"""The sweep's victim list: the weakest half of the ranking, weakest first."""
from meshterm.core.contact_score import sweep_candidates
ranked = _purge_ranked()
ranked = _archive_ranked()
return sweep_candidates(ranked, keep=2)
def _purge_ladder(cols: int, rows: int) -> Screen:
from meshterm.ui.purge_screen import _target_screen
def _archive_ladder(cols: int, rows: int) -> Screen:
from meshterm.ui.sweep_screen import _target_screen
ranked = _purge_ranked()
ranked = _archive_ranked()
return _target_screen(ranked, [r for r in ranked if not r.protected])
def _purge_preview(cols: int, rows: int) -> Screen:
from meshterm.ui.purge_screen import _preview_screen
def _archive_preview(cols: int, rows: int) -> Screen:
from meshterm.ui.sweep_screen import _preview_screen
return _preview_screen(_purge_victims())
return _preview_screen(_archive_victims())
def _archived(cols: int, rows: int) -> Screen:
@@ -972,8 +972,8 @@ def _share_qr(cols: int, rows: int) -> Screen:
_ENTRIES: list[_Entry] = [
_Entry("dashboard", _dashboard),
_Entry("contacts", _contacts),
_Entry("purge_ladder", _purge_ladder),
_Entry("purge_preview", _purge_preview),
_Entry("archive_ladder", _archive_ladder),
_Entry("archive_preview", _archive_preview),
_Entry("archived", _archived),
_Entry("node_detail", _node_detail),
_Entry("node_detail_map", _node_detail_map),
+6 -6
View File
@@ -251,12 +251,12 @@ def test_marked_label_lines_up_a_mixed_list_when_told_its_lane() -> None:
lane = icon_lane((_NARROW, _WIDE))
rows = [
marked_label(_NARROW, "Purge contacts…", "err", lane=lane),
marked_label(_NARROW, "Archive contacts…", "err", lane=lane),
marked_label(_WIDE, "View archived contacts", "", lane=lane),
]
starts = {
cell_len(row.plain[: row.plain.index(word)])
for row, word in zip(rows, ("Purge", "View"), strict=True)
for row, word in zip(rows, ("Archive", "View"), strict=True)
}
assert starts == {lane + 1}
@@ -287,10 +287,10 @@ def test_align_icons_starts_every_word_in_the_same_cell() -> None:
"""
plain_row = "Reorder"
labels = align_icons(
[f"{_NARROW} Purge contacts…", Text.assemble((_WIDE, "ok"), " View archived"), plain_row]
[f"{_NARROW} Archive contacts…", Text.assemble((_WIDE, "ok"), " View archived"), plain_row]
)
assert [_plain(label) for label in labels] == [
f"{_NARROW} Purge contacts…",
f"{_NARROW} Archive contacts…",
f"{_WIDE} View archived",
"Reorder",
]
@@ -337,8 +337,8 @@ def test_align_icons_pads_nothing_where_the_platform_draws_no_icons() -> None:
set_platform(PICOCALC)
try:
labels = align_icons([f"{_NARROW} Purge contacts…", f"{_WIDE} View archived"])
assert [_plain(label) for label in labels] == ["Purge contacts…", "View archived"]
labels = align_icons([f"{_NARROW} Archive contacts…", f"{_WIDE} View archived"])
assert [_plain(label) for label in labels] == ["Archive contacts…", "View archived"]
finally:
set_platform(REGULAR)
+1 -1
View File
@@ -189,7 +189,7 @@ def test_select_cursor_line_counts_a_prompt_once() -> None:
The rows are laid out after the prompt's lines, so the index they are recorded at
already counts them. Adding the prompt's height on top reported a line further down,
and the frame kept *that* line in view: walking ↑ then carried the real highlight above
the window's top edge (the purge preview, whose prompt sits over a long list).
the window's top edge (the archive preview, whose prompt sits over a long list).
"""
items = [Choice(f"c{i}", i) for i in range(12)]
screen = SelectScreen("pick", items, prompt="Read the list, then pick one.", default=3)