@wlfi-agent/cli 1.4.17 → 1.4.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +5 -0
- package/README.md +61 -28
- package/crates/vault-cli-admin/src/io_utils.rs +149 -1
- package/crates/vault-cli-admin/src/main.rs +639 -16
- package/crates/vault-cli-admin/src/shared_config.rs +18 -18
- package/crates/vault-cli-admin/src/tui/token_rpc.rs +190 -3
- package/crates/vault-cli-admin/src/tui/utils.rs +59 -0
- package/crates/vault-cli-admin/src/tui.rs +1205 -120
- package/crates/vault-cli-agent/Cargo.toml +1 -0
- package/crates/vault-cli-agent/src/io_utils.rs +163 -2
- package/crates/vault-cli-agent/src/main.rs +648 -32
- package/crates/vault-cli-daemon/Cargo.toml +4 -0
- package/crates/vault-cli-daemon/src/main.rs +617 -67
- package/crates/vault-cli-daemon/src/relay_sync.rs +776 -4
- package/crates/vault-cli-daemon/tests/system_keychain_helper_acl.rs +5 -0
- package/crates/vault-daemon/src/daemon_parts/api_impl_and_utils.rs +32 -1
- package/crates/vault-daemon/src/persistence.rs +637 -100
- package/crates/vault-daemon/src/tests.rs +1013 -3
- package/crates/vault-daemon/src/tests_parts/part2.rs +99 -0
- package/crates/vault-daemon/src/tests_parts/part4.rs +11 -7
- package/crates/vault-domain/src/nonce.rs +4 -0
- package/crates/vault-domain/src/tests.rs +616 -0
- package/crates/vault-policy/src/engine.rs +55 -32
- package/crates/vault-policy/src/tests.rs +195 -0
- package/crates/vault-sdk-agent/src/lib.rs +415 -22
- package/crates/vault-signer/Cargo.toml +3 -0
- package/crates/vault-signer/src/lib.rs +266 -40
- package/crates/vault-transport-unix/src/lib.rs +653 -5
- package/crates/vault-transport-xpc/src/tests.rs +531 -3
- package/crates/vault-transport-xpc/tests/e2e_flow.rs +3 -0
- package/dist/cli.cjs +663 -190
- package/dist/cli.cjs.map +1 -1
- package/package.json +5 -2
- package/packages/cache/.turbo/turbo-build.log +20 -20
- package/packages/cache/coverage/clover.xml +529 -394
- package/packages/cache/coverage/coverage-final.json +2 -2
- package/packages/cache/coverage/index.html +21 -21
- package/packages/cache/coverage/src/client/index.html +1 -1
- package/packages/cache/coverage/src/client/index.ts.html +1 -1
- package/packages/cache/coverage/src/errors/index.html +1 -1
- package/packages/cache/coverage/src/errors/index.ts.html +12 -12
- package/packages/cache/coverage/src/index.html +1 -1
- package/packages/cache/coverage/src/index.ts.html +1 -1
- package/packages/cache/coverage/src/service/index.html +21 -21
- package/packages/cache/coverage/src/service/index.ts.html +769 -313
- package/packages/cache/dist/{chunk-QNK6GOTI.js → chunk-KC53LH5Z.js} +35 -2
- package/packages/cache/dist/chunk-KC53LH5Z.js.map +1 -0
- package/packages/cache/dist/{chunk-QF4XKEIA.cjs → chunk-UVU7VFE3.cjs} +35 -2
- package/packages/cache/dist/chunk-UVU7VFE3.cjs.map +1 -0
- package/packages/cache/dist/index.cjs +2 -2
- package/packages/cache/dist/index.js +1 -1
- package/packages/cache/dist/service/index.cjs +2 -2
- package/packages/cache/dist/service/index.js +1 -1
- package/packages/cache/node_modules/.bin/tsc +2 -2
- package/packages/cache/node_modules/.bin/tsserver +2 -2
- package/packages/cache/node_modules/.bin/tsup +2 -2
- package/packages/cache/node_modules/.bin/tsup-node +2 -2
- package/packages/cache/node_modules/.bin/vitest +4 -4
- package/packages/cache/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/packages/cache/src/service/index.test.ts +165 -19
- package/packages/cache/src/service/index.ts +38 -1
- package/packages/config/.turbo/turbo-build.log +4 -4
- package/packages/config/dist/index.cjs +0 -17
- package/packages/config/dist/index.cjs.map +1 -1
- package/packages/config/src/index.ts +0 -17
- package/packages/rpc/.turbo/turbo-build.log +11 -11
- package/packages/rpc/dist/index.cjs +0 -17
- package/packages/rpc/dist/index.cjs.map +1 -1
- package/packages/rpc/src/index.js +1 -0
- package/packages/ui/node_modules/.bin/tsc +2 -2
- package/packages/ui/node_modules/.bin/tsserver +2 -2
- package/packages/ui/node_modules/.bin/tsup +2 -2
- package/packages/ui/node_modules/.bin/tsup-node +2 -2
- package/scripts/install-cli-launcher.mjs +37 -0
- package/scripts/install-rust-binaries.mjs +47 -0
- package/scripts/run-tests-isolated.mjs +210 -0
- package/src/cli.ts +310 -50
- package/src/lib/admin-reset.ts +15 -30
- package/src/lib/admin-setup.ts +246 -55
- package/src/lib/agent-auth-migrate.ts +5 -1
- package/src/lib/asset-broadcast.ts +15 -4
- package/src/lib/config-amounts.ts +6 -4
- package/src/lib/hidden-tty-prompt.js +1 -0
- package/src/lib/hidden-tty-prompt.ts +105 -0
- package/src/lib/keychain.ts +1 -0
- package/src/lib/local-admin-access.ts +4 -29
- package/src/lib/rust.ts +129 -33
- package/src/lib/signed-tx.ts +1 -0
- package/src/lib/sudo.ts +15 -5
- package/src/lib/wallet-profile.ts +3 -0
- package/src/lib/wallet-setup.ts +52 -0
- package/packages/cache/dist/chunk-QF4XKEIA.cjs.map +0 -1
- package/packages/cache/dist/chunk-QNK6GOTI.js.map +0 -1
|
@@ -4,7 +4,10 @@ use std::path::PathBuf;
|
|
|
4
4
|
use std::time::Duration;
|
|
5
5
|
|
|
6
6
|
use anyhow::{anyhow, bail, Context, Result};
|
|
7
|
-
use crossterm::event::{
|
|
7
|
+
use crossterm::event::{
|
|
8
|
+
self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEvent, KeyEventKind,
|
|
9
|
+
KeyModifiers,
|
|
10
|
+
};
|
|
8
11
|
use crossterm::execute;
|
|
9
12
|
use crossterm::terminal::{
|
|
10
13
|
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
|
@@ -132,14 +135,15 @@ enum PendingDiscardAction {
|
|
|
132
135
|
NewNetworkDraft,
|
|
133
136
|
CycleSavedToken(i8),
|
|
134
137
|
CycleSavedNetwork(i8),
|
|
138
|
+
Cancel,
|
|
135
139
|
}
|
|
136
140
|
|
|
137
|
-
#[derive(Debug, Clone,
|
|
141
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
138
142
|
enum PendingDeleteAction {
|
|
139
|
-
DeleteDestinationOverride,
|
|
140
|
-
DeleteManualApproval,
|
|
141
|
-
DeleteToken,
|
|
142
|
-
DeleteNetwork,
|
|
143
|
+
DeleteDestinationOverride(usize),
|
|
144
|
+
DeleteManualApproval(usize),
|
|
145
|
+
DeleteToken(String),
|
|
146
|
+
DeleteNetwork(String),
|
|
143
147
|
}
|
|
144
148
|
|
|
145
149
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
@@ -212,6 +216,25 @@ enum Field {
|
|
|
212
216
|
Execute,
|
|
213
217
|
}
|
|
214
218
|
|
|
219
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
220
|
+
enum FieldInteraction {
|
|
221
|
+
Edit,
|
|
222
|
+
Select,
|
|
223
|
+
Action,
|
|
224
|
+
ReadOnly,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
impl FieldInteraction {
|
|
228
|
+
fn badge(self) -> &'static str {
|
|
229
|
+
match self {
|
|
230
|
+
Self::Edit => "[E]",
|
|
231
|
+
Self::Select => "[S]",
|
|
232
|
+
Self::Action => "[A]",
|
|
233
|
+
Self::ReadOnly => "[R]",
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
215
238
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
216
239
|
struct LimitDraft {
|
|
217
240
|
per_tx_limit: String,
|
|
@@ -906,6 +929,16 @@ impl AppState {
|
|
|
906
929
|
self.visible_fields()[self.selected]
|
|
907
930
|
}
|
|
908
931
|
|
|
932
|
+
fn select_field(&mut self, target: Field) {
|
|
933
|
+
if let Some(index) = self
|
|
934
|
+
.visible_fields()
|
|
935
|
+
.iter()
|
|
936
|
+
.position(|field| *field == target)
|
|
937
|
+
{
|
|
938
|
+
self.selected = index;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
909
942
|
fn active_draft_dirty(&self) -> bool {
|
|
910
943
|
match self.view {
|
|
911
944
|
View::Tokens => self.token_dirty,
|
|
@@ -983,7 +1016,7 @@ impl AppState {
|
|
|
983
1016
|
}
|
|
984
1017
|
|
|
985
1018
|
fn confirm_delete(&mut self, action: PendingDeleteAction, label: &str) -> bool {
|
|
986
|
-
if self.pending_delete_action == Some(action) {
|
|
1019
|
+
if self.pending_delete_action == Some(action.clone()) {
|
|
987
1020
|
self.clear_pending_delete();
|
|
988
1021
|
return true;
|
|
989
1022
|
}
|
|
@@ -1080,6 +1113,10 @@ impl AppState {
|
|
|
1080
1113
|
}
|
|
1081
1114
|
}
|
|
1082
1115
|
|
|
1116
|
+
fn request_cancel(&mut self) -> bool {
|
|
1117
|
+
self.confirm_discard(PendingDiscardAction::Cancel)
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1083
1120
|
fn load_token_draft(&mut self, token_key: Option<&str>) {
|
|
1084
1121
|
self.token_draft = token_key
|
|
1085
1122
|
.and_then(|token_key| {
|
|
@@ -1159,6 +1196,7 @@ impl AppState {
|
|
|
1159
1196
|
self.network_dirty = false;
|
|
1160
1197
|
self.clear_pending_discard();
|
|
1161
1198
|
self.clear_pending_delete();
|
|
1199
|
+
self.select_field(Field::ChainConfigKey);
|
|
1162
1200
|
self.set_success_message("new network draft ready");
|
|
1163
1201
|
}
|
|
1164
1202
|
|
|
@@ -1167,10 +1205,9 @@ impl AppState {
|
|
|
1167
1205
|
self.delete_destination_override();
|
|
1168
1206
|
return;
|
|
1169
1207
|
}
|
|
1170
|
-
|
|
1171
|
-
PendingDeleteAction::DeleteDestinationOverride
|
|
1172
|
-
|
|
1173
|
-
) {
|
|
1208
|
+
let target =
|
|
1209
|
+
PendingDeleteAction::DeleteDestinationOverride(self.token_draft.selected_override);
|
|
1210
|
+
if self.confirm_delete(target, "the selected destination override") {
|
|
1174
1211
|
self.delete_destination_override();
|
|
1175
1212
|
}
|
|
1176
1213
|
}
|
|
@@ -1180,33 +1217,34 @@ impl AppState {
|
|
|
1180
1217
|
self.delete_manual_approval();
|
|
1181
1218
|
return;
|
|
1182
1219
|
}
|
|
1183
|
-
|
|
1184
|
-
PendingDeleteAction::DeleteManualApproval
|
|
1185
|
-
|
|
1186
|
-
) {
|
|
1220
|
+
let target =
|
|
1221
|
+
PendingDeleteAction::DeleteManualApproval(self.token_draft.selected_manual_approval);
|
|
1222
|
+
if self.confirm_delete(target, "the selected manual approval policy") {
|
|
1187
1223
|
self.delete_manual_approval();
|
|
1188
1224
|
}
|
|
1189
1225
|
}
|
|
1190
1226
|
|
|
1191
1227
|
fn request_delete_token(&mut self) -> Result<()> {
|
|
1192
|
-
let has_candidate =
|
|
1193
|
-
|| !self.token_draft.key.trim().is_empty();
|
|
1228
|
+
let has_candidate =
|
|
1229
|
+
self.token_draft.source_key.is_some() || !self.token_draft.key.trim().is_empty();
|
|
1194
1230
|
if !has_candidate {
|
|
1195
1231
|
return self.delete_token_config();
|
|
1196
1232
|
}
|
|
1197
|
-
|
|
1233
|
+
let target = PendingDeleteAction::DeleteToken(self.pending_token_delete_key()?);
|
|
1234
|
+
if self.confirm_delete(target, "the selected token") {
|
|
1198
1235
|
return self.delete_token_config();
|
|
1199
1236
|
}
|
|
1200
1237
|
Ok(())
|
|
1201
1238
|
}
|
|
1202
1239
|
|
|
1203
1240
|
fn request_delete_network(&mut self) -> Result<()> {
|
|
1204
|
-
let has_candidate =
|
|
1205
|
-
|| !self.network_draft.key.trim().is_empty();
|
|
1241
|
+
let has_candidate =
|
|
1242
|
+
self.network_draft.source_key.is_some() || !self.network_draft.key.trim().is_empty();
|
|
1206
1243
|
if !has_candidate {
|
|
1207
1244
|
return self.delete_network_config();
|
|
1208
1245
|
}
|
|
1209
|
-
|
|
1246
|
+
let target = PendingDeleteAction::DeleteNetwork(self.pending_network_delete_key()?);
|
|
1247
|
+
if self.confirm_delete(target, "the selected network") {
|
|
1210
1248
|
return self.delete_network_config();
|
|
1211
1249
|
}
|
|
1212
1250
|
Ok(())
|
|
@@ -1227,6 +1265,7 @@ impl AppState {
|
|
|
1227
1265
|
}
|
|
1228
1266
|
}
|
|
1229
1267
|
Field::SelectedDestinationOverride => {
|
|
1268
|
+
self.clear_pending_delete();
|
|
1230
1269
|
cycle_index(
|
|
1231
1270
|
&mut self.token_draft.selected_override,
|
|
1232
1271
|
self.token_draft.destination_overrides.len(),
|
|
@@ -1234,6 +1273,7 @@ impl AppState {
|
|
|
1234
1273
|
);
|
|
1235
1274
|
}
|
|
1236
1275
|
Field::SelectedManualApproval => {
|
|
1276
|
+
self.clear_pending_delete();
|
|
1237
1277
|
cycle_index(
|
|
1238
1278
|
&mut self.token_draft.selected_manual_approval,
|
|
1239
1279
|
self.token_draft.manual_approvals.len(),
|
|
@@ -1335,9 +1375,8 @@ impl AppState {
|
|
|
1335
1375
|
);
|
|
1336
1376
|
}
|
|
1337
1377
|
|
|
1338
|
-
fn
|
|
1339
|
-
|
|
1340
|
-
let target = match selected_field {
|
|
1378
|
+
fn selected_text_field_mut(&mut self, selected_field: Field) -> Option<&mut String> {
|
|
1379
|
+
match selected_field {
|
|
1341
1380
|
Field::TokenKey => Some(&mut self.token_draft.key),
|
|
1342
1381
|
Field::NetworkAddress => self
|
|
1343
1382
|
.token_draft
|
|
@@ -1427,34 +1466,38 @@ impl AppState {
|
|
|
1427
1466
|
Field::ChainConfigName => Some(&mut self.network_draft.name),
|
|
1428
1467
|
Field::ChainConfigRpcUrl => Some(&mut self.network_draft.rpc_url),
|
|
1429
1468
|
_ => None,
|
|
1430
|
-
}
|
|
1431
|
-
|
|
1432
|
-
let Some(target) = target else {
|
|
1433
|
-
return;
|
|
1434
|
-
};
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1435
1471
|
|
|
1472
|
+
fn edit_selected(&mut self, key: KeyEvent) {
|
|
1473
|
+
let selected_field = self.selected_field();
|
|
1436
1474
|
match key.code {
|
|
1437
1475
|
KeyCode::Backspace => {
|
|
1438
|
-
target.
|
|
1439
|
-
|
|
1440
|
-
|
|
1476
|
+
let changed = if let Some(target) = self.selected_text_field_mut(selected_field) {
|
|
1477
|
+
target.pop();
|
|
1478
|
+
true
|
|
1441
1479
|
} else {
|
|
1442
|
-
|
|
1480
|
+
false
|
|
1481
|
+
};
|
|
1482
|
+
if changed {
|
|
1483
|
+
self.record_selected_text_change(selected_field);
|
|
1443
1484
|
}
|
|
1444
|
-
self.clear_message();
|
|
1445
1485
|
}
|
|
1446
1486
|
KeyCode::Char(ch)
|
|
1447
1487
|
if !key.modifiers.contains(KeyModifiers::CONTROL)
|
|
1448
1488
|
&& !key.modifiers.contains(KeyModifiers::ALT) =>
|
|
1449
1489
|
{
|
|
1450
1490
|
if is_allowed_input_char(selected_field, ch) {
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1491
|
+
let changed =
|
|
1492
|
+
if let Some(target) = self.selected_text_field_mut(selected_field) {
|
|
1493
|
+
target.push(ch);
|
|
1494
|
+
true
|
|
1495
|
+
} else {
|
|
1496
|
+
false
|
|
1497
|
+
};
|
|
1498
|
+
if changed {
|
|
1499
|
+
self.record_selected_text_change(selected_field);
|
|
1456
1500
|
}
|
|
1457
|
-
self.clear_message();
|
|
1458
1501
|
} else {
|
|
1459
1502
|
self.set_error_message(format!(
|
|
1460
1503
|
"invalid character '{ch}' for the selected field"
|
|
@@ -1465,6 +1508,48 @@ impl AppState {
|
|
|
1465
1508
|
}
|
|
1466
1509
|
}
|
|
1467
1510
|
|
|
1511
|
+
fn record_selected_text_change(&mut self, selected_field: Field) {
|
|
1512
|
+
if field_uses_network_draft(selected_field) {
|
|
1513
|
+
self.mark_network_dirty();
|
|
1514
|
+
} else {
|
|
1515
|
+
self.mark_token_dirty();
|
|
1516
|
+
}
|
|
1517
|
+
self.clear_message();
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
fn paste_selected(&mut self, pasted: &str) {
|
|
1521
|
+
let selected_field = self.selected_field();
|
|
1522
|
+
let sanitized = pasted
|
|
1523
|
+
.trim()
|
|
1524
|
+
.chars()
|
|
1525
|
+
.filter(|ch| !matches!(ch, '\r' | '\n' | '\t'))
|
|
1526
|
+
.collect::<String>();
|
|
1527
|
+
let sanitized = sanitized.trim();
|
|
1528
|
+
if sanitized.is_empty() {
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
if let Some(invalid) = sanitized
|
|
1533
|
+
.chars()
|
|
1534
|
+
.find(|ch| !is_allowed_input_char(selected_field, *ch))
|
|
1535
|
+
{
|
|
1536
|
+
self.set_error_message(format!(
|
|
1537
|
+
"invalid character '{invalid}' for the selected field"
|
|
1538
|
+
));
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
let changed = if let Some(target) = self.selected_text_field_mut(selected_field) {
|
|
1543
|
+
target.push_str(sanitized);
|
|
1544
|
+
true
|
|
1545
|
+
} else {
|
|
1546
|
+
false
|
|
1547
|
+
};
|
|
1548
|
+
if changed {
|
|
1549
|
+
self.record_selected_text_change(selected_field);
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1468
1553
|
fn add_destination_override(&mut self) {
|
|
1469
1554
|
self.token_draft
|
|
1470
1555
|
.destination_overrides
|
|
@@ -1696,6 +1781,28 @@ impl AppState {
|
|
|
1696
1781
|
Ok(())
|
|
1697
1782
|
}
|
|
1698
1783
|
|
|
1784
|
+
fn pending_token_delete_key(&self) -> Result<String> {
|
|
1785
|
+
self.token_draft
|
|
1786
|
+
.source_key
|
|
1787
|
+
.clone()
|
|
1788
|
+
.or_else(|| {
|
|
1789
|
+
let trimmed = self.token_draft.key.trim().to_lowercase();
|
|
1790
|
+
(!trimmed.is_empty()).then_some(trimmed)
|
|
1791
|
+
})
|
|
1792
|
+
.context("no saved token is selected")
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
fn pending_network_delete_key(&self) -> Result<String> {
|
|
1796
|
+
self.network_draft
|
|
1797
|
+
.source_key
|
|
1798
|
+
.clone()
|
|
1799
|
+
.or_else(|| {
|
|
1800
|
+
let trimmed = self.network_draft.key.trim().to_lowercase();
|
|
1801
|
+
(!trimmed.is_empty()).then_some(trimmed)
|
|
1802
|
+
})
|
|
1803
|
+
.context("no saved network is selected")
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1699
1806
|
fn build_params(&self) -> Result<BootstrapParams> {
|
|
1700
1807
|
build_bootstrap_params_from_shared_config(
|
|
1701
1808
|
&self.shared_config_draft,
|
|
@@ -1808,6 +1915,7 @@ enum LoopAction {
|
|
|
1808
1915
|
Continue,
|
|
1809
1916
|
RefreshTokenMetadata,
|
|
1810
1917
|
SaveTokenAndApply,
|
|
1918
|
+
SaveNetworkAndApply,
|
|
1811
1919
|
ApplyAndExit(Box<BootstrapParams>),
|
|
1812
1920
|
Cancel,
|
|
1813
1921
|
}
|
|
@@ -1819,7 +1927,8 @@ pub(crate) fn run_bootstrap_tui<T>(
|
|
|
1819
1927
|
) -> Result<Option<T>> {
|
|
1820
1928
|
enable_raw_mode().context("failed to enable raw mode")?;
|
|
1821
1929
|
let mut stdout = io::stdout();
|
|
1822
|
-
execute!(stdout, EnterAlternateScreen
|
|
1930
|
+
execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)
|
|
1931
|
+
.context("failed to enter alternate screen")?;
|
|
1823
1932
|
let backend = CrosstermBackend::new(stdout);
|
|
1824
1933
|
let mut terminal = Terminal::new(backend).context("failed to initialize terminal backend")?;
|
|
1825
1934
|
|
|
@@ -1839,7 +1948,7 @@ pub(crate) fn run_bootstrap_tui<T>(
|
|
|
1839
1948
|
|
|
1840
1949
|
fn cleanup_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
|
|
1841
1950
|
disable_raw_mode().context("failed to disable raw mode")?;
|
|
1842
|
-
execute!(terminal.backend_mut(), LeaveAlternateScreen)
|
|
1951
|
+
execute!(terminal.backend_mut(), DisableBracketedPaste, LeaveAlternateScreen)
|
|
1843
1952
|
.context("failed to leave alternate screen")?;
|
|
1844
1953
|
terminal.show_cursor().context("failed to show cursor")?;
|
|
1845
1954
|
Ok(())
|
|
@@ -1893,9 +2002,27 @@ fn run_save_token_and_apply<T, B: Backend>(
|
|
|
1893
2002
|
.clone()
|
|
1894
2003
|
.unwrap_or_else(|| "saved token".to_string());
|
|
1895
2004
|
|
|
1896
|
-
let params = app
|
|
1897
|
-
|
|
1898
|
-
|
|
2005
|
+
let params = app
|
|
2006
|
+
.build_params()
|
|
2007
|
+
.map_err(|err| anyhow!("{success_message} but failed to apply to wallet: {err}"))?;
|
|
2008
|
+
let output = apply_with_progress(terminal, app, params, on_apply)?;
|
|
2009
|
+
Ok((output, format!("{success_message} and applied to wallet")))
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
fn run_save_network_and_apply<T, B: Backend>(
|
|
2013
|
+
terminal: &mut Terminal<B>,
|
|
2014
|
+
app: &mut AppState,
|
|
2015
|
+
on_apply: &mut impl FnMut(BootstrapParams, &mut dyn FnMut(&str) -> Result<()>) -> Result<T>,
|
|
2016
|
+
) -> Result<(T, String)> {
|
|
2017
|
+
app.save_network_config()?;
|
|
2018
|
+
let success_message = app
|
|
2019
|
+
.message
|
|
2020
|
+
.clone()
|
|
2021
|
+
.unwrap_or_else(|| "saved network".to_string());
|
|
2022
|
+
|
|
2023
|
+
let params = app
|
|
2024
|
+
.build_params()
|
|
2025
|
+
.map_err(|err| anyhow!("{success_message} but failed to apply to wallet: {err}"))?;
|
|
1899
2026
|
let output = apply_with_progress(terminal, app, params, on_apply)?;
|
|
1900
2027
|
Ok((output, format!("{success_message} and applied to wallet")))
|
|
1901
2028
|
}
|
|
@@ -1914,51 +2041,70 @@ fn run_event_loop<T, B: Backend>(
|
|
|
1914
2041
|
continue;
|
|
1915
2042
|
}
|
|
1916
2043
|
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
continue;
|
|
1922
|
-
}
|
|
1923
|
-
|
|
1924
|
-
match handle_key_event(&mut app, key)? {
|
|
2044
|
+
match handle_terminal_event(
|
|
2045
|
+
&mut app,
|
|
2046
|
+
event::read().context("failed to read terminal event")?,
|
|
2047
|
+
)? {
|
|
1925
2048
|
LoopAction::Continue => {}
|
|
1926
2049
|
LoopAction::RefreshTokenMetadata => {
|
|
1927
2050
|
if let Err(err) = refresh_metadata_with_progress(terminal, &mut app) {
|
|
1928
2051
|
app.set_error_message(err.to_string());
|
|
1929
2052
|
}
|
|
1930
2053
|
}
|
|
1931
|
-
LoopAction::SaveTokenAndApply =>
|
|
1932
|
-
terminal,
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
2054
|
+
LoopAction::SaveTokenAndApply => {
|
|
2055
|
+
match run_save_token_and_apply(terminal, &mut app, &mut on_apply) {
|
|
2056
|
+
Ok((output, success_message)) => {
|
|
2057
|
+
last_output = Some(output);
|
|
2058
|
+
app.set_success_message(success_message);
|
|
2059
|
+
}
|
|
2060
|
+
Err(err) => {
|
|
2061
|
+
app.set_error_message(err.to_string());
|
|
2062
|
+
}
|
|
1939
2063
|
}
|
|
1940
|
-
|
|
1941
|
-
|
|
2064
|
+
}
|
|
2065
|
+
LoopAction::SaveNetworkAndApply => {
|
|
2066
|
+
match run_save_network_and_apply(terminal, &mut app, &mut on_apply) {
|
|
2067
|
+
Ok((output, success_message)) => {
|
|
2068
|
+
last_output = Some(output);
|
|
2069
|
+
app.set_success_message(success_message);
|
|
2070
|
+
}
|
|
2071
|
+
Err(err) => {
|
|
2072
|
+
app.set_error_message(err.to_string());
|
|
2073
|
+
}
|
|
1942
2074
|
}
|
|
1943
|
-
}
|
|
1944
|
-
LoopAction::ApplyAndExit(params) =>
|
|
1945
|
-
terminal,
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
Ok(output) => return Ok(Some(output)),
|
|
1951
|
-
Err(err) => {
|
|
1952
|
-
app.set_error_message(err.to_string());
|
|
2075
|
+
}
|
|
2076
|
+
LoopAction::ApplyAndExit(params) => {
|
|
2077
|
+
match apply_with_progress(terminal, &mut app, *params, &mut on_apply) {
|
|
2078
|
+
Ok(output) => return Ok(Some(output)),
|
|
2079
|
+
Err(err) => {
|
|
2080
|
+
app.set_error_message(err.to_string());
|
|
2081
|
+
}
|
|
1953
2082
|
}
|
|
1954
|
-
}
|
|
2083
|
+
}
|
|
1955
2084
|
LoopAction::Cancel => return Ok(last_output),
|
|
1956
2085
|
}
|
|
1957
2086
|
}
|
|
1958
2087
|
}
|
|
1959
2088
|
|
|
2089
|
+
fn handle_terminal_event(app: &mut AppState, event: Event) -> Result<LoopAction> {
|
|
2090
|
+
match event {
|
|
2091
|
+
Event::Key(key) if key.kind == KeyEventKind::Press => handle_key_event(app, key),
|
|
2092
|
+
Event::Paste(data) => {
|
|
2093
|
+
app.paste_selected(&data);
|
|
2094
|
+
Ok(LoopAction::Continue)
|
|
2095
|
+
}
|
|
2096
|
+
_ => Ok(LoopAction::Continue),
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
|
|
1960
2100
|
fn handle_key_event(app: &mut AppState, key: KeyEvent) -> Result<LoopAction> {
|
|
1961
2101
|
if key.code == KeyCode::Char('s') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
|
2102
|
+
if app.view == View::Tokens && app.token_dirty {
|
|
2103
|
+
return Ok(LoopAction::SaveTokenAndApply);
|
|
2104
|
+
}
|
|
2105
|
+
if app.view == View::Networks && app.network_dirty {
|
|
2106
|
+
return Ok(LoopAction::SaveNetworkAndApply);
|
|
2107
|
+
}
|
|
1962
2108
|
match app.build_params() {
|
|
1963
2109
|
Ok(params) => return Ok(LoopAction::ApplyAndExit(Box::new(params))),
|
|
1964
2110
|
Err(err) => {
|
|
@@ -1994,12 +2140,41 @@ fn handle_key_event(app: &mut AppState, key: KeyEvent) -> Result<LoopAction> {
|
|
|
1994
2140
|
return Ok(LoopAction::Continue);
|
|
1995
2141
|
}
|
|
1996
2142
|
|
|
2143
|
+
if field_interaction(app.selected_field()) == FieldInteraction::Edit
|
|
2144
|
+
&& !key.modifiers.contains(KeyModifiers::CONTROL)
|
|
2145
|
+
&& !key.modifiers.contains(KeyModifiers::ALT)
|
|
2146
|
+
{
|
|
2147
|
+
match key.code {
|
|
2148
|
+
KeyCode::Char(_) | KeyCode::Backspace => {
|
|
2149
|
+
app.edit_selected(key);
|
|
2150
|
+
return Ok(LoopAction::Continue);
|
|
2151
|
+
}
|
|
2152
|
+
_ => {}
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
|
|
1997
2156
|
match key.code {
|
|
1998
|
-
KeyCode::Esc =>
|
|
2157
|
+
KeyCode::Esc => {
|
|
2158
|
+
return Ok(if app.request_cancel() {
|
|
2159
|
+
LoopAction::Cancel
|
|
2160
|
+
} else {
|
|
2161
|
+
LoopAction::Continue
|
|
2162
|
+
});
|
|
2163
|
+
}
|
|
1999
2164
|
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
2000
|
-
return Ok(
|
|
2165
|
+
return Ok(if app.request_cancel() {
|
|
2166
|
+
LoopAction::Cancel
|
|
2167
|
+
} else {
|
|
2168
|
+
LoopAction::Continue
|
|
2169
|
+
});
|
|
2170
|
+
}
|
|
2171
|
+
KeyCode::Char('q') if key.modifiers.is_empty() => {
|
|
2172
|
+
return Ok(if app.request_cancel() {
|
|
2173
|
+
LoopAction::Cancel
|
|
2174
|
+
} else {
|
|
2175
|
+
LoopAction::Continue
|
|
2176
|
+
});
|
|
2001
2177
|
}
|
|
2002
|
-
KeyCode::Char('q') if key.modifiers.is_empty() => return Ok(LoopAction::Cancel),
|
|
2003
2178
|
KeyCode::Down | KeyCode::Char('j') => {
|
|
2004
2179
|
app.select_next();
|
|
2005
2180
|
app.clear_message();
|
|
@@ -2210,7 +2385,7 @@ fn draw_ui(frame: &mut ratatui::Frame<'_>, app: &AppState) {
|
|
|
2210
2385
|
Style::default()
|
|
2211
2386
|
};
|
|
2212
2387
|
ListItem::new(Line::from(vec![
|
|
2213
|
-
Span::styled(
|
|
2388
|
+
Span::styled(field_display_label(*field), style),
|
|
2214
2389
|
Span::styled(": ", style),
|
|
2215
2390
|
Span::styled(field_value(app, *field), style),
|
|
2216
2391
|
]))
|
|
@@ -2258,11 +2433,12 @@ fn status_message_style(app: &AppState) -> Style {
|
|
|
2258
2433
|
|
|
2259
2434
|
fn build_help_lines(view: View) -> Vec<Line<'static>> {
|
|
2260
2435
|
let mut lines = vec![
|
|
2436
|
+
Line::from("Legend: [E] edit [S] select/toggle [A] Enter [R] read-only"),
|
|
2261
2437
|
Line::from("Views: Tab/Shift+Tab or ]/["),
|
|
2262
|
-
Line::from("
|
|
2263
|
-
Line::from("
|
|
2264
|
-
Line::from("Actions: Enter"),
|
|
2438
|
+
Line::from("Move: ↑/↓ j/k Home End"),
|
|
2439
|
+
Line::from("Use: ←/→ h/l Space Enter"),
|
|
2265
2440
|
Line::from("Drafts: Ctrl+N new, Ctrl+R reload, Ctrl+S bootstrap"),
|
|
2441
|
+
Line::from("Paste: terminal paste appends into editable fields."),
|
|
2266
2442
|
];
|
|
2267
2443
|
match view {
|
|
2268
2444
|
View::Tokens => {
|
|
@@ -2270,10 +2446,10 @@ fn build_help_lines(view: View) -> Vec<Line<'static>> {
|
|
|
2270
2446
|
"Tokens: Ctrl+O add override, Ctrl+M add manual approval",
|
|
2271
2447
|
));
|
|
2272
2448
|
lines.push(Line::from(
|
|
2273
|
-
"
|
|
2449
|
+
"RPC fields are [R]; use [A] Fetch Metadata to refresh them.",
|
|
2274
2450
|
));
|
|
2275
2451
|
lines.push(Line::from(
|
|
2276
|
-
"Advanced exposes gas, fee, tx count, and calldata limits.",
|
|
2452
|
+
"Advanced [S] exposes gas, fee, tx count, and calldata limits.",
|
|
2277
2453
|
));
|
|
2278
2454
|
}
|
|
2279
2455
|
View::Networks => {
|
|
@@ -2488,6 +2664,66 @@ fn field_label(field: Field) -> &'static str {
|
|
|
2488
2664
|
}
|
|
2489
2665
|
}
|
|
2490
2666
|
|
|
2667
|
+
fn field_interaction(field: Field) -> FieldInteraction {
|
|
2668
|
+
match field {
|
|
2669
|
+
Field::SelectedToken
|
|
2670
|
+
| Field::NetworkMembership
|
|
2671
|
+
| Field::EditingNetwork
|
|
2672
|
+
| Field::NetworkIsNative
|
|
2673
|
+
| Field::ShowAdvanced
|
|
2674
|
+
| Field::SelectedDestinationOverride
|
|
2675
|
+
| Field::SelectedManualApproval
|
|
2676
|
+
| Field::SelectedNetwork
|
|
2677
|
+
| Field::ChainConfigUseAsActive => FieldInteraction::Select,
|
|
2678
|
+
Field::TokenKey
|
|
2679
|
+
| Field::NetworkAddress
|
|
2680
|
+
| Field::PerTxLimit
|
|
2681
|
+
| Field::DailyLimit
|
|
2682
|
+
| Field::WeeklyLimit
|
|
2683
|
+
| Field::MaxGasPerChainWei
|
|
2684
|
+
| Field::DailyMaxTxCount
|
|
2685
|
+
| Field::PerTxMaxFeePerGasGwei
|
|
2686
|
+
| Field::PerTxMaxPriorityFeePerGasWei
|
|
2687
|
+
| Field::PerTxMaxCalldataBytes
|
|
2688
|
+
| Field::OverrideRecipientAddress
|
|
2689
|
+
| Field::OverridePerTxLimit
|
|
2690
|
+
| Field::OverrideDailyLimit
|
|
2691
|
+
| Field::OverrideWeeklyLimit
|
|
2692
|
+
| Field::OverrideMaxGasPerChainWei
|
|
2693
|
+
| Field::OverrideDailyMaxTxCount
|
|
2694
|
+
| Field::OverridePerTxMaxFeePerGasGwei
|
|
2695
|
+
| Field::OverridePerTxMaxPriorityFeePerGasWei
|
|
2696
|
+
| Field::OverridePerTxMaxCalldataBytes
|
|
2697
|
+
| Field::ManualApprovalRecipientAddress
|
|
2698
|
+
| Field::ManualApprovalMinAmount
|
|
2699
|
+
| Field::ManualApprovalMaxAmount
|
|
2700
|
+
| Field::ManualApprovalPriority
|
|
2701
|
+
| Field::ChainConfigKey
|
|
2702
|
+
| Field::ChainConfigId
|
|
2703
|
+
| Field::ChainConfigName
|
|
2704
|
+
| Field::ChainConfigRpcUrl => FieldInteraction::Edit,
|
|
2705
|
+
Field::RefreshTokenMetadata
|
|
2706
|
+
| Field::DestinationOverrides
|
|
2707
|
+
| Field::DeleteDestinationOverride
|
|
2708
|
+
| Field::ManualApprovals
|
|
2709
|
+
| Field::DeleteManualApproval
|
|
2710
|
+
| Field::SaveToken
|
|
2711
|
+
| Field::DeleteToken
|
|
2712
|
+
| Field::SaveNetwork
|
|
2713
|
+
| Field::DeleteNetwork
|
|
2714
|
+
| Field::Execute => FieldInteraction::Action,
|
|
2715
|
+
Field::TokenName | Field::TokenSymbol | Field::TokenDecimals => FieldInteraction::ReadOnly,
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
fn field_display_label(field: Field) -> String {
|
|
2720
|
+
format!(
|
|
2721
|
+
"{} {}",
|
|
2722
|
+
field_interaction(field).badge(),
|
|
2723
|
+
field_label(field)
|
|
2724
|
+
)
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2491
2727
|
fn field_value(app: &AppState, field: Field) -> String {
|
|
2492
2728
|
match field {
|
|
2493
2729
|
Field::SelectedToken => app
|
|
@@ -3402,10 +3638,18 @@ fn validate_optional_overlay_limit(
|
|
|
3402
3638
|
mod tests {
|
|
3403
3639
|
use super::{
|
|
3404
3640
|
apply_with_progress, build_bootstrap_panel_lines, build_token_panel_lines,
|
|
3405
|
-
handle_key_event,
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3641
|
+
handle_key_event, handle_terminal_event, resolve_all_token_manual_approval_policies,
|
|
3642
|
+
resolve_all_token_policies, resolve_gwei_or_wei_or_default,
|
|
3643
|
+
resolve_optional_gwei_or_wei, resolve_optional_policy_value,
|
|
3644
|
+
resolve_optional_policy_value_or_default, resolve_policy_amount_or_default,
|
|
3645
|
+
resolve_required_policy_amount, run_save_network_and_apply, run_save_token_and_apply,
|
|
3646
|
+
status_message_style, validate_destination_override_overlay,
|
|
3647
|
+
validate_optional_overlay_limit, validate_overlay_limit, AppState, ChainProfile, Event,
|
|
3648
|
+
Field, KeyCode, KeyEvent, KeyModifiers, LoopAction, ManualApprovalDraft, MessageLevel,
|
|
3649
|
+
NetworkDraft, PendingDeleteAction, PendingDiscardAction, ResolvedLimitFields,
|
|
3650
|
+
TokenChainProfile, TokenDestinationOverrideProfile, TokenDraft,
|
|
3651
|
+
TokenManualApprovalProfile, TokenNetworkDraft, TokenPolicyProfile, TokenProfile, View,
|
|
3652
|
+
WlfiConfig,
|
|
3409
3653
|
};
|
|
3410
3654
|
use crate::shared_config::WalletProfile;
|
|
3411
3655
|
use ratatui::{backend::TestBackend, style::Color, Terminal};
|
|
@@ -3454,28 +3698,28 @@ mod tests {
|
|
|
3454
3698
|
fn sample_config() -> WlfiConfig {
|
|
3455
3699
|
let mut config = empty_config();
|
|
3456
3700
|
config.chains.insert(
|
|
3457
|
-
"
|
|
3701
|
+
"eth".to_string(),
|
|
3458
3702
|
ChainProfile {
|
|
3459
|
-
chain_id:
|
|
3460
|
-
name: "
|
|
3461
|
-
rpc_url: Some("https://rpc.
|
|
3703
|
+
chain_id: 1,
|
|
3704
|
+
name: "eth".to_string(),
|
|
3705
|
+
rpc_url: Some("https://rpc.ethereum.example".to_string()),
|
|
3462
3706
|
extra: BTreeMap::new(),
|
|
3463
3707
|
},
|
|
3464
3708
|
);
|
|
3465
3709
|
config.chains.insert(
|
|
3466
|
-
"
|
|
3710
|
+
"bsc".to_string(),
|
|
3467
3711
|
ChainProfile {
|
|
3468
|
-
chain_id:
|
|
3469
|
-
name: "
|
|
3470
|
-
rpc_url: Some("https://
|
|
3712
|
+
chain_id: 56,
|
|
3713
|
+
name: "bsc".to_string(),
|
|
3714
|
+
rpc_url: Some("https://rpc.bsc.example".to_string()),
|
|
3471
3715
|
extra: BTreeMap::new(),
|
|
3472
3716
|
},
|
|
3473
3717
|
);
|
|
3474
3718
|
config.tokens.insert(
|
|
3475
|
-
"
|
|
3719
|
+
"usd1".to_string(),
|
|
3476
3720
|
TokenProfile {
|
|
3477
|
-
name: Some("
|
|
3478
|
-
symbol: "
|
|
3721
|
+
name: Some("USD1".to_string()),
|
|
3722
|
+
symbol: "USD1".to_string(),
|
|
3479
3723
|
default_policy: Some(sample_policy("10", "100", "500")),
|
|
3480
3724
|
destination_overrides: vec![TokenDestinationOverrideProfile {
|
|
3481
3725
|
recipient: "0x1000000000000000000000000000000000000001".to_string(),
|
|
@@ -3494,9 +3738,9 @@ mod tests {
|
|
|
3494
3738
|
}],
|
|
3495
3739
|
chains: BTreeMap::from([
|
|
3496
3740
|
(
|
|
3497
|
-
"
|
|
3741
|
+
"eth".to_string(),
|
|
3498
3742
|
TokenChainProfile {
|
|
3499
|
-
chain_id:
|
|
3743
|
+
chain_id: 1,
|
|
3500
3744
|
is_native: false,
|
|
3501
3745
|
address: Some("0x1000000000000000000000000000000000000000".to_string()),
|
|
3502
3746
|
decimals: 6,
|
|
@@ -3505,9 +3749,9 @@ mod tests {
|
|
|
3505
3749
|
},
|
|
3506
3750
|
),
|
|
3507
3751
|
(
|
|
3508
|
-
"
|
|
3752
|
+
"bsc".to_string(),
|
|
3509
3753
|
TokenChainProfile {
|
|
3510
|
-
chain_id:
|
|
3754
|
+
chain_id: 56,
|
|
3511
3755
|
is_native: false,
|
|
3512
3756
|
address: Some("0x2000000000000000000000000000000000000000".to_string()),
|
|
3513
3757
|
decimals: 6,
|
|
@@ -3522,6 +3766,240 @@ mod tests {
|
|
|
3522
3766
|
config
|
|
3523
3767
|
}
|
|
3524
3768
|
|
|
3769
|
+
#[test]
|
|
3770
|
+
fn view_and_message_level_helpers_cover_navigation_and_labels() {
|
|
3771
|
+
assert_eq!(View::Tokens.title(), "Tokens");
|
|
3772
|
+
assert!(View::Tokens.description().contains("source of truth"));
|
|
3773
|
+
assert_eq!(View::Tokens.next(), View::Networks);
|
|
3774
|
+
assert_eq!(View::Networks.next(), View::Bootstrap);
|
|
3775
|
+
assert_eq!(View::Bootstrap.next(), View::Tokens);
|
|
3776
|
+
assert_eq!(View::Tokens.previous(), View::Bootstrap);
|
|
3777
|
+
assert_eq!(View::Networks.previous(), View::Tokens);
|
|
3778
|
+
assert_eq!(View::Bootstrap.previous(), View::Networks);
|
|
3779
|
+
|
|
3780
|
+
assert_eq!(MessageLevel::Info.style(), ratatui::style::Style::default().fg(Color::Cyan));
|
|
3781
|
+
assert_eq!(
|
|
3782
|
+
MessageLevel::Success.style(),
|
|
3783
|
+
ratatui::style::Style::default().fg(Color::Green)
|
|
3784
|
+
);
|
|
3785
|
+
assert_eq!(MessageLevel::Error.style(), ratatui::style::Style::default().fg(Color::Red));
|
|
3786
|
+
|
|
3787
|
+
let mut app = AppState::from_shared_config(&sample_config(), false);
|
|
3788
|
+
assert_eq!(app.active_draft_label(), "token draft");
|
|
3789
|
+
app.token_dirty = true;
|
|
3790
|
+
assert!(app.active_draft_dirty());
|
|
3791
|
+
|
|
3792
|
+
app.view = View::Networks;
|
|
3793
|
+
assert_eq!(app.active_draft_label(), "network draft");
|
|
3794
|
+
app.network_dirty = true;
|
|
3795
|
+
assert!(app.active_draft_dirty());
|
|
3796
|
+
|
|
3797
|
+
app.view = View::Bootstrap;
|
|
3798
|
+
assert_eq!(app.active_draft_label(), "current view");
|
|
3799
|
+
assert!(!app.active_draft_dirty());
|
|
3800
|
+
}
|
|
3801
|
+
|
|
3802
|
+
#[test]
|
|
3803
|
+
fn limit_draft_roundtrip_and_empty_policy_helpers_cover_chain_paths() {
|
|
3804
|
+
let empty = super::LimitDraft::from_policy(None, 6);
|
|
3805
|
+
assert_eq!(empty, super::LimitDraft::empty());
|
|
3806
|
+
|
|
3807
|
+
let draft = super::LimitDraft::from_policy(Some(&sample_policy("12.5", "100", "500.25")), 6);
|
|
3808
|
+
assert_eq!(draft.per_tx_limit, "12.5");
|
|
3809
|
+
assert_eq!(draft.daily_limit, "100");
|
|
3810
|
+
assert_eq!(draft.weekly_limit, "500.25");
|
|
3811
|
+
assert_eq!(draft.max_gas_per_chain_wei, "1000000000000000");
|
|
3812
|
+
assert_eq!(draft.per_tx_max_fee_per_gas_gwei, "25");
|
|
3813
|
+
|
|
3814
|
+
let token_policy = draft.as_token_level_policy(6).expect("token-level policy");
|
|
3815
|
+
assert_eq!(token_policy.per_tx_amount_decimal.as_deref(), Some("12.5"));
|
|
3816
|
+
assert_eq!(token_policy.daily_amount_decimal.as_deref(), Some("100"));
|
|
3817
|
+
assert_eq!(token_policy.weekly_amount_decimal.as_deref(), Some("500.25"));
|
|
3818
|
+
assert_eq!(
|
|
3819
|
+
token_policy.per_tx_max_fee_per_gas_wei.as_deref(),
|
|
3820
|
+
Some("25000000000")
|
|
3821
|
+
);
|
|
3822
|
+
assert!(token_policy.daily_max_tx_count.is_none());
|
|
3823
|
+
|
|
3824
|
+
let chain_policy = draft.as_chain_policy(6).expect("chain policy");
|
|
3825
|
+
assert_eq!(chain_policy.per_tx_limit.as_deref(), Some("12500000"));
|
|
3826
|
+
assert_eq!(chain_policy.daily_limit.as_deref(), Some("100000000"));
|
|
3827
|
+
assert_eq!(chain_policy.weekly_limit.as_deref(), Some("500250000"));
|
|
3828
|
+
}
|
|
3829
|
+
|
|
3830
|
+
#[test]
|
|
3831
|
+
fn field_visibility_and_selection_helpers_cover_view_transitions() {
|
|
3832
|
+
let mut app = AppState::from_shared_config(&sample_config(), false);
|
|
3833
|
+
app.message = Some("stale".to_string());
|
|
3834
|
+
app.pending_discard_action = Some(PendingDiscardAction::NextView);
|
|
3835
|
+
app.pending_delete_action = Some(PendingDeleteAction::DeleteToken("usd1".to_string()));
|
|
3836
|
+
app.selected = usize::MAX;
|
|
3837
|
+
app.normalize_selection();
|
|
3838
|
+
assert_eq!(app.selected, app.visible_fields().len() - 1);
|
|
3839
|
+
|
|
3840
|
+
app.next_view();
|
|
3841
|
+
assert_eq!(app.view, View::Networks);
|
|
3842
|
+
assert_eq!(app.selected, 0);
|
|
3843
|
+
assert!(app.message.is_none());
|
|
3844
|
+
assert!(app.pending_discard_action.is_none());
|
|
3845
|
+
assert!(app.pending_delete_action.is_none());
|
|
3846
|
+
assert!(app.visible_fields().contains(&Field::DeleteNetwork));
|
|
3847
|
+
|
|
3848
|
+
app.previous_view();
|
|
3849
|
+
assert_eq!(app.view, View::Tokens);
|
|
3850
|
+
|
|
3851
|
+
app.view = View::Bootstrap;
|
|
3852
|
+
assert_eq!(app.visible_fields(), vec![Field::Execute]);
|
|
3853
|
+
app.select_next();
|
|
3854
|
+
assert_eq!(app.selected, 0);
|
|
3855
|
+
app.select_prev();
|
|
3856
|
+
assert_eq!(app.selected, 0);
|
|
3857
|
+
assert!(app.request_reload_current_view());
|
|
3858
|
+
app.request_new_current_draft();
|
|
3859
|
+
assert_eq!(app.view, View::Bootstrap);
|
|
3860
|
+
assert!(app.request_cancel());
|
|
3861
|
+
}
|
|
3862
|
+
|
|
3863
|
+
#[test]
|
|
3864
|
+
fn delete_key_and_pending_selection_helpers_cover_errors_and_normalization() {
|
|
3865
|
+
let mut app = AppState::from_shared_config(&empty_config(), false);
|
|
3866
|
+
assert!(app.pending_token_delete_key().is_err());
|
|
3867
|
+
|
|
3868
|
+
app.view = View::Networks;
|
|
3869
|
+
assert!(app.pending_network_delete_key().is_err());
|
|
3870
|
+
|
|
3871
|
+
app.view = View::Tokens;
|
|
3872
|
+
app.token_draft.key = " MixedToken ".to_string();
|
|
3873
|
+
assert_eq!(
|
|
3874
|
+
app.pending_token_delete_key().expect("pending token key"),
|
|
3875
|
+
"mixedtoken"
|
|
3876
|
+
);
|
|
3877
|
+
|
|
3878
|
+
app.view = View::Networks;
|
|
3879
|
+
app.network_draft.key = " ExampleChain ".to_string();
|
|
3880
|
+
assert_eq!(
|
|
3881
|
+
app.pending_network_delete_key().expect("pending network key"),
|
|
3882
|
+
"examplechain"
|
|
3883
|
+
);
|
|
3884
|
+
}
|
|
3885
|
+
|
|
3886
|
+
#[test]
|
|
3887
|
+
fn save_and_apply_helpers_return_success_messages() {
|
|
3888
|
+
let mut token_app = AppState::from_shared_config(&sample_config(), false);
|
|
3889
|
+
let backend = TestBackend::new(120, 40);
|
|
3890
|
+
let mut terminal = Terminal::new(backend).expect("terminal");
|
|
3891
|
+
let (token_output, token_message) = run_save_token_and_apply(
|
|
3892
|
+
&mut terminal,
|
|
3893
|
+
&mut token_app,
|
|
3894
|
+
&mut |params, on_status| {
|
|
3895
|
+
on_status("token apply hook").expect("status");
|
|
3896
|
+
Ok(params.token_policies.len())
|
|
3897
|
+
},
|
|
3898
|
+
)
|
|
3899
|
+
.expect("save token and apply");
|
|
3900
|
+
assert_eq!(token_output, 2);
|
|
3901
|
+
assert!(token_message.contains("saved token"));
|
|
3902
|
+
|
|
3903
|
+
let mut network_app = AppState::from_shared_config(&sample_config(), false);
|
|
3904
|
+
network_app.view = View::Networks;
|
|
3905
|
+
let backend = TestBackend::new(120, 40);
|
|
3906
|
+
let mut terminal = Terminal::new(backend).expect("terminal");
|
|
3907
|
+
let (network_output, network_message) = run_save_network_and_apply(
|
|
3908
|
+
&mut terminal,
|
|
3909
|
+
&mut network_app,
|
|
3910
|
+
&mut |params, on_status| {
|
|
3911
|
+
on_status("network apply hook").expect("status");
|
|
3912
|
+
Ok(params.token_policies.len())
|
|
3913
|
+
},
|
|
3914
|
+
)
|
|
3915
|
+
.expect("save network and apply");
|
|
3916
|
+
assert_eq!(network_output, 2);
|
|
3917
|
+
assert!(network_message.contains("saved network"));
|
|
3918
|
+
}
|
|
3919
|
+
|
|
3920
|
+
#[test]
|
|
3921
|
+
fn policy_value_resolution_and_overlay_helpers_cover_remaining_branches() {
|
|
3922
|
+
assert_eq!(
|
|
3923
|
+
resolve_required_policy_amount("limit", Some("1.5"), None, None, 6)
|
|
3924
|
+
.expect("decimal amount"),
|
|
3925
|
+
1_500_000
|
|
3926
|
+
);
|
|
3927
|
+
assert_eq!(
|
|
3928
|
+
resolve_required_policy_amount("limit", None, Some("42"), None, 6).expect("raw amount"),
|
|
3929
|
+
42
|
|
3930
|
+
);
|
|
3931
|
+
assert_eq!(
|
|
3932
|
+
resolve_required_policy_amount("limit", None, None, Some(2.5), 6)
|
|
3933
|
+
.expect("legacy amount"),
|
|
3934
|
+
2_500_000
|
|
3935
|
+
);
|
|
3936
|
+
assert!(resolve_required_policy_amount("limit", None, None, None, 6).is_err());
|
|
3937
|
+
|
|
3938
|
+
assert_eq!(
|
|
3939
|
+
resolve_policy_amount_or_default(Some("3"), None, None, 9, 6).expect("decimal"),
|
|
3940
|
+
3_000_000
|
|
3941
|
+
);
|
|
3942
|
+
assert_eq!(
|
|
3943
|
+
resolve_policy_amount_or_default(None, Some("7"), None, 9, 6).expect("raw"),
|
|
3944
|
+
7
|
|
3945
|
+
);
|
|
3946
|
+
assert_eq!(
|
|
3947
|
+
resolve_policy_amount_or_default(None, None, Some(1.25), 9, 6).expect("legacy"),
|
|
3948
|
+
1_250_000
|
|
3949
|
+
);
|
|
3950
|
+
assert_eq!(
|
|
3951
|
+
resolve_policy_amount_or_default(None, None, None, 9, 6).expect("default"),
|
|
3952
|
+
9
|
|
3953
|
+
);
|
|
3954
|
+
|
|
3955
|
+
assert_eq!(
|
|
3956
|
+
resolve_optional_gwei_or_wei(Some("2"), None).expect("gwei"),
|
|
3957
|
+
2_000_000_000
|
|
3958
|
+
);
|
|
3959
|
+
assert_eq!(
|
|
3960
|
+
resolve_optional_gwei_or_wei(None, Some("5")).expect("wei"),
|
|
3961
|
+
5
|
|
3962
|
+
);
|
|
3963
|
+
assert_eq!(
|
|
3964
|
+
resolve_gwei_or_wei_or_default(None, None, 11).expect("default"),
|
|
3965
|
+
11
|
|
3966
|
+
);
|
|
3967
|
+
assert_eq!(resolve_optional_policy_value(None).expect("none"), 0);
|
|
3968
|
+
assert_eq!(
|
|
3969
|
+
resolve_optional_policy_value_or_default(None, 13).expect("default"),
|
|
3970
|
+
13
|
|
3971
|
+
);
|
|
3972
|
+
|
|
3973
|
+
let defaults = ResolvedLimitFields {
|
|
3974
|
+
per_tx_max_wei: 100,
|
|
3975
|
+
daily_max_wei: 200,
|
|
3976
|
+
weekly_max_wei: 300,
|
|
3977
|
+
max_gas_per_chain_wei: 400,
|
|
3978
|
+
daily_max_tx_count: 5,
|
|
3979
|
+
per_tx_max_fee_per_gas_wei: 6,
|
|
3980
|
+
per_tx_max_priority_fee_per_gas_wei: 7,
|
|
3981
|
+
per_tx_max_calldata_bytes: 8,
|
|
3982
|
+
};
|
|
3983
|
+
let tighter = ResolvedLimitFields {
|
|
3984
|
+
per_tx_max_wei: 90,
|
|
3985
|
+
daily_max_wei: 190,
|
|
3986
|
+
weekly_max_wei: 290,
|
|
3987
|
+
max_gas_per_chain_wei: 300,
|
|
3988
|
+
daily_max_tx_count: 4,
|
|
3989
|
+
per_tx_max_fee_per_gas_wei: 5,
|
|
3990
|
+
per_tx_max_priority_fee_per_gas_wei: 6,
|
|
3991
|
+
per_tx_max_calldata_bytes: 7,
|
|
3992
|
+
};
|
|
3993
|
+
validate_destination_override_overlay("recipient", &defaults, &tighter)
|
|
3994
|
+
.expect("tighter overlay");
|
|
3995
|
+
|
|
3996
|
+
assert!(validate_overlay_limit("recipient", "per-tx max", 100, 101).is_err());
|
|
3997
|
+
assert!(validate_optional_overlay_limit("recipient", "gas max", 0, 1).is_err());
|
|
3998
|
+
assert!(validate_optional_overlay_limit("recipient", "gas max", 10, 0).is_err());
|
|
3999
|
+
assert!(validate_optional_overlay_limit("recipient", "gas max", 10, 11).is_err());
|
|
4000
|
+
validate_optional_overlay_limit("recipient", "gas max", 10, 9).expect("valid optional");
|
|
4001
|
+
}
|
|
4002
|
+
|
|
3525
4003
|
#[test]
|
|
3526
4004
|
fn tokens_view_is_default_and_not_numbered() {
|
|
3527
4005
|
let app = AppState::from_shared_config(&sample_config(), false);
|
|
@@ -3537,7 +4015,7 @@ mod tests {
|
|
|
3537
4015
|
.map(|line| line.to_string())
|
|
3538
4016
|
.collect::<Vec<_>>()
|
|
3539
4017
|
.join("\n");
|
|
3540
|
-
assert!(rendered.contains("
|
|
4018
|
+
assert!(rendered.contains("USD1 (usd1)"));
|
|
3541
4019
|
assert!(rendered.contains("overrides 1"));
|
|
3542
4020
|
assert!(rendered.contains("manual approvals 1"));
|
|
3543
4021
|
}
|
|
@@ -3554,7 +4032,7 @@ mod tests {
|
|
|
3554
4032
|
#[test]
|
|
3555
4033
|
fn build_params_skips_empty_destination_override_limits() {
|
|
3556
4034
|
let mut config = sample_config();
|
|
3557
|
-
if let Some(token) = config.tokens.get_mut("
|
|
4035
|
+
if let Some(token) = config.tokens.get_mut("usd1") {
|
|
3558
4036
|
token.destination_overrides = vec![TokenDestinationOverrideProfile {
|
|
3559
4037
|
recipient: "0x1000000000000000000000000000000000000001".to_string(),
|
|
3560
4038
|
limits: TokenPolicyProfile::default(),
|
|
@@ -3646,6 +4124,35 @@ mod tests {
|
|
|
3646
4124
|
assert_eq!(super::field_value(&app, Field::PerTxLimit), "10");
|
|
3647
4125
|
}
|
|
3648
4126
|
|
|
4127
|
+
#[test]
|
|
4128
|
+
fn field_display_labels_show_interaction_badges() {
|
|
4129
|
+
assert_eq!(super::field_display_label(Field::TokenKey), "[E] Token Key");
|
|
4130
|
+
assert_eq!(
|
|
4131
|
+
super::field_display_label(Field::SelectedToken),
|
|
4132
|
+
"[S] Selected Token"
|
|
4133
|
+
);
|
|
4134
|
+
assert_eq!(
|
|
4135
|
+
super::field_display_label(Field::RefreshTokenMetadata),
|
|
4136
|
+
"[A] Fetch Metadata"
|
|
4137
|
+
);
|
|
4138
|
+
assert_eq!(
|
|
4139
|
+
super::field_display_label(Field::TokenName),
|
|
4140
|
+
"[R] Token Name (RPC)"
|
|
4141
|
+
);
|
|
4142
|
+
}
|
|
4143
|
+
|
|
4144
|
+
#[test]
|
|
4145
|
+
fn token_help_lines_include_interaction_legend() {
|
|
4146
|
+
let rendered = super::build_help_lines(View::Tokens)
|
|
4147
|
+
.into_iter()
|
|
4148
|
+
.map(|line| line.to_string())
|
|
4149
|
+
.collect::<Vec<_>>()
|
|
4150
|
+
.join("\n");
|
|
4151
|
+
assert!(rendered.contains("Legend: [E] edit [S] select/toggle [A] Enter [R] read-only"));
|
|
4152
|
+
assert!(rendered.contains("Paste: terminal paste appends into editable fields."));
|
|
4153
|
+
assert!(rendered.contains("RPC fields are [R]; use [A] Fetch Metadata to refresh them."));
|
|
4154
|
+
}
|
|
4155
|
+
|
|
3649
4156
|
#[test]
|
|
3650
4157
|
fn save_token_defers_apply_to_event_loop_when_metadata_is_complete() {
|
|
3651
4158
|
let config = sample_config();
|
|
@@ -3668,6 +4175,84 @@ mod tests {
|
|
|
3668
4175
|
fs::remove_dir_all(config_root).expect("cleanup temp config root");
|
|
3669
4176
|
}
|
|
3670
4177
|
|
|
4178
|
+
#[test]
|
|
4179
|
+
fn ctrl_s_saves_dirty_token_before_bootstrap() {
|
|
4180
|
+
let config = sample_config();
|
|
4181
|
+
let mut app = AppState::from_shared_config(&config, false);
|
|
4182
|
+
app.token_draft.key.push('x');
|
|
4183
|
+
app.mark_token_dirty();
|
|
4184
|
+
|
|
4185
|
+
let action = handle_key_event(
|
|
4186
|
+
&mut app,
|
|
4187
|
+
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL),
|
|
4188
|
+
)
|
|
4189
|
+
.expect("handle ctrl+s");
|
|
4190
|
+
|
|
4191
|
+
assert!(matches!(action, LoopAction::SaveTokenAndApply));
|
|
4192
|
+
}
|
|
4193
|
+
|
|
4194
|
+
#[test]
|
|
4195
|
+
fn ctrl_s_saves_dirty_network_before_bootstrap() {
|
|
4196
|
+
let config = sample_config();
|
|
4197
|
+
let mut app = AppState::from_shared_config(&config, false);
|
|
4198
|
+
app.view = View::Networks;
|
|
4199
|
+
app.network_draft.name.push('x');
|
|
4200
|
+
app.mark_network_dirty();
|
|
4201
|
+
|
|
4202
|
+
let action = handle_key_event(
|
|
4203
|
+
&mut app,
|
|
4204
|
+
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL),
|
|
4205
|
+
)
|
|
4206
|
+
.expect("handle ctrl+s");
|
|
4207
|
+
|
|
4208
|
+
assert!(matches!(action, LoopAction::SaveNetworkAndApply));
|
|
4209
|
+
}
|
|
4210
|
+
|
|
4211
|
+
#[test]
|
|
4212
|
+
fn paste_event_appends_trimmed_text_to_editable_fields() {
|
|
4213
|
+
let config = sample_config();
|
|
4214
|
+
let mut app = AppState::from_shared_config(&config, false);
|
|
4215
|
+
app.view = View::Networks;
|
|
4216
|
+
app.network_draft.rpc_url.clear();
|
|
4217
|
+
app.selected = app
|
|
4218
|
+
.visible_fields()
|
|
4219
|
+
.iter()
|
|
4220
|
+
.position(|field| *field == Field::ChainConfigRpcUrl)
|
|
4221
|
+
.expect("chain config rpc field");
|
|
4222
|
+
|
|
4223
|
+
let action = handle_terminal_event(
|
|
4224
|
+
&mut app,
|
|
4225
|
+
Event::Paste(" https://bsc.drpc.org \n".to_string()),
|
|
4226
|
+
)
|
|
4227
|
+
.expect("handle paste");
|
|
4228
|
+
|
|
4229
|
+
assert!(matches!(action, LoopAction::Continue));
|
|
4230
|
+
assert_eq!(app.network_draft.rpc_url, "https://bsc.drpc.org");
|
|
4231
|
+
assert!(app.network_dirty);
|
|
4232
|
+
}
|
|
4233
|
+
|
|
4234
|
+
#[test]
|
|
4235
|
+
fn paste_event_rejects_invalid_chars_for_selected_field() {
|
|
4236
|
+
let config = sample_config();
|
|
4237
|
+
let mut app = AppState::from_shared_config(&config, false);
|
|
4238
|
+
let original_key = app.token_draft.key.clone();
|
|
4239
|
+
app.selected = app
|
|
4240
|
+
.visible_fields()
|
|
4241
|
+
.iter()
|
|
4242
|
+
.position(|field| *field == Field::TokenKey)
|
|
4243
|
+
.expect("token key field");
|
|
4244
|
+
|
|
4245
|
+
let action = handle_terminal_event(&mut app, Event::Paste("bad key".to_string()))
|
|
4246
|
+
.expect("handle invalid paste");
|
|
4247
|
+
|
|
4248
|
+
assert!(matches!(action, LoopAction::Continue));
|
|
4249
|
+
assert_eq!(app.token_draft.key, original_key);
|
|
4250
|
+
assert_eq!(
|
|
4251
|
+
app.message.as_deref(),
|
|
4252
|
+
Some("invalid character ' ' for the selected field")
|
|
4253
|
+
);
|
|
4254
|
+
}
|
|
4255
|
+
|
|
3671
4256
|
#[test]
|
|
3672
4257
|
fn refresh_metadata_action_is_deferred_to_event_loop_for_progress_rendering() {
|
|
3673
4258
|
let config = sample_config();
|
|
@@ -3690,13 +4275,14 @@ mod tests {
|
|
|
3690
4275
|
let mut app = AppState::from_shared_config(&sample_config(), false);
|
|
3691
4276
|
let params = app.build_params().expect("params");
|
|
3692
4277
|
|
|
3693
|
-
let output =
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
4278
|
+
let output =
|
|
4279
|
+
apply_with_progress(&mut terminal, &mut app, params, &mut |params, on_status| {
|
|
4280
|
+
assert_eq!(params.token_policies.len(), 2);
|
|
4281
|
+
on_status("initializing daemon")?;
|
|
4282
|
+
on_status("registering spending policies")?;
|
|
4283
|
+
Ok("ok".to_string())
|
|
4284
|
+
})
|
|
4285
|
+
.expect("apply succeeds");
|
|
3700
4286
|
|
|
3701
4287
|
assert_eq!(output, "ok");
|
|
3702
4288
|
assert_eq!(
|
|
@@ -3774,8 +4360,11 @@ mod tests {
|
|
|
3774
4360
|
.position(|field| *field == Field::TokenKey)
|
|
3775
4361
|
.expect("token key field");
|
|
3776
4362
|
|
|
3777
|
-
let _ = handle_key_event(
|
|
3778
|
-
|
|
4363
|
+
let _ = handle_key_event(
|
|
4364
|
+
&mut app,
|
|
4365
|
+
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
|
|
4366
|
+
)
|
|
4367
|
+
.expect("edit token key");
|
|
3779
4368
|
assert!(app.token_dirty);
|
|
3780
4369
|
|
|
3781
4370
|
let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
|
|
@@ -3803,8 +4392,11 @@ mod tests {
|
|
|
3803
4392
|
.position(|field| *field == Field::ChainConfigName)
|
|
3804
4393
|
.expect("chain config name field");
|
|
3805
4394
|
|
|
3806
|
-
let _ = handle_key_event(
|
|
3807
|
-
|
|
4395
|
+
let _ = handle_key_event(
|
|
4396
|
+
&mut app,
|
|
4397
|
+
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
|
|
4398
|
+
)
|
|
4399
|
+
.expect("edit network name");
|
|
3808
4400
|
assert!(app.network_dirty);
|
|
3809
4401
|
assert!(app.network_draft.name.ends_with('x'));
|
|
3810
4402
|
|
|
@@ -3837,8 +4429,11 @@ mod tests {
|
|
|
3837
4429
|
.iter()
|
|
3838
4430
|
.position(|field| *field == Field::TokenKey)
|
|
3839
4431
|
.expect("token key field");
|
|
3840
|
-
let _ = handle_key_event(
|
|
3841
|
-
|
|
4432
|
+
let _ = handle_key_event(
|
|
4433
|
+
&mut app,
|
|
4434
|
+
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
|
|
4435
|
+
)
|
|
4436
|
+
.expect("edit token key");
|
|
3842
4437
|
|
|
3843
4438
|
app.selected = app
|
|
3844
4439
|
.visible_fields()
|
|
@@ -3848,7 +4443,7 @@ mod tests {
|
|
|
3848
4443
|
|
|
3849
4444
|
let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Right, KeyModifiers::NONE))
|
|
3850
4445
|
.expect("first cycle");
|
|
3851
|
-
assert_eq!(app.token_draft.source_key.as_deref(), Some("
|
|
4446
|
+
assert_eq!(app.token_draft.source_key.as_deref(), Some("usd1"));
|
|
3852
4447
|
assert!(app
|
|
3853
4448
|
.message
|
|
3854
4449
|
.as_deref()
|
|
@@ -3861,6 +4456,53 @@ mod tests {
|
|
|
3861
4456
|
assert!(!app.token_dirty);
|
|
3862
4457
|
}
|
|
3863
4458
|
|
|
4459
|
+
#[test]
|
|
4460
|
+
fn q_requires_confirmation_before_canceling_dirty_token_draft() {
|
|
4461
|
+
let mut app = AppState::from_shared_config(&sample_config(), false);
|
|
4462
|
+
app.token_draft.key.push('x');
|
|
4463
|
+
app.mark_token_dirty();
|
|
4464
|
+
|
|
4465
|
+
let action = handle_key_event(
|
|
4466
|
+
&mut app,
|
|
4467
|
+
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
|
|
4468
|
+
)
|
|
4469
|
+
.expect("first cancel");
|
|
4470
|
+
assert!(matches!(action, LoopAction::Continue));
|
|
4471
|
+
assert!(app
|
|
4472
|
+
.message
|
|
4473
|
+
.as_deref()
|
|
4474
|
+
.expect("discard warning")
|
|
4475
|
+
.contains("unsaved changes in the token draft"));
|
|
4476
|
+
|
|
4477
|
+
let action = handle_key_event(
|
|
4478
|
+
&mut app,
|
|
4479
|
+
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
|
|
4480
|
+
)
|
|
4481
|
+
.expect("second cancel");
|
|
4482
|
+
assert!(matches!(action, LoopAction::Cancel));
|
|
4483
|
+
}
|
|
4484
|
+
|
|
4485
|
+
#[test]
|
|
4486
|
+
fn escape_requires_confirmation_before_canceling_dirty_network_draft() {
|
|
4487
|
+
let mut app = AppState::from_shared_config(&sample_config(), false);
|
|
4488
|
+
app.view = View::Networks;
|
|
4489
|
+
app.network_draft.name.push('x');
|
|
4490
|
+
app.mark_network_dirty();
|
|
4491
|
+
|
|
4492
|
+
let action = handle_key_event(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
|
|
4493
|
+
.expect("first cancel");
|
|
4494
|
+
assert!(matches!(action, LoopAction::Continue));
|
|
4495
|
+
assert!(app
|
|
4496
|
+
.message
|
|
4497
|
+
.as_deref()
|
|
4498
|
+
.expect("discard warning")
|
|
4499
|
+
.contains("unsaved changes in the network draft"));
|
|
4500
|
+
|
|
4501
|
+
let action = handle_key_event(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
|
|
4502
|
+
.expect("second cancel");
|
|
4503
|
+
assert!(matches!(action, LoopAction::Cancel));
|
|
4504
|
+
}
|
|
4505
|
+
|
|
3864
4506
|
#[test]
|
|
3865
4507
|
fn delete_manual_approval_requires_confirmation() {
|
|
3866
4508
|
let mut app = AppState::from_shared_config(&sample_config(), false);
|
|
@@ -3884,6 +4526,103 @@ mod tests {
|
|
|
3884
4526
|
assert!(app.token_draft.manual_approvals.is_empty());
|
|
3885
4527
|
}
|
|
3886
4528
|
|
|
4529
|
+
#[test]
|
|
4530
|
+
fn changing_selected_manual_approval_restarts_delete_confirmation() {
|
|
4531
|
+
let mut config = sample_config();
|
|
4532
|
+
let token = config.tokens.get_mut("usd1").expect("usd1 token");
|
|
4533
|
+
let mut second_policy = token.manual_approval_policies[0].clone();
|
|
4534
|
+
second_policy.priority = 200;
|
|
4535
|
+
second_policy.min_amount_decimal = Some("600".to_string());
|
|
4536
|
+
second_policy.max_amount_decimal = Some("900".to_string());
|
|
4537
|
+
token.manual_approval_policies.push(second_policy);
|
|
4538
|
+
|
|
4539
|
+
let mut app = AppState::from_shared_config(&config, false);
|
|
4540
|
+
let delete_field = app
|
|
4541
|
+
.visible_fields()
|
|
4542
|
+
.iter()
|
|
4543
|
+
.position(|field| *field == Field::DeleteManualApproval)
|
|
4544
|
+
.expect("delete manual approval field");
|
|
4545
|
+
let selected_field = app
|
|
4546
|
+
.visible_fields()
|
|
4547
|
+
.iter()
|
|
4548
|
+
.position(|field| *field == Field::SelectedManualApproval)
|
|
4549
|
+
.expect("selected manual approval field");
|
|
4550
|
+
|
|
4551
|
+
app.selected = delete_field;
|
|
4552
|
+
let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
|
|
4553
|
+
.expect("first delete");
|
|
4554
|
+
assert_eq!(app.token_draft.manual_approvals.len(), 2);
|
|
4555
|
+
|
|
4556
|
+
app.selected = selected_field;
|
|
4557
|
+
let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Right, KeyModifiers::NONE))
|
|
4558
|
+
.expect("cycle manual approval");
|
|
4559
|
+
assert_eq!(app.token_draft.selected_manual_approval, 1);
|
|
4560
|
+
|
|
4561
|
+
app.selected = delete_field;
|
|
4562
|
+
let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
|
|
4563
|
+
.expect("re-arm delete for second selection");
|
|
4564
|
+
assert_eq!(app.token_draft.manual_approvals.len(), 2);
|
|
4565
|
+
assert!(app
|
|
4566
|
+
.message
|
|
4567
|
+
.as_deref()
|
|
4568
|
+
.expect("renewed confirmation")
|
|
4569
|
+
.contains("repeat the action to confirm deleting the selected manual approval policy"));
|
|
4570
|
+
|
|
4571
|
+
let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
|
|
4572
|
+
.expect("delete selected manual approval");
|
|
4573
|
+
assert_eq!(app.token_draft.manual_approvals.len(), 1);
|
|
4574
|
+
assert_eq!(app.token_draft.manual_approvals[0].priority, "120");
|
|
4575
|
+
}
|
|
4576
|
+
|
|
4577
|
+
#[test]
|
|
4578
|
+
fn changing_selected_override_restarts_delete_confirmation() {
|
|
4579
|
+
let mut config = sample_config();
|
|
4580
|
+
let token = config.tokens.get_mut("usd1").expect("usd1 token");
|
|
4581
|
+
let mut second_override = token.destination_overrides[0].clone();
|
|
4582
|
+
second_override.recipient = "0x2000000000000000000000000000000000000002".to_string();
|
|
4583
|
+
token.destination_overrides.push(second_override);
|
|
4584
|
+
|
|
4585
|
+
let mut app = AppState::from_shared_config(&config, false);
|
|
4586
|
+
let delete_field = app
|
|
4587
|
+
.visible_fields()
|
|
4588
|
+
.iter()
|
|
4589
|
+
.position(|field| *field == Field::DeleteDestinationOverride)
|
|
4590
|
+
.expect("delete override field");
|
|
4591
|
+
let selected_field = app
|
|
4592
|
+
.visible_fields()
|
|
4593
|
+
.iter()
|
|
4594
|
+
.position(|field| *field == Field::SelectedDestinationOverride)
|
|
4595
|
+
.expect("selected override field");
|
|
4596
|
+
|
|
4597
|
+
app.selected = delete_field;
|
|
4598
|
+
let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
|
|
4599
|
+
.expect("first delete");
|
|
4600
|
+
assert_eq!(app.token_draft.destination_overrides.len(), 2);
|
|
4601
|
+
|
|
4602
|
+
app.selected = selected_field;
|
|
4603
|
+
let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Right, KeyModifiers::NONE))
|
|
4604
|
+
.expect("cycle override");
|
|
4605
|
+
assert_eq!(app.token_draft.selected_override, 1);
|
|
4606
|
+
|
|
4607
|
+
app.selected = delete_field;
|
|
4608
|
+
let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
|
|
4609
|
+
.expect("re-arm delete for second selection");
|
|
4610
|
+
assert_eq!(app.token_draft.destination_overrides.len(), 2);
|
|
4611
|
+
assert!(app
|
|
4612
|
+
.message
|
|
4613
|
+
.as_deref()
|
|
4614
|
+
.expect("renewed confirmation")
|
|
4615
|
+
.contains("repeat the action to confirm deleting the selected destination override"));
|
|
4616
|
+
|
|
4617
|
+
let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
|
|
4618
|
+
.expect("delete selected override");
|
|
4619
|
+
assert_eq!(app.token_draft.destination_overrides.len(), 1);
|
|
4620
|
+
assert_eq!(
|
|
4621
|
+
app.token_draft.destination_overrides[0].recipient_address,
|
|
4622
|
+
"0x1000000000000000000000000000000000000001"
|
|
4623
|
+
);
|
|
4624
|
+
}
|
|
4625
|
+
|
|
3887
4626
|
#[test]
|
|
3888
4627
|
fn delete_token_requires_confirmation() {
|
|
3889
4628
|
let mut app = AppState::from_shared_config(&sample_config(), false);
|
|
@@ -3899,7 +4638,7 @@ mod tests {
|
|
|
3899
4638
|
|
|
3900
4639
|
let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
|
|
3901
4640
|
.expect("first delete");
|
|
3902
|
-
assert!(app.shared_config_draft.tokens.contains_key("
|
|
4641
|
+
assert!(app.shared_config_draft.tokens.contains_key("usd1"));
|
|
3903
4642
|
assert!(app
|
|
3904
4643
|
.message
|
|
3905
4644
|
.as_deref()
|
|
@@ -3908,8 +4647,354 @@ mod tests {
|
|
|
3908
4647
|
|
|
3909
4648
|
let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
|
|
3910
4649
|
.expect("second delete");
|
|
3911
|
-
assert!(!app.shared_config_draft.tokens.contains_key("
|
|
4650
|
+
assert!(!app.shared_config_draft.tokens.contains_key("usd1"));
|
|
3912
4651
|
|
|
3913
4652
|
fs::remove_dir_all(config_root).expect("cleanup temp config root");
|
|
3914
4653
|
}
|
|
4654
|
+
|
|
4655
|
+
#[test]
|
|
4656
|
+
fn token_draft_validation_errors_cover_remaining_paths() {
|
|
4657
|
+
let config = sample_config();
|
|
4658
|
+
let mut draft = TokenDraft::blank(&config);
|
|
4659
|
+
|
|
4660
|
+
assert!(draft.selected_network_mut().is_none());
|
|
4661
|
+
assert!(draft
|
|
4662
|
+
.min_network_decimals()
|
|
4663
|
+
.expect_err("empty draft must fail")
|
|
4664
|
+
.to_string()
|
|
4665
|
+
.contains("select at least one network for the token"));
|
|
4666
|
+
assert!(draft
|
|
4667
|
+
.to_profile(&config)
|
|
4668
|
+
.expect_err("missing key must fail")
|
|
4669
|
+
.to_string()
|
|
4670
|
+
.contains("token key is required"));
|
|
4671
|
+
|
|
4672
|
+
draft.key = "usd1".to_string();
|
|
4673
|
+
assert!(draft
|
|
4674
|
+
.to_profile(&config)
|
|
4675
|
+
.expect_err("missing name must fail")
|
|
4676
|
+
.to_string()
|
|
4677
|
+
.contains("fetch token metadata before saving the token"));
|
|
4678
|
+
|
|
4679
|
+
draft.name = "USD1".to_string();
|
|
4680
|
+
assert!(draft
|
|
4681
|
+
.to_profile(&config)
|
|
4682
|
+
.expect_err("missing symbol must fail")
|
|
4683
|
+
.to_string()
|
|
4684
|
+
.contains("fetch token metadata before saving the token"));
|
|
4685
|
+
|
|
4686
|
+
draft.symbol = "USD1".to_string();
|
|
4687
|
+
assert!(draft
|
|
4688
|
+
.to_profile(&config)
|
|
4689
|
+
.expect_err("missing network must fail")
|
|
4690
|
+
.to_string()
|
|
4691
|
+
.contains("select at least one network for the token"));
|
|
4692
|
+
|
|
4693
|
+
draft.limits = super::LimitDraft::from_policy(Some(&sample_policy("10", "100", "500")), 6);
|
|
4694
|
+
draft.networks.push(TokenNetworkDraft {
|
|
4695
|
+
chain_key: "eth".to_string(),
|
|
4696
|
+
chain_id: "1".to_string(),
|
|
4697
|
+
is_native: false,
|
|
4698
|
+
address: "0x1000000000000000000000000000000000000000".to_string(),
|
|
4699
|
+
decimals: "999".to_string(),
|
|
4700
|
+
});
|
|
4701
|
+
assert!(draft
|
|
4702
|
+
.to_profile(&config)
|
|
4703
|
+
.expect_err("large decimals must fail")
|
|
4704
|
+
.to_string()
|
|
4705
|
+
.contains("decimals must be <= 255"));
|
|
4706
|
+
|
|
4707
|
+
draft.networks[0].decimals = "6".to_string();
|
|
4708
|
+
draft.networks[0].is_native = true;
|
|
4709
|
+
assert!(draft
|
|
4710
|
+
.to_profile(&config)
|
|
4711
|
+
.expect_err("native token with address must fail")
|
|
4712
|
+
.to_string()
|
|
4713
|
+
.contains("must not set an address when native"));
|
|
4714
|
+
|
|
4715
|
+
draft.networks[0].is_native = false;
|
|
4716
|
+
draft.networks[0].chain_key = "missing".to_string();
|
|
4717
|
+
assert!(draft
|
|
4718
|
+
.to_profile(&config)
|
|
4719
|
+
.expect_err("unknown chain must fail")
|
|
4720
|
+
.to_string()
|
|
4721
|
+
.contains("unknown saved network 'missing'"));
|
|
4722
|
+
|
|
4723
|
+
draft.networks[0].chain_key = "eth".to_string();
|
|
4724
|
+
draft.destination_overrides.push(super::DestinationOverrideDraft {
|
|
4725
|
+
recipient_address: "not-an-address".to_string(),
|
|
4726
|
+
limits: draft.limits.clone(),
|
|
4727
|
+
});
|
|
4728
|
+
assert!(draft
|
|
4729
|
+
.to_profile(&config)
|
|
4730
|
+
.expect_err("invalid override recipient must fail")
|
|
4731
|
+
.to_string()
|
|
4732
|
+
.contains("destination override recipient"));
|
|
4733
|
+
|
|
4734
|
+
draft.destination_overrides.clear();
|
|
4735
|
+
draft.manual_approvals.push(ManualApprovalDraft {
|
|
4736
|
+
recipient_address: String::new(),
|
|
4737
|
+
min_amount: "1".to_string(),
|
|
4738
|
+
max_amount: "2".to_string(),
|
|
4739
|
+
priority: (u32::MAX as u64 + 1).to_string(),
|
|
4740
|
+
});
|
|
4741
|
+
assert!(draft
|
|
4742
|
+
.to_profile(&config)
|
|
4743
|
+
.expect_err("large priority must fail")
|
|
4744
|
+
.to_string()
|
|
4745
|
+
.contains("manual approval priority must be <= 4294967295"));
|
|
4746
|
+
|
|
4747
|
+
draft.manual_approvals[0].priority = "10".to_string();
|
|
4748
|
+
draft.manual_approvals[0].recipient_address = "not-an-address".to_string();
|
|
4749
|
+
assert!(draft
|
|
4750
|
+
.to_profile(&config)
|
|
4751
|
+
.expect_err("invalid manual approval recipient must fail")
|
|
4752
|
+
.to_string()
|
|
4753
|
+
.contains("manual approval recipient"));
|
|
4754
|
+
}
|
|
4755
|
+
|
|
4756
|
+
#[test]
|
|
4757
|
+
fn token_draft_membership_and_normalize_cover_remaining_branches() {
|
|
4758
|
+
let empty = empty_config();
|
|
4759
|
+
let mut empty_draft = TokenDraft::blank(&empty);
|
|
4760
|
+
assert!(empty_draft
|
|
4761
|
+
.toggle_network_membership(&empty)
|
|
4762
|
+
.expect_err("empty config must fail")
|
|
4763
|
+
.to_string()
|
|
4764
|
+
.contains("save a network before adding it to a token"));
|
|
4765
|
+
|
|
4766
|
+
let config = sample_config();
|
|
4767
|
+
let mut draft = TokenDraft::blank(&config);
|
|
4768
|
+
draft.available_network_index = 1;
|
|
4769
|
+
draft.toggle_network_membership(&config).expect("add network");
|
|
4770
|
+
assert_eq!(draft.networks.len(), 1);
|
|
4771
|
+
assert_eq!(draft.networks[0].chain_key, "eth");
|
|
4772
|
+
|
|
4773
|
+
draft.destination_overrides.push(super::DestinationOverrideDraft::default());
|
|
4774
|
+
draft.manual_approvals.push(ManualApprovalDraft::default());
|
|
4775
|
+
draft.selected_network = usize::MAX;
|
|
4776
|
+
draft.selected_override = usize::MAX;
|
|
4777
|
+
draft.selected_manual_approval = usize::MAX;
|
|
4778
|
+
|
|
4779
|
+
draft.toggle_network_membership(&config).expect("remove network");
|
|
4780
|
+
assert!(draft.networks.is_empty());
|
|
4781
|
+
assert_eq!(draft.selected_network, 0);
|
|
4782
|
+
assert_eq!(draft.selected_override, 0);
|
|
4783
|
+
assert_eq!(draft.selected_manual_approval, 0);
|
|
4784
|
+
}
|
|
4785
|
+
|
|
4786
|
+
#[test]
|
|
4787
|
+
fn network_draft_to_profile_defaults_name_and_optional_rpc() {
|
|
4788
|
+
let draft = NetworkDraft::blank();
|
|
4789
|
+
assert!(draft
|
|
4790
|
+
.to_profile()
|
|
4791
|
+
.expect_err("blank key must fail")
|
|
4792
|
+
.to_string()
|
|
4793
|
+
.contains("network key is required"));
|
|
4794
|
+
|
|
4795
|
+
let mut draft = NetworkDraft::blank();
|
|
4796
|
+
draft.key = " BSC ".to_string();
|
|
4797
|
+
draft.chain_id = "56".to_string();
|
|
4798
|
+
let (source_key, chain_key, profile, use_as_active) =
|
|
4799
|
+
draft.to_profile().expect("network profile");
|
|
4800
|
+
assert!(source_key.is_none());
|
|
4801
|
+
assert_eq!(chain_key, "bsc");
|
|
4802
|
+
assert_eq!(profile.name, "bsc");
|
|
4803
|
+
assert_eq!(profile.rpc_url, None);
|
|
4804
|
+
assert!(!use_as_active);
|
|
4805
|
+
}
|
|
4806
|
+
|
|
4807
|
+
#[test]
|
|
4808
|
+
fn app_state_network_draft_navigation_helpers_cover_remaining_paths() {
|
|
4809
|
+
let mut app = AppState::from_shared_config(&sample_config(), false);
|
|
4810
|
+
app.view = View::Networks;
|
|
4811
|
+
app.network_draft.name.push('x');
|
|
4812
|
+
app.mark_network_dirty();
|
|
4813
|
+
|
|
4814
|
+
app.request_previous_view();
|
|
4815
|
+
assert_eq!(app.view, View::Networks);
|
|
4816
|
+
assert!(app
|
|
4817
|
+
.message
|
|
4818
|
+
.as_deref()
|
|
4819
|
+
.expect("discard warning")
|
|
4820
|
+
.contains("unsaved changes in the network draft"));
|
|
4821
|
+
|
|
4822
|
+
app.request_previous_view();
|
|
4823
|
+
assert_eq!(app.view, View::Tokens);
|
|
4824
|
+
|
|
4825
|
+
app.view = View::Networks;
|
|
4826
|
+
app.load_network_draft(Some("missing"));
|
|
4827
|
+
assert_eq!(app.network_draft.source_key.as_deref(), Some("bsc"));
|
|
4828
|
+
|
|
4829
|
+
app.request_new_current_draft();
|
|
4830
|
+
assert!(app.network_draft.source_key.is_none());
|
|
4831
|
+
assert_eq!(app.message.as_deref(), Some("new network draft ready"));
|
|
4832
|
+
assert_eq!(app.selected_field(), Field::ChainConfigKey);
|
|
4833
|
+
|
|
4834
|
+
app.cycle_saved_network(1);
|
|
4835
|
+
assert_eq!(app.network_draft.source_key.as_deref(), Some("bsc"));
|
|
4836
|
+
app.cycle_saved_network(-1);
|
|
4837
|
+
assert!(app.network_draft.source_key.is_none());
|
|
4838
|
+
}
|
|
4839
|
+
|
|
4840
|
+
#[test]
|
|
4841
|
+
fn step_selected_and_delete_network_cover_remaining_network_paths() {
|
|
4842
|
+
let config = sample_config();
|
|
4843
|
+
let mut app = AppState::from_shared_config(&config, false);
|
|
4844
|
+
|
|
4845
|
+
app.selected = app
|
|
4846
|
+
.visible_fields()
|
|
4847
|
+
.iter()
|
|
4848
|
+
.position(|field| *field == Field::NetworkMembership)
|
|
4849
|
+
.expect("network membership field");
|
|
4850
|
+
app.step_selected(1);
|
|
4851
|
+
assert_eq!(app.token_draft.available_network_index, 1);
|
|
4852
|
+
|
|
4853
|
+
app.selected = app
|
|
4854
|
+
.visible_fields()
|
|
4855
|
+
.iter()
|
|
4856
|
+
.position(|field| *field == Field::EditingNetwork)
|
|
4857
|
+
.expect("editing network field");
|
|
4858
|
+
app.step_selected(1);
|
|
4859
|
+
assert_eq!(app.token_draft.selected_network, 1);
|
|
4860
|
+
|
|
4861
|
+
app.token_draft.networks[app.token_draft.selected_network].address =
|
|
4862
|
+
"0x3000000000000000000000000000000000000000".to_string();
|
|
4863
|
+
app.selected = app
|
|
4864
|
+
.visible_fields()
|
|
4865
|
+
.iter()
|
|
4866
|
+
.position(|field| *field == Field::NetworkIsNative)
|
|
4867
|
+
.expect("network native field");
|
|
4868
|
+
app.step_selected(1);
|
|
4869
|
+
let selected_network = app
|
|
4870
|
+
.token_draft
|
|
4871
|
+
.selected_network()
|
|
4872
|
+
.expect("selected network");
|
|
4873
|
+
assert!(selected_network.is_native);
|
|
4874
|
+
assert!(selected_network.address.is_empty());
|
|
4875
|
+
|
|
4876
|
+
app.selected = app
|
|
4877
|
+
.visible_fields()
|
|
4878
|
+
.iter()
|
|
4879
|
+
.position(|field| *field == Field::ShowAdvanced)
|
|
4880
|
+
.expect("show advanced field");
|
|
4881
|
+
app.step_selected(1);
|
|
4882
|
+
assert!(app.show_advanced);
|
|
4883
|
+
|
|
4884
|
+
app.view = View::Networks;
|
|
4885
|
+
app.selected = app
|
|
4886
|
+
.visible_fields()
|
|
4887
|
+
.iter()
|
|
4888
|
+
.position(|field| *field == Field::SelectedNetwork)
|
|
4889
|
+
.expect("selected network field");
|
|
4890
|
+
app.step_selected(1);
|
|
4891
|
+
assert_eq!(app.network_draft.source_key.as_deref(), Some("eth"));
|
|
4892
|
+
|
|
4893
|
+
app.selected = app
|
|
4894
|
+
.visible_fields()
|
|
4895
|
+
.iter()
|
|
4896
|
+
.position(|field| *field == Field::ChainConfigUseAsActive)
|
|
4897
|
+
.expect("use as active field");
|
|
4898
|
+
app.step_selected(1);
|
|
4899
|
+
assert!(app.network_draft.use_as_active);
|
|
4900
|
+
assert!(app.network_dirty);
|
|
4901
|
+
|
|
4902
|
+
let mut empty_app = AppState::from_shared_config(&empty_config(), false);
|
|
4903
|
+
empty_app.view = View::Networks;
|
|
4904
|
+
assert!(empty_app
|
|
4905
|
+
.request_delete_network()
|
|
4906
|
+
.expect_err("blank network delete must fail")
|
|
4907
|
+
.to_string()
|
|
4908
|
+
.contains("no saved network is selected"));
|
|
4909
|
+
|
|
4910
|
+
let mut deleting_app = AppState::from_shared_config(&config, false);
|
|
4911
|
+
deleting_app.view = View::Networks;
|
|
4912
|
+
deleting_app
|
|
4913
|
+
.request_delete_network()
|
|
4914
|
+
.expect("first delete only arms confirmation");
|
|
4915
|
+
assert_eq!(
|
|
4916
|
+
deleting_app.pending_delete_action,
|
|
4917
|
+
Some(PendingDeleteAction::DeleteNetwork("bsc".to_string()))
|
|
4918
|
+
);
|
|
4919
|
+
assert!(deleting_app
|
|
4920
|
+
.request_delete_network()
|
|
4921
|
+
.expect_err("network still referenced by token must fail")
|
|
4922
|
+
.to_string()
|
|
4923
|
+
.contains("network 'bsc' is still used by token 'usd1'"));
|
|
4924
|
+
}
|
|
4925
|
+
|
|
4926
|
+
#[test]
|
|
4927
|
+
fn switching_to_new_network_focuses_key_field_for_immediate_typing() {
|
|
4928
|
+
let config = sample_config();
|
|
4929
|
+
let mut app = AppState::from_shared_config(&config, false);
|
|
4930
|
+
app.view = View::Networks;
|
|
4931
|
+
app.selected = app
|
|
4932
|
+
.visible_fields()
|
|
4933
|
+
.iter()
|
|
4934
|
+
.position(|field| *field == Field::SelectedNetwork)
|
|
4935
|
+
.expect("selected network field");
|
|
4936
|
+
|
|
4937
|
+
app.cycle_saved_network(-1);
|
|
4938
|
+
assert!(app.network_draft.source_key.is_none());
|
|
4939
|
+
assert_eq!(app.selected_field(), Field::ChainConfigKey);
|
|
4940
|
+
|
|
4941
|
+
let _ = handle_key_event(
|
|
4942
|
+
&mut app,
|
|
4943
|
+
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE),
|
|
4944
|
+
)
|
|
4945
|
+
.expect("type into new network key");
|
|
4946
|
+
|
|
4947
|
+
assert_eq!(app.network_draft.key, "s");
|
|
4948
|
+
assert!(app.network_dirty);
|
|
4949
|
+
}
|
|
4950
|
+
|
|
4951
|
+
#[test]
|
|
4952
|
+
fn editing_network_name_accepts_letters_that_overlap_navigation_shortcuts() {
|
|
4953
|
+
let mut app = AppState::from_shared_config(&sample_config(), false);
|
|
4954
|
+
app.view = View::Networks;
|
|
4955
|
+
app.request_new_current_draft();
|
|
4956
|
+
app.selected = app
|
|
4957
|
+
.visible_fields()
|
|
4958
|
+
.iter()
|
|
4959
|
+
.position(|field| *field == Field::ChainConfigName)
|
|
4960
|
+
.expect("network name field");
|
|
4961
|
+
|
|
4962
|
+
let selected = app.selected;
|
|
4963
|
+
for ch in "sepolia".chars() {
|
|
4964
|
+
let _ = handle_key_event(
|
|
4965
|
+
&mut app,
|
|
4966
|
+
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
|
|
4967
|
+
)
|
|
4968
|
+
.expect("type network name");
|
|
4969
|
+
}
|
|
4970
|
+
|
|
4971
|
+
assert_eq!(app.selected, selected);
|
|
4972
|
+
assert_eq!(app.network_draft.name, "sepolia");
|
|
4973
|
+
assert!(app.network_dirty);
|
|
4974
|
+
}
|
|
4975
|
+
|
|
4976
|
+
#[test]
|
|
4977
|
+
fn editing_network_rpc_url_accepts_http_prefix() {
|
|
4978
|
+
let mut app = AppState::from_shared_config(&sample_config(), false);
|
|
4979
|
+
app.view = View::Networks;
|
|
4980
|
+
app.request_new_current_draft();
|
|
4981
|
+
app.selected = app
|
|
4982
|
+
.visible_fields()
|
|
4983
|
+
.iter()
|
|
4984
|
+
.position(|field| *field == Field::ChainConfigRpcUrl)
|
|
4985
|
+
.expect("network rpc url field");
|
|
4986
|
+
|
|
4987
|
+
let selected = app.selected;
|
|
4988
|
+
for ch in "http".chars() {
|
|
4989
|
+
let _ = handle_key_event(
|
|
4990
|
+
&mut app,
|
|
4991
|
+
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
|
|
4992
|
+
)
|
|
4993
|
+
.expect("type rpc prefix");
|
|
4994
|
+
}
|
|
4995
|
+
|
|
4996
|
+
assert_eq!(app.selected, selected);
|
|
4997
|
+
assert_eq!(app.network_draft.rpc_url, "http");
|
|
4998
|
+
assert!(app.network_dirty);
|
|
4999
|
+
}
|
|
3915
5000
|
}
|