Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@
"types": "./dist/checklists/index.d.ts",
"import": "./dist/checklists/index.js"
},
"./checklists/amstar2": {
"types": "./dist/checklists/amstar2/index.d.ts",
"import": "./dist/checklists/amstar2/index.js"
},
"./checklists/robins-i": {
"types": "./dist/checklists/robins-i/index.d.ts",
"import": "./dist/checklists/robins-i/index.js"
},
"./checklists/rob2": {
"types": "./dist/checklists/rob2/index.d.ts",
"import": "./dist/checklists/rob2/index.js"
Expand All @@ -41,5 +49,8 @@
"devDependencies": {
"typescript": "^5.9.3",
"vitest": "^4.1.3"
},
"dependencies": {
"zod": "^4.3.6"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import {
Amstar2QuestionAnswerSchema,
AMSTAR2_KEY_SCHEMAS,
isAmstar2Key,
} from '../amstar2/answers-schema.js';

describe('Amstar2 answer-payload schemas', () => {
it('accepts a well-formed answer payload', () => {
const parsed = Amstar2QuestionAnswerSchema.parse({
answers: [
[true, false],
[false, true],
],
critical: true,
});
expect(parsed.answers).toHaveLength(2);
expect(parsed.critical).toBe(true);
});

it('allows critical to be omitted', () => {
const parsed = Amstar2QuestionAnswerSchema.parse({
answers: [[true, false]],
});
expect(parsed.critical).toBeUndefined();
});

it('rejects non-boolean matrix entries', () => {
expect(() =>
Amstar2QuestionAnswerSchema.parse({
answers: [['oops' as unknown as boolean]],
}),
).toThrow();
});

it('rejects missing answers field', () => {
expect(() => Amstar2QuestionAnswerSchema.parse({ critical: false } as unknown)).toThrow();
});

it('isAmstar2Key narrows known and rejects unknown keys', () => {
expect(isAmstar2Key('q1')).toBe(true);
expect(isAmstar2Key('q9a')).toBe(true);
expect(isAmstar2Key('q11b')).toBe(true);
expect(isAmstar2Key('q17')).toBe(false);
expect(isAmstar2Key('notes')).toBe(false);
});

it('AMSTAR2_KEY_SCHEMAS covers every AMSTAR2 data key', () => {
const expectedKeys = [
'q1',
'q2',
'q3',
'q4',
'q5',
'q6',
'q7',
'q8',
'q9a',
'q9b',
'q10',
'q11a',
'q11b',
'q12',
'q13',
'q14',
'q15',
'q16',
];
expect(Object.keys(AMSTAR2_KEY_SCHEMAS).sort()).toEqual(expectedKeys.sort());
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import {
Rob2DomainUpdateSchema,
Rob2OverallUpdateSchema,
Rob2PreliminaryUpdateSchema,
ROB2_KEY_SCHEMAS,
isRob2Key,
} from '../rob2/answers-schema.js';

describe('RoB2 answer-payload schemas', () => {
it('accepts partial domain update (answers only)', () => {
const parsed = Rob2DomainUpdateSchema.parse({
answers: { d1_1: { answer: 'Y' } },
});
expect(parsed.answers?.d1_1?.answer).toBe('Y');
});

it('accepts full domain update', () => {
const parsed = Rob2DomainUpdateSchema.parse({
judgement: 'Low',
direction: 'NA',
answers: {
d1_1: { answer: 'Y', comment: 'ok' },
},
});
expect(parsed.judgement).toBe('Low');
expect(parsed.direction).toBe('NA');
});

it('rejects unknown keys on domain update (strict)', () => {
expect(() => Rob2DomainUpdateSchema.parse({ judgement: 'Low', foo: 1 } as unknown)).toThrow();
});

it('accepts overall update', () => {
const parsed = Rob2OverallUpdateSchema.parse({ judgement: 'High', direction: null });
expect(parsed.judgement).toBe('High');
expect(parsed.direction).toBeNull();
});

it('accepts full preliminary payload including text fields', () => {
const parsed = Rob2PreliminaryUpdateSchema.parse({
studyDesign: 'Individually-randomized parallel-group trial',
aim: 'ASSIGNMENT',
deviationsToAddress: [],
sources: { 'Journal article(s)': true },
experimental: 'Drug X',
comparator: 'Placebo',
numericalResult: 'RR=1.5',
});
expect(parsed.experimental).toBe('Drug X');
expect(parsed.sources?.['Journal article(s)']).toBe(true);
});

it('accepts preliminary reset payload', () => {
const parsed = Rob2PreliminaryUpdateSchema.parse({
studyDesign: null,
aim: null,
deviationsToAddress: [],
sources: {},
experimental: '',
comparator: '',
numericalResult: '',
});
expect(parsed.studyDesign).toBeNull();
});

it('isRob2Key narrows known keys', () => {
expect(isRob2Key('preliminary')).toBe(true);
expect(isRob2Key('domain2b')).toBe(true);
expect(isRob2Key('overall')).toBe(true);
expect(isRob2Key('sectionB')).toBe(false);
});

it('ROB2_KEY_SCHEMAS covers every expected section', () => {
const expected = [
'preliminary',
'domain1',
'domain2a',
'domain2b',
'domain3',
'domain4',
'domain5',
'overall',
];
expect(Object.keys(ROB2_KEY_SCHEMAS).sort()).toEqual(expected.sort());
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest';
import {
RobinsIDomainUpdateSchema,
RobinsISectionBUpdateSchema,
RobinsISectionDUpdateSchema,
ROBINS_I_KEY_SCHEMAS,
isRobinsIKey,
} from '../robins-i/answers-schema.js';

describe('ROBINS-I answer-payload schemas', () => {
it('accepts partial domain updates (direction only)', () => {
const parsed = RobinsIDomainUpdateSchema.parse({ direction: 'Favours intervention' });
expect(parsed.direction).toBe('Favours intervention');
expect(parsed.judgement).toBeUndefined();
});

it('accepts full domain update with answers record', () => {
const parsed = RobinsIDomainUpdateSchema.parse({
judgement: 'Moderate',
direction: null,
answers: {
d1_1: { answer: 'Y', comment: 'rationale' },
d1_2: { answer: null },
},
});
expect(parsed.answers?.d1_1?.answer).toBe('Y');
expect(parsed.answers?.d1_2?.answer).toBeNull();
});

it('rejects unknown keys on domain update (strict)', () => {
expect(() =>
RobinsIDomainUpdateSchema.parse({ judgement: 'Low', bogus: true } as unknown),
).toThrow();
});

it('accepts sectionB partial (b1 only)', () => {
const parsed = RobinsISectionBUpdateSchema.parse({ b1: { answer: 'Y' } });
expect(parsed.b1?.answer).toBe('Y');
});

it('accepts sectionB with stopAssessment flag', () => {
const parsed = RobinsISectionBUpdateSchema.parse({ stopAssessment: true });
expect(parsed.stopAssessment).toBe(true);
});

it('accepts sectionD partial', () => {
const parsed = RobinsISectionDUpdateSchema.parse({
sources: { 'Journal article(s)': true },
otherSpecify: 'extra',
});
expect(parsed.sources?.['Journal article(s)']).toBe(true);
expect(parsed.otherSpecify).toBe('extra');
});

it('isRobinsIKey accepts known section keys', () => {
expect(isRobinsIKey('sectionB')).toBe(true);
expect(isRobinsIKey('domain1a')).toBe(true);
expect(isRobinsIKey('domain6')).toBe(true);
expect(isRobinsIKey('overall')).toBe(true);
expect(isRobinsIKey('garbage')).toBe(false);
});

it('ROBINS_I_KEY_SCHEMAS covers every expected section', () => {
const expected = [
'planning',
'sectionA',
'sectionB',
'sectionC',
'sectionD',
'confoundingEvaluation',
'domain1a',
'domain1b',
'domain2',
'domain3',
'domain4',
'domain5',
'domain6',
'overall',
];
expect(Object.keys(ROBINS_I_KEY_SCHEMAS).sort()).toEqual(expected.sort());
});
});
67 changes: 67 additions & 0 deletions packages/shared/src/checklists/amstar2/answers-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* AMSTAR2 answer-payload schemas.
*
* Runtime Zod schemas and derived types for the `data` passed to
* `handler.updateAnswer(answersMap, key, data)`. Distinct from the rendering
* schema in `schema.ts` (which describes question UI) and the broader
* interfaces in `../types.ts`.
*/

import { z } from 'zod';

export const Amstar2QuestionAnswerSchema = z.object({
answers: z.array(z.array(z.boolean())),
critical: z.boolean().optional(),
});

export type Amstar2QuestionAnswer = z.infer<typeof Amstar2QuestionAnswerSchema>;

export const Amstar2AnswersSchema = z.object({
q1: Amstar2QuestionAnswerSchema,
q2: Amstar2QuestionAnswerSchema,
q3: Amstar2QuestionAnswerSchema,
q4: Amstar2QuestionAnswerSchema,
q5: Amstar2QuestionAnswerSchema,
q6: Amstar2QuestionAnswerSchema,
q7: Amstar2QuestionAnswerSchema,
q8: Amstar2QuestionAnswerSchema,
q9a: Amstar2QuestionAnswerSchema,
q9b: Amstar2QuestionAnswerSchema,
q10: Amstar2QuestionAnswerSchema,
q11a: Amstar2QuestionAnswerSchema,
q11b: Amstar2QuestionAnswerSchema,
q12: Amstar2QuestionAnswerSchema,
q13: Amstar2QuestionAnswerSchema,
q14: Amstar2QuestionAnswerSchema,
q15: Amstar2QuestionAnswerSchema,
q16: Amstar2QuestionAnswerSchema,
});

export type Amstar2Answers = z.infer<typeof Amstar2AnswersSchema>;
export type Amstar2Key = keyof Amstar2Answers;
export type Amstar2AnswerFor<K extends Amstar2Key> = Amstar2Answers[K];

export const AMSTAR2_KEY_SCHEMAS: Record<Amstar2Key, typeof Amstar2QuestionAnswerSchema> = {
q1: Amstar2QuestionAnswerSchema,
q2: Amstar2QuestionAnswerSchema,
q3: Amstar2QuestionAnswerSchema,
q4: Amstar2QuestionAnswerSchema,
q5: Amstar2QuestionAnswerSchema,
q6: Amstar2QuestionAnswerSchema,
q7: Amstar2QuestionAnswerSchema,
q8: Amstar2QuestionAnswerSchema,
q9a: Amstar2QuestionAnswerSchema,
q9b: Amstar2QuestionAnswerSchema,
q10: Amstar2QuestionAnswerSchema,
q11a: Amstar2QuestionAnswerSchema,
q11b: Amstar2QuestionAnswerSchema,
q12: Amstar2QuestionAnswerSchema,
q13: Amstar2QuestionAnswerSchema,
q14: Amstar2QuestionAnswerSchema,
q15: Amstar2QuestionAnswerSchema,
q16: Amstar2QuestionAnswerSchema,
};

export function isAmstar2Key(key: string): key is Amstar2Key {
return key in AMSTAR2_KEY_SCHEMAS;
}
3 changes: 3 additions & 0 deletions packages/shared/src/checklists/amstar2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
// Schema (checklist map and question definitions)
export * from './schema.js';

// Answer-payload schemas (Zod runtime validation for updateAnswer data)
export * from './answers-schema.js';

// Checklist creation
export * from './create.js';

Expand Down
Loading