arblant
railway-clean-ip-scanner-android / app / src / main / java / com / arblant / railwayipscanner / Workbench.kt
Workbench.kt97 lines · 3.7 KB
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()
    }
}