diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cd4d36..0206703 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,11 +32,14 @@ can change how things behave. It does not only add new things. keys hold what only one screen has: `Retry` or `Resend`, and `Log in` on a room. The `Day ↑` and `Day ↓` chips are gone. On the desktop, ^PgUp and ^PgDn still move from one day to the next. -- **A name that two nodes use is grey.** A channel message names its sender only by the - name that the sender typed, and any node can use any name. When two nodes use the same - name, MeshTerm cannot know which one wrote the message. Thus the name is grey, as an - unknown node is, and ^L and ^O do not open anything for it. Before, MeshTerm chose one - of the two nodes, and the name got the colour, the map, and the page of that node. +- **A name that two nodes use at the same time is grey.** A channel message names its + sender only by the name that the sender typed, and any node can use any name. When two + nodes used the same name within a week, MeshTerm cannot know which one wrote the + message. Thus the name is grey, as an unknown node is, and ^L and ^O do not open + anything for it. When the old node has been quiet for longer than that, the name goes + to the node heard most recently, because a radio that is flashed again gets a new + identity. Before, MeshTerm chose one of the two nodes, often the old one. The new + **Shared names** preference on the Preferences page sets the week. - **Chips show where a route starts and where it ends.** Where your terminal draws path chips, the author of a message has a point on the right of its chip. A mention is a chip with a notch on its left. The packet viewer, the live feed, the Messages list, and the diff --git a/CLAUDE.md b/CLAUDE.md index 46f34f2..8ec4883 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -496,9 +496,11 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow node's key (its first byte — any known prefix agrees) so a rename keeps the colour. One rule on **every** platform; only the resolution changes (see **Platforms**). A surface holding only a name resolves it first (`make_name_key_resolver`); a node no - key can place — an unresolved sender, a name two or more keys use (a name is a claim - anyone can make, so MeshTerm never picks between them; JP, 2026-10-11), a bare hash - standing in as a name, an `○` ring — takes `node.unknown`, THE light grey for an unidentified node, everywhere and on both + key can place — an unresolved sender, a name two keys used within `name_overlap_days` + of each other (a name is a claim anyone can make, so MeshTerm never picks between two + live claimants; outside that window the newest key wins, because a reflash makes a new + key routinely; JP, 2026-10-11), a bare hash standing in as a name, an `○` ring — takes + `node.unknown`, THE light grey for an unidentified node, everywhere and on both platforms. Colour is reserved for keyed identities, never seeded from a name's characters. `node.unknown` is deliberately not `muted`: muted is chrome and may sit a step darker, while an unidentified node is content you can still act on. Our own node diff --git a/docs/cli/README.md b/docs/cli/README.md index 193b94f..5deec5a 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -1749,6 +1749,7 @@ full_width auto auto color_depth auto auto How many colours to send this terminal (next launch) console_setup auto auto Move to a better terminal when this console can't draw MeshTerm console_font 6x12 6x12 Bigger text, or more rows on the handheld's panel +name_overlap_days 7 7 Grey a name two nodes used within this many days log_level WARNING WARNING How much MeshTerm writes to its log file (next launch) ``` diff --git a/meshterm/core/models.py b/meshterm/core/models.py index 8fcda00..9a2c784 100644 --- a/meshterm/core/models.py +++ b/meshterm/core/models.py @@ -248,6 +248,16 @@ def utcnow() -> datetime: #: "now" for as long as the false timestamp stays ahead of the wall clock. ADVERT_FUTURE_SKEW_S = 300 +#: How close together (in days) two keys must use one name before MeshTerm calls the name +#: unsure. A name is a label that the owner of a key chooses, and a new key for the same +#: name is usual on a mesh: a reflash with an erase makes a new identity. Thus the key heard +#: most recently gets the name. Only two keys that both used the name within this window +#: make it unsure, because then two nodes claim it at the same time (refer to +#: :func:`~meshterm.services.trace_runner.make_name_key_resolver`). Seven days, from the +#: history of a real mesh (JP, 2026-10-11): it kept the colour of each node that was given +#: a new key, and made grey the names that two active nodes shared. +NAME_OVERLAP_DAYS = 7 + def advert_time(last_advert: object) -> datetime | None: """Change the Unix timestamp of an advert into a plausible UTC datetime, or ``None``. diff --git a/meshterm/core/preferences.py b/meshterm/core/preferences.py index 7d8105b..579e98f 100644 --- a/meshterm/core/preferences.py +++ b/meshterm/core/preferences.py @@ -54,6 +54,7 @@ from .advert_store import DEFAULT_QUIET_S, QUIET_CHOICES_S from .atomicwrite import write_atomically from .courier_store import DONE_CAP from .geo import DEFAULT_VIEW_FRACTION +from .models import NAME_OVERLAP_DAYS from .watch_store import ALERT_CAP, DEFAULT_SILENCE_HOURS, OFF, SILENCE_CHOICES_H if sys.version_info >= (3, 11): @@ -488,6 +489,21 @@ PREFERENCES: tuple[PrefSpec, ...] = ( choices=_CONSOLE_FONT_CHOICES, platforms=frozenset({"picocalc-lyra"}), ), + PrefSpec( + key="name_overlap_days", + label="Shared names", + help="Grey a name two nodes used within this many days", + group="Display", + value_type="int", + # A channel message gives only the name of its sender. When two keys used that + # name within this window, MeshTerm cannot know which one sent it, and the name + # is grey. Outside the window, the key heard most recently gets the name. A larger + # value makes more names grey. 0 always gives the name to the newest key. + default=NAME_OVERLAP_DAYS, + minimum=0, + maximum=3650, + unit="days", + ), # --- Diagnostics --------------------------------------------------------------- PrefSpec( key="log_level", diff --git a/meshterm/persistence/repository.py b/meshterm/persistence/repository.py index 0193293..05c20da 100644 --- a/meshterm/persistence/repository.py +++ b/meshterm/persistence/repository.py @@ -1734,6 +1734,21 @@ class Repository: The latest name that is not empty, keyed by the stored node id (the key prefix of 12 hex digits). """ + return self.node_names_with_times()[0] + + def node_names_with_times(self) -> tuple[dict[str, str], dict[str, datetime]]: + """The latest name of each node (as :meth:`node_names` gives it), and when it used it. + + The time is the latest reception that carried the name, with the time from our + clock. The name resolver needs it to know which of two keys that use one name is + the one in use now (refer to + :func:`~meshterm.services.trace_runner.make_name_key_resolver`). The same scan + gives the two, because a second scan of all the history costs as much again, and + a screen that resolves names opens with it. + + Returns: + ``(names, times)``, each keyed by the stored node id. The times are aware UTC. + """ # The SQLite bare-column-with-MAX guarantee: in a group with ``MAX(observed_at)``, # the ungrouped ``name`` comes from the row that has the maximum. Thus one # aggregate scan gives the latest name of each node, and Python does not walk all @@ -1741,11 +1756,13 @@ class Repository: # ``idx_observations_node`` row by row (one random-access read for each entry, # which is measurably slower than the scan). rows = self._conn.execute( - "SELECT node, name, MAX(observed_at) FROM observations NOT INDEXED " + "SELECT node, name, MAX(observed_at) AS last FROM observations NOT INDEXED " "WHERE node IS NOT NULL AND name IS NOT NULL AND name != '' " "AND kind != 'packet' GROUP BY node" ).fetchall() - return {row["node"]: row["name"] for row in rows} + names = {row["node"]: row["name"] for row in rows} + times = {row["node"]: datetime.fromisoformat(row["last"]) for row in rows} + return names, times def last_heard_by_node(self) -> dict[str, datetime]: """The most recent reception of each node, with the time from our clock. diff --git a/meshterm/services/trace_runner.py b/meshterm/services/trace_runner.py index a0dcb6a..f124be7 100644 --- a/meshterm/services/trace_runner.py +++ b/meshterm/services/trace_runner.py @@ -12,8 +12,10 @@ this module. from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Mapping +from datetime import datetime, timedelta, timezone +from ..core import preferences from ..core.connection import Device from ..core.models import Contact, NameKeyResolver, NodeResolver, TraceResult @@ -182,6 +184,9 @@ def make_key_resolver(contacts: list[Contact] | None) -> NodeResolver: def make_name_key_resolver( contacts: list[Contact] | None, stored_names: dict[str, str] | None = None, + *, + heard: Mapping[str, datetime] | None = None, + overlap_days: int | None = None, ) -> NameKeyResolver: """Build a resolver that finds the key of a display name. @@ -194,48 +199,79 @@ def make_name_key_resolver( no node has resolves to ``None``. The caller renders it in the grey of an unknown node, because colour is only for identities with a key. - **A name that two or more nodes use also resolves to** ``None``. A name is a label that - the owner of a key chooses, and nothing stops a second key from taking it. A channel + **When more than one key uses a name, the key heard most recently gets it**, unless + another of those keys used the name within ``overlap_days`` of it. Then the name + resolves to ``None``. A name is a label that the owner of a key chooses. A new key for + the same name is usual: a reflash with an erase makes a new identity, and an old key + that is silent for weeks is the earlier identity of the same owner. But a channel message carries only the name of its sender, as text that each holder of the channel - secret can write. Thus, when two keys use one name, MeshTerm cannot know which one - sent the message, and a choice between the two is a guess that the hue, the map (^L), - and the node page (^O) then show as a fact. Before, the last contact in the list won. - A second "LOG.IC 3R" with a new key then took the hue and the map of the first, which - nothing proved (JP, 2026-10-11). Two ids are one node when one is a prefix of the - other: the full key of a contact and the stored id of the same node are one claim, not - two. + secret can write. When two keys use the name at the same time, MeshTerm cannot know + which one sent the message, and a choice between the two is a guess that the hue, the + map (^L), and the node page (^O) then show as a fact. + + This rule replaced two others. First, the last contact in the list won: a second + "LOG.IC 3R" with a new key then took the hue and the map of the first. Then each name + that two keys ever used was grey: an old key, silent since September, then made "Smog" + grey in October (JP, 2026-10-11). A key with no time (a contact that was never heard) + never wins against a key with a time. When no key of a name has a time, the name is + unsure. Two ids are one node when one is a prefix of the other: the full key of a + contact and the stored id of the same node are one claim, not two. Args: contacts: The known contacts to resolve against (name → public key/key prefix). + Their ``last_seen`` is when each was last heard. stored_names: The latest advertised name of each stored node id in the recorder (:meth:`~meshterm.persistence.repository.Repository.node_names`). This function inverts it into a fallback from name → node id. + heard: When each stored node id last used its name + (:meth:`~meshterm.persistence.repository.Repository.node_names_with_times`). + overlap_days: The window, in days. ``None`` reads the ``name_overlap_days`` + preference. Returns: A callable that takes a display name and returns the best key hex that we have - for it, or ``None`` when the name matches no known node, or more than one. + for it, or ``None`` when the name matches no known node, or two that use it at + the same time. """ - # The ids of the nodes that use each name, one entry for each node. - claims: dict[str, list[str]] = {} + if overlap_days is None: + overlap_days = preferences.current().name_overlap_days + window = timedelta(days=max(0, overlap_days)) + times = heard or {} + # The nodes that use each name: one [id, last time] entry for each node. + claims: dict[str, list[list]] = {} - def claim(name: str, ident: str, *, canonical: bool) -> None: + def claim(name: str, ident: str, when: datetime | None, *, canonical: bool) -> None: held = claims.setdefault(name.casefold(), []) - for i, other in enumerate(held): - if other.startswith(ident) or ident.startswith(other): + when = _aware(when) + for entry in held: + if entry[0].startswith(ident) or ident.startswith(entry[0]): if canonical: - held[i] = ident # the key of a contact replaces the stored id + entry[0] = ident # the key of a contact replaces the stored id + if when is not None and (entry[1] is None or when > entry[1]): + entry[1] = when return - held.append(ident) + held.append([ident, when]) for node, name in (stored_names or {}).items(): ident = node.lower().removeprefix("0x") if name and ident: - claim(name, ident, canonical=False) + claim(name, ident, times.get(node), canonical=False) for c in contacts or []: ident = (c.public_key or c.key_prefix or "").lower().removeprefix("0x") if c.name and ident: - claim(c.name, ident, canonical=True) - keys = {name: ids[0] for name, ids in claims.items() if len(ids) == 1} + claim(c.name, ident, c.last_seen, canonical=True) + keys: dict[str, str] = {} + for name, held in claims.items(): + if len(held) == 1: + keys[name] = held[0][0] + continue + dated = [entry for entry in held if entry[1] is not None] + if not dated: + continue # nothing tells which key uses the name now + newest = max(when for _ident, when in dated) + live = [ident for ident, when in dated if when >= newest - window] + if len(live) == 1: + keys[name] = live[0] def key_of(name: str) -> str | None: if not name: @@ -245,6 +281,13 @@ def make_name_key_resolver( return key_of +def _aware(when: datetime | None) -> datetime | None: + """``when`` as an aware time. A time with no zone is UTC, as MeshTerm stores each time.""" + if when is None or when.tzinfo is not None: + return when + return when.replace(tzinfo=timezone.utc) + + def parse_trace_path(spec: str, contacts: list[Contact] | None = None) -> str: """Parse a path spec from the user into the hex path string that ``send_trace`` expects. diff --git a/meshterm/tools/chat.py b/meshterm/tools/chat.py index 8af12a3..9b54572 100644 --- a/meshterm/tools/chat.py +++ b/meshterm/tools/chat.py @@ -227,8 +227,8 @@ class ChatTool(Tool): # app-wide colour rule). A name that no contact and no stored advert has stays # muted. A room post names its author by key, which resolves in the other # direction. - stored_names = ctx.repo.node_names() - key_of = make_name_key_resolver(contacts, stored_names) + stored_names, named_at = ctx.repo.node_names_with_times() + key_of = make_name_key_resolver(contacts, stored_names, heard=named_at) name_of = make_node_resolver(contacts, stored_names) # List the contacts by recency: first the contacts with messages, with the newest diff --git a/meshterm/ui/chat.py b/meshterm/ui/chat.py index b0a2009..c466e91 100644 --- a/meshterm/ui/chat.py +++ b/meshterm/ui/chat.py @@ -1468,7 +1468,8 @@ async def open_chat(ctx: AppContext, conversation: Conversation) -> int: ) contacts = await ctx.devstate.contacts() names = _contact_names(contacts) - key_of = trace_runner.make_name_key_resolver(contacts, ctx.repo.node_names()) + stored_names, named_at = ctx.repo.node_names_with_times() + key_of = trace_runner.make_name_key_resolver(contacts, stored_names, heard=named_at) async def send(text: str) -> ChatMessage | None: if conversation.is_channel: @@ -1727,10 +1728,10 @@ async def _make_paths_presenter( session = ctx.ui.session contacts = await ctx.devstate.contacts() - stored_names = ctx.repo.node_names() + stored_names, named_at = ctx.repo.node_names_with_times() resolve = trace_runner.make_node_resolver(contacts, stored_names) type_of = trace_runner.make_node_type_resolver(contacts) - key_of = trace_runner.make_name_key_resolver(contacts, stored_names) + key_of = trace_runner.make_name_key_resolver(contacts, stored_names, heard=named_at) prefix_bytes = await ctx.devstate.routing_prefix_bytes() self_name: str | None = None self_key: str | None = None diff --git a/meshterm/ui/livefeed_screen.py b/meshterm/ui/livefeed_screen.py index 7135b78..682e490 100644 --- a/meshterm/ui/livefeed_screen.py +++ b/meshterm/ui/livefeed_screen.py @@ -1022,10 +1022,10 @@ async def open_livefeed(ctx: AppContext) -> None: # The contacts first, then each name that the recorder overheard, as the fallback. # This is the rule of all of the app: a node that we can name never renders as a bare # hash. - stored_names = ctx.repo.node_names() + stored_names, named_at = ctx.repo.node_names_with_times() resolve = trace_runner.make_node_resolver(contacts, stored_names) type_of = trace_runner.make_node_type_resolver(contacts) - key_of = trace_runner.make_name_key_resolver(contacts, stored_names) + key_of = trace_runner.make_name_key_resolver(contacts, stored_names, heard=named_at) prefix_bytes = await ctx.devstate.routing_prefix_bytes() # The cap is the only limit: the newest _FEED_CAP packets that were ever stored, also diff --git a/meshterm/ui/node_detail_screen.py b/meshterm/ui/node_detail_screen.py index bcd0580..8f57666 100644 --- a/meshterm/ui/node_detail_screen.py +++ b/meshterm/ui/node_detail_screen.py @@ -1406,7 +1406,7 @@ async def open_node_detail( except Exception: # noqa: BLE001 - optional reads. Safe defaults keep the page working. prefix_bytes, width_bytes = 0, 1 - stored_names = ctx.repo.node_names() + stored_names, named_at = ctx.repo.node_names_with_times() # One resolver from hash to name for the full page. The route list and the route graph # all name their hops through it (the contacts first, then the history of the recorder). resolve = make_node_resolver(contacts, stored_names) @@ -1559,7 +1559,7 @@ async def open_node_detail( width_bytes, resolve=resolve, type_of=make_node_type_resolver(contacts), - key_of=make_name_key_resolver(contacts, stored_names), + key_of=make_name_key_resolver(contacts, stored_names, heard=named_at), style=route_graph_style, self_name=self_name, node_label=label, diff --git a/meshterm/ui/room.py b/meshterm/ui/room.py index 94f3bf9..6bb5c8a 100644 --- a/meshterm/ui/room.py +++ b/meshterm/ui/room.py @@ -404,7 +404,7 @@ async def open_room(ctx: AppContext, conversation: Conversation) -> int: posts_only=True, ) contacts = await ctx.devstate.contacts() - stored_names = ctx.repo.node_names() + stored_names, named_at = ctx.repo.node_names_with_times() async def send(text: str) -> ChatMessage | None: return await _with_restore(ctx, lambda: ctx.chat.send_post(room, text)) @@ -438,7 +438,7 @@ async def open_room(ctx: AppContext, conversation: Conversation) -> int: access=ctx.rooms.access(room), resend=resend, paths=await _make_paths_presenter(ctx, conversation, device), - key_of=trace_runner.make_name_key_resolver(contacts, stored_names), + key_of=trace_runner.make_name_key_resolver(contacts, stored_names, heard=named_at), locator=await Locator.gather(ctx), pages=ContactPages(ctx, list(contacts)), ) diff --git a/tests/test_gallery.py b/tests/test_gallery.py index 299b241..04a6ed7 100644 --- a/tests/test_gallery.py +++ b/tests/test_gallery.py @@ -795,6 +795,9 @@ class _PickerRepo: def node_names(self) -> dict: return {} + def node_names_with_times(self) -> tuple[dict, dict]: + return {}, {} + def _chat_picker(cols: int, rows: int) -> Screen: """The conversation picker, built through the tool's own row funnel. diff --git a/tests/test_repository.py b/tests/test_repository.py index aff75e1..4ea4202 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -20,6 +20,25 @@ def repo(tmp_path: Path) -> Repository: r.close() +def test_node_names_with_times_gives_each_latest_name_and_when_it_was_heard( + repo: Repository, +) -> None: + """One scan gives the names and their times. ``node_names`` is the first half of it.""" + from meshterm.core.models import Observation + + run = repo.start_run("monitor", {}) + early = datetime(2026, 9, 20, 12, 0, tzinfo=timezone.utc) + late = datetime(2026, 10, 10, 12, 0, tzinfo=timezone.utc) + repo.record_observation(run, Observation(node="aa11bb22cc33", name="Smog", observed_at=early)) + repo.record_observation(run, Observation(node="aa11bb22cc33", name="Smog 2", observed_at=late)) + repo.record_observation(run, Observation(node="dd44ee55ff66", name="Bob", observed_at=early)) + + names, times = repo.node_names_with_times() + assert names == {"aa11bb22cc33": "Smog 2", "dd44ee55ff66": "Bob"} + assert times == {"aa11bb22cc33": late, "dd44ee55ff66": early} + assert repo.node_names() == names + + def _make_trace( target: str, *hops: tuple[str | None, float], diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 312407a..c549ceb 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -175,25 +175,82 @@ def test_make_name_key_resolver_contacts_win_over_stored_names() -> None: assert key_of("Zed") is None # no node has the name -def test_a_name_that_two_keys_use_resolves_to_no_key() -> None: - """Two nodes with one name: MeshTerm cannot know which one a name means, so it is grey. +def test_a_name_that_two_keys_use_at_the_same_time_resolves_to_no_key() -> None: + """Two nodes that used one name within the window: MeshTerm cannot tell them apart. A channel message carries only the name of its sender. A second key that takes a name - must not get the hue, the map, and the page of the first (JP, 2026-10-11). + while the first is still in use must not get the hue, the map, and the page of either. """ + from datetime import datetime, timedelta, timezone + from meshterm.core.models import Contact from meshterm.services.trace_runner import make_name_key_resolver - old = Contact(name="LOG.IC 3R", public_key="5d" + "0" * 62) - new = Contact(name="LOG.IC 3R", public_key="47" + "0" * 62) - assert make_name_key_resolver([old, new])("LOG.IC 3R") is None - assert make_name_key_resolver([new, old])("log.ic 3r") is None # the order does not matter + now = datetime(2026, 10, 11, 12, 0, tzinfo=timezone.utc) + old = Contact(name="LOG.IC 3R", public_key="5d" + "0" * 62, last_seen=now - timedelta(days=3)) + new = Contact(name="LOG.IC 3R", public_key="47" + "0" * 62, last_seen=now) + assert make_name_key_resolver([old, new], overlap_days=7)("LOG.IC 3R") is None + assert make_name_key_resolver([new, old], overlap_days=7)("log.ic 3r") is None # any order # A stranger that we only heard is a claim too, and it makes the name of a contact unsure. + alice = Contact(name="Alice", public_key="d4" + "0" * 62, last_seen=now) stranger = {"77ee00112233": "Alice"} - alice = Contact(name="Alice", public_key="d4" + "0" * 62) - assert make_name_key_resolver([alice], stranger)("Alice") is None - assert make_name_key_resolver([alice])("Alice") == "d4" + "0" * 62 + heard = {"77ee00112233": now - timedelta(hours=1)} + assert make_name_key_resolver([alice], stranger, heard=heard, overlap_days=7)("Alice") is None + assert make_name_key_resolver([alice], overlap_days=7)("Alice") == "d4" + "0" * 62 + + +def test_a_name_goes_to_the_newest_key_when_the_old_one_went_quiet() -> None: + """A new key for a name is usual: a reflash makes a new identity (JP, 2026-10-11). + + An old key that has been silent for longer than the window is the earlier identity of + the owner. It does not make the name unsure, and the newest key gets the name. + """ + from datetime import datetime, timedelta, timezone + + from meshterm.core.models import Contact + from meshterm.services.trace_runner import make_name_key_resolver + + now = datetime(2026, 10, 11, 12, 0, tzinfo=timezone.utc) + smog = Contact(name="Smog", public_key="fa" + "0" * 62, last_seen=now - timedelta(days=1)) + september = {"def5e1786fff": "Smog", "caae7a09503d": "Smog"} + heard = {"def5e1786fff": now - timedelta(days=21), "caae7a09503d": now - timedelta(days=19)} + key_of = make_name_key_resolver([smog], september, heard=heard, overlap_days=7) + assert key_of("Smog") == "fa" + "0" * 62 + + # A wider window reaches the old keys again, and the name is unsure. + wide = make_name_key_resolver([smog], september, heard=heard, overlap_days=30) + assert wide("Smog") is None + + # The window is measured from the newest key, whichever list it is in. + renamed = make_name_key_resolver( + [smog], {"0123456789ab": "Smog"}, heard={"0123456789ab": now}, overlap_days=0 + ) + assert renamed("Smog") == "0123456789ab" + + +def test_a_key_with_no_time_never_wins_and_no_times_at_all_is_unsure() -> None: + """A contact that was never heard is not in use. With no time at all, nothing decides.""" + from datetime import datetime, timezone + + from meshterm.core.models import Contact + from meshterm.services.trace_runner import make_name_key_resolver + + heard = Contact( + name="Bob", public_key="b0" + "0" * 62, last_seen=datetime(2026, 10, 1, tzinfo=timezone.utc) + ) + never = Contact(name="Bob", public_key="c0" + "0" * 62) + assert make_name_key_resolver([never, heard], overlap_days=7)("Bob") == "b0" + "0" * 62 + other = Contact(name="Bob", public_key="d0" + "0" * 62) + assert make_name_key_resolver([never, other], overlap_days=7)("Bob") is None + + +def test_the_window_is_the_name_overlap_preference() -> None: + """With no window given, the resolver reads the preference: a value that a user can change.""" + from meshterm.core.models import NAME_OVERLAP_DAYS + from meshterm.core.preferences import current + + assert current().name_overlap_days == NAME_OVERLAP_DAYS == 7 def test_name_style_without_a_key_is_the_unknown_grey() -> None: