mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-27 07:52:03 +00:00
tui2: default profiles online and add phase4 inspector boards
This commit is contained in:
1 parent
d4cbd04242
commit
93cf78b2c8
7 files changed
+447
-23
No files matched your search
@@ -362,3 +362,57 @@ Notes:
|
||||
|
||||
1. This is a layout/chrome scaffold only; no table/board template conversion yet.
|
||||
2. Next slice should implement Phase 2 grid modernization without regressing interaction tests.
|
||||
|
||||
### 2026-03-02 - Phase 2 grid modernization (initial pass) landed
|
||||
|
||||
Implemented in `src/seedpass/tui_v2/app.py`:
|
||||
|
||||
1. Upgraded center section heading to a multi-line table scaffold:
|
||||
- column header row (`Idx`, `Entry`, `Title`, `Kind`, `Meta`, `Arch`)
|
||||
- sort-indicator placeholder row
|
||||
- live page/row counts
|
||||
|
||||
2. Updated entry list row formatter to aligned, dense table-style rows:
|
||||
- fixed-width columns for index/entry/title/kind/meta/archive.
|
||||
- meta column now uses URL or username preview when present.
|
||||
|
||||
3. Kept behavior and command flows unchanged (visual/data-presentational slice only).
|
||||
|
||||
Validation:
|
||||
|
||||
1. `poetry run pytest -q src/tests/test_tui_v2_textual_interactions.py src/tests/test_tui_v2_action_matrix.py`
|
||||
2. Result: `28 passed`.
|
||||
|
||||
Next recommended slice:
|
||||
|
||||
1. Phase 3 inspector board templates for core kinds (`password`, `document/note`, `seed`, `totp`).
|
||||
|
||||
### 2026-03-02 - Phase 3 inspector board templates (core kinds) landed
|
||||
|
||||
Implemented in `src/seedpass/tui_v2/app.py`:
|
||||
|
||||
1. Replaced raw JSON detail rendering for core kinds with structured board layouts:
|
||||
- `password` / `stored_password`
|
||||
- `document` / `note`
|
||||
- `seed` / `managed_account`
|
||||
- `totp`
|
||||
|
||||
2. Added reusable board helpers:
|
||||
- entry kind normalization
|
||||
- common board header (label/kind/id/modified/archived)
|
||||
- tags and notes normalization
|
||||
|
||||
3. Added secure-by-default board copy:
|
||||
- sensitive values remain hidden in board body
|
||||
- reveal/QR actions remain the explicit path for sensitive disclosure
|
||||
|
||||
4. Kept fallback generic JSON board for non-templated kinds to preserve coverage while templates are expanded.
|
||||
|
||||
Validation:
|
||||
|
||||
1. `poetry run pytest -q src/tests/test_tui_v2_textual_interactions.py src/tests/test_tui_v2_action_matrix.py`
|
||||
2. Result: `28 passed`.
|
||||
|
||||
Next recommended slice:
|
||||
|
||||
1. Phase 4 inspector board templates for advanced kinds (`ssh`, `pgp`, `nostr`, `key_value`) and stronger action-strip context hints.
|
||||
@@ -918,3 +918,39 @@
|
||||
- Result: `28 passed`.
|
||||
- Refresh plan updated with explicit phase progress and next step:
|
||||
- `docs/tui_v2_ui_refresh_plan.md` (Phase 2 grid modernization next).
|
||||
|
||||
## 2026-03-02 UI refresh implementation slice: Phase 2 grid modernization (initial pass)
|
||||
- Implemented denser table-style grid presentation in `src/seedpass/tui_v2/app.py`:
|
||||
- `#grid-heading` expanded to show:
|
||||
- column header scaffold
|
||||
- sort-indicator placeholder row
|
||||
- live page/row counters
|
||||
- `_render_entry_label(...)` now outputs aligned fixed-width columns:
|
||||
- `Idx`, `Entry`, `Title`, `Kind`, `Meta`, `Arch`
|
||||
- row `Meta` column uses URL/username preview when present.
|
||||
- No command or service-flow behavior changes; visual presentation only.
|
||||
- Validation:
|
||||
- `poetry run pytest -q src/tests/test_tui_v2_textual_interactions.py src/tests/test_tui_v2_action_matrix.py`
|
||||
- Result: `28 passed`.
|
||||
- Refresh plan updated:
|
||||
- `docs/tui_v2_ui_refresh_plan.md` now records Phase 2 progress and sets Phase 3 (inspector board templates) as next slice.
|
||||
|
||||
## 2026-03-02 UI refresh implementation slice: Phase 3 inspector board templates (core kinds)
|
||||
- Implemented structured inspector-board rendering in `src/seedpass/tui_v2/app.py` for core kinds:
|
||||
- `password` / `stored_password`
|
||||
- `document` / `note`
|
||||
- `seed` / `managed_account`
|
||||
- `totp`
|
||||
- Added reusable board helper methods:
|
||||
- `_entry_kind(...)`
|
||||
- `_entry_tags_text(...)`
|
||||
- `_entry_notes_text(...)`
|
||||
- `_entry_board_header(...)`
|
||||
- `_entry_board_text(...)`
|
||||
- Entry details now default to board format for covered kinds and keep generic JSON fallback for remaining kinds.
|
||||
- Sensitive fields remain hidden in board body; existing reveal/QR flows are still the explicit disclosure path.
|
||||
- Validation:
|
||||
- `poetry run pytest -q src/tests/test_tui_v2_textual_interactions.py src/tests/test_tui_v2_action_matrix.py`
|
||||
- Result: `28 passed`.
|
||||
- Refresh plan updated:
|
||||
- `docs/tui_v2_ui_refresh_plan.md` now records Phase 3 progress and sets Phase 4 advanced-kind board templates as next slice.
|
||||
@@ -70,7 +70,8 @@ class ConfigManager:
|
||||
logger.info("Config file not found; returning defaults")
|
||||
return {
|
||||
"relays": list(DEFAULT_NOSTR_RELAYS),
|
||||
"offline_mode": True,
|
||||
"offline_mode": False,
|
||||
"online_mode_notice_seen": False,
|
||||
"pin_hash": "",
|
||||
"password_hash": "",
|
||||
"inactivity_timeout": INACTIVITY_TIMEOUT,
|
||||
@@ -101,7 +102,8 @@ class ConfigManager:
|
||||
raise ValueError("Config data must be a dictionary")
|
||||
# Ensure defaults for missing keys
|
||||
data.setdefault("relays", list(DEFAULT_NOSTR_RELAYS))
|
||||
data.setdefault("offline_mode", True)
|
||||
data.setdefault("offline_mode", False)
|
||||
data.setdefault("online_mode_notice_seen", False)
|
||||
data.setdefault("pin_hash", "")
|
||||
data.setdefault("password_hash", "")
|
||||
data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT)
|
||||
@@ -277,7 +279,7 @@ class ConfigManager:
|
||||
def get_offline_mode(self) -> bool:
|
||||
"""Retrieve the offline mode setting."""
|
||||
config = self.load_config(require_pin=False)
|
||||
return bool(config.get("offline_mode", True))
|
||||
return bool(config.get("offline_mode", False))
|
||||
|
||||
def set_clipboard_clear_delay(self, delay: int) -> None:
|
||||
"""Persist clipboard clear timeout in seconds."""
|
||||
|
||||
@@ -292,7 +292,7 @@ class PasswordManager:
|
||||
self.secret_mode_enabled: bool = False
|
||||
self.deterministic_totp: bool = False
|
||||
self.clipboard_clear_delay: int = 45
|
||||
self.offline_mode: bool = True
|
||||
self.offline_mode: bool = False
|
||||
self.profile_stack: list[tuple[str, Path, str]] = []
|
||||
self.last_unlock_duration: float | None = None
|
||||
self.verbose_timing: bool = False
|
||||
@@ -1771,13 +1771,28 @@ class PasswordManager:
|
||||
self.manifest_id = None
|
||||
self.delta_since = 0
|
||||
self.nostr_account_idx = 0
|
||||
self.offline_mode = bool(config.get("offline_mode", True))
|
||||
self.offline_mode = bool(config.get("offline_mode", False))
|
||||
self.inactivity_timeout = config.get("inactivity_timeout", INACTIVITY_TIMEOUT)
|
||||
self.secret_mode_enabled = bool(config.get("secret_mode_enabled", False))
|
||||
self.clipboard_clear_delay = int(config.get("clipboard_clear_delay", 45))
|
||||
self.verbose_timing = bool(config.get("verbose_timing", False))
|
||||
if not self.offline_mode:
|
||||
print("Connecting to relays...")
|
||||
if not self.offline_mode and not bool(
|
||||
config.get("online_mode_notice_seen", False)
|
||||
):
|
||||
self.notify(
|
||||
"Profile is ONLINE by default. Switch to offline mode in Settings if needed.",
|
||||
level="INFO",
|
||||
)
|
||||
config["online_mode_notice_seen"] = True
|
||||
try:
|
||||
self.config_manager.save_config(config)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to persist online mode onboarding notice state: %s",
|
||||
exc,
|
||||
)
|
||||
self.nostr_client = NostrClient(
|
||||
encryption_manager=self.encryption_manager,
|
||||
fingerprint=self.current_fingerprint,
|
||||
|
||||
+259
-16
@@ -198,13 +198,14 @@ def launch_tui2(
|
||||
#center { width: 1fr; border: solid #1a3024; padding: 1; background: #0d1114; }
|
||||
#right { width: 1fr; border: solid #1a3024; padding: 1; background: #0d1114; }
|
||||
#grid-heading {
|
||||
height: 1;
|
||||
height: 3;
|
||||
margin-bottom: 1;
|
||||
border: solid #274533;
|
||||
background: #0b0f13;
|
||||
color: #daf2e5;
|
||||
text-style: bold;
|
||||
padding: 0 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
#inspector-heading {
|
||||
height: 1;
|
||||
@@ -359,7 +360,7 @@ def launch_tui2(
|
||||
yield Static("", id="filters")
|
||||
yield Static("", id="activity")
|
||||
with Vertical(id="center"):
|
||||
yield Static("Entry Grid", id="grid-heading")
|
||||
yield Static("", id="grid-heading")
|
||||
yield Input(
|
||||
placeholder="Search entries (Enter to apply)", id="search"
|
||||
)
|
||||
@@ -561,11 +562,23 @@ def launch_tui2(
|
||||
or self._selected_entry.get("type")
|
||||
or ""
|
||||
).strip()
|
||||
context = (
|
||||
f"V:Reveal G:QR A:Archive E:Edit ({kind})"
|
||||
if kind
|
||||
else "V:Reveal G:QR A:Archive E:Edit"
|
||||
)
|
||||
kind_l = kind.lower()
|
||||
if kind_l in {"password", "stored_password", "seed", "managed_account"}:
|
||||
context = "V:Reveal G:QR A:Archive E:Edit"
|
||||
elif kind_l == "totp":
|
||||
context = "6:2FABoard V:Reveal G:QR A:Archive"
|
||||
elif kind_l in {"ssh", "pgp"}:
|
||||
context = "V:Reveal A:Archive DocExport Palette: field/tag/note"
|
||||
elif kind_l == "nostr":
|
||||
context = "V:Reveal G:QR(public/private) A:Archive"
|
||||
elif kind_l in {"document", "note"}:
|
||||
context = "E:EditDoc Ctrl+S:Save A:Archive DocExport"
|
||||
elif kind_l == "key_value":
|
||||
context = "SetField/ClearField A:Archive Note/Tag via palette"
|
||||
else:
|
||||
context = "V:Reveal G:QR A:Archive E:Edit"
|
||||
if kind:
|
||||
context = f"{context} ({kind_l})"
|
||||
text = (
|
||||
"/:Search J:Jump F:Filter H:ArchiveScope L:LinkFilter "
|
||||
"Ctrl+P:Palette X:Retry "
|
||||
@@ -573,6 +586,19 @@ def launch_tui2(
|
||||
)
|
||||
self.query_one("#action-strip", Static).update(text)
|
||||
|
||||
def _update_grid_heading(self) -> None:
|
||||
table_cols = (
|
||||
"Idx Entry Title Kind Meta Arch"
|
||||
)
|
||||
sort_line = (
|
||||
"Sort: index ▲▼ | entry ▲▼ | title ▲▼ | kind ▲▼ "
|
||||
f"Page {self._result_page + 1}/{self._total_pages()} "
|
||||
f"Rows {len(self._entry_ids_in_view)}/{len(self._all_results)}"
|
||||
)
|
||||
self.query_one("#grid-heading", Static).update(
|
||||
f"Entry Grid\n{table_cols}\n{sort_line}"
|
||||
)
|
||||
|
||||
def _quickstart_text(self) -> str:
|
||||
return self._onboarding_text()
|
||||
|
||||
@@ -812,12 +838,29 @@ def launch_tui2(
|
||||
self.query_one("#filters", Static).update(text)
|
||||
self._update_top_ribbon()
|
||||
self._update_action_strip()
|
||||
self._update_grid_heading()
|
||||
|
||||
def _render_entry_label(
|
||||
self, idx: int, label: str, etype: str, archived: bool
|
||||
self,
|
||||
idx: int,
|
||||
label: str,
|
||||
etype: str,
|
||||
archived: bool,
|
||||
username: str | None = None,
|
||||
url: str | None = None,
|
||||
) -> str:
|
||||
arch = " [archived]" if archived else ""
|
||||
return f"{idx:>4} {etype:<15} {label}{arch}"
|
||||
entry_num = idx
|
||||
title = (label or "").replace("\n", " ").strip()[:25]
|
||||
kind = (etype or "unknown")[:14]
|
||||
if url:
|
||||
meta = str(url).replace("\n", " ").strip()
|
||||
elif username:
|
||||
meta = str(username).replace("\n", " ").strip()
|
||||
else:
|
||||
meta = "-"
|
||||
meta = meta[:20]
|
||||
arch = "YES" if archived else "NO"
|
||||
return f"{idx:<8} {entry_num:<6} {title:<25} {kind:<14} {meta:<20} {arch:<4}"
|
||||
|
||||
def _total_pages(self) -> int:
|
||||
_page, _start, _end, total_pages = pagination_window(
|
||||
@@ -948,11 +991,18 @@ def launch_tui2(
|
||||
total, self.RESULT_PAGE_SIZE, self._result_page
|
||||
)
|
||||
page_rows = self._all_results[start:end]
|
||||
for idx, label, _username, _url, archived, etype in page_rows:
|
||||
for idx, label, username, url, archived, etype in page_rows:
|
||||
kind = getattr(etype, "value", str(etype))
|
||||
item = EntryListItem(
|
||||
idx,
|
||||
self._render_entry_label(idx, label, kind, bool(archived)),
|
||||
self._render_entry_label(
|
||||
idx,
|
||||
label,
|
||||
kind,
|
||||
bool(archived),
|
||||
username=username,
|
||||
url=url,
|
||||
),
|
||||
)
|
||||
self._entry_ids_in_view.append(int(idx))
|
||||
list_view.append(item)
|
||||
@@ -970,14 +1020,207 @@ def launch_tui2(
|
||||
payload = truncate_entry_for_display(
|
||||
entry, self.DETAIL_CONTENT_PREVIEW_LIMIT
|
||||
)
|
||||
return self._entry_board_text(payload)
|
||||
|
||||
@staticmethod
|
||||
def _entry_kind(entry: dict[str, Any]) -> str:
|
||||
return str(entry.get("kind") or entry.get("type") or "unknown").strip().lower()
|
||||
|
||||
@staticmethod
|
||||
def _entry_tags_text(entry: dict[str, Any]) -> str:
|
||||
tags = entry.get("tags")
|
||||
if isinstance(tags, list) and tags:
|
||||
return ", ".join(str(tag).strip() for tag in tags if str(tag).strip())
|
||||
if isinstance(tags, str) and tags.strip():
|
||||
return tags.strip()
|
||||
return "(none)"
|
||||
|
||||
@staticmethod
|
||||
def _entry_notes_text(entry: dict[str, Any]) -> str:
|
||||
notes = entry.get("notes")
|
||||
if isinstance(notes, str) and notes.strip():
|
||||
return notes.strip()
|
||||
return "(none)"
|
||||
|
||||
def _entry_board_header(self, entry: dict[str, Any]) -> list[str]:
|
||||
entry_id = (
|
||||
self._selected_entry_id
|
||||
if self._selected_entry_id is not None
|
||||
else payload.get("id", "?")
|
||||
else entry.get("id", "?")
|
||||
)
|
||||
label = str(entry.get("label") or "(untitled)")
|
||||
kind = self._entry_kind(entry) or "unknown"
|
||||
modified = str(
|
||||
entry.get("modified_at")
|
||||
or entry.get("updated_at")
|
||||
or entry.get("date_modified")
|
||||
or "(unknown)"
|
||||
)
|
||||
archived = "Yes" if bool(entry.get("archived", False)) else "No"
|
||||
return [
|
||||
f"{label}",
|
||||
(
|
||||
f"Kind: {kind} | Entry: #{entry_id} | Modified: {modified} | "
|
||||
f"Archived: {archived}"
|
||||
),
|
||||
"-" * 64,
|
||||
]
|
||||
|
||||
def _entry_board_text(self, entry: dict[str, Any]) -> str:
|
||||
kind = self._entry_kind(entry)
|
||||
header = self._entry_board_header(entry)
|
||||
tags_text = self._entry_tags_text(entry)
|
||||
notes_text = self._entry_notes_text(entry)
|
||||
|
||||
if kind in {"password", "stored_password"}:
|
||||
username = str(entry.get("username") or "(none)")
|
||||
url = str(entry.get("url") or "(none)")
|
||||
length = str(entry.get("length") or "(auto)")
|
||||
lines = header + [
|
||||
"Credentials",
|
||||
f"- Password: (hidden) use 'v' to reveal",
|
||||
f"- Username: {username}",
|
||||
f"- URL: {url}",
|
||||
f"- Length: {length}",
|
||||
"",
|
||||
"Tags",
|
||||
f"- {tags_text}",
|
||||
"",
|
||||
"Notes",
|
||||
notes_text,
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
if kind in {"document", "note"}:
|
||||
file_type = str(entry.get("file_type") or "txt")
|
||||
content = str(entry.get("content") or "")
|
||||
lines = header + [
|
||||
"Document",
|
||||
f"- File type: {file_type}",
|
||||
f"- Content length: {len(content)} chars",
|
||||
"",
|
||||
"Content",
|
||||
content if content.strip() else "(empty)",
|
||||
"",
|
||||
"Tags",
|
||||
f"- {tags_text}",
|
||||
"",
|
||||
"Notes",
|
||||
notes_text,
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
if kind in {"seed", "managed_account"}:
|
||||
words = str(entry.get("words") or "(unknown)")
|
||||
index = str(entry.get("index") or "(auto)")
|
||||
lines = header + [
|
||||
"Seed Material",
|
||||
"- Seed phrase: (hidden) use 'v confirm' to reveal",
|
||||
f"- Word count: {words}",
|
||||
f"- Index: {index}",
|
||||
"",
|
||||
"Tags",
|
||||
f"- {tags_text}",
|
||||
"",
|
||||
"Notes",
|
||||
notes_text,
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
if kind == "totp":
|
||||
period = str(entry.get("period") or 30)
|
||||
digits = str(entry.get("digits") or 6)
|
||||
lines = header + [
|
||||
"TOTP",
|
||||
"- Current code: use '6' (2FA board) or 'v' to inspect details",
|
||||
f"- Period: {period}s",
|
||||
f"- Digits: {digits}",
|
||||
"- Secret: (hidden)",
|
||||
"",
|
||||
"Tags",
|
||||
f"- {tags_text}",
|
||||
"",
|
||||
"Notes",
|
||||
notes_text,
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
if kind == "ssh":
|
||||
lines = header + [
|
||||
"SSH Keys",
|
||||
"- Public key: available",
|
||||
"- Private key: (hidden) use 'v confirm' to reveal",
|
||||
"- Export: use palette + document export workflow",
|
||||
"",
|
||||
"Tags",
|
||||
f"- {tags_text}",
|
||||
"",
|
||||
"Notes",
|
||||
notes_text,
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
if kind == "pgp":
|
||||
fingerprint_text = str(entry.get("fingerprint") or "(unknown)")
|
||||
lines = header + [
|
||||
"PGP Keys",
|
||||
f"- Fingerprint: {fingerprint_text}",
|
||||
"- Public key: available",
|
||||
"- Private key: (hidden) use 'v confirm' to reveal",
|
||||
"- Export: use palette + document export workflow",
|
||||
"",
|
||||
"Tags",
|
||||
f"- {tags_text}",
|
||||
"",
|
||||
"Notes",
|
||||
notes_text,
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
if kind == "nostr":
|
||||
npub = str(entry.get("npub") or "(unavailable)")
|
||||
lines = header + [
|
||||
"Nostr Keys",
|
||||
f"- npub: {npub}",
|
||||
"- nsec: (hidden) use 'v confirm' to reveal",
|
||||
"- QR: use 'g' for public, or 'qr private confirm' for nsec",
|
||||
"",
|
||||
"Tags",
|
||||
f"- {tags_text}",
|
||||
"",
|
||||
"Notes",
|
||||
notes_text,
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
if kind == "key_value":
|
||||
key_name = str(entry.get("key") or "(none)")
|
||||
value = str(entry.get("value") or "")
|
||||
value_preview = value[:80] + ("..." if len(value) > 80 else "")
|
||||
lines = header + [
|
||||
"Key/Value",
|
||||
f"- Key: {key_name}",
|
||||
f"- Value: {value_preview if value_preview else '(empty)'}",
|
||||
"",
|
||||
"Tags",
|
||||
f"- {tags_text}",
|
||||
"",
|
||||
"Notes",
|
||||
notes_text,
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
entry_id = (
|
||||
self._selected_entry_id
|
||||
if self._selected_entry_id is not None
|
||||
else entry.get("id", "?")
|
||||
)
|
||||
fallback_kind = entry.get("kind") or entry.get("type") or "unknown"
|
||||
title = f"Entry #{entry_id} [{fallback_kind}]"
|
||||
return (
|
||||
f"{title}\n{'-' * len(title)}\n"
|
||||
f"{json.dumps(entry, indent=2, sort_keys=True)}"
|
||||
)
|
||||
kind = payload.get("kind") or payload.get("type") or "unknown"
|
||||
header = f"Entry #{entry_id} [{kind}]"
|
||||
return f"{header}\n{'-' * len(header)}\n{json.dumps(payload, indent=2, sort_keys=True)}"
|
||||
|
||||
def _load_entries(self, query: str = "", *, reset_page: bool = False) -> None:
|
||||
self._last_query = query
|
||||
|
||||
@@ -5,10 +5,10 @@ from seedpass.core.config_manager import ConfigManager
|
||||
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
|
||||
def test_offline_mode_default_enabled():
|
||||
def test_offline_mode_default_disabled():
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
tmp_path = Path(tmpdir)
|
||||
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
|
||||
cfg_mgr = ConfigManager(vault, tmp_path)
|
||||
config = cfg_mgr.load_config(require_pin=False)
|
||||
assert config["offline_mode"] is True
|
||||
assert config["offline_mode"] is False
|
||||
@@ -0,0 +1,74 @@
|
||||
import queue
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from seedpass.core.manager import PasswordManager
|
||||
|
||||
|
||||
class _DummyConfigManager:
|
||||
def __init__(self, cfg: dict) -> None:
|
||||
self.cfg = dict(cfg)
|
||||
self.saved: list[dict] = []
|
||||
|
||||
def load_config(self) -> dict:
|
||||
return dict(self.cfg)
|
||||
|
||||
def save_config(self, cfg: dict) -> None:
|
||||
snapshot = dict(cfg)
|
||||
self.cfg = snapshot
|
||||
self.saved.append(snapshot)
|
||||
|
||||
|
||||
class _DummyNostrClient:
|
||||
def __init__(self, **kwargs) -> None:
|
||||
self.kwargs = kwargs
|
||||
self.current_manifest_id = None
|
||||
self.current_manifest = None
|
||||
|
||||
|
||||
def _make_pm(cfg: dict) -> PasswordManager:
|
||||
pm = PasswordManager.__new__(PasswordManager)
|
||||
pm.config_manager = _DummyConfigManager(cfg)
|
||||
pm.state_manager = None
|
||||
pm.encryption_manager = object()
|
||||
pm.current_fingerprint = "fp-test"
|
||||
pm.parent_seed = "seed"
|
||||
pm.KEY_INDEX = b"index-key"
|
||||
pm.notifications = queue.Queue()
|
||||
pm._current_notification = None
|
||||
pm._notification_expiry = 0.0
|
||||
return pm
|
||||
|
||||
|
||||
def test_first_online_login_emits_notice_and_persists_seen(monkeypatch):
|
||||
pm = _make_pm({"offline_mode": False, "relays": ["wss://relay.example"]})
|
||||
monkeypatch.setattr("seedpass.core.manager.NostrClient", _DummyNostrClient)
|
||||
monkeypatch.setattr("builtins.print", lambda *a, **k: None)
|
||||
|
||||
pm._initialize_nostr_client()
|
||||
|
||||
note = pm.get_current_notification()
|
||||
assert note is not None
|
||||
assert "ONLINE by default" in note.message
|
||||
assert pm.config_manager.cfg.get("online_mode_notice_seen") is True
|
||||
assert len(pm.config_manager.saved) == 1
|
||||
|
||||
|
||||
def test_online_notice_not_repeated_when_seen(monkeypatch):
|
||||
pm = _make_pm(
|
||||
{
|
||||
"offline_mode": False,
|
||||
"online_mode_notice_seen": True,
|
||||
"relays": ["wss://relay.example"],
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr("seedpass.core.manager.NostrClient", _DummyNostrClient)
|
||||
monkeypatch.setattr("builtins.print", lambda *a, **k: None)
|
||||
|
||||
pm._initialize_nostr_client()
|
||||
|
||||
assert pm.get_current_notification() is None
|
||||
assert pm.config_manager.saved == []
|
||||
Reference in new issue
Block a user