Files
databisnisid/test_dashboard.py

589 lines
28 KiB
Python

"""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 ber-first_seen
# lama → pemilih matang (V43: badge peringatan tetap tampil padanya).
# V52: semua acct### di-seed first_seen LAMA (matang) agar tetap di band
# VALID (maturity diukur dari first_seen_at, ⊥ last_vote terkuantisasi).
conn = sqlite3.connect(db)
conn.execute('CREATE TABLE IF NOT EXISTS voter_first_seen '
'(owner TEXT PRIMARY KEY, first_seen_at TEXT NOT NULL)')
fs_rows = [('newacct1', _iso(1)), ('revoter1', _iso(60)),
('acct001', _iso(1)), ('expire1', _iso(60))]
fs_rows += [(f'acct{i:03d}', _iso(60)) for i in range(1, 126)]
conn.executemany(
'INSERT OR REPLACE INTO voter_first_seen (owner, first_seen_at) '
'VALUES (?, ?)',
fs_rows,
)
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', 'SIMPLE MINING' in html)
check('V13 kolom VOTE TERAKHIR', 'VOTE TERAKHIR' in html)
check('V51 akun jadi link vexascan',
'href="https://vexascan.com/account/acct125"' in html
and 'target="_blank"' in html and 'owner-link' 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('V47 3 sel stats (2 pemilih + saldo liquid)',
html.count('spec-cell') == 3)
check('V47 TOTAL VEX dihapus dari dashboard',
'TOTAL VEX' not in html)
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('V47 catatan definisi total vs vexascan',
'SUARA TUNGGAL KE' in html
and 'BUKAN TOTAL VOTE-WEIGHT' not in html)
check('V53 promo VEXWALLET link + logo IDRS',
'play.google.com/store/apps/details?id=kriptoteknologi.io.vexwallet' in html
and 'BY DATABISNISID' in html
and 'UNDUH DI PLAY STORE' in html
and 'idrs.png' in html)
check('V40 TOTAL PEMILIH = baris tampil (129)',
'<span class="cell-value">129</span>' 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('V52 BARU countdown VALID DALAM N HARI',
'maturity" data-label="MATURITY PERIOD">VALID DALAM 2 HARI' in html)
check('V52 BARU band kolom MATURITY PERIOD',
'MATURITY PERIOD' in _slice_band(html))
check('V52 BARU band ⊖ kolom VOTE TERAKHIR & TOTAL REWARD',
'VOTE TERAKHIR' not in _slice_band(html)
and 'TOTAL REWARD' not in _slice_band(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',
'<span class="reward" data-label="TOTAL REWARD (VEX)">0,0000</span>'
in html)
resp = client.get('/?page=3')
html = resp.get_data(as_text=True)
check('V29 reward acct001 = total sent (50,0000)',
'<span class="reward" data-label="TOTAL REWARD (VEX)">50,0000</span>'
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 —————
# V64/B25: mock `_now` (helper jam) ke tanggal TETAP — ⊖ jam dinding
# nyata (oracle lulus hanya kalau kalender < tanggal launch baked).
_orig_dash_now = dashboard._now
dashboard._now = lambda: datetime(2026, 8, 6, 9, 0) # sebelum launch
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 ≠ Rabu/Sabtu)',
'SETIAP RABU & 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._now = lambda: datetime(2026, 8, 18, 9, 0) # setelah launch
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 RABU & SABTU · 10:00' in html)
dashboard._now = _orig_dash_now
# 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, 19, 9, 0), datetime(2026, 8, 22, 11, 0),
datetime(2026, 8, 12, 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))
check('V44 drift-guard multi-hari wed,sat = distribute_loop',
all(dashboard._next_boundary(n, 10, [2, 5], fr)
== distribute_loop.next_boundary(n, 10, [2, 5], fr)
for n in drift_dts))
check('V44 drift-guard skalar = distribute_loop',
all(dashboard._next_boundary(n, 10, 2)
== distribute_loop.next_boundary(n, 10, 2)
for n in drift_dts))
check('V44 parse_weekdays mirror',
dashboard._parse_weekdays('wed,sat') == [2, 5]
and dashboard._parse_weekdays('sat') == [5])
# 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('V51 history akun jadi link vexascan',
'href="https://vexascan.com/account/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)
check('V51 search akun jadi link vexascan',
'href="https://vexascan.com/account/acct125"' 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)))
# ————— V57: /api/voter/<owner> — cek akun pemilih reward-eligible —————
resp = client.get('/api/voter/acct125')
check('V57 eligible akun VALID → 200', resp.status_code == 200)
check('V57 shape {owner,is_valid_voter}',
set(resp.get_json().keys()) == {'owner', 'is_valid_voter'})
check('V57 acct125 (matang+segar) → true',
resp.get_json() == {'owner': 'acct125', 'is_valid_voter': True})
resp = client.get('/api/voter/revoter1')
check('V57 revoter1 (REVOTE matang) → true',
resp.get_json() == {'owner': 'revoter1', 'is_valid_voter': True})
resp = client.get('/api/voter/expire1')
check('V57 expire1 (VALID, badge peringatan) → true',
resp.get_json() == {'owner': 'expire1', 'is_valid_voter': True})
resp = client.get('/api/voter/newacct1')
check('V57 newacct1 (BARU, belum matang) → false',
resp.get_json() == {'owner': 'newacct1', 'is_valid_voter': False})
resp = client.get('/api/voter/zzzold')
check('V57 zzzold (kadaluarsa) → false',
resp.get_json() == {'owner': 'zzzold', 'is_valid_voter': False})
resp = client.get('/api/voter/zzhide')
check('V57 zzhide (tersembunyi) → false',
resp.get_json() == {'owner': 'zzhide', 'is_valid_voter': False})
resp = client.get('/api/voter/tidakada1')
check('V57 akun tak ada → false',
resp.get_json() == {'owner': 'tidakada1', 'is_valid_voter': False})
# V57: konsisten dgn db.eligible_voters + hasil distribusi (single source)
for who in ('acct125', 'revoter1', 'expire1', 'newacct1',
'zzzold', 'zzhide', 'tidakada1'):
want = dbm.eligible_voters(who) # [] or one row
got = client.get(f'/api/voter/{who}').get_json()['is_valid_voter']
check(f'V57 endpoint == db.eligible_voters({who})',
got == (len(want) > 0))
legit = {r[0] for r in dbm.eligible_voters()}
check('V57 distribusi-eligible == endpoint pool (semua acct + revote + expire)',
legit == {f'acct{i:03d}' for i in range(1, 126)}
| {'revoter1', 'expire1'})
check('V57 pool ⊖ BARU/kadaluarsa/sembunyi/asing',
legit.isdisjoint({'newacct1', 'zzzold', 'zzhide', 'tidakada1'}))
# ————— 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 HYPERION_API', config.HYPERION_API == 'https://api.databisnis.id')
check('V60 config default HYPERION_NODES',
config.HYPERION_NODES == ['https://api.databisnis.id',
'https://v2.vexascan.com:2096'])
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) == ('', '', ''))
# ————— backward-compat: env lama DATABISNIS_API masih dihormati —————
import importlib
os.environ['DATABISNIS_API'] = 'http://hyperion-lama'
importlib.reload(config)
check('rename fallback env lama DATABISNIS_API dihormati',
config.HYPERION_API == 'http://hyperion-lama')
os.environ.pop('DATABISNIS_API', None)
importlib.reload(config)
check('rename default pulih stlh env lama dibuang',
config.HYPERION_API == 'https://api.databisnis.id')
# ————— V60: env override pool Hyperion —————
os.environ['VEX_HYPERION_NODES'] = 'http://hyperion-a,http://hyperion-b'
importlib.reload(config)
check('V60 override VEX_HYPERION_NODES dipakai',
config.HYPERION_NODES == ['http://hyperion-a', 'http://hyperion-b'])
os.environ.pop('VEX_HYPERION_NODES', None)
importlib.reload(config)
check('V60 default pulih stlh override dibuang',
config.HYPERION_NODES == ['https://api.databisnis.id',
'https://v2.vexascan.com:2096'])
# ————— 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}
_orig_hynodes = list(dashboard.HYPERION_NODES)
dashboard.HYPERION_NODES = ['http://single'] # hitungan V16 dgn 1 host
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)
# ————— V60: failover pool Hyperion — host mati → host cadangan —————
dashboard._liquid_cache.update(at=0.0, value=None)
fl_calls = {'n': 0}
def fake_failover(url, params=None, timeout=None):
fl_calls['n'] += 1
if url.startswith('http://dead'):
raise requests.exceptions.ConnectionError('hyperion mati')
return type('R', (), {
'ok': True,
'raise_for_status': lambda self: None,
'json': lambda self: {'account': {'core_liquid_balance': '999.0000 VEX'}},
})()
dashboard.HYPERION_NODES = ['http://dead', 'http://alive']
dashboard.requests.get = fake_failover
check('V60 saldo dari host cadangan',
dashboard._get_liquid_vex() == 999.0 and fl_calls['n'] == 2)
dashboard.HYPERION_NODES = _orig_hynodes
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'<div class="list-row(?: expired)?">(.*?)</div>',
chunk, re.S):
if ' expired' in m.group(0):
continue
sm = re.search(r'<span class="stake"[^>]*>(.*?)</span>', 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 '<nav class="pager"' in text:
end = text.index('<nav class="pager"', start)
else:
end = text.index('<footer', start)
return text[start:end]
def _slice_band(text):
"""Potong section band BARU (`band-list news-list`) utk cek kolom."""
start = text.index('band-list news-list') + len('band-list news-list')
end = text.index('</section>', start)
return text[start:end]
def _parse_vex(text):
# format '1.749.791,2303' → float
return float(text.replace('.', '').replace(',', '.'))
def _table_hashes(path):
conn = sqlite3.connect(path)
try:
rows = conn.execute('SELECT owner, weight, staked, scanned_at '
'FROM voters ORDER BY owner').fetchall()
finally:
conn.close()
return tuple(rows)
if __name__ == '__main__':
main()