diff --git a/packages/cutie-core/src/lib/expressionEvaluator/comparison.ts b/packages/cutie-core/src/lib/expressionEvaluator/comparison.ts index 9974fd5..ebf97e7 100644 --- a/packages/cutie-core/src/lib/expressionEvaluator/comparison.ts +++ b/packages/cutie-core/src/lib/expressionEvaluator/comparison.ts @@ -3,7 +3,7 @@ */ import { getChildElements } from '../../utils/dom'; -import { deepEqual, deepEqualUnordered } from '../../utils/equality'; +import { deepEqual, deepEqualUnordered, stringEquals } from '../../utils/equality'; import { compareMathExpressions, type MathComparisonMode } from './math'; import type { SubEvaluate } from './types'; @@ -43,6 +43,20 @@ function getFormulaMode(itemDoc: Document, identifier: string): MathComparisonMo return null; } +/** + * Check if a response declaration has base-type="string" + */ +function isStringBaseType(itemDoc: Document, identifier: string): boolean { + const declarations = itemDoc.getElementsByTagName('qti-response-declaration'); + for (let i = 0; i < declarations.length; i++) { + const decl = declarations[i]; + if (decl.getAttribute('identifier') === identifier) { + return decl.getAttribute('base-type') === 'string'; + } + } + return false; +} + /** * Evaluate qti-lt (less than) element */ @@ -215,6 +229,12 @@ export function evaluateMatch( formulaMode ); } + + // String base-type responses use case-insensitive comparison by default + if (isStringBaseType(itemDoc, responseId) && + typeof values[0] === 'string' && typeof values[1] === 'string') { + return stringEquals(values[0], values[1]); + } } return deepEqual(values[0], values[1]); diff --git a/packages/cutie-core/src/lib/expressionEvaluator/string.ts b/packages/cutie-core/src/lib/expressionEvaluator/string.ts index d2f41b2..ef0b29d 100644 --- a/packages/cutie-core/src/lib/expressionEvaluator/string.ts +++ b/packages/cutie-core/src/lib/expressionEvaluator/string.ts @@ -3,6 +3,7 @@ */ import { getChildElements } from '../../utils/dom'; +import { stringEquals, stringIncludes } from '../../utils/equality'; import type { SubEvaluate } from './types'; /** @@ -22,9 +23,7 @@ export function evaluateSubstring( } if (values.length >= 2) { - const text = caseSensitive ? values[0] : values[0].toLowerCase(); - const substring = caseSensitive ? values[1] : values[1].toLowerCase(); - return text.includes(substring); + return stringIncludes(values[0], values[1], caseSensitive); } return false; @@ -47,9 +46,7 @@ export function evaluateStringMatch( } if (values.length >= 2) { - const str1 = caseSensitive ? values[0] : values[0].toLowerCase(); - const str2 = caseSensitive ? values[1] : values[1].toLowerCase(); - return str1 === str2; + return stringEquals(values[0], values[1], caseSensitive); } return false; diff --git a/packages/cutie-core/src/lib/responseProcessing.spec.ts b/packages/cutie-core/src/lib/responseProcessing.spec.ts index 4a2c5ef..6bb626c 100644 --- a/packages/cutie-core/src/lib/responseProcessing.spec.ts +++ b/packages/cutie-core/src/lib/responseProcessing.spec.ts @@ -1573,7 +1573,7 @@ describe('Response Processing Operators and Expressions', () => { expect(newState.variables.SCORE).toBe(0); }); - test('matches string values (case-sensitive)', () => { + test('qti-match uses case-insensitive comparison for string base-type', () => { const itemXml = ` @@ -1614,7 +1614,109 @@ describe('Response Processing Operators and Expressions', () => { const newState = processResponse(itemDoc, submission, currentState); - // Should not match due to case difference + // Should match case-insensitively for string base-type responses + expect(newState.variables.SCORE).toBe(1); + }); + + test('qti-match with qti-correct uses case-insensitive comparison for string base-type', () => { + const itemXml = ` + + + + wicked king + + + + + + 0 + + + + + + + + + + + + + + + + 1 + + + + +`; + + const itemDoc = parser.parseFromString(itemXml, 'text/xml'); + const currentState = { + variables: { SCORE: 0 }, + completionStatus: 'not_attempted' as const, + score: null, + }; + const submission = { RESPONSE: 'Wicked King' }; + + const newState = processResponse(itemDoc, submission, currentState); + + // Should match case-insensitively for string base-type responses + expect(newState.variables.SCORE).toBe(1); + }); + + test('qti-match remains case-sensitive for identifier base-type', () => { + const itemXml = ` + + + + ChoiceA + + + + + + 0 + + + + + + A + B + + + + + + + + + + + + 1 + + + + +`; + + const itemDoc = parser.parseFromString(itemXml, 'text/xml'); + const currentState = { + variables: { SCORE: 0 }, + completionStatus: 'not_attempted' as const, + score: null, + }; + // cspell:disable-next-line + const submission = { RESPONSE: 'choiceA' }; // wrong case + + const newState = processResponse(itemDoc, submission, currentState); + + // Should NOT match - identifier comparison is case-sensitive expect(newState.variables.SCORE).toBe(0); }); diff --git a/packages/cutie-core/src/lib/responseProcessing.ts b/packages/cutie-core/src/lib/responseProcessing.ts index 8926303..1da4275 100644 --- a/packages/cutie-core/src/lib/responseProcessing.ts +++ b/packages/cutie-core/src/lib/responseProcessing.ts @@ -1,6 +1,6 @@ import { AttemptState, ResponseData } from '../types'; import { getChildElements, getFirstChildElement } from '../utils/dom'; -import { deepEqual, deepEqualUnordered } from '../utils/equality'; +import { deepEqual, deepEqualUnordered, stringEquals } from '../utils/equality'; import { parseResponseValue } from '../utils/typeParser'; import { evaluateExpression as evaluateExpressionShared, @@ -407,9 +407,7 @@ function getMappedValue(value: unknown, mapping: ResponseMapping): number { const key = String(value); for (const entry of mapping.entries) { - const matches = entry.caseSensitive - ? key === entry.mapKey - : key.toLowerCase() === entry.mapKey.toLowerCase(); + const matches = stringEquals(key, entry.mapKey, entry.caseSensitive); if (matches) { return entry.mappedValue; diff --git a/packages/cutie-core/src/utils/equality.ts b/packages/cutie-core/src/utils/equality.ts index 25ae1e0..2ef3363 100644 --- a/packages/cutie-core/src/utils/equality.ts +++ b/packages/cutie-core/src/utils/equality.ts @@ -1,3 +1,19 @@ +/** + * Case-aware string equality. Defaults to case-insensitive. + */ +export function stringEquals(a: string, b: string, caseSensitive = false): boolean { + return caseSensitive ? a === b : a.toLowerCase() === b.toLowerCase(); +} + +/** + * Case-aware substring check. Defaults to case-insensitive. + */ +export function stringIncludes(haystack: string, needle: string, caseSensitive = false): boolean { + return caseSensitive + ? haystack.includes(needle) + : haystack.toLowerCase().includes(needle.toLowerCase()); +} + /** * Deep equality check for comparing values */