From 31c83fade8e1e0925af02230d232e91aa72bfa0b Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Thu, 20 Aug 2026 10:03:55 +0300 Subject: [PATCH 1/4] fix(viewer): restructure IDS HTML report to group by requirement The IDS validation HTML report grouped results only by entity: each specification rendered a flat table of entities, with a requirement's outcome visible only inside a per-entity
, and only when it failed. Finding "requirement 3 failed on 87 entities" meant expanding every row and tallying by hand. There was also no truncation: a specification failing on thousands of entities produced an unopenable HTML file. Restructure the report around the IDS model's three nested levels (specification -> requirement -> check, one entity measured against one requirement): - Group each specification's entityResults by requirement.id (stable and unique per specification, assigned once by the XML parser and reused by reference across every entity's requirementResults) and render one block per requirement with pass/fail counts and the failing elements beneath it (type, name, GlobalId, failure reason). Grouping happens before classifying status, since filtering not_applicable first would break the per-entity alignment the grouping depends on. - Show three independently-computed, clearly-labeled pass rates in the summary: check-level (aggregated here for the first time, from requirementResults), entity-level (report.summary.overallPassRate, read as-is so the CLI/BCF/JSON exports that share this field keep its existing meaning), and specification-level. An inline note explains why the numbers legitimately diverge and states that the specification-level rate is the one that matters for a deliverable. - not_applicable requirement results are excluded from both the passed count and the denominator at every level, matching how the validator already treats entities outside a specification's applicability. - Truncate long failing-element lists: group by IFC type, show ~5 examples per type and ~100 total, and always state the exact hidden count plus a note that the HTML is a summary and the JSON export has the complete results. - Keep the existing per-entity table as a secondary, collapsible view so nothing already in the report is lost, and preserve search, filtering, sorting, click-to-copy GlobalId, and HTML escaping on every interpolated value. --- .../src/hooks/ids/idsExportService.test.ts | 250 ++++++++++++ apps/viewer/src/hooks/ids/idsExportService.ts | 360 ++++++++++++++++-- 2 files changed, 584 insertions(+), 26 deletions(-) create mode 100644 apps/viewer/src/hooks/ids/idsExportService.test.ts diff --git a/apps/viewer/src/hooks/ids/idsExportService.test.ts b/apps/viewer/src/hooks/ids/idsExportService.test.ts new file mode 100644 index 0000000000..ec47b62cb6 --- /dev/null +++ b/apps/viewer/src/hooks/ids/idsExportService.test.ts @@ -0,0 +1,250 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +/** + * Tests for the requirement-level HTML report restructuring. + * + * The IDS report has three nested levels: specification -> requirement -> + * check (one entity measured against one requirement). These tests build + * minimal, fully-typed `IDSValidationReport` fixtures — not run through the + * real validator — and assert on the rendered HTML string, because the + * report is a presentation layer over already-computed validation results + * and the thing that ships to a reader IS the markup. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import type { + IDSEntityFacet, + IDSEntityResult, + IDSRequirement, + IDSRequirementResult, + IDSSpecification, + IDSSpecificationResult, + IDSValidationReport, +} from '@ifc-lite/ids'; +import { buildReportHTML } from './idsExportService.js'; + +// ---------------------------------------------------------------------------- +// Fixture builders — minimal, fully-typed, no validator involved. +// ---------------------------------------------------------------------------- + +function makeFacet(name = 'IFCWALL'): IDSEntityFacet { + return { type: 'entity', name: { type: 'simpleValue', value: name } }; +} + +function makeRequirement(id: string, description = 'Entity check'): IDSRequirement { + return { id, facet: makeFacet(), optionality: 'required', description }; +} + +function makeReqResult( + requirement: IDSRequirement, + status: 'pass' | 'fail' | 'not_applicable', + overrides: Partial = {}, +): IDSRequirementResult { + return { + requirement, + status, + facetType: 'entity', + checkedDescription: overrides.checkedDescription ?? requirement.description ?? 'check', + failureReason: overrides.failureReason, + actualValue: overrides.actualValue, + expectedValue: overrides.expectedValue, + }; +} + +function makeEntity( + expressId: number, + requirementResults: IDSRequirementResult[], + overrides: Partial = {}, +): IDSEntityResult { + return { + expressId, + modelId: overrides.modelId ?? 'model-1', + entityType: overrides.entityType ?? 'IfcWall', + entityName: overrides.entityName, + globalId: overrides.globalId, + passed: requirementResults.every(r => r.status !== 'fail'), + requirementResults, + }; +} + +function makeSpecification(id: string, name: string, requirements: IDSRequirement[]): IDSSpecification { + return { + id, + name, + ifcVersions: ['IFC4'], + applicability: { facets: [makeFacet()] }, + requirements, + }; +} + +function makeSpecResult(specification: IDSSpecification, entityResults: IDSEntityResult[]): IDSSpecificationResult { + const applicableCount = entityResults.length; + const passedCount = entityResults.filter(e => e.passed).length; + const failedCount = applicableCount - passedCount; + const passRate = applicableCount > 0 ? Math.floor((passedCount / applicableCount) * 100) : 100; + const status: IDSSpecificationResult['status'] = + applicableCount === 0 ? 'not_applicable' : failedCount > 0 ? 'fail' : 'pass'; + return { specification, status, applicableCount, passedCount, failedCount, passRate, entityResults }; +} + +function makeReport(specResults: IDSSpecificationResult[]): IDSValidationReport { + const totalSpecifications = specResults.length; + const passedSpecifications = specResults.filter(s => s.status === 'pass').length; + const failedSpecifications = specResults.filter(s => s.status === 'fail').length; + const totalEntitiesChecked = specResults.reduce((s, sp) => s + sp.applicableCount, 0); + const totalEntitiesPassed = specResults.reduce((s, sp) => s + sp.passedCount, 0); + const totalEntitiesFailed = specResults.reduce((s, sp) => s + sp.failedCount, 0); + return { + document: { info: { title: 'Test IDS' }, specifications: specResults.map(s => s.specification) }, + modelInfo: { modelId: 'model-1', schemaVersion: 'IFC4', entityCount: 1000 }, + timestamp: new Date('2026-01-01T00:00:00Z'), + summary: { + totalSpecifications, + passedSpecifications, + failedSpecifications, + totalEntitiesChecked, + totalEntitiesPassed, + totalEntitiesFailed, + overallPassRate: totalEntitiesChecked > 0 ? Math.round((totalEntitiesPassed / totalEntitiesChecked) * 100) : 100, + }, + specificationResults: specResults, + }; +} + +function extractRate(html: string, label: string): number { + const re = new RegExp(`rate-value">(\\d+)%\\s*${label}`); + const m = html.match(re); + if (!m) throw new Error(`rate not found for label: ${label}`); + return Number(m[1]); +} + +// ---------------------------------------------------------------------------- + +describe('buildReportHTML — requirement-level grouping', () => { + it('computes check-level, entity-level, and specification-level pass rates independently, and they legitimately diverge', () => { + const reqA = makeRequirement('req-a', 'Requirement A'); + const reqB = makeRequirement('req-b', 'Requirement B'); + // 100 entities, 2 requirements each. Entities 0 and 1 fail requirement A + // only (requirement B always passes) — so 2 of 100 entities fail the + // specification, but only 2 of 200 individual checks fail. + const spec0Entities = Array.from({ length: 100 }, (_, i) => + makeEntity( + i, + [ + makeReqResult(reqA, i < 2 ? 'fail' : 'pass', i < 2 ? { failureReason: 'bad wall' } : {}), + makeReqResult(reqB, 'pass'), + ], + { entityName: `Wall ${i}`, globalId: `GID-${i}` }, + ), + ); + const spec0 = makeSpecResult(makeSpecification('spec-0', 'Mostly Passing Spec', [reqA, reqB]), spec0Entities); + + const otherSpecs = [1, 2, 3].map(n => { + const req = makeRequirement(`req-${n}`); + const entities = Array.from({ length: 10 }, (_, i) => + makeEntity(i, [makeReqResult(req, 'pass')], { entityName: `E${n}-${i}`, globalId: `G${n}-${i}` }), + ); + return makeSpecResult(makeSpecification(`spec-${n}`, `Passing Spec ${n}`, [req]), entities); + }); + + const report = makeReport([spec0, ...otherSpecs]); + const html = buildReportHTML(report, 'en'); + + // Check level (finest): 198 of 200 element-requirement checks in spec-0, + // plus 30 of 30 in the other specs = 228 of 230 = 99%. + const checkRate = extractRate(html, 'Check pass rate'); + assert.equal(checkRate, 99); + + // Entity level: an entity passes only if ALL its requirements pass, so + // the 2 entities failing requirement A fail entirely — 128 of 130 = 98%. + const entityRate = extractRate(html, 'Entity pass rate'); + assert.equal(entityRate, 98); + + // Specification level (coarsest): spec-0 has 2 failing entities and so + // fails outright — only 3 of 4 specifications fully passed = 75%. + const specRate = extractRate(html, 'Specification pass rate'); + assert.equal(specRate, 75); + + assert.notEqual(checkRate, entityRate, 'check and entity rates must be shown independently, not conflated'); + assert.notEqual(entityRate, specRate, 'entity and specification rates must be shown independently, not conflated'); + assert.notEqual(checkRate, specRate, 'check and specification rates must be shown independently, not conflated'); + }); + + it('does not count a not_applicable requirement result as a pass', () => { + const req = makeRequirement('req-na'); + const naEntity = makeEntity(1, [makeReqResult(req, 'not_applicable')], { + entityName: 'NA Wall', + globalId: 'GID-NA', + }); + const passEntity = makeEntity(2, [makeReqResult(req, 'pass')], { + entityName: 'Pass Wall', + globalId: 'GID-PASS', + }); + const spec = makeSpecResult(makeSpecification('spec-na', 'NA Spec', [req]), [naEntity, passEntity]); + const report = makeReport([spec]); + const html = buildReportHTML(report, 'en'); + + // Exactly one real check (the pass); the not_applicable result must not + // inflate either the passed count or the denominator. + assert.match( + html, + /1<\/span>\/1<\/span> checks passed \(100%\)/, + ); + assert.match(html, /1 not applicable/); + assert.ok( + !/2<\/span>/.test(html), + 'not_applicable must not be counted toward the checked total', + ); + }); + + it('truncates a requirement failing across many entities, grouping by type and stating the hidden count', () => { + const req = makeRequirement('req-bulk'); + const entities = Array.from({ length: 300 }, (_, i) => + makeEntity(i, [makeReqResult(req, 'fail', { failureReason: `Missing FireRating on wall ${i}` })], { + entityType: 'IfcWall', + entityName: `Wall ${i}`, + globalId: `GID-fail-${i}`, + }), + ); + const spec = makeSpecResult(makeSpecification('spec-bulk', 'Bulk Fail Spec', [req]), entities); + const report = makeReport([spec]); + const html = buildReportHTML(report, 'en'); + + assert.match(html, /Showing 5 of 300 IfcWall failures/); + assert.match(html, /Showing 5 of 300 failing elements for this requirement \(295 hidden\)/); + + const failuresBlock = html.match(/
([\s\S]*?)<\/table>/); + assert.ok(failuresBlock, 'requirement failures block must be present'); + const rowCount = (failuresBlock![1].match(/ { + const req = makeRequirement('req-esc', 'Name must not contain "'`; + const entity = makeEntity( + 1, + [makeReqResult(req, 'fail', { failureReason: `Bad name: ${evilName}` })], + { entityName: evilName, globalId: `GID"''), 'a raw `; + const longHostile = 'C'.repeat(200) + hostileTail; + const req = makeRequirement('req-hostile'); + const entity = makeEntity(1, [makeReqResult(req, 'fail', { failureReason: longHostile })], { + entityName: 'D'.repeat(200) + hostileTail, + }); + const html = buildReportHTML( + makeReport([makeSpecResult(makeSpecification('spec-hostile', 'Hostile Spec', [req]), [entity])]), + 'en', + ); + + // The literal text `onmouseover=` survives escaping (only the quotes and + // angle brackets change), so assert on the form that would actually be a + // live attribute: the name followed by an UNESCAPED quote. + assert.ok( + !html.includes('onmouseover="'), + 'the injected attribute must never appear followed by a raw quote, i.e. as live markup', + ); + assert.ok(!/"\s+onmouseover/.test(html), 'no raw quote may close the title attribute early'); + assert.ok(!html.includes(''), 'no raw script tag anywhere'); + assert.ok( + html.includes('" onmouseover="alert(1)"'), + 'quotes inside the title attribute are escaped', + ); + assert.ok( + html.includes('<script>alert(2)</script>'), + 'angle brackets inside the title attribute are escaped', + ); + }); + + it('truncates on code points, never splitting a surrogate pair', () => { + // 200 astral-plane characters, each a surrogate PAIR in UTF-16. A + // UTF-16-based slice(0, 160) would cut at 80 emoji plus half of the 81st, + // emitting a lone surrogate. A code-point slice yields 160 whole emoji. + const emoji = '\u{1F9F1}'; // brick + const req = makeRequirement('req-emoji'); + const entity = makeEntity(1, [makeReqResult(req, 'fail', { failureReason: emoji.repeat(200) })]); + const html = buildReportHTML( + makeReport([makeSpecResult(makeSpecification('spec-emoji', 'Emoji Spec', [req]), [entity])]), + 'en', + ); + + const cell = html.match(/([\s\S]*?)<\/td>/); + assert.ok(cell, 'failure cell must be present'); + const visible = cell![1].replace(/<[^>]*>/g, '').replace('…', ''); + assert.equal(Array.from(visible).length, 160, '160 whole code points are shown'); + assert.ok( + !/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(visible), + 'no unpaired high surrogate may be emitted', + ); + }); +}); + +describe('buildReportHTML — per-entity table truncation', () => { + it('caps the per-entity table, lists failing entities first, and states the hidden count', () => { + const req = makeRequirement('req-cap'); + // 250 entities where the ONLY failures sit at the very end of the array. + // A naive slice(0, 100) would render 100 passes and hide every failure. + const entities = Array.from({ length: 250 }, (_, i) => + makeEntity( + i, + [makeReqResult(req, i >= 245 ? 'fail' : 'pass', i >= 245 ? { failureReason: `late failure ${i}` } : {})], + { entityName: `Wall ${i}`, globalId: `GID-${i}` }, + ), + ); + const html = buildReportHTML( + makeReport([makeSpecResult(makeSpecification('spec-cap', 'Capped Spec', [req]), entities)]), + 'en', + ); + + const tbody = html.match(/([\s\S]*?)<\/tbody>/); + assert.ok(tbody, 'per-entity tbody must be present'); + const rows = tbody![1].match(/ { + const req = makeRequirement('req-small'); + const entities = Array.from({ length: 3 }, (_, i) => makeEntity(i, [makeReqResult(req, 'pass')])); + const html = buildReportHTML( + makeReport([makeSpecResult(makeSpecification('spec-small', 'Small Spec', [req]), entities)]), + 'en', + ); + + assert.ok(!/\d+ hidden/.test(html), 'no hidden-count note when nothing was hidden'); + }); +}); diff --git a/apps/viewer/src/hooks/ids/idsExportService.ts b/apps/viewer/src/hooks/ids/idsExportService.ts index d320b12622..82daa9f945 100644 --- a/apps/viewer/src/hooks/ids/idsExportService.ts +++ b/apps/viewer/src/hooks/ids/idsExportService.ts @@ -176,6 +176,52 @@ function buildRequirementGroups( const FAILING_ELEMENTS_TOTAL_CAP = 100; const FAILING_ELEMENTS_PER_TYPE_CAP = 5; +/** + * Row cap for the secondary per-entity table. Without it a specification + * applicable to thousands of entities emits thousands of `` into the + * single self-contained HTML string, which is the file-size problem the + * requirement grouping alone does not solve — the rows merely moved inside + * a `
`, they were still all emitted. + */ +const ENTITY_ROWS_CAP = 100; + +/** + * Character budget for a single rendered text field. + * + * IFC-supplied strings have no length limit: a `Description`, a property + * value echoed into `failureReason`, or an element name generated by an + * authoring tool can run to thousands of characters and blow out the table + * layout. Budgeting in CHARACTERS rather than guessing at pixels keeps the + * cut deterministic and testable. + */ +const FIELD_CHAR_BUDGET = 160; + +/** + * Render one text field for display, truncated to a character budget. + * + * Truncation must never destroy the value: when the field is cut, the full + * text is preserved verbatim in a `title` attribute (hover / assistive + * tooltip), and the cut itself is made visible with an ellipsis rather than + * ending mid-word with no signal. Both the visible text and the `title` + * attribute go through `escapeHtml`, which escapes `"` and `'` as well as + * `<`, `>` and `&` — an unescaped quote inside `title` would otherwise let + * an IFC-supplied string break out of the attribute. + * + * The slice is taken over code points (`Array.from`), not UTF-16 code + * units, so truncating never splits a surrogate pair into a lone half. + */ +function truncateField( + value: string | undefined | null, + esc: typeof escapeHtml, + budget: number = FIELD_CHAR_BUDGET, +): string { + if (value == null) return ''; + const text = String(value); + const chars = Array.from(text); + if (chars.length <= budget) return esc(text); + return `${esc(chars.slice(0, budget).join(''))}…`; +} + /** * Render a requirement's failing elements, truncated so a requirement that * fails on thousands of entities doesn't produce an unopenable document. @@ -210,10 +256,10 @@ function buildFailingElementsHTML(elements: FailingElement[], esc: typeof escape const el = elems[i]; rows.push(` ${esc(el.entityType)} - ${esc(el.entityName) || 'unnamed'} + ${truncateField(el.entityName, esc) || 'unnamed'} ${esc(el.globalId) || '—'} ${el.expressId} - ${esc(el.failureReason) || '—'} + ${truncateField(el.failureReason, esc) || '—'} `); } shown += take; @@ -255,7 +301,7 @@ function buildRequirementGroupHTML(group: RequirementGroup, esc: typeof escapeHt
${status === 'pass' ? 'PASS' : 'FAIL'} ${esc(group.facetType)} - ${esc(group.checkedDescription)} + ${truncateField(group.checkedDescription, esc)}
${group.passed}/${totalChecked} checks passed (${passRate}%) @@ -265,12 +311,25 @@ function buildRequirementGroupHTML(group: RequirementGroup, esc: typeof escapeHt
`; } -/** Build entity rows HTML for a specification table */ +/** + * Build entity rows HTML for a specification table, capped at + * `ENTITY_ROWS_CAP`. + * + * Failing entities are emitted first so that the cap can never hide every + * failure behind a wall of passes — the table is sortable in the browser + * anyway, so the emitted order is a truncation-safety choice, not a + * presentation preference. The caller renders the hidden count; nothing + * disappears without being stated. + */ function buildEntityRows( spec: IDSValidationReport['specificationResults'][0], esc: typeof escapeHtml, ): string { - return spec.entityResults.map(entity => { + const ordered = [ + ...spec.entityResults.filter(e => !e.passed), + ...spec.entityResults.filter(e => e.passed), + ]; + return ordered.slice(0, ENTITY_ROWS_CAP).map(entity => { const failedReqs = entity.requirementResults.filter(r => r.status === 'fail'); const passedReqs = entity.requirementResults.filter(r => r.status === 'pass'); const allReqs = entity.requirementResults.filter(r => r.status !== 'not_applicable'); @@ -278,16 +337,16 @@ function buildEntityRows( const reqDetails = failedReqs.length > 0 ? failedReqs.map(req => `
${esc(req.facetType)} - ${esc(req.checkedDescription)} - ${req.failureReason ? `
${esc(req.failureReason)}
` : ''} - ${req.expectedValue || req.actualValue ? `
${req.expectedValue ? `Expected: ${esc(req.expectedValue)}` : ''}${req.actualValue ? `Actual: ${esc(req.actualValue)}` : ''}
` : ''} + ${truncateField(req.checkedDescription, esc)} + ${req.failureReason ? `
${truncateField(req.failureReason, esc)}
` : ''} + ${req.expectedValue || req.actualValue ? `
${req.expectedValue ? `Expected: ${truncateField(req.expectedValue, esc)}` : ''}${req.actualValue ? `Actual: ${truncateField(req.actualValue, esc)}` : ''}
` : ''}
`).join('') : 'All requirements passed'; return ` ${entity.passed ? 'PASS' : 'FAIL'} ${esc(entity.entityType)} - ${esc(entity.entityName) || 'unnamed'} + ${truncateField(entity.entityName, esc) || 'unnamed'} ${esc(entity.globalId) || '\u2014'} ${entity.expressId} ${passedReqs.length}/${allReqs.length} @@ -472,7 +531,9 @@ export function buildReportHTML(report: IDSValidationReport, locale: SupportedLo .req-fail-table th { padding: 6px 10px; text-align: left; background: var(--bg); font-weight: 600; font-size: 0.7rem; text-transform: uppercase; color: var(--muted); border-bottom: 2px solid var(--border); } .req-fail-table td { padding: 6px 10px; border-bottom: 1px solid #f1f5f9; vertical-align: top; } .col-failure { min-width: 200px; color: var(--fail); } - .truncation-note { font-size: 0.75rem; color: var(--muted); font-style: italic; margin-top: 6px; } + .truncation-note { font-size: 0.75rem; color: var(--muted); font-style: italic; margin-top: 6px; padding: 0 10px; } + /* A truncated field keeps its full text in a title attribute; cue that it is hoverable. */ + .truncated { border-bottom: 1px dotted var(--muted); cursor: help; } .truncation-total { font-weight: 600; } /* Secondary per-entity table */ @@ -584,7 +645,9 @@ export function buildReportHTML(report: IDSValidationReport, locale: SupportedLo

This HTML report is a summary, not a data source: long failing-element lists are - truncated below. For complete, untruncated results, use the JSON export. + truncated below (always with the hidden count stated), and individual long text fields are shortened + to ${FIELD_CHAR_BUDGET} characters with an ellipsis — hover such a field to read it in full. + For complete, untruncated results, use the JSON export.

@@ -650,6 +713,7 @@ export function buildReportHTML(report: IDSValidationReport, locale: SupportedLo ${buildEntityRows(spec, esc)} + ${spec.entityResults.length > ENTITY_ROWS_CAP ? `
Showing ${ENTITY_ROWS_CAP} of ${spec.entityResults.length} entities (${spec.entityResults.length - ENTITY_ROWS_CAP} hidden, failing entities listed first). See the JSON export for complete results.
` : ''}
@@ -683,7 +747,13 @@ export function buildReportHTML(report: IDSValidationReport, locale: SupportedLo document.querySelectorAll('.entity-row').forEach(row => { total++; const status = row.dataset.status; - const text = row.textContent.toLowerCase(); + // Visible cell text is truncated to a character budget, so also match + // against the full values kept in data-* attributes and in the title + // attributes that carry the untruncated text. + const titles = Array.from(row.querySelectorAll('[title]')) + .map(el => el.getAttribute('title')) + .join(' '); + const text = (row.textContent + ' ' + (row.dataset.name || '') + ' ' + (row.dataset.type || '') + ' ' + titles).toLowerCase(); const matchesFilter = currentFilter === 'all' || status === currentFilter; const matchesSearch = !search || text.includes(search); const show = matchesFilter && matchesSearch; @@ -693,8 +763,8 @@ export function buildReportHTML(report: IDSValidationReport, locale: SupportedLo document.getElementById('result-count').textContent = search || currentFilter !== 'all' - ? visible + ' of ' + total + ' entities shown' - : total + ' entities'; + ? visible + ' of ' + total + ' rows shown' + : total + ' rows'; } function sortTable(specIndex, colIndex) { From b26045edd4d2e6d817742952f40716d78c6394d2 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Fri, 21 Aug 2026 19:06:17 +0300 Subject: [PATCH 4/4] fix(viewer): search the IDS report's untruncated values, not its copy hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `title` sweep this PR added to `filterAll` read every `[title]` in a row. Two of the three `title=` sites in the generated report are the GlobalId cell's static `title="Click to copy"` — one in the per-entity table, one in each requirement's failing-elements table — so every `.entity-row`'s search haystack ended with "click to copy". Typing `to`, on the way to `torsion` or `total`, matched every row and the count read "N of N rows shown": the report claimed every element matched a term none of them contain. On main the same search filtered correctly, because `filterAll` read `row.textContent` only and a `title` is not text content. Restrict the sweep to `.truncated[title]`, the spans `truncateField` emits — the only titles that carry a value rather than an affordance. Adds the search test the file had none of: the two other suites assert on the markup string, but this defect is invisible there (hint and value are both just `title` attributes). It parses the report into happy-dom, takes the inline `