Files
Web_FreeCAD_Bitbybit/src/chromeSpreadsheetHarness.ts

146 lines
10 KiB
TypeScript

import { createMockFacade } from './facade/mockFacade'
import { createSpreadsheet } from './facade/spreadsheet'
type SpreadsheetReport = {
schemaVersion: 1
status: 'pass' | 'failed'
browserId: 'chrome'
crossOriginIsolated: boolean
cells?: { width: number; length: number; updatedLength: number; dimension: string }
aliases?: string[]
dependencies?: string[]
cycle?: { detected: boolean; cells: string[] }
binding?: { alias: string; objectId: string; propertyName: string; appliedValue: number; undoValue: number; redoValue: number }
expression?: { value: number; dimension: string; references: string[] }
layout?: { styledCells: number; namedRange: string; mergedRange: string; shiftedFormulaAddress: string }
csv?: { rows: number; columns: number; bytes: number }
largeSheet?: { cells: number; rows: number; columns: number; evaluationErrors: number; csvBytes: number; durationMs: number; budgetMs: number }
persistence?: { mode: string; savedVersion: number; reopenedVersion: number; reopenedValue: number }
resource?: { hash: string; byteLength: number; roundTrip: boolean; released: boolean }
opfs?: { markerSuite: string; markerCells: number; markerRemoved: boolean }
afterRelease?: { shapeCount: number; kernelReferenceCount: number }
error?: string
}
const propertyValue = (facade: ReturnType<typeof createMockFacade>, objectId: string, name: string) => facade.app.document.getObject(objectId)?.properties.find((property) => property.name === name)?.value
const run = async (): Promise<SpreadsheetReport> => {
const facade = createMockFacade()
const report: SpreadsheetReport = { schemaVersion: 1, status: 'failed', browserId: 'chrome', crossOriginIsolated: globalThis.crossOriginIsolated === true }
try {
if (!report.crossOriginIsolated || !navigator.storage?.getDirectory) throw new Error('Chrome Spreadsheet evidence requires isolated OPFS.')
await facade.project.list()
const sheet = createSpreadsheet('spreadsheet', 'Parameters')
sheet.setCell('A1', '6 mm', { alias: 'Width' })
sheet.setCell('A2', '=Width * 2', { alias: 'Length' })
sheet.setCell('B1', '=max(A1, 4 mm)')
const binding = sheet.bindProperty('Length', 'pad', 'Length')
const first = sheet.evaluate()
const width = first.cells.A1.value
const length = first.cells.A2.value
if (!width || !length || first.errors.length > 0) throw new Error(`Spreadsheet formula evaluation failed: ${JSON.stringify(first.errors)}`)
const expression = facade.app.expression.evaluate('Spreadsheet.Length + 1 mm', { 'Spreadsheet.Length': length })
facade.app.document.setProperty({ objectId: binding.objectId, propertyName: binding.propertyName, value: length.value })
sheet.setCell('A1', '8 mm', { alias: 'Width' })
const updated = sheet.evaluate()
const updatedLength = updated.cells.A2.value
if (!updatedLength) throw new Error('Spreadsheet alias update did not recompute Length.')
facade.app.document.setProperty({ objectId: binding.objectId, propertyName: binding.propertyName, value: updatedLength.value })
const appliedValue = Number(propertyValue(facade, 'pad', 'Length'))
facade.history.undo()
const undoValue = Number(propertyValue(facade, 'pad', 'Length'))
facade.history.redo()
const redoValue = Number(propertyValue(facade, 'pad', 'Length'))
sheet.setCell('C1', '=D1 + 1')
sheet.setCell('D1', '=C1 + 1')
const diagnosed = sheet.evaluate()
const cycleCells = [...new Set(diagnosed.cycles.flat())].sort()
const csv = sheet.exportCsv()
const csvRows = csv.split('\n')
const layout = createSpreadsheet('layout', 'Layout')
layout.setCell('A1', 1)
layout.setCell('A2', '=A1 + 1')
const styledCells = layout.setStyle('A1:B2', { background: '#e6f4f1', bold: true, numberFormat: '0.00' })
layout.mergeCells('B1:A2')
layout.setNamedRange('Inputs', 'A1:A2')
layout.insertRows(2)
layout.insertColumns(2)
const layoutSnapshot = layout.snapshot()
const largeStarted = performance.now()
const large = createSpreadsheet('large', 'Large sheet')
const columnName = (column: number) => { let value = ''; for (let current = column; current > 0; current = Math.floor((current - 1) / 26)) value = String.fromCharCode(65 + ((current - 1) % 26)) + value; return value }
for (let row = 1; row <= 100; row += 1) for (let column = 1; column <= 100; column += 1) large.setCell(`${columnName(column)}${row}`, row + column)
const largeEvaluation = large.evaluate()
const largeCsv = large.exportCsv()
const largeDurationMs = performance.now() - largeStarted
const document = facade.app.document.getActive()
const saved = await facade.project.save(document)
const reopened = await facade.project.load(document.id)
const reopenedValue = Number(reopened?.objects.find((object) => object.id === 'pad')?.properties.find((property) => property.name === 'Length')?.value)
if (!reopened || reopenedValue !== updatedLength.value) throw new Error('Spreadsheet-bound property did not survive the OPFS project round-trip.')
const payload = new TextEncoder().encode(JSON.stringify({ sheet: sheet.snapshot(), layout: layoutSnapshot }))
const stored = await facade.project.resource.put(payload, 'application/vnd.bitbybit.spreadsheet+json')
const resourceRoundTrip = await facade.project.resource.get(stored.hash)
const roundTrip = resourceRoundTrip !== null && new TextDecoder().decode(resourceRoundTrip) === new TextDecoder().decode(payload)
await facade.project.resource.release(stored.hash)
const released = await facade.project.resource.get(stored.hash) === null
const directory = await navigator.storage.getDirectory()
const markerName = 'spreadsheet-chrome-marker.json'
const marker = await directory.getFileHandle(markerName, { create: true })
const writable = await marker.createWritable()
await writable.write(JSON.stringify({ suite: 'SS-ALL', cells: sheet.snapshot().cells.length + layoutSnapshot.cells.length }))
await writable.close()
const markerPayload = JSON.parse(await (await marker.getFile()).text()) as { suite: string; cells: number }
await directory.removeEntry(markerName)
let markerRemoved = false
try { await directory.getFileHandle(markerName) } catch (error) { markerRemoved = error instanceof DOMException && error.name === 'NotFoundError' }
report.cells = { width: width.value, length: length.value, updatedLength: updatedLength.value, dimension: updatedLength.dimension }
report.aliases = sheet.snapshot().cells.flatMap((cell) => cell.alias ? [cell.alias] : []).sort()
report.dependencies = first.dependencies.map((dependency) => `${dependency.source}->${dependency.target}:${dependency.reference}`).sort()
report.cycle = { detected: diagnosed.cycles.length > 0, cells: cycleCells }
report.binding = { ...binding, appliedValue, undoValue, redoValue }
report.expression = { value: expression.value.value, dimension: expression.value.dimension, references: expression.references }
report.layout = { styledCells: styledCells.length, namedRange: layoutSnapshot.namedRanges[0]?.range ?? '', mergedRange: layoutSnapshot.mergedRanges[0] ?? '', shiftedFormulaAddress: layoutSnapshot.cells.find((cell) => cell.input === '=A1 + 1')?.address ?? '' }
report.csv = { rows: csvRows.length, columns: Math.max(...csvRows.map((row) => row.split(',').length)), bytes: new TextEncoder().encode(csv).byteLength }
report.largeSheet = { cells: large.snapshot().cells.length, rows: 100, columns: 100, evaluationErrors: largeEvaluation.errors.length, csvBytes: new TextEncoder().encode(largeCsv).byteLength, durationMs: largeDurationMs, budgetMs: 5000 }
report.persistence = { mode: facade.project.capabilities().mode, savedVersion: saved.documentVersion, reopenedVersion: reopened.version, reopenedValue }
report.resource = { hash: stored.hash, byteLength: stored.byteLength, roundTrip, released }
report.opfs = { markerSuite: markerPayload.suite, markerCells: markerPayload.cells, markerRemoved }
report.status = report.cells.width === 6 && report.cells.length === 12 && report.cells.updatedLength === 16 && report.cells.dimension === 'length'
&& JSON.stringify(report.aliases) === JSON.stringify(['Length', 'Width'])
&& report.dependencies.length === 2 && report.cycle.detected && JSON.stringify(report.cycle.cells) === JSON.stringify(['C1', 'D1'])
&& report.binding.appliedValue === 16 && report.binding.undoValue === 12 && report.binding.redoValue === 16
&& report.expression.value === 13 && report.expression.dimension === 'length' && report.expression.references.includes('Spreadsheet.Length')
&& report.layout.styledCells === 4 && report.layout.namedRange === 'A1:A3' && report.layout.mergedRange === 'A1:C3' && report.layout.shiftedFormulaAddress === 'A3'
&& report.largeSheet.cells === 10000 && report.largeSheet.evaluationErrors === 0 && report.largeSheet.csvBytes > 0 && report.largeSheet.durationMs <= report.largeSheet.budgetMs
&& report.persistence.mode === 'sqlite-opfs' && report.resource.roundTrip && report.resource.released
&& report.opfs.markerSuite === 'SS-ALL' && report.opfs.markerCells === 9 && report.opfs.markerRemoved ? 'pass' : 'failed'
} catch (error) {
report.error = error instanceof Error ? error.stack || error.message : String(error)
} finally {
facade.geometry.dispose()
await new Promise((resolve) => setTimeout(resolve, 0))
report.afterRelease = { shapeCount: facade.geometry.capabilities().shapeCount, kernelReferenceCount: facade.geometry.capabilities().kernelReferenceCount }
if (report.afterRelease.shapeCount !== 0 || report.afterRelease.kernelReferenceCount !== 0) { report.status = 'failed'; report.error = `${report.error ? `${report.error} ` : ''}Shape ownership gate failed.` }
}
return report
}
run().then((report) => {
;(window as Window & { __bitbybitSpreadsheetReport?: SpreadsheetReport }).__bitbybitSpreadsheetReport = report
document.documentElement.dataset.status = report.status
document.querySelector('#result')!.textContent = JSON.stringify(report, null, 2)
}).catch((error) => {
document.documentElement.dataset.status = 'failed'
document.querySelector('#result')!.textContent = error instanceof Error ? error.stack || error.message : String(error)
})
export {}