1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
package com.arblant.railwayipscanner
/**
* Generate-workbench assignment logic (final spec 2026-10-27).
*
* Pure and engine-free: it only reads [ScanResult]s produced by the
* scanner and decides which clean IP each generated config row gets.
* Keeping it free of Android views makes the semantics auditable:
*
* - TOP_N: row i gets the i-th fastest distinct IP; when the count
* exceeds the number of clean IPs the assignment wraps
* from the top (and [hasShortage] reports it so the UI can
* show the "some repeat" notice instead of hiding it).
* - BEST_ALL: every row gets the single fastest IP.
* - CUSTOM: per-row picks (by row index) win while they exist. A pick
* whose IP is no longer in the latest scan results is kept
* but flagged [Row.stale]; a pick that differs from the
* automatic (Top N) assignment is flagged [Row.changed].
* Rows without a pick fall back to the Top N assignment.
*/
object WorkbenchLogic {
enum class Mode { TOP_N, BEST_ALL, CUSTOM }
const val MIN_COUNT = 1
const val MAX_COUNT = 20
const val DEFAULT_COUNT = 5
data class Row(
val index: Int,
val ip: String,
/** Last known latency for [ip] from the scan results, or null. */
val latencyMs: Long?,
/** True when a custom pick's IP is absent from the latest results. */
val stale: Boolean,
/** True when a custom pick differs from the automatic assignment. */
val changed: Boolean
)
/** The automatic assignment for [mode]; CUSTOM's baseline is Top N. */
fun autoAssignment(mode: Mode, count: Int, sortedIps: List<String>): List<String> {
if (sortedIps.isEmpty() || count <= 0) return emptyList()
return when (mode) {
Mode.BEST_ALL -> List(count) { sortedIps.first() }
Mode.TOP_N, Mode.CUSTOM -> List(count) { i -> sortedIps[i % sortedIps.size] }
}
}
/** True when Top N is asked for more configs than there are clean IPs. */
fun hasShortage(mode: Mode, count: Int, uniqueIpCount: Int): Boolean =
mode == Mode.TOP_N && uniqueIpCount > 0 && count > uniqueIpCount
/**
* Resolve the visible rows. [results] may arrive in any order; they
* are ranked here by ascending latency. [customPicks] maps row index
* to a user-chosen IP (Custom mode only).
*/
fun resolve(
mode: Mode,
count: Int,
results: List<ScanResult>,
customPicks: Map<Int, String>
): List<Row> {
val sorted = results.filter { it.isSuccess }.sortedBy { it.delayMs }
val sortedIps = sorted.map { it.ip }
val latencyByIp = sorted.associate { it.ip to it.delayMs }
val auto = autoAssignment(mode, count, sortedIps)
if (auto.isEmpty()) return emptyList()
return List(count) { i ->
val pick = if (mode == Mode.CUSTOM) customPicks[i] else null
if (pick != null) {
Row(
index = i,
ip = pick,
latencyMs = latencyByIp[pick],
stale = pick !in latencyByIp,
changed = pick != auto[i]
)
} else {
Row(
index = i,
ip = auto[i],
latencyMs = latencyByIp[auto[i]],
stale = false,
changed = false
)
}
}
}
/** Distinct IPs used by [rows], in first-use order (test dedup). */
fun uniqueIps(rows: List<Row>): List<String> {
val seen = LinkedHashSet<String>()
for (row in rows) seen.add(row.ip)
return seen.toList()
}
}