diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 5bf64522..6f08ff67 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -101,6 +101,8 @@ Badge adds `success` to the defaults. Badge hover is globally disabled — Badge All "business state → visual color" mapping goes through `frontend/composables/use-status-tone.js`. It exposes four tones: `wait` / `ok-fast` / `ok-slow` / `fail`. +For the common "status string → tone" shape (WebRTC / DnsLeak / RuleTest / Connectivity style), use the `ipFieldTone(value, { waitLabels, errorLabels, isSuccess?, time?, fastMs? })` helper from the same module rather than hand-rolling the switch locally. Default `isSuccess` treats any string containing `.` or `:` as a successful IP-shaped payload; pass a custom predicate when the success signal is elsewhere (Connectivity uses the localized "Available" label). + Rule: any new module that surfaces a status reuses these tones. Do not hand-write `switch` statements that map states to hex codes or Tailwind classes. ### Canonical patterns diff --git a/frontend/components/ConnectivityTest.vue b/frontend/components/ConnectivityTest.vue index 702ae4c3..1950c164 100644 --- a/frontend/components/ConnectivityTest.vue +++ b/frontend/components/ConnectivityTest.vue @@ -126,7 +126,7 @@ import { Card, CardContent } from '@/components/ui/card'; import { Dialog, DialogContent, DialogHeader } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { useStatusTone } from '@/composables/use-status-tone.js'; +import { useStatusTone, ipFieldTone } from '@/composables/use-status-tone.js'; import { Play, Chrome, Cloud, Compass, CirclePlus, Frown, Github, Meh, MessageCircle, MessageSquareQuote, RotateCw, Smile, Store, X, Youtube, @@ -224,15 +224,19 @@ const letterColor = (name) => { return `hsl(${hue}, 50%, 45%)`; }; -// Business status → 4 tone levels (wait / ok-fast / ok-slow / fail) +// Business status → 4 tone levels (wait / ok-fast / ok-slow / fail). +// Unlike the other toneOf call sites (WebRTC / DnsLeak / RuleTest), here the +// value is a status string like "Available (123ms)" rather than a raw IP, +// so we pass a custom isSuccess predicate plus test.time for the ok-fast / +// ok-slow split at 200ms. const toneOf = (test) => { - const waitLabel = t('connectivity.StatusWait'); const okLabel = t('connectivity.StatusAvailable'); - const unavailableLabels = [t('connectivity.StatusUnavailable'), t('connectivity.StatusTimeout')]; - if (test.status === waitLabel) return 'wait'; - if (unavailableLabels.includes(test.status)) return 'fail'; - if (test.status.includes(okLabel)) return test.time < 200 ? 'ok-fast' : 'ok-slow'; - return 'wait'; + return ipFieldTone(test.status, { + waitLabels: t('connectivity.StatusWait'), + errorLabels: [t('connectivity.StatusUnavailable'), t('connectivity.StatusTimeout')], + isSuccess: (s) => typeof s === 'string' && s.includes(okLabel), + time: test.time, + }); }; const { dotClass, textClass } = useStatusTone(); diff --git a/frontend/components/DnsLeaksTest.vue b/frontend/components/DnsLeaksTest.vue index 925548f9..e52c79bf 100644 --- a/frontend/components/DnsLeaksTest.vue +++ b/frontend/components/DnsLeaksTest.vue @@ -93,7 +93,7 @@ import { JnTooltip } from '@/components/ui/tooltip'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import getCountryName from '@/data/country-name.js'; -import { useStatusTone } from '@/composables/use-status-tone.js'; +import { useStatusTone, ipFieldTone } from '@/composables/use-status-tone.js'; import { EthernetPort, Play, HeartPulse, MapPin, RotateCw } from 'lucide-vue-next'; import { Icon } from '@iconify/vue'; import FitText from '@/components/widgets/FitText.vue'; @@ -108,12 +108,10 @@ const isStarted = ref(false); const { dotClass, textClass } = useStatusTone(); // Business status → 4 tone levels -const toneOf = (leak) => { - if (leak.ip === t('dnsleaktest.StatusWait')) return 'wait'; - if (leak.ip === t('dnsleaktest.StatusError')) return 'fail'; - if (leak.ip.includes('.') || leak.ip.includes(':')) return 'ok-fast'; - return 'wait'; -}; +const toneOf = (leak) => ipFieldTone(leak.ip, { + waitLabels: t('dnsleaktest.StatusWait'), + errorLabels: t('dnsleaktest.StatusError'), +}); // Status diff --git a/frontend/components/WebRtcTest.vue b/frontend/components/WebRtcTest.vue index 200ae940..f84c17c3 100644 --- a/frontend/components/WebRtcTest.vue +++ b/frontend/components/WebRtcTest.vue @@ -89,7 +89,7 @@ import getCountryName from '@/data/country-name.js'; import { JnTooltip } from '@/components/ui/tooltip'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; -import { useStatusTone } from '@/composables/use-status-tone.js'; +import { useStatusTone, ipFieldTone } from '@/composables/use-status-tone.js'; import { Play, MapPin, Flower, Network, RotateCw } from 'lucide-vue-next'; import { Icon } from '@iconify/vue'; import FitText from '@/components/widgets/FitText.vue'; @@ -110,12 +110,10 @@ const stunServers = reactive([ ]); // Business status → 4 tone levels -const toneOf = (stun) => { - if (stun.ip === t('webrtc.StatusWait')) return 'wait'; - if (stun.ip === t('webrtc.StatusError')) return 'fail'; - if (stun.ip.includes('.') || stun.ip.includes(':')) return 'ok-fast'; - return 'wait'; -}; +const toneOf = (stun) => ipFieldTone(stun.ip, { + waitLabels: t('webrtc.StatusWait'), + errorLabels: t('webrtc.StatusError'), +}); // Single field in dl block is in "no data" state (waiting/error) // These fields may fail independently (e.g. IP success but country query fails), so determine by field. diff --git a/frontend/components/advanced-tools/RuleTest.vue b/frontend/components/advanced-tools/RuleTest.vue index d4f0ea9d..8f3feb1b 100644 --- a/frontend/components/advanced-tools/RuleTest.vue +++ b/frontend/components/advanced-tools/RuleTest.vue @@ -78,7 +78,7 @@ import getCountryName from '@/data/country-name.js'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Spinner } from '@/components/ui/spinner'; -import { useStatusTone } from '@/composables/use-status-tone.js'; +import { useStatusTone, ipFieldTone } from '@/composables/use-status-tone.js'; import { Icon } from '@iconify/vue'; import { MapPin, RotateCw, Waypoints, SignpostBig } from 'lucide-vue-next'; import FitText from '@/components/widgets/FitText.vue'; @@ -110,12 +110,10 @@ const testCount = ref(ruleTests.value.length); const finishAll = ref(false); // Business status → 4 tone levels -const toneOf = (test) => { - if (test.ip === t('ruletest.StatusWait')) return 'wait'; - if (test.ip === t('ruletest.StatusError')) return 'fail'; - if (test.ip.includes('.') || test.ip.includes(':')) return 'ok-fast'; - return 'wait'; -}; +const toneOf = (test) => ipFieldTone(test.ip, { + waitLabels: t('ruletest.StatusWait'), + errorLabels: t('ruletest.StatusError'), +}); const isFieldPending = (value) => { return !value || value === t('ruletest.StatusWait') || value === t('ruletest.StatusError'); diff --git a/frontend/composables/use-status-tone.js b/frontend/composables/use-status-tone.js index 06ced917..d021e72a 100644 --- a/frontend/composables/use-status-tone.js +++ b/frontend/composables/use-status-tone.js @@ -29,3 +29,45 @@ export function useStatusTone() { textClass: (tone) => textColorMap[tone] || textColorMap.wait, }; } + +// Unified "IP/MAC/status string → tone" mapping for card-like rows. +// +// Four call sites share the same shape: a single string value that's either +// a "wait" sentinel (localized), an "error" sentinel (localized), or a +// successful payload (IP/host). Business variations are expressed via +// options rather than by copying the whole function: +// +// - waitLabels / errorLabels: single string or array; compared by === +// - isSuccess(value): custom predicate for "this value represents a +// successful result". Default checks for IP-shaped tokens (contains +// '.' or ':'), which fits WebRTC / DnsLeak / RuleTest. Connectivity +// passes a custom predicate based on its localized "Available" label. +// - time / fastMs: if time is a number, success splits into ok-fast +// (< fastMs) vs ok-slow; otherwise success is flat 'ok-fast'. +export function ipFieldTone(value, { + waitLabels = [], + errorLabels = [], + isSuccess = defaultIsSuccess, + time, + fastMs = 200, +} = {}) { + const waits = asArray(waitLabels); + const errs = asArray(errorLabels); + if (waits.includes(value)) return 'wait'; + if (errs.includes(value)) return 'fail'; + if (isSuccess(value)) { + if (typeof time === 'number') return time < fastMs ? 'ok-fast' : 'ok-slow'; + return 'ok-fast'; + } + return 'wait'; +} + +function asArray(x) { + if (Array.isArray(x)) return x; + if (x == null) return []; + return [x]; +} + +function defaultIsSuccess(v) { + return typeof v === 'string' && (v.includes('.') || v.includes(':')); +} diff --git a/tests/composable-status-tone.test.js b/tests/composable-status-tone.test.js new file mode 100644 index 00000000..2a410206 --- /dev/null +++ b/tests/composable-status-tone.test.js @@ -0,0 +1,80 @@ +// Tests for ipFieldTone — the unified "status string → tone" mapping that +// WebRtcTest / DnsLeaksTest / RuleTest / ConnectivityTest all use. + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { ipFieldTone, useStatusTone } from '../frontend/composables/use-status-tone.js'; + +describe('ipFieldTone()', () => { + it('returns "wait" when value equals the wait label', () => { + assert.equal(ipFieldTone('Waiting', { waitLabels: 'Waiting', errorLabels: 'Error' }), 'wait'); + }); + + it('returns "fail" when value equals the error label', () => { + assert.equal(ipFieldTone('Error', { waitLabels: 'Waiting', errorLabels: 'Error' }), 'fail'); + }); + + it('accepts arrays of wait/error labels', () => { + const opts = { waitLabels: ['W1', 'W2'], errorLabels: ['E1', 'E2'] }; + assert.equal(ipFieldTone('W2', opts), 'wait'); + assert.equal(ipFieldTone('E1', opts), 'fail'); + }); + + it('returns "ok-fast" for an IPv4-shaped value (default success predicate)', () => { + assert.equal(ipFieldTone('192.168.1.1', { waitLabels: 'W', errorLabels: 'E' }), 'ok-fast'); + }); + + it('returns "ok-fast" for an IPv6-shaped value (default success predicate)', () => { + assert.equal(ipFieldTone('2001:db8::1', { waitLabels: 'W', errorLabels: 'E' }), 'ok-fast'); + }); + + it('falls back to "wait" for unrecognized non-IP strings', () => { + assert.equal(ipFieldTone('hello', { waitLabels: 'W', errorLabels: 'E' }), 'wait'); + }); + + it('splits success into ok-fast vs ok-slow when time is a number', () => { + const opts = { waitLabels: 'W', errorLabels: 'E' }; + assert.equal(ipFieldTone('1.2.3.4', { ...opts, time: 150 }), 'ok-fast'); + assert.equal(ipFieldTone('1.2.3.4', { ...opts, time: 350 }), 'ok-slow'); + }); + + it('respects a custom fastMs cutoff', () => { + const opts = { waitLabels: 'W', errorLabels: 'E', fastMs: 100 }; + assert.equal(ipFieldTone('1.2.3.4', { ...opts, time: 80 }), 'ok-fast'); + assert.equal(ipFieldTone('1.2.3.4', { ...opts, time: 150 }), 'ok-slow'); + }); + + it('uses a custom isSuccess predicate (ConnectivityTest-style)', () => { + // ConnectivityTest: status string like "Available (123ms)" includes an + // "Available" label rather than being IP-shaped. + const opts = { + waitLabels: 'Waiting', + errorLabels: ['Unavailable', 'Timeout'], + isSuccess: (s) => s.includes('Available'), + time: 80, + fastMs: 200, + }; + assert.equal(ipFieldTone('Available (80ms)', opts), 'ok-fast'); + assert.equal(ipFieldTone('Available (500ms)', { ...opts, time: 500 }), 'ok-slow'); + assert.equal(ipFieldTone('Waiting', opts), 'wait'); + assert.equal(ipFieldTone('Timeout', opts), 'fail'); + }); + + it('is resilient to non-string values', () => { + assert.equal(ipFieldTone(undefined, { waitLabels: 'W', errorLabels: 'E' }), 'wait'); + assert.equal(ipFieldTone(null, { waitLabels: 'W', errorLabels: 'E' }), 'wait'); + }); +}); + +describe('useStatusTone() (existing contract still holds)', () => { + it('dotClass maps tones to bg-* classes', () => { + const { dotClass } = useStatusTone(); + assert.equal(dotClass('wait'), 'bg-info'); + assert.equal(dotClass('ok-fast'), 'bg-success'); + assert.equal(dotClass('ok-slow'), 'bg-warning'); + assert.equal(dotClass('fail'), 'bg-destructive'); + // unknown tone falls back to wait + assert.equal(dotClass('bogus'), 'bg-info'); + }); +});