diff --git a/CHANGELOG.md b/CHANGELOG.md index 550dc7a..4eb937d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/meshterm/core/contact_score.py b/meshterm/core/contact_score.py index 9b3b6b6..d6b53f7 100644 --- a/meshterm/core/contact_score.py +++ b/meshterm/core/contact_score.py @@ -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. diff --git a/meshterm/services/device_state.py b/meshterm/services/device_state.py index 2230bcb..af4ce10 100644 --- a/meshterm/services/device_state.py +++ b/meshterm/services/device_state.py @@ -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`` diff --git a/meshterm/tools/chat.py b/meshterm/tools/chat.py index beaeabf..4cda642 100644 --- a/meshterm/tools/chat.py +++ b/meshterm/tools/chat.py @@ -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. diff --git a/meshterm/ui/archived_screen.py b/meshterm/ui/archived_screen.py index 740fb2d..5e5703c 100644 --- a/meshterm/ui/archived_screen.py +++ b/meshterm/ui/archived_screen.py @@ -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. diff --git a/meshterm/ui/contactlist.py b/meshterm/ui/contactlist.py index 4c68972..eb71ff8 100644 --- a/meshterm/ui/contactlist.py +++ b/meshterm/ui/contactlist.py @@ -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) diff --git a/meshterm/ui/contacts_screen.py b/meshterm/ui/contacts_screen.py index 69376c6..4be588b 100644 --- a/meshterm/ui/contacts_screen.py +++ b/meshterm/ui/contacts_screen.py @@ -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 diff --git a/meshterm/ui/node_detail_screen.py b/meshterm/ui/node_detail_screen.py index 587fe94..e8c1edf 100644 --- a/meshterm/ui/node_detail_screen.py +++ b/meshterm/ui/node_detail_screen.py @@ -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 diff --git a/meshterm/ui/purge_screen.py b/meshterm/ui/sweep_screen.py similarity index 97% rename from meshterm/ui/purge_screen.py rename to meshterm/ui/sweep_screen.py index 1c79a37..606db24 100644 --- a/meshterm/ui/purge_screen.py +++ b/meshterm/ui/sweep_screen.py @@ -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 diff --git a/tests/test_colsnap.py b/tests/test_colsnap.py index 4f3b1e7..4f370c1 100644 --- a/tests/test_colsnap.py +++ b/tests/test_colsnap.py @@ -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 diff --git a/tests/test_contact_score.py b/tests/test_contact_score.py index 1e83b87..7d8eeb9 100644 --- a/tests/test_contact_score.py +++ b/tests/test_contact_score.py @@ -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( diff --git a/tests/test_contact_store.py b/tests/test_contact_store.py index e5b7a7e..6784b05 100644 --- a/tests/test_contact_store.py +++ b/tests/test_contact_store.py @@ -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 diff --git a/tests/test_device_config.py b/tests/test_device_config.py index a8bcdec..ddfb5d5 100644 --- a/tests/test_device_config.py +++ b/tests/test_device_config.py @@ -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] = [] diff --git a/tests/test_device_state.py b/tests/test_device_state.py index 73d7913..df0af6f 100644 --- a/tests/test_device_state.py +++ b/tests/test_device_state.py @@ -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() diff --git a/tests/test_gallery.py b/tests/test_gallery.py index 2071e65..966107d 100644 --- a/tests/test_gallery.py +++ b/tests/test_gallery.py @@ -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), diff --git a/tests/test_menus.py b/tests/test_menus.py index 3bf75f2..66075ed 100644 --- a/tests/test_menus.py +++ b/tests/test_menus.py @@ -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) diff --git a/tests/test_tui.py b/tests/test_tui.py index 65bd290..1be656e 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -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)