From 0c77cc45348fcd90d2af7c3d47933a3612814bb7 Mon Sep 17 00:00:00 2001 From: Kirill Pimenov Date: Tue, 4 Aug 2026 16:10:30 +0200 Subject: [PATCH] fix: correct reprint threshold display and de-duplicate the formula (#124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: correct reprint threshold display and de-duplicate the formula (F5) Print.vue recomputed the recovery threshold as floor(total/2)+2 and stored the *total* in a field misleadingly named `requiredShards`. The reprint sheets therefore overstated how many more QR codes are needed (e.g. total=5 showed "need 4" instead of the correct 3) — misleading during recovery, though the QR payloads themselves were always untouched. Root cause: the threshold policy was duplicated. Share.vue computes floor(total/2)+1; Print.vue re-implemented it and drifted. Extract a single `defaultThreshold()` helper (src/util/shards.ts) and use it in both, so they can never disagree again. Rename Print.vue's field to `totalShards` to match what it actually holds. Add unit tests for the helper, including the exact F5 case (total=5 -> 3) and a strict-majority invariant across the whole 3..255 UI range. * fix: reject fractional shard counts, clamp the remaining-code count Review follow-ups on the reprint view. `` only constrains the spinner, not the value: typing "3.5" (or clearing the box, which `v-model.number` leaves as "") sailed through the old `>= 3 && <= 255` check and reached defaultThreshold(), producing fractional totals and remaining counts. Add `step="1"` for the spinner and gate on a shared `isValidShardCount()` predicate that also requires a whole number, so the input and the threshold policy keep agreeing about what a shard count is. `needMoreShards` compared with `!==`, so scanning more codes than announced kept the scanner open and drove `remainingCodes` negative. Compare with `<` and clamp the remainder at 0. `threshold` returned 0 for "nothing entered yet", a valid-looking value that could reach ShardInfo's required-shards prop. Return `undefined` instead and guard the consuming block on it, making the not-yet-entered state explicit. The `threshold` and validation paths are covered by shards.spec.ts; the scanner-overshoot clamp is not, as the repo has no component-test harness yet. * fix: apply the same shard-count validation to the generator Share.vue's shard-count input had the defect Copilot flagged on Print.vue's: no `step`, no integer check, and `totalShards` fed straight into crypto.share() — a fractional count surfaced as an opaque secrets.js error routed through the generic error hub, and an emptied box passed "" through. Reuse `isValidShardCount()` and gate the generate button on it, matching the existing `secretTooLong` pattern (disabled button plus an inline error span), so both shard-count inputs now agree on what a shard count is. * fix: stop rendering a threshold derived from an invalid shard count Gating the generate button left the sentence above it still interpolating defaultThreshold(totalShards) for values that had just been rejected: an emptied field coerces to 0 and renders "Will require any 1 shards", and 3.5 renders 2. Give Share.vue's `requiredShards` the same contract as Print.vue's `threshold` — `undefined` unless the count is usable — show an em dash in the sentence, and guard both the generated-shards block and crypto.share() on it. Also strengthen the threshold invariant test. Asserting only `defaultThreshold(n) > n / 2` does not pin the policy: floor(n/2)+2, the very formula this branch removed, satisfies it for every even n. Assert the smallest strict majority instead, which floor(n/2)+1 alone satisfies. --- src/util/shards.ts | 16 +++++++++++ src/views/Print.vue | 29 +++++++++++++------- src/views/Share.vue | 29 +++++++++++++++----- tests/unit/shards.spec.ts | 57 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 16 deletions(-) create mode 100644 src/util/shards.ts create mode 100644 tests/unit/shards.spec.ts diff --git a/src/util/shards.ts b/src/util/shards.ts new file mode 100644 index 0000000..87a1023 --- /dev/null +++ b/src/util/shards.ts @@ -0,0 +1,16 @@ +// Banana Split's fixed threshold policy: reconstruction requires a majority of +// the shards, i.e. floor(total / 2) + 1. This lives in one place so the +// generator (Share.vue) and the reprint view (Print.vue) can never disagree — +// they did once: Print.vue re-implemented the formula as floor(total/2)+2 and +// displayed the wrong "you need N more" count on reprints (finding F5). +export function defaultThreshold(totalShards: number): number { + return Math.floor(totalShards / 2) + 1; +} + +// Mirrors the min/max on the shard-count inputs. `v-model.number` on an +// `` hands us whatever the field holds, which includes "" +// for an empty box and fractions like 3.5 — neither is a shard count, so the +// range check alone is not enough. +export function isValidShardCount(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 3 && value <= 255; +} diff --git a/src/views/Print.vue b/src/views/Print.vue index a207e74..624f637 100644 --- a/src/views/Print.vue +++ b/src/views/Print.vue @@ -11,10 +11,11 @@

@@ -36,7 +37,7 @@ v-for="code in qrCodes" :key="code" :shard="code" - :required-shards="parseInt(requiredShards/2)+2" + :required-shards="threshold" :title="title" /> @@ -64,6 +65,7 @@