Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ const contentsOf = pairs => pairs.map(pair => pair.map(choice => choice.content)

const SCHEMA = { baseType: BaseType.PAIR, cardinality: Cardinality.MULTIPLE };

const SOLVE_X2 = '<p>Solve <span data-latex="x^2"></span> for x</p>';
const SOLVE_Y3 = '<p>Solve <span data-latex="y^3"></span> for x</p>';

const parseXmlString = xml => parseXML(xml).documentElement;

describe('parse()', () => {
Expand Down Expand Up @@ -430,6 +433,21 @@ describe('parse → buildXML → parse round-trip', () => {
]);
});

it('preserves two choices with the same text but different formulas', () => {
const state = {
responseIdentifier: 'RESPONSE',
prompt: '',
pairs: [
[
{ id: 'choice_aaa11111', content: SOLVE_X2 },
{ id: 'choice_bbb22222', content: SOLVE_Y3 },
],
],
distractors: [],
};
expect(contentsOf(roundTrip(state).pairs)).toEqual([[SOLVE_X2, SOLVE_Y3]]);
});

it('preserves the default state as one pair of two blank choices', () => {
const reparsed = roundTrip(parse('', []));
expect(contentsOf(reparsed.pairs)).toEqual([['', '']]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,19 @@ describe('validate()', () => {
ValidationError.DUPLICATE_CHOICE_CONTENT,
);
});

it.each([
['a formula', '<span data-latex="x^2"></span>', '<span data-latex="y^3"></span>'],
['an image', '<img src="abc123.png"/>', '<img src="def456.png"/>'],
])('does not flag choices with the same text but %s that differs', (_, first, second) => {
const state = makeState({
choices: [
makeAnswer({ id: 'a', content: `<p>Solve ${first} for x</p>`, correct: true }),
makeAnswer({ id: 'b', content: `<p>Solve ${second} for x</p>`, correct: false }),
],
});
expect(validate(state, QuestionType.SINGLE_SELECT)).toEqual([]);
});
});

describe('EMPTY_CHOICE_CONTENT', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ const declaration = (...values) =>
<qti-correct-response>${values.map(v => `<qti-value>${v}</qti-value>`).join('')}</qti-correct-response>
</qti-response-declaration>`;

const SOLVE_X2 = '<p>Solve <span data-latex="x^2"></span> for x</p>';
const SOLVE_Y3 = '<p>Solve <span data-latex="y^3"></span> for x</p>';

const ROWS = matchSet(choice('row_dog', 'Dog') + choice('row_eagle', 'Eagle'));
const RESPONSES = matchSet(
choice('choice_mammal', 'Mammal') + choice('choice_bird', 'Bird') + choice('choice_fur', 'Fur'),
Expand Down Expand Up @@ -484,6 +487,19 @@ describe('parse → buildXML → parse round-trip', () => {
expect(roundTrip(state)).toEqual(state);
});

it('preserves answers with the same text but different formulas', () => {
const state = {
responseIdentifier: 'RESPONSE',
prompt: '',
rows: [
{ id: 'row_dog', content: 'Dog', matches: [{ id: 'choice_a', content: SOLVE_X2 }] },
{ id: 'row_eagle', content: 'Eagle', matches: [{ id: 'choice_b', content: SOLVE_Y3 }] },
],
distractors: [],
};
expect(matchContents(roundTrip(state).rows)).toEqual([[SOLVE_X2], [SOLVE_Y3]]);
});

it('preserves the default state', () => {
const original = parse('', []);
expect(roundTrip(original)).toEqual(original);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,5 +157,18 @@ describe('validateOrderingInteraction()', () => {
ValidationError.DUPLICATE_CHOICE_CONTENT,
);
});

it.each([
['a formula', '<span data-latex="x^2"></span>', '<span data-latex="y^3"></span>'],
['an image', '<img src="abc123.png"/>', '<img src="def456.png"/>'],
])('does not flag items with the same text but %s that differs', (_, first, second) => {
const state = makeState({
items: [
makeItem({ id: 'a', content: `<p>Solve ${first} for x</p>` }),
makeItem({ id: 'b', content: `<p>Solve ${second} for x</p>` }),
],
});
expect(validateOrderingInteraction(state)).toEqual([]);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,20 @@ export class QTISanitizer {
if (typeof value !== 'string') return String(value ?? '');
// Fast path: if no `<` is present there is nothing to strip.
if (!value.includes('<')) return value;
const doc = parseXML(value, 'text/html');
return QTISanitizer.textBody(value).textContent ?? '';
}

/**
* Parse an HTML fragment into a body whose `textContent` is its visible text.
*
* @param {string} html
* @returns {HTMLElement}
*/
static textBody(html) {
const { body } = parseXML(html, 'text/html');
// Remove script and style elements entirely — we do NOT want their text content.
doc.querySelectorAll('script, style').forEach(el => el.remove());
return doc.body.textContent ?? '';
body.querySelectorAll('script, style').forEach(el => el.remove());
return body;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useEditor } from '../../../TipTapEditor/TipTapEditor/composables/useEditor';
import { hasRichTextContent, richTextComparisonKey } from '../richText';

describe('hasRichTextContent', () => {
Expand Down Expand Up @@ -52,6 +53,171 @@ describe('richTextComparisonKey', () => {
);
});

it('keeps the same text apart when its formulas differ', () => {
expect(richTextComparisonKey('<p>Solve <span data-latex="x^2"></span> for x</p>')).not.toBe(
richTextComparisonKey('<p>Solve <span data-latex="y^3"></span> for x</p>'),
);
});

it('keeps the same text apart when its images differ', () => {
expect(richTextComparisonKey('<p>see <img src="a.png"/></p>')).not.toBe(
richTextComparisonKey('<p>see <img src="b.png"/></p>'),
);
});

it('keeps the same text and formula apart when the formula moves', () => {
expect(richTextComparisonKey('<p><span data-latex="x^2"></span> solve</p>')).not.toBe(
richTextComparisonKey('<p>solve <span data-latex="x^2"></span></p>'),
);
});

it('keeps a formula apart from its LaTeX typed as text', () => {
expect(richTextComparisonKey('<p>a <span data-latex="x^2"></span></p>')).not.toBe(
richTextComparisonKey('<p>a x^2</p>'),
);
});

it('keeps a formula apart from its key typed as text', () => {
const key = richTextComparisonKey('<p>a <span data-latex="x^2"></span></p>');
expect(richTextComparisonKey(`<p>${key}</p>`)).not.toBe(key);
});

it('keeps a formula apart from an image whose identity it spells', () => {
const image = '<p>a<img src="a.png"/></p>';
const [, identity] = richTextComparisonKey(image).split('\u0000');
expect(richTextComparisonKey(`<p>a<span data-latex='${identity}'></span></p>`)).not.toBe(
richTextComparisonKey(image),
);
});

it.each([
['alt text', '<img src="a.png" alt="a cat"/>', '<img src="a.png" alt="a dog"/>'],
['width', '<img src="a.png" width="100"/>', '<img src="a.png" width="300"/>'],
['height', '<img src="a.png" height="100"/>', '<img src="a.png" height="300"/>'],
['alignment', '<img src="a.png"/>', '<img src="a.png" data-text-align="center"/>'],
['style', '<img src="a.png"/>', '<img src="a.png" style="text-align: center"/>'],
])('keeps the same text and image apart when its %s differs', (_, first, second) => {
expect(richTextComparisonKey(`<p>see ${first}</p>`)).not.toBe(
richTextComparisonKey(`<p>see ${second}</p>`),
);
});

it('reads attribute order and the default alignment as the same image beside text', () => {
expect(richTextComparisonKey('<p>see <img alt="a cat" src="a.png"></p>')).toBe(
richTextComparisonKey('<p>see <img src="a.png" style="text-align: left" alt="a cat"></p>'),
);
});

it.each([
['title', '<img src="a.png" title="t"/>'],
['class', '<img src="a.png" class="wide"/>'],
['size style', '<img src="a.png" style="width: 50%"/>'],
['saved upload', '<img src="a.png" permanentsrc="https://storage/a.png"/>'],
])('reads an image beside text with a %s it does not show as the same image', (_, other) => {
expect(richTextComparisonKey(`<p>see ${other}</p>`)).toBe(
richTextComparisonKey('<p>see <img src="a.png"/></p>'),
);
});

it.each([
['title', '<img src="a.png" title="t"/>'],
['size style', '<img src="a.png" style="width: 50%"/>'],
])('keeps an image with a %s apart when there is no text to compare', (_, other) => {
expect(richTextComparisonKey(`<p>${other}</p>`)).not.toBe(
richTextComparisonKey('<p><img src="a.png"/></p>'),
);
});

it.each([
['formula', '<span data-latex="a"></span>', '<span data-latex="b"></span>'],
['image', '<img src="a.png"/>', '<img src="b.png"/>'],
])('keeps the same code apart when its %s differs', (_, first, second) => {
expect(richTextComparisonKey(`<pre><code>x ${first}</code></pre>`)).not.toBe(
richTextComparisonKey(`<pre><code>x ${second}</code></pre>`),
);
});

it('keeps the indentation of a code block', () => {
expect(richTextComparisonKey('<pre><code>if x:\n return 1</code></pre>')).not.toBe(
richTextComparisonKey('<pre><code>if x:\n return 1</code></pre>'),
);
});

it.each([
['paragraphs', '<p>a</p><p>b</p>'],
['a line break', '<p>a<br>b</p>'],
['list items', '<ul><li>a</li><li>b</li></ul>'],
['a code block', '<pre><code>ab</code></pre>'],
])('reads text in %s as the same text in one paragraph', (_, split) => {
expect(richTextComparisonKey(split)).toBe(richTextComparisonKey('<p>ab</p>'));
expect(richTextComparisonKey(split)).not.toBe(richTextComparisonKey('<p>a b</p>'));
});

it.each([
['a trailing', '<p>Paris&nbsp;</p>'],
['a leading', '<p>&nbsp;Paris</p>'],
['an empty paragraph of', '<p>Paris</p><p>&nbsp;</p>'],
])('drops %s non-breaking space', (_, padded) => {
expect(richTextComparisonKey(padded)).toBe(richTextComparisonKey('<p>Paris</p>'));
});

it('reads plain text as the same text in a paragraph', () => {
expect(richTextComparisonKey('Paris')).toBe(richTextComparisonKey('<p>Paris</p>'));
});

describe('after TipTap rewraps it', () => {
let editors = [];

const rewrap = html => {
const { initializeEditor, editor } = useEditor();
initializeEditor(html);
editors.push(editor.value);
return editor.value.getHTML();
};

afterEach(() => {
editors.forEach(editor => editor.destroy());
editors = [];
});

it.each([
'<p>see <img src="a.png"/> here</p>',
'<p>\n <img src="a.png"/>\n caption\n</p>',
'<p>see <img alt="a cat" src="H.png" /></p>',
'<p>Solve\n <span data-latex="x^2"></span>\n for x</p>',
'<p>a</p>\n<p><span data-latex="x"></span></p>',
'<p>a</p>\n<p>\n <span data-latex="x"></span>\n</p>',
'<p>see <img src="a.png" title="t"/></p>',
'<pre><code>if x:\n return 1</code></pre>',
'<p>a<br>b</p>',
])('%j keys the same', stored => {
expect(richTextComparisonKey(rewrap(stored))).toBe(richTextComparisonKey(stored));
});
});

it('keeps the spaces around a formula', () => {
expect(richTextComparisonKey('<p>a <span data-latex="x"></span> b</p>')).not.toBe(
richTextComparisonKey('<p>a<span data-latex="x"></span>b</p>'),
);
});

it('collapses runs of whitespace beside a formula', () => {
expect(richTextComparisonKey('<p>a <span data-latex="x"></span></p>')).toBe(
richTextComparisonKey('<p>a <span data-latex="x"></span></p>'),
);
});

it('keeps runs of whitespace in text alone', () => {
expect(richTextComparisonKey('<p>a b</p>')).not.toBe(richTextComparisonKey('<p>a b</p>'));
});

it('keeps a non-breaking space apart from a space', () => {
expect(richTextComparisonKey('<p>a&nbsp; b</p>')).not.toBe(richTextComparisonKey('<p>a b</p>'));
expect(richTextComparisonKey('<p>a&nbsp;<span data-latex="x"></span></p>')).not.toBe(
richTextComparisonKey('<p>a <span data-latex="x"></span></p>'),
);
});

it('still matches the same image offered twice', () => {
expect(richTextComparisonKey('<p><img src="a.png"/></p>')).toBe(
richTextComparisonKey('<p>\n <img src="a.png"/>\n</p>'),
Expand Down
Loading
Loading