Refactor(status-tone): consolidate 4 toneOf implementations into ipFieldTone

ConnectivityTest / WebRtcTest / DnsLeaksTest / RuleTest each reimplemented
the same "status string → wait / ok-fast / ok-slow / fail" branching, with
the first three being near-byte-identical and ConnectivityTest adding a
time-threshold twist.

Export a single ipFieldTone(value, opts) from use-status-tone.js that
covers all four shapes via optional { waitLabels, errorLabels, isSuccess,
time, fastMs }. The three IP-content call sites collapse to a 4-line call;
ConnectivityTest passes a custom isSuccess predicate to preserve its
"Available" label + time split semantics.

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Claude committed 2026-04-20 15:39:04 +00:00
1 parent 0f03f2c9cc
commit af0f7ccc4e
7 files changed
+151 -29

No files matched your search

+2
View File
@@ -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
+12 -8
View File
@@ -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();
+5 -7
View File
@@ -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
+5 -7
View File
@@ -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.
@@ -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');
+42
View File
@@ -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(':'));
}
+80
View File
@@ -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');
});
});