146 lines
10 KiB
TypeScript
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 {}
|