Skip to content
Closed
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
22 changes: 21 additions & 1 deletion packages/cutie-core/src/lib/expressionEvaluator/comparison.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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]);
Expand Down
9 changes: 3 additions & 6 deletions packages/cutie-core/src/lib/expressionEvaluator/string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import { getChildElements } from '../../utils/dom';
import { stringEquals, stringIncludes } from '../../utils/equality';
import type { SubEvaluate } from './types';

/**
Expand All @@ -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;
Expand All @@ -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;
Expand Down
106 changes: 104 additions & 2 deletions packages/cutie-core/src/lib/responseProcessing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
identifier="match-string">
Expand Down Expand Up @@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
identifier="match-string-correct">
<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="string">
<qti-correct-response>
<qti-value>wicked king</qti-value>
</qti-correct-response>
</qti-response-declaration>

<qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float">
<qti-default-value>
<qti-value>0</qti-value>
</qti-default-value>
</qti-outcome-declaration>

<qti-item-body>
<qti-text-entry-interaction response-identifier="RESPONSE"/>
</qti-item-body>

<qti-response-processing>
<qti-response-condition>
<qti-response-if>
<qti-match>
<qti-variable identifier="RESPONSE"/>
<qti-correct identifier="RESPONSE"/>
</qti-match>
<qti-set-outcome-value identifier="SCORE">
<qti-base-value base-type="float">1</qti-base-value>
</qti-set-outcome-value>
</qti-response-if>
</qti-response-condition>
</qti-response-processing>
</qti-assessment-item>`;

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 = `<?xml version="1.0" encoding="UTF-8"?>
<qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
identifier="match-identifier">
<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="identifier">
<qti-correct-response>
<qti-value>ChoiceA</qti-value>
</qti-correct-response>
</qti-response-declaration>

<qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float">
<qti-default-value>
<qti-value>0</qti-value>
</qti-default-value>
</qti-outcome-declaration>

<qti-item-body>
<qti-choice-interaction response-identifier="RESPONSE" max-choices="1">
<qti-simple-choice identifier="ChoiceA">A</qti-simple-choice>
<qti-simple-choice identifier="ChoiceB">B</qti-simple-choice>
</qti-choice-interaction>
</qti-item-body>

<qti-response-processing>
<qti-response-condition>
<qti-response-if>
<qti-match>
<qti-variable identifier="RESPONSE"/>
<qti-correct identifier="RESPONSE"/>
</qti-match>
<qti-set-outcome-value identifier="SCORE">
<qti-base-value base-type="float">1</qti-base-value>
</qti-set-outcome-value>
</qti-response-if>
</qti-response-condition>
</qti-response-processing>
</qti-assessment-item>`;

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);
});

Expand Down
6 changes: 2 additions & 4 deletions packages/cutie-core/src/lib/responseProcessing.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
Expand Down
16 changes: 16 additions & 0 deletions packages/cutie-core/src/utils/equality.ts
Original file line number Diff line number Diff line change
@@ -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
*/
Expand Down
Loading