A shared name goes to the newest key unless two keys used it within a week
Greying every name that two keys ever used was too strict: a reflash with an erase makes a new key, and 34 names on this mesh have one, JP's own among them. Smog went grey this morning because of two keys silent since September. The newest key now gets the name, and the name is grey only when another key used it within name_overlap_days (a new Display preference, "Shared names", default 7) of it: two live claimants. A contact that was never heard never wins. On this machine's real data, LOG.IC 3R goes to its active key 47368a7c, Smog to fa331c83, and 8 of 883 names stay grey. The stored names come with their times from the one scan node_names() already ran (node_names_with_times), so opening a chat costs no second scan. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
28529d6b62
commit
9ae0947717
15 files changed
+224
-52
No files matched your search
+8
-5
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
|
||||
@@ -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``.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
-2
@@ -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)),
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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],
|
||||
|
||||
+67
-10
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user