Commit c8548e01 by PLN (Algolia)

tsa: CAT-Q instrument — data, scoring, fr/en copy, unit tests

25 items Likert 1-7 (verbatim Hull 2019 in EN, our translation in FR,
flagged non-validated), items 3/12/19/22/24 reversed, subscales
compensation/masking/assimilation. Percentiles via normal CDF on the
Hull 2020 gender-matched norms; Baker 2024 descriptor bands on the
total score. Storage contract catq:v1, same discipline as clarte:v1.
parent 8ef0bad8
/* Clarté TSA — structure de l'instrument CAT-Q (Hull et al., 2019).
25 items, Likert 1–7, items 3/12/19/22/24 inversés. Les TEXTES vivent dans
i18n (fr.js / en.js) via les clés catq.q{n} ; l'anglais est verbatim Hull
2019, le français est notre traduction (non validée psychométriquement).
Subscales et inversion sont l'instrument : intouchables.
Normes : Hull et al. (2020), moyennes/écart-types par genre, reproduites
depuis la fiche NovoPsych (novopsych.com) — elles alimentent le calcul de
percentiles par distribution normale (voir engine.js). */
export const LIKERT_MIN = 1;
export const LIKERT_MAX = 7;
export const CATQ_ITEMS = [
{ id: 'catq_1', num: 1, sub: 'compensation', reverse: false, t: 'catq.q1' },
{ id: 'catq_2', num: 2, sub: 'masking', reverse: false, t: 'catq.q2' },
{ id: 'catq_3', num: 3, sub: 'assimilation', reverse: true, t: 'catq.q3' },
{ id: 'catq_4', num: 4, sub: 'compensation', reverse: false, t: 'catq.q4' },
{ id: 'catq_5', num: 5, sub: 'compensation', reverse: false, t: 'catq.q5' },
{ id: 'catq_6', num: 6, sub: 'masking', reverse: false, t: 'catq.q6' },
{ id: 'catq_7', num: 7, sub: 'assimilation', reverse: false, t: 'catq.q7' },
{ id: 'catq_8', num: 8, sub: 'compensation', reverse: false, t: 'catq.q8' },
{ id: 'catq_9', num: 9, sub: 'masking', reverse: false, t: 'catq.q9' },
{ id: 'catq_10', num: 10, sub: 'assimilation', reverse: false, t: 'catq.q10' },
{ id: 'catq_11', num: 11, sub: 'compensation', reverse: false, t: 'catq.q11' },
{ id: 'catq_12', num: 12, sub: 'masking', reverse: true, t: 'catq.q12' },
{ id: 'catq_13', num: 13, sub: 'assimilation', reverse: false, t: 'catq.q13' },
{ id: 'catq_14', num: 14, sub: 'compensation', reverse: false, t: 'catq.q14' },
{ id: 'catq_15', num: 15, sub: 'masking', reverse: false, t: 'catq.q15' },
{ id: 'catq_16', num: 16, sub: 'assimilation', reverse: false, t: 'catq.q16' },
{ id: 'catq_17', num: 17, sub: 'compensation', reverse: false, t: 'catq.q17' },
{ id: 'catq_18', num: 18, sub: 'masking', reverse: false, t: 'catq.q18' },
{ id: 'catq_19', num: 19, sub: 'assimilation', reverse: true, t: 'catq.q19' },
{ id: 'catq_20', num: 20, sub: 'compensation', reverse: false, t: 'catq.q20' },
{ id: 'catq_21', num: 21, sub: 'masking', reverse: false, t: 'catq.q21' },
{ id: 'catq_22', num: 22, sub: 'assimilation', reverse: true, t: 'catq.q22' },
{ id: 'catq_23', num: 23, sub: 'compensation', reverse: false, t: 'catq.q23' },
{ id: 'catq_24', num: 24, sub: 'masking', reverse: true, t: 'catq.q24' },
{ id: 'catq_25', num: 25, sub: 'assimilation', reverse: false, t: 'catq.q25' },
];
export const SUBSCALES = [
{ key: 'compensation', nameKey: 'catq.sub.compensation', descKey: 'catq.sub.compensation.desc', items: [1, 4, 5, 8, 11, 14, 17, 20, 23] },
{ key: 'masking', nameKey: 'catq.sub.masking', descKey: 'catq.sub.masking.desc', items: [2, 6, 9, 12, 15, 18, 21, 24] },
{ key: 'assimilation', nameKey: 'catq.sub.assimilation', descKey: 'catq.sub.assimilation.desc', items: [3, 7, 10, 13, 16, 19, 22, 25] },
];
export const CATQ_IDS = CATQ_ITEMS.map((q) => q.id);
export const TOTAL_QUESTIONS = CATQ_ITEMS.length; /* 25 */
export const GENDERS = ['f', 'm', 'nb', 'unspecified'];
/* Moyennes (mu) et écarts-types (sd) — Hull et al. 2020, fiche NovoPsych.
f = femmes, m = hommes, nb = non-binaires ; unspecified = moyenne des trois
groupes (pooled, approximation assumée, comme chez NovoPsych). */
export const NORMS = {
nonAutistic: {
total: { f: { mu: 90.87, sd: 27.67 }, m: { mu: 96.89, sd: 24.22 }, nb: { mu: 109.44, sd: 27.20 } },
compensation: { f: { mu: 27.18, sd: 11.5 }, m: { mu: 30.06, sd: 10.92 }, nb: { mu: 35.48, sd: 11.32 } },
masking: { f: { mu: 34.69, sd: 9.05 }, m: { mu: 36.34, sd: 8.13 }, nb: { mu: 38.70, sd: 7.61 } },
assimilation: { f: { mu: 29.00, sd: 11.73 }, m: { mu: 30.48, sd: 10.33 }, nb: { mu: 35.26, sd: 12.11 } },
},
autistic: {
total: { f: { mu: 124.35, sd: 23.27 }, m: { mu: 109.64, sd: 26.50 }, nb: { mu: 122.00, sd: 17.12 } },
compensation: { f: { mu: 41.85, sd: 11.11 }, m: { mu: 36.81, sd: 12.14 }, nb: { mu: 43.50, sd: 9.89 } },
masking: { f: { mu: 37.87, sd: 10.54 }, m: { mu: 32.90, sd: 10.57 }, nb: { mu: 36.06, sd: 8.78 } },
assimilation: { f: { mu: 44.63, sd: 7.82 }, m: { mu: 39.93, sd: 11.26 }, nb: { mu: 39.88, sd: 6.43 } },
},
};
/* Bornes des descripteurs qualitatifs du score total (Baker et al., 2024,
fiche NovoPsych) — dérivées des percentiles de l'échantillon non-autiste
(Hull et al., 2020) : ≤5e, 6–24, 25–75, 76–94, ≥95e. Les mêmes tranches de
percentiles sont appliquées aux subscales (mêmes normes, même logique). */
export const TOTAL_BANDS = [
{ key: 'extremely_low', min: 25, max: 51 },
{ key: 'low', min: 52, max: 75 },
{ key: 'moderate', min: 76, max: 112 },
{ key: 'high', min: 113, max: 136 },
{ key: 'extremely_high', min: 137, max: 175 },
];
/* Clarté TSA — moteur : persistance (contrat catq:v1, même discipline que
clarte:v1) et scoring CAT-Q. Les réponses stockent le Likert BRUT 1–7 tel
qu'affiché ; l'inversion (items 3/12/19/22/24) se fait au scoring
(8 − v), comme sur la fiche papier du constructeur.
Percentiles : Φ((x − μ)/σ) sur les normes Hull 2020 appariées au genre —
la même voie que NovoPsych, sans table propriétaire. */
import { CATQ_ITEMS, SUBSCALES, NORMS, TOTAL_BANDS, TOTAL_QUESTIONS, GENDERS } from './data';
export const STORAGE_KEY = 'catq:v1';
export function storageAvailable() {
try {
const k = STORAGE_KEY + ':probe';
localStorage.setItem(k, '1');
localStorage.removeItem(k);
return true;
} catch (e) { return false; }
}
export function readState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const v = JSON.parse(raw);
if (!v || typeof v !== 'object' || typeof v.responses !== 'object' || v.responses === null) return null;
return v;
} catch (e) { return null; }
}
export function writeState(state) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
return true;
} catch (e) { return false; }
}
export function clearState() {
try { localStorage.removeItem(STORAGE_KEY); } catch (e) {}
}
/* Φ par erf d'Abramowitz-Stegun (précision ~1e-7, largement assez pour un
percentile affiché en entier). */
function erf(x) {
const sign = x < 0 ? -1 : 1;
const a = Math.abs(x);
const t = 1 / (1 + 0.3275911 * a);
const y = 1 - ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-a * a);
return sign * y;
}
export function normalCdf(x, mu, sd) {
return 0.5 * (1 + erf((x - mu) / (sd * Math.SQRT2)));
}
function normFor(pool, scaleKey, gender) {
const entry = NORMS[pool][scaleKey];
if (gender && GENDERS.indexOf(gender) !== -1 && entry[gender]) return entry[gender];
/* pooled : moyenne des trois groupes (approximation assumée) */
const gs = ['f', 'm', 'nb'];
return {
mu: gs.reduce((s, g) => s + entry[g].mu, 0) / 3,
sd: gs.reduce((s, g) => s + entry[g].sd, 0) / 3,
};
}
/* Percentile affiché : 1–99 (Φ brut, borné pour éviter le 0/100 trompeur). */
export function percentileOf(score, pool, scaleKey, gender) {
const { mu, sd } = normFor(pool, scaleKey, gender);
const p = normalCdf(score, mu, sd) * 100;
return Math.max(1, Math.min(99, Math.round(p)));
}
/* Descripteur qualitatif par tranche de percentile (≤5, 6–24, 25–75, 76–94, ≥95). */
export function descriptorFromPercentile(p) {
if (p <= 5) return 'extremely_low';
if (p <= 24) return 'low';
if (p <= 75) return 'moderate';
if (p <= 94) return 'high';
return 'extremely_high';
}
export function totalBand(score) {
const band = TOTAL_BANDS.find((b) => score >= b.min && score <= b.max);
return band ? band.key : null;
}
export function scoreCatq(responses, gender) {
const raw = (id) => responses[id];
const scored = (q) => {
const v = raw(q.id);
if (v === undefined) return undefined;
const s = Math.max(1, Math.min(7, v));
return q.reverse ? 8 - s : s;
};
const subscales = {};
let total = 0;
let complete = true;
SUBSCALES.forEach((sub) => {
let score = 0;
sub.items.forEach((num) => {
const q = CATQ_ITEMS[num - 1];
const v = scored(q);
if (v === undefined) complete = false;
else score += v;
});
subscales[sub.key] = {
score,
min: sub.items.length,
max: sub.items.length * 7,
pNonAutistic: percentileOf(score, 'nonAutistic', sub.key, gender),
pAutistic: percentileOf(score, 'autistic', sub.key, gender),
};
});
CATQ_ITEMS.forEach((q) => { const v = scored(q); if (v !== undefined) total += v; });
return {
complete,
subscales,
total: {
score: total,
min: TOTAL_QUESTIONS,
max: TOTAL_QUESTIONS * 7,
pNonAutistic: percentileOf(total, 'nonAutistic', 'total', gender),
pAutistic: percentileOf(total, 'autistic', 'total', gender),
band: totalBand(total),
},
};
}
export function calculateResults(responses, gender) {
return scoreCatq(responses, gender);
}
/* Clarté TSA — moteur : scoring CAT-Q (sous-échelles, inversion 8−v, total
25–175), percentiles Φ appariés au genre, bandes qualitatives, persistance. */
import {
scoreCatq, totalBand, percentileOf, descriptorFromPercentile,
storageAvailable, readState, writeState, clearState, normalCdf,
} from '@/components/tsa/engine';
import { CATQ_ITEMS, NORMS } from '@/components/tsa/data';
const allDisagree = () => {
const r = {};
CATQ_ITEMS.forEach((q) => { r[q.id] = 1; });
return r;
};
const allAgree = () => {
const r = {};
CATQ_ITEMS.forEach((q) => { r[q.id] = 7; });
return r;
};
describe('CAT-Q scoring', () => {
test('all « pas du tout d’accord » (1) → total 55 (les 5 items inversés valent 7)', () => {
const r = scoreCatq(allDisagree(), 'f');
/* items inversés : 3, 12, 19, 22, 24 → 8−1 = 7 */
expect(r.total.score).toBe(25 + 5 * 6);
expect(r.complete).toBe(true);
});
test('all « tout à fait d’accord » (7) → total 145 (les 5 items inversés valent 1)', () => {
const r = scoreCatq(allAgree(), 'f');
expect(r.total.score).toBe(175 - 5 * 6);
});
test('répondre « pas du tout d’accord » minimise les subscales hors inversés', () => {
const r = scoreCatq(allDisagree(), 'm');
/* compensation (9 items, aucun inversé) : min 9 */
expect(r.subscales.compensation.score).toBe(9);
expect(r.subscales.compensation.max).toBe(63);
/* masking : items 12 et 24 inversés → 2×7 + 6×1 = 20 */
expect(r.subscales.masking.score).toBe(20);
expect(r.subscales.masking.max).toBe(56);
/* assimilation : items 3,19,22 inversés (3×7) + 5×1 = 26 */
expect(r.subscales.assimilation.score).toBe(26);
expect(r.subscales.assimilation.max).toBe(56);
});
test('inversion : 8 − v sur les items 3/12/19/22/24', () => {
const r = {};
CATQ_ITEMS.forEach((q) => { r[q.id] = q.reverse ? 2 : 6; });
const s = scoreCatq(r, 'unspecified');
/* chaque item vaut 6 dans les deux cas */
expect(s.total.score).toBe(25 * 6);
});
test('incomplet → complete=false et total partiel cohérent', () => {
const r = allDisagree();
delete r.catq_13;
const s = scoreCatq(r, 'f');
expect(s.complete).toBe(false);
expect(s.total.score).toBe(55 - 1); /* sans catq_13 (non inversé → 1) */
});
test('clamp : réponses hors 1–7 bornées', () => {
const r = allDisagree();
r.catq_1 = 42;
r.catq_2 = -3;
const s = scoreCatq(r, 'f');
/* catq_1 non inversé → 7 (+6) ; catq_2 non inversé → 1 (+0) */
expect(s.total.score).toBe(55 + 6);
});
});
describe('bands and percentiles', () => {
test('bornes des bandes totales (Baker 2024)', () => {
expect(totalBand(25)).toBe('extremely_low');
expect(totalBand(51)).toBe('extremely_low');
expect(totalBand(52)).toBe('low');
expect(totalBand(75)).toBe('low');
expect(totalBand(76)).toBe('moderate');
expect(totalBand(112)).toBe('moderate');
expect(totalBand(113)).toBe('high');
expect(totalBand(136)).toBe('high');
expect(totalBand(137)).toBe('extremely_high');
expect(totalBand(175)).toBe('extremely_high');
});
test('percentile croissant avec le score', () => {
const p1 = percentileOf(40, 'nonAutistic', 'total', 'f');
const p2 = percentileOf(90, 'nonAutistic', 'total', 'f');
const p3 = percentileOf(140, 'nonAutistic', 'total', 'f');
expect(p1).toBeLessThan(p2);
expect(p2).toBeLessThan(p3);
expect(p1).toBeGreaterThanOrEqual(1);
expect(p3).toBeLessThanOrEqual(99);
});
test('Φ normale : mu ± 1sd ≈ 84e percentile', () => {
const { mu, sd } = NORMS.nonAutistic.total.f;
expect(Math.round(normalCdf(mu + sd, mu, sd) * 100)).toBe(84);
expect(Math.round(normalCdf(mu, mu, sd) * 100)).toBe(50);
});
test('descripteur par tranche de percentile', () => {
expect(descriptorFromPercentile(3)).toBe('extremely_low');
expect(descriptorFromPercentile(15)).toBe('low');
expect(descriptorFromPercentile(50)).toBe('moderate');
expect(descriptorFromPercentile(85)).toBe('high');
expect(descriptorFromPercentile(97)).toBe('extremely_high');
});
test('genre non précisé → normes groupées (entre f et nb)', () => {
const pooled = percentileOf(90, 'nonAutistic', 'total', 'unspecified');
const f = percentileOf(90, 'nonAutistic', 'total', 'f');
const nb = percentileOf(90, 'nonAutistic', 'total', 'nb');
expect(pooled).toBeGreaterThanOrEqual(Math.min(f, nb));
expect(pooled).toBeLessThanOrEqual(Math.max(f, nb));
});
});
describe('persistence', () => {
beforeEach(() => { localStorage.clear(); });
test('writeState/readState/clearState roundtrip', () => {
expect(readState()).toBeNull();
writeState({ responses: { catq_1: 4 }, gender: 'nb', pos: 3, consented: true });
expect(readState()).toEqual({ responses: { catq_1: 4 }, gender: 'nb', pos: 3, consented: true });
clearState();
expect(readState()).toBeNull();
});
test('readState refuse les payloads non conformes', () => {
localStorage.setItem('catq:v1', 'not json');
expect(readState()).toBeNull();
localStorage.setItem('catq:v1', '{"nope":1}');
expect(readState()).toBeNull();
});
});
/* Clarté TSA — dictionnaires : parité fr/en, 25 items résolus dans les deux
langues, options Likert 1–7 complètes, zéro tiret cadratin, pas de
placeholder oublié dans les gabarits de résultats. */
import { dict as fr } from '@/components/tsa/i18n/fr';
import { dict as en } from '@/components/tsa/i18n/en';
import { CATQ_ITEMS, SUBSCALES, GENDERS } from '@/components/tsa/data';
describe('dictionary parity fr/en', () => {
test('mêmes clés dans les deux langues', () => {
const fk = Object.keys(fr).sort();
const ek = Object.keys(en).sort();
expect(fk).toEqual(ek);
});
test('aucune valeur vide', () => {
[fr, en].forEach((d) => {
Object.keys(d).forEach((k) => {
expect(typeof d[k]).toBe('string');
expect(d[k].length).toBeGreaterThan(0);
});
});
});
test('zéro tiret cadratin (convention maison : tiret demi-cadratin ou parenthèses)', () => {
[fr, en].forEach((d) => {
Object.keys(d).forEach((k) => {
expect(d[k]).not.toMatch(/—/);
});
});
});
});
describe('items', () => {
test('25 items, clés catq.q1..q25 résolues en fr et en', () => {
expect(CATQ_ITEMS).toHaveLength(25);
CATQ_ITEMS.forEach((q) => {
expect(fr[q.t]).toBeTruthy();
expect(en[q.t]).toBeTruthy();
});
});
test('exactement 5 items inversés : 3, 12, 19, 22, 24', () => {
const reversed = CATQ_ITEMS.filter((q) => q.reverse).map((q) => q.num);
expect(reversed).toEqual([3, 12, 19, 22, 24]);
});
test('subscales = l’instrument (9/8/8, aucun recouvrement)', () => {
expect(SUBSCALES.map((s) => s.items.length)).toEqual([9, 8, 8]);
const all = SUBSCALES.flatMap((s) => s.items);
expect(new Set(all).size).toBe(25);
expect([...all].sort((a, b) => a - b)).toEqual([...Array(25)].map((_, i) => i + 1));
});
test('options Likert 1–7 complètes dans les deux langues', () => {
[fr, en].forEach((d) => {
[1, 2, 3, 4, 5, 6, 7].forEach((v) => {
expect(d['catq.option.' + v]).toBeTruthy();
});
});
});
});
describe('copy integrity', () => {
test('gabarits résultats : placeholders tous substituables', () => {
const total = fr['results.summary.text'];
expect(total).toMatch(/\{score\}/);
expect(total).toMatch(/\{band\}/);
expect(total).toMatch(/\{pNA\}/);
expect(total).toMatch(/\{pA\}/);
expect(total).toMatch(/\{genre\}/);
expect(fr['results.sub.line']).toMatch(/\{name\}.*\{score\}.*\{max\}.*\{pNA\}.*\{pA\}.*\{band\}/);
});
test('les 4 options de genre résolues dans les deux langues', () => {
GENDERS.forEach((g) => {
expect(fr['home.gender.' + g]).toBeTruthy();
expect(en['home.gender.' + g]).toBeTruthy();
});
});
test('l’avertissement de traduction non validée figure dans le français', () => {
expect(fr['home.warning.li3']).toMatch(/pas été validée/);
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment