diff --git a/apps/learnosity-parity-demo/src/lib/samples/mc-populated-blank.json b/apps/learnosity-parity-demo/src/lib/samples/mc-populated-blank.json index 18c960a4..ca5b0039 100644 --- a/apps/learnosity-parity-demo/src/lib/samples/mc-populated-blank.json +++ b/apps/learnosity-parity-demo/src/lib/samples/mc-populated-blank.json @@ -819,6 +819,56 @@ "choiceId": "" } }, + { + "id": "variant-sel-vic-cloze-punct", + "title": "sel_vic — cloze followed by punctuation (CONTOOL-2572)", + "description": "Reproduction sample for CONTOOL-2572: cloze marker is immediately followed by a period; PIE renders a different gap to the punctuation than Learnosity. Sourced from item 0d6e94d3-b8d7-486c-803f-b037e49bbd9c.", + "tags": ["mc-populated-blank", "cqt-sample", "sel_vic", "CONTOOL-2572"], + "model": { + "id": "18b", + "element": "mc-populated-blank", + "prompt": "", + "promptEnabled": false, + "template": "
Choose the right word: {{blank}}.
", + "choiceMode": "text", + "choices": [ + { + "id": "distractor_1", + "labelHtml": "run" + }, + { + "id": "distractor_2", + "labelHtml": "do" + }, + { + "id": "distractor_3", + "labelHtml": "toy" + } + ], + "correctChoiceId": "distractor_1", + "hasAudio": true, + "audioUrl": "https://assets.learnosity.com/organisations/844/af9f6179-fca4-4e5a-9b7f-8fb5374a23e5.mp3", + "audioTranscript": "Choose the right word.", + "interactionMode": "populate_blank", + "sentenceHtml": "", + "layoutProfile": "inline_sentence", + "customType": "sel_vic", + "useFeatureButtonAudio": true, + "choiceLayout": "vertical", + "locale": "", + "autoplayAudioEnabled": true, + "completeAudioEnabled": true, + "source": { + "csvQuestionTypeTag": "sel_vicv0.0.1", + "learnosityItemReference": "0d6e94d3-b8d7-486c-803f-b037e49bbd9c_v2.0" + } + }, + "session": { + "id": "18b", + "element": "mc-populated-blank", + "choiceId": "" + } + }, { "id": "variant-sel-r1-gg-plus-graphic", "title": "sel_r1-gg_plusggg \u2014 graphic choices English (bc827dec)", diff --git a/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-gg-plus-parity.spec.ts b/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-gg-plus-parity.spec.ts index a2915436..718bfef4 100644 --- a/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-gg-plus-parity.spec.ts +++ b/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-gg-plus-parity.spec.ts @@ -119,18 +119,19 @@ test('gg-plus: hovered unselected choice tile background is #f2f2f2', async ({ p }); // --------------------------------------------------------------------------- -// 5. Template-line font size is 1.9em (r1 content-element base) -// r1.scss: .rli-r1-content-element { font-size: 1.9em } — shared via sel-r1-base.css. -// Without the base CSS this variant renders template tokens at the default body size. +// 5. Template-line font size resolves to 26.6px (r1 content-element base) +// r1.scss: .rli-r1-content-element { font-size: 1.9em } against the Learnosity +// host base of 14px → 26.6px. PIE pins the same 14px base on the variant root +// in sel-r1-base.css so the existing 1.9em rule resolves to the same px value. +// Tolerance ±0.5px to absorb sub-pixel font rendering noise. // --------------------------------------------------------------------------- -test('gg-plus: template line font size is 1.9em', async ({ page }) => { +test('gg-plus: template line font size matches LSY (14px × 1.9em = 26.6px)', async ({ page }) => { await openGgPlusRoute(page); const root = deliveryContainer(page); const templateLine = root.locator('.pie-template-line'); await expect(templateLine).toBeVisible(); const px = parseFloat(await templateLine.evaluate((el) => getComputedStyle(el).fontSize)); - expect(px).toBeGreaterThanOrEqual(30); - expect(px).toBeLessThanOrEqual(32); + expect(Math.abs(px - 26.6)).toBeLessThanOrEqual(0.5); }); // --------------------------------------------------------------------------- diff --git a/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-ggplus-parity.spec.ts b/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-ggplus-parity.spec.ts index 3cda040e..1a350696 100644 --- a/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-ggplus-parity.spec.ts +++ b/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-ggplus-parity.spec.ts @@ -119,17 +119,17 @@ test('ggplus: hovered unselected choice tile background is #f2f2f2', async ({ pa }); // --------------------------------------------------------------------------- -// 5. Template-line font size is 1.9em (r1 content-element base) -// r1.scss: .rli-r1-content-element { font-size: 1.9em } — shared via sel-r1-base.css. +// 5. Template-line font size resolves to 26.6px (r1 content-element base) +// r1.scss: .rli-r1-content-element { font-size: 1.9em } against the Learnosity +// host base of 14px → 26.6px. Tolerance ±0.5px for sub-pixel rendering. // --------------------------------------------------------------------------- -test('ggplus: template line font size is 1.9em', async ({ page }) => { +test('ggplus: template line font size matches LSY (14px × 1.9em = 26.6px)', async ({ page }) => { await openGgplusRoute(page); const root = deliveryContainer(page); const templateLine = root.locator('.pie-template-line'); await expect(templateLine).toBeVisible(); const px = parseFloat(await templateLine.evaluate((el) => getComputedStyle(el).fontSize)); - expect(px).toBeGreaterThanOrEqual(30); - expect(px).toBeLessThanOrEqual(32); + expect(Math.abs(px - 26.6)).toBeLessThanOrEqual(0.5); }); // --------------------------------------------------------------------------- diff --git a/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-gplusggg-parity.spec.ts b/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-gplusggg-parity.spec.ts index df69d9f9..88c6c1af 100644 --- a/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-gplusggg-parity.spec.ts +++ b/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-gplusggg-parity.spec.ts @@ -215,12 +215,11 @@ test('gplusggg: after-cloze content does not shift vertically when a distractor // --------------------------------------------------------------------------- // r1.scss: .rli-r1-content-element { font-size: 1.9em } wraps all stem tokens. -// Inline font-size spans in the template (e.g. style="font-size:1.8em") must -// multiply on top of the 1.9em base — so the template-line context must be 1.9em. +// Inline font-size spans in the template (e.g. style="font-size:1.8em") multiply +// on top of this. The 1.9em resolves against the Learnosity host base of 14px, +// pinned on the variant root in sel-r1-base.css for parity. → 26.6px. // --------------------------------------------------------------------------- -test('gplusggg: template line font size is 1.9em (r1 content-element base size)', async ({ - page, -}) => { +test('gplusggg: template line font size matches LSY (14px × 1.9em = 26.6px)', async ({ page }) => { await openGplusgggRoute(page); const root = deliveryContainer(page); @@ -228,10 +227,8 @@ test('gplusggg: template line font size is 1.9em (r1 content-element base size)' await expect(templateLine).toBeVisible(); const fontSize = await templateLine.evaluate((el) => getComputedStyle(el).fontSize); - // 1.9em relative to default 16px body = 30.4px; accept 30–31px to allow subpixel rounding. const px = parseFloat(fontSize); - expect(px).toBeGreaterThanOrEqual(30); - expect(px).toBeLessThanOrEqual(32); + expect(Math.abs(px - 26.6)).toBeLessThanOrEqual(0.5); }); // --------------------------------------------------------------------------- diff --git a/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-graphic-parity.spec.ts b/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-graphic-parity.spec.ts index e1a8c77f..36d896af 100644 --- a/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-graphic-parity.spec.ts +++ b/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-graphic-parity.spec.ts @@ -19,7 +19,7 @@ */ import { type Page, expect, test } from '@playwright/test'; -import { deliveryContainer, waitForMathRendering } from './test-helpers'; +import { deliveryContainer, disableTransitions, waitForMathRendering } from './test-helpers'; import { PARITY_REGIONS } from './parity-regions'; const GRAPHIC_VARIANTS = [ @@ -47,6 +47,10 @@ async function openGraphicRoute(page: Page, demoId: string) { }, { timeout: 30_000 } ); + // Kill CSS transitions so toHaveCSS doesn't sample mid-fade. ChoiceRow.svelte + // animates background-color over 120ms; without this, a check() followed by + // an immediate computed-style read can land on the intermediate color. + await disableTransitions(page); } for (const DEMO_ID of GRAPHIC_VARIANTS) { @@ -72,7 +76,6 @@ for (const DEMO_ID of GRAPHIC_VARIANTS) { const root = deliveryContainer(page); await root.locator('input[type="radio"]').first().check(); - await page.waitForTimeout(100); const selectedRow = root.locator('.pie-choice.is-selected').first(); await expect(selectedRow).toBeVisible(); @@ -105,10 +108,9 @@ for (const DEMO_ID of GRAPHIC_VARIANTS) { const bgBefore = await firstTile.evaluate((el) => getComputedStyle(el).backgroundColor); await firstTile.hover(); - await page.waitForTimeout(100); - const bgAfter = await firstTile.evaluate((el) => getComputedStyle(el).backgroundColor); - - expect(bgAfter).not.toBe(bgBefore); + await expect + .poll(() => firstTile.evaluate((el) => getComputedStyle(el).backgroundColor)) + .not.toBe(bgBefore); }); test('gap between adjacent choice tiles on the same row is at least 16px', async ({ page }) => { diff --git a/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-issue-CONTOOL-2567-parity.spec.ts b/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-issue-CONTOOL-2567-parity.spec.ts new file mode 100644 index 00000000..36682b4f --- /dev/null +++ b/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-issue-CONTOOL-2567-parity.spec.ts @@ -0,0 +1,102 @@ +/** + * Live parity test for CONTOOL-2567: + * https://illuminate.atlassian.net/browse/CONTOOL-2567 + * + * Reported by content: McPopulatedBlank renders larger text than the + * Learnosity reference, and the rendered text runs together in the + * distractors and in the stem after a selection is made. + * + * Root cause hypothesis: McPopulatedBlank's stem/distractor cascade is + * em-based, while the Learnosity Custom Question type sizes the equivalent + * spans in px. Author content uses inline `font-size:1.8em;` on the same + * spans on both sides — Learnosity's px-based parents resolve the em + * relative to a smaller base, so the visible glyph is ~14% smaller than PIE's. + * + * The test asserts that the visible 1.8em span inside the stem and the first + * distractor renders at the same computed font-size as the corresponding + * Learnosity span, within ±0.5px to absorb sub-pixel rendering noise. + * + * Variant: variant-sel-r1-g-stem (token_sequence layout, horizontal choices). + * The reported reference item (1d45e2d4-...) is a different item of the same + * customType; the bug is at the customType layer, not item-content specific. + */ + +import { expect, test } from '@playwright/test'; + +const DEMO_ID = 'variant-sel-r1-g-stem'; +const FONT_SIZE_TOLERANCE_PX = 0.5; +const CREDENTIALS_PRESENT = !!process.env.LEARNOSITY_CONSUMER_KEY; + +async function openParityRoute(page: import('@playwright/test').Page) { + await page.goto(`/mc-populated-blank/parity?demo=${encodeURIComponent(DEMO_ID)}`); + await page.waitForLoadState('domcontentloaded'); + await page.waitForLoadState('networkidle'); + await page.waitForSelector('#pie-container pie-element-player', { timeout: 20_000 }); + await page.waitForSelector('[data-learnosity-ready="true"]', { timeout: 30_000 }); +} + +/** + * Find the deepest descendant of `root` whose own (non-nested) text content + * includes `text`. Used to locate the leaf that + * directly wraps the visible token, regardless of intervening wrappers. + */ +function deepestTextSpanFn() { + return (args: { rootSelector: string; text: string }) => { + const root = document.querySelector(args.rootSelector); + if (!root) return null; + let candidate: Element | null = null; + const walk = (el: Element) => { + const ownText = Array.from(el.childNodes) + .filter((n) => n.nodeType === Node.TEXT_NODE) + .map((n) => n.textContent ?? '') + .join(''); + if (ownText.includes(args.text)) candidate = el; + for (const child of Array.from(el.children)) walk(child); + }; + walk(root); + if (!candidate) return null; + return parseFloat(getComputedStyle(candidate).fontSize); + }; +} + +test.describe('CONTOOL-2567 — font-size parity', () => { + test.skip(!CREDENTIALS_PRESENT, 'Skipped: LEARNOSITY_CONSUMER_KEY not set'); + + test('stem token (1.8em span) matches Learnosity computed font-size', async ({ page }) => { + await openParityRoute(page); + + const piePx = await page.evaluate(deepestTextSpanFn(), { + rootSelector: '#pie-container .pie-template-line', + text: 'will', + }); + const lrnPx = await page.evaluate(deepestTextSpanFn(), { + rootSelector: '#learnosity-container .rli-r1-stem', + text: 'will', + }); + + expect(piePx, 'PIE stem span not found').not.toBeNull(); + expect(lrnPx, 'Learnosity stem span not found').not.toBeNull(); + expect(Math.abs((piePx as number) - (lrnPx as number))).toBeLessThanOrEqual( + FONT_SIZE_TOLERANCE_PX + ); + }); + + test('first distractor (1.8em span) matches Learnosity computed font-size', async ({ page }) => { + await openParityRoute(page); + + const piePx = await page.evaluate(deepestTextSpanFn(), { + rootSelector: '#pie-container .pie-choices-fieldset .pie-choice', + text: 'fill', + }); + const lrnPx = await page.evaluate(deepestTextSpanFn(), { + rootSelector: '#learnosity-container .rli-r1-distractors .rli-r1-distractor', + text: 'fill', + }); + + expect(piePx, 'PIE distractor span not found').not.toBeNull(); + expect(lrnPx, 'Learnosity distractor span not found').not.toBeNull(); + expect(Math.abs((piePx as number) - (lrnPx as number))).toBeLessThanOrEqual( + FONT_SIZE_TOLERANCE_PX + ); + }); +}); diff --git a/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-issue-CONTOOL-2569-parity.spec.ts b/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-issue-CONTOOL-2569-parity.spec.ts new file mode 100644 index 00000000..c4a7671e --- /dev/null +++ b/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-issue-CONTOOL-2569-parity.spec.ts @@ -0,0 +1,85 @@ +/** + * Live parity test for CONTOOL-2569: + * https://illuminate.atlassian.net/browse/CONTOOL-2569 + * + * Reported by content: when a distractor is selected on the + * sel_r1-_ggplusggg variant, the visible whitespace between the cloze + * marker (filled blank) and the first trailing stem token does not match + * the Learnosity reference. PIE renders a noticeably larger gap than LSY. + * + * Variant: variant-sel-r1-ggplus (token_sequence layout, horizontal choices). + * The reported reference item (f522383b-8f50-4014-b557-f1b1acd678f3) is a + * different sample of the same customType; the gap divergence is at the + * customType layer, not item-content specific. + * + * The test asserts that the horizontal gap from the cloze marker's right + * edge to the first trailing stem token's left edge matches the same gap + * on the Learnosity side, within ±2px to absorb sub-pixel rendering noise. + */ + +import { expect, test } from '@playwright/test'; + +const DEMO_ID = 'variant-sel-r1-ggplus'; +const GAP_TOLERANCE_PX = 2; +const CREDENTIALS_PRESENT = !!process.env.LEARNOSITY_CONSUMER_KEY; + +async function openParityRoute(page: import('@playwright/test').Page) { + await page.goto(`/mc-populated-blank/parity?demo=${encodeURIComponent(DEMO_ID)}`); + await page.waitForLoadState('domcontentloaded'); + await page.waitForLoadState('networkidle'); + await page.waitForSelector('#pie-container pie-element-player', { timeout: 20_000 }); + await page.waitForSelector('[data-learnosity-ready="true"]', { timeout: 30_000 }); +} + +/** + * Measures the horizontal gap (in px) between the cloze marker's right edge + * and the first trailing stem token's left edge on the given side. The + * trailing token is the first descendant of the stem container that is NOT + * inside the cloze marker itself — Learnosity wraps trailing tokens in + * .rli-r1-content-element (siblings of .rli-r1-cloze), and PIE renders them + * as direct-child spans of .pie-template-line outside .pie-blank-slot. + */ +function measureGapFn() { + return (args: { stemSelector: string; clozeSelector: string; tokenSelector: string }) => { + const stem = document.querySelector(args.stemSelector) as HTMLElement | null; + const cloze = document.querySelector(args.clozeSelector) as HTMLElement | null; + if (!stem || !cloze) return null; + const tokenCandidates = Array.from(stem.querySelectorAll(args.tokenSelector)) as HTMLElement[]; + const token = tokenCandidates.find((el) => !cloze.contains(el)); + if (!token) return null; + const clozeRight = cloze.getBoundingClientRect().right; + const tokenLeft = token.getBoundingClientRect().left; + return tokenLeft - clozeRight; + }; +} + +test.describe('CONTOOL-2569 — cloze-to-token gap parity (ggplus)', () => { + test.skip(!CREDENTIALS_PRESENT, 'Skipped: LEARNOSITY_CONSUMER_KEY not set'); + + test('cloze→trailing-token gap matches Learnosity within ±2px after selection', async ({ + page, + }) => { + await openParityRoute(page); + + // Select the first distractor on each side so the cloze renders a value. + await page.locator('#pie-container input[type="radio"]').first().check(); + await page.locator('#learnosity-container input[type="radio"]').first().check(); + await page.waitForTimeout(200); + + const piePx = await page.evaluate(measureGapFn(), { + stemSelector: '#pie-container .pie-template-line', + clozeSelector: '#pie-container .pie-blank-slot', + // Direct-child classless spans: trailing tokens from {@html} template content. + tokenSelector: ':scope > span:not([class])', + }); + const lrnPx = await page.evaluate(measureGapFn(), { + stemSelector: '#learnosity-container .rli-r1-stem', + clozeSelector: '#learnosity-container .rli-r1-cloze', + tokenSelector: '.rli-r1-content-element', + }); + + expect(piePx, 'PIE cloze/token not found').not.toBeNull(); + expect(lrnPx, 'Learnosity cloze/token not found').not.toBeNull(); + expect(Math.abs((piePx as number) - (lrnPx as number))).toBeLessThanOrEqual(GAP_TOLERANCE_PX); + }); +}); diff --git a/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-issue-CONTOOL-2570-parity.spec.ts b/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-issue-CONTOOL-2570-parity.spec.ts new file mode 100644 index 00000000..fc1a8cc8 --- /dev/null +++ b/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-issue-CONTOOL-2570-parity.spec.ts @@ -0,0 +1,96 @@ +/** + * Live parity test for CONTOOL-2570: + * https://illuminate.atlassian.net/browse/CONTOOL-2570 + * + * Reported by content: on every plusggg variant that uses image content + * (graphic stem tokens or graphic distractors), images render smaller in PIE + * than they do in Learnosity. The reported reference item is + * 69d73376-ab3c-4ec6-8453-5b4e88e8977a (sel_r1-_ggplusggg, graphic). + * + * Variant: variant-sel-r1-ggplus-graphic — stem has two graphic tokens (woman, + * girl) and three graphic distractors. Authored width/height attributes are + * 108×108. LSY normalizes these to 150×150 (it rewrites the attributes server + * side, and r1.scss applies .rli-r1-content-element { min/max:150px }), so + * every image in the stem and the choice tiles renders at 150×150. + * + * The test asserts PIE renders the same image size as LSY, within ±2px to + * absorb sub-pixel rendering noise. Without the fix PIE renders the authored + * 108×108 box, which is the visible regression. + */ + +import { expect, test } from '@playwright/test'; + +const DEMO_ID = 'variant-sel-r1-ggplus-graphic'; +const SIZE_TOLERANCE_PX = 2; +const CREDENTIALS_PRESENT = !!process.env.LEARNOSITY_CONSUMER_KEY; + +async function openParityRoute(page: import('@playwright/test').Page) { + await page.goto(`/mc-populated-blank/parity?demo=${encodeURIComponent(DEMO_ID)}`); + await page.waitForLoadState('domcontentloaded'); + await page.waitForLoadState('networkidle'); + await page.waitForSelector('#pie-container pie-element-player', { timeout: 20_000 }); + await page.waitForSelector('[data-learnosity-ready="true"]', { timeout: 30_000 }); +} + +function imageSizesFn() { + return (selector: string) => { + const imgs = Array.from(document.querySelectorAll(selector)) as HTMLImageElement[]; + return imgs.map((img) => { + const rect = img.getBoundingClientRect(); + return { width: rect.width, height: rect.height }; + }); + }; +} + +test.describe('CONTOOL-2570 — graphic image size parity (ggplus-graphic)', () => { + test.skip(!CREDENTIALS_PRESENT, 'Skipped: LEARNOSITY_CONSUMER_KEY not set'); + + test('stem graphic images render at the same size as Learnosity within ±2px', async ({ + page, + }) => { + await openParityRoute(page); + + const pieSizes = await page.evaluate(imageSizesFn(), '#pie-container .pie-template-line img'); + const lrnSizes = await page.evaluate(imageSizesFn(), '#learnosity-container .rli-r1-stem img'); + + expect(pieSizes.length, 'PIE stem images').toBeGreaterThan(0); + expect(pieSizes.length, 'PIE/LSY stem image count mismatch').toBe(lrnSizes.length); + + for (let i = 0; i < pieSizes.length; i += 1) { + expect( + Math.abs(pieSizes[i].width - lrnSizes[i].width), + `stem image #${i} width drift` + ).toBeLessThanOrEqual(SIZE_TOLERANCE_PX); + expect( + Math.abs(pieSizes[i].height - lrnSizes[i].height), + `stem image #${i} height drift` + ).toBeLessThanOrEqual(SIZE_TOLERANCE_PX); + } + }); + + test('distractor graphic images render at the same size as Learnosity within ±2px', async ({ + page, + }) => { + await openParityRoute(page); + + const pieSizes = await page.evaluate(imageSizesFn(), '#pie-container .pie-choice-label img'); + const lrnSizes = await page.evaluate( + imageSizesFn(), + '#learnosity-container .rli-r1-distractors img' + ); + + expect(pieSizes.length, 'PIE distractor images').toBeGreaterThan(0); + expect(pieSizes.length, 'PIE/LSY distractor image count mismatch').toBe(lrnSizes.length); + + for (let i = 0; i < pieSizes.length; i += 1) { + expect( + Math.abs(pieSizes[i].width - lrnSizes[i].width), + `distractor image #${i} width drift` + ).toBeLessThanOrEqual(SIZE_TOLERANCE_PX); + expect( + Math.abs(pieSizes[i].height - lrnSizes[i].height), + `distractor image #${i} height drift` + ).toBeLessThanOrEqual(SIZE_TOLERANCE_PX); + } + }); +}); diff --git a/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-issue-CONTOOL-2571-parity.spec.ts b/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-issue-CONTOOL-2571-parity.spec.ts new file mode 100644 index 00000000..2e3d9bc9 --- /dev/null +++ b/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-issue-CONTOOL-2571-parity.spec.ts @@ -0,0 +1,79 @@ +/** + * Live parity test for CONTOOL-2571: + * https://illuminate.atlassian.net/browse/CONTOOL-2571 + * + * Reported by content: distractor labels and the filled cloze on the + * sel_r1-s3_plusggg variant render in a different weight in PIE than they do + * in Learnosity. The reported reference item is + * b924b8b7-0ce2-4a03-bf10-3f9647e23025. + * + * Variant: variant-sel-r1-s3 (stimulus_image_blank, horizontal text choices, + * distractor labels wrapped in ). LSY's + * sel_r1-s3_plusggg/question.css has no font-weight rule for distractors or + * the cloze content-element, so both fall through to the host root weight 400. + * PIE adds a font-weight:500 rule on .pie-choice-label that does not exist in + * LSY, so PIE renders the same content visibly heavier. + * + * The test reads the computed font-weight of the deepest text-bearing span + * (the inner author span) on both sides and asserts they match. + */ + +import { expect, test } from '@playwright/test'; + +const DEMO_ID = 'variant-sel-r1-s3'; +const CREDENTIALS_PRESENT = !!process.env.LEARNOSITY_CONSUMER_KEY; + +async function openParityRoute(page: import('@playwright/test').Page) { + await page.goto(`/mc-populated-blank/parity?demo=${encodeURIComponent(DEMO_ID)}`); + await page.waitForLoadState('domcontentloaded'); + await page.waitForLoadState('networkidle'); + await page.waitForSelector('#pie-container pie-element-player', { timeout: 20_000 }); + await page.waitForSelector('[data-learnosity-ready="true"]', { timeout: 30_000 }); +} + +function fontWeightOf() { + return (selector: string) => { + const el = document.querySelector(selector) as HTMLElement | null; + if (!el) return null; + return getComputedStyle(el).fontWeight; + }; +} + +test.describe('CONTOOL-2571 — distractor/cloze font weight parity (s3)', () => { + test.skip(!CREDENTIALS_PRESENT, 'Skipped: LEARNOSITY_CONSUMER_KEY not set'); + + test('distractor inner span font weight matches Learnosity', async ({ page }) => { + await openParityRoute(page); + + // The inner author carries the visible text + // on both sides; if PIE applies any weight override on its ancestor, the + // inner span inherits it. + const piePartialSel = '#pie-container .pie-choice-label span[style*="font-size"]'; + const lrnPartialSel = '#learnosity-container .rli-s3-content-element span[style*="font-size"]'; + const pieWeight = await page.evaluate(fontWeightOf(), piePartialSel); + const lrnWeight = await page.evaluate(fontWeightOf(), lrnPartialSel); + + expect(pieWeight, 'PIE distractor inner span').not.toBeNull(); + expect(lrnWeight, 'LSY distractor inner span').not.toBeNull(); + expect(pieWeight).toBe(lrnWeight); + }); + + test('filled cloze inner span font weight matches Learnosity', async ({ page }) => { + await openParityRoute(page); + + // Select the first distractor on each side so the cloze fills with the + // selected label; only then does the filled cloze span appear. + await page.locator('#pie-container input[type="radio"]').first().check(); + await page.locator('#learnosity-container input[type="radio"]').first().check(); + await page.waitForTimeout(200); + + const pieClozeSel = '#pie-container .pie-blank-value span[style*="font-size"]'; + const lrnClozeSel = '#learnosity-container .rli-s3-cloze span[style*="font-size"]'; + const pieWeight = await page.evaluate(fontWeightOf(), pieClozeSel); + const lrnWeight = await page.evaluate(fontWeightOf(), lrnClozeSel); + + expect(pieWeight, 'PIE filled cloze inner span').not.toBeNull(); + expect(lrnWeight, 'LSY filled cloze inner span').not.toBeNull(); + expect(pieWeight).toBe(lrnWeight); + }); +}); diff --git a/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-issue-CONTOOL-2572-parity.spec.ts b/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-issue-CONTOOL-2572-parity.spec.ts new file mode 100644 index 00000000..863b527c --- /dev/null +++ b/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-issue-CONTOOL-2572-parity.spec.ts @@ -0,0 +1,111 @@ +/** + * Live parity test for CONTOOL-2572: + * https://illuminate.atlassian.net/browse/CONTOOL-2572 + * + * Reported by content: on the sel_vic variant, when the cloze marker is + * immediately followed by a piece of punctuation (e.g. a period), the + * horizontal gap between the filled cloze value and the punctuation is not + * the same in PIE as it is in Learnosity. Reported reference item is + * 0d6e94d3-b8d7-486c-803f-b037e49bbd9c. + * + * The reproduction sample registered for this case is `variant-sel-vic-cloze-punct`, + * which uses a generic `Choose the right word: {{blank}}.` template so the + * leading text doesn't affect the cloze→period adjacency we are measuring. + * + * The test selects a distractor on each side, then measures the px gap from + * the cloze marker's right edge to the leading edge of the trailing + * punctuation node. PIE and LSY must agree within ±2px. + */ + +import { expect, test } from '@playwright/test'; + +const DEMO_ID = 'variant-sel-vic-cloze-punct'; +const GAP_TOLERANCE_PX = 2; +const CREDENTIALS_PRESENT = !!process.env.LEARNOSITY_CONSUMER_KEY; + +async function openParityRoute(page: import('@playwright/test').Page) { + await page.goto(`/mc-populated-blank/parity?demo=${encodeURIComponent(DEMO_ID)}`); + await page.waitForLoadState('domcontentloaded'); + await page.waitForLoadState('networkidle'); + await page.waitForSelector('#pie-container pie-element-player', { timeout: 20_000 }); + await page.waitForSelector('[data-learnosity-ready="true"]', { timeout: 30_000 }); +} + +/** + * Returns the horizontal gap (in CSS px) from the right edge of the cloze + * marker to the leading edge of the first trailing punctuation glyph. We use + * a Range over the trailing text node so the measurement is glyph-accurate + * rather than including any container padding/margin past the punctuation. + */ +function measureClozeToPunctGapFn() { + return (args: { stemSelector: string; clozeSelector: string }) => { + const stem = document.querySelector(args.stemSelector) as HTMLElement | null; + const cloze = document.querySelector(args.clozeSelector) as HTMLElement | null; + if (!stem || !cloze) return null; + const clozeRect = cloze.getBoundingClientRect(); + + // Find the first punctuation glyph that sits to the right of the cloze on + // the same line. We walk every text node in the stem (skipping the cloze + // contents themselves) and for each punctuation char build a Range and + // measure its rect. + const walker = document.createTreeWalker(stem, NodeFilter.SHOW_TEXT); + let best: { left: number; rect: DOMRect } | null = null; + let node = walker.nextNode() as Text | null; + while (node) { + if (cloze.contains(node)) { + node = walker.nextNode() as Text | null; + continue; + } + const text = node.nodeValue ?? ''; + for (let i = 0; i < text.length; i += 1) { + if (!/[.,!?;:]/.test(text[i])) continue; + const range = document.createRange(); + range.setStart(node, i); + range.setEnd(node, i + 1); + const rect = range.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) continue; + // Same-line: punctuation top is within the cloze's vertical extent. + const sameLine = rect.top < clozeRect.bottom - 1 && rect.bottom > clozeRect.top + 1; + if (!sameLine) continue; + // Must be to the right of the cloze. + if (rect.left < clozeRect.right) continue; + if (!best || rect.left < best.left) { + best = { left: rect.left, rect }; + } + } + node = walker.nextNode() as Text | null; + } + if (!best) return null; + return best.left - clozeRect.right; + }; +} + +test.describe('CONTOOL-2572 — sel-vic cloze→punctuation gap parity', () => { + test.skip(!CREDENTIALS_PRESENT, 'Skipped: LEARNOSITY_CONSUMER_KEY not set'); + + test('cloze→trailing-period gap matches Learnosity within ±2px after selection', async ({ + page, + }) => { + await openParityRoute(page); + + await page.locator('#pie-container input[type="radio"]').first().check(); + await page.locator('#learnosity-container input[type="radio"]').first().check(); + await page.waitForTimeout(200); + + const piePx = await page.evaluate(measureClozeToPunctGapFn(), { + stemSelector: '#pie-container .pie-template-line', + clozeSelector: '#pie-container .pie-blank-slot', + }); + const lrnPx = await page.evaluate(measureClozeToPunctGapFn(), { + stemSelector: '#learnosity-container .rli-vic-stem, #learnosity-container .rli-vic-answer', + clozeSelector: '#learnosity-container .rli-vic-cloze', + }); + + expect(piePx, 'PIE cloze/punct not found').not.toBeNull(); + expect(lrnPx, 'Learnosity cloze/punct not found').not.toBeNull(); + expect( + Math.abs((piePx as number) - (lrnPx as number)), + `PIE gap=${piePx} LSY gap=${lrnPx}` + ).toBeLessThanOrEqual(GAP_TOLERANCE_PX); + }); +}); diff --git a/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-issue-CONTOOL-2573-parity.spec.ts b/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-issue-CONTOOL-2573-parity.spec.ts new file mode 100644 index 00000000..04886ee3 --- /dev/null +++ b/apps/learnosity-parity-demo/test/e2e/mc-populated-blank-issue-CONTOOL-2573-parity.spec.ts @@ -0,0 +1,157 @@ +/** + * Live parity test for CONTOOL-2573: + * https://illuminate.atlassian.net/browse/CONTOOL-2573 + * + * Reported by content: on the sel_vic variant, when the viewport is narrow + * enough that the template sentence wraps to a second line, PIE renders the + * second line center-aligned while Learnosity renders it left-aligned. The + * reported reference item is 0d6e94d3-b8d7-486c-803f-b037e49bbd9c (v2.0). + * + * The reproduction reuses the variant-sel-vic-cloze-punct sample (registered + * for CONTOOL-2572) but extends the template via JS to a length that forces a + * wrap at the test viewport. The test then measures the leftmost glyph of the + * second visible line in the stem and asserts that it sits at (or very close + * to) the stem container's content-box left edge — i.e. the line is + * left-aligned, not centered. + * + * The PIE side is the only side asserted because the LSY side already renders + * left-aligned wrapped lines. Asserting absolute pixel parity between sides + * would couple the test to the exact stem text used in the live LSY item; + * since the symptom is purely about alignment, an alignment-only assertion is + * sufficient and item-content independent. + */ + +import { expect, test } from '@playwright/test'; + +const DEMO_ID = 'variant-sel-vic-cloze-punct'; +const CREDENTIALS_PRESENT = !!process.env.LEARNOSITY_CONSUMER_KEY; +// How close to the content-box left the second line must start. A center- +// aligned wrap leaves tens of px of slack on the left; left alignment puts +// the first glyph within a few px of the content-box edge. +const ALIGNMENT_TOLERANCE_PX = 6; + +async function openParityRoute(page: import('@playwright/test').Page) { + await page.goto(`/mc-populated-blank/parity?demo=${encodeURIComponent(DEMO_ID)}`); + await page.waitForLoadState('domcontentloaded'); + await page.waitForLoadState('networkidle'); + await page.waitForSelector('#pie-container pie-element-player', { timeout: 20_000 }); + await page.waitForSelector('[data-learnosity-ready="true"]', { timeout: 30_000 }); +} + +/** + * Replaces the leading text of the PIE template so the sentence is long
+ * enough to force a wrap at the constrained viewport, while preserving the
+ * cloze marker and trailing period that follow it. We do not touch the LSY
+ * side because the symptom is PIE-side and the LSY content already wraps in
+ * its own way at narrow widths.
+ */
+async function lengthenPieStemToForceWrap(page: import('@playwright/test').Page) {
+ await page.evaluate(() => {
+ const p = document.querySelector('#pie-container .pie-template-line p') as HTMLElement | null;
+ if (!p) return;
+ const long =
+ 'When the screen is narrow enough that the prompt does not fit on a single line, the wrapped second line should remain left aligned and not snap back to the center of the container — please pick the right word for the missing slot:';
+ // Replace the original "Choose the right word:" text node with the long
+ // prompt; preserve the trailing nodes (cloze + " .") untouched.
+ const firstChild = p.firstChild;
+ if (firstChild && firstChild.nodeType === Node.TEXT_NODE) {
+ firstChild.nodeValue = `${long} `;
+ }
+ });
+}
+
+/**
+ * Returns the line-by-line first-glyph x positions inside the stem element.
+ * Uses Range.getClientRects() over each text node so we get one rect per
+ * visual line, then groups rects by their integer top to identify lines.
+ * For each line we record both the leftmost and rightmost glyph edge so the
+ * caller can reason about indent vs. leftover space.
+ */
+function measureLineLeftsFn() {
+ return (selector: string) => {
+ const stem = document.querySelector(selector) as HTMLElement | null;
+ if (!stem) return null;
+ const stemRect = stem.getBoundingClientRect();
+ const stemStyles = getComputedStyle(stem);
+ const padLeft = parseFloat(stemStyles.paddingLeft) || 0;
+ const padRight = parseFloat(stemStyles.paddingRight) || 0;
+ const contentLeft = stemRect.left + padLeft;
+ const contentRight = stemRect.right - padRight;
+ const contentWidth = contentRight - contentLeft;
+
+ const lineLefts = new Map {{blank}} {{blank}}
+ to 150 server-side. PIE keeps the authored attributes (typically 108×108),
+ so without an explicit override images render ~28% smaller than LSY.
+ See CONTOOL-2570. */
+[class*="variant-sel-r1-"] .pie-template-line img,
+[class*="variant-sel-r1-"] .pie-choice-label img {
+ width: 150px;
+ height: 150px;
+}
diff --git a/packages/elements-svelte/mc-populated-blank/src/delivery/cqt-css/sel-r1-ggplus.css b/packages/elements-svelte/mc-populated-blank/src/delivery/cqt-css/sel-r1-ggplus.css
index 092d8dce..400526a4 100644
--- a/packages/elements-svelte/mc-populated-blank/src/delivery/cqt-css/sel-r1-ggplus.css
+++ b/packages/elements-svelte/mc-populated-blank/src/delivery/cqt-css/sel-r1-ggplus.css
@@ -10,8 +10,3 @@
--mpb-choice-hover-bg: #f2f2f2;
--mpb-choice-selected-bg: #fcfcd3;
}
-
-/* In this variant, the blank appears before trailing tokens; avoid left-margin offset. */
-.mc-populated-blank-root.variant-sel-r1-ggplus.layout-token_sequence .pie-blank-slot {
- margin-left: 0;
-}
diff --git a/packages/elements-svelte/mc-populated-blank/src/delivery/cqt-css/sel-r1-s3.css b/packages/elements-svelte/mc-populated-blank/src/delivery/cqt-css/sel-r1-s3.css
index 0ebf7407..91d6e48f 100644
--- a/packages/elements-svelte/mc-populated-blank/src/delivery/cqt-css/sel-r1-s3.css
+++ b/packages/elements-svelte/mc-populated-blank/src/delivery/cqt-css/sel-r1-s3.css
@@ -17,12 +17,14 @@
--mpb-blank-wide-width: 6.8rem;
}
-/* s3 overrides base choice-label with font-weight:500 */
+/* sel_r1-s3_plusggg/question.css does not set font-weight on distractors or
+ cloze content-element, so they inherit 400 from the host root. The earlier
+ font-weight:500 override caused PIE to render visibly heavier than LSY for
+ the same author content. See CONTOOL-2571. */
.mc-populated-blank-root.variant-sel-r1-s3 .pie-choice-label,
.mc-populated-blank-root.variant-sel-r1-s3 .pie-choice-label :is(span, p, div) {
font-size: 1.9em;
line-height: 89px;
- font-weight: 500;
}
.mc-populated-blank-root.variant-sel-r1-s3 .pie-blank-value,
diff --git a/packages/elements-svelte/mc-populated-blank/src/delivery/cqt-css/sel-vic.css b/packages/elements-svelte/mc-populated-blank/src/delivery/cqt-css/sel-vic.css
index 44e590a8..8a6d9ed5 100644
--- a/packages/elements-svelte/mc-populated-blank/src/delivery/cqt-css/sel-vic.css
+++ b/packages/elements-svelte/mc-populated-blank/src/delivery/cqt-css/sel-vic.css
@@ -2,7 +2,10 @@
/* Reference layout: web-ItemBankViewer/learnosity/templates/Renaissance/sel_vic/scss/main.scss + vic.scss */
.mc-populated-blank-root.variant-sel-vic {
font-family: Starial, Arial, sans-serif;
- font-size: 1.5em;
+ /* LSY chain: .lrn_widget (14px host) → .sel-vic { 1.5em } = 21px.
+ PIE collapses both LSY layers into this single variant-root selector, so
+ the resolved value is set directly. See CONTOOL-2567. */
+ font-size: 21px;
max-width: 800px;
margin-inline: auto;
--mpb-inline-grid-column-gap: 1rem;
@@ -45,6 +48,26 @@
margin: 0;
}
+/* LSY .rli-vic-answer has no text-align rule and inherits start; PIE's
+ inline_sentence layout sets text-align:center on .template-line via a
+ Svelte-scoped selector with specificity (0,4,0), so we need to match or
+ exceed that. Pin sel-vic back to start so wrapped lines stay left-aligned.
+ See CONTOOL-2573. */
+.mc-populated-blank-root.variant-sel-vic.layout-inline_sentence .pie-template-line.template-line {
+ text-align: start;
+}
+
+/* vic.scss: .rli-vic-cloze { margin-left: 6px; margin-right: 6px } gives the
+ cloze breathing room from adjacent text or punctuation. The PIE markup uses
+ Tailwind-style mx-1 in the class list, but mc-populated-blank does not
+ compile Tailwind, so that class is a no-op. Set the inline margin
+ explicitly so the cloze→trailing-punctuation gap matches LSY. See
+ CONTOOL-2572. */
+.mc-populated-blank-root.variant-sel-vic .pie-template-line .pie-blank-slot {
+ margin-left: 6px;
+ margin-right: 6px;
+}
+
/* vic.scss: .rli-vic-cloze > .rli-vic-content-element { color: #cc3333 } */
.mc-populated-blank-root.variant-sel-vic .pie-blank-value {
color: #cc3333;
diff --git a/packages/elements-svelte/mc-populated-blank/src/delivery/cqt-css/sr-vic.css b/packages/elements-svelte/mc-populated-blank/src/delivery/cqt-css/sr-vic.css
index dc56617c..a0b7863f 100644
--- a/packages/elements-svelte/mc-populated-blank/src/delivery/cqt-css/sr-vic.css
+++ b/packages/elements-svelte/mc-populated-blank/src/delivery/cqt-css/sr-vic.css
@@ -22,6 +22,15 @@
margin: 0;
}
+/* LSY .rli-vic-answer has no text-align rule and inherits start; PIE's
+ inline_sentence layout sets text-align:center on .template-line via a
+ Svelte-scoped selector with specificity (0,4,0), so we need to match or
+ exceed that. Pin sr-vic back to start so wrapped lines stay left-aligned.
+ Same root cause as sel-vic. See CONTOOL-2573. */
+.mc-populated-blank-root.variant-sr-vic.layout-inline_sentence .pie-template-line.template-line {
+ text-align: start;
+}
+
/* vic.scss: .rli-vic-cloze > .rli-vic-content-element { color: #cc3333 } */
.mc-populated-blank-root.variant-sr-vic .pie-blank-value {
color: #cc3333;