Device-test round 2: 4 in-app fixes + Matrix intake cleanup (v0.1.0:99)
Grant's real-phone testing surfaced seven items; this lands six (the seventh, in-app camera card intake, is planned in docs/handoffs/in-app-card-intake-plan.md). CRM half — ships in the s9pk (v0.1.0:99): - Intake fuzzy match no longer over-indexes on generic firm words. _name_similarity now compares DISTINCTIVE tokens only (generic descriptors — "Investment Group", "Capital", "Family Office" — stripped via _GENERIC_ORG_WORDS) for both the difflib ratio and the Jaccard, so "Fortitude Investment Group" stops surfacing Aether/Russell while "Aether Capital" still surfaces "Aether Investment Group". +2 regression cases. - Mobile grid "Last contact"/staleness sort is reversible. SortSheet gains opt-in dir/onToggleDir; other surfaces (Contacts/Pipeline) are untouched. - Mobile "Edit investor" prefills a contact's saved email. GET /api/fundraising/state heals a blank grid pill email from the linked classic contact (fundraising_contacts.contact_id -> contacts.email), fill-only, by pill order then name; the next one-row save persists it. +test_grid_email_heal.py. - Mobile quick-log pencil icon renders. iOS collapses a sole, centered, attribute-only -sized flex-child <svg>; .quicklog-btn svg now gets explicit CSS width/height + flex:none (the pattern the working bottom-tab/sort-pill icons use). The v97 fix only changed color. Matrix intake bot — ships on the Spark (bot-only, NOT the s9pk): - Approve/reject now redacts the whole intake thread (card + ack + main-timeline nudge + the user's own photo/note), mirroring the email-review room; redact_thread takes the room as an arg and matches replies by m.thread OR m.in_reply_to (so the nudge clears). No more in-Matrix confirmation after a commit (the thread vanishing is the ack). Needs the bot to hold a redact/moderator power level in the intake room. - New one-time backend/matrix_intake/redact_intake.py clears the room's pre-existing backlog (dry-run default; --apply). Tests 42/42 green; frontend render-smoke green. Frontend fixes are inspection + render -smoke verified (on-device confirm pending); the bot redaction is live-smoke only.
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression: GET /api/fundraising/state heals blank grid-pill emails from the relational mirror.
|
||||
|
||||
The grid blob is canonical for the mobile "Edit investor" sheet, but an email can reach a linked
|
||||
classic contact (email capture / a contact edit) without ever being written back into the blob pill
|
||||
— so the edit form showed an empty email for a contact the directory clearly had (Grant, 2026-06-20).
|
||||
The state handler now fills a blank pill email from fundraising_contacts.email, else the linked
|
||||
contacts.email, matched by pill order then name. This asserts:
|
||||
- a blank pill whose linked contact has an email is HEALED on read;
|
||||
- a blank pill whose linked contact is also blank stays blank;
|
||||
- a pill that already carries an email in the blob is NEVER overwritten (fill-only).
|
||||
Synthetic data only.
|
||||
|
||||
Run: cd backend && python3 test_grid_email_heal.py
|
||||
"""
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from http.server import ThreadingHTTPServer
|
||||
|
||||
_DATA = tempfile.mkdtemp()
|
||||
os.environ["CRM_DATA_DIR"] = _DATA
|
||||
os.environ["CRM_DB_PATH"] = os.path.join(_DATA, "crm.db")
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import server # noqa: E402
|
||||
|
||||
FAILS = []
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
print((" PASS " if cond else " FAIL ") + msg)
|
||||
if not cond:
|
||||
FAILS.append(msg)
|
||||
|
||||
|
||||
class _Quiet(server.CRMHandler):
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
def _get_state(port, token):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
|
||||
conn.request("GET", "/api/fundraising/state", headers={"Authorization": "Bearer " + token})
|
||||
resp = conn.getresponse()
|
||||
raw = resp.read().decode("utf-8", "replace")
|
||||
conn.close()
|
||||
return resp.status, (json.loads(raw) if raw else None)
|
||||
|
||||
|
||||
GRID = {
|
||||
"columns": [{"id": "investor_name", "label": "Investor", "type": "text"},
|
||||
{"id": "contacts", "label": "Contacts", "type": "contacts"}],
|
||||
"rows": [
|
||||
{"id": "rowW", "investor_name": "Wyoming", "notes": "",
|
||||
"contacts": [{"name": "Philip Treick", "email": "", "title": ""},
|
||||
{"name": "Jose Briones", "email": "", "title": ""}]},
|
||||
{"id": "rowA", "investor_name": "Acme Capital", "notes": "",
|
||||
"contacts": [{"name": "Jane Doe", "email": "keep@acme.com", "title": ""}]},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def seed():
|
||||
c = sqlite3.connect(os.environ["CRM_DB_PATH"])
|
||||
c.execute("INSERT INTO users (id,username,email,password_hash,full_name,role,is_active) "
|
||||
"VALUES ('u1','grant','grant@ten31.example','x','Grant','admin',1)")
|
||||
c.execute("INSERT INTO fundraising_state (id, grid_json, views_json, version) "
|
||||
"VALUES ('main', ?, '[]', 1) "
|
||||
"ON CONFLICT(id) DO UPDATE SET grid_json = excluded.grid_json", (json.dumps(GRID),))
|
||||
# Classic contacts directory: Jose has the captured email the blob never got; Philip is blank.
|
||||
c.execute("INSERT INTO contacts (id,first_name,last_name,email) VALUES "
|
||||
"('c-phil','Philip','Treick',''),"
|
||||
"('c-jose','Jose','Briones','jbriones@uwyo.edu'),"
|
||||
"('c-jane','Jane','Doe','other@acme.com')") # differs from the blob's keep@acme.com
|
||||
# Relational mirror (what sync_fundraising_relational would build): blank fc.email, linked contact_id.
|
||||
c.execute("INSERT INTO fundraising_investors (id,investor_name,source_row_id,total_invested) VALUES "
|
||||
"('inv-w','Wyoming','rowW',0),('inv-a','Acme Capital','rowA',0)")
|
||||
c.execute("INSERT INTO fundraising_contacts (id,investor_id,full_name,email,sort_order,contact_id) VALUES "
|
||||
"('fc-phil','inv-w','Philip Treick','',0,'c-phil'),"
|
||||
"('fc-jose','inv-w','Jose Briones','',1,'c-jose'),"
|
||||
"('fc-jane','inv-a','Jane Doe','',0,'c-jane')")
|
||||
c.commit()
|
||||
c.close()
|
||||
|
||||
|
||||
def main():
|
||||
server.init_db()
|
||||
seed()
|
||||
token = server.create_token("u1", "grant", "admin")
|
||||
|
||||
httpd = ThreadingHTTPServer(("127.0.0.1", 0), _Quiet)
|
||||
port = httpd.server_address[1]
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
try:
|
||||
st, d = _get_state(port, token)
|
||||
rows = ((d or {}).get("data", {}).get("grid", {}) or {}).get("rows", [])
|
||||
by_id = {r.get("id"): r for r in rows}
|
||||
w = by_id.get("rowW", {})
|
||||
a = by_id.get("rowA", {})
|
||||
wc = w.get("contacts", [])
|
||||
ac = a.get("contacts", [])
|
||||
|
||||
print("\n[heal: blank pill email filled from the linked contact (Jose)]")
|
||||
jose = next((c for c in wc if c.get("name") == "Jose Briones"), {})
|
||||
check(st == 200 and jose.get("email") == "jbriones@uwyo.edu",
|
||||
f"Jose pill healed to jbriones@uwyo.edu (got {jose.get('email')!r})")
|
||||
|
||||
print("\n[heal: blank pill whose contact is also blank stays blank (Philip)]")
|
||||
phil = next((c for c in wc if c.get("name") == "Philip Treick"), {})
|
||||
check(phil.get("email", "") == "",
|
||||
f"Philip pill stays blank (got {phil.get('email')!r})")
|
||||
|
||||
print("\n[heal: a pill that already has an email is never overwritten (Jane)]")
|
||||
jane = next((c for c in ac if c.get("name") == "Jane Doe"), {})
|
||||
check(jane.get("email") == "keep@acme.com",
|
||||
f"Jane pill keeps its blob email, not the contact's (got {jane.get('email')!r})")
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
|
||||
print()
|
||||
if FAILS:
|
||||
print(f"FAILED ({len(FAILS)}):")
|
||||
for f in FAILS:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("ALL PASS (grid email heal)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user