"""Uji verifikasi dashboard (oracle). `./venv/bin/python test_dashboard.py` harus exit 0. Memakai DB sementara, bukan voters.db asli (read-only invariant tetap diverifikasi). """ import os import re import sqlite3 import tempfile from datetime import datetime, timedelta, timezone import requests import dashboard import db as dbm def check(tag, cond): if not cond: raise AssertionError(f'GAGAL: {tag}') print(f'ok: {tag}') def make_db(path, rows): conn = sqlite3.connect(path) conn.execute('CREATE TABLE voters (' 'owner TEXT PRIMARY KEY, weight TEXT NOT NULL, ' 'staked REAL NOT NULL, scanned_at TEXT NOT NULL, ' 'last_vote TEXT)') conn.executemany( 'INSERT INTO voters (owner, weight, staked, scanned_at, last_vote) ' 'VALUES (?, ?, ?, ?, ?)', rows, ) conn.commit() conn.close() def main(): tmp = tempfile.mkdtemp() db = os.path.join(tmp, 'voters.db') rows = [] now = datetime.now(timezone.utc).replace(tzinfo=None) def _iso(days_ago): return (now - timedelta(days=days_ago)).isoformat(timespec='seconds') fresh = _iso(5) for i in range(1, 126): # 125 baris valid -> 3 halaman owner = f'acct{i:03d}' rows.append((owner, f'{i}.00000000000000000', float(i) * 10.0, '2026-08-04T00:00:00', fresh)) rows[5] = (rows[5][0], rows[5][1], 5.0 * 10.0, rows[5][3], fresh) # non-monotonic guard rows.append(('zzzold', '999.00000000000000000', 2.0, '2026-08-04T00:00:00', _iso(30))) # V40: kadaluarsa (28-31 hr) rows.append(('zzhide', '888.00000000000000000', 1.0, '2026-08-04T00:00:00', _iso(60))) # V40: tersembunyi (>31 hr) rows.append(('newacct1', '11.00000000000000000', 1300.0, '2026-08-04T00:00:00', _iso(1))) # V41: BARU (first-seen baru) rows.append(('revoter1', '12.00000000000000000', 1200.0, '2026-08-04T00:00:00', _iso(0))) # V41/V43: REVOTE < 1 hari (first-seen lama) rows.append(('expire1', '13.00000000000000000', 1290.0, '2026-08-04T00:00:00', _iso(26))) # V43: VALID matang + VOTE ULANG SEGERA (25-28 hr) make_db(db, rows) # V41: first_seen — newacct1 pertama kali muncul baru-baru ini → BARU; # revoter1 sudah pernah terlihat lama → REVOTE. acct001 juga ber-first_seen # (baru) — flag revote harus ⊖ True padanya (guard regresi V42: tag hanya # utk first_seen lama, ⊥ sekedar first_seen ada). expire1 tanpa first_seen # → pemilih baru yang matang (V43: badge peringatan tetap tampil padanya). conn = sqlite3.connect(db) conn.execute('CREATE TABLE IF NOT EXISTS voter_first_seen ' '(owner TEXT PRIMARY KEY, first_seen_at TEXT NOT NULL)') conn.executemany( 'INSERT OR REPLACE INTO voter_first_seen (owner, first_seen_at) ' 'VALUES (?, ?)', [('newacct1', _iso(1)), ('revoter1', _iso(60)), ('acct001', _iso(1))], ) conn.commit() conn.close() dbm.DB_PATH = db # arahkan app ke DB uji (read-only tetap dipertahankan) real_get_liquid = dashboard._get_liquid_vex dashboard._get_liquid_vex = lambda: 50691.4676 # suite utama ⊥ jaringan nyata # ————— V27: seed riwayat distribusi utk uji /history ————— # run lama di-seed duluan agar run_id-nya lebih kecil (⊥ run terbaru). today = datetime.now().strftime('%Y-%m-%d') yesterday = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d') dbm.ensure_distribute_schema() old_rid = dbm.record_run(yesterday, 50000.0, 1, 1000.0, 80.0, 'ok', '2026-08-04T10:00:00') dbm.record_payment(old_rid, 'oldacct', 40.0, 'sent', '2026-08-04T10:00:01', txid='txold999') rid = dbm.record_run(today, 52186.0417, 1, 1200.0, 100.0, 'ok', '2026-08-05T10:00:00') dbm.record_payment(rid, 'acct001', 50.0, 'sent', '2026-08-05T10:00:01', txid='txabc123') client = dashboard.app.test_client() # V10: judul halaman & kolom list (bobot suara tak ditampilkan, vote segar ada) resp = client.get('/?page=1') check('V10 halaman 1 → 200', resp.status_code == 200) html = resp.get_data(as_text=True) check('V10 judul halaman', 'DAFTAR PEMILIH' in html) check('V13 kolom VOTE TERAKHIR', 'VOTE TERAKHIR' in html) check('V15 kolom BOBOT SUARA tak tampil', 'BOBOT SUARA' not in html) check('V15 tak ada sel .weight', 'class="weight"' not in html) check('V15 tak ada penanda basi', 'stale-mark' not in html and 'BASI' not in html) check('V16 4 sel stats (3 pemilih + saldo liquid)', html.count('spec-cell') == 4) check('V16 sel SALDO LIQUID', 'SALDO LIQUID' in html) check('V16 SALDO LIQUID paling kiri (sblm TOTAL PEMILIH)', html.index('SALDO LIQUID') < html.index('TOTAL PEMILIH')) check('V16 nilai saldo diformat id-ID', '50.691,4676' in html) check('V40 TOTAL PEMILIH = baris tampil (129)', '129' in html) # ————— V40/V41: band umur — BARU / REVOTE / daftar utama (kadaluarsa menyatu) ————— check('V40 KADALUARSA menyatu dgn daftar utama (bukan bagian sendiri)', 'expired-list' not in html) check('V40 baris kadaluarsa ber-sorot .expired', 'class="list-row expired"' in html) check('V40 badge VOTE ULANG', 'VOTE ULANG' in html) check('V40 legenda amber di daftar utama', 'BARIS AMBER = VOTE LEWAT' in html and 'SUARA KADALUARSA' in html) check('V40 pemilih kadaluarsa tampil', 'zzzold' in html) check('V40 kadaluarsa & valid satu daftar (#rows)', 'zzzold' in _slice_rows(html) and 'acct125' in _slice_rows(html)) check('V40 pemilih tersembunyi (>31 hr) tak tampil', 'zzhide' not in html) check('V42 REVOTE ⊖ bagian sendiri (menyatu dgn VALID)', 'PEMILIH REVOTE · BELUM MATANG' not in html) check('V41 caption BARU count', 'PEMILIH BARU · BELUM MATANG (0-3 HARI) · 1' in html) check('V42 revoter1 (REVOTE) ada di daftar utama (#rows)', 'revoter1' in _slice_rows(html)) check('V42 newacct1 di BARU, revoter1 di VALID', 'newacct1' in html and 'revoter1' in html) check('V42 tag REVOTE pada baris valid', 'tag-revote">REVOTE' in html) check('V42 hanya 1 tag REVOTE di daftar utama hal.1 (revoter1)', _slice_rows(html).count('tag-revote') == 1) check('V43 badge VOTE ULANG SEGERA pada baris valid (expire1)', 'warn-badge">VOTE ULANG SEGERA' in _slice_rows(html)) check('V43 legenda mint REVOTE 1-hari', 'TAG MINT = REVOTE KURANG DARI 1 HARI' in html) check('V43 legenda amber peringatan', 'TAG AMBER = KADALUARSA DALAM 3 HARI' in html and '1 PEMILIH' in html) staked_vals = [] for i in range(1, 4): resp = client.get(f'/?page={i}') check(f'V10 halaman {i} → 200', resp.status_code == 200) staked_vals += _extract_stakes(resp.get_data(as_text=True)) check('V42 total 127 baris valid ditarik (125 acct + revoter1 + expire1)', len(staked_vals) == 127) check('V10 urut non-naik', all(staked_vals[i] >= staked_vals[i + 1] for i in range(len(staked_vals) - 1))) # ————— V29: kolom TOTAL REWARD (Σ transfer sent per voter, 0 bila tanpa) ————— resp = client.get('/?page=1') html = resp.get_data(as_text=True) check('V29 kolom TOTAL REWARD (VEX)', 'TOTAL REWARD (VEX)' in html) check('V29 reward 0 utk tanpa transfer', '0,0000' in html) resp = client.get('/?page=3') html = resp.get_data(as_text=True) check('V29 reward acct001 = total sent (50,0000)', '50,0000' in html) check('V42 acct001 (first_seen baru) ⊖ tag REVOTE di hal.3', 'tag-revote">REVOTE' not in _slice_rows(html)) # ————— V38: banner BERITA jadwal distribusi berikutnya ————— dashboard._next_schedule = lambda now=None: datetime(2026, 8, 17, 10, 0) resp = client.get('/') html = resp.get_data(as_text=True) check('V38 tag BERITA', 'BERITA' in html) check('V38 judul DISTRIBUSI BERIKUTNYA', 'DISTRIBUSI BERIKUTNYA' in html) check('V38 tanggal id-ID (Senin 17 Agt)', 'SENIN · 17 AGUSTUS 2026 · 10:00' in html) check('V38 baris berulang TAK tampil saat first-run (17 Agt ≠ Sabtu)', 'SETIAP SABTU · 10:00' not in html) check('V38 _is_first_run_next sebelum launch = True', dashboard._is_first_run_next(datetime(2026, 8, 6)) is True) check('V38 _is_first_run_next setelah launch = False', dashboard._is_first_run_next(datetime(2026, 8, 18)) is False) dashboard._next_schedule = lambda now=None: datetime(2026, 8, 22, 10, 0) html = client.get('/').get_data(as_text=True) check('V38 baris berulang tampil utk putaran mingguan', 'SETIAP SABTU · 10:00' in html) # drift-guard: logika boundary mirror distribute_loop (V31 ⊥ impor) import distribute_loop drift_dts = [datetime(2026, 8, 6, 9, 0), datetime(2026, 8, 17, 11, 0), datetime(2026, 8, 22, 9, 0), datetime(2026, 8, 22, 11, 0), datetime(2026, 8, 15, 9, 0)] fr = dashboard._parse_first_run('2026-08-17') check('V38 drift-guard one-off = distribute_loop', all(dashboard._next_boundary(n, 10, 5, fr) == distribute_loop.next_boundary(n, 10, 5, fr) for n in drift_dts)) check('V38 drift-guard mingguan = distribute_loop', all(dashboard._next_boundary(n, 10, 5) == distribute_loop.next_boundary(n, 10, 5) for n in drift_dts)) # halaman melebihi total → dibatasi (tidak error) resp = client.get('/?page=99') check('V10 halaman ekstra → 200', resp.status_code == 200) # V10: read-only — tidak ada tabel baru / perubahan pada DB asli before = _table_hashes(db) client.get('/') client.get('/history') check('V10 read-only (DB tak berubah)', _table_hashes(db) == before) # ————— V27: /history — riwayat distribusi (read-only) ————— resp = client.get('/history') check('V27 /history → 200', resp.status_code == 200) html = resp.get_data(as_text=True) check('V27 judul RIWAYAT DISTRIBUSI', 'RIWAYAT DISTRIBUSI' in html) check('V27 run date tampil', today in html) check('V27 tanggal di judul TRANSFER PER PEMILIH', f'TRANSFER PER PEMILIH · {today}' in html) check('V27 transfer per-pemilih tampil', 'acct001' in html) check('V27 hanya run terbaru (transfer lama tak tampil)', 'oldacct' not in html) check('V27 status SENT tampil', 'SENT' in html) check('V27 memo tak dirender', f'DATABISNISID PROFIT SHARE {today}' not in html) check('V27 txid tercatat', 'txabc123' in html) check('V27 txid jadi link explorer', 'href="https://vexascan.com/transaction/txabc123"' in html) # ————— V11: search server-side ?q= (substring, case-insensitive) ————— resp = client.get('/?q=acct12') check('V11 search ?q= → 200', resp.status_code == 200) html = resp.get_data(as_text=True) check('V11 hanya akun cocok', 'acct125' in html and 'acct120' in html and 'acct119' not in html) check('V11 caption HASIL · 6', 'HASIL · 6 AKUN' in html) q_vals = _extract_stakes(html) check('V11 hasil search urut DESC', q_vals == sorted(q_vals, reverse=True) and len(q_vals) == 6) # mode search = daftar datar ber-tag; input mempertahankan q check('V40 search input mempertahankan q', 'value="acct12"' in html) # halaman ekstra saat filter → dipatok ke total_pages filter resp = client.get('/?q=acct12&page=99') check('V11 clamp halaman saat search', resp.status_code == 200) # V43: badge peringatan juga tampil di mode search tanpa-JS (?q=) resp = client.get('/?q=expire1') html = resp.get_data(as_text=True) check('V43 search ?q= menampilkan badge VOTE ULANG SEGERA', 'warn-badge">VOTE ULANG SEGERA' in html and 'expire1' in _slice_rows(html)) # search tanpa hasil → status kosong resp = client.get('/?q=zzzz') html = resp.get_data(as_text=True) check('V11 tak ada hasil → pesan kosong', 'TAK ADA AKUN COCOK' in html) # ————— V11: /api/search (JSON, rank global via ROW_NUMBER) ————— resp = client.get('/api/search?q=acct12') data = resp.get_json() check('V11 /api/search shape', set(data.keys()) == {'query', 'count', 'cap', 'results'}) check('V11 /api/search count', data['count'] == 6) owners = [r['owner'] for r in data['results']] ranks = [r['rank'] for r in data['results']] check('V11 /api/search cocok substring', all('acct12' in o for o in owners)) check('V11 /api/search rank global', ranks == sorted(ranks) and ranks == list(range(1, 7))) check('V11 /api/search rank sejajar staked tertinggi', [r['owner'] for r in data['results']] == ['acct125', 'acct124', 'acct123', 'acct122', 'acct121', 'acct120']) check('V11 /api/search urut staked DESC', [r['staked'] for r in data['results']] == sorted( [r['staked'] for r in data['results']], reverse=True)) check('V11 /api/search bobot string verbatim', isinstance(data['results'][0]['weight'], str)) check('V11 /api/search last_vote hadir', all(r['last_vote'] for r in data['results'])) check('V29 search total_reward hadir', all('total_reward' in r for r in data['results'])) check('V29 search total_reward 0 (tanpa transfer)', all(r['total_reward'] == 0 for r in data['results'])) check('V40 search group hadir', all(r['group'] in ('baru', 'revote', 'valid', 'kadaluarsa') for r in data['results'])) check('V40 search expired flag hadir', all('expired' in r for r in data['results'])) check('V43 search expiring flag hadir', all('expiring' in r for r in data['results'])) check('V40 search acct12 semua valid + expired=False', all(r['group'] == 'valid' and not r['expired'] for r in data['results'])) resp = client.get('/api/search?q=revoter1') data = resp.get_json() check('V41 search revoter1 → group revote (tag < 1 hari)', len(data['results']) == 1 and data['results'][0]['group'] == 'revote' and data['results'][0]['expired'] is False and data['results'][0]['expiring'] is False) resp = client.get('/api/search?q=expire1') data = resp.get_json() check('V43 search expire1 → group valid + expiring', len(data['results']) == 1 and data['results'][0]['group'] == 'valid' and data['results'][0]['expired'] is False and data['results'][0]['expiring'] is True) resp = client.get('/api/search?q=newacct1') data = resp.get_json() check('V41 search newacct1 → group baru', len(data['results']) == 1 and data['results'][0]['group'] == 'baru' and data['results'][0]['expired'] is False) resp = client.get('/api/search?q=zzzold') data = resp.get_json() check('V40 search kadaluarsa → group + expired', len(data['results']) == 1 and data['results'][0]['group'] == 'kadaluarsa' and data['results'][0]['expired'] is True) resp = client.get('/api/search?q=zzhide') check('V40 search tersembunyi (>31 hr) → 0 hasil', resp.get_json()['count'] == 0) resp = client.get('/api/search?q=acct001') data = resp.get_json() check('V29 search total_reward acct001 = 50.0', len(data['results']) == 1 and data['results'][0]['total_reward'] == 50.0) resp = client.get('/api/search?q=zzzz') check('V11 /api/search kosong', resp.get_json()['count'] == 0) # wildcard `%`/`_` dari pengguna dibuang (escape `!`, bukan pola): # tak ada owner berisi `_` atau `%` → 0 hasil bila benar-benar literal resp = client.get('/api/search?q=acct!_') check('V17 wildcard `_` di-escape (bukan pola)', resp.get_json()['count'] == 0) resp = client.get('/api/search?q=acct!%') check('V17 wildcard `%` di-escape (bukan pola)', resp.get_json()['count'] == 0) # rank ordinal DALAM kelompok: 'acct00' menyentuh 9 baris VALID → 1..9 resp = client.get('/api/search?q=acct00') data = resp.get_json() check('V40 rank ordinal dalam kelompok (1..9)', [r['rank'] for r in data['results']] == list(range(1, 10))) # ————— env config: default = konstanta lama ————— import config check('V11 config default TARGET_BP', config.TARGET_BP == 'databisnisid') check('V11 config default API_NODE', config.API_NODE == 'https://v2.vexascan.com:2096') check('V11 config default PAGE_SIZE', config.PAGE_SIZE == 50) check('V11 config default MIN_STAKED_VEX', config.MIN_STAKED_VEX == 1000.0) check('V11 config default DB_PATH', config.DB_PATH == 'voters.db') check('V12 config default DASH_WORKERS', config.DASH_WORKERS == 2) check('V13 config default VEX_STALE_DAYS', config.VEX_STALE_DAYS == 28) check('V40 config default VEX_MATURITY_DAYS', config.VEX_MATURITY_DAYS == 3) check('V40 config default VEX_EXPIRED_DAYS', config.VEX_EXPIRED_DAYS == 3) check('V43 config default VEX_REVOTE_TAG_DAYS', config.VEX_REVOTE_TAG_DAYS == 1) check('V43 config default VEX_WARN_DAYS', config.VEX_WARN_DAYS == 3) check('V16 config default DATABISNIS_API', config.DATABISNIS_API == 'https://api.databisnis.id') check('V16 config default DASH_LIQUID_TTL', config.DASH_LIQUID_TTL == 60) check('V17 config default DB_BACKEND', config.DB_BACKEND == 'sqlite') check('V17 config default DB_HOST/PORT', config.DB_HOST == '127.0.0.1' and config.DB_PORT == 3306) check('V17 config default DB_USER/PASS/NAME', (config.DB_USER, config.DB_PASS, config.DB_NAME) == ('', '', '')) # ————— V16: saldo liquid — cache TTL + cooldown kegagalan ————— dashboard._get_liquid_vex = real_get_liquid dashboard._liquid_cache.update(at=0.0, value=None) calls = {'n': 0} def fake_ok(url, params=None, timeout=None): calls['n'] += 1 return type('R', (), { 'ok': True, 'raise_for_status': lambda self: None, 'json': lambda self: {'account': {'core_liquid_balance': '12345.6789 VEX'}}, })() def fake_fail(url, params=None, timeout=None): calls['n'] += 1 raise requests.exceptions.ConnectionError('api down') dashboard.requests.get = fake_ok check('V16 fetch saldo → float', dashboard._get_liquid_vex() == 12345.6789) check('V16 TTL belum lewat → 1 fetch', calls['n'] == 1) check('V16 panggilan kedua dari cache', dashboard._get_liquid_vex() == 12345.6789 and calls['n'] == 1) dashboard._liquid_cache.update(at=0.0, value=12345.6789) # cache kedaluwarsa dashboard.requests.get = fake_fail check('V16 gagal → nilai lama tetap', dashboard._get_liquid_vex() == 12345.6789 and calls['n'] == 2) check('V16 cooldown → API tak dipukul lagi', dashboard._get_liquid_vex() == 12345.6789 and calls['n'] == 2) dashboard._liquid_cache.update(at=0.0, value=None) check('V16 gagal tanpa cache → None', dashboard._get_liquid_vex() is None and calls['n'] == 3) def _extract_stakes(text): """Ambil nilai stake (dalam urutan kemunculan) dari baris HTML — HANYA dari kontainer `id="rows"` (daftar utama VALID / hasil search). Baris sorot `list-row expired` dilewati (V40: kadaluarsa menyatu di daftar utama, ⊥ hitungan/pengurutan VALID).""" chunk = _slice_rows(text) vals = [] for m in re.finditer(r'
(.*?)
', chunk, re.S): if ' expired' in m.group(0): continue sm = re.search(r']*>(.*?)', m.group(1)) if sm: vals.append(_parse_vex(sm.group(1))) return vals def _slice_rows(text): start = text.index('id="rows">') + len('id="rows">') if '