diff --git a/.eslintrc.js b/.eslintrc.js index b5da18d97..27fe09a0c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -128,7 +128,7 @@ module.exports = { 'jest/no-standalone-expect': 'warn', 'jest/no-test-prefixes': 'off', 'jest/prefer-to-be': 'warn', - 'jest/prefer-to-have-length': 'warn', + 'jest/prefer-to-have-length': 'off', }, overrides: [ { @@ -143,5 +143,11 @@ module.exports = { 'sort-keys': ['error', 'asc'], } }, + { + files: ['**/*.spec.ts'], + rules: { + '@typescript-eslint/no-non-null-assertion': 'off', + } + } ], } diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 0c008061a..b4c0826dd 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -32,4 +32,4 @@ jobs: - name: Run audit run: | - npm audit --omit='dev' + npm run audit diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f29f6265..76b86ebbd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed +- Fixed an issue where cells were not recalculated after adding, removing and renaming sheets. [#1116](https://github.com/handsontable/hyperformula/issues/1116) - Fixed an issue where overwriting a non-computed cell caused the `Value of the formula cell is not computed` error. [#1194](https://github.com/handsontable/hyperformula/issues/1194) ## [3.1.0] - 2025-10-14 diff --git a/package.json b/package.json index a6d5478fd..429636c4b 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "test": "npm-run-all lint test:unit test:browser test:compatibility", "test:unit": "cross-env NODE_ICU_DATA=node_modules/full-icu jest", "test:watch": "cross-env NODE_ICU_DATA=node_modules/full-icu jest --watch", - "test:tdd": "cross-env NODE_ICU_DATA=node_modules/full-icu jest --watch named-expressions", + "test:tdd": "cross-env NODE_ICU_DATA=node_modules/full-icu jest --watch adding-sheet", "test:coverage": "npm run test:unit -- --coverage", "test:logMemory": "cross-env NODE_ICU_DATA=node_modules/full-icu jest --runInBand --logHeapUsage", "test:unit.ci": "cross-env NODE_ICU_DATA=node_modules/full-icu node --expose-gc ./node_modules/jest/bin/jest --forceExit", @@ -93,6 +93,7 @@ "benchmark:compare-benchmarks": "npm run tsnode test/performance/compare-benchmarks.ts", "lint": "eslint . --ext .js,.ts", "lint:fix": "eslint . --ext .js,.ts --fix", + "audit": "npm audit --omit=dev", "clean": "rimraf coverage/ commonjs/ dist/ es/ languages/ lib/ typings/ test-jasmine/", "compile": "tsc", "check:licenses": "license-checker --production --excludePackages=\"hyperformula@3.0.1\" --onlyAllow=\"MIT; Apache-2.0; BSD-3-Clause; BSD-2-Clause; ISC; BSD; Unlicense\"", diff --git a/src/BuildEngineFactory.ts b/src/BuildEngineFactory.ts index 9ab3574ca..1faff1e88 100644 --- a/src/BuildEngineFactory.ts +++ b/src/BuildEngineFactory.ts @@ -87,13 +87,17 @@ export class BuildEngineFactory { throw new SheetSizeLimitExceededError() } const sheetId = sheetMapping.addSheet(sheetName) - addressMapping.autoAddSheet(sheetId, boundaries) + addressMapping.addSheetAndSetStrategyBasedOnBoundaries(sheetId, boundaries, { throwIfSheetAlreadyExists: true }) } } - const parser = new ParserWithCaching(config, functionRegistry, sheetMapping.get) + const parser = new ParserWithCaching( + config, + functionRegistry, + dependencyGraph.sheetReferenceRegistrar.ensureSheetRegistered.bind(dependencyGraph.sheetReferenceRegistrar) + ) lazilyTransformingAstService.parser = parser - const unparser = new Unparser(config, buildLexerConfig(config), sheetMapping.fetchDisplayName, namedExpressions) + const unparser = new Unparser(config, sheetMapping, namedExpressions) const dateTimeHelper = new DateTimeHelper(config) const numberLiteralHelper = new NumberLiteralHelper(config) const arithmeticHelper = new ArithmeticHelper(config, dateTimeHelper, numberLiteralHelper) @@ -106,7 +110,7 @@ export class BuildEngineFactory { const clipboardOperations = new ClipboardOperations(config, dependencyGraph, operations) const crudOperations = new CrudOperations(config, operations, undoRedo, clipboardOperations, dependencyGraph, columnSearch, parser, cellContentParser, lazilyTransformingAstService, namedExpressions) - const exporter = new Exporter(config, namedExpressions, sheetMapping.fetchDisplayName, lazilyTransformingAstService) + const exporter = new Exporter(config, namedExpressions, sheetMapping, lazilyTransformingAstService) const serialization = new Serialization(dependencyGraph, unparser, exporter) const interpreter = new Interpreter(config, dependencyGraph, columnSearch, stats, arithmeticHelper, functionRegistry, namedExpressions, serialization, arraySizePredictor, dateTimeHelper) diff --git a/src/Cell.ts b/src/Cell.ts index e303d4df5..faab24bab 100644 --- a/src/Cell.ts +++ b/src/Cell.ts @@ -3,8 +3,8 @@ * Copyright (c) 2025 Handsoncode. All rights reserved. */ -import {ArrayVertex, CellVertex, FormulaCellVertex, ParsingErrorVertex, ValueCellVertex} from './DependencyGraph' -import {FormulaVertex} from './DependencyGraph/FormulaCellVertex' +import {ArrayFormulaVertex, CellVertex, ScalarFormulaVertex, ParsingErrorVertex, ValueCellVertex} from './DependencyGraph' +import {FormulaVertex} from './DependencyGraph/FormulaVertex' import {ErrorMessage} from './error-message' import { EmptyValue, @@ -59,14 +59,14 @@ export enum CellType { } export const getCellType = (vertex: Maybe, address: SimpleCellAddress): CellType => { - if (vertex instanceof ArrayVertex) { + if (vertex instanceof ArrayFormulaVertex) { if (vertex.isLeftCorner(address)) { return CellType.ARRAYFORMULA } else { return CellType.ARRAY } } - if (vertex instanceof FormulaCellVertex || vertex instanceof ParsingErrorVertex) { + if (vertex instanceof ScalarFormulaVertex || vertex instanceof ParsingErrorVertex) { return CellType.FORMULA } if (vertex instanceof ValueCellVertex) { @@ -196,7 +196,12 @@ export interface SimpleCellAddress { } export const simpleCellAddress = (sheet: number, col: number, row: number): SimpleCellAddress => ({sheet, col, row}) -export const invalidSimpleCellAddress = (address: SimpleCellAddress): boolean => (address.col < 0 || address.row < 0) + +/** + * Checks if the column or row id is negative. + */ +export const isColOrRowInvalid = (address: SimpleCellAddress): boolean => (address.col < 0 || address.row < 0) + export const movedSimpleCellAddress = (address: SimpleCellAddress, toSheet: number, toRight: number, toBottom: number): SimpleCellAddress => { return simpleCellAddress(toSheet, address.col + toRight, address.row + toBottom) } diff --git a/src/ClipboardOperations.ts b/src/ClipboardOperations.ts index eabcf6226..45848085d 100644 --- a/src/ClipboardOperations.ts +++ b/src/ClipboardOperations.ts @@ -4,7 +4,7 @@ */ import {AbsoluteCellRange} from './AbsoluteCellRange' -import {invalidSimpleCellAddress, simpleCellAddress, SimpleCellAddress} from './Cell' +import {isColOrRowInvalid, simpleCellAddress, SimpleCellAddress} from './Cell' import {RawCellContent} from './CellContentParser' import {Config} from './Config' import {DependencyGraph} from './DependencyGraph' @@ -119,7 +119,7 @@ export class ClipboardOperations { return } - if (invalidSimpleCellAddress(destinationLeftCorner) || + if (isColOrRowInvalid(destinationLeftCorner) || !this.dependencyGraph.sheetMapping.hasSheetWithId(destinationLeftCorner.sheet)) { throw new InvalidArgumentsError('a valid target address.') } diff --git a/src/CrudOperations.ts b/src/CrudOperations.ts index 4f44518cb..bf0238dbc 100644 --- a/src/CrudOperations.ts +++ b/src/CrudOperations.ts @@ -4,7 +4,7 @@ */ import {AbsoluteCellRange} from './AbsoluteCellRange' -import {invalidSimpleCellAddress, simpleCellAddress, SimpleCellAddress} from './Cell' +import {isColOrRowInvalid, simpleCellAddress, SimpleCellAddress} from './Cell' import {CellContent, CellContentParser, RawCellContent} from './CellContentParser' import {ClipboardCell, ClipboardOperations} from './ClipboardOperations' import {Config} from './Config' @@ -206,29 +206,35 @@ export class CrudOperations { this.ensureItIsPossibleToAddSheet(name) } this.undoRedo.clearRedoStack() - const addedSheetName = this.operations.addSheet(name) - this.undoRedo.saveOperation(new AddSheetUndoEntry(addedSheetName)) - return addedSheetName + const { sheetName, sheetId } = this.operations.addSheet(name) + this.undoRedo.saveOperation(new AddSheetUndoEntry(sheetName, sheetId)) + return sheetName } public removeSheet(sheetId: number): void { this.ensureScopeIdIsValid(sheetId) this.undoRedo.clearRedoStack() this.clipboardOperations.abortCut() - const originalName = this.sheetMapping.fetchDisplayName(sheetId) + const originalName = this.sheetMapping.getSheetNameOrThrowError(sheetId) const oldSheetContent = this.operations.getSheetClipboardCells(sheetId) - const {version, scopedNamedExpressions} = this.operations.removeSheet(sheetId) - this.undoRedo.saveOperation(new RemoveSheetUndoEntry(originalName, sheetId, oldSheetContent, scopedNamedExpressions, version)) + const scopedNamedExpressions = this.operations.removeSheet(sheetId) + this.undoRedo.saveOperation(new RemoveSheetUndoEntry(originalName, sheetId, oldSheetContent, scopedNamedExpressions)) } public renameSheet(sheetId: number, newName: string): Maybe { this.ensureItIsPossibleToRenameSheet(sheetId, newName) - const oldName = this.operations.renameSheet(sheetId, newName) - if (oldName !== undefined) { + const { previousDisplayName, version, mergedPlaceholderSheetId } = this.operations.renameSheet(sheetId, newName) + if (previousDisplayName !== undefined) { this.undoRedo.clearRedoStack() - this.undoRedo.saveOperation(new RenameSheetUndoEntry(sheetId, oldName, newName)) + this.undoRedo.saveOperation(new RenameSheetUndoEntry( + sheetId, + previousDisplayName, + newName, + version, + mergedPlaceholderSheetId, + )) } - return oldName + return previousDisplayName } public clearSheet(sheetId: number): void { @@ -495,8 +501,8 @@ export class CrudOperations { if ( !this.sheetMapping.hasSheetWithId(sheet) - || invalidSimpleCellAddress(sourceStart) - || invalidSimpleCellAddress(targetStart) + || isColOrRowInvalid(sourceStart) + || isColOrRowInvalid(targetStart) || !isPositiveInteger(numberOfRows) || (targetRow <= startRow + numberOfRows && targetRow >= startRow) ) { @@ -522,8 +528,8 @@ export class CrudOperations { if ( !this.sheetMapping.hasSheetWithId(sheet) - || invalidSimpleCellAddress(sourceStart) - || invalidSimpleCellAddress(targetStart) + || isColOrRowInvalid(sourceStart) + || isColOrRowInvalid(targetStart) || !isPositiveInteger(numberOfColumns) || (targetColumn <= startColumn + numberOfColumns && targetColumn >= startColumn) ) { @@ -552,14 +558,14 @@ export class CrudOperations { throw new NoSheetWithIdError(sheetId) } - const existingSheetId = this.sheetMapping.get(name) + const existingSheetId = this.sheetMapping.getSheetId(name) if (existingSheetId !== undefined && existingSheetId !== sheetId) { throw new SheetNameAlreadyTakenError(name) } } public ensureItIsPossibleToChangeContent(address: SimpleCellAddress): void { - if (invalidSimpleCellAddress(address)) { + if (isColOrRowInvalid(address)) { throw new InvalidAddressError(address) } if (!this.sheetMapping.hasSheetWithId(address.sheet)) { diff --git a/src/DependencyGraph/AddressMapping/AddressMapping.ts b/src/DependencyGraph/AddressMapping/AddressMapping.ts index ef02f762a..e329edb7e 100644 --- a/src/DependencyGraph/AddressMapping/AddressMapping.ts +++ b/src/DependencyGraph/AddressMapping/AddressMapping.ts @@ -10,41 +10,86 @@ import {EmptyValue, InterpreterValue} from '../../interpreter/InterpreterValue' import {Maybe} from '../../Maybe' import {SheetBoundaries} from '../../Sheet' import {ColumnsSpan, RowsSpan} from '../../Span' -import {ArrayVertex, ValueCellVertex} from '../index' +import {ArrayFormulaVertex, DenseStrategy, ValueCellVertex} from '../index' import {CellVertex} from '../Vertex' import {ChooseAddressMapping} from './ChooseAddressMappingPolicy' import {AddressMappingStrategy} from './AddressMappingStrategy' +/** + * Options for adding a sheet to the address mapping. + */ +export interface AddressMappingAddSheetOptions { + throwIfSheetAlreadyExists: boolean, +} + +export interface AddressMappingGetCellOptions { + throwIfSheetNotExists?: boolean, + throwIfCellNotExists?: boolean, +} + +/** + * Manages cell vertices and provides access to vertex by SimpleCellAddress. + * For each sheet it stores vertices according to AddressMappingStrategy: DenseStrategy or SparseStrategy. + * + * Pleceholder sheets: + * - for placeholders sheets (sheets that are used in formulas but not yet added), it stores placeholder strategy entries (DenseStrategy(0, 0)) + * - placeholder strategy entries may contain EmptyCellVertex-es but never ValueCellVertex or FormulaVertex as they content is empty + * - vertices in placeholder strategy entries are used only for dependency tracking + */ export class AddressMapping { private mapping: Map = new Map() constructor( - private readonly policy: ChooseAddressMapping - ) { - } + private readonly policy: ChooseAddressMapping, + ) {} - /** @inheritDoc */ - public getCell(address: SimpleCellAddress): Maybe { + /** + * Gets the cell vertex at the specified address. + */ + public getCell(address: SimpleCellAddress, options: AddressMappingGetCellOptions = {}): Maybe { const sheetMapping = this.mapping.get(address.sheet) - if (sheetMapping === undefined) { - throw new NoSheetWithIdError(address.sheet) + + if (!sheetMapping) { + if (options.throwIfSheetNotExists) { + throw new NoSheetWithIdError(address.sheet) + } + return undefined } - return sheetMapping.getCell(address) + + const cell = sheetMapping.getCell(address) + + if (!cell && options.throwIfCellNotExists) { + throw Error('Vertex for address missing in AddressMapping') + } + + return cell } - public fetchCell(address: SimpleCellAddress): CellVertex { + /** + * Gets the cell vertex at the specified address or throws if it doesn't exist. + * @throws {NoSheetWithIdError} if sheet doesn't exist + * @throws {Error} if cell doesn't exist + */ + public getCellOrThrow(address: SimpleCellAddress): CellVertex { const sheetMapping = this.mapping.get(address.sheet) - if (sheetMapping === undefined) { - throw new NoSheetWithIdError(address.sheet) + + if (!sheetMapping) { + throw new NoSheetWithIdError(address.sheet) } - const vertex = sheetMapping.getCell(address) - if (!vertex) { + + const cell = sheetMapping.getCell(address) + if (!cell) { throw Error('Vertex for address missing in AddressMapping') } - return vertex + + return cell } - public strategyFor(sheetId: number): AddressMappingStrategy { + /** + * Gets the address mapping strategy for the specified sheet. + * @throws {NoSheetWithIdError} if sheet doesn't exist + */ + public getStrategyForSheetOrThrow(sheetId: number): AddressMappingStrategy { const strategy = this.mapping.get(sheetId) if (strategy === undefined) { throw new NoSheetWithIdError(sheetId) @@ -53,71 +98,171 @@ export class AddressMapping { return strategy } - public addSheet(sheetId: number, strategy: AddressMappingStrategy) { - if (this.mapping.has(sheetId)) { - throw Error('Sheet already added') + /** + * Adds a new sheet with the specified strategy. + * @throws {Error} if sheet is already added and throwIfSheetAlreadyExists is true + */ + public addSheetWithStrategy(sheetId: number, strategy: AddressMappingStrategy, options: AddressMappingAddSheetOptions = { throwIfSheetAlreadyExists: true }): AddressMappingStrategy { + const strategyFound = this.mapping.get(sheetId) + + if (strategyFound) { + if (options.throwIfSheetAlreadyExists) { + throw Error('Sheet already added') + } + + return strategyFound } this.mapping.set(sheetId, strategy) + return strategy } - public autoAddSheet(sheetId: number, sheetBoundaries: SheetBoundaries) { + /** + * Adds a sheet or changes the strategy for an existing sheet. + * Designed for the purpose of exchanging the placeholder strategy for a real strategy. + */ + public addSheetOrChangeStrategy(sheetId: number, sheetBoundaries: SheetBoundaries): AddressMappingStrategy { + const newStrategy = this.createStrategyBasedOnBoundaries(sheetBoundaries) + const strategyPlaceholder = this.mapping.get(sheetId) + + if (!strategyPlaceholder) { + this.mapping.set(sheetId, newStrategy) + return newStrategy + } + + if (newStrategy instanceof DenseStrategy) { // new strategy is the same as the placeholder + return strategyPlaceholder + } + + this.moveStrategyContent(strategyPlaceholder, newStrategy, sheetId) + this.mapping.set(sheetId, newStrategy) + + return newStrategy + } + + /** + * Moves the content of the source strategy to the target strategy. + */ + private moveStrategyContent(sourceStrategy: AddressMappingStrategy, targetStrategy: AddressMappingStrategy, sheetContext: number) { + const sourceVertices = sourceStrategy.getEntries(sheetContext) + for (const [address, vertex] of sourceVertices) { + targetStrategy.setCell(address, vertex) + } + } + + /** + * Adds a sheet and sets the strategy based on the sheet boundaries. + * @throws {Error} if sheet already exists and throwIfSheetAlreadyExists is true + */ + public addSheetAndSetStrategyBasedOnBoundaries(sheetId: number, sheetBoundaries: SheetBoundaries, options: AddressMappingAddSheetOptions = { throwIfSheetAlreadyExists: true }) { + this.addSheetWithStrategy(sheetId, this.createStrategyBasedOnBoundaries(sheetBoundaries), options) + } + + /** + * Creates a strategy based on the sheet boundaries. + */ + private createStrategyBasedOnBoundaries(sheetBoundaries: SheetBoundaries): AddressMappingStrategy { const {height, width, fill} = sheetBoundaries const strategyConstructor = this.policy.call(fill) - this.addSheet(sheetId, new strategyConstructor(width, height)) + return new strategyConstructor(width, height) + } + + /** + * Adds a placeholder strategy (DenseStrategy) for a sheet. If the sheet already exists, does nothing. + */ + public addSheetStrategyPlaceholderIfNotExists(sheetId: number): void { + if (this.mapping.has(sheetId)) { + return + } + + this.mapping.set(sheetId, new DenseStrategy(0, 0)) + } + + /** + * Removes a sheet from the address mapping. + * If sheet does not exist, does nothing. + * @returns {boolean} true if sheet was removed, false if it did not exist. + */ + public removeSheetIfExists(sheetId: number): boolean { + return this.mapping.delete(sheetId) } + /** + * Gets the interpreter value of a cell at the specified address. + * @returns {InterpreterValue} The interpreter value (returns EmptyValue if cell doesn't exist) + */ public getCellValue(address: SimpleCellAddress): InterpreterValue { const vertex = this.getCell(address) if (vertex === undefined) { return EmptyValue - } else if (vertex instanceof ArrayVertex) { + } else if (vertex instanceof ArrayFormulaVertex) { return vertex.getArrayCellValue(address) } else { return vertex.getCellValue() } } + /** + * Gets the raw cell content at the specified address. + * @returns {RawCellContent} The raw cell content or null if cell doesn't exist or is not a value cell + */ public getRawValue(address: SimpleCellAddress): RawCellContent { const vertex = this.getCell(address) if (vertex instanceof ValueCellVertex) { return vertex.getValues().rawValue - } else if (vertex instanceof ArrayVertex) { + } else if (vertex instanceof ArrayFormulaVertex) { return vertex.getArrayCellRawValue(address) } else { return null } } - /** @inheritDoc */ + /** + * Sets a cell vertex at the specified address. + * @throws {Error} if sheet not initialized + */ public setCell(address: SimpleCellAddress, newVertex: CellVertex) { const sheetMapping = this.mapping.get(address.sheet) + if (!sheetMapping) { throw Error('Sheet not initialized') } sheetMapping.setCell(address, newVertex) } + /** + * Moves a cell from source address to destination address. + * Supports cross-sheet moves (used for placeholder sheet merging). + * @throws {Error} if source sheet not initialized + * @throws {Error} if destination occupied + * @throws {Error} if source cell doesn't exist + */ public moveCell(source: SimpleCellAddress, destination: SimpleCellAddress) { const sheetMapping = this.mapping.get(source.sheet) + if (!sheetMapping) { throw Error('Sheet not initialized.') } - if (source.sheet !== destination.sheet) { - throw Error('Cannot move cells between sheets.') - } - if (sheetMapping.has(destination)) { + + if (this.has(destination)) { throw new Error('Cannot move cell. Destination already occupied.') } + const vertex = sheetMapping.getCell(source) + if (vertex === undefined) { throw new Error('Cannot move cell. No cell with such address.') } + this.setCell(destination, vertex) this.removeCell(source) } + /** + * Removes a cell at the specified address. + * @throws Error if sheet not initialized + */ public removeCell(address: SimpleCellAddress) { const sheetMapping = this.mapping.get(address.sheet) if (!sheetMapping) { @@ -126,7 +271,9 @@ export class AddressMapping { sheetMapping.removeCell(address) } - /** @inheritDoc */ + /** + * Checks if a cell exists at the specified address. + */ public has(address: SimpleCellAddress): boolean { const sheetMapping = this.mapping.get(address.sheet) if (sheetMapping === undefined) { @@ -135,88 +282,110 @@ export class AddressMapping { return sheetMapping.has(address) } - /** @inheritDoc */ - public getHeight(sheetId: number): number { - const sheetMapping = this.mapping.get(sheetId) - if (sheetMapping === undefined) { - throw new NoSheetWithIdError(sheetId) - } + /** + * Gets the height of the specified sheet. + */ + public getSheetHeight(sheetId: number): number { + const sheetMapping = this.getStrategyForSheetOrThrow(sheetId) return sheetMapping.getHeight() } - /** @inheritDoc */ - public getWidth(sheetId: number): number { - const sheetMapping = this.mapping.get(sheetId) - if (!sheetMapping) { - throw new NoSheetWithIdError(sheetId) - } + /** + * Gets the width of the specified sheet. + */ + public getSheetWidth(sheetId: number): number { + const sheetMapping = this.getStrategyForSheetOrThrow(sheetId) return sheetMapping.getWidth() } - public addRows(sheet: number, row: number, numberOfRows: number) { - const sheetMapping = this.mapping.get(sheet) - if (sheetMapping === undefined) { - throw new NoSheetWithIdError(sheet) - } + /** + * Adds rows to a sheet. + */ + public addRows(sheetId: number, row: number, numberOfRows: number) { + const sheetMapping = this.getStrategyForSheetOrThrow(sheetId) sheetMapping.addRows(row, numberOfRows) } + /** + * Removes rows from a sheet. + */ public removeRows(removedRows: RowsSpan) { - const sheetMapping = this.mapping.get(removedRows.sheet) - if (sheetMapping === undefined) { - throw new NoSheetWithIdError(removedRows.sheet) - } + const sheetMapping = this.getStrategyForSheetOrThrow(removedRows.sheet) sheetMapping.removeRows(removedRows) } - public removeSheet(sheetId: number) { - this.mapping.delete(sheetId) - } - - public addColumns(sheet: number, column: number, numberOfColumns: number) { - const sheetMapping = this.mapping.get(sheet) - if (sheetMapping === undefined) { - throw new NoSheetWithIdError(sheet) - } + /** + * Adds columns to a sheet starting at the specified column index. + */ + public addColumns(sheetId: number, column: number, numberOfColumns: number) { + const sheetMapping = this.getStrategyForSheetOrThrow(sheetId) sheetMapping.addColumns(column, numberOfColumns) } + /** + * Removes columns from a sheet. + */ public removeColumns(removedColumns: ColumnsSpan) { - const sheetMapping = this.mapping.get(removedColumns.sheet) - if (sheetMapping === undefined) { - throw new NoSheetWithIdError(removedColumns.sheet) - } + const sheetMapping = this.getStrategyForSheetOrThrow(removedColumns.sheet) sheetMapping.removeColumns(removedColumns) } + /** + * Returns an iterator of cell vertices within the specified rows span. + */ public* verticesFromRowsSpan(rowsSpan: RowsSpan): IterableIterator { yield* this.mapping.get(rowsSpan.sheet)!.verticesFromRowsSpan(rowsSpan) // eslint-disable-line @typescript-eslint/no-non-null-assertion } + /** + * Returns an iterator of cell vertices within the specified columns span. + */ public* verticesFromColumnsSpan(columnsSpan: ColumnsSpan): IterableIterator { yield* this.mapping.get(columnsSpan.sheet)!.verticesFromColumnsSpan(columnsSpan) // eslint-disable-line @typescript-eslint/no-non-null-assertion } + /** + * Returns an iterator of address-vertex pairs within the specified rows span. + */ public* entriesFromRowsSpan(rowsSpan: RowsSpan): IterableIterator<[SimpleCellAddress, CellVertex]> { - yield* this.mapping.get(rowsSpan.sheet)!.entriesFromRowsSpan(rowsSpan) + const sheetMapping = this.getStrategyForSheetOrThrow(rowsSpan.sheet) + yield* sheetMapping.entriesFromRowsSpan(rowsSpan) } + /** + * Returns an iterator of address-vertex pairs within the specified columns span. + */ public* entriesFromColumnsSpan(columnsSpan: ColumnsSpan): IterableIterator<[SimpleCellAddress, CellVertex]> { - yield* this.mapping.get(columnsSpan.sheet)!.entriesFromColumnsSpan(columnsSpan) + const sheetMapping = this.getStrategyForSheetOrThrow(columnsSpan.sheet) + yield* sheetMapping.entriesFromColumnsSpan(columnsSpan) } + /** + * Returns an iterator of all address-vertex pairs across all sheets. + * @returns {IterableIterator<[SimpleCellAddress, Maybe]>} Iterator of [address, vertex] tuples + */ public* entries(): IterableIterator<[SimpleCellAddress, Maybe]> { for (const [sheet, mapping] of this.mapping.entries()) { yield* mapping.getEntries(sheet) } } - public* sheetEntries(sheet: number): IterableIterator<[SimpleCellAddress, CellVertex]> { - const sheetMapping = this.mapping.get(sheet) - if (sheetMapping !== undefined) { - yield* sheetMapping.getEntries(sheet) - } else { - throw new NoSheetWithIdError(sheet) - } + /** + * Returns an iterator of address-vertex pairs for a specific sheet. + * @returns {IterableIterator<[SimpleCellAddress, CellVertex]>} Iterator of [address, vertex] tuples + * @throws {NoSheetWithIdError} if sheet doesn't exist + */ + public* sheetEntries(sheetId: number): IterableIterator<[SimpleCellAddress, CellVertex]> { + const sheetMapping = this.getStrategyForSheetOrThrow(sheetId) + yield* sheetMapping.getEntries(sheetId) + } + + /** + * Checks if a sheet has any entries. + * @throws {NoSheetWithIdError} if sheet doesn't exist + */ + public hasAnyEntries(sheetId: number): boolean { + const iterator = this.sheetEntries(sheetId) + return !iterator.next().done } } diff --git a/src/DependencyGraph/ArrayMapping.ts b/src/DependencyGraph/ArrayMapping.ts index 87ffcaf2b..ee3e48820 100644 --- a/src/DependencyGraph/ArrayMapping.ts +++ b/src/DependencyGraph/ArrayMapping.ts @@ -7,12 +7,23 @@ import {AbsoluteCellRange} from '../AbsoluteCellRange' import {addressKey, SimpleCellAddress} from '../Cell' import {Maybe} from '../Maybe' import {ColumnsSpan, RowsSpan} from '../Span' -import {ArrayVertex} from './' +import {ArrayFormulaVertex} from './' +/** + * Maps top-left corner addresses to their ArrayFormulaVertex instances. + * An ArrayFormulaVertex is created for formulas that output multiple values (e.g., MMULT, TRANSPOSE, array literals). + * The same ArrayFormulaVertex is referenced in AddressMapping for all cells within its spill range. + * ArrayFormulaVertex lifecycle: + * - Prediction (ArraySizePredictor.checkArraySize) → determines if formula will produce array + * - Creation → new ArrayFormulaVertex(...) added via addArrayFormulaVertex() + * - Address registration → setAddressMappingForArrayFormulaVertex() sets the vertex for all cells in range + * - Evaluation → computes actual values, stores in ArrayFormulaVertex.array + * - Shrinking → if content placed in array area, array shrinks via shrinkArrayToCorner() + */ export class ArrayMapping { - public readonly arrayMapping: Map = new Map() + public readonly arrayMapping: Map = new Map() - public getArray(range: AbsoluteCellRange): Maybe { + public getArray(range: AbsoluteCellRange): Maybe { const array = this.getArrayByCorner(range.start) if (array?.getRange().sameAs(range)) { return array @@ -20,11 +31,11 @@ export class ArrayMapping { return } - public getArrayByCorner(address: SimpleCellAddress): Maybe { + public getArrayByCorner(address: SimpleCellAddress): Maybe { return this.arrayMapping.get(addressKey(address)) } - public setArray(range: AbsoluteCellRange, vertex: ArrayVertex) { + public setArray(range: AbsoluteCellRange, vertex: ArrayFormulaVertex) { this.arrayMapping.set(addressKey(range.start), vertex) } @@ -40,7 +51,7 @@ export class ArrayMapping { return this.arrayMapping.size } - public* arraysInRows(rowsSpan: RowsSpan): IterableIterator<[string, ArrayVertex]> { + public* arraysInRows(rowsSpan: RowsSpan): IterableIterator<[string, ArrayFormulaVertex]> { for (const [mtxKey, mtx] of this.arrayMapping.entries()) { if (mtx.spansThroughSheetRows(rowsSpan.sheet, rowsSpan.rowStart, rowsSpan.rowEnd)) { yield [mtxKey, mtx] @@ -48,7 +59,7 @@ export class ArrayMapping { } } - public* arraysInCols(col: ColumnsSpan): IterableIterator<[string, ArrayVertex]> { + public* arraysInCols(col: ColumnsSpan): IterableIterator<[string, ArrayFormulaVertex]> { for (const [mtxKey, mtx] of this.arrayMapping.entries()) { if (mtx.spansThroughSheetColumn(col.sheet, col.columnStart, col.columnEnd)) { yield [mtxKey, mtx] @@ -113,21 +124,21 @@ export class ArrayMapping { } public moveArrayVerticesAfterRowByRows(sheet: number, row: number, numberOfRows: number) { - this.updateArrayVerticesInSheet(sheet, (key: string, vertex: ArrayVertex) => { + this.updateArrayVerticesInSheet(sheet, (key: string, vertex: ArrayFormulaVertex) => { const range = vertex.getRange() return row <= range.start.row ? [range.shifted(0, numberOfRows), vertex] : undefined }) } public moveArrayVerticesAfterColumnByColumns(sheet: number, column: number, numberOfColumns: number) { - this.updateArrayVerticesInSheet(sheet, (key: string, vertex: ArrayVertex) => { + this.updateArrayVerticesInSheet(sheet, (key: string, vertex: ArrayFormulaVertex) => { const range = vertex.getRange() return column <= range.start.col ? [range.shifted(numberOfColumns, 0), vertex] : undefined }) } - private updateArrayVerticesInSheet(sheet: number, fn: (key: string, vertex: ArrayVertex) => Maybe<[AbsoluteCellRange, ArrayVertex]>) { - const updated = Array<[AbsoluteCellRange, ArrayVertex]>() + private updateArrayVerticesInSheet(sheet: number, fn: (key: string, vertex: ArrayFormulaVertex) => Maybe<[AbsoluteCellRange, ArrayFormulaVertex]>) { + const updated = Array<[AbsoluteCellRange, ArrayFormulaVertex]>() for (const [key, vertex] of this.arrayMapping.entries()) { if (vertex.sheet !== sheet) { diff --git a/src/DependencyGraph/DependencyGraph.ts b/src/DependencyGraph/DependencyGraph.ts index 0cdf1778a..530d4bd81 100644 --- a/src/DependencyGraph/DependencyGraph.ts +++ b/src/DependencyGraph/DependencyGraph.ts @@ -28,10 +28,10 @@ import {Ast, collectDependencies, NamedExpressionDependency} from '../parser' import {ColumnsSpan, RowsSpan, Span} from '../Span' import {Statistics, StatType} from '../statistics' import { - ArrayVertex, + ArrayFormulaVertex, CellVertex, EmptyCellVertex, - FormulaCellVertex, + ScalarFormulaVertex, ParsingErrorVertex, RangeVertex, ValueCellVertex, @@ -40,16 +40,19 @@ import { import {AddressMapping} from './AddressMapping/AddressMapping' import {ArrayMapping} from './ArrayMapping' import {collectAddressesDependentToRange} from './collectAddressesDependentToRange' -import {FormulaVertex} from './FormulaCellVertex' +import {FormulaVertex} from './FormulaVertex' import {DependencyQuery, Graph} from './Graph' import {RangeMapping} from './RangeMapping' import {SheetMapping} from './SheetMapping' +import {SheetReferenceRegistrar} from './SheetReferenceRegistrar' import {RawAndParsedValue} from './ValueCellVertex' import {TopSortResult} from './TopSort' +import { findBoundaries } from '../Sheet' export class DependencyGraph { public readonly graph: Graph private changes: ContentChanges = ContentChanges.empty() + public readonly sheetReferenceRegistrar: SheetReferenceRegistrar constructor( public readonly addressMapping: AddressMapping, @@ -62,6 +65,7 @@ export class DependencyGraph { public readonly namedExpressions: NamedExpressions, ) { this.graph = new Graph(this.dependencyQueryVertices) + this.sheetReferenceRegistrar = new SheetReferenceRegistrar(sheetMapping, addressMapping) } /** @@ -109,7 +113,7 @@ export class DependencyGraph { public setValueToCell(address: SimpleCellAddress, value: RawAndParsedValue): ContentChanges { const vertex = this.shrinkPossibleArrayAndGetCell(address) - if (vertex instanceof ArrayVertex) { + if (vertex instanceof ArrayFormulaVertex) { this.arrayMapping.removeArray(vertex.getRange()) } @@ -131,6 +135,11 @@ export class DependencyGraph { return this.getAndClearContentChanges() } + /** + * Sets a cell empty. + * - if vertex has no dependents, removes it from graph, address mapping and range mapping and cleans up its dependencies + * - if vertex has dependents, exchanges it for an EmptyCellVertex and marks it as dirty + */ public setCellEmpty(address: SimpleCellAddress): ContentChanges { const vertex = this.shrinkPossibleArrayAndGetCell(address) if (vertex === undefined) { @@ -163,7 +172,11 @@ export class DependencyGraph { } public processCellDependencies(cellDependencies: CellDependency[], endVertex: Vertex) { - const endVertexId = this.graph.getNodeId(endVertex)! + const endVertexId = this.graph.getNodeId(endVertex) + + if (endVertexId === undefined) { + throw new Error('End vertex not found') + } cellDependencies.forEach((dep: CellDependency) => { if (dep instanceof AbsoluteCellRange) { @@ -172,11 +185,15 @@ export class DependencyGraph { let rangeVertex = this.getRange(range.start, range.end) if (rangeVertex === undefined) { rangeVertex = new RangeVertex(range) - this.rangeMapping.setRange(rangeVertex) + this.rangeMapping.addOrUpdateVertex(rangeVertex) } this.graph.addNodeAndReturnId(rangeVertex) - const rangeVertexId = this.graph.getNodeId(rangeVertex)! + const rangeVertexId = this.graph.getNodeId(rangeVertex) + + if (rangeVertexId === undefined) { + throw new Error('Range vertex not found') + } if (!range.isFinite()) { this.graph.markNodeAsInfiniteRange(rangeVertexId) @@ -210,7 +227,7 @@ export class DependencyGraph { this.correctInfiniteRangesDependenciesByRangeVertex(rangeVertex) } } else if (dep instanceof NamedExpressionDependency) { - const sheetOfVertex = (endVertex as FormulaCellVertex).getAddress(this.lazilyTransformingAstService).sheet + const sheetOfVertex = (endVertex as ScalarFormulaVertex).getAddress(this.lazilyTransformingAstService).sheet const { vertex, id } = this.fetchNamedExpressionVertex(dep.name, sheetOfVertex) this.graph.addEdge(id ?? vertex, endVertexId) } else { @@ -252,7 +269,7 @@ export class DependencyGraph { for (const adjacentNode of this.graph.adjacentNodes(vertex)) { this.graph.markNodeAsDirty(adjacentNode) } - if (vertex instanceof ArrayVertex) { + if (vertex instanceof ArrayFormulaVertex) { if (vertex.isLeftCorner(address)) { this.shrinkArrayToCorner(vertex) this.arrayMapping.removeArray(vertex.getRange()) @@ -285,33 +302,70 @@ export class DependencyGraph { } } - public removeSheet(removedSheetId: number) { - this.clearSheet(removedSheetId) + /** + * Adds a new sheet to the graph. + * If the sheetId was a placeholder sheet, marks its vertices as dirty. + */ + public addSheet(sheetId: number): void { + this.addressMapping.addSheetOrChangeStrategy(sheetId, findBoundaries([])) - for (const [adr, vertex] of this.addressMapping.sheetEntries(removedSheetId)) { - for (const adjacentNode of this.graph.adjacentNodes(vertex)) { - this.graph.markNodeAsDirty(adjacentNode) - } - this.removeVertex(vertex) - this.addressMapping.removeCell(adr) - } + this.stats.measure(StatType.ADJUSTING_ADDRESS_MAPPING, () => { + this.markAllCellsAsDirtyInSheet(sheetId) + }) this.stats.measure(StatType.ADJUSTING_RANGES, () => { - const rangesToRemove = this.rangeMapping.removeRangesInSheet(removedSheetId) - for (const range of rangesToRemove) { - this.removeVertex(range) - } - - this.stats.measure(StatType.ADJUSTING_ADDRESS_MAPPING, () => { - this.addressMapping.removeSheet(removedSheetId) - }) + this.markAllRangesAsDirtyInSheet(sheetId) }) } + /** + * Removes all vertices without dependents in other sheets from address mapping, range mapping and array mapping. + * - If nothing is left, removes the sheet from sheet mapping and address mapping. + * - Otherwise, marks it as placeholder. + */ + public removeSheet(sheetId: number): void { + this.clearSheet(sheetId) + const addressMappingCleared = !this.addressMapping.hasAnyEntries(sheetId) + const rangeMappingCleared = this.rangeMapping.getNumberOfRangesInSheet(sheetId) === 0 + + if (addressMappingCleared && rangeMappingCleared) { + this.sheetMapping.removeSheetIfExists(sheetId) + this.addressMapping.removeSheetIfExists(sheetId) + } else { + this.sheetMapping.markSheetAsPlaceholder(sheetId) + } + } + + /** + * Removes placeholderSheetToDelete and reroutes edges to the corresponding vertices in sheetToKeep + * + * Assumptions about placeholderSheetToDelete: + * - is empty (contains only empty cell vertices and range vertices), + * - empty cell vertices have no dependencies, + * - range vertices have dependencies only in placeholderSheetToDelete, + * - vertices may have dependents in placeholderSheetToDelete and other sheets, + */ + public mergeSheets(sheetToKeep: number, placeholderSheetToDelete: number): void { + if (!this.isPlaceholder(placeholderSheetToDelete)) { + throw new Error(`Cannot merge sheets: sheet ${placeholderSheetToDelete} is not a placeholder`) + } + + this.mergeRangeVertices(sheetToKeep, placeholderSheetToDelete) + this.mergeCellVertices(sheetToKeep, placeholderSheetToDelete) + this.addressMapping.removeSheetIfExists(placeholderSheetToDelete) + this.addStructuralNodesToChangeSet() + } + + /** + * Clears the sheet content. + * - removes all cell vertices without dependents + * - removes all array vertices + * - for vertices with dependents, exchanges them for EmptyCellVertex and marks them as dirty + */ public clearSheet(sheetId: number) { - const arrays: Set = new Set() + const arrays: Set = new Set() for (const [address, vertex] of this.addressMapping.sheetEntries(sheetId)) { - if (vertex instanceof ArrayVertex) { + if (vertex instanceof ArrayFormulaVertex) { arrays.add(vertex) } else { this.setCellEmpty(address) @@ -331,7 +385,7 @@ export class DependencyGraph { for (const adjacentNode of this.graph.adjacentNodes(vertex)) { this.graph.markNodeAsDirty(adjacentNode) } - if (vertex instanceof ArrayVertex) { + if (vertex instanceof ArrayFormulaVertex) { if (vertex.isLeftCorner(address)) { this.shrinkArrayToCorner(vertex) this.arrayMapping.removeArray(vertex.getRange()) @@ -370,7 +424,7 @@ export class DependencyGraph { }) const affectedArrays = this.stats.measure(StatType.ADJUSTING_RANGES, () => { - const result = this.rangeMapping.moveAllRangesInSheetAfterRowByRows(addedRows.sheet, addedRows.rowStart, addedRows.numberOfRows) + const result = this.rangeMapping.moveAllRangesInSheetAfterAddingRows(addedRows.sheet, addedRows.rowStart, addedRows.numberOfRows) this.fixRangesWhenAddingRows(addedRows.sheet, addedRows.rowStart, addedRows.numberOfRows) return this.getArrayVerticesRelatedToRanges(result.verticesWithChangedSize) }) @@ -394,7 +448,7 @@ export class DependencyGraph { }) const affectedArrays = this.stats.measure(StatType.ADJUSTING_RANGES, () => { - const result = this.rangeMapping.moveAllRangesInSheetAfterColumnByColumns(addedColumns.sheet, addedColumns.columnStart, addedColumns.numberOfColumns) + const result = this.rangeMapping.moveAllRangesInSheetAfterAddingColumns(addedColumns.sheet, addedColumns.columnStart, addedColumns.numberOfColumns) this.fixRangesWhenAddingColumns(addedColumns.sheet, addedColumns.columnStart, addedColumns.numberOfColumns) return this.getArrayVerticesRelatedToRanges(result.verticesWithChangedSize) }) @@ -412,7 +466,7 @@ export class DependencyGraph { return {affectedArrays, contentChanges: this.getAndClearContentChanges()} } - public isThereSpaceForArray(arrayVertex: ArrayVertex): boolean { + public isThereSpaceForArray(arrayVertex: ArrayFormulaVertex): boolean { const range = arrayVertex.getRangeOrUndef() if (range === undefined) { return false @@ -482,15 +536,22 @@ export class DependencyGraph { this.rangeMapping.moveRangesInsideSourceRange(sourceRange, toRight, toBottom, toSheet) } - public setArrayEmpty(arrayVertex: ArrayVertex) { + /** + * Sets an array empty. + * - removes all corresponding entries from address mapping + * - reroutes the edges + * - removes vertex from graph and cleans up its dependencies + * - removes vertex from range mapping and array mapping + */ + public setArrayEmpty(arrayVertex: ArrayFormulaVertex) { const arrayRange = AbsoluteCellRange.spanFrom(arrayVertex.getAddress(this.lazilyTransformingAstService), arrayVertex.width, arrayVertex.height) - const adjacentNodes = this.graph.adjacentNodes(arrayVertex) + const dependentVertices = this.graph.adjacentNodes(arrayVertex) for (const address of arrayRange.addresses(this)) { this.addressMapping.removeCell(address) } - for (const adjacentNode of adjacentNodes.values()) { + for (const adjacentNode of dependentVertices.values()) { const nodeDependencies = collectAddressesDependentToRange(this.functionRegistry, adjacentNode, arrayVertex.getRange(), this.lazilyTransformingAstService, this) for (const address of nodeDependencies) { const { vertex, id } = this.fetchCellOrCreateEmpty(address) @@ -510,14 +571,17 @@ export class DependencyGraph { this.addressMapping.setCell(address, vertex) } - public addArrayVertex(address: SimpleCellAddress, vertex: ArrayVertex): void { + public addArrayVertex(address: SimpleCellAddress, vertex: ArrayFormulaVertex): void { this.graph.addNodeAndReturnId(vertex) this.setAddressMappingForArrayVertex(vertex, address) } - public* arrayFormulaNodes(): IterableIterator { + /** + * Iterator over all array formula nodes in the graph. + */ + public* arrayFormulaNodes(): IterableIterator { for (const vertex of this.graph.getNodes()) { - if (vertex instanceof ArrayVertex) { + if (vertex instanceof ArrayFormulaVertex) { yield vertex } } @@ -532,22 +596,38 @@ export class DependencyGraph { } public fetchCell(address: SimpleCellAddress): CellVertex { - return this.addressMapping.fetchCell(address) + return this.addressMapping.getCellOrThrow(address) } + /** + * Gets the cell vertex at the specified address. + * @throws {NoSheetWithIdError} if sheet doesn't exist + */ public getCell(address: SimpleCellAddress): Maybe { - return this.addressMapping.getCell(address) + return this.addressMapping.getCell(address, { throwIfSheetNotExists: true }) } public getCellValue(address: SimpleCellAddress): InterpreterValue { + if (this.isPlaceholder(address.sheet)) { + return new CellError(ErrorType.REF, ErrorMessage.SheetRef) + } + return this.addressMapping.getCellValue(address) } public getRawValue(address: SimpleCellAddress): RawCellContent { + if (this.isPlaceholder(address.sheet)) { + return null + } + return this.addressMapping.getRawValue(address) } public getScalarValue(address: SimpleCellAddress): InternalScalarValue { + if (this.isPlaceholder(address.sheet)) { + return new CellError(ErrorType.REF, ErrorMessage.SheetRef) + } + const value = this.addressMapping.getCellValue(address) if (value instanceof SimpleRangeValue) { return new CellError(ErrorType.VALUE, ErrorMessage.ScalarExpected) @@ -560,23 +640,23 @@ export class DependencyGraph { } public getSheetId(sheetName: string): number { - return this.sheetMapping.fetch(sheetName) + return this.sheetMapping.getSheetIdOrThrowError(sheetName) } public getSheetHeight(sheet: number): number { - return this.addressMapping.getHeight(sheet) + return this.addressMapping.getSheetHeight(sheet) } public getSheetWidth(sheet: number): number { - return this.addressMapping.getWidth(sheet) + return this.addressMapping.getSheetWidth(sheet) } - public getArray(range: AbsoluteCellRange): Maybe { + public getArray(range: AbsoluteCellRange): Maybe { return this.arrayMapping.getArray(range) } public getRange(start: SimpleCellAddress, end: SimpleCellAddress): Maybe { - return this.rangeMapping.getRange(start, end) + return this.rangeMapping.getRangeVertex(start, end) } public topSortWithScc(): TopSortResult { @@ -593,7 +673,7 @@ export class DependencyGraph { public forceApplyPostponedTransformations() { for (const vertex of this.graph.getNodes()) { - if (vertex instanceof FormulaCellVertex) { + if (vertex instanceof ScalarFormulaVertex) { vertex.ensureRecentData(this.lazilyTransformingAstService) } } @@ -639,7 +719,7 @@ export class DependencyGraph { return values } - public shrinkArrayToCorner(array: ArrayVertex) { + public shrinkArrayToCorner(array: ArrayFormulaVertex) { this.cleanAddressMappingUnderArray(array) for (const adjacentVertex of this.adjacentArrayVertices(array)) { let relevantDependencies @@ -665,7 +745,7 @@ export class DependencyGraph { public isArrayInternalCell(address: SimpleCellAddress): boolean { const vertex = this.getCell(address) - return vertex instanceof ArrayVertex && !vertex.isLeftCorner(address) + return vertex instanceof ArrayFormulaVertex && !vertex.isLeftCorner(address) } public getAndClearContentChanges(): ContentChanges { @@ -678,16 +758,108 @@ export class DependencyGraph { const deps = this.graph.adjacentNodes(inputVertex) const ret: (SimpleCellRange | SimpleCellAddress)[] = [] deps.forEach((vertex: Vertex) => { - const castVertex = vertex as RangeVertex | FormulaCellVertex | ArrayVertex - if (castVertex instanceof RangeVertex) { - ret.push(simpleCellRange(castVertex.start, castVertex.end)) - } else { - ret.push(castVertex.getAddress(this.lazilyTransformingAstService)) + if (vertex instanceof RangeVertex) { + ret.push(simpleCellRange(vertex.start, vertex.end)) + } else if (vertex instanceof FormulaVertex) { + ret.push(vertex.getAddress(this.lazilyTransformingAstService)) } }) return ret } + /** + * Marks all cell vertices in the sheet as dirty. + */ + private markAllCellsAsDirtyInSheet(sheetId: number): void { + const sheetCells = this.addressMapping.sheetEntries(sheetId) + for (const [, vertex] of sheetCells) { + this.graph.markNodeAsDirty(vertex) + } + } + + /** + * Marks all range vertices in the sheet as dirty. + */ + private markAllRangesAsDirtyInSheet(sheetId: number): void { + const sheetRanges = this.rangeMapping.rangesInSheet(sheetId) + + for (const vertex of sheetRanges) { + this.graph.markNodeAsDirty(vertex) + } + } + + /** + * For each range vertex in placeholderSheetToDelete: + * - reroutes dependencies and dependents of range vertex to the corresponding vertex in sheetToKeep + * - removes range vertex from graph and range mapping + * - cleans up dependencies of the removed vertex + */ + private mergeRangeVertices(sheetToKeep: number, placeholderSheetToDelete: number): void { + const rangeVertices = Array.from(this.rangeMapping.rangesInSheet(placeholderSheetToDelete)) + + for (const vertexToDelete of rangeVertices) { + if (!this.graph.hasNode(vertexToDelete)) { + continue + } + + const start = vertexToDelete.start + const end = vertexToDelete.end + + if (start.sheet !== placeholderSheetToDelete && end.sheet !== placeholderSheetToDelete) { + continue + } + + const targetStart = simpleCellAddress(sheetToKeep, start.col, start.row) + const targetEnd = simpleCellAddress(sheetToKeep, end.col, end.row) + const vertexToKeep = this.rangeMapping.getRangeVertex(targetStart, targetEnd) + + if (vertexToKeep) { + this.rerouteDependents(vertexToDelete, vertexToKeep) + this.removeVertexAndRerouteDependencies(vertexToDelete, vertexToKeep) + this.rangeMapping.removeVertexIfExists(vertexToDelete) + this.graph.markNodeAsDirty(vertexToKeep) + } else { + this.rangeMapping.removeVertexIfExists(vertexToDelete) + vertexToDelete.range.moveToSheet(sheetToKeep) + this.rangeMapping.addOrUpdateVertex(vertexToDelete) + this.graph.markNodeAsDirty(vertexToDelete) + } + } + } + + /** + * For each cell vertex in placeholderSheetToDelete: + * - reroutes dependents of cell vertex to the corresponding vertex in sheetToKeep + * - removes cell vertex from graph and address mapping + * - cleans up dependencies of the removed vertex + */ + private mergeCellVertices(sheetToKeep: number, placeholderSheetToDelete: number): void { + const cellVertices = Array.from(this.addressMapping.sheetEntries(placeholderSheetToDelete)) // placeholder sheet contains only EmptyCellVertex-es + + for (const [addressToDelete, vertexToDelete] of cellVertices) { + const addressToKeep = simpleCellAddress(sheetToKeep, addressToDelete.col, addressToDelete.row) + const vertexToKeep = this.getCell(addressToKeep) + + if (vertexToKeep) { + this.rerouteDependents(vertexToDelete, vertexToKeep) + this.removeVertexAndCleanupDependencies(vertexToDelete) + this.addressMapping.removeCell(addressToDelete) + this.graph.markNodeAsDirty(vertexToKeep) + } else { + this.addressMapping.moveCell(addressToDelete, addressToKeep) + this.graph.markNodeAsDirty(vertexToDelete) + } + } + } + + /** + * Checks if the given sheet ID refers to a placeholder sheet (doesn't exist but is referenced by other sheets) + */ + private isPlaceholder(sheetId: number): boolean { + return sheetId !== NamedExpressions.SHEET_FOR_WORKBOOK_EXPRESSIONS && + !this.sheetMapping.hasSheetWithId(sheetId, { includePlaceholders: false }) + } + private exchangeGraphNode(oldNode: Vertex, newNode: Vertex) { this.graph.addNodeAndReturnId(newNode) const adjNodesStored = this.graph.adjacentNodes(oldNode) @@ -699,7 +871,7 @@ export class DependencyGraph { }) } - private setArray(range: AbsoluteCellRange, vertex: ArrayVertex): void { + private setArray(range: AbsoluteCellRange, vertex: ArrayFormulaVertex): void { this.arrayMapping.setArray(range, vertex) } @@ -712,7 +884,11 @@ export class DependencyGraph { } const { vertex, id: maybeVertexId } = this.fetchCellOrCreateEmpty(address) - const vertexId = maybeVertexId ?? this.graph.getNodeId(vertex)! + const vertexId = maybeVertexId ?? this.graph.getNodeId(vertex) + + if (vertexId === undefined) { + throw new Error('Vertex not found') + } relevantInfiniteRanges.forEach(({ id }) => { this.graph.addEdge(vertexId, id) @@ -736,12 +912,12 @@ export class DependencyGraph { const [address, dependencies] = dependenciesResult return dependencies.map((dependency: CellDependency) => { if (dependency instanceof AbsoluteCellRange) { - return [dependency.start, this.rangeMapping.fetchRange(dependency.start, dependency.end)] + return [dependency.start, this.rangeMapping.getVertexOrThrow(dependency.start, dependency.end)] } else if (dependency instanceof NamedExpressionDependency) { const namedExpression = this.namedExpressions.namedExpressionOrPlaceholder(dependency.name, address.sheet) - return [namedExpression.address, this.addressMapping.fetchCell(namedExpression.address)] + return [namedExpression.address, this.addressMapping.getCellOrThrow(namedExpression.address)] } else { - return [dependency, this.addressMapping.fetchCell(dependency)] + return [dependency, this.addressMapping.getCellOrThrow(dependency)] } }) } else { @@ -750,8 +926,8 @@ export class DependencyGraph { } } - private getArrayVerticesRelatedToRanges(ranges: RangeVertex[]): Set { - const arrayVertices = new Set() + private getArrayVerticesRelatedToRanges(ranges: RangeVertex[]): Set { + const arrayVertices = new Set() ranges.forEach(range => { if (!this.graph.hasNode(range)) { @@ -759,7 +935,7 @@ export class DependencyGraph { } this.graph.adjacentNodes(range).forEach(adjacentVertex => { - if (adjacentVertex instanceof ArrayVertex) { + if (adjacentVertex instanceof ArrayFormulaVertex) { arrayVertices.add(adjacentVertex) } }) @@ -784,7 +960,7 @@ export class DependencyGraph { }) } - private cleanAddressMappingUnderArray(vertex: ArrayVertex) { + private cleanAddressMappingUnderArray(vertex: ArrayFormulaVertex) { const arrayRange = vertex.getRange() for (const address of arrayRange.addresses(this)) { const oldValue = vertex.getArrayCellValue(address) @@ -801,7 +977,7 @@ export class DependencyGraph { } } - private* formulaDirectDependenciesToArray(vertex: FormulaVertex, array: ArrayVertex): IterableIterator<[SimpleCellAddress, CellVertex]> { + private* formulaDirectDependenciesToArray(vertex: FormulaVertex, array: ArrayFormulaVertex): IterableIterator<[SimpleCellAddress, CellVertex]> { const [, formulaDependencies] = this.formulaDependencyQuery(vertex) ?? [] if (formulaDependencies === undefined) { return @@ -817,7 +993,7 @@ export class DependencyGraph { } } - private* rangeDirectDependenciesToArray(vertex: RangeVertex, array: ArrayVertex): IterableIterator<[SimpleCellAddress, CellVertex]> { + private* rangeDirectDependenciesToArray(vertex: RangeVertex, array: ArrayFormulaVertex): IterableIterator<[SimpleCellAddress, CellVertex]> { const {restRange: range} = this.rangeMapping.findSmallerRange(vertex.range) for (const address of range.addresses(this)) { if (array.getRange().addressInRange(address)) { @@ -827,7 +1003,7 @@ export class DependencyGraph { } } - private* adjacentArrayVertices(vertex: ArrayVertex): IterableIterator { + private* adjacentArrayVertices(vertex: ArrayFormulaVertex): IterableIterator { const adjacentNodes = this.graph.adjacentNodes(vertex) for (const item of adjacentNodes) { if (item instanceof FormulaVertex || item instanceof RangeVertex) { @@ -840,12 +1016,14 @@ export class DependencyGraph { const allDeps: [(SimpleCellAddress | AbsoluteCellRange), Vertex][] = [] const {smallerRangeVertex, restRange} = this.rangeMapping.findSmallerRange((vertex as RangeVertex).range) //checking whether this range was splitted by bruteForce or not let range + if (smallerRangeVertex !== undefined && this.graph.adjacentNodes(smallerRangeVertex).has(vertex)) { range = restRange allDeps.push([new AbsoluteCellRange(smallerRangeVertex.start, smallerRangeVertex.end), smallerRangeVertex]) } else { //did we ever need to use full range range = (vertex as RangeVertex).range } + for (const address of range.addresses(this)) { const cell = this.addressMapping.getCell(address) if (cell !== undefined) { @@ -890,7 +1068,7 @@ export class DependencyGraph { } while (find.smallerRangeVertex === undefined) { const newRangeVertex = new RangeVertex(AbsoluteCellRange.spanFrom(currentRangeVertex.range.start, currentRangeVertex.range.width(), currentRangeVertex.range.height() - 1)) - this.rangeMapping.setRange(newRangeVertex) + this.rangeMapping.addOrUpdateVertex(newRangeVertex) this.graph.addNodeAndReturnId(newRangeVertex) const restRange = new AbsoluteCellRange(simpleCellAddress(currentRangeVertex.range.start.sheet, currentRangeVertex.range.start.col, currentRangeVertex.range.end.row), currentRangeVertex.range.end) this.addAllFromRange(restRange, currentRangeVertex) @@ -934,13 +1112,13 @@ export class DependencyGraph { const address = vertex.getAddress(this.lazilyTransformingAstService) const range = AbsoluteCellRange.spanFrom(address, vertex.width, vertex.height) const oldNode = this.shrinkPossibleArrayAndGetCell(address) - if (vertex instanceof ArrayVertex) { + if (vertex instanceof ArrayFormulaVertex) { this.setArray(range, vertex) } this.exchangeOrAddGraphNode(oldNode, vertex) this.addressMapping.setCell(address, vertex) - if (vertex instanceof ArrayVertex) { + if (vertex instanceof ArrayFormulaVertex) { if (!this.isThereSpaceForArray(vertex)) { return } @@ -961,7 +1139,7 @@ export class DependencyGraph { private setAddressMappingForArrayVertex(vertex: CellVertex, formulaAddress: SimpleCellAddress): void { this.addressMapping.setCell(formulaAddress, vertex) - if (!(vertex instanceof ArrayVertex)) { + if (!(vertex instanceof ArrayFormulaVertex)) { return } @@ -987,7 +1165,8 @@ export class DependencyGraph { verticesWithChangedSize } = this.rangeMapping.truncateRanges(span, coordinate) for (const [existingVertex, mergedVertex] of verticesToMerge) { - this.mergeRangeVertices(existingVertex, mergedVertex) + this.rerouteDependents(mergedVertex, existingVertex) + this.removeVertexAndCleanupDependencies(mergedVertex) } for (const rangeVertex of verticesToRemove) { this.removeVertexAndCleanupDependencies(rangeVertex) @@ -1067,7 +1246,7 @@ export class DependencyGraph { private shrinkPossibleArrayAndGetCell(address: SimpleCellAddress): Maybe { const vertex = this.getCell(address) - if (!(vertex instanceof ArrayVertex)) { + if (!(vertex instanceof ArrayFormulaVertex)) { return vertex } this.setNoSpaceIfArray(vertex) @@ -1075,35 +1254,64 @@ export class DependencyGraph { } private setNoSpaceIfArray(vertex: Maybe) { - if (vertex instanceof ArrayVertex) { + if (vertex instanceof ArrayFormulaVertex) { this.shrinkArrayToCorner(vertex) vertex.setNoSpace() } } + /** + * Removes a vertex from the graph and range mapping and cleans up its dependencies. + */ private removeVertex(vertex: Vertex) { this.removeVertexAndCleanupDependencies(vertex) if (vertex instanceof RangeVertex) { - this.rangeMapping.removeRange(vertex) + this.rangeMapping.removeVertexIfExists(vertex) } } - private mergeRangeVertices(existingVertex: RangeVertex, newVertex: RangeVertex) { - const adjNodesStored = this.graph.adjacentNodes(newVertex) + /** + * Reroutes dependent vertices of source to target. Also removes the edge target -> source if it exists. + */ + private rerouteDependents(source: Vertex, target: Vertex) { + const dependents = this.graph.adjacentNodes(source) + this.graph.removeEdgeIfExists(target, source) - this.removeVertexAndCleanupDependencies(newVertex) - this.graph.removeEdgeIfExists(existingVertex, newVertex) - adjNodesStored.forEach((adjacentNode) => { + dependents.forEach((adjacentNode) => { if (this.graph.hasNode(adjacentNode)) { - this.graph.addEdge(existingVertex, adjacentNode) + this.graph.addEdge(target, adjacentNode) + } + }) + + } + + /** + * Removes a vertex from graph and reroutes its dependencies to other vertex. Also removes the edge vertexToKeep -> vertexToDelete if it exists. + */ + private removeVertexAndRerouteDependencies(vertexToDelete: Vertex, vertexToKeep: Vertex) { + const dependencies = this.graph.removeNode(vertexToDelete) + + this.graph.removeEdgeIfExists(vertexToKeep, vertexToDelete) + + dependencies.forEach(([_, dependency]) => { + if (this.graph.hasNode(dependency)) { + this.graph.addEdge(dependency, vertexToKeep) } }) } + /** + * Removes a vertex from graph and cleans up its dependencies. + * Dependency clean up = remove all RangeVertex and EmptyCellVertex dependencies if no other vertex depends on them. + * Also cleans up placeholder sheets that have no remaining vertices (not needed anymore) + */ private removeVertexAndCleanupDependencies(inputVertex: Vertex) { const dependencies = new Set(this.graph.removeNode(inputVertex)) + const affectedSheets = new Set() + while (dependencies.size > 0) { - const dependency = dependencies.values().next().value + const dependency = dependencies.values().next().value as [SimpleCellAddress | SimpleCellRange, Vertex] + dependencies.delete(dependency) const [address, vertex] = dependency if (this.graph.hasNode(vertex) && this.graph.adjacentNodesCount(vertex) === 0) { @@ -1111,17 +1319,35 @@ export class DependencyGraph { this.graph.removeNode(vertex).forEach((candidate) => dependencies.add(candidate)) } if (vertex instanceof RangeVertex) { - this.rangeMapping.removeRange(vertex) - } else if (vertex instanceof EmptyCellVertex) { + this.rangeMapping.removeVertexIfExists(vertex) + affectedSheets.add(vertex.sheet) + } else if (vertex instanceof EmptyCellVertex && isSimpleCellAddress(address)) { this.addressMapping.removeCell(address) + affectedSheets.add(address.sheet) } } } + + this.cleanupPlaceholderSheets(affectedSheets) + } + + /** + * Removes placeholder sheets that have no remaining vertices. + */ + private cleanupPlaceholderSheets(sheetIds: Set): void { + for (const sheetId of sheetIds) { + if (this.isPlaceholder(sheetId) && + !this.addressMapping.hasAnyEntries(sheetId) && + this.rangeMapping.getNumberOfRangesInSheet(sheetId) === 0) { + this.sheetMapping.removeSheetIfExists(sheetId, { includePlaceholders: true }) + this.addressMapping.removeSheetIfExists(sheetId) + } + } } } export interface ArrayAffectingGraphChangeResult { - affectedArrays: Set, + affectedArrays: Set, } export interface EagerChangesGraphChangeResult extends ArrayAffectingGraphChangeResult { diff --git a/src/DependencyGraph/FormulaCellVertex.ts b/src/DependencyGraph/FormulaVertex.ts similarity index 96% rename from src/DependencyGraph/FormulaCellVertex.ts rename to src/DependencyGraph/FormulaVertex.ts index 2656e1238..cf1c02161 100644 --- a/src/DependencyGraph/FormulaCellVertex.ts +++ b/src/DependencyGraph/FormulaVertex.ts @@ -33,9 +33,9 @@ export abstract class FormulaVertex { static fromAst(formula: Ast, address: SimpleCellAddress, size: ArraySize, version: number) { if (size.isScalar()) { - return new FormulaCellVertex(formula, address, version) + return new ScalarFormulaVertex(formula, address, version) } else { - return new ArrayVertex(formula, address, size, version) + return new ArrayFormulaVertex(formula, address, size, version) } } @@ -79,7 +79,7 @@ export abstract class FormulaVertex { public abstract isComputed(): boolean } -export class ArrayVertex extends FormulaVertex { +export class ArrayFormulaVertex extends FormulaVertex { array: CellArray constructor(formula: Ast, cellAddress: SimpleCellAddress, size: ArraySize, version: number = 0) { @@ -223,7 +223,7 @@ export class ArrayVertex extends FormulaVertex { /** * Represents vertex which keeps formula */ -export class FormulaCellVertex extends FormulaVertex { +export class ScalarFormulaVertex extends FormulaVertex { /** Most recently computed value of this formula. */ private cachedCellValue?: InterpreterValue diff --git a/src/DependencyGraph/Graph.ts b/src/DependencyGraph/Graph.ts index 9a0fc2162..60c5f3487 100644 --- a/src/DependencyGraph/Graph.ts +++ b/src/DependencyGraph/Graph.ts @@ -414,6 +414,6 @@ export class Graph { * Returns error for missing node. */ private missingNodeError(node: Node): Error { - return new Error(`Unknown node ${node}`) + return new Error(`Unknown node ${JSON.stringify(node)}`) } } diff --git a/src/DependencyGraph/RangeMapping.ts b/src/DependencyGraph/RangeMapping.ts index 9666ad021..df5d4b9be 100644 --- a/src/DependencyGraph/RangeMapping.ts +++ b/src/DependencyGraph/RangeMapping.ts @@ -19,39 +19,56 @@ export interface TruncateRangesResult extends AdjustRangesResult { verticesWithChangedSize: RangeVertex[], } +type AdjustVerticesOperationResult = { + changedSize: boolean, + vertex: RangeVertex, +} + +type AdjustVerticesOperation = (key: string, vertex: RangeVertex) => Maybe + /** - * Mapping from address ranges to range vertices + * Maintains a per-sheet map from serialized start/end coordinates to `RangeVertex`. + * - Every range vertex in dependency graph should be stored in this mapping. + * - Guarantees uniqueness: one vertex per distinct rectangle, enabling cache reuse. + * - Implements "smaller prefix + tail row" optimization: if A1:A4 exists, A1:A5 depends on it + only A5. + * - RangeVertex stores cached results for associative aggregates (SUM, COUNT) and criterion functions. */ export class RangeMapping { - /** Map in which actual data is stored. */ - private rangeMapping: Map> = new Map() + /** + * Map sheetId -> address of start and end (as string) -> vertex + */ + private rangeMapping: Map> = new Map() - public getMappingSize(sheet: number): Maybe { + /** + * Returns number of ranges in the sheet or 0 if the sheet does not exist + */ + public getNumberOfRangesInSheet(sheet: number): number { return this.rangeMapping.get(sheet)?.size ?? 0 } /** - * Saves range vertex - * - * @param vertex - vertex to save + * Adds or updates vertex in the mapping */ - public setRange(vertex: RangeVertex) { - let sheetMap = this.rangeMapping.get(vertex.getStart().sheet) + public addOrUpdateVertex(vertex: RangeVertex): void { + let sheetMap = this.rangeMapping.get(vertex.sheet) if (sheetMap === undefined) { sheetMap = new Map() - this.rangeMapping.set(vertex.getStart().sheet, sheetMap) + this.rangeMapping.set(vertex.sheet, sheetMap) } - const key = keyFromAddresses(vertex.getStart(), vertex.getEnd()) + const key = RangeMapping.calculateRangeKey(vertex.start, vertex.end) sheetMap.set(key, vertex) } - public removeRange(vertex: RangeVertex) { - const sheet = vertex.getStart().sheet + /** + * Removes vertex from the mapping if it exists + */ + public removeVertexIfExists(vertex: RangeVertex): void { + const sheet = vertex.sheet const sheetMap = this.rangeMapping.get(sheet) if (sheetMap === undefined) { return } - const key = keyFromAddresses(vertex.getStart(), vertex.getEnd()) + const key = RangeMapping.calculateRangeKey(vertex.start, vertex.end) sheetMap.delete(key) if (sheetMap.size === 0) { this.rangeMapping.delete(sheet) @@ -60,18 +77,18 @@ export class RangeMapping { /** * Returns associated vertex for given range - * - * @param start - top-left corner of the range - * @param end - bottom-right corner of the range */ - public getRange(start: SimpleCellAddress, end: SimpleCellAddress): Maybe { + public getRangeVertex(start: SimpleCellAddress, end: SimpleCellAddress): Maybe { const sheetMap = this.rangeMapping.get(start.sheet) - const key = keyFromAddresses(start, end) + const key = RangeMapping.calculateRangeKey(start, end) return sheetMap?.get(key) } - public fetchRange(start: SimpleCellAddress, end: SimpleCellAddress): RangeVertex { - const maybeRange = this.getRange(start, end) + /** + * Returns associated vertex for given range or throws an error if not found + */ + public getVertexOrThrow(start: SimpleCellAddress, end: SimpleCellAddress): RangeVertex { + const maybeRange = this.getRangeVertex(start, end) if (!maybeRange) { throw Error('Range does not exist') } @@ -99,9 +116,9 @@ export class RangeMapping { } const verticesToMerge: [RangeVertex, RangeVertex][] = [] - updated.sort((left, right) => compareBy(left[1], right[1], coordinate)) + updated.sort((left, right) => RangeMapping.compareBy(left[1], right[1], coordinate)) for (const [oldKey, vertex] of updated) { - const newKey = keyFromRange(vertex.range) + const newKey = RangeMapping.calculateRangeKey(vertex.range.start, vertex.range.end) if (newKey === oldKey) { continue } @@ -111,7 +128,7 @@ export class RangeMapping { if (existingVertex !== undefined && vertex != existingVertex) { verticesToMerge.push([existingVertex, vertex]) } else { - this.setRange(vertex) + this.addOrUpdateVertex(vertex) } } @@ -122,7 +139,7 @@ export class RangeMapping { } } - public moveAllRangesInSheetAfterRowByRows(sheet: number, row: number, numberOfRows: number): AdjustRangesResult { + public moveAllRangesInSheetAfterAddingRows(sheet: number, row: number, numberOfRows: number): AdjustRangesResult { return this.updateVerticesFromSheet(sheet, (key: string, vertex: RangeVertex) => { if (row <= vertex.start.row) { vertex.range.shiftByRows(numberOfRows) @@ -142,7 +159,7 @@ export class RangeMapping { }) } - public moveAllRangesInSheetAfterColumnByColumns(sheet: number, column: number, numberOfColumns: number): AdjustRangesResult { + public moveAllRangesInSheetAfterAddingColumns(sheet: number, column: number, numberOfColumns: number): AdjustRangesResult { return this.updateVerticesFromSheet(sheet, (key: string, vertex: RangeVertex) => { if (column <= vertex.start.col) { vertex.range.shiftByColumns(numberOfColumns) @@ -178,15 +195,6 @@ export class RangeMapping { }) } - public removeRangesInSheet(sheet: number): IterableIterator { - if (this.rangeMapping.has(sheet)) { - const ranges = this.rangeMapping.get(sheet)!.values() - this.rangeMapping.delete(sheet) - return ranges - } - return [][Symbol.iterator]() - } - public* rangesInSheet(sheet: number): IterableIterator { const sheetMap = this.rangeMapping.get(sheet) if (!sheetMap) { @@ -204,14 +212,12 @@ export class RangeMapping { } /** - * Finds smaller range does have own vertex. - * - * @param range + * Finds smaller range if exists. */ public findSmallerRange(range: AbsoluteCellRange): { smallerRangeVertex?: RangeVertex, restRange: AbsoluteCellRange } { if (range.height() > 1 && Number.isFinite(range.height())) { const valuesRangeEndRowLess = simpleCellAddress(range.end.sheet, range.end.col, range.end.row - 1) - const rowLessVertex = this.getRange(range.start, valuesRangeEndRowLess) + const rowLessVertex = this.getRangeVertex(range.start, valuesRangeEndRowLess) if (rowLessVertex !== undefined) { const restRange = AbsoluteCellRange.fromSimpleCellAddresses(simpleCellAddress(range.start.sheet, range.start.col, range.end.row), range.end) return { @@ -225,6 +231,28 @@ export class RangeMapping { } } + /** + * Calculates a string key from start and end addresses + */ + private static calculateRangeKey(start: SimpleCellAddress, end: SimpleCellAddress): string { + return `${start.col},${start.row},${end.col},${end.row}` + } + + /** + * Compares two range vertices by their start and end addresses using the provided coordinate function + */ + private static compareBy(left: RangeVertex, right: RangeVertex, coordinate: (address: SimpleCellAddress) => number): number { + const leftStart = coordinate(left.range.start) + const rightStart = coordinate(right.range.start) + if (leftStart === rightStart) { + const leftEnd = coordinate(left.range.end) + const rightEnd = coordinate(right.range.end) + return leftEnd - rightEnd + } else { + return leftStart - rightStart + } + } + private* entriesFromSheet(sheet: number): IterableIterator<[string, RangeVertex]> { const sheetMap = this.rangeMapping.get(sheet) if (!sheetMap) { @@ -233,8 +261,13 @@ export class RangeMapping { yield* sheetMap.entries() } - private removeByKey(sheet: number, key: string) { - this.rangeMapping.get(sheet)!.delete(key) + private removeByKey(sheet: number, key: string): void { + const sheetMap = this.rangeMapping.get(sheet) + + if (!sheetMap) { + throw new Error(`Sheet ${sheet} not found`) + } + sheetMap.delete(key) } private getByKey(sheet: number, key: string): RangeVertex | undefined { @@ -242,7 +275,7 @@ export class RangeMapping { } private updateVerticesFromSheet(sheet: number, fn: AdjustVerticesOperation): AdjustRangesResult { - const updated = Array() + const updated = Array() for (const [key, vertex] of this.entriesFromSheet(sheet)) { const result = fn(key, vertex) @@ -253,7 +286,7 @@ export class RangeMapping { } updated.forEach(entry => { - this.setRange(entry.vertex) + this.addOrUpdateVertex(entry.vertex) }) return { @@ -263,30 +296,3 @@ export class RangeMapping { } } } - -type AdjustVeticesOperationResult = { - changedSize: boolean, - vertex: RangeVertex, -} - -type AdjustVerticesOperation = (key: string, vertex: RangeVertex) => Maybe - -function keyFromAddresses(start: SimpleCellAddress, end: SimpleCellAddress): string { - return `${start.col},${start.row},${end.col},${end.row}` -} - -function keyFromRange(range: AbsoluteCellRange): string { - return keyFromAddresses(range.start, range.end) -} - -const compareBy = (left: RangeVertex, right: RangeVertex, coordinate: (address: SimpleCellAddress) => number) => { - const leftStart = coordinate(left.range.start) - const rightStart = coordinate(left.range.start) - if (leftStart === rightStart) { - const leftEnd = coordinate(left.range.end) - const rightEnd = coordinate(right.range.end) - return leftEnd - rightEnd - } else { - return leftStart - rightStart - } -} diff --git a/src/DependencyGraph/RangeVertex.ts b/src/DependencyGraph/RangeVertex.ts index e92213dcd..b64e1187d 100644 --- a/src/DependencyGraph/RangeVertex.ts +++ b/src/DependencyGraph/RangeVertex.ts @@ -3,8 +3,8 @@ * Copyright (c) 2025 Handsoncode. All rights reserved. */ +import { SimpleCellAddress } from '..' import {AbsoluteCellRange} from '../AbsoluteCellRange' -import {SimpleCellAddress} from '../Cell' import {CriterionLambda} from '../interpreter/Criterion' /** @@ -30,15 +30,15 @@ export class RangeVertex { this.bruteForce = false } - public get start() { + public get start(): SimpleCellAddress { return this.range.start } - public get end() { + public get end(): SimpleCellAddress { return this.range.end } - public get sheet() { + public get sheet(): number { return this.range.start.sheet } @@ -105,18 +105,4 @@ export class RangeVertex { this.dependentCacheRanges.forEach(range => range.criterionFunctionCache.clear()) this.dependentCacheRanges.clear() } - - /** - * Returns start of the range (it's top-left corner) - */ - public getStart(): SimpleCellAddress { - return this.start - } - - /** - * Returns end of the range (it's bottom-right corner) - */ - public getEnd(): SimpleCellAddress { - return this.end - } } diff --git a/src/DependencyGraph/SheetMapping.ts b/src/DependencyGraph/SheetMapping.ts index 703007116..836479a5c 100644 --- a/src/DependencyGraph/SheetMapping.ts +++ b/src/DependencyGraph/SheetMapping.ts @@ -7,126 +7,371 @@ import {NoSheetWithIdError, NoSheetWithNameError, SheetNameAlreadyTakenError} fr import {TranslationPackage, UIElement} from '../i18n' import {Maybe} from '../Maybe' -function canonicalize(sheetDisplayName: string): string { - return sheetDisplayName.toLowerCase() +/** + * Options for querying the sheet mapping. + */ +export interface SheetMappingQueryOptions { + includePlaceholders?: boolean, } +/** + * Representation of a sheet internal to SheetMapping. Not exported outside of this file. + */ class Sheet { constructor( public readonly id: number, public displayName: string, - ) { - } + public isPlaceholder: boolean = false, + ) {} - public get canonicalName() { - return canonicalize(this.displayName) + /** + * Returns the canonical (normalized) name of the sheet. + */ + public get canonicalName(): string { + return SheetMapping.canonicalizeSheetName(this.displayName) } } +/** + * Manages the sheets in the instance. + * - Can convert between sheet names and ids and vice versa. + * - Also stores placeholders for sheets that are used in formulas but not yet added. They are marked as isPlaceholder=true. + * - Sheetnames thet differ only in case are considered the same. (See: canonicalizeSheetName) + */ export class SheetMapping { - private readonly mappingFromCanonicalName: Map = new Map() - private readonly mappingFromId: Map = new Map() + /** + * Prefix for new sheet names if no name is provided by the user + */ private readonly sheetNamePrefix: string + /** + * Last used sheet ID. Used to generate new sheet IDs. + */ private lastSheetId = -1 + /** + * Mapping from canonical sheet name to sheet ID. + */ + private mappingFromCanonicalNameToId: Map = new Map() + /** + * Mapping from sheet ID to sheet. + */ + private allSheets: Map = new Map() - constructor(private languages: TranslationPackage) { + constructor(languages: TranslationPackage) { this.sheetNamePrefix = languages.getUITranslation(UIElement.NEW_SHEET_PREFIX) } + /** + * Converts sheet name to canonical/normalized form. + * @static + */ + public static canonicalizeSheetName(sheetDisplayName: string): string { + return sheetDisplayName.toLowerCase() + } + + /** + * Returns sheet ID for the given name. By default excludes placeholders. + */ + public getSheetId(sheetName: string, options: SheetMappingQueryOptions = {}): Maybe { + return this._getSheetByName(sheetName, options)?.id + } + + /** + * Returns sheet ID for the given name. Excludes placeholders. + * + * @throws {NoSheetWithNameError} if the sheet with the given name does not exist. + */ + public getSheetIdOrThrowError(sheetName: string): number { + const sheet = this._getSheetByName(sheetName, {}) + + if (sheet === undefined) { + throw new NoSheetWithNameError(sheetName) + } + return sheet.id + } + + /** + * Returns display name for the given sheet ID. Excludes placeholders. + * + * @returns {Maybe} the display name, or undefined if the sheet with the given ID does not exist. + */ + public getSheetName(sheetId: number): Maybe { + return this._getSheet(sheetId, {})?.displayName + } + + /** + * Returns display name for the given sheet ID. Excludes placeholders. + * + * @throws {NoSheetWithIdError} if the sheet with the given ID does not exist. + */ + public getSheetNameOrThrowError(sheetId: number, options: SheetMappingQueryOptions = {}): string { + return this._getSheetOrThrowError(sheetId, options).displayName + } + + /** + * Iterates over all sheet display names. By default excludes placeholders. + */ + public* iterateSheetNames(options: SheetMappingQueryOptions = {}): IterableIterator { + for (const sheet of this.allSheets.values()) { + if (options.includePlaceholders || !sheet.isPlaceholder) { + yield sheet.displayName + } + } + } + + /** + * Returns array of all sheet display names. By default excludes placeholders. + */ + public getSheetNames(options: SheetMappingQueryOptions = {}): string[] { + return Array.from(this.iterateSheetNames(options)) + } + + /** + * Returns total count of sheets. By default excludes placeholders. + */ + public numberOfSheets(options: SheetMappingQueryOptions = {}): number { + return this.getSheetNames(options).length + } + + /** + * Checks if sheet with given ID exists. By default excludes placeholders. + */ + public hasSheetWithId(sheetId: number, options: SheetMappingQueryOptions = {}): boolean { + return this._getSheet(sheetId, options) !== undefined + } + + /** + * Checks if sheet with given name exists (case-insensitive). Excludes placeholders. + */ + public hasSheetWithName(sheetName: string): boolean { + return this._getSheetByName(sheetName, {}) !== undefined + } + + /** + * Adds new sheet with optional name and returns its ID. + * If called with a name of an existing placeholder sheet, converts the placeholder sheet to a real sheet. + * + * @throws {SheetNameAlreadyTakenError} if the sheet with the given name already exists. + */ public addSheet(newSheetDisplayName: string = `${this.sheetNamePrefix}${this.lastSheetId + 2}`): number { - const newSheetCanonicalName = canonicalize(newSheetDisplayName) - if (this.mappingFromCanonicalName.has(newSheetCanonicalName)) { - throw new SheetNameAlreadyTakenError(newSheetDisplayName) + const sheetWithConflictingName = this._getSheetByName(newSheetDisplayName, { includePlaceholders: true }) + + if (sheetWithConflictingName) { + if (!sheetWithConflictingName.isPlaceholder) { + throw new SheetNameAlreadyTakenError(newSheetDisplayName) + } + + sheetWithConflictingName.isPlaceholder = false + return sheetWithConflictingName.id } this.lastSheetId++ const sheet = new Sheet(this.lastSheetId, newSheetDisplayName) - this.store(sheet) + this._storeSheetInMappings(sheet) return sheet.id } - public removeSheet(sheetId: number) { - const sheet = this.fetchSheetById(sheetId) - if (sheetId == this.lastSheetId) { - --this.lastSheetId + /** + * Adds a sheet with a specific ID and name. Used for redo operations. + * If called with a name of an existing placeholder sheet, converts the placeholder sheet to a real sheet. + * + * @throws {SheetNameAlreadyTakenError} if the sheet with the given name already exists. + */ + public addSheetWithId(sheetId: number, sheetDisplayName: string): void { + const sheetWithConflictingName = this._getSheetByName(sheetDisplayName, { includePlaceholders: true }) + + if (sheetWithConflictingName) { + if (sheetWithConflictingName.id !== sheetId) { + throw new SheetNameAlreadyTakenError(sheetDisplayName) + } + + if (!sheetWithConflictingName.isPlaceholder) { + throw new SheetNameAlreadyTakenError(sheetDisplayName) + } + + sheetWithConflictingName.isPlaceholder = false + return + } + + if (sheetId > this.lastSheetId) { + this.lastSheetId = sheetId } - this.mappingFromCanonicalName.delete(sheet.canonicalName) - this.mappingFromId.delete(sheet.id) + + const sheet = new Sheet(sheetId, sheetDisplayName) + this._storeSheetInMappings(sheet) } - public fetch = (sheetName: string): number => { - const sheet = this.mappingFromCanonicalName.get(canonicalize(sheetName)) - if (sheet === undefined) { - throw new NoSheetWithNameError(sheetName) + /** + * Adds a placeholder sheet with the given name if it does not exist yet + */ + public addPlaceholderIfNotExists(sheetName: string): number { + const sheetWithConflictingName = this._getSheetByName(sheetName, { includePlaceholders: true }) + + if (sheetWithConflictingName) { + return sheetWithConflictingName.id } + + this.lastSheetId++ + const sheet = new Sheet(this.lastSheetId, sheetName, true) + this._storeSheetInMappings(sheet) return sheet.id } - public get = (sheetName: string): Maybe => { - return this.mappingFromCanonicalName.get(canonicalize(sheetName))?.id - } + /** + * Adds a placeholder sheet with a specific ID and name. + * Used for undo operations to restore previously merged placeholder sheets. + * + * @throws {SheetNameAlreadyTakenError} if the sheet with the given name already exists. + */ + public addPlaceholderWithId(sheetId: number, sheetDisplayName: string): void { + const sheetWithConflictingName = this._getSheetByName(sheetDisplayName, { includePlaceholders: true }) - public fetchDisplayName = (sheetId: number): string => { - return this.fetchSheetById(sheetId).displayName - } + if (sheetWithConflictingName) { + throw new SheetNameAlreadyTakenError(sheetDisplayName) + } - public getDisplayName(sheetId: number): Maybe { - return this.mappingFromId.get(sheetId)?.displayName - } + if (this.hasSheetWithId(sheetId, { includePlaceholders: true })) { + throw new Error(`Sheet with id ${sheetId} already exists`) + } - public* displayNames(): IterableIterator { - for (const sheet of this.mappingFromCanonicalName.values()) { - yield sheet.displayName + if (sheetId > this.lastSheetId) { + this.lastSheetId = sheetId } + const sheet = new Sheet(sheetId, sheetDisplayName, true) + this._storeSheetInMappings(sheet) } - public numberOfSheets(): number { - return this.mappingFromCanonicalName.size - } + /** + * + * Removes sheet with given ID. + * If sheet does not exist, does nothing. + * @returns {boolean} true if sheet was removed, false if it did not exist. + */ + public removeSheetIfExists(sheetId: number, options: SheetMappingQueryOptions = {}): boolean { + const sheet = this._getSheet(sheetId, options) - public hasSheetWithId(sheetId: number): boolean { - return this.mappingFromId.has(sheetId) + if (!sheet) { + return false + } + + this.allSheets.delete(sheetId) + this.mappingFromCanonicalNameToId.delete(sheet.canonicalName) + + if (sheetId === this.lastSheetId) { + this.lastSheetId-- + } + + return true } - public hasSheetWithName(sheetName: string): boolean { - return this.mappingFromCanonicalName.has(canonicalize(sheetName)) + /** + * Marks sheet with given ID as a placeholder. + * @throws {NoSheetWithIdError} if the sheet with the given ID does not exist + */ + public markSheetAsPlaceholder(sheetId: number): void { + const sheet = this._getSheetOrThrowError(sheetId, {}) + sheet.isPlaceholder = true } - public renameSheet(sheetId: number, newDisplayName: string): Maybe { - const sheet = this.fetchSheetById(sheetId) + /** + * Renames sheet. + * - If called with sheetId of a placeholder sheet, throws {NoSheetWithIdError}. + * - If newDisplayName is conflicting with an existing sheet, throws {SheetNameAlreadyTakenError}. + * - If newDisplayName is conflicting with a placeholder sheet name, deletes the placeholder sheet and returns its id as mergedWithPlaceholderSheet. + * + * @throws {SheetNameAlreadyTakenError} if the sheet with the given name already exists. + * @throws {NoSheetWithIdError} if the sheet with the given ID does not exist. + */ + public renameSheet(sheetId: number, newDisplayName: string): { previousDisplayName: Maybe, mergedWithPlaceholderSheet?: number } { + const sheet = this._getSheetOrThrowError(sheetId, {}) const currentDisplayName = sheet.displayName if (currentDisplayName === newDisplayName) { - return undefined + return { previousDisplayName: undefined } } - const sheetWithThisCanonicalName = this.mappingFromCanonicalName.get(canonicalize(newDisplayName)) - if (sheetWithThisCanonicalName !== undefined && sheetWithThisCanonicalName.id !== sheet.id) { - throw new SheetNameAlreadyTakenError(newDisplayName) + const sheetWithConflictingName = this._getSheetByName(newDisplayName, { includePlaceholders: true }) + let mergedWithPlaceholderSheet: number | undefined = undefined + + if (sheetWithConflictingName !== undefined && sheetWithConflictingName.id !== sheet.id) { + if (!sheetWithConflictingName.isPlaceholder) { + throw new SheetNameAlreadyTakenError(newDisplayName) + } else { + this.mappingFromCanonicalNameToId.delete(sheetWithConflictingName.canonicalName) + this.allSheets.delete(sheetWithConflictingName.id) + + if (sheetWithConflictingName.id === this.lastSheetId) { + this.lastSheetId-- + } + + mergedWithPlaceholderSheet = sheetWithConflictingName.id + } } const currentCanonicalName = sheet.canonicalName - this.mappingFromCanonicalName.delete(currentCanonicalName) + this.mappingFromCanonicalNameToId.delete(currentCanonicalName) sheet.displayName = newDisplayName - this.store(sheet) - return currentDisplayName + this._storeSheetInMappings(sheet) + return { previousDisplayName: currentDisplayName, mergedWithPlaceholderSheet } + } + + /** + * Stores sheet in both internal mappings. + * - If ID exists, it is updated. If not, it is added. + * - If canonical name exists, it is updated. If not, it is added. + * + * @internal + */ + private _storeSheetInMappings(sheet: Sheet): void { + this.allSheets.set(sheet.id, sheet) + this.mappingFromCanonicalNameToId.set(sheet.canonicalName, sheet.id) } - public sheetNames(): string[] { - return Array.from(this.mappingFromId.values()).map((s) => s.displayName) + /** + * Returns sheet by ID + * + * @returns {Maybe} the sheet, or undefined if not found. + * @internal + */ + private _getSheet(sheetId: number, options: SheetMappingQueryOptions): Maybe { + const retrievedSheet = this.allSheets.get(sheetId) + + if (retrievedSheet === undefined) { + return undefined + } + + return (options.includePlaceholders || !retrievedSheet.isPlaceholder) ? retrievedSheet : undefined } - private store(sheet: Sheet): void { - this.mappingFromId.set(sheet.id, sheet) - this.mappingFromCanonicalName.set(sheet.canonicalName, sheet) + /** + * Returns sheet by name + * + * @returns {Maybe} the sheet, or undefined if not found. + * @internal + */ + private _getSheetByName(sheetName: string, options: SheetMappingQueryOptions): Maybe { + const sheetId = this.mappingFromCanonicalNameToId.get(SheetMapping.canonicalizeSheetName(sheetName)) + + if (sheetId === undefined) { + return undefined + } + + return this._getSheet(sheetId, options) } - private fetchSheetById(sheetId: number): Sheet { - const sheet = this.mappingFromId.get(sheetId) + /** + * Returns sheet by ID + * + * @throws {NoSheetWithIdError} if the sheet with the given ID does not exist. + * @internal + */ + private _getSheetOrThrowError(sheetId: number, options: SheetMappingQueryOptions): Sheet { + const sheet = this._getSheet(sheetId, options) + if (sheet === undefined) { throw new NoSheetWithIdError(sheetId) } + return sheet } } diff --git a/src/DependencyGraph/SheetReferenceRegistrar.ts b/src/DependencyGraph/SheetReferenceRegistrar.ts new file mode 100644 index 000000000..7c2cfa123 --- /dev/null +++ b/src/DependencyGraph/SheetReferenceRegistrar.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +import {AddressMapping} from './AddressMapping/AddressMapping' +import {SheetMapping} from './SheetMapping' + +/** + * Converts sheet names to ids and adds placeholder sheets if they don't exist. + */ +export class SheetReferenceRegistrar { + constructor( + private readonly sheetMapping: SheetMapping, + private readonly addressMapping: AddressMapping, + ) {} + + /** + * Adds placeholder sheet if it doesn't exist and adds placeholder strategy to address mapping. + * @returns {number} sheet id + */ + public ensureSheetRegistered(sheetName: string): number { + const sheetId = this.sheetMapping.addPlaceholderIfNotExists(sheetName) + this.addressMapping.addSheetStrategyPlaceholderIfNotExists(sheetId) + return sheetId + } +} diff --git a/src/DependencyGraph/Vertex.ts b/src/DependencyGraph/Vertex.ts index 1c9baa6d2..4b8ae0d42 100644 --- a/src/DependencyGraph/Vertex.ts +++ b/src/DependencyGraph/Vertex.ts @@ -4,7 +4,7 @@ */ import {EmptyCellVertex, ParsingErrorVertex, RangeVertex, ValueCellVertex} from './' -import {FormulaVertex} from './FormulaCellVertex' +import {FormulaVertex} from './FormulaVertex' /** * Represents vertex which keeps values of one or more cells diff --git a/src/DependencyGraph/collectAddressesDependentToRange.ts b/src/DependencyGraph/collectAddressesDependentToRange.ts index d75d078cb..efa77d5dd 100644 --- a/src/DependencyGraph/collectAddressesDependentToRange.ts +++ b/src/DependencyGraph/collectAddressesDependentToRange.ts @@ -9,7 +9,7 @@ import {FunctionRegistry} from '../interpreter/FunctionRegistry' import {LazilyTransformingAstService} from '../LazilyTransformingAstService' import {AddressDependency, Ast, collectDependencies} from '../parser' import {DependencyGraph} from './DependencyGraph' -import {FormulaVertex} from './FormulaCellVertex' +import {FormulaVertex} from './FormulaVertex' import {RangeVertex} from './RangeVertex' import {Vertex} from './Vertex' diff --git a/src/DependencyGraph/index.ts b/src/DependencyGraph/index.ts index 05e4a2f86..9b5a1d003 100644 --- a/src/DependencyGraph/index.ts +++ b/src/DependencyGraph/index.ts @@ -9,13 +9,14 @@ export {Graph} from './Graph' export {TopSort} from './TopSort' export {RangeMapping} from './RangeMapping' export {SheetMapping} from './SheetMapping' +export {SheetReferenceRegistrar} from './SheetReferenceRegistrar' export {ArrayMapping} from './ArrayMapping' export {CellVertex, Vertex} from './Vertex' -export {FormulaCellVertex} from './FormulaCellVertex' +export {ScalarFormulaVertex} from './FormulaVertex' export {EmptyCellVertex} from './EmptyCellVertex' export {ValueCellVertex} from './ValueCellVertex' export {ParsingErrorVertex} from './ParsingErrorVertex' export {RangeVertex, CriterionCache} from './RangeVertex' export {SparseStrategy} from './AddressMapping/SparseStrategy' export {DenseStrategy} from './AddressMapping/DenseStrategy' -export {ArrayVertex} from './FormulaCellVertex' +export {ArrayFormulaVertex} from './FormulaVertex' diff --git a/src/Evaluator.ts b/src/Evaluator.ts index f47272b9b..f810bee36 100644 --- a/src/Evaluator.ts +++ b/src/Evaluator.ts @@ -8,8 +8,8 @@ import {absolutizeDependencies} from './absolutizeDependencies' import {CellError, ErrorType, SimpleCellAddress} from './Cell' import {Config} from './Config' import {ContentChanges} from './ContentChanges' -import {ArrayVertex, DependencyGraph, RangeVertex, Vertex} from './DependencyGraph' -import {FormulaVertex} from './DependencyGraph/FormulaCellVertex' +import {ArrayFormulaVertex, DependencyGraph, RangeVertex, Vertex} from './DependencyGraph' +import {FormulaVertex} from './DependencyGraph/FormulaVertex' import {Interpreter} from './interpreter/Interpreter' import {InterpreterState} from './interpreter/InterpreterState' import {EmptyValue, getRawValue, InterpreterValue} from './interpreter/InterpreterValue' @@ -46,35 +46,8 @@ export class Evaluator { this.stats.measure(StatType.EVALUATION, () => { this.dependencyGraph.graph.getTopSortedWithSccSubgraphFrom(vertices, - (vertex: Vertex) => { - if (vertex instanceof FormulaVertex) { - const currentValue = vertex.isComputed() ? vertex.getCellValue() : undefined - const newCellValue = this.recomputeFormulaVertexValue(vertex) - if (newCellValue !== currentValue) { - const address = vertex.getAddress(this.lazilyTransformingAstService) - changes.addChange(newCellValue, address) - this.columnSearch.change(getRawValue(currentValue), getRawValue(newCellValue), address) - return true - } - return false - } else if (vertex instanceof RangeVertex) { - vertex.clearCache() - return true - } else { - return true - } - }, - (vertex: Vertex) => { - if (vertex instanceof RangeVertex) { - vertex.clearCache() - } else if (vertex instanceof FormulaVertex) { - const address = vertex.getAddress(this.lazilyTransformingAstService) - this.columnSearch.remove(getRawValue(vertex.valueOrUndef()), address) - const error = new CellError(ErrorType.CYCLE, undefined, vertex) - vertex.setCellValue(error) - changes.addChange(error, address) - } - }, + (vertex: Vertex) => this.recomputeVertex(vertex, changes), + (vertex: Vertex) => this.processVertexOnCycle(vertex, changes), ) }) return changes @@ -87,7 +60,7 @@ export class Evaluator { const range = dep if (this.dependencyGraph.getRange(range.start, range.end) === undefined) { const rangeVertex = new RangeVertex(range) - this.dependencyGraph.rangeMapping.setRange(rangeVertex) + this.dependencyGraph.rangeMapping.addOrUpdateVertex(rangeVertex) tmpRanges.push(rangeVertex) } } @@ -95,12 +68,49 @@ export class Evaluator { const ret = this.evaluateAstToCellValue(ast, new InterpreterState(address, this.config.useArrayArithmetic)) tmpRanges.forEach((rangeVertex) => { - this.dependencyGraph.rangeMapping.removeRange(rangeVertex) + this.dependencyGraph.rangeMapping.removeVertexIfExists(rangeVertex) }) return ret } + /** + * Recalculates the value of a single vertex assuming its dependencies have already been recalculated + */ + private recomputeVertex(vertex: Vertex, changes: ContentChanges): boolean { + if (vertex instanceof FormulaVertex) { + const currentValue = vertex.isComputed() ? vertex.getCellValue() : undefined + const newCellValue = this.recomputeFormulaVertexValue(vertex) + if (newCellValue !== currentValue) { + const address = vertex.getAddress(this.lazilyTransformingAstService) + changes.addChange(newCellValue, address) + this.columnSearch.change(getRawValue(currentValue), getRawValue(newCellValue), address) + return true + } + return false + } else if (vertex instanceof RangeVertex) { + vertex.clearCache() + return true + } else { + return true + } + } + + /** + * Processes a vertex that is part of a cycle in dependency graph + */ + private processVertexOnCycle(vertex: Vertex, changes: ContentChanges): void { + if (vertex instanceof RangeVertex) { + vertex.clearCache() + } else if (vertex instanceof FormulaVertex) { + const address = vertex.getAddress(this.lazilyTransformingAstService) + this.columnSearch.remove(getRawValue(vertex.valueOrUndef()), address) + const error = new CellError(ErrorType.CYCLE, undefined, vertex) + vertex.setCellValue(error) + changes.addChange(error, address) + } + } + /** * Recalculates formulas in the topological sort order */ @@ -123,7 +133,7 @@ export class Evaluator { private recomputeFormulaVertexValue(vertex: FormulaVertex): InterpreterValue { const address = vertex.getAddress(this.lazilyTransformingAstService) - if (vertex instanceof ArrayVertex && (vertex.array.size.isRef || !this.dependencyGraph.isThereSpaceForArray(vertex))) { + if (vertex instanceof ArrayFormulaVertex && (vertex.array.size.isRef || !this.dependencyGraph.isThereSpaceForArray(vertex))) { return vertex.setNoSpace() } else { const formula = vertex.getFormula(this.lazilyTransformingAstService) diff --git a/src/Exporter.ts b/src/Exporter.ts index 0400e1689..c5ff24f73 100644 --- a/src/Exporter.ts +++ b/src/Exporter.ts @@ -12,7 +12,8 @@ import {EmptyValue, getRawValue, InterpreterValue, isExtendedNumber} from './int import {SimpleRangeValue} from './SimpleRangeValue' import {LazilyTransformingAstService} from './LazilyTransformingAstService' import {NamedExpressions} from './NamedExpressions' -import {SheetIndexMappingFn, simpleCellAddressToString} from './parser/addressRepresentationConverters' +import {simpleCellAddressToString} from './parser/addressRepresentationConverters' +import { SheetMapping } from './DependencyGraph/SheetMapping' export type ExportedChange = ExportedCellChange | ExportedNamedExpressionChange @@ -55,7 +56,7 @@ export class Exporter implements ChangeExporter { constructor( private readonly config: Config, private readonly namedExpressions: NamedExpressions, - private readonly sheetIndexMapping: SheetIndexMappingFn, + private readonly sheetMapping: SheetMapping, private readonly lazilyTransformingService: LazilyTransformingAstService, ) { } @@ -119,7 +120,7 @@ export class Exporter implements ChangeExporter { if (originAddress.sheet === NamedExpressions.SHEET_FOR_WORKBOOK_EXPRESSIONS) { address = this.namedExpressions.namedExpressionInAddress(originAddress.row)?.displayName } else { - address = simpleCellAddressToString(this.sheetIndexMapping, originAddress, -1) + address = simpleCellAddressToString(this.sheetMapping.getSheetNameOrThrowError.bind(this.sheetMapping), originAddress, -1) } } return new DetailedCellError(error, this.config.translationPackage.getErrorTranslation(error.type), address) diff --git a/src/GraphBuilder.ts b/src/GraphBuilder.ts index 695a6c321..6d54d0e54 100644 --- a/src/GraphBuilder.ts +++ b/src/GraphBuilder.ts @@ -9,9 +9,9 @@ import {SimpleCellAddress, simpleCellAddress} from './Cell' import {CellContent, CellContentParser} from './CellContentParser' import {CellDependency} from './CellDependency' import { - ArrayVertex, + ArrayFormulaVertex, DependencyGraph, - FormulaCellVertex, + ScalarFormulaVertex, ParsingErrorVertex, ValueCellVertex, Vertex @@ -99,7 +99,7 @@ export class SimpleStrategy implements GraphBuilderStrategy { this.shrinkArrayIfNeeded(address) const size = this.arraySizePredictor.checkArraySize(parseResult.ast, address) if (size.isScalar()) { - const vertex = new FormulaCellVertex(parseResult.ast, address, 0) + const vertex = new ScalarFormulaVertex(parseResult.ast, address, 0) dependencies.set(vertex, absolutizeDependencies(parseResult.dependencies, address)) this.dependencyGraph.addVertex(address, vertex) if (parseResult.hasVolatileFunction) { @@ -109,7 +109,7 @@ export class SimpleStrategy implements GraphBuilderStrategy { this.dependencyGraph.markAsDependentOnStructureChange(vertex) } } else { - const vertex = new ArrayVertex(parseResult.ast, address, new ArraySize(size.width, size.height)) + const vertex = new ArrayFormulaVertex(parseResult.ast, address, new ArraySize(size.width, size.height)) dependencies.set(vertex, absolutizeDependencies(parseResult.dependencies, address)) this.dependencyGraph.addArrayVertex(address, vertex) } @@ -131,7 +131,7 @@ export class SimpleStrategy implements GraphBuilderStrategy { private shrinkArrayIfNeeded(address: SimpleCellAddress) { const vertex = this.dependencyGraph.getCell(address) - if (vertex instanceof ArrayVertex) { + if (vertex instanceof ArrayFormulaVertex) { this.dependencyGraph.shrinkArrayToCorner(vertex) } } diff --git a/src/HyperFormula.ts b/src/HyperFormula.ts index fa79813bc..e7c4b5150 100644 --- a/src/HyperFormula.ts +++ b/src/HyperFormula.ts @@ -2606,6 +2606,8 @@ export class HyperFormula implements TypedEmitter { /** * Adds a new sheet to the HyperFormula instance. Returns given or autogenerated name of a new sheet. * + * Note that this method may trigger dependency graph recalculation. + * * @param {string} [sheetName] - if not specified, name is autogenerated * * @fires [[sheetAdded]] after the sheet was added @@ -2635,6 +2637,7 @@ export class HyperFormula implements TypedEmitter { validateArgToType(sheetName, 'string', 'sheetName') } const addedSheetName = this._crudOperations.addSheet(sheetName) + this.recomputeIfDependencyGraphNeedsIt() this._emitter.emit(Events.SheetAdded, addedSheetName) return addedSheetName } @@ -2706,7 +2709,7 @@ export class HyperFormula implements TypedEmitter { */ public removeSheet(sheetId: number): ExportedChange[] { validateArgToType(sheetId, 'number', 'sheetId') - const displayName = this.sheetMapping.getDisplayName(sheetId) as string + const displayName = this.sheetMapping.getSheetName(sheetId) as string this._crudOperations.removeSheet(sheetId) const changes = this.recomputeIfDependencyGraphNeedsIt() this._emitter.emit(Events.SheetRemoved, displayName, changes) @@ -2885,7 +2888,7 @@ export class HyperFormula implements TypedEmitter { public simpleCellAddressFromString(cellAddress: string, contextSheetId: number): SimpleCellAddress | undefined { validateArgToType(cellAddress, 'string', 'cellAddress') validateArgToType(contextSheetId, 'number', 'sheetId') - return simpleCellAddressFromString(this.sheetMapping.get, cellAddress, contextSheetId) + return simpleCellAddressFromString(this.sheetMapping.getSheetId.bind(this.sheetMapping), cellAddress, contextSheetId) } /** @@ -2914,7 +2917,7 @@ export class HyperFormula implements TypedEmitter { public simpleCellRangeFromString(cellRange: string, contextSheetId: number): SimpleCellRange | undefined { validateArgToType(cellRange, 'string', 'cellRange') validateArgToType(contextSheetId, 'number', 'sheetId') - return simpleCellRangeFromString(this.sheetMapping.get, cellRange, contextSheetId) + return simpleCellRangeFromString(this.sheetMapping.getSheetId.bind(this.sheetMapping), cellRange, contextSheetId) } /** @@ -2960,7 +2963,7 @@ export class HyperFormula implements TypedEmitter { ? optionsOrContextSheetId : optionsOrContextSheetId.includeSheetName ? cellAddress.sheet+1 : cellAddress.sheet - return simpleCellAddressToString(this.sheetMapping.fetchDisplayName, cellAddress, contextSheetId) + return simpleCellAddressToString(this.sheetMapping.getSheetNameOrThrowError.bind(this.sheetMapping), cellAddress, contextSheetId) } /** @@ -3013,7 +3016,7 @@ export class HyperFormula implements TypedEmitter { ? optionsOrContextSheetId : optionsOrContextSheetId.includeSheetName ? cellRange.start.sheet+cellRange.end.sheet+1 : cellRange.start.sheet - return simpleCellRangeToString(this.sheetMapping.fetchDisplayName, cellRange, contextSheetId) + return simpleCellRangeToString(this.sheetMapping.getSheetNameOrThrowError.bind(this.sheetMapping), cellRange, contextSheetId) } /** @@ -3046,7 +3049,7 @@ export class HyperFormula implements TypedEmitter { if (isSimpleCellAddress(address)) { vertex = this._dependencyGraph.addressMapping.getCell(address) } else if (isSimpleCellRange(address)) { - vertex = this._dependencyGraph.rangeMapping.getRange(address.start, address.end) + vertex = this._dependencyGraph.rangeMapping.getRangeVertex(address.start, address.end) } else { throw new ExpectedValueOfTypeError('SimpleCellAddress | SimpleCellRange', address) } @@ -3084,7 +3087,7 @@ export class HyperFormula implements TypedEmitter { if (isSimpleCellAddress(address)) { vertex = this._dependencyGraph.addressMapping.getCell(address) } else if (isSimpleCellRange(address)) { - vertex = this._dependencyGraph.rangeMapping.getRange(address.start, address.end) + vertex = this._dependencyGraph.rangeMapping.getRangeVertex(address.start, address.end) } else { throw new ExpectedValueOfTypeError('SimpleCellAddress | SimpleCellRange', address) } @@ -3116,7 +3119,7 @@ export class HyperFormula implements TypedEmitter { */ public getSheetName(sheetId: number): string | undefined { validateArgToType(sheetId, 'number', 'sheetId') - return this.sheetMapping.getDisplayName(sheetId) + return this.sheetMapping.getSheetName(sheetId) } /** @@ -3137,7 +3140,7 @@ export class HyperFormula implements TypedEmitter { * @category Sheets */ public getSheetNames(): string[] { - return this.sheetMapping.sheetNames() + return this.sheetMapping.getSheetNames() } /** @@ -3162,7 +3165,7 @@ export class HyperFormula implements TypedEmitter { */ public getSheetId(sheetName: string): number | undefined { validateArgToType(sheetName, 'string', 'sheetName') - return this.sheetMapping.get(sheetName) + return this.sheetMapping.getSheetId(sheetName) } /** @@ -3507,6 +3510,8 @@ export class HyperFormula implements TypedEmitter { /** * Renames a specified sheet. * + * Note that this method may trigger dependency graph recalculation. + * * @param {number} sheetId - a sheet ID * @param {string} newName - a name of the sheet to be given, if is the same as the old one the method does nothing * @@ -3533,6 +3538,7 @@ export class HyperFormula implements TypedEmitter { validateArgToType(sheetId, 'number', 'sheetId') validateArgToType(newName, 'string', 'newName') const oldName = this._crudOperations.renameSheet(sheetId, newName) + this.recomputeIfDependencyGraphNeedsIt() if (oldName !== undefined) { this._emitter.emit(Events.SheetRenamed, oldName, newName) } diff --git a/src/Operations.ts b/src/Operations.ts index 3a95311da..d3b4d8e5b 100644 --- a/src/Operations.ts +++ b/src/Operations.ts @@ -6,7 +6,7 @@ import { AbsoluteCellRange } from './AbsoluteCellRange' import { absolutizeDependencies, filterDependenciesOutOfScope } from './absolutizeDependencies' import { ArraySize, ArraySizePredictor } from './ArraySize' -import { equalSimpleCellAddress, invalidSimpleCellAddress, simpleCellAddress, SimpleCellAddress } from './Cell' +import { equalSimpleCellAddress, isColOrRowInvalid, simpleCellAddress, SimpleCellAddress } from './Cell' import { CellContent, CellContentParser, RawCellContent } from './CellContentParser' import { ClipboardCell, ClipboardCellType } from './ClipboardOperations' import { Config } from './Config' @@ -14,17 +14,17 @@ import { ContentChanges } from './ContentChanges' import { ColumnRowIndex } from './CrudOperations' import { AddressMapping, - ArrayVertex, + ArrayFormulaVertex, CellVertex, DependencyGraph, EmptyCellVertex, - FormulaCellVertex, + ScalarFormulaVertex, ParsingErrorVertex, SheetMapping, SparseStrategy, ValueCellVertex, } from './DependencyGraph' -import { FormulaVertex } from './DependencyGraph/FormulaCellVertex' +import { FormulaVertex } from './DependencyGraph/FormulaVertex' import { RawAndParsedValue, ValueCellVertexValue } from './DependencyGraph/ValueCellVertex' import { AddColumnsTransformer } from './dependencyTransformers/AddColumnsTransformer' import { AddRowsTransformer } from './dependencyTransformers/AddRowsTransformer' @@ -32,7 +32,7 @@ import { CleanOutOfScopeDependenciesTransformer } from './dependencyTransformers import { MoveCellsTransformer } from './dependencyTransformers/MoveCellsTransformer' import { RemoveColumnsTransformer } from './dependencyTransformers/RemoveColumnsTransformer' import { RemoveRowsTransformer } from './dependencyTransformers/RemoveRowsTransformer' -import { RemoveSheetTransformer } from './dependencyTransformers/RemoveSheetTransformer' +import { RenameSheetTransformer } from './dependencyTransformers/RenameSheetTransformer' import { InvalidArgumentsError, NamedExpressionDoesNotExistError, @@ -44,6 +44,7 @@ import { import { EmptyValue, getRawValue } from './interpreter/InterpreterValue' import { LazilyTransformingAstService } from './LazilyTransformingAstService' import { ColumnSearchStrategy } from './Lookup/SearchStrategy' +import { Maybe } from './Maybe' import { doesContainRelativeReferences, InternalNamedExpression, @@ -53,7 +54,6 @@ import { import { NamedExpressionDependency, ParserWithCaching, ParsingErrorType, RelativeDependency } from './parser' import { ParsingError } from './parser/Ast' import { ParsingResult } from './parser/ParserWithCaching' -import { findBoundaries, Sheet } from './Sheet' import { ColumnsSpan, RowsSpan } from './Span' import { Statistics, StatType } from './statistics' @@ -217,43 +217,89 @@ export class Operations { return columnsRemovals } - public removeSheet(sheetId: number) { - this.dependencyGraph.removeSheet(sheetId) + /** + * Clears the sheet content. + */ + public clearSheet(sheetId: number) { + this.dependencyGraph.clearSheet(sheetId) + this.columnSearch.removeSheet(sheetId) + } - let version = 0 - this.stats.measure(StatType.TRANSFORM_ASTS, () => { - const transformation = new RemoveSheetTransformer(sheetId) - transformation.performEagerTransformations(this.dependencyGraph, this.parser) - version = this.lazilyTransformingAstService.addTransformation(transformation) - }) + /** + * Adds a new sheet to the workbook. + */ + public addSheet(name?: string): { sheetName: string, sheetId: number } { + const sheetId = this.sheetMapping.addSheet(name) + this.dependencyGraph.addSheet(sheetId) + return { sheetName: this.sheetMapping.getSheetNameOrThrowError(sheetId), sheetId } + } - this.sheetMapping.removeSheet(sheetId) + /** + * Adds a sheet with a specific ID for redo operations. + */ + public addSheetWithId(sheetId: number, name: string): void { + this.sheetMapping.addSheetWithId(sheetId, name) + this.dependencyGraph.addSheet(sheetId) + } + + /** + * Adds a placeholder sheet with a specific ID for undo operations. + * Used to restore previously merged placeholder sheets. + * + * Note: Unlike `addSheetWithId`, this does NOT call `dependencyGraph.addSheet()` + * because placeholders don't need dirty marking or strategy changes - they only + * need to exist in the mappings so formulas can reference them again. + */ + public addPlaceholderSheetWithId(sheetId: number, name: string): void { + this.sheetMapping.addPlaceholderWithId(sheetId, name) + this.addressMapping.addSheetStrategyPlaceholderIfNotExists(sheetId) + } + + /** + * Removes a sheet from the workbook. + */ + public removeSheet(sheetId: number): [InternalNamedExpression, ClipboardCell][] { + this.dependencyGraph.removeSheet(sheetId) this.columnSearch.removeSheet(sheetId) const scopedNamedExpressions = this.namedExpressions.getAllNamedExpressionsForScope(sheetId).map( (namedExpression) => this.removeNamedExpression(namedExpression.normalizeExpressionName(), sheetId) ) - return { version: version, scopedNamedExpressions } + return scopedNamedExpressions } + /** + * Removes a sheet from the workbook by name. + */ public removeSheetByName(sheetName: string) { - const sheetId = this.sheetMapping.fetch(sheetName) + const sheetId = this.sheetMapping.getSheetIdOrThrowError(sheetName) return this.removeSheet(sheetId) } - public clearSheet(sheetId: number) { - this.dependencyGraph.clearSheet(sheetId) - this.columnSearch.removeSheet(sheetId) - } - - public addSheet(name?: string) { - const sheetId = this.sheetMapping.addSheet(name) - const sheet: Sheet = [] - this.dependencyGraph.addressMapping.autoAddSheet(sheetId, findBoundaries(sheet)) - return this.sheetMapping.fetchDisplayName(sheetId) - } + /** + * Renames a sheet in the workbook. + */ + public renameSheet(sheetId: number, newName: string): { + previousDisplayName: Maybe, + version?: number, + mergedPlaceholderSheetId?: number, + } { + const { previousDisplayName, mergedWithPlaceholderSheet } = this.sheetMapping.renameSheet(sheetId, newName) + + let version: number | undefined + if (mergedWithPlaceholderSheet !== undefined) { + this.dependencyGraph.mergeSheets(sheetId, mergedWithPlaceholderSheet) + this.stats.measure(StatType.TRANSFORM_ASTS, () => { + const transformation = new RenameSheetTransformer(sheetId, mergedWithPlaceholderSheet) + transformation.performEagerTransformations(this.dependencyGraph, this.parser) + version = this.lazilyTransformingAstService.addTransformation(transformation) + }) + } - public renameSheet(sheetId: number, newName: string) { - return this.sheetMapping.renameSheet(sheetId, newName) + return { + previousDisplayName, + version, + mergedPlaceholderSheetId: mergedWithPlaceholderSheet, + } } public moveRows(sheet: number, startRow: number, numberOfRows: number, targetRow: number): number { @@ -419,9 +465,9 @@ export class Operations { public ensureItIsPossibleToMoveCells(sourceLeftCorner: SimpleCellAddress, width: number, height: number, destinationLeftCorner: SimpleCellAddress): void { if ( - invalidSimpleCellAddress(sourceLeftCorner) || + isColOrRowInvalid(sourceLeftCorner) || !((isPositiveInteger(width) && isPositiveInteger(height)) || isRowOrColumnRange(sourceLeftCorner, width, height)) || - invalidSimpleCellAddress(destinationLeftCorner) || + isColOrRowInvalid(destinationLeftCorner) || !this.sheetMapping.hasSheetWithId(sourceLeftCorner.sheet) || !this.sheetMapping.hasSheetWithId(destinationLeftCorner.sheet) ) { @@ -507,13 +553,13 @@ export class Operations { return { type: ClipboardCellType.EMPTY } } else if (vertex instanceof ValueCellVertex) { return { type: ClipboardCellType.VALUE, ...vertex.getValues() } - } else if (vertex instanceof ArrayVertex) { + } else if (vertex instanceof ArrayFormulaVertex) { const val = vertex.getArrayCellValue(address) if (val === EmptyValue) { return { type: ClipboardCellType.EMPTY } } return { type: ClipboardCellType.VALUE, parsedValue: val, rawValue: vertex.getArrayCellRawValue(address) } - } else if (vertex instanceof FormulaCellVertex) { + } else if (vertex instanceof ScalarFormulaVertex) { return { type: ClipboardCellType.FORMULA, hash: this.parser.computeHashFromAst(vertex.getFormula(this.lazilyTransformingAstService)) @@ -612,7 +658,7 @@ export class Operations { /** * Sets cell content to a formula. - * Creates a FormulaCellVertex and updates the dependency graph and column search index. + * Creates a ScalarFormulaVertex and updates the dependency graph and column search index. */ public setFormulaToCell(address: SimpleCellAddress, size: ArraySize, { ast, @@ -681,7 +727,7 @@ export class Operations { * @param {number} sheet - sheet ID number */ public rowEffectivelyNotInSheet(row: number, sheet: number): boolean { - const height = this.dependencyGraph.addressMapping.getHeight(sheet) + const height = this.dependencyGraph.addressMapping.getSheetHeight(sheet) return row >= height } @@ -783,7 +829,7 @@ export class Operations { this.rewriteAffectedArrays(affectedArrays) } - private rewriteAffectedArrays(affectedArrays: Set) { + private rewriteAffectedArrays(affectedArrays: Set) { for (const arrayVertex of affectedArrays.values()) { if (arrayVertex.array.size.isRef) { continue @@ -824,7 +870,7 @@ export class Operations { * @param {number} sheet - sheet ID number */ private columnEffectivelyNotInSheet(column: number, sheet: number): boolean { - const width = this.dependencyGraph.addressMapping.getWidth(sheet) + const width = this.dependencyGraph.addressMapping.getSheetWidth(sheet) return column >= width } @@ -840,7 +886,7 @@ export class Operations { const globalVertexId = maybeGlobalVertexId ?? this.dependencyGraph.graph.getNodeId(globalVertex) for (const adjacentNode of this.dependencyGraph.graph.adjacentNodes(globalVertex)) { - if (adjacentNode instanceof FormulaCellVertex && adjacentNode.getAddress(this.lazilyTransformingAstService).sheet === sheetId) { + if (adjacentNode instanceof ScalarFormulaVertex && adjacentNode.getAddress(this.lazilyTransformingAstService).sheet === sheetId) { const ast = adjacentNode.getFormula(this.lazilyTransformingAstService) const formulaAddress = adjacentNode.getAddress(this.lazilyTransformingAstService) const { dependencies } = this.parser.fetchCachedResultForAst(ast) @@ -879,8 +925,8 @@ export class Operations { const targetRange = AbsoluteCellRange.spanFrom(destinationLeftCorner, width, height) for (const formulaAddress of targetRange.addresses(this.dependencyGraph)) { - const vertex = this.addressMapping.fetchCell(formulaAddress) - if (vertex instanceof FormulaCellVertex && formulaAddress.sheet !== sourceLeftCorner.sheet) { + const vertex = this.addressMapping.getCell(formulaAddress, { throwIfCellNotExists: true }) + if (vertex instanceof ScalarFormulaVertex && formulaAddress.sheet !== sourceLeftCorner.sheet) { const ast = vertex.getFormula(this.lazilyTransformingAstService) const { dependencies } = this.parser.fetchCachedResultForAst(ast) addedGlobalNamedExpressions.push(...this.updateNamedExpressionsForTargetAddress(sourceLeftCorner.sheet, formulaAddress, dependencies)) @@ -896,7 +942,7 @@ export class Operations { } const addedGlobalNamedExpressions: string[] = [] - const vertex = this.addressMapping.fetchCell(targetAddress) + const vertex = this.addressMapping.getCellOrThrow(targetAddress) for (const namedExpressionDependency of absolutizeDependencies(dependencies, targetAddress)) { if (!(namedExpressionDependency instanceof NamedExpressionDependency)) { @@ -921,7 +967,7 @@ export class Operations { } private allocateNamedExpressionAddressSpace() { - this.dependencyGraph.addressMapping.addSheet(NamedExpressions.SHEET_FOR_WORKBOOK_EXPRESSIONS, new SparseStrategy(0, 0)) + this.dependencyGraph.addressMapping.addSheetWithStrategy(NamedExpressions.SHEET_FOR_WORKBOOK_EXPRESSIONS, new SparseStrategy(0, 0)) } private copyOrFetchGlobalNamedExpressionVertex(expressionName: string, sourceVertex: CellVertex, addedNamedExpressions: string[]): CellVertex { @@ -929,7 +975,7 @@ export class Operations { if (expression === undefined) { expression = this.namedExpressions.addNamedExpression(expressionName) addedNamedExpressions.push(expression.normalizeExpressionName()) - if (sourceVertex instanceof FormulaCellVertex) { + if (sourceVertex instanceof ScalarFormulaVertex) { const parsingResult = this.parser.fetchCachedResultForAst(sourceVertex.getFormula(this.lazilyTransformingAstService)) const { ast, hasVolatileFunction, hasStructuralChangeFunction, dependencies } = parsingResult this.dependencyGraph.setFormulaToCell(expression.address, ast, absolutizeDependencies(dependencies, expression.address), ArraySize.scalar(), hasVolatileFunction, hasStructuralChangeFunction) @@ -969,7 +1015,7 @@ export class Operations { } /** - * Checks if the FormulaCellVertex or ArrayVertex at the given address is not computed. + * Checks if the ScalarFormulaVertex or ArrayFormulaVertex at the given address is not computed. */ private isNotComputed(address: SimpleCellAddress): boolean { const vertex = this.dependencyGraph.getCell(address) diff --git a/src/Serialization.ts b/src/Serialization.ts index 71a256fda..e33474ec5 100644 --- a/src/Serialization.ts +++ b/src/Serialization.ts @@ -7,11 +7,11 @@ import {simpleCellAddress, SimpleCellAddress} from './Cell' import {RawCellContent} from './CellContentParser' import {CellValue} from './CellValue' import {Config} from './Config' -import {ArrayVertex, DependencyGraph, FormulaCellVertex, ParsingErrorVertex} from './DependencyGraph' +import {ArrayFormulaVertex, DependencyGraph, ScalarFormulaVertex, ParsingErrorVertex} from './DependencyGraph' import {Exporter} from './Exporter' import {Maybe} from './Maybe' import {NamedExpressionOptions, NamedExpressions} from './NamedExpressions' -import {buildLexerConfig, ProcedureAst, Unparser} from './parser' +import {ProcedureAst, Unparser} from './parser' export interface SerializedNamedExpression { name: string, @@ -30,7 +30,7 @@ export class Serialization { public getCellHyperlink(address: SimpleCellAddress): Maybe { const formulaVertex = this.dependencyGraph.getCell(address) - if (formulaVertex instanceof FormulaCellVertex) { + if (formulaVertex instanceof ScalarFormulaVertex) { const formula = formulaVertex.getFormula(this.dependencyGraph.lazilyTransformingAstService) as ProcedureAst if ('HYPERLINK' === formula.procedureName) { return formula.hyperlink @@ -41,11 +41,11 @@ export class Serialization { public getCellFormula(address: SimpleCellAddress, targetAddress?: SimpleCellAddress): Maybe { const formulaVertex = this.dependencyGraph.getCell(address) - if (formulaVertex instanceof FormulaCellVertex) { + if (formulaVertex instanceof ScalarFormulaVertex) { const formula = formulaVertex.getFormula(this.dependencyGraph.lazilyTransformingAstService) targetAddress = targetAddress ?? address return this.unparser.unparse(formula, targetAddress) - } else if (formulaVertex instanceof ArrayVertex) { + } else if (formulaVertex instanceof ArrayFormulaVertex) { const arrayVertexAddress = formulaVertex.getAddress(this.dependencyGraph.lazilyTransformingAstService) if (arrayVertexAddress.row !== address.row || arrayVertexAddress.col !== address.col || arrayVertexAddress.sheet !== address.sheet) { return undefined @@ -114,8 +114,8 @@ export class Serialization { public genericAllSheetsGetter(sheetGetter: (sheet: number) => T): Record { const result: Record = {} - for (const sheetName of this.dependencyGraph.sheetMapping.displayNames()) { - const sheetId = this.dependencyGraph.sheetMapping.fetch(sheetName) + for (const sheetName of this.dependencyGraph.sheetMapping.iterateSheetNames()) { + const sheetId = this.dependencyGraph.sheetMapping.getSheetIdOrThrowError(sheetName) result[sheetName] = sheetGetter(sheetId) } return result @@ -140,8 +140,8 @@ export class Serialization { public getAllNamedExpressionsSerialized(): SerializedNamedExpression[] { const idMap: number[] = [] let id = 0 - for (const sheetName of this.dependencyGraph.sheetMapping.displayNames()) { - const sheetId = this.dependencyGraph.sheetMapping.fetch(sheetName) + for (const sheetName of this.dependencyGraph.sheetMapping.iterateSheetNames()) { + const sheetId = this.dependencyGraph.sheetMapping.getSheetIdOrThrowError(sheetName) idMap[sheetId] = id id++ } @@ -156,7 +156,7 @@ export class Serialization { } public withNewConfig(newConfig: Config, namedExpressions: NamedExpressions): Serialization { - const newUnparser = new Unparser(newConfig, buildLexerConfig(newConfig), this.dependencyGraph.sheetMapping.fetchDisplayName, namedExpressions) + const newUnparser = new Unparser(newConfig, this.dependencyGraph.sheetMapping, namedExpressions) return new Serialization(this.dependencyGraph, newUnparser, this.exporter) } } diff --git a/src/UndoRedo.ts b/src/UndoRedo.ts index cea071117..5413a79d5 100644 --- a/src/UndoRedo.ts +++ b/src/UndoRedo.ts @@ -225,6 +225,7 @@ export class RemoveColumnsUndoEntry extends BaseUndoEntry { export class AddSheetUndoEntry extends BaseUndoEntry { constructor( public readonly sheetName: string, + public readonly sheetId: number, ) { super() } @@ -244,7 +245,6 @@ export class RemoveSheetUndoEntry extends BaseUndoEntry { public readonly sheetId: number, public readonly oldSheetContent: ClipboardCell[][], public readonly scopedNamedExpressions: [InternalNamedExpression, ClipboardCell][], - public readonly version: number, ) { super() } @@ -258,11 +258,23 @@ export class RemoveSheetUndoEntry extends BaseUndoEntry { } } +/** + * Undo entry for renaming a sheet. + * + * When renaming a sheet to a name that was previously referenced (but didn't exist), + * a placeholder sheet gets merged into the renamed sheet. In this case: + * - `version` contains the transformation version for restoring formulas during undo + * - `mergedPlaceholderSheetId` contains the ID of the placeholder sheet that was merged + * + * When renaming to a name not previously referenced, both optional params are undefined. + */ export class RenameSheetUndoEntry extends BaseUndoEntry { constructor( public readonly sheetId: number, public readonly oldName: string, public readonly newName: string, + public readonly version?: number, + public readonly mergedPlaceholderSheetId?: number, ) { super() } @@ -583,8 +595,8 @@ export class UndoRedo { public undoRemoveSheet(operation: RemoveSheetUndoEntry) { this.operations.forceApplyPostponedTransformations() - const {oldSheetContent, sheetId} = operation - this.operations.addSheet(operation.sheetName) + const {oldSheetContent, sheetId, scopedNamedExpressions, sheetName} = operation + this.operations.addSheetWithId(sheetId, sheetName) for (let rowIndex = 0; rowIndex < oldSheetContent.length; rowIndex++) { const row = oldSheetContent[rowIndex] for (let col = 0; col < row.length; col++) { @@ -594,15 +606,19 @@ export class UndoRedo { } } - for (const [namedexpression, content] of operation.scopedNamedExpressions) { + for (const [namedexpression, content] of scopedNamedExpressions) { this.operations.restoreNamedExpression(namedexpression, content, sheetId) } - - this.restoreOldDataFromVersion(operation.version - 1) } public undoRenameSheet(operation: RenameSheetUndoEntry) { + this.operations.forceApplyPostponedTransformations() this.operations.renameSheet(operation.sheetId, operation.oldName) + + if (operation.mergedPlaceholderSheetId !== undefined && operation.version !== undefined) { + this.operations.addPlaceholderSheetWithId(operation.mergedPlaceholderSheetId, operation.newName) + this.restoreOldDataFromVersion(operation.version - 1) + } } public undoClearSheet(operation: ClearSheetUndoEntry) { @@ -711,7 +727,7 @@ export class UndoRedo { } public redoAddSheet(operation: AddSheetUndoEntry) { - this.operations.addSheet(operation.sheetName) + this.operations.addSheetWithId(operation.sheetId, operation.sheetName) } public redoRenameSheet(operation: RenameSheetUndoEntry) { diff --git a/src/absolutizeDependencies.ts b/src/absolutizeDependencies.ts index 200890ef4..80c5b16a7 100644 --- a/src/absolutizeDependencies.ts +++ b/src/absolutizeDependencies.ts @@ -4,7 +4,7 @@ */ import {AbsoluteCellRange} from './AbsoluteCellRange' -import {invalidSimpleCellAddress, SimpleCellAddress} from './Cell' +import {isColOrRowInvalid, SimpleCellAddress} from './Cell' import {CellDependency} from './CellDependency' import {NamedExpressionDependency, RelativeDependency} from './parser' @@ -24,9 +24,9 @@ export const filterDependenciesOutOfScope = (deps: CellDependency[]) => { return true } if (dep instanceof AbsoluteCellRange) { - return !(invalidSimpleCellAddress(dep.start) || invalidSimpleCellAddress(dep.end)) + return !(isColOrRowInvalid(dep.start) || isColOrRowInvalid(dep.end)) } else { - return !invalidSimpleCellAddress(dep) + return !isColOrRowInvalid(dep) } }) } diff --git a/src/dependencyTransformers/RemoveSheetTransformer.ts b/src/dependencyTransformers/RemoveSheetTransformer.ts deleted file mode 100644 index 97566a81c..000000000 --- a/src/dependencyTransformers/RemoveSheetTransformer.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * @license - * Copyright (c) 2025 Handsoncode. All rights reserved. - */ - -import {ErrorType, SimpleCellAddress} from '../Cell' -import {DependencyGraph} from '../DependencyGraph' -import {CellAddress, ParserWithCaching} from '../parser' -import {ColumnAddress} from '../parser/ColumnAddress' -import {RowAddress} from '../parser/RowAddress' -import {Transformer} from './Transformer' - -export class RemoveSheetTransformer extends Transformer { - constructor( - public readonly sheet: number - ) { - super() - } - - public isIrreversible() { - return true - } - - public performEagerTransformations(graph: DependencyGraph, _parser: ParserWithCaching): void { - for (const node of graph.arrayFormulaNodes()) { - const [newAst] = this.transformSingleAst(node.getFormula(graph.lazilyTransformingAstService), node.getAddress(graph.lazilyTransformingAstService)) - node.setFormula(newAst) - } - } - - protected fixNodeAddress(address: SimpleCellAddress): SimpleCellAddress { - return address - } - - protected transformCellAddress(dependencyAddress: T, _formulaAddress: SimpleCellAddress): ErrorType.REF | false | T { - return this.transformAddress(dependencyAddress) - } - - protected transformCellRange(start: CellAddress, _end: CellAddress, _formulaAddress: SimpleCellAddress): ErrorType.REF | false { - return this.transformAddress(start) - } - - protected transformColumnRange(start: ColumnAddress, _end: ColumnAddress, _formulaAddress: SimpleCellAddress): ErrorType.REF | false { - return this.transformAddress(start) - } - - protected transformRowRange(start: RowAddress, _end: RowAddress, _formulaAddress: SimpleCellAddress): ErrorType.REF | false { - return this.transformAddress(start) - } - - private transformAddress(address: T): ErrorType.REF | false { - if (address.sheet === this.sheet) { - return ErrorType.REF - } - return false - } -} diff --git a/src/dependencyTransformers/RenameSheetTransformer.ts b/src/dependencyTransformers/RenameSheetTransformer.ts new file mode 100644 index 000000000..d51c4faa2 --- /dev/null +++ b/src/dependencyTransformers/RenameSheetTransformer.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +import {SimpleCellAddress} from '../Cell' +import {CellAddress} from '../parser' +import {ColumnAddress} from '../parser/ColumnAddress' +import {RowAddress} from '../parser/RowAddress' +import {Transformer} from './Transformer' + +type SheetAwareAddress = CellAddress | RowAddress | ColumnAddress + +/** + * Transformer that reassigns references from a merged sheet into the surviving sheet. + */ +export class RenameSheetTransformer extends Transformer { + constructor( + public readonly sheetIdToKeep: number, + public readonly sheetBeingMerged: number, + ) { + super() + } + + /** + * Returns id of sheet that survives merge operation. + * + * @returns {number} sheet identifier. + */ + public get sheet(): number { + return this.sheetIdToKeep + } + + /** + * Sheet merge cannot be undone because original sheet id is lost. + * + * @returns {boolean} always true to indicate transformation irreversibility. + */ + public isIrreversible(): boolean { + return true + } + + /** + * Updates cell address sheet when it points to merged sheet. + * + * @param {T} dependencyAddress - dependency address needing sheet update. + * @param {SimpleCellAddress} _formulaAddress - location of formula (unused but required by base class). + * @returns {T | false} updated address or false when nothing changes. + */ + protected transformCellAddress(dependencyAddress: T, _formulaAddress: SimpleCellAddress): T | false { + return this.updateSheetInAddress(dependencyAddress) + } + + /** + * Updates sheet for both ends of cell range. + * + * @param {CellAddress} start - start address of range. + * @param {CellAddress} end - end address of range. + * @param {SimpleCellAddress} _formulaAddress - formula location (unused). + * @returns {[CellAddress, CellAddress] | false} updated range tuple or false when unchanged. + */ + protected transformCellRange(start: CellAddress, end: CellAddress, _formulaAddress: SimpleCellAddress): [CellAddress, CellAddress] | false { + return this.transformRange(start, end) + } + + /** + * Updates sheet for both ends of column range. + * + * @param {ColumnAddress} start - beginning column of range. + * @param {ColumnAddress} end - ending column of range. + * @param {SimpleCellAddress} _formulaAddress - formula location (unused). + * @returns {[ColumnAddress, ColumnAddress] | false} updated column range or false. + */ + protected transformColumnRange(start: ColumnAddress, end: ColumnAddress, _formulaAddress: SimpleCellAddress): [ColumnAddress, ColumnAddress] | false { + return this.transformRange(start, end) + } + + /** + * Updates sheet for both ends of row range. + * + * @param {RowAddress} start - beginning row address. + * @param {RowAddress} end - ending row address. + * @param {SimpleCellAddress} _formulaAddress - formula location (unused). + * @returns {[RowAddress, RowAddress] | false} updated row range or false. + */ + protected transformRowRange(start: RowAddress, end: RowAddress, _formulaAddress: SimpleCellAddress): [RowAddress, RowAddress] | false { + return this.transformRange(start, end) + } + + /** + * Node addresses are already absolute, so no change is needed. + * + * @param {SimpleCellAddress} address - node address to inspect. + * @returns {SimpleCellAddress} original address unchanged. + */ + protected fixNodeAddress(address: SimpleCellAddress): SimpleCellAddress { + return address + } + + /** + * Updates sheet identifier for both range ends if needed. + * + * @param {T} start - range start address. + * @param {T} end - range end address. + * @returns {[T, T] | false} tuple with updated addresses or false when no updates happen. + */ + private transformRange(start: T, end: T): [T, T] | false { + const newStart = this.updateSheetInAddress(start) + const newEnd = this.updateSheetInAddress(end) + + if (newStart || newEnd) { + return [(newStart || start), (newEnd || end)] + } + + return false + } + + /** + * Replaces sheet id in address when it points to merged sheet. + * + * @param {T} address - address to update. + * @returns {T | false} address with new sheet id or false when no change occurs. + */ + private updateSheetInAddress(address: T): T | false { + if (address.sheet === this.sheetBeingMerged) { + return address.withSheet(this.sheetIdToKeep) as T + } + + return false + } +} diff --git a/src/interpreter/Interpreter.ts b/src/interpreter/Interpreter.ts index b5c12f430..8edf08f0a 100644 --- a/src/interpreter/Interpreter.ts +++ b/src/interpreter/Interpreter.ts @@ -6,11 +6,11 @@ import {AbsoluteCellRange, AbsoluteColumnRange, AbsoluteRowRange} from '../AbsoluteCellRange' import {ArraySizePredictor} from '../ArraySize' import {ArrayValue, NotComputedArray} from '../ArrayValue' -import {CellError, ErrorType, invalidSimpleCellAddress} from '../Cell' +import {CellError, ErrorType, isColOrRowInvalid} from '../Cell' import {Config} from '../Config' import {DateTimeHelper} from '../DateTimeHelper' import {DependencyGraph} from '../DependencyGraph' -import {FormulaVertex} from '../DependencyGraph/FormulaCellVertex' +import {FormulaVertex} from '../DependencyGraph/FormulaVertex' import {ErrorMessage} from '../error-message' import {LicenseKeyValidityState} from '../helpers/licenseKeyValidator' import {ColumnSearchStrategy} from '../Lookup/SearchStrategy' @@ -40,6 +40,7 @@ import { isExtendedNumber, } from './InterpreterValue' import {SimpleRangeValue} from '../SimpleRangeValue' +import { AddressWithSheet } from '../parser/Address' export class Interpreter { public readonly criterionBuilder: CriterionBuilder @@ -78,8 +79,8 @@ export class Interpreter { /** * Calculates cell value from formula abstract syntax tree * - * @param formula - abstract syntax tree of formula - * @param formulaAddress - address of the cell in which formula is located + * @param {Ast} ast - abstract syntax tree of formula + * @param {InterpreterState} state - interpreter state */ private evaluateAstWithoutPostprocessing(ast: Ast, state: InterpreterState): InterpreterValue { switch (ast.type) { @@ -88,9 +89,15 @@ export class Interpreter { } case AstNodeType.CELL_REFERENCE: { const address = ast.reference.toSimpleCellAddress(state.formulaAddress) - if (invalidSimpleCellAddress(address)) { + + if (isColOrRowInvalid(address)) { return new CellError(ErrorType.REF, ErrorMessage.BadRef) } + + if (!this.isSheetValid(ast.reference)) { + return new CellError(ErrorType.REF, ErrorMessage.SheetRef) + } + return this.dependencyGraph.getCellValue(address) } case AstNodeType.NUMBER: @@ -189,11 +196,17 @@ export class Interpreter { } } case AstNodeType.CELL_RANGE: { + if (!this.isSheetValid(ast.start) || !this.isSheetValid(ast.end)) { + return new CellError(ErrorType.REF, ErrorMessage.SheetRef) + } + if (!this.rangeSpansOneSheet(ast)) { return new CellError(ErrorType.REF, ErrorMessage.RangeManySheets) } + const range = AbsoluteCellRange.fromCellRange(ast, state.formulaAddress) const arrayVertex = this.dependencyGraph.getArray(range) + if (arrayVertex) { const array = arrayVertex.array if (array instanceof NotComputedArray) { @@ -205,11 +218,15 @@ export class Interpreter { } else { throw new Error('Unknown array') } - } else { - return SimpleRangeValue.onlyRange(range, this.dependencyGraph) } + + return SimpleRangeValue.onlyRange(range, this.dependencyGraph) } case AstNodeType.COLUMN_RANGE: { + if (!this.isSheetValid(ast.start) || !this.isSheetValid(ast.end)) { + return new CellError(ErrorType.REF, ErrorMessage.SheetRef) + } + if (!this.rangeSpansOneSheet(ast)) { return new CellError(ErrorType.REF, ErrorMessage.RangeManySheets) } @@ -217,6 +234,10 @@ export class Interpreter { return SimpleRangeValue.onlyRange(range, this.dependencyGraph) } case AstNodeType.ROW_RANGE: { + if (!this.isSheetValid(ast.start) || !this.isSheetValid(ast.end)) { + return new CellError(ErrorType.REF, ErrorMessage.SheetRef) + } + if (!this.rangeSpansOneSheet(ast)) { return new CellError(ErrorType.REF, ErrorMessage.RangeManySheets) } @@ -265,6 +286,17 @@ export class Interpreter { } } + /** + * Sheet is valid if: + * - sheet is undefined OR + * - sheet is a named expressions store OR + * - sheet exists in sheet mapping + * - sheet is not a placeholder + */ + private isSheetValid(address: AddressWithSheet): boolean { + return address.sheet === undefined || address.sheet === NamedExpressions.SHEET_FOR_WORKBOOK_EXPRESSIONS || this.dependencyGraph.sheetMapping.hasSheetWithId(address.sheet, { includePlaceholders: false }) + } + private rangeSpansOneSheet(ast: CellRangeAst | ColumnRangeAst | RowRangeAst): boolean { return ast.start.sheet === ast.end.sheet } @@ -465,4 +497,3 @@ function wrapperForRootVertex(val: InterpreterValue, vertex?: FormulaVertex): In } return val } - diff --git a/src/interpreter/InterpreterState.ts b/src/interpreter/InterpreterState.ts index fdf5cb900..be48aa6a0 100644 --- a/src/interpreter/InterpreterState.ts +++ b/src/interpreter/InterpreterState.ts @@ -4,7 +4,7 @@ */ import {SimpleCellAddress} from '../Cell' -import {FormulaVertex} from '../DependencyGraph/FormulaCellVertex' +import {FormulaVertex} from '../DependencyGraph/FormulaVertex' export class InterpreterState { constructor( @@ -14,4 +14,3 @@ export class InterpreterState { ) { } } - diff --git a/src/interpreter/plugin/InformationPlugin.ts b/src/interpreter/plugin/InformationPlugin.ts index 625453ecb..481522107 100644 --- a/src/interpreter/plugin/InformationPlugin.ts +++ b/src/interpreter/plugin/InformationPlugin.ts @@ -4,7 +4,7 @@ */ import {CellError, ErrorType, SimpleCellAddress} from '../../Cell' -import {FormulaVertex} from '../../DependencyGraph/FormulaCellVertex' +import {FormulaVertex} from '../../DependencyGraph/FormulaVertex' import {ErrorMessage} from '../../error-message' import {AstNodeType, ProcedureAst} from '../../parser' import {InterpreterState} from '../InterpreterState' @@ -457,7 +457,7 @@ export class InformationPlugin extends FunctionPlugin implements FunctionPluginT () => state.formulaAddress.sheet + 1, (reference: SimpleCellAddress) => reference.sheet + 1, (value: string) => { - const sheetNumber = this.dependencyGraph.sheetMapping.get(value) + const sheetNumber = this.dependencyGraph.sheetMapping.getSheetId(value) if (sheetNumber !== undefined) { return sheetNumber + 1 } else { diff --git a/src/interpreter/plugin/NumericAggregationPlugin.ts b/src/interpreter/plugin/NumericAggregationPlugin.ts index 1b0abf9fe..5a675e936 100644 --- a/src/interpreter/plugin/NumericAggregationPlugin.ts +++ b/src/interpreter/plugin/NumericAggregationPlugin.ts @@ -609,13 +609,13 @@ export class NumericAggregationPlugin extends FunctionPlugin implements Function /** * Performs range operation on given range * - * @param ast - cell range ast - * @param state - * @param initialAccValue - initial accumulator value for reducing function - * @param functionName - function name to use as cache key - * @param reducingFunction - reducing function - * @param mapFunction - * @param coercionFunction + * @param {CellRangeAst | ColumnRangeAst | RowRangeAst} ast - cell range ast + * @param {InterpreterState} state - interpreter state + * @param {T} initialAccValue - initial accumulator value for reducing function + * @param {string} functionName - function name to use as cache key + * @param {BinaryOperation} reducingFunction - reducing function + * @param {MapOperation} mapFunction - mapper transforming coerced scalar + * @param {coercionOperation} coercionFunction - scalar-to-number coercer */ private evaluateRange(ast: CellRangeAst | ColumnRangeAst | RowRangeAst, state: InterpreterState, initialAccValue: T, functionName: string, reducingFunction: BinaryOperation, mapFunction: MapOperation, coercionFunction: coercionOperation): T | CellError { let range @@ -629,6 +629,10 @@ export class NumericAggregationPlugin extends FunctionPlugin implements Function } } + if (!this.isSheetValid(range)) { + return new CellError(ErrorType.REF, ErrorMessage.SheetRef) + } + const rangeVertex = this.dependencyGraph.getRange(range.start, range.end) if (rangeVertex === undefined) { @@ -653,6 +657,16 @@ export class NumericAggregationPlugin extends FunctionPlugin implements Function return value } + /** + * Checks whether both ends of a range point to existing sheets (placeholders excluded). + */ + private isSheetValid(range: AbsoluteCellRange): boolean { + return ( + this.dependencyGraph.sheetMapping.hasSheetWithId(range.start.sheet, {includePlaceholders: false}) && + this.dependencyGraph.sheetMapping.hasSheetWithId(range.end.sheet, {includePlaceholders: false}) + ) + } + /** * Returns list of values for given range and function name * @@ -687,6 +701,7 @@ export class NumericAggregationPlugin extends FunctionPlugin implements Function } else { actualRange = range } + for (const cellFromRange of actualRange.addresses(this.dependencyGraph)) { const val = coercionFunction(this.dependencyGraph.getScalarValue(cellFromRange)) if (val instanceof CellError) { diff --git a/src/parser/CellAddress.ts b/src/parser/CellAddress.ts index e1243d069..7013349d2 100644 --- a/src/parser/CellAddress.ts +++ b/src/parser/CellAddress.ts @@ -5,7 +5,7 @@ import { absoluteSheetReference, - invalidSimpleCellAddress, + isColOrRowInvalid, simpleCellAddress, SimpleCellAddress, simpleColumnAddress, @@ -155,7 +155,7 @@ export class CellAddress implements AddressWithColumn, AddressWithRow { } public isInvalid(baseAddress: SimpleCellAddress): boolean { - return invalidSimpleCellAddress(this.toSimpleCellAddress(baseAddress)) + return isColOrRowInvalid(this.toSimpleCellAddress(baseAddress)) } public shiftRelativeDimensions(toRight: number, toBottom: number): CellAddress { @@ -190,7 +190,7 @@ export class CellAddress implements AddressWithColumn, AddressWithRow { public unparse(baseAddress: SimpleCellAddress): Maybe { const simpleAddress = this.toSimpleCellAddress(baseAddress) - if (invalidSimpleCellAddress(simpleAddress)) { + if (isColOrRowInvalid(simpleAddress)) { return undefined } const column = columnIndexToLabel(simpleAddress.col) diff --git a/src/parser/FormulaParser.ts b/src/parser/FormulaParser.ts index 01d2d8d57..82c70e1c1 100644 --- a/src/parser/FormulaParser.ts +++ b/src/parser/FormulaParser.ts @@ -21,7 +21,7 @@ import { cellAddressFromString, columnAddressFromString, rowAddressFromString, - SheetMappingFn, + ResolveSheetReferenceFn, } from './addressRepresentationConverters' import { ArrayAst, @@ -130,12 +130,21 @@ export class FormulaParser extends EmbeddedActionsParser { private customParsingError?: ParsingError - private readonly sheetMapping: SheetMappingFn - /** * Cache for positiveAtomicExpression alternatives */ private atomicExpCache: Maybe + + constructor( + lexerConfig: LexerConfig, + private readonly resolveSheetReference: ResolveSheetReferenceFn, + ) { + super(lexerConfig.allTokens, {outputCst: false, maxLookahead: 7}) + this.lexerConfig = lexerConfig + this.formulaAddress = simpleCellAddress(0, 0, 0) + this.performSelfAnalysis() + } + private booleanExpressionOrEmpty: AstRule = this.RULE('booleanExpressionOrEmpty', () => { return this.OR([ {ALT: () => this.SUBRULE(this.booleanExpression)}, @@ -200,8 +209,8 @@ export class FormulaParser extends EmbeddedActionsParser { private columnRangeExpression: AstRule = this.RULE('columnRangeExpression', () => { const range = this.CONSUME(ColumnRange) as ExtendedToken const [startImage, endImage] = range.image.split(':') - const firstAddress = this.ACTION(() => columnAddressFromString(this.sheetMapping, startImage, this.formulaAddress)) - const secondAddress = this.ACTION(() => columnAddressFromString(this.sheetMapping, endImage, this.formulaAddress)) + const firstAddress = this.ACTION(() => columnAddressFromString(startImage, this.formulaAddress, this.resolveSheetReference)) + const secondAddress = this.ACTION(() => columnAddressFromString(endImage, this.formulaAddress, this.resolveSheetReference)) if (firstAddress === undefined || secondAddress === undefined) { return buildCellErrorAst(new CellError(ErrorType.REF)) @@ -226,8 +235,8 @@ export class FormulaParser extends EmbeddedActionsParser { private rowRangeExpression: AstRule = this.RULE('rowRangeExpression', () => { const range = this.CONSUME(RowRange) as ExtendedToken const [startImage, endImage] = range.image.split(':') - const firstAddress = this.ACTION(() => rowAddressFromString(this.sheetMapping, startImage, this.formulaAddress)) - const secondAddress = this.ACTION(() => rowAddressFromString(this.sheetMapping, endImage, this.formulaAddress)) + const firstAddress = this.ACTION(() => rowAddressFromString(startImage, this.formulaAddress, this.resolveSheetReference)) + const secondAddress = this.ACTION(() => rowAddressFromString(endImage, this.formulaAddress, this.resolveSheetReference)) if (firstAddress === undefined || secondAddress === undefined) { return buildCellErrorAst(new CellError(ErrorType.REF)) @@ -252,8 +261,9 @@ export class FormulaParser extends EmbeddedActionsParser { private cellReference: AstRule = this.RULE('cellReference', () => { const cell = this.CONSUME(CellReference) as ExtendedToken const address = this.ACTION(() => { - return cellAddressFromString(this.sheetMapping, cell.image, this.formulaAddress) + return cellAddressFromString(cell.image, this.formulaAddress, this.resolveSheetReference) }) + if (address === undefined) { return buildErrorWithRawInputAst(cell.image, new CellError(ErrorType.REF), cell.leadingWhitespace) } else if (address.exceedsSheetSizeLimits(this.lexerConfig.maxColumns, this.lexerConfig.maxRows)) { @@ -270,10 +280,10 @@ export class FormulaParser extends EmbeddedActionsParser { const end = this.CONSUME(CellReference) as ExtendedToken const startAddress = this.ACTION(() => { - return cellAddressFromString(this.sheetMapping, start.image, this.formulaAddress) + return cellAddressFromString(start.image, this.formulaAddress, this.resolveSheetReference) }) const endAddress = this.ACTION(() => { - return cellAddressFromString(this.sheetMapping, end.image, this.formulaAddress) + return cellAddressFromString(end.image, this.formulaAddress, this.resolveSheetReference) }) if (startAddress === undefined || endAddress === undefined) { @@ -306,7 +316,7 @@ export class FormulaParser extends EmbeddedActionsParser { ALT: () => { const offsetProcedure = this.SUBRULE(this.offsetProcedureExpression) const startAddress = this.ACTION(() => { - return cellAddressFromString(this.sheetMapping, start.image, this.formulaAddress) + return cellAddressFromString(start.image, this.formulaAddress, this.resolveSheetReference) }) if (startAddress === undefined) { return buildCellErrorAst(new CellError(ErrorType.REF)) @@ -337,7 +347,7 @@ export class FormulaParser extends EmbeddedActionsParser { const end = this.CONSUME(CellReference) as ExtendedToken const endAddress = this.ACTION(() => { - return cellAddressFromString(this.sheetMapping, end.image, this.formulaAddress) + return cellAddressFromString(end.image, this.formulaAddress, this.resolveSheetReference) }) if (endAddress === undefined) { @@ -451,14 +461,6 @@ export class FormulaParser extends EmbeddedActionsParser { ]) as Ast }) - constructor(lexerConfig: LexerConfig, sheetMapping: SheetMappingFn) { - super(lexerConfig.allTokens, {outputCst: false, maxLookahead: 7}) - this.lexerConfig = lexerConfig - this.sheetMapping = sheetMapping - this.formulaAddress = simpleCellAddress(0, 0, 0) - this.performSelfAnalysis() - } - /** * Parses tokenized formula and builds abstract syntax tree * @@ -829,7 +831,6 @@ export class FormulaParser extends EmbeddedActionsParser { } if (cellArg.reference.type === CellReferenceType.CELL_REFERENCE_RELATIVE || cellArg.reference.type === CellReferenceType.CELL_REFERENCE_ABSOLUTE_ROW) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion absoluteCol = absoluteCol + this.formulaAddress.col } diff --git a/src/parser/ParserWithCaching.ts b/src/parser/ParserWithCaching.ts index 84681e216..a116aa8e5 100644 --- a/src/parser/ParserWithCaching.ts +++ b/src/parser/ParserWithCaching.ts @@ -11,7 +11,7 @@ import { cellAddressFromString, columnAddressFromString, rowAddressFromString, - SheetMappingFn, + ResolveSheetReferenceFn, } from './addressRepresentationConverters' import {Ast, imageWithWhitespace, ParsingError, ParsingErrorType, RangeSheetReferenceType} from './Ast' import {binaryOpTokenMap} from './binaryOpTokenMap' @@ -52,11 +52,11 @@ export class ParserWithCaching { constructor( private readonly config: ParserConfig, private readonly functionRegistry: FunctionRegistry, - private readonly sheetMapping: SheetMappingFn, + private readonly resolveSheetReference: ResolveSheetReferenceFn, ) { this.lexerConfig = buildLexerConfig(config) this.lexer = new FormulaLexer(this.lexerConfig) - this.formulaParser = new FormulaParser(this.lexerConfig, this.sheetMapping) + this.formulaParser = new FormulaParser(this.lexerConfig, this.resolveSheetReference) this.cache = new Cache(this.functionRegistry) } @@ -232,7 +232,7 @@ export class ParserWithCaching { while (idx < tokens.length) { const token = tokens[idx] if (tokenMatcher(token, CellReference)) { - const cellAddress = cellAddressFromString(this.sheetMapping, token.image, baseAddress) + const cellAddress = cellAddressFromString(token.image, baseAddress, this.resolveSheetReference) if (cellAddress === undefined) { hash = hash.concat(token.image) } else { @@ -244,8 +244,8 @@ export class ParserWithCaching { hash = hash.concat(canonicalProcedureName, '(') } else if (tokenMatcher(token, ColumnRange)) { const [start, end] = token.image.split(':') - const startAddress = columnAddressFromString(this.sheetMapping, start, baseAddress) - const endAddress = columnAddressFromString(this.sheetMapping, end, baseAddress) + const startAddress = columnAddressFromString(start, baseAddress, this.resolveSheetReference) + const endAddress = columnAddressFromString(end, baseAddress, this.resolveSheetReference) if (startAddress === undefined || endAddress === undefined) { hash = hash.concat('!REF') } else { @@ -253,8 +253,8 @@ export class ParserWithCaching { } } else if (tokenMatcher(token, RowRange)) { const [start, end] = token.image.split(':') - const startAddress = rowAddressFromString(this.sheetMapping, start, baseAddress) - const endAddress = rowAddressFromString(this.sheetMapping, end, baseAddress) + const startAddress = rowAddressFromString(start, baseAddress, this.resolveSheetReference) + const endAddress = rowAddressFromString(end, baseAddress, this.resolveSheetReference) if (startAddress === undefined || endAddress === undefined) { hash = hash.concat('!REF') } else { diff --git a/src/parser/Unparser.ts b/src/parser/Unparser.ts index 425ed689c..d2154477d 100644 --- a/src/parser/Unparser.ts +++ b/src/parser/Unparser.ts @@ -4,9 +4,10 @@ */ import {ErrorType, SimpleCellAddress} from '../Cell' +import {SheetMapping} from '../DependencyGraph/SheetMapping' import {NoSheetWithIdError} from '../index' import {NamedExpressions} from '../NamedExpressions' -import {SheetIndexMappingFn, sheetIndexToString} from './addressRepresentationConverters' +import {sheetIndexToString} from './addressRepresentationConverters' import { Ast, AstNodeType, @@ -17,14 +18,12 @@ import { RowRangeAst, } from './Ast' import {binaryOpTokenMap} from './binaryOpTokenMap' -import {LexerConfig} from './LexerConfig' import {ParserConfig} from './ParserConfig' export class Unparser { constructor( private readonly config: ParserConfig, - private readonly lexerConfig: LexerConfig, - private readonly sheetMappingFn: SheetIndexMappingFn, + private readonly sheetMapping: SheetMapping, private readonly namedExpressions: NamedExpressions, ) { } @@ -109,8 +108,13 @@ export class Unparser { } } + /** + * Unparses a sheet name. + * @param {number} sheetId - the ID of the sheet + * @returns {string} the unparsed sheet name + */ private unparseSheetName(sheetId: number): string { - const sheetName = sheetIndexToString(sheetId, this.sheetMappingFn) + const sheetName = sheetIndexToString(sheetId, id => this.sheetMapping.getSheetNameOrThrowError(id, { includePlaceholders: true })) if (sheetName === undefined) { throw new NoSheetWithIdError(sheetId) } diff --git a/src/parser/addressRepresentationConverters.ts b/src/parser/addressRepresentationConverters.ts index b27188387..06f7cb6dd 100644 --- a/src/parser/addressRepresentationConverters.ts +++ b/src/parser/addressRepresentationConverters.ts @@ -11,8 +11,8 @@ import {ColumnAddress} from './ColumnAddress' import {ABSOLUTE_OPERATOR, RANGE_OPERATOR, SHEET_NAME_PATTERN, UNQUOTED_SHEET_NAME_PATTERN} from './parser-consts' import {RowAddress} from './RowAddress' -export type SheetMappingFn = (sheetName: string) => Maybe export type SheetIndexMappingFn = (sheetIndex: number) => Maybe +export type ResolveSheetReferenceFn = (sheetName: string) => Maybe const addressRegex = new RegExp(`^(${SHEET_NAME_PATTERN})?(\\${ABSOLUTE_OPERATOR}?)([A-Za-z]+)(\\${ABSOLUTE_OPERATOR}?)([0-9]+)$`) const columnRegex = new RegExp(`^(${SHEET_NAME_PATTERN})?(\\${ABSOLUTE_OPERATOR}?)([A-Za-z]+)$`) @@ -22,26 +22,27 @@ const simpleSheetNameRegex = new RegExp(`^${UNQUOTED_SHEET_NAME_PATTERN}$`) /** * Computes R0C0 representation of cell address based on it's string representation and base address. * - * @param sheetMapping - mapping function needed to change name of a sheet to index - * @param stringAddress - string representation of cell address, e.g., 'C64' - * @param baseAddress - base address for R0C0 conversion - * @returns object representation of address + * @param {string} stringAddress - string representation of cell address, e.g., 'C64' + * @param {SimpleCellAddress} baseAddress - base address for R0C0 conversion + * @param {ResolveSheetReferenceFn} resolveSheetReference - mapping function needed to change name of a sheet to index + * @returns {Maybe} object representation of address or `undefined` if the sheet cannot be resolved */ -export const cellAddressFromString = (sheetMapping: SheetMappingFn, stringAddress: string, baseAddress: SimpleCellAddress): Maybe => { - const result = addressRegex.exec(stringAddress)! +export const cellAddressFromString = (stringAddress: string, baseAddress: SimpleCellAddress, resolveSheetReference: ResolveSheetReferenceFn): Maybe => { + const result = addressRegex.exec(stringAddress) - const col = columnLabelToIndex(result[6]) - - let sheet = extractSheetNumber(result, sheetMapping) - if (sheet === undefined) { + if (!result) { return undefined } + const col = columnLabelToIndex(result[6]) + const row = Number(result[8]) - 1 + const sheetName = extractSheetName(result) + const sheet = sheetNameToId(sheetName, resolveSheetReference) + if (sheet === null) { - sheet = undefined + return undefined } - const row = Number(result[8]) - 1 if (result[5] === ABSOLUTE_OPERATOR && result[7] === ABSOLUTE_OPERATOR) { return CellAddress.absolute(col, row, sheet) } else if (result[5] === ABSOLUTE_OPERATOR) { @@ -53,20 +54,21 @@ export const cellAddressFromString = (sheetMapping: SheetMappingFn, stringAddres } } -export const columnAddressFromString = (sheetMapping: SheetMappingFn, stringAddress: string, baseAddress: SimpleCellAddress): Maybe => { - const result = columnRegex.exec(stringAddress)! +export const columnAddressFromString = (stringAddress: string, baseAddress: SimpleCellAddress, resolveSheetReference: ResolveSheetReferenceFn): Maybe => { + const result = columnRegex.exec(stringAddress) - let sheet = extractSheetNumber(result, sheetMapping) - if (sheet === undefined) { + if (!result) { return undefined } + const col = columnLabelToIndex(result[6]) + const sheetName = extractSheetName(result) + const sheet = sheetNameToId(sheetName, resolveSheetReference) + if (sheet === null) { - sheet = undefined + return undefined } - const col = columnLabelToIndex(result[6]) - if (result[5] === ABSOLUTE_OPERATOR) { return ColumnAddress.absolute(col, sheet) } else { @@ -74,20 +76,21 @@ export const columnAddressFromString = (sheetMapping: SheetMappingFn, stringAddr } } -export const rowAddressFromString = (sheetMapping: SheetMappingFn, stringAddress: string, baseAddress: SimpleCellAddress): Maybe => { - const result = rowRegex.exec(stringAddress)! +export const rowAddressFromString = (stringAddress: string, baseAddress: SimpleCellAddress, resolveSheetReference: ResolveSheetReferenceFn): Maybe => { + const result = rowRegex.exec(stringAddress) - let sheet = extractSheetNumber(result, sheetMapping) - if (sheet === undefined) { + if (!result) { return undefined } + const row = Number(result[6]) - 1 + const sheetName = extractSheetName(result) + const sheet = sheetNameToId(sheetName, resolveSheetReference) + if (sheet === null) { - sheet = undefined + return undefined } - const row = Number(result[6]) - 1 - if (result[5] === ABSOLUTE_OPERATOR) { return RowAddress.absolute(row, sheet) } else { @@ -100,44 +103,43 @@ export const rowAddressFromString = (sheetMapping: SheetMappingFn, stringAddress * - If sheet name is present in the string representation but is not present in sheet mapping, returns `undefined`. * - If sheet name is not present in the string representation, returns {@param contextSheetId} as sheet number. * - * @param sheetMapping - mapping function needed to change name of a sheet to index - * @param stringAddress - string representation of cell address, e.g., 'C64' - * @param contextSheetId - sheet in context of which we should parse the address - * @returns absolute representation of address, e.g., { sheet: 0, col: 1, row: 1 } + * @param {ResolveSheetReferenceFn} resolveSheetReference - mapping function needed to change name of a sheet to index + * @param {string} stringAddress - string representation of cell address, e.g., 'C64' + * @param {number} contextSheetId - sheet in context of which we should parse the address + * @returns {Maybe} absolute representation of address, e.g., { sheet: 0, col: 1, row: 1 } */ -export const simpleCellAddressFromString = (sheetMapping: SheetMappingFn, stringAddress: string, contextSheetId: number): Maybe => { - const regExpExecArray = addressRegex.exec(stringAddress)! +export const simpleCellAddressFromString = (resolveSheetReference: ResolveSheetReferenceFn, stringAddress: string, contextSheetId: number): Maybe => { + const regExpExecArray = addressRegex.exec(stringAddress) if (!regExpExecArray) { return undefined } const col = columnLabelToIndex(regExpExecArray[6]) + const row = Number(regExpExecArray[8]) - 1 + const sheetName = extractSheetName(regExpExecArray) + const sheet = sheetNameToId(sheetName, resolveSheetReference) - let sheet = extractSheetNumber(regExpExecArray, sheetMapping) - if (sheet === undefined) { + if (sheet === null) { return undefined } - if (sheet === null) { - sheet = contextSheetId - } + const effectiveSheet = sheet === undefined ? contextSheetId : sheet - const row = Number(regExpExecArray[8]) - 1 - return simpleCellAddress(sheet, col, row) + return simpleCellAddress(effectiveSheet, col, row) } -export const simpleCellRangeFromString = (sheetMapping: SheetMappingFn, stringAddress: string, contextSheetId: number): Maybe => { +export const simpleCellRangeFromString = (resolveSheetReference: ResolveSheetReferenceFn, stringAddress: string, contextSheetId: number): Maybe => { const split = stringAddress.split(RANGE_OPERATOR) if (split.length !== 2) { return undefined } const [startString, endString] = split - const start = simpleCellAddressFromString(sheetMapping, startString, contextSheetId) + const start = simpleCellAddressFromString(resolveSheetReference, startString, contextSheetId) if (start === undefined) { return undefined } - const end = simpleCellAddressFromString(sheetMapping, endString, start.sheet) + const end = simpleCellAddressFromString(resolveSheetReference, endString, start.sheet) if (end === undefined) { return undefined } @@ -150,10 +152,6 @@ export const simpleCellRangeFromString = (sheetMapping: SheetMappingFn, stringAd /** * Returns string representation of absolute address * If sheet index is not present in sheet mapping, returns undefined - * - * @param sheetIndexMapping - mapping function needed to change sheet index to sheet name - * @param address - object representation of absolute address - * @param sheetIndex - if is not equal with address sheet index, string representation will contain sheet name */ export const simpleCellAddressToString = (sheetIndexMapping: SheetIndexMappingFn, address: SimpleCellAddress, sheetIndex: number): Maybe => { const column = columnIndexToLabel(address.col) @@ -227,13 +225,25 @@ export function sheetIndexToString(sheetId: number, sheetMappingFn: SheetIndexMa } } -function extractSheetNumber(regexResult: RegExpExecArray, sheetMapping: SheetMappingFn): number | null | undefined { - let maybeSheetName = regexResult[3] ?? regexResult[2] +function extractSheetName(regexResult: RegExpExecArray): string | null { + const maybeSheetName = regexResult[3] ?? regexResult[2] - if (maybeSheetName) { - maybeSheetName = maybeSheetName.replace(/''/g, "'") - return sheetMapping(maybeSheetName) - } else { - return null + return maybeSheetName ? maybeSheetName.replace(/''/g, "'") : null +} + +/** + * Resolves sheet name to sheet id. + * + * @param sheetName - extracted sheet name or null when not provided. + * @param resolveSheetReference - mapping function resolving sheet name to id. + * @returns sheet id, undefined when sheet name absent, null when resolution fails. + */ +function sheetNameToId(sheetName: string | null, resolveSheetReference: ResolveSheetReferenceFn): Maybe | null { + if (!sheetName) { + return undefined } + + const sheetId = resolveSheetReference(sheetName) + + return sheetId === undefined ? null : sheetId } diff --git a/test/unit/CellValueExporter.spec.ts b/test/unit/CellValueExporter.spec.ts index 8f7df4f06..ca1daf8cd 100644 --- a/test/unit/CellValueExporter.spec.ts +++ b/test/unit/CellValueExporter.spec.ts @@ -1,24 +1,24 @@ import {DetailedCellError, ErrorType, HyperFormula} from '../../src' import {CellError} from '../../src/Cell' import {Config} from '../../src/Config' +import { SheetMapping } from '../../src/DependencyGraph' import {ErrorMessage} from '../../src/error-message' import {Exporter} from '../../src/Exporter' import {plPL} from '../../src/i18n/languages' import {EmptyValue} from '../../src/interpreter/InterpreterValue' import {LazilyTransformingAstService} from '../../src/LazilyTransformingAstService' import {NamedExpressions} from '../../src/NamedExpressions' -import {SheetIndexMappingFn} from '../../src/parser/addressRepresentationConverters' import {EmptyStatistics} from '../../src/statistics' import {detailedError} from './testUtils' const namedExpressionsMock = {} as NamedExpressions -const sheetIndexMock = {} as SheetIndexMappingFn +const sheetMappingMock = {} as SheetMapping const lazilyTransforminService = new LazilyTransformingAstService(new EmptyStatistics()) describe('rounding', () => { it('no rounding', () => { const config = new Config({smartRounding: false}) - const cellValueExporter = new Exporter(config, namedExpressionsMock, sheetIndexMock, lazilyTransforminService) + const cellValueExporter = new Exporter(config, namedExpressionsMock, sheetMappingMock, lazilyTransforminService) expect(cellValueExporter.exportValue(1.000000000000001)).toBe(1.000000000000001) expect(cellValueExporter.exportValue(-1.000000000000001)).toBe(-1.000000000000001) expect(cellValueExporter.exportValue(0.000000000000001)).toBe(0.000000000000001) @@ -32,7 +32,7 @@ describe('rounding', () => { it('with rounding', () => { const config = new Config() - const cellValueExporter = new Exporter(config, namedExpressionsMock, sheetIndexMock, lazilyTransforminService) + const cellValueExporter = new Exporter(config, namedExpressionsMock, sheetMappingMock, lazilyTransforminService) expect(cellValueExporter.exportValue(1.000000001)).toBe(1.000000001) expect(cellValueExporter.exportValue(-1.000000001)).toBe(-1.000000001) expect(cellValueExporter.exportValue(1.00000000001)).toBe(1) @@ -50,7 +50,7 @@ describe('rounding', () => { describe('detailed error', () => { it('should return detailed errors', () => { const config = new Config({language: 'enGB'}) - const cellValueExporter = new Exporter(config, namedExpressionsMock, sheetIndexMock, lazilyTransforminService) + const cellValueExporter = new Exporter(config, namedExpressionsMock, sheetMappingMock, lazilyTransforminService) const error = cellValueExporter.exportValue(new CellError(ErrorType.VALUE)) as DetailedCellError expect(error).toEqualError(detailedError(ErrorType.VALUE)) @@ -60,7 +60,7 @@ describe('detailed error', () => { it('should return detailed errors with translation', () => { HyperFormula.registerLanguage('plPL', plPL) const config = new Config({language: 'plPL'}) - const cellValueExporter = new Exporter(config, namedExpressionsMock, sheetIndexMock, lazilyTransforminService) + const cellValueExporter = new Exporter(config, namedExpressionsMock, sheetMappingMock, lazilyTransforminService) const error = cellValueExporter.exportValue(new CellError(ErrorType.VALUE)) as DetailedCellError expect(error).toEqualError(detailedError(ErrorType.VALUE, undefined, config)) diff --git a/test/unit/address-mapping.spec.ts b/test/unit/address-mapping.spec.ts index 02014a6ce..496c714d5 100644 --- a/test/unit/address-mapping.spec.ts +++ b/test/unit/address-mapping.spec.ts @@ -16,7 +16,7 @@ const sharedExamples = (builder: (width: number, height: number) => AddressMappi mapping.setCell(address, vertex) - expect(mapping.fetchCell(address)).toBe(vertex) + expect(mapping.getCell(address)).toBe(vertex) }) it('set and using different reference when get', () => { @@ -25,7 +25,7 @@ const sharedExamples = (builder: (width: number, height: number) => AddressMappi mapping.setCell(adr('A1'), vertex) - expect(mapping.fetchCell(adr('A1'))).toBe(vertex) + expect(mapping.getCell(adr('A1'))).toBe(vertex) }) it("get when there's even no column", () => { @@ -141,8 +141,8 @@ const sharedExamples = (builder: (width: number, height: number) => AddressMappi mapping.setCell(adr('A2'), vertex1) - expect(mapping.fetchCell(adr('A1'))).toBe(vertex0) - expect(mapping.fetchCell(adr('A2'))).toBe(vertex1) + expect(mapping.getCell(adr('A1'))).toBe(vertex0) + expect(mapping.getCell(adr('A2'))).toBe(vertex1) }) it('set overrides old value', () => { @@ -153,7 +153,7 @@ const sharedExamples = (builder: (width: number, height: number) => AddressMappi mapping.setCell(adr('A1'), vertex1) - expect(mapping.fetchCell(adr('A1'))).toBe(vertex1) + expect(mapping.getCell(adr('A1'))).toBe(vertex1) }) it("has when there's even no column", () => { @@ -192,8 +192,8 @@ const sharedExamples = (builder: (width: number, height: number) => AddressMappi mapping.addRows(0, 0, 1) expect(mapping.getCell(adr('A1'))).toBe(undefined) - expect(mapping.fetchCell(adr('A2'))).toEqual(new ValueCellVertex(42, 42)) - expect(mapping.getHeight(0)).toEqual(2) + expect(mapping.getCell(adr('A2'))).toEqual(new ValueCellVertex(42, 42)) + expect(mapping.getSheetHeight(0)).toEqual(2) }) it('addRows in the middle of a mapping', () => { @@ -204,10 +204,10 @@ const sharedExamples = (builder: (width: number, height: number) => AddressMappi mapping.addRows(0, 1, 1) - expect(mapping.fetchCell(adr('A1'))).toEqual(new ValueCellVertex(42, 42)) + expect(mapping.getCell(adr('A1'))).toEqual(new ValueCellVertex(42, 42)) expect(mapping.getCell(adr('A2'))).toBe(undefined) - expect(mapping.fetchCell(adr('A3'))).toEqual(new ValueCellVertex(43, 43)) - expect(mapping.getHeight(0)).toEqual(3) + expect(mapping.getCell(adr('A3'))).toEqual(new ValueCellVertex(43, 43)) + expect(mapping.getSheetHeight(0)).toEqual(3) }) it('addRows in the end of a mapping', () => { @@ -217,9 +217,9 @@ const sharedExamples = (builder: (width: number, height: number) => AddressMappi mapping.addRows(0, 1, 1) - expect(mapping.fetchCell(adr('A1'))).toEqual(new ValueCellVertex(42, 42)) + expect(mapping.getCell(adr('A1'))).toEqual(new ValueCellVertex(42, 42)) expect(mapping.getCell(adr('A2'))).toBe(undefined) - expect(mapping.getHeight(0)).toEqual(2) + expect(mapping.getSheetHeight(0)).toEqual(2) }) it('addRows more than one row', () => { @@ -230,12 +230,12 @@ const sharedExamples = (builder: (width: number, height: number) => AddressMappi mapping.addRows(0, 1, 3) - expect(mapping.fetchCell(adr('A1'))).toEqual(new ValueCellVertex(42, 42)) + expect(mapping.getCell(adr('A1'))).toEqual(new ValueCellVertex(42, 42)) expect(mapping.getCell(adr('A2'))).toBe(undefined) expect(mapping.getCell(adr('A3'))).toBe(undefined) expect(mapping.getCell(adr('A4'))).toBe(undefined) - expect(mapping.fetchCell(adr('A5'))).toEqual(new ValueCellVertex(43, 43)) - expect(mapping.getHeight(0)).toEqual(5) + expect(mapping.getCell(adr('A5'))).toEqual(new ValueCellVertex(43, 43)) + expect(mapping.getSheetHeight(0)).toEqual(5) }) it('addRows when more than one column present', () => { @@ -248,13 +248,13 @@ const sharedExamples = (builder: (width: number, height: number) => AddressMappi mapping.addRows(0, 1, 1) - expect(mapping.fetchCell(adr('A1'))).toEqual(new ValueCellVertex(11, 11)) - expect(mapping.fetchCell(adr('B1'))).toEqual(new ValueCellVertex(12, 12)) + expect(mapping.getCell(adr('A1'))).toEqual(new ValueCellVertex(11, 11)) + expect(mapping.getCell(adr('B1'))).toEqual(new ValueCellVertex(12, 12)) expect(mapping.getCell(adr('A2'))).toBe(undefined) expect(mapping.getCell(adr('B2'))).toBe(undefined) - expect(mapping.fetchCell(adr('A3'))).toEqual(new ValueCellVertex(21, 21)) - expect(mapping.fetchCell(adr('B3'))).toEqual(new ValueCellVertex(22, 22)) - expect(mapping.getHeight(0)).toEqual(3) + expect(mapping.getCell(adr('A3'))).toEqual(new ValueCellVertex(21, 21)) + expect(mapping.getCell(adr('B3'))).toEqual(new ValueCellVertex(22, 22)) + expect(mapping.getSheetHeight(0)).toEqual(3) }) it('removeRows - one row', () => { @@ -264,9 +264,9 @@ const sharedExamples = (builder: (width: number, height: number) => AddressMappi mapping.setCell(adr('A2'), new ValueCellVertex(21, 21)) mapping.setCell(adr('B2'), new ValueCellVertex(22, 22)) - expect(mapping.getHeight(0)).toBe(2) + expect(mapping.getSheetHeight(0)).toBe(2) mapping.removeRows(new RowsSpan(0, 0, 0)) - expect(mapping.getHeight(0)).toBe(1) + expect(mapping.getSheetHeight(0)).toBe(1) expect(mapping.getCellValue(adr('A1'))).toBe(21) expect(mapping.getCellValue(adr('B1'))).toBe(22) }) @@ -282,9 +282,9 @@ const sharedExamples = (builder: (width: number, height: number) => AddressMappi mapping.setCell(adr('A4'), new ValueCellVertex(41, 41)) mapping.setCell(adr('B4'), new ValueCellVertex(42, 42)) - expect(mapping.getHeight(0)).toBe(4) + expect(mapping.getSheetHeight(0)).toBe(4) mapping.removeRows(new RowsSpan(0, 1, 2)) - expect(mapping.getHeight(0)).toBe(2) + expect(mapping.getSheetHeight(0)).toBe(2) expect(mapping.getCellValue(adr('A1'))).toBe(11) expect(mapping.getCellValue(adr('A2'))).toBe(41) }) @@ -296,9 +296,9 @@ const sharedExamples = (builder: (width: number, height: number) => AddressMappi mapping.setCell(adr('A2'), new ValueCellVertex(21, 21)) mapping.setCell(adr('B2'), new ValueCellVertex(22, 22)) - expect(mapping.getHeight(0)).toBe(2) + expect(mapping.getSheetHeight(0)).toBe(2) mapping.removeRows(new RowsSpan(0, 0, 5)) - expect(mapping.getHeight(0)).toBe(0) + expect(mapping.getSheetHeight(0)).toBe(0) expect(mapping.has(adr('A1'))).toBe(false) }) @@ -311,7 +311,7 @@ const sharedExamples = (builder: (width: number, height: number) => AddressMappi mapping.removeRows(new RowsSpan(0, 1, 5)) - expect(mapping.getHeight(0)).toBe(1) + expect(mapping.getSheetHeight(0)).toBe(1) expect(mapping.has(adr('A1'))).toBe(true) expect(mapping.has(adr('A2'))).toBe(false) }) @@ -325,7 +325,7 @@ const sharedExamples = (builder: (width: number, height: number) => AddressMappi mapping.removeRows(new RowsSpan(0, 2, 3)) - expect(mapping.getHeight(0)).toBe(2) + expect(mapping.getSheetHeight(0)).toBe(2) expect(mapping.has(adr('A1'))).toBe(true) expect(mapping.has(adr('A2'))).toBe(true) }) @@ -341,9 +341,9 @@ const sharedExamples = (builder: (width: number, height: number) => AddressMappi mapping.setCell(adr('D1'), new ValueCellVertex(41, 41)) mapping.setCell(adr('D2'), new ValueCellVertex(42, 42)) - expect(mapping.getWidth(0)).toBe(4) + expect(mapping.getSheetWidth(0)).toBe(4) mapping.removeColumns(new ColumnsSpan(0, 1, 2)) - expect(mapping.getWidth(0)).toBe(2) + expect(mapping.getSheetWidth(0)).toBe(2) expect(mapping.getCellValue(adr('A1'))).toBe(11) expect(mapping.getCellValue(adr('B1'))).toBe(41) }) @@ -355,9 +355,9 @@ const sharedExamples = (builder: (width: number, height: number) => AddressMappi mapping.setCell(adr('A2'), new ValueCellVertex(21, 21)) mapping.setCell(adr('B2'), new ValueCellVertex(22, 22)) - expect(mapping.getHeight(0)).toBe(2) + expect(mapping.getSheetHeight(0)).toBe(2) mapping.removeColumns(new ColumnsSpan(0, 0, 5)) - expect(mapping.getWidth(0)).toBe(0) + expect(mapping.getSheetWidth(0)).toBe(0) expect(mapping.has(adr('A1'))).toBe(false) }) @@ -370,7 +370,7 @@ const sharedExamples = (builder: (width: number, height: number) => AddressMappi mapping.removeColumns(new ColumnsSpan(0, 1, 5)) - expect(mapping.getWidth(0)).toBe(1) + expect(mapping.getSheetWidth(0)).toBe(1) expect(mapping.has(adr('A1'))).toBe(true) expect(mapping.has(adr('B1'))).toBe(false) }) @@ -384,7 +384,7 @@ const sharedExamples = (builder: (width: number, height: number) => AddressMappi mapping.removeColumns(new ColumnsSpan(0, 2, 3)) - expect(mapping.getWidth(0)).toBe(2) + expect(mapping.getSheetWidth(0)).toBe(2) expect(mapping.has(adr('A1'))).toBe(true) expect(mapping.has(adr('B1'))).toBe(true) }) @@ -392,13 +392,13 @@ const sharedExamples = (builder: (width: number, height: number) => AddressMappi it('should expand columns when adding cell', () => { const mapping = builder(2, 2) mapping.setCell(adr('C1'), new EmptyCellVertex()) - expect(mapping.getWidth(0)).toBe(3) + expect(mapping.getSheetWidth(0)).toBe(3) }) it('should expand rows when adding cell', () => { const mapping = builder(2, 2) mapping.setCell(adr('A3'), new EmptyCellVertex()) - expect(mapping.getHeight(0)).toBe(3) + expect(mapping.getSheetHeight(0)).toBe(3) }) it('should move cell from source to destination', () => { @@ -425,11 +425,14 @@ const sharedExamples = (builder: (width: number, height: number) => AddressMappi expect(() => mapping.moveCell(adr('A1'), adr('A2'))).toThrowError('Cannot move cell. Destination already occupied.') }) - it('should throw error when trying to move vertices between sheets', () => { + it('should move vertices between sheets', () => { const mapping = builder(1, 2) mapping.setCell(adr('A1', 0), new ValueCellVertex(42, 42)) - expect(() => mapping.moveCell(adr('A1', 0), adr('A2', 1))).toThrowError('Cannot move cells between sheets.') + mapping.moveCell(adr('A1', 0), adr('A2', 1)) + + expect(mapping.has(adr('A1', 0))).toEqual(false) + expect(mapping.has(adr('A2', 1))).toEqual(true) }) it('should throw error when trying to move vertices in non-existing sheet', () => { @@ -441,10 +444,10 @@ const sharedExamples = (builder: (width: number, height: number) => AddressMappi it('entriesFromColumnsSpan returns the same result regardless of the strategy', () => { const denseMapping = new AddressMapping(new AlwaysDense()) - denseMapping.addSheet(0, new DenseStrategy(5, 5)) + denseMapping.addSheetWithStrategy(0, new DenseStrategy(5, 5)) const sparseMapping = new AddressMapping(new AlwaysSparse()) - sparseMapping.addSheet(0, new SparseStrategy(5, 5)) + sparseMapping.addSheetWithStrategy(0, new SparseStrategy(5, 5)) const mappingsAndResults: { mapping: AddressMapping, results: String[][] }[] = [ { @@ -487,25 +490,25 @@ const sharedExamples = (builder: (width: number, height: number) => AddressMappi describe('SparseStrategy', () => { sharedExamples((maxCol: number, maxRow: number) => { const mapping = new AddressMapping(new AlwaysSparse()) - mapping.addSheet(0, new SparseStrategy(maxCol, maxRow)) - mapping.addSheet(1, new SparseStrategy(maxCol, maxRow)) + mapping.addSheetWithStrategy(0, new SparseStrategy(maxCol, maxRow)) + mapping.addSheetWithStrategy(1, new SparseStrategy(maxCol, maxRow)) return mapping }) it('returns maximum row/col for simplest case', () => { const mapping = new AddressMapping(new AlwaysSparse()) - mapping.addSheet(0, new SparseStrategy(4, 16)) + mapping.addSheetWithStrategy(0, new SparseStrategy(4, 16)) mapping.setCell(adr('D16'), new ValueCellVertex(42, 42)) - expect(mapping.getHeight(0)).toEqual(16) - expect(mapping.getWidth(0)).toEqual(4) + expect(mapping.getSheetHeight(0)).toEqual(16) + expect(mapping.getSheetWidth(0)).toEqual(4) }) it('get all vertices', () => { const mapping = new AddressMapping(new AlwaysSparse()) const sparseStrategy = new SparseStrategy(3, 3) - mapping.addSheet(0, sparseStrategy) + mapping.addSheetWithStrategy(0, sparseStrategy) mapping.setCell(adr('A1', 0), new ValueCellVertex(42, 42)) mapping.setCell(adr('A2', 0), new ValueCellVertex(43, 43)) @@ -530,7 +533,7 @@ describe('SparseStrategy', () => { it('get all vertices - from column', () => { const mapping = new AddressMapping(new AlwaysSparse()) const sparseStrategy = new SparseStrategy(3, 3) - mapping.addSheet(0, sparseStrategy) + mapping.addSheetWithStrategy(0, sparseStrategy) mapping.setCell(adr('A1', 0), new ValueCellVertex(42, 42)) mapping.setCell(adr('A2', 0), new ValueCellVertex(43, 43)) @@ -559,7 +562,7 @@ describe('SparseStrategy', () => { it('get all vertices - from row', () => { const mapping = new AddressMapping(new AlwaysSparse()) const sparseStrategy = new SparseStrategy(3, 3) - mapping.addSheet(0, sparseStrategy) + mapping.addSheetWithStrategy(0, sparseStrategy) mapping.setCell(adr('A1', 0), new ValueCellVertex(42, 42)) mapping.setCell(adr('A2', 0), new ValueCellVertex(43, 43)) @@ -589,23 +592,23 @@ describe('SparseStrategy', () => { describe('DenseStrategy', () => { sharedExamples((maxCol, maxRow) => { const mapping = new AddressMapping(new AlwaysDense()) - mapping.addSheet(0, new DenseStrategy(maxCol, maxRow)) - mapping.addSheet(1, new DenseStrategy(maxCol, maxRow)) + mapping.addSheetWithStrategy(0, new DenseStrategy(maxCol, maxRow)) + mapping.addSheetWithStrategy(1, new DenseStrategy(maxCol, maxRow)) return mapping }) it('returns maximum row/col for simplest case', () => { const mapping = new AddressMapping(new AlwaysDense()) - mapping.addSheet(0, new DenseStrategy(1, 2)) + mapping.addSheetWithStrategy(0, new DenseStrategy(1, 2)) - expect(mapping.getHeight(0)).toEqual(2) - expect(mapping.getWidth(0)).toEqual(1) + expect(mapping.getSheetHeight(0)).toEqual(2) + expect(mapping.getSheetWidth(0)).toEqual(1) }) it('get all vertices', () => { const mapping = new AddressMapping(new AlwaysDense()) const denseStratgey = new DenseStrategy(3, 3) - mapping.addSheet(0, denseStratgey) + mapping.addSheetWithStrategy(0, denseStratgey) mapping.setCell(adr('A1', 0), new ValueCellVertex(42, 42)) mapping.setCell(adr('A2', 0), new ValueCellVertex(43, 43)) @@ -630,7 +633,7 @@ describe('DenseStrategy', () => { it('get all vertices - from column', () => { const mapping = new AddressMapping(new AlwaysDense()) const denseStratgey = new DenseStrategy(3, 3) - mapping.addSheet(0, denseStratgey) + mapping.addSheetWithStrategy(0, denseStratgey) mapping.setCell(adr('A1', 0), new ValueCellVertex(42, 42)) mapping.setCell(adr('A2', 0), new ValueCellVertex(43, 43)) @@ -659,7 +662,7 @@ describe('DenseStrategy', () => { it('get all vertices - from row', () => { const mapping = new AddressMapping(new AlwaysDense()) const denseStratgey = new DenseStrategy(3, 3) - mapping.addSheet(0, denseStratgey) + mapping.addSheetWithStrategy(0, denseStratgey) mapping.setCell(adr('A1', 0), new ValueCellVertex(42, 42)) mapping.setCell(adr('A2', 0), new ValueCellVertex(43, 43)) @@ -693,9 +696,9 @@ describe('AddressMapping', () => { [null, null, null], [null, null, '1'], ] - addressMapping.autoAddSheet(0, findBoundaries(sheet)) + addressMapping.addSheetAndSetStrategyBasedOnBoundaries(0, findBoundaries(sheet)) - expect(addressMapping.strategyFor(0)).toBeInstanceOf(SparseStrategy) + expect(addressMapping.getStrategyForSheetOrThrow(0)).toBeInstanceOf(SparseStrategy) }) it('#buildAddresMapping - when dense matrix', () => { @@ -704,8 +707,8 @@ describe('AddressMapping', () => { ['1', '1'], ['1', '1'], ] - addressMapping.autoAddSheet(0, findBoundaries(sheet)) + addressMapping.addSheetAndSetStrategyBasedOnBoundaries(0, findBoundaries(sheet)) - expect(addressMapping.strategyFor(0)).toBeInstanceOf(DenseStrategy) + expect(addressMapping.getStrategyForSheetOrThrow(0)).toBeInstanceOf(DenseStrategy) }) }) diff --git a/test/unit/arrays.spec.ts b/test/unit/arrays.spec.ts index 5300ad9f5..71d29cc94 100644 --- a/test/unit/arrays.spec.ts +++ b/test/unit/arrays.spec.ts @@ -1,6 +1,6 @@ import {ErrorType, HyperFormula} from '../../src' import {ArraySize} from '../../src/ArraySize' -import {ArrayVertex, ValueCellVertex} from '../../src/DependencyGraph' +import {ArrayFormulaVertex, ValueCellVertex} from '../../src/DependencyGraph' import {ErrorMessage} from '../../src/error-message' import {adr, detailedError, detailedErrorWithOrigin, expectVerticesOfTypes, noSpace} from './testUtils' @@ -363,8 +363,8 @@ describe('build from array', () => { ], {useArrayArithmetic: true}) expectVerticesOfTypes(engine, [ - [ArrayVertex, ArrayVertex], - [ArrayVertex, ArrayVertex], + [ArrayFormulaVertex, ArrayFormulaVertex], + [ArrayFormulaVertex, ArrayFormulaVertex], ]) }) @@ -375,9 +375,9 @@ describe('build from array', () => { ], {useArrayArithmetic: true}) expectVerticesOfTypes(engine, [ - [ArrayVertex, ArrayVertex, undefined], - [ArrayVertex, ArrayVertex, ArrayVertex], - [undefined, ArrayVertex, ArrayVertex], + [ArrayFormulaVertex, ArrayFormulaVertex, undefined], + [ArrayFormulaVertex, ArrayFormulaVertex, ArrayFormulaVertex], + [undefined, ArrayFormulaVertex, ArrayFormulaVertex], ]) expect(engine.arrayMapping.arrayMapping.size).toEqual(4) }) @@ -389,8 +389,8 @@ describe('build from array', () => { ], {useArrayArithmetic: true}) expectVerticesOfTypes(engine, [ - [ArrayVertex, ArrayVertex, ArrayVertex], - [ArrayVertex, ArrayVertex, ArrayVertex], + [ArrayFormulaVertex, ArrayFormulaVertex, ArrayFormulaVertex], + [ArrayFormulaVertex, ArrayFormulaVertex, ArrayFormulaVertex], [undefined, undefined], ]) expect(engine.getSheetValues(0)).toEqual([ @@ -431,8 +431,8 @@ describe('build from array', () => { ], {useArrayArithmetic: true}) expectVerticesOfTypes(engine, [ - [ArrayVertex, ArrayVertex], - [ArrayVertex, ArrayVertex], + [ArrayFormulaVertex, ArrayFormulaVertex], + [ArrayFormulaVertex, ArrayFormulaVertex], ]) }) @@ -445,7 +445,7 @@ describe('build from array', () => { expect(engine.arrayMapping.getArrayByCorner(adr('A1'))?.array.size).toEqual(ArraySize.error()) expectVerticesOfTypes(engine, [ - [ArrayVertex, undefined], + [ArrayFormulaVertex, undefined], [ValueCellVertex, undefined], ]) }) @@ -507,4 +507,31 @@ describe('column ranges', () => { expect(engine.getCellValue(adr('D1'))).toEqual(3) }) + + it('should handle array shrinking when dependent is a value cell', () => { + const engine = HyperFormula.buildFromArray([ + [1, 2], + [3, 4], + ['=TRANSPOSE(A1:B2)'], + ], {useArrayArithmetic: true}) + + expect(engine.getCellValue(adr('A3'))).toBe(1) + expect(engine.getCellValue(adr('A4'))).toBe(2) + expect(engine.getCellValue(adr('B3'))).toBe(3) + expect(engine.getCellValue(adr('B4'))).toBe(4) + + engine.setCellContents(adr('B3'), 'obstructing value') + + expect(engine.getCellValue(adr('A3'))).toEqualError(detailedError(ErrorType.SPILL, ErrorMessage.NoSpaceForArrayResult)) + }) + + it('should correctly set address mapping for scalar formula', () => { + const engine = HyperFormula.buildFromArray([ + ['=1+1'], + ]) + + expect(engine.getCellValue(adr('A1'))).toBe(2) + expect(engine.addressMapping.getCell(adr('A1'))).toBeDefined() + expect(engine.addressMapping.getCell(adr('B1'))).toBeUndefined() + }) }) diff --git a/test/unit/build-engine.spec.ts b/test/unit/build-engine.spec.ts index cf688447f..0e16c9448 100644 --- a/test/unit/build-engine.spec.ts +++ b/test/unit/build-engine.spec.ts @@ -56,14 +56,16 @@ describe('Building engine from arrays', () => { }) it('should allow to create sheets with a delay', () => { - const engine1 = HyperFormula.buildFromArray([['=Sheet2!A1']]) + const sheetName = 'Sheet2' + const engine = HyperFormula.buildFromArray([[`=${sheetName}!A1`]]) - engine1.addSheet('Sheet2') - engine1.setSheetContent(1, [['1']]) - engine1.rebuildAndRecalculate() + engine.addSheet(sheetName) + const sheetId = engine.getSheetId(sheetName)! + engine.setSheetContent(sheetId, [['1']]) + engine.rebuildAndRecalculate() - expect(engine1.getCellValue(adr('A1', 1))).toBe(1) - expect(engine1.getCellValue(adr('A1', 0))).toBe(1) + expect(engine.getCellValue(adr('A1', sheetId))).toBe(1) + expect(engine.getCellValue(adr('A1', 0))).toBe(1) }) it('corrupted sheet definition', () => { diff --git a/test/unit/column-index.spec.ts b/test/unit/column-index.spec.ts index 8fa69a07c..b82f81fe5 100644 --- a/test/unit/column-index.spec.ts +++ b/test/unit/column-index.spec.ts @@ -35,7 +35,7 @@ describe('ColumnIndex#add', () => { const columnMap = index.getColumnMap(0, 1) expect(columnMap.size).toBe(1) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(columnMap.get(1)!.index[0]).toBe(4) }) @@ -48,7 +48,7 @@ describe('ColumnIndex#add', () => { const columnMap = index.getColumnMap(0, 0) expect(columnMap.size).toBe(1) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(columnMap.get(1)!.index[0]).toBe(0) }) @@ -63,7 +63,7 @@ describe('ColumnIndex#add', () => { const columnMap = index.getColumnMap(0, 0) expect(columnMap.size).toBe(1) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(columnMap.get(1)!.index.length).toBe(2) }) @@ -108,13 +108,11 @@ describe('ColumnIndex#add', () => { const columnMap = index.getColumnMap(0, 0) - // eslint-disable @typescript-eslint/no-non-null-assertion expect(columnMap.get('a')!.index.length).toBe(3) expect(columnMap.get('l')!.index.length).toBe(1) expect(columnMap.get('ł')!.index.length).toBe(1) expect(columnMap.get('t')!.index.length).toBe(1) expect(columnMap.get('ŧ')!.index.length).toBe(1) - // eslint-enable @typescript-eslint/no-non-null-assertion }) it('should ignore EmptyValue', () => { @@ -136,7 +134,7 @@ describe('ColumnIndex change/remove', () => { index.remove(1, adr('A2')) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const valueIndex = index.getColumnMap(0, 0).get(1)! expect(valueIndex.index.length).toBe(2) expect(valueIndex.index).toContain(0) @@ -151,7 +149,7 @@ describe('ColumnIndex change/remove', () => { index.remove(undefined, adr('A2')) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const valueIndex = index.getColumnMap(0, 0).get(1)! expect(valueIndex.index.length).toBe(3) expect(valueIndex.index).toContain(0) @@ -166,7 +164,7 @@ describe('ColumnIndex change/remove', () => { index.change(1, 2, adr('A1')) expect(index.getColumnMap(0, 0).keys()).not.toContain(1) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const valueIndex = index.getColumnMap(0, 0).get(2)! expect(valueIndex.index).toContain(0) }) diff --git a/test/unit/column-range.spec.ts b/test/unit/column-range.spec.ts index 7848035f1..a538063c5 100644 --- a/test/unit/column-range.spec.ts +++ b/test/unit/column-range.spec.ts @@ -17,7 +17,7 @@ describe('Column ranges', () => { ['=SUM(C:D)', '=SUM(C5:D6)'], ]) - const cd = engine.rangeMapping.getRange(colStart('C'), colEnd('D')) as RangeVertex + const cd = engine.rangeMapping.getRangeVertex(colStart('C'), colEnd('D')) as RangeVertex const c5 = engine.dependencyGraph.fetchCell(adr('C5')) const c6 = engine.dependencyGraph.fetchCell(adr('C6')) @@ -38,8 +38,8 @@ describe('Column ranges', () => { engine.setCellContents(adr('B1'), '=SUM(D42:H42)') - const ce = engine.rangeMapping.getRange(colStart('C'), colEnd('E')) as RangeVertex - const dg = engine.rangeMapping.getRange(colStart('D'), colEnd('G')) as RangeVertex + const ce = engine.rangeMapping.getRangeVertex(colStart('C'), colEnd('E')) as RangeVertex + const dg = engine.rangeMapping.getRangeVertex(colStart('D'), colEnd('G')) as RangeVertex const d42 = engine.dependencyGraph.fetchCell(adr('D42')) const e42 = engine.dependencyGraph.fetchCell(adr('E42')) @@ -81,4 +81,17 @@ describe('Column ranges', () => { expect(range.start).toEqual(colStart('A')) expect(range.end).toEqual(colEnd('B')) }) + + it('should correctly handle infinite column ranges when setting cell values (line 890)', () => { + const engine = HyperFormula.buildFromArray([ + ['=SUM(C:D)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toBe(0) + + engine.setCellContents(adr('C5'), 10) + engine.setCellContents(adr('D5'), 20) + + expect(engine.getCellValue(adr('A1'))).toBe(30) + }) }) diff --git a/test/unit/crud-random.spec.ts b/test/unit/crud-random.spec.ts index 94eb9d091..cdea9913c 100644 --- a/test/unit/crud-random.spec.ts +++ b/test/unit/crud-random.spec.ts @@ -272,6 +272,6 @@ describe('large psuedo-random test', () => { } randomCleanup(engine, rectangleFromCorner({x: 0, y: 0}, 2 * (n + 1) * sideX, 2 * sideY)) expect(engine.dependencyGraph.graph.getNodes().length).toBe(0) - expect(engine.dependencyGraph.rangeMapping.getMappingSize(0)).toBe(0) + expect(engine.dependencyGraph.rangeMapping.getNumberOfRangesInSheet(0)).toBe(0) }) }) diff --git a/test/unit/cruds/adding-columns-dependencies.spec.ts b/test/unit/cruds/adding-columns-dependencies.spec.ts index cdaeae8ec..17a243405 100644 --- a/test/unit/cruds/adding-columns-dependencies.spec.ts +++ b/test/unit/cruds/adding-columns-dependencies.spec.ts @@ -251,12 +251,12 @@ describe('Adding column, fixing ranges', () => { ['=SUM(A1:C1)'], ]) - expect(engine.rangeMapping.getRange(adr('A1'), adr('C1'))).not.toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(adr('A1'), adr('C1'))).not.toBe(undefined) engine.addColumns(0, [1, 1]) - expect(engine.rangeMapping.getRange(adr('A1'), adr('C1'))).toBe(undefined) - expect(engine.rangeMapping.getRange(adr('A1'), adr('D1'))).not.toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(adr('A1'), adr('C1'))).toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(adr('A1'), adr('D1'))).not.toBe(undefined) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ [null, null, null, null], @@ -270,12 +270,12 @@ describe('Adding column, fixing ranges', () => { ['=SUM(A1:C1)'], ]) - expect(engine.rangeMapping.getRange(adr('A1'), adr('C1'))).not.toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(adr('A1'), adr('C1'))).not.toBe(undefined) engine.addColumns(0, [1, 1]) - expect(engine.rangeMapping.getRange(adr('A1'), adr('C1'))).toBe(undefined) - expect(engine.rangeMapping.getRange(adr('A1'), adr('D1'))).not.toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(adr('A1'), adr('C1'))).toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(adr('A1'), adr('D1'))).not.toBe(undefined) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ ['1', null, '2', '3'], @@ -289,10 +289,10 @@ describe('Adding column, fixing ranges', () => { ['=SUM(A1:C1)'], ]) - expect(engine.rangeMapping.getRange(adr('A1'), adr('C1'))).not.toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(adr('A1'), adr('C1'))).not.toBe(undefined) engine.addColumns(0, [0, 1]) - expect(engine.rangeMapping.getRange(adr('A1'), adr('C1'))).toBe(undefined) - expect(engine.rangeMapping.getRange(adr('B1'), adr('D1'))).not.toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(adr('A1'), adr('C1'))).toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(adr('B1'), adr('D1'))).not.toBe(undefined) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ [null, '1', '2', '3'], @@ -306,9 +306,9 @@ describe('Adding column, fixing ranges', () => { ['=SUM(A1:C1)'], ]) - expect(engine.rangeMapping.getRange(adr('A1'), adr('C1'))).not.toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(adr('A1'), adr('C1'))).not.toBe(undefined) engine.addColumns(0, [3, 1]) - expect(engine.rangeMapping.getRange(adr('A1'), adr('C1'))).not.toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(adr('A1'), adr('C1'))).not.toBe(undefined) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ ['1', '2', '3', null], @@ -324,13 +324,13 @@ describe('Adding column, fixing ranges', () => { engine.addColumns(0, [2, 1]) - const c1 = engine.addressMapping.fetchCell(adr('C1')) - const a1d1 = engine.rangeMapping.fetchRange(adr('A1'), adr('D1')) - const a1e1 = engine.rangeMapping.fetchRange(adr('A1'), adr('E1')) + const c1 = engine.addressMapping.getCell(adr('C1')) + const a1d1 = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('D1')) + const a1e1 = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('E1')) - expect(engine.graph.existsEdge(c1, a1d1)).toBe(true) - expect(engine.graph.existsEdge(c1, a1e1)).toBe(true) - expect(engine.graph.adjacentNodesCount(c1)).toBe(2) + expect(engine.graph.existsEdge(c1!, a1d1)).toBe(true) + expect(engine.graph.existsEdge(c1!, a1e1)).toBe(true) + expect(engine.graph.adjacentNodesCount(c1!)).toBe(2) }) it('range start in column', () => { @@ -358,10 +358,10 @@ describe('Adding column, fixing ranges', () => { engine.addColumns(0, [1, 1]) - const b1 = engine.addressMapping.fetchCell(adr('B1')) - const range = engine.rangeMapping.fetchRange(adr('A1'), adr('E1')) + const b1 = engine.addressMapping.getCell(adr('B1')) + const range = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('E1')) expect(b1).toBeInstanceOf(EmptyCellVertex) - expect(engine.graph.existsEdge(b1, range)).toBe(true) + expect(engine.graph.existsEdge(b1!, range)).toBe(true) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ ['1', null, '2', '3', '4'], @@ -411,11 +411,11 @@ describe('Adding column, fixing ranges', () => { engine.addColumns(0, [1, 1]) - const b1 = engine.addressMapping.fetchCell(adr('B1')) + const b1 = engine.addressMapping.getCell(adr('B1')) - const range = engine.rangeMapping.fetchRange(adr('A1'), adr('C1')) + const range = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('C1')) expect(b1).toBeInstanceOf(EmptyCellVertex) - expect(engine.graph.existsEdge(b1, range)).toBe(true) + expect(engine.graph.existsEdge(b1!, range)).toBe(true) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ ['1', null, '2', '3', '4'], @@ -431,11 +431,11 @@ describe('Adding column, fixing ranges', () => { engine.addColumns(0, [1, 1]) - const b1 = engine.addressMapping.fetchCell(adr('B1')) + const b1 = engine.addressMapping.getCell(adr('B1')) - const range = engine.rangeMapping.fetchRange(adr('A1'), adr('D1')) + const range = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('D1')) expect(b1).toBeInstanceOf(EmptyCellVertex) - expect(engine.graph.existsEdge(b1, range)).toBe(true) + expect(engine.graph.existsEdge(b1!, range)).toBe(true) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ ['1', null, '2', '3', '4'], @@ -467,12 +467,12 @@ describe('Adding column, fixing column ranges', () => { ['1', /* new col */ '2', '3', '=SUM(A:C)'], ]) - expect(engine.rangeMapping.getRange(colStart('A'), colEnd('C'))).not.toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(colStart('A'), colEnd('C'))).not.toBe(undefined) engine.addColumns(0, [1, 1]) - expect(engine.rangeMapping.getRange(colStart('A'), colEnd('C'))).toBe(undefined) - expect(engine.rangeMapping.getRange(colStart('A'), colEnd('D'))).not.toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(colStart('A'), colEnd('C'))).toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(colStart('A'), colEnd('D'))).not.toBe(undefined) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ ['1', null, '2', '3', '=SUM(A:D)'], @@ -484,10 +484,10 @@ describe('Adding column, fixing column ranges', () => { [/* new col */ '1', '2', '3', '=SUM(A:C)'], ]) - expect(engine.rangeMapping.getRange(colStart('A'), colEnd('C'))).not.toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(colStart('A'), colEnd('C'))).not.toBe(undefined) engine.addColumns(0, [0, 1]) - expect(engine.rangeMapping.getRange(colStart('A'), colEnd('C'))).toBe(undefined) - expect(engine.rangeMapping.getRange(colStart('B'), colEnd('D'))).not.toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(colStart('A'), colEnd('C'))).toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(colStart('B'), colEnd('D'))).not.toBe(undefined) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ [null, '1', '2', '3', '=SUM(B:D)'], @@ -499,9 +499,9 @@ describe('Adding column, fixing column ranges', () => { ['1', '2', '3' /* new col */, '=SUM(A:C)'], ]) - expect(engine.rangeMapping.getRange(colStart('A'), colEnd('C'))).not.toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(colStart('A'), colEnd('C'))).not.toBe(undefined) engine.addColumns(0, [3, 1]) - expect(engine.rangeMapping.getRange(colStart('A'), colEnd('C'))).not.toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(colStart('A'), colEnd('C'))).not.toBe(undefined) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ ['1', '2', '3', null, '=SUM(A:C)'], @@ -519,7 +519,7 @@ describe('Adding column, row range', () => { engine.addColumns(0, [1, 1]) - expect(engine.rangeMapping.getRange(rowStart(1), rowEnd(2))).not.toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(rowStart(1), rowEnd(2))).not.toBe(undefined) const rowRange = extractRowRange(engine, adr('D3')) expect(rowRange.start).toEqual(rowStart(1)) expect(rowRange.end).toEqual(rowEnd(2)) diff --git a/test/unit/cruds/adding-columns.spec.ts b/test/unit/cruds/adding-columns.spec.ts index 0a2f8a12a..feded1182 100644 --- a/test/unit/cruds/adding-columns.spec.ts +++ b/test/unit/cruds/adding-columns.spec.ts @@ -7,7 +7,7 @@ import { } from '../../../src' import {AbsoluteCellRange} from '../../../src/AbsoluteCellRange' import {Config} from '../../../src/Config' -import {ArrayVertex, FormulaCellVertex} from '../../../src/DependencyGraph' +import {ArrayFormulaVertex, ScalarFormulaVertex} from '../../../src/DependencyGraph' import {ColumnIndex} from '../../../src/Lookup/ColumnIndex' import { adr, @@ -233,7 +233,7 @@ describe('Adding column - reevaluation', () => { }) }) -describe('Adding column - FormulaCellVertex#address update', () => { +describe('Adding column - ScalarFormulaVertex#address update', () => { it('updates addresses in formulas', () => { const engine = HyperFormula.buildFromArray([ ['1', /* new col */ '=A1'], @@ -241,8 +241,8 @@ describe('Adding column - FormulaCellVertex#address update', () => { engine.addColumns(0, [1, 1]) - const c1 = engine.addressMapping.getCell(adr('C1')) as FormulaCellVertex - expect(c1).toBeInstanceOf(FormulaCellVertex) + const c1 = engine.addressMapping.getCell(adr('C1')) as ScalarFormulaVertex + expect(c1).toBeInstanceOf(ScalarFormulaVertex) expect(c1.getAddress(engine.lazilyTransformingAstService)).toEqual(adr('C1')) }) }) @@ -275,7 +275,7 @@ describe('different sheet', () => { engine.addColumns(0, [0, 1]) - const formulaVertex = engine.addressMapping.fetchCell(adr('A1', 1)) as FormulaCellVertex + const formulaVertex = engine.addressMapping.getCell(adr('A1', 1)) as ScalarFormulaVertex expect(formulaVertex.getAddress(engine.lazilyTransformingAstService)).toEqual(adr('A1', 1)) formulaVertex.getFormula(engine.lazilyTransformingAstService) // force transformations to be applied @@ -388,7 +388,7 @@ describe('Adding column - arrays', () => { ], {useArrayArithmetic: true})) }) - it('ArrayVertex#formula should be updated', () => { + it('ArrayFormulaVertex#formula should be updated', () => { const engine = HyperFormula.buildFromArray([ [1, 2, '=TRANSPOSE(A1:B2)'], [3, 4], @@ -399,7 +399,7 @@ describe('Adding column - arrays', () => { expect(extractMatrixRange(engine, adr('D1'))).toEqual(new AbsoluteCellRange(adr('A1'), adr('C2'))) }) - it('ArrayVertex#formula should be updated when different sheets', () => { + it('ArrayFormulaVertex#formula should be updated when different sheets', () => { const engine = HyperFormula.buildFromSheets({ Sheet1: [ ['1', '2'], @@ -415,7 +415,7 @@ describe('Adding column - arrays', () => { expect(extractMatrixRange(engine, adr('A1', 1))).toEqual(new AbsoluteCellRange(adr('A1'), adr('C2'))) }) - it('ArrayVertex#address should be updated', () => { + it('ArrayFormulaVertex#address should be updated', () => { const engine = HyperFormula.buildFromArray([ [1, 2, '=TRANSPOSE(A1:B2)'], [3, 4], @@ -423,7 +423,7 @@ describe('Adding column - arrays', () => { engine.addColumns(0, [1, 1]) - const matrixVertex = engine.addressMapping.fetchCell(adr('D1')) as ArrayVertex + const matrixVertex = engine.addressMapping.getCell(adr('D1')) as ArrayFormulaVertex expect(matrixVertex.getAddress(engine.lazilyTransformingAstService)).toEqual(adr('D1')) }) }) diff --git a/test/unit/cruds/adding-row-dependencies.spec.ts b/test/unit/cruds/adding-row-dependencies.spec.ts index 3d97e4266..812c53dc7 100644 --- a/test/unit/cruds/adding-row-dependencies.spec.ts +++ b/test/unit/cruds/adding-row-dependencies.spec.ts @@ -278,10 +278,10 @@ describe('Adding row, ranges', () => { ['3', null], ]) - expect(engine.rangeMapping.getRange(adr('A1'), adr('A3'))).not.toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(adr('A1'), adr('A3'))).not.toBe(undefined) engine.addRows(0, [1, 1]) - expect(engine.rangeMapping.getRange(adr('A1'), adr('A3'))).toBe(undefined) - expect(engine.rangeMapping.getRange(adr('A1'), adr('A4'))).not.toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(adr('A1'), adr('A3'))).toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(adr('A1'), adr('A4'))).not.toBe(undefined) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ ['1', '=SUM(A1:A4)'], @@ -299,10 +299,10 @@ describe('Adding row, ranges', () => { ['3', null], ]) - expect(engine.rangeMapping.getRange(adr('A1'), adr('A3'))).not.toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(adr('A1'), adr('A3'))).not.toBe(undefined) engine.addRows(0, [0, 1]) - expect(engine.rangeMapping.getRange(adr('A1'), adr('A3'))).toBe(undefined) - expect(engine.rangeMapping.getRange(adr('A2'), adr('A4'))).not.toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(adr('A1'), adr('A3'))).toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(adr('A2'), adr('A4'))).not.toBe(undefined) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ [null, null], @@ -320,9 +320,9 @@ describe('Adding row, ranges', () => { // new row ]) - expect(engine.rangeMapping.getRange(adr('A1'), adr('A3'))).not.toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(adr('A1'), adr('A3'))).not.toBe(undefined) engine.addRows(0, [3, 1]) - expect(engine.rangeMapping.getRange(adr('A1'), adr('A3'))).not.toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(adr('A1'), adr('A3'))).not.toBe(undefined) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ ['1', '=SUM(A1:A3)'], @@ -343,19 +343,19 @@ describe('Adding row, ranges', () => { engine.addRows(0, [2, 1]) - const a4 = engine.addressMapping.fetchCell(adr('A4')) - const a3 = engine.addressMapping.fetchCell(adr('A3')) - const a2 = engine.addressMapping.fetchCell(adr('A2')) - const a1a4 = engine.rangeMapping.fetchRange(adr('A1'), adr('A4')) // A1:A4 - const a1a3 = engine.rangeMapping.fetchRange(adr('A1'), adr('A3')) // A1:A4 - const a1a2 = engine.rangeMapping.fetchRange(adr('A1'), adr('A2')) // A1:A4 - - expect(engine.graph.existsEdge(a4, a1a4)).toBe(true) - expect(engine.graph.existsEdge(a3, a1a3)).toBe(true) - expect(engine.graph.existsEdge(a2, a1a2)).toBe(true) - expect(engine.graph.adjacentNodesCount(a4)).toBe(1) - expect(engine.graph.adjacentNodesCount(a3)).toBe(1) - expect(engine.graph.adjacentNodesCount(a2)).toBe(1) + const a4 = engine.addressMapping.getCell(adr('A4')) + const a3 = engine.addressMapping.getCell(adr('A3')) + const a2 = engine.addressMapping.getCell(adr('A2')) + const a1a4 = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('A4')) // A1:A4 + const a1a3 = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('A3')) // A1:A4 + const a1a2 = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('A2')) // A1:A4 + + expect(engine.graph.existsEdge(a4!, a1a4)).toBe(true) + expect(engine.graph.existsEdge(a3!, a1a3)).toBe(true) + expect(engine.graph.existsEdge(a2!, a1a2)).toBe(true) + expect(engine.graph.adjacentNodesCount(a4!)).toBe(1) + expect(engine.graph.adjacentNodesCount(a3!)).toBe(1) + expect(engine.graph.adjacentNodesCount(a2!)).toBe(1) }) it('should insert new cell with edge to only one range below, shifted by 1', () => { @@ -415,10 +415,10 @@ describe('Adding row, ranges', () => { engine.addRows(0, [1, 1]) - const a2 = engine.addressMapping.fetchCell(adr('A2')) - const range = engine.rangeMapping.fetchRange(adr('A1'), adr('A5')) + const a2 = engine.addressMapping.getCell(adr('A2')) + const range = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('A5')) expect(a2).toBeInstanceOf(EmptyCellVertex) - expect(engine.graph.existsEdge(a2, range)).toBe(true) + expect(engine.graph.existsEdge(a2!, range)).toBe(true) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ ['1', null], @@ -486,11 +486,11 @@ describe('Adding row, ranges', () => { engine.addRows(0, [1, 1]) - const a2 = engine.addressMapping.fetchCell(adr('A2')) + const a2 = engine.addressMapping.getCell(adr('A2')) - const range = engine.rangeMapping.fetchRange(adr('A1'), adr('A3')) + const range = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('A3')) expect(a2).toBeInstanceOf(EmptyCellVertex) - expect(engine.graph.existsEdge(a2, range)).toBe(true) + expect(engine.graph.existsEdge(a2!, range)).toBe(true) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ ['1', null], @@ -512,11 +512,11 @@ describe('Adding row, ranges', () => { engine.addRows(0, [1, 1]) - const a2 = engine.addressMapping.fetchCell(adr('A2')) + const a2 = engine.addressMapping.getCell(adr('A2')) - const range = engine.rangeMapping.fetchRange(adr('A1'), adr('A4')) + const range = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('A4')) expect(a2).toBeInstanceOf(EmptyCellVertex) - expect(engine.graph.existsEdge(a2, range)).toBe(true) + expect(engine.graph.existsEdge(a2!, range)).toBe(true) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ ['1', null], @@ -562,7 +562,7 @@ describe('Adding row, column range', () => { engine.addRows(0, [1, 1]) - expect(engine.rangeMapping.getRange(colStart('A'), colEnd('B'))).not.toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(colStart('A'), colEnd('B'))).not.toBe(undefined) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ ['1', '1', '=SUM(A:B)'], @@ -583,12 +583,12 @@ describe('Adding row, fixing row ranges', () => { ['=SUM(1:3)'], ]) - expect(engine.rangeMapping.getRange(rowStart(1), rowEnd(3))).not.toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(rowStart(1), rowEnd(3))).not.toBe(undefined) engine.addRows(0, [1, 1]) - expect(engine.rangeMapping.getRange(rowStart(1), rowEnd(3))).toBe(undefined) - expect(engine.rangeMapping.getRange(rowStart(1), rowEnd(4))).not.toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(rowStart(1), rowEnd(3))).toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(rowStart(1), rowEnd(4))).not.toBe(undefined) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ ['1'], @@ -608,10 +608,10 @@ describe('Adding row, fixing row ranges', () => { ['=SUM(1:3)'], ]) - expect(engine.rangeMapping.getRange(rowStart(1), rowEnd(3))).not.toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(rowStart(1), rowEnd(3))).not.toBe(undefined) engine.addRows(0, [0, 1]) - expect(engine.rangeMapping.getRange(rowStart(1), rowEnd(3))).toBe(undefined) - expect(engine.rangeMapping.getRange(rowStart(2), rowEnd(4))).not.toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(rowStart(1), rowEnd(3))).toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(rowStart(2), rowEnd(4))).not.toBe(undefined) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ [null], @@ -631,9 +631,9 @@ describe('Adding row, fixing row ranges', () => { ['=SUM(1:3)'], ]) - expect(engine.rangeMapping.getRange(rowStart(1), rowEnd(3))).not.toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(rowStart(1), rowEnd(3))).not.toBe(undefined) engine.addRows(0, [3, 1]) - expect(engine.rangeMapping.getRange(rowStart(1), rowEnd(3))).not.toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(rowStart(1), rowEnd(3))).not.toBe(undefined) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ ['1'], diff --git a/test/unit/cruds/adding-row.spec.ts b/test/unit/cruds/adding-row.spec.ts index 129d35dc7..45d0a8651 100644 --- a/test/unit/cruds/adding-row.spec.ts +++ b/test/unit/cruds/adding-row.spec.ts @@ -1,7 +1,7 @@ import {ExportedCellChange, HyperFormula, SheetSizeLimitExceededError} from '../../../src' import {AbsoluteCellRange} from '../../../src/AbsoluteCellRange' import {Config} from '../../../src/Config' -import {ArrayVertex, FormulaCellVertex} from '../../../src/DependencyGraph' +import {ArrayFormulaVertex, ScalarFormulaVertex} from '../../../src/DependencyGraph' import {AlwaysDense} from '../../../src/DependencyGraph/AddressMapping/ChooseAddressMappingPolicy' import {ColumnIndex} from '../../../src/Lookup/ColumnIndex' import {adr, expectArrayWithSameContent, expectEngineToBeTheSameAs, extractMatrixRange} from '../testUtils' @@ -249,17 +249,17 @@ describe('Adding row - reevaluation', () => { }) }) -describe('Adding row - FormulaCellVertex#address update', () => { +describe('Adding row - ScalarFormulaVertex#address update', () => { it('insert row, formula vertex address shifted', () => { const engine = HyperFormula.buildFromArray([ // new row ['=SUM(1, 2)'], ]) - let vertex = engine.addressMapping.fetchCell(adr('A1')) as FormulaCellVertex + let vertex = engine.addressMapping.getCell(adr('A1')) as ScalarFormulaVertex expect(vertex.getAddress(engine.lazilyTransformingAstService)).toEqual(adr('A1')) engine.addRows(0, [0, 1]) - vertex = engine.addressMapping.fetchCell(adr('A2')) as FormulaCellVertex + vertex = engine.addressMapping.getCell(adr('A2')) as ScalarFormulaVertex expect(vertex.getAddress(engine.lazilyTransformingAstService)).toEqual(adr('A2')) }) @@ -276,7 +276,7 @@ describe('Adding row - FormulaCellVertex#address update', () => { engine.addRows(0, [0, 1]) - const formulaVertex = engine.addressMapping.fetchCell(adr('A1', 1)) as FormulaCellVertex + const formulaVertex = engine.addressMapping.getCell(adr('A1', 1)) as ScalarFormulaVertex expect(formulaVertex.getAddress(engine.lazilyTransformingAstService)).toEqual(adr('A1', 1)) formulaVertex.getFormula(engine.lazilyTransformingAstService) // force transformations to be applied @@ -438,7 +438,7 @@ describe('Adding row - arrays', () => { ], {useArrayArithmetic: true})) }) - it('ArrayVertex#formula should be updated', () => { + it('ArrayFormulaVertex#formula should be updated', () => { const engine = HyperFormula.buildFromArray([ ['1', '2'], ['3', '4'], @@ -450,7 +450,7 @@ describe('Adding row - arrays', () => { expect(extractMatrixRange(engine, adr('A4'))).toEqual(new AbsoluteCellRange(adr('A1'), adr('B3'))) }) - it('ArrayVertex#formula should be updated when different sheets', () => { + it('ArrayFormulaVertex#formula should be updated when different sheets', () => { const engine = HyperFormula.buildFromSheets({ Sheet1: [ ['1', '2'], @@ -466,7 +466,7 @@ describe('Adding row - arrays', () => { expect(extractMatrixRange(engine, adr('A1', 1))).toEqual(new AbsoluteCellRange(adr('A1'), adr('B3'))) }) - it('ArrayVertex#address should be updated', () => { + it('ArrayFormulaVertex#address should be updated', () => { const engine = HyperFormula.buildFromArray([ ['1', '2'], ['3', '4'], @@ -475,7 +475,7 @@ describe('Adding row - arrays', () => { engine.addRows(0, [1, 1]) - const matrixVertex = engine.addressMapping.fetchCell(adr('A4')) as ArrayVertex + const matrixVertex = engine.addressMapping.getCell(adr('A4')) as ArrayFormulaVertex expect(matrixVertex.getAddress(engine.lazilyTransformingAstService)).toEqual(adr('A4')) }) }) diff --git a/test/unit/cruds/adding-sheet.spec.ts b/test/unit/cruds/adding-sheet.spec.ts index dd4566a3b..71de7eff5 100644 --- a/test/unit/cruds/adding-sheet.spec.ts +++ b/test/unit/cruds/adding-sheet.spec.ts @@ -1,13 +1,13 @@ -import {HyperFormula, SheetNameAlreadyTakenError} from '../../../src' +import {AlwaysSparse, ErrorType, HyperFormula, SheetNameAlreadyTakenError} from '../../../src' import {plPL} from '../../../src/i18n/languages' -import {adr} from '../testUtils' +import {adr, detailedError} from '../testUtils' describe('Adding sheet - checking if its possible', () => { it('yes', () => { const engine = HyperFormula.buildEmpty() - expect(engine.isItPossibleToAddSheet('Sheet1')).toEqual(true) - expect(engine.isItPossibleToAddSheet('~`!@#$%^&*()_-+_=/|?{}[]\\"')).toEqual(true) + expect(engine.isItPossibleToAddSheet('Sheet1')).toBe(true) + expect(engine.isItPossibleToAddSheet('~`!@#$%^&*()_-+_=/|?{}[]\\"')).toBe(true) }) it('no', () => { @@ -16,8 +16,8 @@ describe('Adding sheet - checking if its possible', () => { Foo: [], }) - expect(engine.isItPossibleToAddSheet('Sheet1')).toEqual(false) - expect(engine.isItPossibleToAddSheet('Foo')).toEqual(false) + expect(engine.isItPossibleToAddSheet('Sheet1')).toBe(false) + expect(engine.isItPossibleToAddSheet('Foo')).toBe(false) }) }) @@ -27,8 +27,8 @@ describe('add sheet to engine', () => { engine.addSheet() - expect(engine.sheetMapping.numberOfSheets()).toEqual(1) - expect(Array.from(engine.sheetMapping.displayNames())).toEqual(['Sheet1']) + expect(engine.sheetMapping.numberOfSheets()).toBe(1) + expect(Array.from(engine.sheetMapping.iterateSheetNames())).toEqual(['Sheet1']) }) it('should add sheet to engine with one sheet', function() { @@ -38,8 +38,8 @@ describe('add sheet to engine', () => { engine.addSheet() - expect(engine.sheetMapping.numberOfSheets()).toEqual(2) - expect(Array.from(engine.sheetMapping.displayNames())).toEqual(['Sheet1', 'Sheet2']) + expect(engine.sheetMapping.numberOfSheets()).toBe(2) + expect(Array.from(engine.sheetMapping.iterateSheetNames())).toEqual(['Sheet1', 'Sheet2']) }) it('should be possible to fetch empty cell from newly added sheet', function() { @@ -47,7 +47,7 @@ describe('add sheet to engine', () => { engine.addSheet() - expect(engine.getCellValue(adr('A1', 0))).toBe(null) + expect(engine.getCellValue(adr('A1', 0))).toBeNull() }) it('should add sheet with translated sheet name', function() { @@ -56,8 +56,8 @@ describe('add sheet to engine', () => { engine.addSheet() - expect(engine.sheetMapping.numberOfSheets()).toEqual(1) - expect(Array.from(engine.sheetMapping.displayNames())).toEqual(['Arkusz1']) + expect(engine.sheetMapping.numberOfSheets()).toBe(1) + expect(Array.from(engine.sheetMapping.iterateSheetNames())).toEqual(['Arkusz1']) }) it('should add sheet with given name', function() { @@ -65,8 +65,8 @@ describe('add sheet to engine', () => { engine.addSheet('foo') - expect(engine.sheetMapping.numberOfSheets()).toEqual(1) - expect(Array.from(engine.sheetMapping.displayNames())).toEqual(['foo']) + expect(engine.sheetMapping.numberOfSheets()).toBe(1) + expect(Array.from(engine.sheetMapping.iterateSheetNames())).toEqual(['foo']) }) it('cannot add another sheet with same lowercased name', function() { @@ -76,8 +76,9 @@ describe('add sheet to engine', () => { expect(() => { engine.addSheet('FOO') }).toThrowError(/already exists/) - expect(engine.sheetMapping.numberOfSheets()).toEqual(1) - expect(Array.from(engine.sheetMapping.displayNames())).toEqual(['foo']) + + expect(engine.sheetMapping.numberOfSheets()).toBe(1) + expect(Array.from(engine.sheetMapping.iterateSheetNames())).toEqual(['foo']) }) it('should return given name', function() { @@ -85,7 +86,7 @@ describe('add sheet to engine', () => { const sheetName = engine.addSheet('foo') - expect(sheetName).toEqual('foo') + expect(sheetName).toBe('foo') }) @@ -94,7 +95,7 @@ describe('add sheet to engine', () => { const sheetName = engine.addSheet() - expect(sheetName).toEqual('Sheet1') + expect(sheetName).toBe('Sheet1') }) it('should throw error when sheet name is already taken', () => { @@ -106,3 +107,389 @@ describe('add sheet to engine', () => { }).toThrow(new SheetNameAlreadyTakenError('bar')) }) }) + +describe('recalculates formulas after adding new sheet (issue #1116)', () => { + it('recalculates single cell reference', () => { + const engine = HyperFormula.buildEmpty() + const table1Name = 'table1' + const table2Name = 'table2' + + engine.addSheet(table1Name) + engine.setCellContents(adr('A1', engine.getSheetId(table1Name)), `='${table2Name}'!A1`) + + expect(engine.getCellValue(adr('A1', engine.getSheetId(table1Name)))).toEqualError(detailedError(ErrorType.REF)) + + engine.addSheet(table2Name) + + expect(engine.getCellValue(adr('A1', engine.getSheetId(table2Name)))).toBeNull() + expect(engine.getCellValue(adr('A1', engine.getSheetId(table1Name)))).toBeNull() + + engine.setCellContents(adr('A1', engine.getSheetId(table2Name)), 10) + + expect(engine.getCellValue(adr('A1', engine.getSheetId(table1Name)))).toBe(10) + }) + + it('recalculates chained dependencies across multiple sheets', () => { + const engine = HyperFormula.buildEmpty() + const sheet1Name = 'Sheet1' + const sheet2Name = 'Sheet2' + const sheet3Name = 'Sheet3' + + engine.addSheet(sheet1Name) + engine.addSheet(sheet2Name) + engine.setCellContents(adr('A1', engine.getSheetId(sheet1Name)), `='${sheet2Name}'!A1+2`) + engine.setCellContents(adr('A1', engine.getSheetId(sheet2Name)), `='${sheet3Name}'!A1*2`) + + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet1Name)))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet2Name)))).toEqualError(detailedError(ErrorType.REF)) + + engine.addSheet(sheet3Name) + + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet3Name)))).toBeNull() + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet2Name)))).toBe(0) + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet1Name)))).toBe(2) + + engine.setCellContents(adr('A1', engine.getSheetId(sheet3Name)), 42) + + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet2Name)))).toBe(84) + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet1Name)))).toBe(86) + }) + + it('recalculates nested dependencies within same sheet', () => { + const engine = HyperFormula.buildEmpty() + const sheet1Name = 'Sheet1' + const newSheetName = 'NewSheet' + + engine.addSheet(sheet1Name) + engine.setCellContents(adr('B1', engine.getSheetId(sheet1Name)), `='${newSheetName}'!A1`) + engine.setCellContents(adr('A1', engine.getSheetId(sheet1Name)), '=B1*2') + + expect(engine.getCellValue(adr('B1', engine.getSheetId(sheet1Name)))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet1Name)))).toEqualError(detailedError(ErrorType.REF)) + + engine.addSheet(newSheetName) + + expect(engine.getCellValue(adr('B1', engine.getSheetId(newSheetName)))).toBeNull() + expect(engine.getCellValue(adr('B1', engine.getSheetId(sheet1Name)))).toBeNull() + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet1Name)))).toBe(0) + + engine.setCellContents(adr('A1', engine.getSheetId(newSheetName)), 15) + + expect(engine.getCellValue(adr('B1', engine.getSheetId(sheet1Name)))).toBe(15) + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet1Name)))).toBe(30) + }) + + it('recalculates multiple cells from different sheets', () => { + const engine = HyperFormula.buildEmpty() + const sheet1Name = 'Sheet1' + const sheet2Name = 'Sheet2' + const targetSheetName = 'TargetSheet' + + engine.addSheet(sheet1Name) + engine.addSheet(sheet2Name) + engine.setCellContents(adr('A1', engine.getSheetId(sheet1Name)), `='${targetSheetName}'!A1`) + engine.setCellContents(adr('B1', engine.getSheetId(sheet1Name)), `='${targetSheetName}'!B1`) + engine.setCellContents(adr('A1', engine.getSheetId(sheet2Name)), `='${targetSheetName}'!A1+10`) + engine.setCellContents(adr('B1', engine.getSheetId(sheet2Name)), `='${targetSheetName}'!B1+20`) + + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet1Name)))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('B1', engine.getSheetId(sheet1Name)))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet2Name)))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('B1', engine.getSheetId(sheet2Name)))).toEqualError(detailedError(ErrorType.REF)) + + engine.addSheet(targetSheetName) + engine.setCellContents(adr('A1', engine.getSheetId(targetSheetName)), 5) + engine.setCellContents(adr('B1', engine.getSheetId(targetSheetName)), 7) + + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet1Name)))).toBe(5) + expect(engine.getCellValue(adr('B1', engine.getSheetId(sheet1Name)))).toBe(7) + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet2Name)))).toBe(15) + expect(engine.getCellValue(adr('B1', engine.getSheetId(sheet2Name)))).toBe(27) + }) + + it('recalculates formulas with mixed operations', () => { + const engine = HyperFormula.buildEmpty() + const sheet1Name = 'Sheet1' + const newSheetName = 'NewSheet' + + engine.addSheet(sheet1Name) + engine.setCellContents(adr('A1', engine.getSheetId(sheet1Name)), 100) + engine.setCellContents(adr('B1', engine.getSheetId(sheet1Name)), `='${newSheetName}'!A1 + A1`) + engine.setCellContents(adr('C1', engine.getSheetId(sheet1Name)), `='${newSheetName}'!B1 * 2`) + + expect(engine.getCellValue(adr('B1', engine.getSheetId(sheet1Name)))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('C1', engine.getSheetId(sheet1Name)))).toEqualError(detailedError(ErrorType.REF)) + + engine.addSheet(newSheetName) + engine.setCellContents(adr('A1', engine.getSheetId(newSheetName)), 50) + engine.setCellContents(adr('B1', engine.getSheetId(newSheetName)), 25) + + expect(engine.getCellValue(adr('B1', engine.getSheetId(sheet1Name)))).toBe(150) + expect(engine.getCellValue(adr('C1', engine.getSheetId(sheet1Name)))).toBe(50) + }) + + it('recalculates formulas with range references', () => { + const engine = HyperFormula.buildEmpty() + const sheet1Name = 'Sheet1' + const dataSheetName = 'DataSheet' + + engine.addSheet(sheet1Name) + engine.setCellContents(adr('A1', engine.getSheetId(sheet1Name)), `=SUM('${dataSheetName}'!A1:B5)`) + engine.setCellContents(adr('A2', engine.getSheetId(sheet1Name)), `=MEDIAN('${dataSheetName}'!A1:B5)`) + + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet1Name)))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('A2', engine.getSheetId(sheet1Name)))).toEqualError(detailedError(ErrorType.REF)) + + engine.addSheet(dataSheetName) + const dataSheetId = engine.getSheetId(dataSheetName) + engine.setCellContents(adr('A1', dataSheetId), 1) + engine.setCellContents(adr('B1', dataSheetId), 2) + engine.setCellContents(adr('A2', dataSheetId), 3) + engine.setCellContents(adr('B2', dataSheetId), 4) + engine.setCellContents(adr('A3', dataSheetId), 5) + engine.setCellContents(adr('B3', dataSheetId), 6) + engine.setCellContents(adr('A4', dataSheetId), 7) + engine.setCellContents(adr('B4', dataSheetId), 8) + engine.setCellContents(adr('A5', dataSheetId), 9) + engine.setCellContents(adr('B5', dataSheetId), 10) + + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet1Name)))).toBe(55) + expect(engine.getCellValue(adr('A2', engine.getSheetId(sheet1Name)))).toBe(5.5) + }) + + it('recalculates named expressions', () => { + const engine = HyperFormula.buildEmpty() + const sheet1Name = 'Sheet1' + const newSheetName = 'NewSheet' + + engine.addSheet(sheet1Name) + engine.addNamedExpression('MyValue', `='${newSheetName}'!$A$1`) + engine.setCellContents(adr('A1', engine.getSheetId(sheet1Name)), '=MyValue') + engine.setCellContents(adr('A2', engine.getSheetId(sheet1Name)), '=MyValue*2') + + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet1Name)))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('A2', engine.getSheetId(sheet1Name)))).toEqualError(detailedError(ErrorType.REF)) + + engine.addSheet(newSheetName) + + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet1Name)))).toBeNull() + expect(engine.getCellValue(adr('A2', engine.getSheetId(sheet1Name)))).toBe(0) + + engine.setCellContents(adr('A1', engine.getSheetId(newSheetName)), 99) + + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet1Name)))).toBe(99) + expect(engine.getCellValue(adr('A2', engine.getSheetId(sheet1Name)))).toBe(198) + }) + + it('setCellContents adds formula referencing existing sheet after it was added', () => { + const engine = HyperFormula.buildFromSheets({ + 'Main': [[1]], + }) + const mainId = engine.getSheetId('Main')! + + engine.addSheet('NewSheet') + const newSheetId = engine.getSheetId('NewSheet')! + engine.setCellContents(adr('A1', newSheetId), 42) + + engine.setCellContents(adr('B1', mainId), '=NewSheet!A1') + + expect(engine.getCellValue(adr('B1', mainId))).toBe(42) + + engine.setCellContents(adr('C1', mainId), '=FutureSheet!A1') + + expect(engine.getCellValue(adr('C1', mainId))).toEqualError(detailedError(ErrorType.REF)) + + engine.addSheet('FutureSheet') + engine.setCellContents(adr('A1', engine.getSheetId('FutureSheet')), 99) + + expect(engine.getCellValue(adr('C1', mainId))).toBe(99) + }) + + describe('when using ranges with', () => { + it('function using `runFunction`', () => { + const engine = HyperFormula.buildFromSheets({ + 'FirstSheet': [['=MEDIAN(NewSheet!A1:A1)', '=MEDIAN(NewSheet!A1:A2)', '=MEDIAN(NewSheet!A1:A3)', '=MEDIAN(NewSheet!A1:A4)']], + }) + const sheet1Id = engine.getSheetId('FirstSheet')! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('B1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('C1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('D1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + + engine.addSheet('NewSheet') + engine.setSheetContent(engine.getSheetId('NewSheet')!, [[1], [2], [3], [4]]) + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(1) + expect(engine.getCellValue(adr('B1', sheet1Id))).toBe(1.5) + expect(engine.getCellValue(adr('C1', sheet1Id))).toBe(2) + expect(engine.getCellValue(adr('D1', sheet1Id))).toBe(2.5) + }) + + it('function not using `runFunction`', () => { + const engine = HyperFormula.buildFromSheets({ + 'FirstSheet': [['=SUM(NewSheet!A1:A1)', '=SUM(NewSheet!A1:A2)', '=SUM(NewSheet!A1:A3)', '=SUM(NewSheet!A1:A4)']], + }, {useArrayArithmetic: false}) + const sheet1Id = engine.getSheetId('FirstSheet')! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('B1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('C1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('D1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + + engine.addSheet('NewSheet') + engine.setSheetContent(engine.getSheetId('NewSheet')!, [[1], [2], [3], [4]]) + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(1) + expect(engine.getCellValue(adr('B1', sheet1Id))).toBe(3) + expect(engine.getCellValue(adr('C1', sheet1Id))).toBe(6) + expect(engine.getCellValue(adr('D1', sheet1Id))).toBe(10) + }) + + it('function using `runFunction` referencing range indirectly', () => { + const engine = HyperFormula.buildFromSheets({ + 'FirstSheet': [ + ['=MEDIAN(A2)', '=MEDIAN(B2)', '=MEDIAN(C2)', '=MEDIAN(D2)'], + ['=\'NewSheet\'!A1:A1', '=\'NewSheet\'!A1:B2', '=\'NewSheet\'!A1:A3', '=\'NewSheet\'!A1:A4'], + ], + }, {useArrayArithmetic: false}) + const sheet1Id = engine.getSheetId('FirstSheet')! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('B1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('C1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('D1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + + engine.addSheet('NewSheet') + engine.setSheetContent(engine.getSheetId('NewSheet')!, [[1], [2], [3], [4]]) + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(1) + expect(engine.getCellValue(adr('B1', sheet1Id))).toBe(1.5) + expect(engine.getCellValue(adr('C1', sheet1Id))).toBe(2) + expect(engine.getCellValue(adr('D1', sheet1Id))).toBe(2.5) + }) + + it('function not using `runFunction` referencing range indirectly', () => { + const engine = HyperFormula.buildFromSheets({ + 'FirstSheet': [ + ['=SUM(A2)', '=SUM(B2)', '=SUM(C2)', '=SUM(D2)'], + ['=\'NewSheet\'!A1:A1', '=\'NewSheet\'!A1:B2', '=\'NewSheet\'!A1:A3', '=\'NewSheet\'!A1:A4'], + ], + }, {useArrayArithmetic: false}) + const sheet1Id = engine.getSheetId('FirstSheet')! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('B1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('C1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('D1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + + engine.addSheet('NewSheet') + engine.setSheetContent(engine.getSheetId('NewSheet')!, [[1], [2], [3], [4]]) + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(1) + expect(engine.getCellValue(adr('B1', sheet1Id))).toBe(3) + expect(engine.getCellValue(adr('C1', sheet1Id))).toBe(6) + expect(engine.getCellValue(adr('D1', sheet1Id))).toBe(10) + }) + + it('function calling a named expression', () => { + const engine = HyperFormula.buildFromSheets({ + 'FirstSheet': [['=\'NewSheet\'!A1:A4']], + }, {useArrayArithmetic: false}, [ + { name: 'ExprA', expression: '=MEDIAN(NewSheet!$A$1:$A$1)' }, + { name: 'ExprB', expression: '=MEDIAN(NewSheet!$A$1:$A$2)' }, + { name: 'ExprC', expression: '=MEDIAN(NewSheet!$A$1:$A$3)' }, + { name: 'ExprD', expression: '=MEDIAN(FirstSheet!$A$1)' }, + ]) + + expect(engine.getNamedExpressionValue('ExprA')).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getNamedExpressionValue('ExprB')).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getNamedExpressionValue('ExprC')).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getNamedExpressionValue('ExprD')).toEqualError(detailedError(ErrorType.REF)) + + engine.addSheet('NewSheet') + engine.setSheetContent(engine.getSheetId('NewSheet')!, [[1], [2], [3], [4]]) + + expect(engine.getNamedExpressionValue('ExprA')).toBe(1) + expect(engine.getNamedExpressionValue('ExprB')).toBe(1.5) + expect(engine.getNamedExpressionValue('ExprC')).toBe(2) + expect(engine.getNamedExpressionValue('ExprD')).toBe(2.5) + }) + }) + + it('should convert placeholder sheet strategy when adding referenced sheet', () => { + const engine = HyperFormula.buildFromSheets({ + 'MainSheet': [['=PlaceholderSheet!A1']], + }, { chooseAddressMappingPolicy: new AlwaysSparse()}) + + const mainId = engine.getSheetId('MainSheet')! + + expect(engine.getCellValue(adr('A1', mainId))).toEqualError(detailedError(ErrorType.REF)) + + engine.addSheet('PlaceholderSheet') + const placeholderId = engine.getSheetId('PlaceholderSheet')! + + engine.setCellContents(adr('A1', placeholderId), 42) + + expect(engine.getCellValue(adr('A1', mainId))).toBe(42) + }) + + it('should handle adding sheet that resolves references with ranges in placeholder', () => { + const engine = HyperFormula.buildFromSheets({ + Main: [['=SUM(Data!A1:A3)']], + }) + + const mainId = engine.getSheetId('Main')! + + expect(engine.getCellValue(adr('A1', mainId))).toEqualError(detailedError(ErrorType.REF)) + + engine.addSheet('Data') + const dataId = engine.getSheetId('Data')! + engine.setCellContents(adr('A1', dataId), 1) + engine.setCellContents(adr('A2', dataId), 2) + engine.setCellContents(adr('A3', dataId), 3) + + expect(engine.getCellValue(adr('A1', mainId))).toBe(6) + }) + + it('should handle remove and add sheet cycle with range references', () => { + const engine = HyperFormula.buildFromSheets({ + Main: [['=SUM(Data!A1:A3)']], + Data: [[1], [2], [3]], + }) + + const mainId = engine.getSheetId('Main')! + const dataId = engine.getSheetId('Data')! + + expect(engine.getCellValue(adr('A1', mainId))).toBe(6) + + engine.removeSheet(dataId) + + expect(engine.getCellValue(adr('A1', mainId))).toEqualError(detailedError(ErrorType.REF)) + + engine.addSheet('Data') + const newDataId = engine.getSheetId('Data')! + engine.setCellContents(adr('A1', newDataId), 10) + engine.setCellContents(adr('A2', newDataId), 20) + engine.setCellContents(adr('A3', newDataId), 30) + + expect(engine.getCellValue(adr('A1', mainId))).toBe(60) + }) + + it('should correctly merge sheets when adding sheet that was previously referenced', () => { + const engine = HyperFormula.buildFromSheets({ + Main: [['=NewSheet!A1 + NewSheet!B1']], + }) + + const mainId = engine.getSheetId('Main')! + + expect(engine.getCellValue(adr('A1', mainId))).toEqualError(detailedError(ErrorType.REF)) + + engine.addSheet('NewSheet') + const newSheetId = engine.getSheetId('NewSheet')! + engine.setCellContents(adr('A1', newSheetId), 100) + engine.setCellContents(adr('B1', newSheetId), 50) + + expect(engine.getCellValue(adr('A1', mainId))).toBe(150) + }) +}) diff --git a/test/unit/cruds/change-cell-content.spec.ts b/test/unit/cruds/change-cell-content.spec.ts index 00314b996..cc083a0d8 100644 --- a/test/unit/cruds/change-cell-content.spec.ts +++ b/test/unit/cruds/change-cell-content.spec.ts @@ -13,7 +13,7 @@ import { import {AbsoluteCellRange} from '../../../src/AbsoluteCellRange' import {simpleCellAddress} from '../../../src/Cell' import {Config} from '../../../src/Config' -import {ArrayVertex, EmptyCellVertex, ValueCellVertex} from '../../../src/DependencyGraph' +import {ArrayFormulaVertex, EmptyCellVertex, ValueCellVertex} from '../../../src/DependencyGraph' import {ErrorMessage} from '../../../src/error-message' import {ColumnIndex} from '../../../src/Lookup/ColumnIndex' import { @@ -87,18 +87,18 @@ describe('changing cell content', () => { ['1', '2', '=A1'], ] const engine = HyperFormula.buildFromArray(sheet) - const a1 = engine.addressMapping.fetchCell(adr('A1')) - const b1 = engine.addressMapping.fetchCell(adr('B1')) - let c1 = engine.addressMapping.fetchCell(adr('C1')) + const a1 = engine.addressMapping.getCell(adr('A1')) + const b1 = engine.addressMapping.getCell(adr('B1')) + let c1 = engine.addressMapping.getCell(adr('C1')) - expect(engine.graph.existsEdge(a1, c1)).toBe(true) + expect(engine.graph.existsEdge(a1!, c1!)).toBe(true) expect(engine.getCellValue(adr('C1'))).toBe(1) engine.setCellContents(adr('C1'), [['=B1']]) - c1 = engine.addressMapping.fetchCell(adr('C1')) - expect(engine.graph.existsEdge(a1, c1)).toBe(false) - expect(engine.graph.existsEdge(b1, c1)).toBe(true) + c1 = engine.addressMapping.getCell(adr('C1')) + expect(engine.graph.existsEdge(a1!, c1!)).toBe(false) + expect(engine.graph.existsEdge(b1!, c1!)).toBe(true) expect(engine.getCellValue(adr('C1'))).toBe(2) }) @@ -108,14 +108,14 @@ describe('changing cell content', () => { ['1', '=A1'], ] const engine = HyperFormula.buildFromArray(sheet) - const a1 = engine.addressMapping.fetchCell(adr('A1')) - const b1 = engine.addressMapping.fetchCell(adr('B1')) + const a1 = engine.addressMapping.getCell(adr('A1')) + const b1 = engine.addressMapping.getCell(adr('B1')) - expect(engine.graph.existsEdge(a1, b1)).toBe(true) + expect(engine.graph.existsEdge(a1!, b1!)).toBe(true) expect(engine.getCellValue(adr('B1'))).toBe(1) engine.setCellContents(adr('B1'), [['7']]) expect(engine.getCellValue(adr('B1'))).toBe(7) - expect(engine.graph.existsEdge(a1, b1)).toBe(false) + expect(engine.graph.existsEdge(a1!, b1!)).toBe(false) }) it('update formula to plain text cell vertex', () => { @@ -123,14 +123,14 @@ describe('changing cell content', () => { ['1', '=A1'], ] const engine = HyperFormula.buildFromArray(sheet) - const a1 = engine.addressMapping.fetchCell(adr('A1')) - const b1 = engine.addressMapping.fetchCell(adr('B1')) + const a1 = engine.addressMapping.getCell(adr('A1')) + const b1 = engine.addressMapping.getCell(adr('B1')) - expect(engine.graph.existsEdge(a1, b1)).toBe(true) + expect(engine.graph.existsEdge(a1!, b1!)).toBe(true) expect(engine.getCellValue(adr('B1'))).toBe(1) engine.setCellContents(adr('B1'), [['foo']]) expect(engine.getCellValue(adr('B1'))).toBe('foo') - expect(engine.graph.existsEdge(a1, b1)).toBe(false) + expect(engine.graph.existsEdge(a1!, b1!)).toBe(false) }) it('set vertex with edge to empty cell', () => { @@ -140,10 +140,10 @@ describe('changing cell content', () => { engine.setCellContents(adr('A1'), [[null]]) - const a1 = engine.addressMapping.fetchCell(adr('A1')) - const a2 = engine.addressMapping.fetchCell(adr('B1')) + const a1 = engine.addressMapping.getCell(adr('A1')) + const a2 = engine.addressMapping.getCell(adr('B1')) expect(a1).toBeInstanceOf(EmptyCellVertex) - expect(engine.graph.existsEdge(a1, a2)).toBe(true) + expect(engine.graph.existsEdge(a1!, a2!)).toBe(true) expect(engine.getCellValue(adr('A1'))).toBe(null) }) @@ -152,8 +152,8 @@ describe('changing cell content', () => { ['1', '=A1'], ] const engine = HyperFormula.buildFromArray(sheet) - const a1 = engine.addressMapping.fetchCell(adr('A1')) - const b1 = engine.addressMapping.fetchCell(adr('B1')) + const a1 = engine.addressMapping.getCellOrThrow(adr('A1')) + const b1 = engine.addressMapping.getCellOrThrow(adr('B1')) expect(engine.graph.existsEdge(a1, b1)).toBe(true) expect(engine.getCellValue(adr('B1'))).toBe(1) @@ -168,15 +168,15 @@ describe('changing cell content', () => { ['1', '2'], ] const engine = HyperFormula.buildFromArray(sheet) - const a1 = engine.addressMapping.fetchCell(adr('A1')) - let b1 = engine.addressMapping.fetchCell(adr('B1')) + const a1 = engine.addressMapping.getCell(adr('A1')) + let b1 = engine.addressMapping.getCell(adr('B1')) - expect(engine.graph.existsEdge(a1, b1)).toBe(false) + expect(engine.graph.existsEdge(a1!, b1!)).toBe(false) expect(engine.getCellValue(adr('B1'))).toBe(2) engine.setCellContents(adr('B1'), [['=A1']]) - b1 = engine.addressMapping.fetchCell(adr('B1')) - expect(engine.graph.existsEdge(a1, b1)).toBe(true) + b1 = engine.addressMapping.getCell(adr('B1')) + expect(engine.graph.existsEdge(a1!, b1!)).toBe(true) expect(engine.getCellValue(adr('B1'))).toBe(1) }) @@ -290,9 +290,9 @@ describe('changing cell content', () => { engine.setCellContents(adr('B1'), '=A1') - const a1 = engine.addressMapping.fetchCell(adr('A1')) - const b1 = engine.addressMapping.fetchCell(adr('B1')) - expect(engine.graph.existsEdge(a1, b1)).toBe(true) + const a1 = engine.addressMapping.getCell(adr('A1')) + const b1 = engine.addressMapping.getCell(adr('B1')) + expect(engine.graph.existsEdge(a1!, b1!)).toBe(true) expect(engine.getCellValue(adr('B1'))).toBe(42) }) @@ -338,11 +338,11 @@ describe('changing cell content', () => { engine.setCellContents(adr('B1'), '=A1') - const a1 = engine.addressMapping.fetchCell(adr('A1')) - const b1 = engine.addressMapping.fetchCell(adr('B1')) - const c1 = engine.addressMapping.fetchCell(adr('C1')) - expect(engine.graph.existsEdge(a1, b1)).toBe(true) - expect(engine.graph.existsEdge(b1, c1)).toBe(true) + const a1 = engine.addressMapping.getCell(adr('A1')) + const b1 = engine.addressMapping.getCell(adr('B1')) + const c1 = engine.addressMapping.getCell(adr('C1')) + expect(engine.graph.existsEdge(a1!, b1!)).toBe(true) + expect(engine.graph.existsEdge(b1!, c1!)).toBe(true) expect(engine.getCellValue(adr('B1'))).toBe(42) expect(engine.getCellValue(adr('C1'))).toBe(42) }) @@ -355,10 +355,10 @@ describe('changing cell content', () => { engine.setCellContents(adr('A1'), null) - const a1 = engine.addressMapping.fetchCell(adr('A1')) - const b1 = engine.addressMapping.fetchCell(adr('B1')) + const a1 = engine.addressMapping.getCell(adr('A1')) + const b1 = engine.addressMapping.getCell(adr('B1')) expect(a1).toBeInstanceOf(EmptyCellVertex) - expect(engine.graph.existsEdge(a1, b1)).toBe(true) + expect(engine.graph.existsEdge(a1!, b1!)).toBe(true) }) it('change EMPTY to NUMBER', () => { @@ -369,9 +369,9 @@ describe('changing cell content', () => { engine.setCellContents(adr('A1'), '7') - const a1 = engine.addressMapping.fetchCell(adr('A1')) - const b1 = engine.addressMapping.fetchCell(adr('B1')) - expect(engine.graph.existsEdge(a1, b1)).toBe(true) + const a1 = engine.addressMapping.getCell(adr('A1')) + const b1 = engine.addressMapping.getCell(adr('B1')) + expect(engine.graph.existsEdge(a1!, b1!)).toBe(true) expect(engine.getCellValue(adr('A1'))).toBe(7) }) @@ -383,9 +383,9 @@ describe('changing cell content', () => { engine.setCellContents(adr('A1'), 'foo') - const a1 = engine.addressMapping.fetchCell(adr('A1')) - const b1 = engine.addressMapping.fetchCell(adr('B1')) - expect(engine.graph.existsEdge(a1, b1)).toBe(true) + const a1 = engine.addressMapping.getCell(adr('A1')) + const b1 = engine.addressMapping.getCell(adr('B1')) + expect(engine.graph.existsEdge(a1!, b1!)).toBe(true) expect(engine.getCellValue(adr('A1'))).toBe('foo') }) @@ -502,9 +502,9 @@ describe('changing cell content', () => { engine.setCellContents(adr('A1'), '=SUM(') - const a1 = engine.addressMapping.fetchCell(adr('A1')) - const b1 = engine.addressMapping.fetchCell(adr('B1')) - expect(engine.graph.existsEdge(a1, b1)).toBe(true) + const a1 = engine.addressMapping.getCell(adr('A1')) + const b1 = engine.addressMapping.getCell(adr('B1')) + expect(engine.graph.existsEdge(a1!, b1!)).toBe(true) expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.ERROR, ErrorMessage.ParseError)) expect(engine.getCellValue(adr('B1'))).toEqualError(detailedError(ErrorType.ERROR, ErrorMessage.ParseError)) }) @@ -517,9 +517,9 @@ describe('changing cell content', () => { engine.setCellContents(adr('B1'), '=SUM(') - const a1 = engine.addressMapping.fetchCell(adr('A1')) - const b1 = engine.addressMapping.fetchCell(adr('B1')) - expect(engine.graph.existsEdge(a1, b1)).toBe(false) + const a1 = engine.addressMapping.getCell(adr('A1')) + const b1 = engine.addressMapping.getCell(adr('B1')) + expect(engine.graph.existsEdge(a1!, b1!)).toBe(false) expect(engine.getCellValue(adr('A1'))).toEqual(1) expect(engine.getCellValue(adr('B1'))).toEqualError(detailedError(ErrorType.ERROR, ErrorMessage.ParseError)) @@ -761,9 +761,9 @@ describe('column ranges', () => { engine.setCellContents(adr('A2'), '3') - const range = engine.rangeMapping.fetchRange(colStart('A'), colEnd('B')) - const a2 = engine.addressMapping.fetchCell(adr('A2')) - expect(engine.graph.existsEdge(a2, range)).toEqual(true) + const range = engine.rangeMapping.getVertexOrThrow(colStart('A'), colEnd('B')) + const a2 = engine.addressMapping.getCell(adr('A2')) + expect(engine.graph.existsEdge(a2!, range)).toEqual(true) expect(engine.getCellValue(adr('C1'))).toEqual(6) }) @@ -776,11 +776,11 @@ describe('column ranges', () => { engine.setCellContents(adr('B1'), '=TRANSPOSE(A2:A3)') - const range = engine.rangeMapping.fetchRange(colStart('B'), colEnd('C')) - const b1 = engine.addressMapping.fetchCell(adr('B1')) - const c1 = engine.addressMapping.fetchCell(adr('C1')) - expect(engine.graph.existsEdge(b1, range)).toEqual(true) - expect(engine.graph.existsEdge(c1, range)).toEqual(true) + const range = engine.rangeMapping.getVertexOrThrow(colStart('B'), colEnd('C')) + const b1 = engine.addressMapping.getCell(adr('B1')) + const c1 = engine.addressMapping.getCell(adr('C1')) + expect(engine.graph.existsEdge(b1!, range)).toEqual(true) + expect(engine.graph.existsEdge(c1!, range)).toEqual(true) expect(engine.getCellValue(adr('A1'))).toEqual(3) }) }) @@ -807,9 +807,9 @@ describe('row ranges', () => { engine.setCellContents(adr('B1'), '3') - const range = engine.rangeMapping.fetchRange(rowStart(1), rowEnd(2)) - const b1 = engine.addressMapping.fetchCell(adr('B1')) - expect(engine.graph.existsEdge(b1, range)).toEqual(true) + const range = engine.rangeMapping.getVertexOrThrow(rowStart(1), rowEnd(2)) + const b1 = engine.addressMapping.getCell(adr('B1')) + expect(engine.graph.existsEdge(b1!, range)).toEqual(true) expect(engine.getCellValue(adr('A3'))).toEqual(6) }) @@ -820,11 +820,11 @@ describe('row ranges', () => { engine.setCellContents(adr('A2'), '=TRANSPOSE(B1:C1)') - const range = engine.rangeMapping.fetchRange(rowStart(2), rowEnd(3)) - const a2 = engine.addressMapping.fetchCell(adr('A2')) - const a3 = engine.addressMapping.fetchCell(adr('A3')) - expect(engine.graph.existsEdge(a2, range)).toEqual(true) - expect(engine.graph.existsEdge(a3, range)).toEqual(true) + const range = engine.rangeMapping.getVertexOrThrow(rowStart(2), rowEnd(3)) + const a2 = engine.addressMapping.getCell(adr('A2')) + const a3 = engine.addressMapping.getCell(adr('A3')) + expect(engine.graph.existsEdge(a2!, range)).toEqual(true) + expect(engine.graph.existsEdge(a3!, range)).toEqual(true) expect(engine.getCellValue(adr('A1'))).toEqual(3) }) }) @@ -884,7 +884,7 @@ describe('arrays', () => { expect(engine.arrayMapping.getArrayByCorner(adr('A1'))?.array.size).toEqual(ArraySize.error()) expectVerticesOfTypes(engine, [ - [ArrayVertex, undefined], + [ArrayFormulaVertex, undefined], [ValueCellVertex, undefined], ]) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ @@ -902,9 +902,9 @@ describe('arrays', () => { ]) expectVerticesOfTypes(engine, [ - [ArrayVertex, ArrayVertex, undefined], - [ArrayVertex, ArrayVertex, ArrayVertex], - [undefined, ArrayVertex, ArrayVertex], + [ArrayFormulaVertex, ArrayFormulaVertex, undefined], + [ArrayFormulaVertex, ArrayFormulaVertex, ArrayFormulaVertex], + [undefined, ArrayFormulaVertex, ArrayFormulaVertex], ]) expect(engine.arrayMapping.arrayMapping.size).toEqual(4) }) @@ -921,8 +921,8 @@ describe('arrays', () => { ]) expectVerticesOfTypes(engine, [ - [ArrayVertex, ArrayVertex, ArrayVertex], - [ArrayVertex, ArrayVertex, ArrayVertex], + [ArrayFormulaVertex, ArrayFormulaVertex, ArrayFormulaVertex], + [ArrayFormulaVertex, ArrayFormulaVertex, ArrayFormulaVertex], [undefined, undefined], ]) expect(engine.getSheetValues(0)).toEqual([ @@ -1068,8 +1068,8 @@ describe('arrays', () => { const b1 = engine.dependencyGraph.getCell(adr('b1'))! const b2 = engine.dependencyGraph.getCell(adr('b2'))! const b3 = engine.dependencyGraph.getCell(adr('b3'))! - const b1b2 = engine.rangeMapping.getRange(adr('b1'), adr('b2'))! - const b1b3 = engine.rangeMapping.getRange(adr('b1'), adr('b3'))! + const b1b2 = engine.rangeMapping.getRangeVertex(adr('b1'), adr('b2'))! + const b1b3 = engine.rangeMapping.getRangeVertex(adr('b1'), adr('b3'))! expect(engine.graph.existsEdge(b1, b1b2)).toBe(true) expect(engine.graph.existsEdge(b2, b1b2)).toBe(true) diff --git a/test/unit/cruds/copy-paste.spec.ts b/test/unit/cruds/copy-paste.spec.ts index 4f4e0ae8a..44674cd86 100644 --- a/test/unit/cruds/copy-paste.spec.ts +++ b/test/unit/cruds/copy-paste.spec.ts @@ -323,11 +323,11 @@ describe('Copy - paste integration', () => { engine.copy(AbsoluteCellRange.spanFrom(adr('C1'), 1, 1)) engine.paste(adr('A3')) - const range = engine.rangeMapping.fetchRange(rowStart(2), rowEnd(3)) - const a2 = engine.addressMapping.fetchCell(adr('A2')) - const a3 = engine.addressMapping.fetchCell(adr('A3')) - expect(engine.graph.existsEdge(a2, range)).toBe(true) - expect(engine.graph.existsEdge(a3, range)).toBe(true) + const range = engine.rangeMapping.getVertexOrThrow(rowStart(2), rowEnd(3)) + const a2 = engine.addressMapping.getCell(adr('A2')) + const a3 = engine.addressMapping.getCell(adr('A3')) + expect(engine.graph.existsEdge(a2!, range)).toBe(true) + expect(engine.graph.existsEdge(a3!, range)).toBe(true) expect(engine.getCellValue(adr('A1'))).toEqual(4) }) @@ -446,11 +446,14 @@ describe('Copy - paste integration - actions at the Operations layer', () => { const lazilyTransformingAstService = new LazilyTransformingAstService(stats) const dependencyGraph = DependencyGraph.buildEmpty(lazilyTransformingAstService, config, functionRegistry, namedExpressions, stats) const columnSearch = buildColumnSearchStrategy(dependencyGraph, config, stats) - const sheetMapping = dependencyGraph.sheetMapping const dateTimeHelper = new DateTimeHelper(config) const numberLiteralHelper = new NumberLiteralHelper(config) const cellContentParser = new CellContentParser(config, dateTimeHelper, numberLiteralHelper) - const parser = new ParserWithCaching(config, functionRegistry, sheetMapping.get) + const parser = new ParserWithCaching( + config, + functionRegistry, + dependencyGraph.sheetReferenceRegistrar.ensureSheetRegistered.bind(dependencyGraph.sheetReferenceRegistrar) + ) const arraySizePredictor = new ArraySizePredictor(config, functionRegistry) operations = new Operations(config, dependencyGraph, columnSearch, cellContentParser, parser, stats, lazilyTransformingAstService, namedExpressions, arraySizePredictor) }) diff --git a/test/unit/cruds/cut-paste.spec.ts b/test/unit/cruds/cut-paste.spec.ts index bf8bf81a7..37a5e454b 100644 --- a/test/unit/cruds/cut-paste.spec.ts +++ b/test/unit/cruds/cut-paste.spec.ts @@ -255,10 +255,10 @@ describe('Move cells', () => { engine.cut(AbsoluteCellRange.spanFrom(adr('A1'), 1, 1)) engine.paste(adr('A2')) - const b1 = engine.addressMapping.fetchCell(adr('B1')) - const b2 = engine.addressMapping.fetchCell(adr('B2')) + const b1 = engine.addressMapping.getCell(adr('B1')) + const b2 = engine.addressMapping.getCell(adr('B2')) const source = engine.addressMapping.getCell(adr('A1')) - const target = engine.addressMapping.fetchCell(adr('A2')) + const target = engine.addressMapping.getCell(adr('A2')) expect(graphEdgesCount(engine.graph)).toBe( 2, // A2 -> B1, A2 -> B2 @@ -269,8 +269,8 @@ describe('Move cells', () => { ) expect(source).toBe(undefined) - expect(engine.graph.existsEdge(target, b2)).toBe(true) - expect(engine.graph.existsEdge(target, b1)).toBe(true) + expect(engine.graph.existsEdge(target!, b2!)).toBe(true) + expect(engine.graph.existsEdge(target!, b1!)).toBe(true) expect(engine.getCellValue(adr('A2'))).toBe(1) }) }) @@ -291,12 +291,12 @@ describe('moving ranges', () => { expect(range.end).toEqual(adr('A2')) expect(engine.getCellValue(adr('A3'))).toEqual(2) - const a1 = engine.addressMapping.fetchCell(adr('A1')) - const a2 = engine.addressMapping.fetchCell(adr('A2')) - const a1a2 = engine.rangeMapping.fetchRange(adr('A1'), adr('A2')) + const a1 = engine.addressMapping.getCell(adr('A1')) + const a2 = engine.addressMapping.getCell(adr('A2')) + const a1a2 = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('A2')) expect(a1).toBeInstanceOf(EmptyCellVertex) - expect(engine.graph.existsEdge(a1, a1a2)).toBe(true) - expect(engine.graph.existsEdge(a2, a1a2)).toBe(true) + expect(engine.graph.existsEdge(a1!, a1a2)).toBe(true) + expect(engine.graph.existsEdge(a2!, a1a2)).toBe(true) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ [null, '1'], @@ -315,7 +315,7 @@ describe('moving ranges', () => { engine.cut(AbsoluteCellRange.spanFrom(adr('A1'), 1, 2)) engine.paste(adr('B1')) - expect(engine.rangeMapping.getRange(adr('B1'), adr('B2'))).not.toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(adr('B1'), adr('B2'))).not.toBe(undefined) const range = extractRange(engine, adr('A3')) expect(range.start).toEqual(adr('B1')) @@ -372,14 +372,14 @@ describe('moving ranges', () => { engine.cut(AbsoluteCellRange.spanFrom(adr('A1'), 1, 1)) engine.paste(adr('A2')) - const b1 = engine.addressMapping.fetchCell(adr('B1')) - const b2 = engine.addressMapping.fetchCell(adr('B2')) - const source = engine.addressMapping.fetchCell(adr('A1')) - const target = engine.addressMapping.fetchCell(adr('A2')) - const range = engine.rangeMapping.fetchRange(adr('A1'), adr('A2')) + const b1 = engine.addressMapping.getCell(adr('B1')) + const b2 = engine.addressMapping.getCell(adr('B2')) + const source = engine.addressMapping.getCell(adr('A1')) + const target = engine.addressMapping.getCell(adr('A2')) + const range = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('A2')) expect(source).toBeInstanceOf(EmptyCellVertex) - expect(source.getCellValue()).toBe(EmptyValue) + expect(source!.getCellValue()).toBe(EmptyValue) expect(engine.graph.getNodes().length).toBe( +2 // formulas + 1 // A2 @@ -391,10 +391,10 @@ describe('moving ranges', () => { + 1 // A1:A2 -> B1 + 1, // A2 -> B2 ) - expect(engine.graph.existsEdge(target, b2)).toBe(true) - expect(engine.graph.existsEdge(source, range)).toBe(true) - expect(engine.graph.existsEdge(target, range)).toBe(true) - expect(engine.graph.existsEdge(range, b1)).toBe(true) + expect(engine.graph.existsEdge(target!, b2!)).toBe(true) + expect(engine.graph.existsEdge(source!, range)).toBe(true) + expect(engine.graph.existsEdge(target!, range)).toBe(true) + expect(engine.graph.existsEdge(range, b1!)).toBe(true) expect(engine.getCellValue(adr('A2'))).toBe(1) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ @@ -414,10 +414,10 @@ describe('moving ranges', () => { const a1 = engine.addressMapping.getCell(adr('A1')) const a2 = engine.addressMapping.getCell(adr('A2')) - const b1 = engine.addressMapping.fetchCell(adr('B1')) - const c1 = engine.addressMapping.fetchCell(adr('C1')) - const c2 = engine.addressMapping.fetchCell(adr('C2')) - const range = engine.rangeMapping.fetchRange(adr('C1'), adr('C2')) + const b1 = engine.addressMapping.getCell(adr('B1')) + const c1 = engine.addressMapping.getCell(adr('C1')) + const c2 = engine.addressMapping.getCell(adr('C2')) + const range = engine.rangeMapping.getVertexOrThrow(adr('C1'), adr('C2')) expect(a1).toBe(undefined) expect(a2).toBe(undefined) @@ -433,9 +433,9 @@ describe('moving ranges', () => { + 1, // C2 -> B2 ) - expect(engine.graph.existsEdge(c1, range)).toBe(true) - expect(engine.graph.existsEdge(c2, range)).toBe(true) - expect(engine.graph.existsEdge(range, b1)).toBe(true) + expect(engine.graph.existsEdge(c1!, range)).toBe(true) + expect(engine.graph.existsEdge(c2!, range)).toBe(true) + expect(engine.graph.existsEdge(range, b1!)).toBe(true) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ [null, '=SUM(C1:C2)', '1'], @@ -464,16 +464,16 @@ describe('moving ranges', () => { )) /* edges */ - const c1c2 = engine.rangeMapping.fetchRange(adr('C1'), adr('C2')) - const a1a3 = engine.rangeMapping.fetchRange(adr('A1'), adr('A3')) + const c1c2 = engine.rangeMapping.getVertexOrThrow(adr('C1'), adr('C2')) + const a1a3 = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('A3')) expect(engine.graph.existsEdge(c1c2, a1a3)).toBe(false) - expect(engine.graph.existsEdge(engine.addressMapping.fetchCell(adr('A1')), a1a3)).toBe(true) - expect(engine.graph.existsEdge(engine.addressMapping.fetchCell(adr('A2')), a1a3)).toBe(true) - expect(engine.graph.existsEdge(engine.addressMapping.fetchCell(adr('A3')), a1a3)).toBe(true) + expect(engine.graph.existsEdge(engine.addressMapping.getCell(adr('A1'))!, a1a3)).toBe(true) + expect(engine.graph.existsEdge(engine.addressMapping.getCell(adr('A2'))!, a1a3)).toBe(true) + expect(engine.graph.existsEdge(engine.addressMapping.getCell(adr('A3'))!, a1a3)).toBe(true) - expect(engine.graph.existsEdge(engine.addressMapping.fetchCell(adr('C1')), c1c2)).toBe(true) - expect(engine.graph.existsEdge(engine.addressMapping.fetchCell(adr('C2')), c1c2)).toBe(true) + expect(engine.graph.existsEdge(engine.addressMapping.getCell(adr('C1'))!, c1c2)).toBe(true) + expect(engine.graph.existsEdge(engine.addressMapping.getCell(adr('C2'))!, c1c2)).toBe(true) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ [null, null, '1'], @@ -494,26 +494,26 @@ describe('moving ranges', () => { engine.paste(adr('C1')) /* edges */ - const c1c2 = engine.rangeMapping.fetchRange(adr('C1'), adr('C2')) - const c1c3 = engine.rangeMapping.fetchRange(adr('C1'), adr('C3')) - const a1a4 = engine.rangeMapping.fetchRange(adr('A1'), adr('A4')) + const c1c2 = engine.rangeMapping.getVertexOrThrow(adr('C1'), adr('C2')) + const c1c3 = engine.rangeMapping.getVertexOrThrow(adr('C1'), adr('C3')) + const a1a4 = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('A4')) expect(engine.graph.existsEdge(c1c2, c1c3)).toBe(true) expect(engine.graph.existsEdge(c1c3, a1a4)).toBe(false) - expect(engine.graph.existsEdge(engine.addressMapping.fetchCell(adr('A1')), a1a4)).toBe(true) - expect(engine.graph.existsEdge(engine.addressMapping.fetchCell(adr('A2')), a1a4)).toBe(true) - expect(engine.graph.existsEdge(engine.addressMapping.fetchCell(adr('A3')), a1a4)).toBe(true) - expect(engine.graph.existsEdge(engine.addressMapping.fetchCell(adr('A4')), a1a4)).toBe(true) + expect(engine.graph.existsEdge(engine.addressMapping.getCell(adr('A1'))!, a1a4)).toBe(true) + expect(engine.graph.existsEdge(engine.addressMapping.getCell(adr('A2'))!, a1a4)).toBe(true) + expect(engine.graph.existsEdge(engine.addressMapping.getCell(adr('A3'))!, a1a4)).toBe(true) + expect(engine.graph.existsEdge(engine.addressMapping.getCell(adr('A4'))!, a1a4)).toBe(true) - const c1 = engine.addressMapping.fetchCell(adr('C1')) - const c2 = engine.addressMapping.fetchCell(adr('C2')) - const c3 = engine.addressMapping.fetchCell(adr('C3')) - expect(engine.graph.existsEdge(c1, c1c2)).toBe(true) - expect(engine.graph.existsEdge(c2, c1c2)).toBe(true) - expect(engine.graph.existsEdge(c1, c1c3)).toBe(false) - expect(engine.graph.existsEdge(c2, c1c3)).toBe(false) - expect(engine.graph.existsEdge(c3, c1c3)).toBe(true) + const c1 = engine.addressMapping.getCell(adr('C1')) + const c2 = engine.addressMapping.getCell(adr('C2')) + const c3 = engine.addressMapping.getCell(adr('C3')) + expect(engine.graph.existsEdge(c1!, c1c2)).toBe(true) + expect(engine.graph.existsEdge(c2!, c1c2)).toBe(true) + expect(engine.graph.existsEdge(c1!, c1c3)).toBe(false) + expect(engine.graph.existsEdge(c2!, c1c3)).toBe(false) + expect(engine.graph.existsEdge(c3!, c1c3)).toBe(true) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ [null, null, '1'], @@ -715,7 +715,7 @@ describe('overlapping areas', () => { ])) }) - it('ArrayVertex#formula should be updated', () => { + it('ArrayFormulaVertex#formula should be updated', () => { const engine = HyperFormula.buildFromArray([ ['1', '2'], ['3', '4'], @@ -728,7 +728,7 @@ describe('overlapping areas', () => { expect(extractMatrixRange(engine, adr('A3'))).toEqual(new AbsoluteCellRange(adr('C1'), adr('D2'))) }) - it('ArrayVertex#formula should be updated when different sheets', () => { + it('ArrayFormulaVertex#formula should be updated when different sheets', () => { const engine = HyperFormula.buildFromSheets({ Sheet1: [ ['1', '2'], diff --git a/test/unit/cruds/move-cells.spec.ts b/test/unit/cruds/move-cells.spec.ts index 423dd3816..e05a75742 100644 --- a/test/unit/cruds/move-cells.spec.ts +++ b/test/unit/cruds/move-cells.spec.ts @@ -2,7 +2,7 @@ import {ErrorType, HyperFormula, SimpleCellAddress, SimpleCellRange} from '../.. import {AbsoluteCellRange} from '../../../src/AbsoluteCellRange' import {simpleCellAddress} from '../../../src/Cell' import {Config} from '../../../src/Config' -import {EmptyCellVertex, FormulaCellVertex} from '../../../src/DependencyGraph' +import {EmptyCellVertex, ScalarFormulaVertex} from '../../../src/DependencyGraph' import {SheetSizeLimitExceededError} from '../../../src/errors' import {EmptyValue} from '../../../src/interpreter/InterpreterValue' import {ColumnIndex} from '../../../src/Lookup/ColumnIndex' @@ -248,7 +248,7 @@ describe('Move cells', () => { engine.moveCells(AbsoluteCellRange.spanFrom(adr('A2'), 1, 1), adr('B1', 1)) - const vertex = engine.dependencyGraph.fetchCell(adr('B1', 1)) as FormulaCellVertex + const vertex = engine.dependencyGraph.fetchCell(adr('B1', 1)) as ScalarFormulaVertex expect(vertex.getAddress(engine.lazilyTransformingAstService)).toEqual(adr('B1', 1)) }) @@ -354,10 +354,10 @@ describe('Move cells', () => { engine.moveCells(AbsoluteCellRange.spanFrom(adr('A1'), 1, 1), adr('A2')) - const b1 = engine.addressMapping.fetchCell(adr('B1')) - const b2 = engine.addressMapping.fetchCell(adr('B2')) + const b1 = engine.addressMapping.getCell(adr('B1')) + const b2 = engine.addressMapping.getCell(adr('B2')) const source = engine.addressMapping.getCell(adr('A1')) - const target = engine.addressMapping.fetchCell(adr('A2')) + const target = engine.addressMapping.getCell(adr('A2')) expect(graphEdgesCount(engine.graph)).toBe( 2, // A2 -> B1, A2 -> B2 @@ -368,8 +368,8 @@ describe('Move cells', () => { ) expect(source).toBe(undefined) - expect(engine.graph.existsEdge(target, b2)).toBe(true) - expect(engine.graph.existsEdge(target, b1)).toBe(true) + expect(engine.graph.existsEdge(target!, b2!)).toBe(true) + expect(engine.graph.existsEdge(target!, b1!)).toBe(true) expect(engine.getCellValue(adr('A2'))).toBe(1) }) @@ -434,12 +434,12 @@ describe('moving ranges', () => { expect(range.end).toEqual(adr('A2')) expect(engine.getCellValue(adr('A3'))).toEqual(2) - const a1 = engine.addressMapping.fetchCell(adr('A1')) - const a2 = engine.addressMapping.fetchCell(adr('A2')) - const a1a2 = engine.rangeMapping.fetchRange(adr('A1'), adr('A2')) + const a1 = engine.addressMapping.getCell(adr('A1')) + const a2 = engine.addressMapping.getCell(adr('A2')) + const a1a2 = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('A2')) expect(a1).toBeInstanceOf(EmptyCellVertex) - expect(engine.graph.existsEdge(a1, a1a2)).toBe(true) - expect(engine.graph.existsEdge(a2, a1a2)).toBe(true) + expect(engine.graph.existsEdge(a1!, a1a2)).toBe(true) + expect(engine.graph.existsEdge(a2!, a1a2)).toBe(true) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ [null, '1'], @@ -457,7 +457,7 @@ describe('moving ranges', () => { engine.moveCells(AbsoluteCellRange.spanFrom(adr('A1'), 1, 2), adr('B1')) - expect(engine.rangeMapping.getRange(adr('B1'), adr('B2'))).not.toBe(undefined) + expect(engine.rangeMapping.getRangeVertex(adr('B1'), adr('B2'))).not.toBe(undefined) const range = extractRange(engine, adr('A3')) expect(range.start).toEqual(adr('B1')) @@ -504,14 +504,14 @@ describe('moving ranges', () => { engine.moveCells(AbsoluteCellRange.spanFrom(adr('A1'), 1, 1), adr('A2')) - const b1 = engine.addressMapping.fetchCell(adr('B1')) - const b2 = engine.addressMapping.fetchCell(adr('B2')) - const source = engine.addressMapping.fetchCell(adr('A1')) - const target = engine.addressMapping.fetchCell(adr('A2')) - const range = engine.rangeMapping.fetchRange(adr('A1'), adr('A2')) + const b1 = engine.addressMapping.getCell(adr('B1')) + const b2 = engine.addressMapping.getCell(adr('B2')) + const source = engine.addressMapping.getCell(adr('A1')) + const target = engine.addressMapping.getCell(adr('A2')) + const range = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('A2')) expect(source).toBeInstanceOf(EmptyCellVertex) - expect(source.getCellValue()).toBe(EmptyValue) + expect(source!.getCellValue()).toBe(EmptyValue) expect(engine.graph.getNodes().length).toBe( +2 // formulas + 1 // A2 @@ -523,10 +523,10 @@ describe('moving ranges', () => { + 1 // A1:A2 -> B1 + 1, // A2 -> B2 ) - expect(engine.graph.existsEdge(target, b2)).toBe(true) - expect(engine.graph.existsEdge(source, range)).toBe(true) - expect(engine.graph.existsEdge(target, range)).toBe(true) - expect(engine.graph.existsEdge(range, b1)).toBe(true) + expect(engine.graph.existsEdge(target!, b2!)).toBe(true) + expect(engine.graph.existsEdge(source!, range)).toBe(true) + expect(engine.graph.existsEdge(target!, range)).toBe(true) + expect(engine.graph.existsEdge(range, b1!)).toBe(true) expect(engine.getCellValue(adr('A2'))).toBe(1) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ @@ -545,10 +545,10 @@ describe('moving ranges', () => { const a1 = engine.addressMapping.getCell(adr('A1')) const a2 = engine.addressMapping.getCell(adr('A2')) - const b1 = engine.addressMapping.fetchCell(adr('B1')) - const c1 = engine.addressMapping.fetchCell(adr('C1')) - const c2 = engine.addressMapping.fetchCell(adr('C2')) - const range = engine.rangeMapping.fetchRange(adr('C1'), adr('C2')) + const b1 = engine.addressMapping.getCell(adr('B1')) + const c1 = engine.addressMapping.getCell(adr('C1')) + const c2 = engine.addressMapping.getCell(adr('C2')) + const range = engine.rangeMapping.getVertexOrThrow(adr('C1'), adr('C2')) expect(a1).toBe(undefined) expect(a2).toBe(undefined) @@ -564,9 +564,9 @@ describe('moving ranges', () => { + 1, // C2 -> B2 ) - expect(engine.graph.existsEdge(c1, range)).toBe(true) - expect(engine.graph.existsEdge(c2, range)).toBe(true) - expect(engine.graph.existsEdge(range, b1)).toBe(true) + expect(engine.graph.existsEdge(c1!, range)).toBe(true) + expect(engine.graph.existsEdge(c2!, range)).toBe(true) + expect(engine.graph.existsEdge(range, b1!)).toBe(true) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ [null, '=SUM(C1:C2)', '1'], @@ -594,16 +594,16 @@ describe('moving ranges', () => { )) /* edges */ - const c1c2 = engine.rangeMapping.fetchRange(adr('C1'), adr('C2')) - const a1a3 = engine.rangeMapping.fetchRange(adr('A1'), adr('A3')) + const c1c2 = engine.rangeMapping.getVertexOrThrow(adr('C1'), adr('C2')) + const a1a3 = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('A3')) expect(engine.graph.existsEdge(c1c2, a1a3)).toBe(false) - expect(engine.graph.existsEdge(engine.addressMapping.fetchCell(adr('A1')), a1a3)).toBe(true) - expect(engine.graph.existsEdge(engine.addressMapping.fetchCell(adr('A2')), a1a3)).toBe(true) - expect(engine.graph.existsEdge(engine.addressMapping.fetchCell(adr('A3')), a1a3)).toBe(true) + expect(engine.graph.existsEdge(engine.addressMapping.getCell(adr('A1'))!, a1a3)).toBe(true) + expect(engine.graph.existsEdge(engine.addressMapping.getCell(adr('A2'))!, a1a3)).toBe(true) + expect(engine.graph.existsEdge(engine.addressMapping.getCell(adr('A3'))!, a1a3)).toBe(true) - expect(engine.graph.existsEdge(engine.addressMapping.fetchCell(adr('C1')), c1c2)).toBe(true) - expect(engine.graph.existsEdge(engine.addressMapping.fetchCell(adr('C2')), c1c2)).toBe(true) + expect(engine.graph.existsEdge(engine.addressMapping.getCell(adr('C1'))!, c1c2)).toBe(true) + expect(engine.graph.existsEdge(engine.addressMapping.getCell(adr('C2'))!, c1c2)).toBe(true) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ [null, null, '1'], @@ -623,26 +623,26 @@ describe('moving ranges', () => { engine.moveCells(AbsoluteCellRange.spanFrom(adr('A1'), 1, 3), adr('C1')) /* edges */ - const c1c2 = engine.rangeMapping.fetchRange(adr('C1'), adr('C2')) - const c1c3 = engine.rangeMapping.fetchRange(adr('C1'), adr('C3')) - const a1a4 = engine.rangeMapping.fetchRange(adr('A1'), adr('A4')) + const c1c2 = engine.rangeMapping.getVertexOrThrow(adr('C1'), adr('C2')) + const c1c3 = engine.rangeMapping.getVertexOrThrow(adr('C1'), adr('C3')) + const a1a4 = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('A4')) expect(engine.graph.existsEdge(c1c2, c1c3)).toBe(true) expect(engine.graph.existsEdge(c1c3, a1a4)).toBe(false) - expect(engine.graph.existsEdge(engine.addressMapping.fetchCell(adr('A1')), a1a4)).toBe(true) - expect(engine.graph.existsEdge(engine.addressMapping.fetchCell(adr('A2')), a1a4)).toBe(true) - expect(engine.graph.existsEdge(engine.addressMapping.fetchCell(adr('A3')), a1a4)).toBe(true) - expect(engine.graph.existsEdge(engine.addressMapping.fetchCell(adr('A4')), a1a4)).toBe(true) + expect(engine.graph.existsEdge(engine.addressMapping.getCell(adr('A1'))!, a1a4)).toBe(true) + expect(engine.graph.existsEdge(engine.addressMapping.getCell(adr('A2'))!, a1a4)).toBe(true) + expect(engine.graph.existsEdge(engine.addressMapping.getCell(adr('A3'))!, a1a4)).toBe(true) + expect(engine.graph.existsEdge(engine.addressMapping.getCell(adr('A4'))!, a1a4)).toBe(true) - const c1 = engine.addressMapping.fetchCell(adr('C1')) - const c2 = engine.addressMapping.fetchCell(adr('C2')) - const c3 = engine.addressMapping.fetchCell(adr('C3')) - expect(engine.graph.existsEdge(c1, c1c2)).toBe(true) - expect(engine.graph.existsEdge(c2, c1c2)).toBe(true) - expect(engine.graph.existsEdge(c1, c1c3)).toBe(false) - expect(engine.graph.existsEdge(c2, c1c3)).toBe(false) - expect(engine.graph.existsEdge(c3, c1c3)).toBe(true) + const c1 = engine.addressMapping.getCell(adr('C1')) + const c2 = engine.addressMapping.getCell(adr('C2')) + const c3 = engine.addressMapping.getCell(adr('C3')) + expect(engine.graph.existsEdge(c1!, c1c2)).toBe(true) + expect(engine.graph.existsEdge(c2!, c1c2)).toBe(true) + expect(engine.graph.existsEdge(c1!, c1c3)).toBe(false) + expect(engine.graph.existsEdge(c2!, c1c3)).toBe(false) + expect(engine.graph.existsEdge(c3!, c1c3)).toBe(true) expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([ [null, null, '1'], @@ -833,7 +833,7 @@ describe('overlapping areas', () => { ])) }) - it('ArrayVertex#formula should be updated', () => { + it('ArrayFormulaVertex#formula should be updated', () => { const engine = HyperFormula.buildFromArray([ ['1', '2'], ['3', '4'], @@ -845,7 +845,7 @@ describe('overlapping areas', () => { expect(extractMatrixRange(engine, adr('A3'))).toEqual(new AbsoluteCellRange(adr('C1'), adr('D2'))) }) - it('ArrayVertex#formula should be updated when different sheets', () => { + it('ArrayFormulaVertex#formula should be updated when different sheets', () => { const engine = HyperFormula.buildFromSheets({ Sheet1: [ ['1', '2'], @@ -1033,12 +1033,12 @@ describe('column ranges', () => { expect(range.end).toEqual(colEnd('B')) expect(engine.getCellValue(adr('C1'))).toEqual(3) - const a1 = engine.addressMapping.fetchCell(adr('A1')) - const b1 = engine.addressMapping.fetchCell(adr('B1')) - const ab = engine.rangeMapping.fetchRange(colStart('A'), colEnd('B')) + const a1 = engine.addressMapping.getCell(adr('A1')) + const b1 = engine.addressMapping.getCell(adr('B1')) + const ab = engine.rangeMapping.getVertexOrThrow(colStart('A'), colEnd('B')) expect(a1).toBeInstanceOf(EmptyCellVertex) - expect(engine.graph.existsEdge(a1, ab)).toBe(true) - expect(engine.graph.existsEdge(b1, ab)).toBe(true) + expect(engine.graph.existsEdge(a1!, ab)).toBe(true) + expect(engine.graph.existsEdge(b1!, ab)).toBe(true) }) it('should transform relative column references', () => { @@ -1079,12 +1079,12 @@ describe('row ranges', () => { expect(range.end).toEqual(rowEnd(2)) expect(engine.getCellValue(adr('A3'))).toEqual(3) - const a1 = engine.addressMapping.fetchCell(adr('A1')) - const a2 = engine.addressMapping.fetchCell(adr('A2')) - const ab = engine.rangeMapping.fetchRange(rowStart(1), rowEnd(2)) + const a1 = engine.addressMapping.getCell(adr('A1')) + const a2 = engine.addressMapping.getCell(adr('A2')) + const ab = engine.rangeMapping.getVertexOrThrow(rowStart(1), rowEnd(2)) expect(a1).toBeInstanceOf(EmptyCellVertex) - expect(engine.graph.existsEdge(a1, ab)).toBe(true) - expect(engine.graph.existsEdge(a2, ab)).toBe(true) + expect(engine.graph.existsEdge(a1!, ab)).toBe(true) + expect(engine.graph.existsEdge(a2!, ab)).toBe(true) }) it('should transform relative column references', () => { diff --git a/test/unit/cruds/removing-columns.spec.ts b/test/unit/cruds/removing-columns.spec.ts index 196b2a49b..fb278786e 100644 --- a/test/unit/cruds/removing-columns.spec.ts +++ b/test/unit/cruds/removing-columns.spec.ts @@ -1,6 +1,6 @@ import {AlwaysDense, AlwaysSparse, ExportedCellChange, HyperFormula} from '../../../src' import {AbsoluteCellRange} from '../../../src/AbsoluteCellRange' -import {ArrayVertex, RangeVertex} from '../../../src/DependencyGraph' +import {ArrayFormulaVertex, RangeVertex} from '../../../src/DependencyGraph' import {ColumnIndex} from '../../../src/Lookup/ColumnIndex' import {CellAddress} from '../../../src/parser' import { @@ -492,7 +492,7 @@ describe('Removing columns - reevaluation', () => { }) describe('Removing rows - arrays', () => { - it('ArrayVertex#formula should be updated', () => { + it('ArrayFormulaVertex#formula should be updated', () => { const engine = HyperFormula.buildFromArray([ ['1', '2', '3', '=TRANSPOSE(A1:C2)'], ['4', '5', '6'], @@ -503,7 +503,7 @@ describe('Removing rows - arrays', () => { expect(extractMatrixRange(engine, adr('C1'))).toEqual(new AbsoluteCellRange(adr('A1'), adr('B2'))) }) - it('ArrayVertex#address should be updated', () => { + it('ArrayFormulaVertex#address should be updated', () => { const engine = HyperFormula.buildFromArray([ ['1', '2', '3', '=TRANSPOSE(A1:C2)'], ['4', '5', '6'], @@ -511,11 +511,11 @@ describe('Removing rows - arrays', () => { engine.removeColumns(0, [1, 1]) - const matrixVertex = engine.addressMapping.fetchCell(adr('C1')) as ArrayVertex + const matrixVertex = engine.addressMapping.getCell(adr('C1')) as ArrayFormulaVertex expect(matrixVertex.getAddress(engine.lazilyTransformingAstService)).toEqual(adr('C1')) }) - it('ArrayVertex#formula should be updated when different sheets', () => { + it('ArrayFormulaVertex#formula should be updated when different sheets', () => { const engine = HyperFormula.buildFromSheets({ Sheet1: [ ['1', '2', '3'], @@ -648,8 +648,8 @@ describe('Removing columns - graph', function() { engine.removeColumns(0, [2, 1]) - const b1 = engine.addressMapping.fetchCell(adr('b1')) - expect(engine.graph.adjacentNodes(b1)).toEqual(new Set()) + const b1 = engine.addressMapping.getCell(adr('b1')) + expect(engine.graph.adjacentNodes(b1!)).toEqual(new Set()) }) it('should remove vertices from graph', function() { @@ -702,9 +702,9 @@ describe('Removing columns - ranges', function() { engine.removeColumns(0, [0, 1]) - const range = engine.rangeMapping.fetchRange(adr('A1'), adr('B1')) - const a1 = engine.addressMapping.fetchCell(adr('A1')) - expect(engine.graph.existsEdge(a1, range)).toBe(true) + const range = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('B1')) + const a1 = engine.addressMapping.getCell(adr('A1')) + expect(engine.graph.existsEdge(a1!, range)).toBe(true) }) it('shift ranges in range mapping, range start before removed columns', () => { @@ -716,9 +716,9 @@ describe('Removing columns - ranges', function() { engine.removeColumns(0, [1, 2]) - const range = engine.rangeMapping.fetchRange(adr('A1'), adr('A1')) - const a1 = engine.addressMapping.fetchCell(adr('A1')) - expect(engine.graph.existsEdge(a1, range)).toBe(true) + const range = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('A1')) + const a1 = engine.addressMapping.getCell(adr('A1')) + expect(engine.graph.existsEdge(a1!, range)).toBe(true) }) it('shift ranges in range mapping, whole range', () => { @@ -726,7 +726,7 @@ describe('Removing columns - ranges', function() { ['1', '2', '3', '=SUM(A1:C1)'], /* */ ]) - const range = engine.rangeMapping.getRange(adr('A1'), adr('C1')) as RangeVertex + const range = engine.rangeMapping.getRangeVertex(adr('A1'), adr('C1')) as RangeVertex engine.removeColumns(0, [0, 3]) @@ -885,7 +885,7 @@ describe('Removing columns - merge ranges', () => { verifyRangesInSheet(engine, 0, []) verifyValues(engine) expect(engine.dependencyGraph.graph.getNodes().length).toBe(0) - expect(engine.dependencyGraph.rangeMapping.getMappingSize(0)).toBe(0) + expect(engine.dependencyGraph.rangeMapping.getNumberOfRangesInSheet(0)).toBe(0) }) it('should merge ranges in proper order', () => { diff --git a/test/unit/cruds/removing-rows.spec.ts b/test/unit/cruds/removing-rows.spec.ts index 67b2d0916..cf02c8b7d 100644 --- a/test/unit/cruds/removing-rows.spec.ts +++ b/test/unit/cruds/removing-rows.spec.ts @@ -1,6 +1,6 @@ import {ExportedCellChange, HyperFormula, InvalidArgumentsError} from '../../../src' import {AbsoluteCellRange} from '../../../src/AbsoluteCellRange' -import {ArrayVertex} from '../../../src/DependencyGraph' +import {ArrayFormulaVertex} from '../../../src/DependencyGraph' import {ColumnIndex} from '../../../src/Lookup/ColumnIndex' import {CellAddress} from '../../../src/parser' import { @@ -548,7 +548,7 @@ describe('Removing rows - reevaluation', () => { }) describe('Removing rows - arrays', () => { - it('ArrayVertex#formula should be updated', () => { + it('ArrayFormulaVertex#formula should be updated', () => { const engine = HyperFormula.buildFromArray([ ['1', '4'], ['2', '5'], @@ -561,7 +561,7 @@ describe('Removing rows - arrays', () => { expect(extractMatrixRange(engine, adr('A3'))).toEqual(new AbsoluteCellRange(adr('A1'), adr('B2'))) }) - it('ArrayVertex#address should be updated', () => { + it('ArrayFormulaVertex#address should be updated', () => { const engine = HyperFormula.buildFromArray([ ['1', '4'], ['2', '5'], @@ -571,11 +571,11 @@ describe('Removing rows - arrays', () => { engine.removeRows(0, [1, 1]) - const matrixVertex = engine.addressMapping.fetchCell(adr('A3')) as ArrayVertex + const matrixVertex = engine.addressMapping.getCell(adr('A3')) as ArrayFormulaVertex expect(matrixVertex.getAddress(engine.lazilyTransformingAstService)).toEqual(adr('A3')) }) - it('ArrayVertex#formula should be updated when different sheets', () => { + it('ArrayFormulaVertex#formula should be updated when different sheets', () => { const engine = HyperFormula.buildFromSheets({ Sheet1: [ ['1', '4'], @@ -737,8 +737,8 @@ describe('Removing rows - graph', function() { engine.removeRows(0, [2, 1]) - const a2 = engine.addressMapping.fetchCell(adr('A2')) - expect(engine.graph.adjacentNodes(a2)).toEqual(new Set()) + const a2 = engine.addressMapping.getCell(adr('A2')) + expect(engine.graph.adjacentNodes(a2!)).toEqual(new Set()) }) it('should remove vertices from graph', function() { @@ -772,9 +772,9 @@ describe('Removing rows - range mapping', function() { ]) engine.removeRows(0, [0, 1]) - const range = engine.rangeMapping.fetchRange(adr('A1'), adr('A2')) - const a1 = engine.addressMapping.fetchCell(adr('A1')) - expect(engine.graph.existsEdge(a1, range)).toBe(true) + const range = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('A2')) + const a1 = engine.addressMapping.getCell(adr('A1')) + expect(engine.graph.existsEdge(a1!, range)).toBe(true) }) it('shift ranges in range mapping, range start above removed rows', () => { @@ -785,9 +785,9 @@ describe('Removing rows - range mapping', function() { ]) engine.removeRows(0, [1, 2]) - const range = engine.rangeMapping.fetchRange(adr('A1'), adr('A1')) - const a1 = engine.addressMapping.fetchCell(adr('A1')) - expect(engine.graph.existsEdge(a1, range)).toBe(true) + const range = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('A1')) + const a1 = engine.addressMapping.getCell(adr('A1')) + expect(engine.graph.existsEdge(a1!, range)).toBe(true) }) it('shift ranges in range mapping, whole range', () => { @@ -798,7 +798,7 @@ describe('Removing rows - range mapping', function() { ['=SUM(A1:A3)'], ]) - const range = engine.rangeMapping.fetchRange(adr('A1'), adr('A3')) + const range = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('A3')) engine.removeRows(0, [0, 3]) const ranges = Array.from(engine.rangeMapping.rangesInSheet(0)) expect(ranges.length).toBe(0) @@ -814,10 +814,10 @@ describe('Removing rows - range mapping', function() { ['=SUM(A1:A3)'], ]) - const a1a3 = engine.rangeMapping.fetchRange(adr('A1'), adr('A3')) + const a1a3 = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('A3')) expect(graphReversedAdjacentNodes(engine.graph, a1a3).length).toBe(2) engine.removeRows(0, [0, 2]) - const a1a1 = engine.rangeMapping.fetchRange(adr('A1'), adr('A1')) + const a1a1 = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('A1')) expect(a1a1).toBe(a1a3) expect(graphReversedAdjacentNodes(engine.graph, a1a1).length).toBe(1) }) @@ -1028,7 +1028,7 @@ describe('Removing rows - merge ranges', () => { verifyRangesInSheet(engine, 0, []) verifyValues(engine) expect(engine.dependencyGraph.graph.getNodes().length).toBe(0) - expect(engine.dependencyGraph.rangeMapping.getMappingSize(0)).toBe(0) + expect(engine.dependencyGraph.rangeMapping.getNumberOfRangesInSheet(0)).toBe(0) }) it('should merge ranges in proper order', () => { diff --git a/test/unit/cruds/removing-sheet.spec.ts b/test/unit/cruds/removing-sheet.spec.ts index 3ee8f6b77..08c39c4ee 100644 --- a/test/unit/cruds/removing-sheet.spec.ts +++ b/test/unit/cruds/removing-sheet.spec.ts @@ -1,15 +1,15 @@ -import {ExportedCellChange, HyperFormula, NoSheetWithIdError} from '../../../src' +import {ExportedCellChange, HyperFormula, NoSheetWithIdError, CellValueType} from '../../../src' import {AbsoluteCellRange} from '../../../src/AbsoluteCellRange' import {ErrorType} from '../../../src/Cell' -import {ArrayVertex} from '../../../src/DependencyGraph' +import {ArrayFormulaVertex} from '../../../src/DependencyGraph' +import { ErrorMessage } from '../../../src/error-message' import {ColumnIndex} from '../../../src/Lookup/ColumnIndex' import {CellAddress} from '../../../src/parser' import { adr, + detailedError, detailedErrorWithOrigin, expectArrayWithSameContent, - expectEngineToBeTheSameAs, - expectReferenceToHaveRefError, extractReference, } from '../testUtils' @@ -17,13 +17,13 @@ describe('Removing sheet - checking if its possible', () => { it('no if theres no such sheet', () => { const engine = HyperFormula.buildFromArray([[]]) - expect(engine.isItPossibleToRemoveSheet(1)).toEqual(false) + expect(engine.isItPossibleToRemoveSheet(1)).toBe(false) }) it('yes otherwise', () => { const engine = HyperFormula.buildFromArray([[]]) - expect(engine.isItPossibleToRemoveSheet(0)).toEqual(true) + expect(engine.isItPossibleToRemoveSheet(0)).toBe(true) }) }) @@ -54,33 +54,6 @@ describe('remove sheet', () => { expect(Array.from(engine.addressMapping.entries())).toEqual([]) }) - it('should decrease last sheet id when removing last sheet', () => { - const engine = HyperFormula.buildFromSheets({ - Sheet1: [], - Sheet2: [], - }) - - engine.removeSheet(1) - - expect(Array.from(engine.sheetMapping.displayNames())).toEqual(['Sheet1']) - engine.addSheet() - expect(Array.from(engine.sheetMapping.displayNames())).toEqual(['Sheet1', 'Sheet2']) - }) - - it('should not decrease last sheet id when removing sheet other than last', () => { - const engine = HyperFormula.buildFromSheets({ - Sheet1: [], - Sheet2: [], - Sheet3: [], - }) - - engine.removeSheet(1) - - expect(Array.from(engine.sheetMapping.displayNames())).toEqual(['Sheet1', 'Sheet3']) - engine.addSheet() - expect(Array.from(engine.sheetMapping.displayNames())).toEqual(['Sheet1', 'Sheet3', 'Sheet4']) - }) - it('should remove sheet with matrix', () => { const engine = HyperFormula.buildFromSheets({ Sheet1: [ @@ -136,11 +109,75 @@ describe('remove sheet', () => { engine.removeSheet(0) - expect(Array.from(engine.sheetMapping.displayNames())).toEqual(['Sheet2']) + expect(Array.from(engine.sheetMapping.iterateSheetNames())).toEqual(['Sheet2']) expect(engine.getCellValue(adr('A1', 1))).toBe(1) expect(engine.getCellValue(adr('A2', 1))).toBe(2) expect(engine.getCellValue(adr('A3', 1))).toBe(3) }) + + it('converts sheet to placeholder if other sheet depends on it', () => { + const engine = HyperFormula.buildFromSheets({ + Sheet1: [[42]], + Sheet2: [['=Sheet1!A1']], + }) + + const sheet1Id = engine.getSheetId('Sheet1')! + + engine.removeSheet(sheet1Id) + + expect(engine.sheetMapping.hasSheetWithId(sheet1Id, { includePlaceholders: false })).toBe(false) + expect(engine.sheetMapping.hasSheetWithId(sheet1Id, { includePlaceholders: true })).toBe(true) + }) + + it('removes sheet completely if nothing depends on it', () => { + const engine = HyperFormula.buildFromSheets({ + Sheet1: [[42]], + Sheet2: [[100]], + }) + + const sheet1Id = engine.getSheetId('Sheet1')! + + engine.removeSheet(sheet1Id) + + expect(engine.sheetMapping.hasSheetWithId(sheet1Id, { includePlaceholders: false })).toBe(false) + expect(engine.sheetMapping.hasSheetWithId(sheet1Id, { includePlaceholders: true })).toBe(false) + }) + + it('removes the placeholder sheet if nothing depends on it any longer', () => { + const engine = HyperFormula.buildFromSheets({ + Sheet1: [[42]], + Sheet2: [['=Sheet1!A1']], + }) + + const sheet1Id = engine.getSheetId('Sheet1')! + const sheet2Id = engine.getSheetId('Sheet2')! + + engine.removeSheet(sheet1Id) + + expect(engine.sheetMapping.hasSheetWithId(sheet1Id, { includePlaceholders: false })).toBe(false) + expect(engine.sheetMapping.hasSheetWithId(sheet1Id, { includePlaceholders: true })).toBe(true) + + engine.setCellContents(adr('A1', sheet2Id), 100) + + expect(engine.sheetMapping.hasSheetWithId(sheet1Id, { includePlaceholders: false })).toBe(false) + expect(engine.sheetMapping.hasSheetWithId(sheet1Id, { includePlaceholders: true })).toBe(false) + }) + + it('decreases lastSheetId if removed sheet was the last one', () => { + const engine = HyperFormula.buildFromSheets({ + Sheet1: [[1]], + Sheet2: [[2]], + }) + + const sheet2Id = engine.getSheetId('Sheet2')! + + engine.removeSheet(sheet2Id) + + engine.addSheet('Sheet3') + const sheet3Id = engine.getSheetId('Sheet3')! + + expect(sheet3Id).toBe(sheet2Id) // new sheet reuses the ID + }) }) describe('remove sheet - adjust edges', () => { @@ -156,10 +193,10 @@ describe('remove sheet - adjust edges', () => { engine.removeSheet(1) - const a1 = engine.addressMapping.fetchCell(adr('A1')) - const b1 = engine.addressMapping.fetchCell(adr('B1')) + const a1 = engine.addressMapping.getCell(adr('A1')) + const b1 = engine.addressMapping.getCell(adr('B1')) - expect(engine.graph.existsEdge(a1, b1)).toBe(true) + expect(engine.graph.existsEdge(a1!, b1!)).toBe(true) }) it('should remove edge between sheets', () => { @@ -172,13 +209,14 @@ describe('remove sheet - adjust edges', () => { ], }) - const a1From0 = engine.addressMapping.fetchCell(adr('A1')) - const a1From1 = engine.addressMapping.fetchCell(adr('A1', 1)) - expect(engine.graph.existsEdge(a1From1, a1From0)).toBe(true) + const a1From0 = engine.addressMapping.getCell(adr('A1')) + const a1From1 = engine.addressMapping.getCell(adr('A1', 1)) + + expect(engine.graph.existsEdge(a1From1!, a1From0!)).toBe(true) engine.removeSheet(1) - expect(engine.graph.existsEdge(a1From1, a1From0)).toBe(false) + expect(engine.graph.existsEdge(a1From1!, a1From0!)).toBe(false) }) }) @@ -198,28 +236,34 @@ describe('remove sheet - adjust formula dependencies', () => { const reference = extractReference(engine, adr('B1')) expect(reference).toEqual(CellAddress.relative(-1, 0)) - expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([['1', '=A1']])) + expect(engine.getAllSheetsSerialized()).toEqual({Sheet1: [['1', '=A1']]}) + expect(engine.getAllSheetsValues()).toEqual({Sheet1: [[1, 1]]}) }) it('should be #REF after removing sheet', () => { + const sheet1Name = 'Sheet1' + const sheet2Name = 'Sheet2' const engine = HyperFormula.buildFromSheets({ - Sheet1: [ + [sheet1Name]: [ ['=Sheet2!A1'], ['=Sheet2!A1:A2'], ['=Sheet2!A:B'], ['=Sheet2!1:2'], ], - Sheet2: [ + [sheet2Name]: [ ['1'], ], }) - engine.removeSheet(1) + const sheet1Id = engine.getSheetId(sheet1Name)! + const sheet2Id = engine.getSheetId(sheet2Name)! + + engine.removeSheet(sheet2Id) - expectReferenceToHaveRefError(engine, adr('A1')) - expectReferenceToHaveRefError(engine, adr('A2')) - expectReferenceToHaveRefError(engine, adr('A3')) - expectReferenceToHaveRefError(engine, adr('A4')) + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('A2', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('A3', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('A4', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) }) it('should return changed values', () => { @@ -235,17 +279,465 @@ describe('remove sheet - adjust formula dependencies', () => { const changes = engine.removeSheet(1) expect(changes.length).toBe(1) - expect(changes).toContainEqual(new ExportedCellChange(adr('A1'), detailedErrorWithOrigin(ErrorType.REF, 'Sheet1!A1'))) + expect(changes).toContainEqual(new ExportedCellChange(adr('A1'), detailedErrorWithOrigin(ErrorType.REF, 'Sheet1!A1', ErrorMessage.SheetRef))) + }) + +}) + +describe('removeSheet() recalculates formulas (issue #1116)', () => { + it('returns REF error if other sheet depends on the removed one', () => { + const table1Name = 'table1' + const table2Name = 'table2' + const engine = HyperFormula.buildFromSheets({ + [table1Name]: [[`='${table2Name}'!A1`]], + [table2Name]: [[10]], + }) + + expect(engine.getCellValue(adr('A1', engine.getSheetId(table1Name)))).toBe(10) + expect(engine.getCellValue(adr('A1', engine.getSheetId(table2Name)))).toBe(10) + + engine.removeSheet(engine.getSheetId(table2Name)!) + + expect(engine.getCellValue(adr('A1', engine.getSheetId(table1Name)))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('A1', engine.getSheetId(table2Name)))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('returns REF error for chained dependencies across multiple sheets', () => { + const sheet1Name = 'Sheet1' + const sheet2Name = 'Sheet2' + const sheet3Name = 'Sheet3' + const engine = HyperFormula.buildFromSheets({ + [sheet1Name]: [[`='${sheet2Name}'!A1+2`]], + [sheet2Name]: [[`='${sheet3Name}'!A1*2`]], + [sheet3Name]: [[42]], + }) + + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet2Name)))).toBe(84) + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet1Name)))).toBe(86) + + engine.removeSheet(engine.getSheetId(sheet3Name)!) + + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet2Name)))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet1Name)))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('returns REF error for nested dependencies within same sheet referencing removed sheet', () => { + const sheet1Name = 'Sheet1' + const removedSheetName = 'RemovedSheet' + const engine = HyperFormula.buildFromSheets({ + [sheet1Name]: [['=B1*2', `='${removedSheetName}'!A1`]], + [removedSheetName]: [[15]], + }) + + expect(engine.getCellValue(adr('B1', engine.getSheetId(sheet1Name)))).toBe(15) + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet1Name)))).toBe(30) + + engine.removeSheet(engine.getSheetId(removedSheetName)!) + + expect(engine.getCellValue(adr('B1', engine.getSheetId(sheet1Name)))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet1Name)))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('returns REF error for multiple cells from different sheets referencing removed sheet', () => { + const sheet1Name = 'Sheet1' + const sheet2Name = 'Sheet2' + const targetSheetName = 'TargetSheet' + const engine = HyperFormula.buildFromSheets({ + [sheet1Name]: [[`='${targetSheetName}'!A1`, `='${targetSheetName}'!B1`]], + [sheet2Name]: [[`='${targetSheetName}'!A1+10`, `='${targetSheetName}'!B1+20`]], + [targetSheetName]: [[5, 7]], + }) + + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet1Name)))).toBe(5) + expect(engine.getCellValue(adr('B1', engine.getSheetId(sheet1Name)))).toBe(7) + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet2Name)))).toBe(15) + expect(engine.getCellValue(adr('B1', engine.getSheetId(sheet2Name)))).toBe(27) + + engine.removeSheet(engine.getSheetId(targetSheetName)!) + + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet1Name)))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('B1', engine.getSheetId(sheet1Name)))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet2Name)))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('B1', engine.getSheetId(sheet2Name)))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('returns REF error for formulas with mixed operations combining removed sheet references', () => { + const sheet1Name = 'Sheet1' + const removedSheetName = 'RemovedSheet' + const engine = HyperFormula.buildFromSheets({ + [sheet1Name]: [[100, `='${removedSheetName}'!A1 + A1`, `='${removedSheetName}'!B1 * 2`]], + [removedSheetName]: [[50, 25]], + }) + + expect(engine.getCellValue(adr('B1', engine.getSheetId(sheet1Name)))).toBe(150) + expect(engine.getCellValue(adr('C1', engine.getSheetId(sheet1Name)))).toBe(50) + + engine.removeSheet(engine.getSheetId(removedSheetName)!) + + expect(engine.getCellValue(adr('B1', engine.getSheetId(sheet1Name)))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('C1', engine.getSheetId(sheet1Name)))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('returns REF error for formulas with multi-cell ranges from removed sheet', () => { + const sheet1Name = 'Sheet1' + const dataSheetName = 'DataSheet' + const engine = HyperFormula.buildFromSheets({ + [sheet1Name]: [ + [`=SUM('${dataSheetName}'!A1:B5)`], + [`=MEDIAN('${dataSheetName}'!A1:B5)`], + ], + [dataSheetName]: [ + [1, 2], + [3, 4], + [5, 6], + [7, 8], + [9, 10], + ], + }) + + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet1Name)))).toBe(55) + expect(engine.getCellValue(adr('A2', engine.getSheetId(sheet1Name)))).toBe(5.5) + + engine.removeSheet(engine.getSheetId(dataSheetName)!) + + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet1Name)))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('A2', engine.getSheetId(sheet1Name)))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('returns REF error for named expressions referencing removed sheet', () => { + const sheet1Name = 'Sheet1' + const removedSheetName = 'RemovedSheet' + const engine = HyperFormula.buildFromSheets({ + [sheet1Name]: [['=MyValue'], ['=MyValue*2']], + [removedSheetName]: [[99]] + }, {}, [ + { name: 'MyValue', expression: `='${removedSheetName}'!$A$1` } + ]) + + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet1Name)))).toBe(99) + expect(engine.getCellValue(adr('A2', engine.getSheetId(sheet1Name)))).toBe(198) + + engine.removeSheet(engine.getSheetId(removedSheetName)!) + + expect(engine.getCellValue(adr('A1', engine.getSheetId(sheet1Name)))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('A2', engine.getSheetId(sheet1Name)))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('handles add-remove-add cycle correctly', () => { + const engine = HyperFormula.buildEmpty() + const sheet1Name = 'Sheet1' + const sheet2Name = 'Sheet2' + + engine.addSheet(sheet1Name) + const sheet1Id = engine.getSheetId(sheet1Name)! + engine.setCellContents(adr('A1', sheet1Id), `='${sheet2Name}'!A1`) + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + + engine.addSheet(sheet2Name) + const oldSheet2Id = engine.getSheetId(sheet2Name)! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBeNull() + + engine.setCellContents(adr('A1', oldSheet2Id), 42) + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(42) + expect(engine.getCellValue(adr('A1', oldSheet2Id))).toBe(42) + + engine.removeSheet(oldSheet2Id) + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('A1', oldSheet2Id))).toEqualError(detailedError(ErrorType.REF)) + + engine.addSheet(sheet2Name) + let newSheet2Id = engine.getSheetId(sheet2Name)! + engine.setCellContents(adr('A1', newSheet2Id), 43) + + expect(newSheet2Id).toBe(oldSheet2Id) + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(43) + expect(engine.getCellValue(adr('A1', newSheet2Id))).toBe(43) + + engine.removeSheet(oldSheet2Id) + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('A1', oldSheet2Id))).toEqualError(detailedError(ErrorType.REF)) + + engine.addSheet(sheet2Name) + newSheet2Id = engine.getSheetId(sheet2Name)! + engine.setCellContents(adr('A1', newSheet2Id), 44) + + expect(newSheet2Id).toBe(oldSheet2Id) + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(44) + expect(engine.getCellValue(adr('A1', newSheet2Id))).toBe(44) + + engine.removeSheet(oldSheet2Id) + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('A1', oldSheet2Id))).toEqualError(detailedError(ErrorType.REF)) + + engine.addSheet(sheet2Name) + newSheet2Id = engine.getSheetId(sheet2Name)! + engine.setCellContents(adr('A1', newSheet2Id), 45) + + expect(newSheet2Id).toBe(oldSheet2Id) + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(45) + expect(engine.getCellValue(adr('A1', newSheet2Id))).toBe(45) + }) + + it('REF error propagates through dependency chain when source sheet is removed', () => { + const engine = HyperFormula.buildFromSheets({ + 'Main': [['=Intermediate!A1*2']], + 'Intermediate': [['=Source!A1+10']], + 'Source': [[5]], + }) + const mainId = engine.getSheetId('Main')! + const intermediateId = engine.getSheetId('Intermediate')! + const sourceId = engine.getSheetId('Source')! + + expect(engine.getCellValue(adr('A1', mainId))).toBe(30) + expect(engine.getCellValue(adr('A1', intermediateId))).toBe(15) + + // Remove source sheet - error should propagate through chain + engine.removeSheet(sourceId) + + expect(engine.getCellValue(adr('A1', intermediateId))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('A1', mainId))).toEqualError(detailedError(ErrorType.REF)) + + // Re-add the sheet to resolve the errors + engine.addSheet('Source') + engine.setCellContents(adr('A1', engine.getSheetId('Source')), 5) + + expect(engine.getCellValue(adr('A1', intermediateId))).toBe(15) + expect(engine.getCellValue(adr('A1', mainId))).toBe(30) + }) + + it('removing sheet creates REF and adding it back resolves it', () => { + const engine = HyperFormula.buildFromSheets({ + 'Main': [['=Data!A1']], + 'Data': [[42]], + }) + const mainId = engine.getSheetId('Main')! + const dataId = engine.getSheetId('Data')! + + expect(engine.getCellValue(adr('A1', mainId))).toBe(42) + + engine.removeSheet(dataId) + + expect(engine.getCellValue(adr('A1', mainId))).toEqualError(detailedError(ErrorType.REF)) + + engine.addSheet('Data') + engine.setCellContents(adr('A1', engine.getSheetId('Data')), 99) + + expect(engine.getCellValue(adr('A1', mainId))).toBe(99) + }) + + describe('when using ranges with', () => { + it('function using `runFunction`', () => { + const sheet1Name = 'FirstSheet' + const sheet2Name = 'NewSheet' + const sheet1Data = [['=MEDIAN(NewSheet!A1:A1)', '=MEDIAN(NewSheet!A1:A2)', '=MEDIAN(NewSheet!A1:A3)', '=MEDIAN(NewSheet!A1:A4)']] + const sheet2Data = [[1], [2], [3], [4]] + const engine = HyperFormula.buildFromSheets({ + [sheet1Name]: sheet1Data, + [sheet2Name]: sheet2Data, + }) + + const sheet1Id = engine.getSheetId(sheet1Name)! + const sheet2Id = engine.getSheetId(sheet2Name)! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(1) + expect(engine.getCellValue(adr('B1', sheet1Id))).toBe(1.5) + expect(engine.getCellValue(adr('C1', sheet1Id))).toBe(2) + expect(engine.getCellValue(adr('D1', sheet1Id))).toBe(2.5) + + engine.removeSheet(sheet2Id) + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('B1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('C1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('D1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('function not using `runFunction`', () => { + const sheet1Name = 'FirstSheet' + const sheet2Name = 'NewSheet' + const sheet1Data = [['=SUM(NewSheet!A1:A1)', '=SUM(NewSheet!A1:A2)', '=SUM(NewSheet!A1:A3)', '=SUM(NewSheet!A1:A4)']] + const sheet2Data = [[1], [2], [3], [4]] + const engine = HyperFormula.buildFromSheets({ + [sheet1Name]: sheet1Data, + [sheet2Name]: sheet2Data, + }) + + const sheet1Id = engine.getSheetId(sheet1Name)! + const sheet2Id = engine.getSheetId(sheet2Name)! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(1) + expect(engine.getCellValue(adr('B1', sheet1Id))).toBe(3) + expect(engine.getCellValue(adr('C1', sheet1Id))).toBe(6) + expect(engine.getCellValue(adr('D1', sheet1Id))).toBe(10) + + engine.removeSheet(sheet2Id) + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('B1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('C1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('D1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('function using `runFunction` referencing range indirectly', () => { + const sheet1Name = 'FirstSheet' + const sheet2Name = 'NewSheet' + const sheet1Data = [ + ['=MEDIAN(A2)', '=MEDIAN(B2)', '=MEDIAN(C2)', '=MEDIAN(D2)'], + [`='${sheet2Name}'!A1:A1`, `='${sheet2Name}'!A1:B2`, `='${sheet2Name}'!A1:A3`, `='${sheet2Name}'!A1:A4`], + ] + const sheet2Data = [[1], [2], [3], [4]] + const engine = HyperFormula.buildFromSheets({ + [sheet1Name]: sheet1Data, + [sheet2Name]: sheet2Data, + }) + + const sheet1Id = engine.getSheetId(sheet1Name)! + const sheet2Id = engine.getSheetId(sheet2Name)! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(1) + expect(engine.getCellValue(adr('B1', sheet1Id))).toBe(1.5) + expect(engine.getCellValue(adr('C1', sheet1Id))).toBe(2) + expect(engine.getCellValue(adr('D1', sheet1Id))).toBe(2.5) + + engine.removeSheet(sheet2Id) + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('B1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('C1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('D1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('function not using `runFunction` referencing range indirectly', () => { + const sheet1Name = 'FirstSheet' + const sheet2Name = 'NewSheet' + const sheet1Data = [ + ['=SUM(A2)', '=SUM(B2)', '=SUM(C2)', '=SUM(D2)'], + [`='${sheet2Name}'!A1:A1`, `='${sheet2Name}'!A1:B2`, `='${sheet2Name}'!A1:A3`, `='${sheet2Name}'!A1:A4`], + ] + const sheet2Data = [[1], [2], [3], [4]] + const engine = HyperFormula.buildFromSheets({ + [sheet1Name]: sheet1Data, + [sheet2Name]: sheet2Data, + }) + + const sheet1Id = engine.getSheetId(sheet1Name)! + const sheet2Id = engine.getSheetId(sheet2Name)! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(1) + expect(engine.getCellValue(adr('B1', sheet1Id))).toBe(3) + expect(engine.getCellValue(adr('C1', sheet1Id))).toBe(6) + expect(engine.getCellValue(adr('D1', sheet1Id))).toBe(10) + + engine.removeSheet(sheet2Id) + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('B1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('C1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('D1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('function calling a named expression', () => { + const sheet1Name = 'FirstSheet' + const sheet2Name = 'NewSheet' + const sheet1Data = [[`='${sheet2Name}'!A1:A4`]] + const sheet2Data = [[1], [2], [3], [4]] + const engine = HyperFormula.buildFromSheets({ + [sheet1Name]: sheet1Data, + [sheet2Name]: sheet2Data, + }, {}, [ + { name: 'ExprA', expression: `=MEDIAN(${sheet2Name}!$A$1:$A$1)` }, + { name: 'ExprB', expression: `=MEDIAN(${sheet2Name}!$A$1:$A$2)` }, + { name: 'ExprC', expression: `=MEDIAN(${sheet2Name}!$A$1:$A$3)` }, + { name: 'ExprD', expression: `=MEDIAN(${sheet1Name}!$A$1)` } + ]) + + const sheet2Id = engine.getSheetId(sheet2Name)! + + expect(engine.getNamedExpressionValue('ExprA')).toBe(1) + expect(engine.getNamedExpressionValue('ExprB')).toBe(1.5) + expect(engine.getNamedExpressionValue('ExprC')).toBe(2) + expect(engine.getNamedExpressionValue('ExprD')).toBe(2.5) + + engine.removeSheet(sheet2Id) + + expect(engine.getNamedExpressionValue('ExprA')).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getNamedExpressionValue('ExprB')).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getNamedExpressionValue('ExprC')).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getNamedExpressionValue('ExprD')).toEqualError(detailedError(ErrorType.REF)) + }) }) }) describe('remove sheet - adjust address mapping', () => { - it('should remove sheet from address mapping', () => { - const engine = HyperFormula.buildFromArray([]) + it('should remove sheet from address mapping if nothing depends on it', () => { + const sheet1Name = 'Sheet1' - engine.removeSheet(0) + const engine = HyperFormula.buildFromSheets({ + [sheet1Name]: [[42]] + }) - expect(() => engine.addressMapping.strategyFor(0)).toThrowError("There's no sheet with id = 0") + const sheet1Id = engine.getSheetId(sheet1Name)! + engine.removeSheet(sheet1Id) + + expect(() => engine.addressMapping.getStrategyForSheetOrThrow(sheet1Id)).toThrow(new NoSheetWithIdError(sheet1Id)) + }) + + it('should not remove sheet from address mapping if another sheet depends on it', () => { + const sheet1Name = 'Sheet1' + const sheet2Name = 'Sheet2' + + const engine = HyperFormula.buildFromSheets({ + [sheet1Name]: [[42]], + [sheet2Name]: [[`='${sheet1Name}'!A1`]], + }) + + const sheet1Id = engine.getSheetId(sheet1Name)! + + engine.removeSheet(sheet1Id) + + expect(() => engine.addressMapping.getStrategyForSheetOrThrow(sheet1Id)).not.toThrow() + }) + + it('should not remove sheet from address mapping if a named expression depends on it', () => { + const sheet1Name = 'Sheet1' + const engine = HyperFormula.buildFromSheets({ + [sheet1Name]: [[42]], + }, {}, [ + { name: 'namedExpressionName', expression: `='${sheet1Name}'!$A$1` }, + ]) + + const sheet1Id = engine.getSheetId(sheet1Name)! + + engine.removeSheet(sheet1Id) + + expect(() => engine.addressMapping.getStrategyForSheetOrThrow(sheet1Id)).not.toThrow() + }) + + it('removes the placeholder sheet from address mapping if nothing depends on it any longer', () => { + const sheet1Name = 'Sheet1' + const sheet2Name = 'Sheet2' + + const engine = HyperFormula.buildFromSheets({ + [sheet1Name]: [[42]], + [sheet2Name]: [[`='${sheet1Name}'!A1`]], + }) + + const sheet1Id = engine.getSheetId(sheet1Name)! + const sheet2Id = engine.getSheetId(sheet2Name)! + + engine.removeSheet(sheet1Id) + + expect(() => engine.addressMapping.getStrategyForSheetOrThrow(sheet1Id)).not.toThrow() + + engine.setCellContents(adr('A1', sheet2Id), 100) + + expect(() => engine.addressMapping.getStrategyForSheetOrThrow(sheet1Id)).toThrow(new NoSheetWithIdError(sheet1Id)) }) }) @@ -284,12 +776,13 @@ describe('remove sheet - adjust matrix mapping', () => { ['=TRANSPOSE(A1:B1)'], ], }) - expect(engine.arrayMapping.getArray(AbsoluteCellRange.spanFrom(adr('A2'), 1, 2))).toBeInstanceOf(ArrayVertex) + + expect(engine.arrayMapping.getArray(AbsoluteCellRange.spanFrom(adr('A2'), 1, 2))).toBeInstanceOf(ArrayFormulaVertex) engine.removeSheet(0) expect(engine.arrayMapping.getArray(AbsoluteCellRange.spanFrom(adr('A2'), 1, 2))).toBeUndefined() - expect(engine.arrayMapping.getArray(AbsoluteCellRange.spanFrom(adr('A2', 1), 1, 2))).toBeInstanceOf(ArrayVertex) + expect(engine.arrayMapping.getArray(AbsoluteCellRange.spanFrom(adr('A2', 1), 1, 2))).toBeInstanceOf(ArrayFormulaVertex) }) }) @@ -303,7 +796,65 @@ describe('remove sheet - adjust column index', () => { engine.removeSheet(0) - expect(removeSheetSpy).toHaveBeenCalled() + expect(removeSheetSpy).toHaveBeenCalledWith(0) expectArrayWithSameContent([], index.getValueIndex(0, 0, 1).index) }) }) + +describe('remove sheet - placeholder sheet behavior', () => { + it('should return ERROR type when getting cell value from a placeholder sheet', () => { + const engine = HyperFormula.buildFromSheets({ + Sheet1: [[42]], + Sheet2: [['=Sheet1!A1']], + }) + + const sheet1Id = engine.getSheetId('Sheet1')! + + engine.removeSheet(sheet1Id) + + expect(engine.getCellValueType(adr('A1', sheet1Id))).toBe('ERROR') + }) + + it('should return null when getting serialized cell from a placeholder sheet', () => { + const engine = HyperFormula.buildFromSheets({ + Sheet1: [[42]], + Sheet2: [['=Sheet1!A1']], + }) + + const sheet1Id = engine.getSheetId('Sheet1')! + + engine.removeSheet(sheet1Id) + + const result = engine.getCellSerialized(adr('A1', sheet1Id)) + expect(result).toBeNull() + }) + + it('should remove range vertices when clearing formulas that use ranges', () => { + const engine = HyperFormula.buildFromArray([ + [1, 2, 3], + ['=SUM(A1:C1)'], + ]) + + expect(engine.rangeMapping.getNumberOfRangesInSheet(0)).toBe(1) + + engine.setCellContents(adr('A2'), null) + + expect(engine.rangeMapping.getNumberOfRangesInSheet(0)).toBe(0) + }) + + it('should remove range vertices when removing sheet with ranges', () => { + const engine = HyperFormula.buildFromSheets({ + Sheet1: [ + [1, 2, 3], + ['=SUM(A1:C1)'], + ], + Sheet2: [[100]], + }) + + expect(engine.rangeMapping.getNumberOfRangesInSheet(0)).toBe(1) + + engine.removeSheet(0) + + expect(engine.rangeMapping.getNumberOfRangesInSheet(0)).toBe(0) + }) +}) diff --git a/test/unit/cruds/rename-sheet.spec.ts b/test/unit/cruds/rename-sheet.spec.ts index 22a62f072..1114f63ff 100644 --- a/test/unit/cruds/rename-sheet.spec.ts +++ b/test/unit/cruds/rename-sheet.spec.ts @@ -1,34 +1,36 @@ import {HyperFormula, NoSheetWithIdError, SheetNameAlreadyTakenError} from '../../../src' +import {ErrorType} from '../../../src/Cell' +import {adr, detailedError} from '../testUtils' -describe('Is it possible to rename sheet', () => { +describe('isItPossibleToRenameSheet() returns', () => { it('true if possible', () => { const engine = HyperFormula.buildFromSheets({'Sheet1': []}) - expect(engine.isItPossibleToRenameSheet(0, 'Foo')).toEqual(true) - expect(engine.isItPossibleToRenameSheet(0, '~`!@#$%^&*()_-+_=/|?{}[]\"')).toEqual(true) + expect(engine.isItPossibleToRenameSheet(0, 'Foo')).toBe(true) + expect(engine.isItPossibleToRenameSheet(0, '~`!@#$%^&*()_-+_=/|?{}[]\"')).toBe(true) }) it('true if same name', () => { const engine = HyperFormula.buildFromSheets({'Sheet1': []}) - expect(engine.isItPossibleToRenameSheet(0, 'Sheet1')).toEqual(true) + expect(engine.isItPossibleToRenameSheet(0, 'Sheet1')).toBe(true) }) it('false if sheet does not exists', () => { const engine = HyperFormula.buildFromSheets({'Sheet1': []}) - expect(engine.isItPossibleToRenameSheet(1, 'Foo')).toEqual(false) + expect(engine.isItPossibleToRenameSheet(1, 'Foo')).toBe(false) }) it('false if given name is taken', () => { const engine = HyperFormula.buildFromSheets({'Sheet1': [], 'Sheet2': []}) - expect(engine.isItPossibleToRenameSheet(0, 'Sheet2')).toEqual(false) + expect(engine.isItPossibleToRenameSheet(0, 'Sheet2')).toBe(false) }) }) -describe('Rename sheet', () => { - it('works', () => { +describe('renameSheet()', () => { + it('renames sheet and updates sheet mapping', () => { const engine = HyperFormula.buildEmpty() engine.addSheet('foo') @@ -39,7 +41,7 @@ describe('Rename sheet', () => { expect(engine.doesSheetExist('bar')).toBe(true) }) - it('error when there is no sheet with given ID', () => { + it('throws error when sheet with given ID does not exist', () => { const engine = HyperFormula.buildEmpty() expect(() => { @@ -47,7 +49,7 @@ describe('Rename sheet', () => { }).toThrow(new NoSheetWithIdError(0)) }) - it('error when new sheet name is already taken', () => { + it('throws error when new sheet name is already taken', () => { const engine = HyperFormula.buildEmpty() engine.addSheet() engine.addSheet('bar') @@ -57,7 +59,7 @@ describe('Rename sheet', () => { }).toThrow(new SheetNameAlreadyTakenError('bar')) }) - it('change for the same name', () => { + it('allows renaming to the same name (no-op)', () => { const engine = HyperFormula.buildEmpty() engine.addSheet('foo') @@ -67,7 +69,7 @@ describe('Rename sheet', () => { expect(engine.doesSheetExist('foo')).toBe(true) }) - it('change for the same canonical name', () => { + it('allows changing case of the same canonical name', () => { const engine = HyperFormula.buildEmpty() engine.addSheet('Foo') @@ -77,11 +79,505 @@ describe('Rename sheet', () => { expect(engine.doesSheetExist('FOO')).toBe(true) }) - it('should update the sheet dependencies', () => { - const engine = HyperFormula.buildFromSheets({'OldSheetName': [[42]], 'DependantSheet': [['=OldSheetName!A1']]}) + describe('recalculates formulas (issue #1116)', () => { + it('recalculates single cell reference', () => { + const sheet1Name = 'Sheet1' + const oldName = 'OldName' + const newName = 'NewName' + const engine = HyperFormula.buildFromSheets({ + [sheet1Name]: [[`='${newName}'!A1`]], + [oldName]: [[42]], + }) + const sheet1Id = engine.getSheetId(sheet1Name)! + const oldNameId = engine.getSheetId(oldName)! - engine.renameSheet(0, 'NewSheetName') + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) - expect(engine.getCellFormula({ sheet: 1, row: 0, col: 0 })).toEqual('=NewSheetName!A1') + engine.renameSheet(oldNameId, newName) + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(42) + }) + + it('recalculates nested dependencies within same sheet', () => { + const sheet1Name = 'Sheet1' + const oldName = 'OldName' + const newName = 'NewName' + const engine = HyperFormula.buildFromSheets({ + [sheet1Name]: [['=B1*2', `='${newName}'!A1`]], + [oldName]: [[15]], + }) + const sheet1Id = engine.getSheetId(sheet1Name)! + const oldNameId = engine.getSheetId(oldName)! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('B1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + + engine.renameSheet(oldNameId, newName) + + expect(engine.getCellValue(adr('B1', sheet1Id))).toBe(15) + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(30) + }) + + it('recalculates formulas with mixed operations', () => { + const sheet1Name = 'Sheet1' + const oldName = 'OldName' + const newName = 'NewName' + const engine = HyperFormula.buildFromSheets({ + [sheet1Name]: [[100, `='${newName}'!A1 + A1`, `='${newName}'!B1 * 2`]], + [oldName]: [[50, 25]], + }) + const sheet1Id = engine.getSheetId(sheet1Name)! + const oldNameId = engine.getSheetId(oldName)! + + expect(engine.getCellValue(adr('B1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('C1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + + engine.renameSheet(oldNameId, newName) + + expect(engine.getCellValue(adr('B1', sheet1Id))).toBe(150) + expect(engine.getCellValue(adr('C1', sheet1Id))).toBe(50) + }) + + it('recalculates named expressions', () => { + const sheet1Name = 'Sheet1' + const oldName = 'OldName' + const newName = 'NewName' + const engine = HyperFormula.buildFromSheets({ + [sheet1Name]: [['=MyValue'], ['=MyValue*2']], + [oldName]: [[99]], + }, {}, [ + { name: 'MyValue', expression: `='${newName}'!$A$1` } + ]) + const sheet1Id = engine.getSheetId(sheet1Name)! + const oldNameId = engine.getSheetId(oldName)! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('A2', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + + engine.renameSheet(oldNameId, newName) + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(99) + expect(engine.getCellValue(adr('A2', sheet1Id))).toBe(198) + }) + + it('moves reserved range vertices onto renamed sheet', () => { + const formulaSheet = 'FormulaSheet' + const oldName = 'SourceSheet' + const newName = 'GhostSheet' + const engine = HyperFormula.buildFromSheets({ + [formulaSheet]: [[`=SUM('${newName}'!A1:B1)`]], + [oldName]: [[1, 2]], + }) + const formulaSheetId = engine.getSheetId(formulaSheet)! + const oldNameId = engine.getSheetId(oldName)! + + expect(engine.getCellValue(adr('A1', formulaSheetId))).toEqualError(detailedError(ErrorType.REF)) + + engine.renameSheet(oldNameId, newName) + + const renamedSheetId = engine.getSheetId(newName)! + + expect(engine.getCellValue(adr('A1', formulaSheetId))).toBe(3) + + const movedRange = engine.rangeMapping.getRangeVertex(adr('A1', renamedSheetId), adr('B1', renamedSheetId)) + + expect(movedRange).toBeDefined() + expect(movedRange?.sheet).toBe(renamedSheetId) + }) + + it('merges duplicate range vertices after renaming into reserved name', () => { + const oldName = 'OldName' + const newName = 'GhostSheet' + const usesOld = 'UsesOld' + const usesNew = 'UsesNew' + const engine = HyperFormula.buildFromSheets({ + [usesOld]: [[`=SUM('${oldName}'!A1:A2)`]], + [usesNew]: [[`=SUM('${newName}'!A1:A2)`]], + [oldName]: [[5], [7]], + }) + const usesOldId = engine.getSheetId(usesOld)! + const usesNewId = engine.getSheetId(usesNew)! + const oldNameId = engine.getSheetId(oldName)! + + expect(engine.getCellValue(adr('A1', usesOldId))).toBe(12) + expect(engine.getCellValue(adr('A1', usesNewId))).toEqualError(detailedError(ErrorType.REF)) + + engine.renameSheet(oldNameId, newName) + + expect(engine.getCellValue(adr('A1', usesOldId))).toBe(12) + expect(engine.getCellValue(adr('A1', usesNewId))).toBe(12) + expect(engine.getCellFormula(adr('A1', usesNewId))).toBe(`=SUM(${newName}!A1:A2)`) + expect(engine.getCellFormula(adr('A1', usesOldId))).toBe(`=SUM(${newName}!A1:A2)`) + + engine.setCellContents(adr('A1', oldNameId), 100) + + expect(engine.getCellValue(adr('A1', usesOldId))).toBe(107) + expect(engine.getCellValue(adr('A1', usesNewId))).toBe(107) + }) + + it('recalculates column and row ranges', () => { + const sheet1Name = 'Sheet1' + const oldName = 'OldName' + const newName = 'NewName' + const engine = HyperFormula.buildFromSheets({ + [sheet1Name]: [ + [`=SUM('${newName}'!A:A)`], + [`=SUM('${newName}'!1:2)`], + ], + [oldName]: [ + [1, 2], + [3, 4], + ], + }) + const sheet1Id = engine.getSheetId(sheet1Name)! + const oldNameId = engine.getSheetId(oldName)! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('A2', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + + engine.renameSheet(oldNameId, newName) + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(4) + expect(engine.getCellValue(adr('A2', sheet1Id))).toBe(10) + }) + + it('keeps existing dependencies and dependents when renaming a sheet', () => { + const mainSheetName = 'Sheet1' + const secondarySheetName = 'Sheet2' + const newNameForSecondarySheet = 'Sheet3' + const engine = HyperFormula.buildFromSheets({ + [mainSheetName]: [['main sheet', `=${secondarySheetName}!A1`, `=${newNameForSecondarySheet}!A1`]], + [secondarySheetName]: [['secondary sheet', `=${mainSheetName}!A1`]], + }) + const mainSheetId = engine.getSheetId(mainSheetName)! + const secondarySheetId = engine.getSheetId(secondarySheetName)! + + expect(engine.getCellValue(adr('B1', mainSheetId))).toBe('secondary sheet') + expect(engine.getCellValue(adr('C1', mainSheetId))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('B1', secondarySheetId))).toBe('main sheet') + + engine.renameSheet(secondarySheetId, newNameForSecondarySheet) + + expect(engine.getSheetId(newNameForSecondarySheet)).toBe(secondarySheetId) + expect(engine.getCellValue(adr('B1', mainSheetId))).toBe('secondary sheet') + expect(engine.getCellValue(adr('C1', mainSheetId))).toBe('secondary sheet') + expect(engine.getCellValue(adr('B1', secondarySheetId))).toBe('main sheet') + }) + + it('removing renamed sheet returns REF error', () => { + const sheet1Name = 'Sheet1' + const oldName = 'OldName' + const newName = 'NewName' + const engine = HyperFormula.buildFromSheets({ + [sheet1Name]: [[`='${newName}'!A1`]], + [oldName]: [[42]], + }) + const sheet1Id = engine.getSheetId(sheet1Name)! + const oldNameId = engine.getSheetId(oldName)! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + + engine.renameSheet(oldNameId, newName) + + expect(engine.getSheetId(newName)).toBe(oldNameId) + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(42) + + engine.removeSheet(oldNameId) + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('adding sheet with the same name as new name of renamed sheet throws error', () => { + const oldName = 'OldName' + const newName = 'NewName' + const engine = HyperFormula.buildFromSheets({ + [oldName]: [[42]], + }) + const oldNameId = engine.getSheetId(oldName)! + + engine.renameSheet(oldNameId, newName) + + expect(() => { + engine.addSheet(newName) + }).toThrow(new SheetNameAlreadyTakenError(newName)) + }) + + it('adding sheet with the old name of renamed sheet creates new sheet', () => { + const sheet1Name = 'Sheet1' + const oldName = 'OldName' + const newName = 'NewName' + const engine = HyperFormula.buildFromSheets({ + [sheet1Name]: [[`='${oldName}'!A1`]], + [oldName]: [[42]], + }) + const sheet1Id = engine.getSheetId(sheet1Name)! + const oldNameId = engine.getSheetId(oldName)! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(42) + + engine.renameSheet(oldNameId, newName) + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(42) + + engine.addSheet(oldName) + engine.setCellContents(adr('A1', engine.getSheetId(oldName)), 100) + + expect(engine.getSheetId(oldName)).not.toBe(engine.getSheetId(newName)) + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(42) + expect(engine.getCellValue(adr('A1', engine.getSheetId(newName)))).toBe(42) + expect(engine.getCellValue(adr('A1', engine.getSheetId(oldName)))).toBe(100) + }) + + + it('renaming sheet that references non-existent sheet keeps REF error', () => { + const sheet1Name = 'Sheet1' + const newName = 'NewName' + const nonExistent = 'NonExistent' + const engine = HyperFormula.buildFromSheets({ + [sheet1Name]: [[`='${nonExistent}'!A1`]], + }) + const sheet1Id = engine.getSheetId(sheet1Name)! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + + engine.renameSheet(sheet1Id, newName) + + expect(engine.getCellValue(adr('A1', engine.getSheetId(newName)))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('chain renaming resolves multiple placeholder references sequentially', () => { + const engine = HyperFormula.buildFromSheets({ + 'Main': [['=SheetB!A1', '=SheetC!A1']], + 'SheetA': [[10]], + }) + const mainId = engine.getSheetId('Main')! + const sheetAId = engine.getSheetId('SheetA')! + + // Both are initially REF errors (placeholders) + expect(engine.getCellValue(adr('A1', mainId))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('B1', mainId))).toEqualError(detailedError(ErrorType.REF)) + + // Rename SheetA → SheetB: resolves first placeholder + engine.renameSheet(sheetAId, 'SheetB') + + expect(engine.getCellValue(adr('A1', mainId))).toBe(10) + expect(engine.getCellValue(adr('B1', mainId))).toEqualError(detailedError(ErrorType.REF)) + // Formula A1 now references SheetB (follows the rename) + expect(engine.getCellFormula(adr('A1', mainId))).toBe('=SheetB!A1') + + // Add a new sheet named SheetC to resolve second placeholder + engine.addSheet('SheetC') + engine.setCellContents(adr('A1', engine.getSheetId('SheetC')), 20) + + expect(engine.getCellValue(adr('A1', mainId))).toBe(10) + expect(engine.getCellValue(adr('B1', mainId))).toBe(20) + }) + + it('renaming sheet with circular formula does not break engine', () => { + const engine = HyperFormula.buildFromSheets({ + 'Sheet1': [['=Sheet2!A1']], + 'Sheet2': [['=Sheet1!A1']], + }) + const sheet1Id = engine.getSheetId('Sheet1')! + const sheet2Id = engine.getSheetId('Sheet2')! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.CYCLE)) + expect(engine.getCellValue(adr('A1', sheet2Id))).toEqualError(detailedError(ErrorType.CYCLE)) + + engine.renameSheet(sheet1Id, 'RenamedSheet1') + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.CYCLE)) + expect(engine.getCellValue(adr('A1', sheet2Id))).toEqualError(detailedError(ErrorType.CYCLE)) + expect(engine.getCellFormula(adr('A1', sheet2Id))).toBe('=RenamedSheet1!A1') + }) + + it('multiple formulas referencing same placeholder all resolve after rename', () => { + const engine = HyperFormula.buildFromSheets({ + 'Sheet1': [['=Ghost!A1'], ['=Ghost!A1+1'], ['=SUM(Ghost!A1:A2)']], + 'Sheet2': [['=Ghost!A1*2'], ['=Ghost!B1']], + 'RealSheet': [[100, 200], [300]], + }) + const sheet1Id = engine.getSheetId('Sheet1')! + const sheet2Id = engine.getSheetId('Sheet2')! + const realSheetId = engine.getSheetId('RealSheet')! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('A2', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('A3', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('A1', sheet2Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('A2', sheet2Id))).toEqualError(detailedError(ErrorType.REF)) + + engine.renameSheet(realSheetId, 'Ghost') + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(100) + expect(engine.getCellValue(adr('A2', sheet1Id))).toBe(101) + expect(engine.getCellValue(adr('A3', sheet1Id))).toBe(400) + expect(engine.getCellValue(adr('A1', sheet2Id))).toBe(200) + expect(engine.getCellValue(adr('A2', sheet2Id))).toBe(200) + }) + + it('when new name is already referenced, engine merges both sheet names', () => { + const engine = HyperFormula.buildFromSheets({ + 'Main': [['=Source!A1', '=Target!A1']], + 'Source': [[42]], + }) + const mainId = engine.getSheetId('Main')! + const sourceId = engine.getSheetId('Source')! + + expect(engine.getCellValue(adr('A1', mainId))).toBe(42) + expect(engine.getCellValue(adr('B1', mainId))).toEqualError(detailedError(ErrorType.REF)) + + engine.renameSheet(sourceId, 'Target') + + expect(engine.getCellFormula(adr('A1', mainId))).toBe('=Target!A1') + expect(engine.getCellFormula(adr('B1', mainId))).toBe('=Target!A1') + expect(engine.getCellValue(adr('A1', mainId))).toBe(42) + expect(engine.getCellValue(adr('B1', mainId))).toBe(42) + + engine.setCellContents(adr('A1', sourceId), 100) + + expect(engine.getCellValue(adr('A1', mainId))).toBe(100) + expect(engine.getCellValue(adr('B1', mainId))).toBe(100) + }) + + it('handles deeply nested REF error propagation', () => { + const engine = HyperFormula.buildFromSheets({ + 'L1': [['=L2!A1+1']], + 'L2': [['=L3!A1+1']], + 'L3': [['=L4!A1+1']], + 'L4': [['=Ghost!A1']], + 'Source': [[100]], + }) + const l1Id = engine.getSheetId('L1')! + const l2Id = engine.getSheetId('L2')! + const l3Id = engine.getSheetId('L3')! + const l4Id = engine.getSheetId('L4')! + const sourceId = engine.getSheetId('Source')! + + expect(engine.getCellValue(adr('A1', l1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('A1', l2Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('A1', l3Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('A1', l4Id))).toEqualError(detailedError(ErrorType.REF)) + + engine.renameSheet(sourceId, 'Ghost') + + expect(engine.getCellValue(adr('A1', l4Id))).toBe(100) + expect(engine.getCellValue(adr('A1', l3Id))).toBe(101) + expect(engine.getCellValue(adr('A1', l2Id))).toBe(102) + expect(engine.getCellValue(adr('A1', l1Id))).toBe(103) + }) + + describe('when using ranges with', () => { + it('function using `runFunction`', () => { + const engine = HyperFormula.buildFromSheets({ + 'FirstSheet': [['=MEDIAN(NewName!A1:A1)', '=MEDIAN(NewName!A1:A2)', '=MEDIAN(NewName!A1:A3)', '=MEDIAN(NewName!A1:A4)']], + 'OldName': [[1], [2], [3], [4]], + }) + const sheet1Id = engine.getSheetId('FirstSheet')! + const sheet2Id = engine.getSheetId('OldName')! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('B1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('C1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('D1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + + engine.renameSheet(sheet2Id, 'NewName') + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(1) + expect(engine.getCellValue(adr('B1', sheet1Id))).toBe(1.5) + expect(engine.getCellValue(adr('C1', sheet1Id))).toBe(2) + expect(engine.getCellValue(adr('D1', sheet1Id))).toBe(2.5) + }) + + it('function not using `runFunction`', () => { + const engine = HyperFormula.buildFromSheets({ + 'FirstSheet': [['=SUM(NewName!A1:A1)', '=SUM(NewName!A1:A2)', '=SUM(NewName!A1:A3)', '=SUM(NewName!A1:A4)']], + 'OldName': [[1], [2], [3], [4]], + }) + const sheet1Id = engine.getSheetId('FirstSheet')! + const sheet2Id = engine.getSheetId('OldName')! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('B1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('C1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('D1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + + engine.renameSheet(sheet2Id, 'NewName') + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(1) + expect(engine.getCellValue(adr('B1', sheet1Id))).toBe(3) + expect(engine.getCellValue(adr('C1', sheet1Id))).toBe(6) + expect(engine.getCellValue(adr('D1', sheet1Id))).toBe(10) + }) + + it('function using `runFunction` referencing range indirectly', () => { + const engine = HyperFormula.buildFromSheets({ + 'FirstSheet': [ + ['=MEDIAN(A2)', '=MEDIAN(B2)', '=MEDIAN(C2)', '=MEDIAN(D2)'], + ['=\'NewName\'!A1:A1', '=\'NewName\'!A1:B2', '=\'NewName\'!A1:A3', '=\'NewName\'!A1:A4'], + ], + 'OldName': [[1], [2], [3], [4]], + }) + const sheet1Id = engine.getSheetId('FirstSheet')! + const sheet2Id = engine.getSheetId('OldName')! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('B1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('C1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('D1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + + engine.renameSheet(sheet2Id, 'NewName') + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(1) + expect(engine.getCellValue(adr('B1', sheet1Id))).toBe(1.5) + expect(engine.getCellValue(adr('C1', sheet1Id))).toBe(2) + expect(engine.getCellValue(adr('D1', sheet1Id))).toBe(2.5) + }) + + it('function not using `runFunction` referencing range indirectly', () => { + const engine = HyperFormula.buildFromSheets({ + 'FirstSheet': [ + ['=SUM(A2)', '=SUM(B2)', '=SUM(C2)', '=SUM(D2)'], + ['=\'NewName\'!A1:A1', '=\'NewName\'!A1:B2', '=\'NewName\'!A1:A3', '=\'NewName\'!A1:A4'], + ], + 'OldName': [[1], [2], [3], [4]], + }) + const sheet1Id = engine.getSheetId('FirstSheet')! + const sheet2Id = engine.getSheetId('OldName')! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('B1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('C1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getCellValue(adr('D1', sheet1Id))).toEqualError(detailedError(ErrorType.REF)) + + engine.renameSheet(sheet2Id, 'NewName') + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(1) + expect(engine.getCellValue(adr('B1', sheet1Id))).toBe(3) + expect(engine.getCellValue(adr('C1', sheet1Id))).toBe(6) + expect(engine.getCellValue(adr('D1', sheet1Id))).toBe(10) + }) + + it('function calling a named expression', () => { + const engine = HyperFormula.buildFromSheets({ + 'FirstSheet': [['=\'OldName\'!A1:A4']], + 'OldName': [[1], [2], [3], [4]], + }, {}, [ + { name: 'ExprA', expression: '=MEDIAN(NewName!$A$1:$A$1)' }, + { name: 'ExprB', expression: '=MEDIAN(NewName!$A$1:$A$2)' }, + { name: 'ExprC', expression: '=MEDIAN(NewName!$A$1:$A$3)' }, + ]) + + expect(engine.getNamedExpressionValue('ExprA')).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getNamedExpressionValue('ExprB')).toEqualError(detailedError(ErrorType.REF)) + expect(engine.getNamedExpressionValue('ExprC')).toEqualError(detailedError(ErrorType.REF)) + + engine.renameSheet(engine.getSheetId('OldName')!, 'NewName') + + expect(engine.getNamedExpressionValue('ExprA')).toBe(1) + expect(engine.getNamedExpressionValue('ExprB')).toBe(1.5) + expect(engine.getNamedExpressionValue('ExprC')).toBe(2) + }) + }) }) }) diff --git a/test/unit/cruds/set-matrix-empty.spec.ts b/test/unit/cruds/set-matrix-empty.spec.ts index e5cd68f9f..b0b8e20fb 100644 --- a/test/unit/cruds/set-matrix-empty.spec.ts +++ b/test/unit/cruds/set-matrix-empty.spec.ts @@ -9,7 +9,7 @@ describe('Set matrix empty', () => { ['=TRANSPOSE(A1:B1)'], ]) const dependencyGraph = engine.dependencyGraph - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const matrixVertex = dependencyGraph.arrayMapping.getArray(AbsoluteCellRange.spanFrom(adr('A2'), 1, 2))! dependencyGraph.setArrayEmpty(matrixVertex) @@ -25,7 +25,7 @@ describe('Set matrix empty', () => { ['=TRANSPOSE(A1:B1)'], ]) const dependencyGraph = engine.dependencyGraph - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const matrixVertex = dependencyGraph.arrayMapping.getArray(AbsoluteCellRange.spanFrom(adr('A2'), 1, 2))! dependencyGraph.setArrayEmpty(matrixVertex) @@ -50,7 +50,7 @@ describe('Set matrix empty', () => { ['=TRANSPOSE(A1:B1)'], ]) const dependencyGraph = engine.dependencyGraph - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const matrixVertex = dependencyGraph.arrayMapping.getArray(AbsoluteCellRange.spanFrom(adr('A2'), 1, 2))! dependencyGraph.setArrayEmpty(matrixVertex) @@ -71,10 +71,10 @@ describe('Set matrix empty', () => { ['=TRANSPOSE(A1:B1)'], ]) const dependencyGraph = engine.dependencyGraph - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const matrixVertex = dependencyGraph.arrayMapping.getArray(AbsoluteCellRange.spanFrom(adr('A2'), 1, 2))! - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const rangeVertex = dependencyGraph.rangeMapping.getRange(adr('A2'), adr('A3'))! + + const rangeVertex = dependencyGraph.rangeMapping.getRangeVertex(adr('A2'), adr('A3'))! expect(dependencyGraph.existsEdge(matrixVertex, rangeVertex)).toBe(true) dependencyGraph.setArrayEmpty(matrixVertex) @@ -97,10 +97,10 @@ describe('Set matrix empty', () => { ['=TRANSPOSE(A1:B1)'], ]) const dependencyGraph = engine.dependencyGraph - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const matrixVertex = dependencyGraph.arrayMapping.getArray(AbsoluteCellRange.spanFrom(adr('A2'), 1, 2))! - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const rangeVertex = dependencyGraph.rangeMapping.getRange(adr('A1'), adr('A2'))! + + const rangeVertex = dependencyGraph.rangeMapping.getRangeVertex(adr('A1'), adr('A2'))! expect(dependencyGraph.existsEdge(matrixVertex, rangeVertex)).toBe(true) dependencyGraph.setArrayEmpty(matrixVertex) diff --git a/test/unit/dependency-graph-sheet-reference-registrar.spec.ts b/test/unit/dependency-graph-sheet-reference-registrar.spec.ts new file mode 100644 index 000000000..1fe440d6a --- /dev/null +++ b/test/unit/dependency-graph-sheet-reference-registrar.spec.ts @@ -0,0 +1,72 @@ +import { AlwaysDense } from '../../src' +import {AddressMapping, DenseStrategy, SheetMapping, SheetReferenceRegistrar} from '../../src/DependencyGraph' +import {buildTranslationPackage} from '../../src/i18n' +import {enGB} from '../../src/i18n/languages' + +describe('SheetReferenceRegistrar', () => { + const createDependencies = () => { + const sheetMapping = new SheetMapping(buildTranslationPackage(enGB)) + const addressMapping = new AddressMapping(new AlwaysDense()) + const registrar = new SheetReferenceRegistrar(sheetMapping, addressMapping) + return { sheetMapping, addressMapping, registrar } + } + + it('when called with non-existing sheet, adds a placeholder', () => { + const { sheetMapping, addressMapping, registrar } = createDependencies() + + const sheetId = registrar.ensureSheetRegistered('NewSheet') + + expect(sheetMapping.numberOfSheets({ includePlaceholders: true })).toBe(1) + expect(sheetMapping.hasSheetWithId(sheetId, { includePlaceholders: true })).toBe(true) + expect(() => addressMapping.getStrategyForSheetOrThrow(sheetId)).not.toThrow() + }) + + it('when called with existing placeholder sheet, doesnt modify address mapping nor sheet mapping', () => { + const { sheetMapping, addressMapping, registrar } = createDependencies() + const firstSheetId = registrar.ensureSheetRegistered('PlaceholderSheet') + + const secondSheetId = registrar.ensureSheetRegistered('PlaceholderSheet') + + expect(secondSheetId).toBe(firstSheetId) + expect(sheetMapping.numberOfSheets({ includePlaceholders: true })).toBe(1) + expect(() => addressMapping.getStrategyForSheetOrThrow(firstSheetId)).not.toThrow() + }) + + it('when called with existing real sheet, doesnt modify address mapping nor sheet mapping', () => { + const { sheetMapping, addressMapping, registrar } = createDependencies() + const realSheetId = sheetMapping.addSheet('RealSheet') + addressMapping.addSheetWithStrategy(realSheetId, new DenseStrategy(0, 0)) + + const returnedSheetId = registrar.ensureSheetRegistered('RealSheet') + + expect(returnedSheetId).toBe(realSheetId) + expect(sheetMapping.numberOfSheets({ includePlaceholders: true })).toBe(1) + expect(sheetMapping.numberOfSheets({ includePlaceholders: false })).toBe(1) + expect(() => addressMapping.getStrategyForSheetOrThrow(realSheetId)).not.toThrow() + }) + + it('when called with name that differs from some placeholder sheet only by case, doesnt modify address mapping nor sheet mapping', () => { + const { sheetMapping, addressMapping, registrar } = createDependencies() + const firstSheetId = registrar.ensureSheetRegistered('PlaceholderSheet') + + const secondSheetId = registrar.ensureSheetRegistered('PLACEHOLDERSHEET') + + expect(secondSheetId).toBe(firstSheetId) + expect(secondSheetId).toBe(firstSheetId) + expect(sheetMapping.numberOfSheets({ includePlaceholders: true })).toBe(1) + expect(() => addressMapping.getStrategyForSheetOrThrow(firstSheetId)).not.toThrow() + }) + + it('when called with name that differs from some real sheet only by case, doesnt modify address mapping nor sheet mapping', () => { + const { sheetMapping, addressMapping, registrar } = createDependencies() + const realSheetId = sheetMapping.addSheet('RealSheet') + addressMapping.addSheetWithStrategy(realSheetId, new DenseStrategy(0, 0)) + + const returnedSheetId = registrar.ensureSheetRegistered('REALSHEET') + + expect(returnedSheetId).toBe(realSheetId) + expect(sheetMapping.numberOfSheets({ includePlaceholders: true })).toBe(1) + expect(sheetMapping.numberOfSheets({ includePlaceholders: false })).toBe(1) + expect(() => addressMapping.getStrategyForSheetOrThrow(realSheetId)).not.toThrow() + }) +}) diff --git a/test/unit/emitting-events.spec.ts b/test/unit/emitting-events.spec.ts index 5393ecff8..6db2665da 100644 --- a/test/unit/emitting-events.spec.ts +++ b/test/unit/emitting-events.spec.ts @@ -6,6 +6,7 @@ import { NamedExpressionDoesNotExistError, } from '../../src' import {Events} from '../../src/Emitter' +import { ErrorMessage } from '../../src/error-message' import {adr, detailedErrorWithOrigin} from './testUtils' @@ -32,7 +33,7 @@ describe('Events', () => { engine.removeSheet(1) expect(handler).toHaveBeenCalledTimes(1) - expect(handler).toHaveBeenCalledWith('Sheet2', [new ExportedCellChange(adr('A1'), detailedErrorWithOrigin(ErrorType.REF, 'Sheet1!A1'))]) + expect(handler).toHaveBeenCalledWith('Sheet2', [new ExportedCellChange(adr('A1'), detailedErrorWithOrigin(ErrorType.REF, 'Sheet1!A1', ErrorMessage.SheetRef))]) }) it('sheetRemoved name contains actual display name', function() { @@ -46,7 +47,7 @@ describe('Events', () => { engine.removeSheet(1) expect(handler).toHaveBeenCalledTimes(1) - expect(handler).toHaveBeenCalledWith('Sheet2', [new ExportedCellChange(adr('A1'), detailedErrorWithOrigin(ErrorType.REF, 'Sheet1!A1'))]) + expect(handler).toHaveBeenCalledWith('Sheet2', [new ExportedCellChange(adr('A1'), detailedErrorWithOrigin(ErrorType.REF, 'Sheet1!A1', ErrorMessage.SheetRef))]) }) it('sheetRenamed works', () => { diff --git a/test/unit/graph-builder.spec.ts b/test/unit/graph-builder.spec.ts index 60133863d..0041398fa 100644 --- a/test/unit/graph-builder.spec.ts +++ b/test/unit/graph-builder.spec.ts @@ -10,9 +10,9 @@ describe('GraphBuilder', () => { ['42'], ]) - const vertex = engine.addressMapping.fetchCell(adr('A1')) + const vertex = engine.addressMapping.getCell(adr('A1')) expect(vertex).toBeInstanceOf(ValueCellVertex) - expect(vertex.getCellValue()).toBe(42) + expect(vertex!.getCellValue()).toBe(42) }) it('build sheet with simple string cell', () => { @@ -20,9 +20,9 @@ describe('GraphBuilder', () => { ['foo'], ]) - const vertex = engine.addressMapping.fetchCell(adr('A1')) + const vertex = engine.addressMapping.getCell(adr('A1')) expect(vertex).toBeInstanceOf(ValueCellVertex) - expect(vertex.getCellValue()).toBe('foo') + expect(vertex!.getCellValue()).toBe('foo') }) it('building for cell with null should give empty vertex', () => { @@ -30,7 +30,7 @@ describe('GraphBuilder', () => { [null, '=A1'], ]) - const vertex = engine.addressMapping.fetchCell(adr('A1')) + const vertex = engine.addressMapping.getCell(adr('A1')) expect(vertex).toBeInstanceOf(EmptyCellVertex) }) @@ -40,12 +40,12 @@ describe('GraphBuilder', () => { ['=A1:B1'], ]) - const a1 = engine.addressMapping.fetchCell(adr('A1')) - const b1 = engine.addressMapping.fetchCell(adr('B1')) - const a1b2 = engine.rangeMapping.fetchRange(adr('A1'), adr('B1')) - const a2 = engine.addressMapping.fetchCell(adr('A2')) - expect(engine.graph.adjacentNodes(a1)).toContain(a1b2) - expect(engine.graph.adjacentNodes(b1)).toContain(a1b2) + const a1 = engine.addressMapping.getCell(adr('A1')) + const b1 = engine.addressMapping.getCell(adr('B1')) + const a1b2 = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('B1')) + const a2 = engine.addressMapping.getCell(adr('A2')) + expect(engine.graph.adjacentNodes(a1!)).toContain(a1b2) + expect(engine.graph.adjacentNodes(b1!)).toContain(a1b2) expect(engine.graph.adjacentNodes(a1b2)).toContain(a2) }) @@ -54,12 +54,12 @@ describe('GraphBuilder', () => { ['1', '2', '=A:B'], ]) - const a1 = engine.addressMapping.fetchCell(adr('A1')) - const b1 = engine.addressMapping.fetchCell(adr('B1')) - const ab = engine.rangeMapping.fetchRange(colStart('A'), colEnd('B')) - const c1 = engine.addressMapping.fetchCell(adr('C1')) - expect(engine.graph.adjacentNodes(a1)).toContain(ab) - expect(engine.graph.adjacentNodes(b1)).toContain(ab) + const a1 = engine.addressMapping.getCell(adr('A1')) + const b1 = engine.addressMapping.getCell(adr('B1')) + const ab = engine.rangeMapping.getVertexOrThrow(colStart('A'), colEnd('B')) + const c1 = engine.addressMapping.getCell(adr('C1')) + expect(engine.graph.adjacentNodes(a1!)).toContain(ab) + expect(engine.graph.adjacentNodes(b1!)).toContain(ab) expect(engine.graph.adjacentNodes(ab)).toContain(c1) }) @@ -70,16 +70,16 @@ describe('GraphBuilder', () => { ['=A1:B1'], ]) - const a1 = engine.addressMapping.fetchCell(adr('A1')) - const b1 = engine.addressMapping.fetchCell(adr('B1')) - const a1b2 = engine.rangeMapping.fetchRange(adr('A1'), adr('B1')) - const a2 = engine.addressMapping.fetchCell(adr('A2')) - const a3 = engine.addressMapping.fetchCell(adr('A3')) + const a1 = engine.addressMapping.getCell(adr('A1')) + const b1 = engine.addressMapping.getCell(adr('B1')) + const a1b2 = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('B1')) + const a2 = engine.addressMapping.getCell(adr('A2')) + const a3 = engine.addressMapping.getCell(adr('A3')) - expect(engine.graph.existsEdge(a1, a1b2)).toBe(true) - expect(engine.graph.existsEdge(b1, a1b2)).toBe(true) - expect(engine.graph.existsEdge(a1b2, a2)).toBe(true) - expect(engine.graph.existsEdge(a1b2, a3)).toBe(true) + expect(engine.graph.existsEdge(a1!, a1b2)).toBe(true) + expect(engine.graph.existsEdge(b1!, a1b2)).toBe(true) + expect(engine.graph.existsEdge(a1b2, a2!)).toBe(true) + expect(engine.graph.existsEdge(a1b2, a3!)).toBe(true) expect(engine.graph.getNodes().length).toBe( 4 + // for cells above 1, // for both ranges (reuse same ranges) @@ -93,11 +93,11 @@ describe('GraphBuilder', () => { ['5', '=A1:A3'], ]) - const a3 = engine.addressMapping.fetchCell(adr('A3')) - const a1a2 = engine.rangeMapping.fetchRange(adr('A1'), adr('A2')) - const a1a3 = engine.rangeMapping.fetchRange(adr('A1'), adr('A3')) + const a3 = engine.addressMapping.getCell(adr('A3')) + const a1a2 = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('A2')) + const a1a3 = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('A3')) - expect(engine.graph.existsEdge(a3, a1a3)).toBe(true) + expect(engine.graph.existsEdge(a3!, a1a3)).toBe(true) expect(engine.graph.existsEdge(a1a2, a1a3)).toBe(true) expect(graphEdgesCount(engine.graph)).toBe( 2 + // from cells to range(A1:A2) @@ -130,11 +130,11 @@ describe('GraphBuilder', () => { ['5', '=A1:A2'], ]) - const a1a2 = engine.rangeMapping.fetchRange(adr('A1'), adr('A2')) - const a1a3 = engine.rangeMapping.fetchRange(adr('A1'), adr('A3')) - const a2 = engine.addressMapping.fetchCell(adr('A2')) - expect(engine.graph.existsEdge(a2, a1a3)).toBe(true) - expect(engine.graph.existsEdge(a2, a1a2)).toBe(true) + const a1a2 = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('A2')) + const a1a3 = engine.rangeMapping.getVertexOrThrow(adr('A1'), adr('A3')) + const a2 = engine.addressMapping.getCell(adr('A2')) + expect(engine.graph.existsEdge(a2!, a1a3)).toBe(true) + expect(engine.graph.existsEdge(a2!, a1a2)).toBe(true) expect(engine.graph.existsEdge(a1a2, a1a3)).toBe(false) expect(graphEdgesCount(engine.graph)).toBe( 3 + // from 3 cells to range(A1:A2) diff --git a/test/unit/graph-dependencies-queries.spec.ts b/test/unit/graph-dependencies-queries.spec.ts index 6d4dd40f3..ea35ed705 100644 --- a/test/unit/graph-dependencies-queries.spec.ts +++ b/test/unit/graph-dependencies-queries.spec.ts @@ -79,6 +79,13 @@ describe('address queries', () => { }).toThrow(new ExpectedValueOfTypeError('SimpleCellAddress | SimpleCellRange', malformedAddress.toString())) }) + it('should return empty array when sheet does not exist', () => { + const engine = HyperFormula.buildFromArray([[1]]) + + const nonExistentSheetId = 999 + + expect(engine.getCellDependents({ sheet: nonExistentSheetId, col: 0, row: 0 })).toEqual([]) + }) }) describe('getCellPrecedents', () => { @@ -131,5 +138,32 @@ describe('address queries', () => { engine.getCellPrecedents(malformedAddress) }).toThrow(new ExpectedValueOfTypeError('SimpleCellAddress | SimpleCellRange', malformedAddress.toString())) }) + + it('should correctly process cell dependencies with multiple range types', () => { + const engine = HyperFormula.buildFromArray([ + [1, 2, 3, 4], + [5, 6, 7, 8], + ['=SUM(A1:D1)', '=SUM(A2:D2)', '=SUM(A1:D2)', '=A1+B1'], + ]) + + expect(engine.getCellValue(adr('A3'))).toBe(10) + expect(engine.getCellValue(adr('B3'))).toBe(26) + expect(engine.getCellValue(adr('C3'))).toBe(36) + expect(engine.getCellValue(adr('D3'))).toBe(3) + }) + + it('should correctly process named expression dependencies', () => { + const engine = HyperFormula.buildFromArray([ + [42], + ['=MyValue * 2'], + ], {}, [ + {name: 'MyValue', expression: '=Sheet1!$A$1'}, + ]) + + expect(engine.getCellValue(adr('A2'))).toBe(84) + + engine.setCellContents(adr('A1'), 100) + expect(engine.getCellValue(adr('A2'))).toBe(200) + }) }) }) diff --git a/test/unit/graph-garbage-collection.spec.ts b/test/unit/graph-garbage-collection.spec.ts index fdd759069..c7c0aa060 100644 --- a/test/unit/graph-garbage-collection.spec.ts +++ b/test/unit/graph-garbage-collection.spec.ts @@ -32,9 +32,9 @@ describe('range mapping', () => { ['1', '2'], ['3', '4'] ]) - expect(engine.dependencyGraph.rangeMapping.getMappingSize(0)).toBe(0) + expect(engine.dependencyGraph.rangeMapping.getNumberOfRangesInSheet(0)).toBe(0) engine.calculateFormula('=SUM(A1:B2)', 0) - expect(engine.dependencyGraph.rangeMapping.getMappingSize(0)).toBe(0) + expect(engine.dependencyGraph.rangeMapping.getNumberOfRangesInSheet(0)).toBe(0) }) it('cruds', () => { @@ -42,11 +42,11 @@ describe('range mapping', () => { ['1', '2'], ['3', '4'] ]) - expect(engine.dependencyGraph.rangeMapping.getMappingSize(0)).toBe(0) + expect(engine.dependencyGraph.rangeMapping.getNumberOfRangesInSheet(0)).toBe(0) engine.setCellContents(adr('A1'), '=SUM(A2:B2)') - expect(engine.dependencyGraph.rangeMapping.getMappingSize(0)).toBe(1) + expect(engine.dependencyGraph.rangeMapping.getNumberOfRangesInSheet(0)).toBe(1) engine.setCellContents(adr('A1'), 1) - expect(engine.dependencyGraph.rangeMapping.getMappingSize(0)).toBe(0) + expect(engine.dependencyGraph.rangeMapping.getNumberOfRangesInSheet(0)).toBe(0) }) }) @@ -76,7 +76,7 @@ describe('larger tests', () => { } } expect(engine.dependencyGraph.graph.getNodes().length).toBe(0) - expect(engine.dependencyGraph.rangeMapping.getMappingSize(0)).toBe(0) + expect(engine.dependencyGraph.rangeMapping.getNumberOfRangesInSheet(0)).toBe(0) }) it('large fixed #2', () => { @@ -93,7 +93,7 @@ describe('larger tests', () => { engine.setCellContents({sheet: 0, col: 2, row: 0}, null) engine.setCellContents({sheet: 0, col: 3, row: 0}, null) expect(engine.dependencyGraph.graph.getNodes().length).toBe(0) - expect(engine.dependencyGraph.rangeMapping.getMappingSize(0)).toBe(0) + expect(engine.dependencyGraph.rangeMapping.getNumberOfRangesInSheet(0)).toBe(0) }) it('large fixed #3', () => { @@ -107,7 +107,7 @@ describe('larger tests', () => { engine.setCellContents({sheet: 0, col: 0, row: 0}, null) expect(engine.dependencyGraph.graph.getNodes().length).toBe(0) - expect(engine.dependencyGraph.rangeMapping.getMappingSize(0)).toBe(0) + expect(engine.dependencyGraph.rangeMapping.getNumberOfRangesInSheet(0)).toBe(0) }) it('large fixed #4', () => { @@ -124,7 +124,7 @@ describe('larger tests', () => { engine.setCellContents({sheet: 0, col: 4, row: 0}, null) expect(engine.dependencyGraph.graph.getNodes().length).toBe(0) - expect(engine.dependencyGraph.rangeMapping.getMappingSize(0)).toBe(0) + expect(engine.dependencyGraph.rangeMapping.getNumberOfRangesInSheet(0)).toBe(0) }) it('repeat the same crud', () => { @@ -157,7 +157,7 @@ describe('larger tests', () => { } } expect(engine.dependencyGraph.graph.getNodes().length).toBe(0) - expect(engine.dependencyGraph.rangeMapping.getMappingSize(0)).toBe(0) + expect(engine.dependencyGraph.rangeMapping.getNumberOfRangesInSheet(0)).toBe(0) }) }) @@ -176,7 +176,7 @@ describe('cruds', () => { engine.removeRows(0, [2, 2]) expect(engine.dependencyGraph.graph.getNodes().length).toBe(0) - expect(engine.dependencyGraph.rangeMapping.getMappingSize(0)).toBe(0) + expect(engine.dependencyGraph.rangeMapping.getNumberOfRangesInSheet(0)).toBe(0) }) it('should collect empty vertices when bigger range is no longer bind to smaller range #2', () => { @@ -193,7 +193,7 @@ describe('cruds', () => { engine.removeRows(0, [2, 2]) expect(engine.dependencyGraph.graph.getNodes().length).toBe(0) - expect(engine.dependencyGraph.rangeMapping.getMappingSize(0)).toBe(0) + expect(engine.dependencyGraph.rangeMapping.getNumberOfRangesInSheet(0)).toBe(0) }) it('should collect empty vertices when bigger range is no longer bind to smaller range #3', () => { @@ -209,7 +209,7 @@ describe('cruds', () => { engine.removeRows(0, [4, 2]) expect(engine.dependencyGraph.graph.getNodes().length).toBe(0) - expect(engine.dependencyGraph.rangeMapping.getMappingSize(0)).toBe(0) + expect(engine.dependencyGraph.rangeMapping.getNumberOfRangesInSheet(0)).toBe(0) }) it('should collect empty vertices when bigger range is no longer bind to smaller range #4', () => { @@ -230,7 +230,7 @@ describe('cruds', () => { engine.removeRows(0, [0, 6]) expect(engine.dependencyGraph.graph.getNodes().length).toBe(0) - expect(engine.dependencyGraph.rangeMapping.getMappingSize(0)).toBe(0) + expect(engine.dependencyGraph.rangeMapping.getNumberOfRangesInSheet(0)).toBe(0) }) it('should collect empty vertices when bigger range is no longer bind to smaller range #5', () => { @@ -251,7 +251,7 @@ describe('cruds', () => { engine.removeRows(0, [0, 6]) expect(engine.dependencyGraph.graph.getNodes().length).toBe(0) - expect(engine.dependencyGraph.rangeMapping.getMappingSize(0)).toBe(0) + expect(engine.dependencyGraph.rangeMapping.getNumberOfRangesInSheet(0)).toBe(0) }) it('should collect empty vertices when bigger range is no longer bind to smaller range #6', () => { @@ -270,7 +270,7 @@ describe('cruds', () => { engine.removeRows(0, [0, 6]) expect(engine.dependencyGraph.graph.getNodes().length).toBe(0) - expect(engine.dependencyGraph.rangeMapping.getMappingSize(0)).toBe(0) + expect(engine.dependencyGraph.rangeMapping.getNumberOfRangesInSheet(0)).toBe(0) }) it('should collect empty vertices when bigger range is no longer bind to smaller range #7', () => { @@ -293,7 +293,7 @@ describe('cruds', () => { engine.removeRows(0, [0, 6]) expect(engine.dependencyGraph.graph.getNodes().length).toBe(0) - expect(engine.dependencyGraph.rangeMapping.getMappingSize(0)).toBe(0) + expect(engine.dependencyGraph.rangeMapping.getNumberOfRangesInSheet(0)).toBe(0) }) it('column adding', () => { @@ -330,7 +330,7 @@ describe('cruds', () => { engine.setCellContents(adr('A6'), null) expect(engine.dependencyGraph.graph.getNodes().length).toBe(0) - expect(engine.dependencyGraph.rangeMapping.getMappingSize(0)).toBe(0) + expect(engine.dependencyGraph.rangeMapping.getNumberOfRangesInSheet(0)).toBe(0) }) it('addColumns after addRows', () => { diff --git a/test/unit/graphComparator.ts b/test/unit/graphComparator.ts index a373cb63b..e9d977bce 100644 --- a/test/unit/graphComparator.ts +++ b/test/unit/graphComparator.ts @@ -3,9 +3,9 @@ import {CellError, HyperFormula} from '../../src' import {AbsoluteCellRange} from '../../src/AbsoluteCellRange' import {SimpleCellAddress, simpleCellAddress} from '../../src/Cell' import { - ArrayVertex, + ArrayFormulaVertex, EmptyCellVertex, - FormulaCellVertex, + ScalarFormulaVertex, ParsingErrorVertex, RangeVertex, ValueCellVertex, @@ -20,9 +20,9 @@ export class EngineComparator { private actual: HyperFormula) { } - public compare() { - const expectedNumberOfSheets = this.expected.sheetMapping.numberOfSheets() - const numberOfSheets = this.actual.sheetMapping.numberOfSheets() + public compare(): void { + const expectedNumberOfSheets = this.expected.sheetMapping.numberOfSheets({includePlaceholders: true}) + const numberOfSheets = this.actual.sheetMapping.numberOfSheets({includePlaceholders: true}) if (expectedNumberOfSheets !== numberOfSheets) { throw Error(`Expected number of sheets ${expectedNumberOfSheets}, actual: ${numberOfSheets}`) @@ -36,7 +36,7 @@ export class EngineComparator { } } - private compareSheet(sheet: number) { + private compareSheet(sheet: number): void { const expectedGraph = this.expected.graph const actualGraph = this.actual.graph @@ -44,10 +44,10 @@ export class EngineComparator { const actualSheetName = this.actual.getSheetName(sheet) equal(expectedSheetName, actualSheetName, `Expected sheet name '${expectedSheetName}', actual '${actualSheetName}'`) - const expectedWidth = this.expected.addressMapping.getWidth(sheet) - const expectedHeight = this.expected.addressMapping.getHeight(sheet) - const actualWidth = this.actual.addressMapping.getWidth(sheet) - const actualHeight = this.actual.addressMapping.getHeight(sheet) + const expectedWidth = this.expected.addressMapping.getSheetWidth(sheet) + const expectedHeight = this.expected.addressMapping.getSheetHeight(sheet) + const actualWidth = this.actual.addressMapping.getSheetWidth(sheet) + const actualHeight = this.actual.addressMapping.getSheetHeight(sheet) this.compareMatrixMappings() @@ -59,8 +59,8 @@ export class EngineComparator { if (expectedVertex === undefined && actualVertex === undefined) { continue } else if ( - (expectedVertex instanceof FormulaCellVertex && actualVertex instanceof FormulaCellVertex) || - (expectedVertex instanceof ArrayVertex && actualVertex instanceof ArrayVertex) + (expectedVertex instanceof ScalarFormulaVertex && actualVertex instanceof ScalarFormulaVertex) || + (expectedVertex instanceof ArrayFormulaVertex && actualVertex instanceof ArrayFormulaVertex) ) { const actualVertexAddress = actualVertex.getAddress(this.actual.dependencyGraph.lazilyTransformingAstService) const expectedVertexAddress = expectedVertex.getAddress(this.expected.dependencyGraph.lazilyTransformingAstService) @@ -87,7 +87,9 @@ export class EngineComparator { actualAdjacentAddresses.add(this.getAddressOfVertex(this.actual, adjacentNode)) } const sheetMapping = this.expected.sheetMapping - deepStrictEqual(actualAdjacentAddresses, expectedAdjacentAddresses, `Dependent vertices of ${simpleCellAddressToString(sheetMapping.fetchDisplayName, address, 0)} (Sheet '${sheetMapping.fetchDisplayName(address.sheet)}') are not same`) + deepStrictEqual(actualAdjacentAddresses, expectedAdjacentAddresses, `Dependent vertices of ${ + simpleCellAddressToString(sheetMapping.getSheetName.bind(sheetMapping), address, 0) ?? 'ERROR' + } (Sheet '${sheetMapping.getSheetName(address.sheet) ?? 'Unknown sheet'}') are not same`) } } } @@ -110,7 +112,7 @@ export class EngineComparator { expect(actual.size).toEqual(expected.size) for (const [key, value] of expected.entries()) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const actualEntry = actual.get(key)! expect(actualEntry).toBeDefined() expect(actualEntry.array.size.isRef).toBe(value.array.size.isRef) diff --git a/test/unit/interpreter.spec.ts b/test/unit/interpreter.spec.ts index 6854952d4..96b8630ef 100644 --- a/test/unit/interpreter.spec.ts +++ b/test/unit/interpreter.spec.ts @@ -177,4 +177,172 @@ describe('Interpreter', () => { expect(engine.getCellValue(adr('A5'))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.RangeManySheets)) expect(engine.getCellValue(adr('A6'))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.RangeManySheets)) }) + + it('should return #REF when referencing non-existing sheet - just cell reference', () => { + const engine = HyperFormula.buildFromArray([ + ['=NonExistingSheet!A1'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('should return #REF when referencing non-existing sheet - cell reference inside a formula', () => { + const engine = HyperFormula.buildFromArray([ + ['=ABS(NonExistingSheet!A1)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('should return #REF when referencing non-existing sheet - cell reference inside a numeric aggregation formula', () => { + const engine = HyperFormula.buildFromArray([ + ['=SUM(NonExistingSheet!A1, 0)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('should return #REF when referencing non-existing sheet - cell range', () => { + const engine = HyperFormula.buildFromArray([ + ['=MEDIAN(NonExistingSheet!C4:F16)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('should return #REF when referencing non-existing sheet - cell range - numeric aggregation function', () => { + const engine = HyperFormula.buildFromArray([ + ['=SUM(NonExistingSheet!C4:F16)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('should return #REF when referencing non-existing sheet - row range', () => { + const engine = HyperFormula.buildFromArray([ + ['=MEDIAN(NonExistingSheet!1:2)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('should return #REF when referencing non-existing sheet - row range - numeric aggregation function', () => { + const engine = HyperFormula.buildFromArray([ + ['=SUM(NonExistingSheet!1:2)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('should return #REF when referencing non-existing sheet - column range', () => { + const engine = HyperFormula.buildFromArray([ + ['=MEDIAN(NonExistingSheet!A:B)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('should return #REF when referencing non-existing sheet - column range - numeric aggregation function', () => { + const engine = HyperFormula.buildFromArray([ + ['=SUM(NonExistingSheet!A:B)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('should return #REF when range starts with non-existing sheet - cell range', () => { + const engine = HyperFormula.buildFromArray([ + ['=MEDIAN(NonExistingSheet!A1:Sheet1!B2)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('should return #REF when range starts with non-existing sheet - cell range - numeric aggregation function', () => { + const engine = HyperFormula.buildFromArray([ + ['=SUM(NonExistingSheet!A1:Sheet1!B2)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('should return #REF when range ends with non-existing sheet - cell range', () => { + const engine = HyperFormula.buildFromArray([ + ['=MEDIAN(Sheet1!A1:NonExistingSheet!B2)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('should return #REF when range ends with non-existing sheet - cell range - numeric aggregation function', () => { + const engine = HyperFormula.buildFromArray([ + ['=SUM(Sheet1!A1:NonExistingSheet!B2)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('should return #REF when range starts with non-existing sheet - row range', () => { + const engine = HyperFormula.buildFromArray([ + ['=MEDIAN(NonExistingSheet!1:Sheet1!2)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('should return #REF when range starts with non-existing sheet - row range - numeric aggregation function', () => { + const engine = HyperFormula.buildFromArray([ + ['=SUM(NonExistingSheet!1:Sheet1!2)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('should return #REF when range ends with non-existing sheet - row range', () => { + const engine = HyperFormula.buildFromArray([ + ['=MEDIAN(Sheet1!1:NonExistingSheet!2)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('should return #REF when range ends with non-existing sheet - row range - numeric aggregation function', () => { + const engine = HyperFormula.buildFromArray([ + ['=SUM(Sheet1!1:NonExistingSheet!2)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('should return #REF when range starts with non-existing sheet - column range', () => { + const engine = HyperFormula.buildFromArray([ + ['=MEDIAN(NonExistingSheet!A:Sheet1!B)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('should return #REF when range starts with non-existing sheet - column range - numeric aggregation function', () => { + const engine = HyperFormula.buildFromArray([ + ['=SUM(NonExistingSheet!A:Sheet1!B)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('should return #REF when range ends with non-existing sheet - column range', () => { + const engine = HyperFormula.buildFromArray([ + ['=MEDIAN(Sheet1!A:NonExistingSheet!B)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.REF)) + }) + + it('should return #REF when range ends with non-existing sheet - column range - numeric aggregation function', () => { + const engine = HyperFormula.buildFromArray([ + ['=SUM(Sheet1!A:NonExistingSheet!B)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.REF)) + }) }) diff --git a/test/unit/interpreter/aliases.spec.ts b/test/unit/interpreter/aliases.spec.ts index 0f1b69d30..7a5627d2a 100644 --- a/test/unit/interpreter/aliases.spec.ts +++ b/test/unit/interpreter/aliases.spec.ts @@ -1,7 +1,5 @@ import {HyperFormula} from '../../../src' -/* eslint-disable @typescript-eslint/no-non-null-assertion */ - describe('Function aliases', () => { const engine = HyperFormula.buildEmpty() it('NEGBINOMDIST should be an alias of NEGBINOM.DIST', () => { diff --git a/test/unit/matchers.spec.ts b/test/unit/matchers.spec.ts index 34fa6ac25..6e3cdd8d3 100644 --- a/test/unit/matchers.spec.ts +++ b/test/unit/matchers.spec.ts @@ -1,7 +1,7 @@ import {DetailedCellError} from '../../src' import {ArraySize} from '../../src/ArraySize' import {CellError, ErrorType} from '../../src/Cell' -import {FormulaVertex} from '../../src/DependencyGraph/FormulaCellVertex' +import {FormulaVertex} from '../../src/DependencyGraph/FormulaVertex' import {buildNumberAst} from '../../src/parser/Ast' import {adr} from './testUtils' diff --git a/test/unit/matrix-mapping.spec.ts b/test/unit/matrix-mapping.spec.ts index f53a341d4..d03f5d670 100644 --- a/test/unit/matrix-mapping.spec.ts +++ b/test/unit/matrix-mapping.spec.ts @@ -1,6 +1,6 @@ import {AbsoluteCellRange} from '../../src/AbsoluteCellRange' import {ArraySize} from '../../src/ArraySize' -import {ArrayMapping, ArrayVertex} from '../../src/DependencyGraph' +import {ArrayMapping, ArrayFormulaVertex} from '../../src/DependencyGraph' import {buildNumberAst} from '../../src/parser/Ast' import {adr} from './testUtils' @@ -13,7 +13,7 @@ describe('MatrixMapping', () => { it('should set matrix', () => { const matrixMapping = new ArrayMapping() - const vertex = new ArrayVertex(buildNumberAst(1), adr('A1'), new ArraySize(2, 2)) + const vertex = new ArrayFormulaVertex(buildNumberAst(1), adr('A1'), new ArraySize(2, 2)) const range = AbsoluteCellRange.spanFrom(adr('A1'), 2, 2) matrixMapping.setArray(range, vertex) @@ -23,7 +23,7 @@ describe('MatrixMapping', () => { it('should answer some questions', () => { const matrixMapping = new ArrayMapping() - const vertex = new ArrayVertex(buildNumberAst(1), adr('B2'), new ArraySize(2, 2)) + const vertex = new ArrayFormulaVertex(buildNumberAst(1), adr('B2'), new ArraySize(2, 2)) const range = AbsoluteCellRange.spanFrom(adr('B2'), 2, 2) matrixMapping.setArray(range, vertex) @@ -42,9 +42,9 @@ describe('MatrixMapping', () => { it('should move matrices below row', () => { const matrixMapping = new ArrayMapping() - const vertex1 = new ArrayVertex(buildNumberAst(1), adr('B1'), new ArraySize(2, 2)) + const vertex1 = new ArrayFormulaVertex(buildNumberAst(1), adr('B1'), new ArraySize(2, 2)) const range1 = AbsoluteCellRange.spanFrom(adr('B1'), 2, 2) - const vertex2 = new ArrayVertex(buildNumberAst(1), adr('D2'), new ArraySize(2, 2)) + const vertex2 = new ArrayFormulaVertex(buildNumberAst(1), adr('D2'), new ArraySize(2, 2)) const range2 = AbsoluteCellRange.spanFrom(adr('D2'), 2, 2) matrixMapping.setArray(range1, vertex1) matrixMapping.setArray(range2, vertex2) diff --git a/test/unit/named-expressions.spec.ts b/test/unit/named-expressions.spec.ts index 3c4322892..774a8a372 100644 --- a/test/unit/named-expressions.spec.ts +++ b/test/unit/named-expressions.spec.ts @@ -1168,15 +1168,16 @@ describe('Named expressions - evaluation', () => { expect(engine.graph.adjacentNodes(fooVertex).size).toBe(0) }) - it('named expressions are transformed during CRUDs', () => { + it('named expression returns REF error after removing referenced sheet', () => { const engine = HyperFormula.buildFromArray([ ['=42'] ]) engine.addNamedExpression('FOO', '=Sheet1!$A$1 + 10') - engine.removeSheet(0) + engine.removeSheet(engine.getSheetId('Sheet1')!) - expect(engine.getNamedExpressionFormula('FOO')).toEqual('=#REF! + 10') + expect(engine.getNamedExpressionFormula('FOO')).toEqual('=Sheet1!$A$1 + 10') + expect(engine.getNamedExpressionValue('FOO')).toEqualError(detailedError(ErrorType.REF)) }) it('local named expression shadows global one', () => { @@ -1323,14 +1324,15 @@ describe('Named expressions - evaluation', () => { expect(engine.getCellValue(adr('A1'))).toEqual(52) }) - it('named expressions are transformed during CRUDs', () => { + it('named expression returns REF error after removing referenced sheet', () => { const engine = HyperFormula.buildFromArray([ ['=42'] ], {}, [{ name: 'FOO', expression: '=Sheet1!$A$1 + 10' }]) engine.removeSheet(0) - expect(engine.getNamedExpressionFormula('FOO')).toEqual('=#REF! + 10') + expect(engine.getNamedExpressionFormula('FOO')).toEqual('=Sheet1!$A$1 + 10') + expect(engine.getNamedExpressionValue('FOO')).toEqualError(detailedError(ErrorType.REF)) }) it('local named expression shadows global one', () => { @@ -1911,11 +1913,14 @@ describe('Named expressions - actions at the Operations layer', () => { const lazilyTransformingAstService = new LazilyTransformingAstService(stats) const dependencyGraph = DependencyGraph.buildEmpty(lazilyTransformingAstService, config, functionRegistry, namedExpressions, stats) const columnSearch = buildColumnSearchStrategy(dependencyGraph, config, stats) - const sheetMapping = dependencyGraph.sheetMapping const dateTimeHelper = new DateTimeHelper(config) const numberLiteralHelper = new NumberLiteralHelper(config) const cellContentParser = new CellContentParser(config, dateTimeHelper, numberLiteralHelper) - const parser = new ParserWithCaching(config, functionRegistry, sheetMapping.get) + const parser = new ParserWithCaching( + config, + functionRegistry, + dependencyGraph.sheetReferenceRegistrar.ensureSheetRegistered.bind(dependencyGraph.sheetReferenceRegistrar) + ) const arraySizePredictor = new ArraySizePredictor(config, functionRegistry) operations = new Operations(config, dependencyGraph, columnSearch, cellContentParser, parser, stats, lazilyTransformingAstService, namedExpressions, arraySizePredictor) }) diff --git a/test/unit/parser/cell-address-from-string.spec.ts b/test/unit/parser/cell-address-from-string.spec.ts index dc9602613..7a19e8f3e 100644 --- a/test/unit/parser/cell-address-from-string.spec.ts +++ b/test/unit/parser/cell-address-from-string.spec.ts @@ -1,46 +1,62 @@ -import {SheetMapping} from '../../../src/DependencyGraph' +import { AlwaysDense } from '../../../src' +import {AddressMapping, SheetMapping, SheetReferenceRegistrar} from '../../../src/DependencyGraph' import {buildTranslationPackage} from '../../../src/i18n' import {enGB} from '../../../src/i18n/languages' import {CellAddress, cellAddressFromString} from '../../../src/parser' import {adr} from '../testUtils' describe('cellAddressFromString', () => { + let sheetMapping: SheetMapping + let addressMapping: AddressMapping + let resolveSheetReference: (sheetName: string) => number + beforeEach(() => { + sheetMapping = new SheetMapping(buildTranslationPackage(enGB)) + addressMapping = new AddressMapping(new AlwaysDense()) + const registrar = new SheetReferenceRegistrar(sheetMapping, addressMapping) + resolveSheetReference = registrar.ensureSheetRegistered.bind(registrar) + }) + it('is zero based', () => { - expect(cellAddressFromString(new SheetMapping(buildTranslationPackage(enGB)).get, 'A1', adr('A1'))).toEqual(CellAddress.relative(0, 0)) + expect(cellAddressFromString('A1', adr('A1'), resolveSheetReference)).toEqual(CellAddress.relative(0, 0)) }) it('works for bigger rows', () => { - expect(cellAddressFromString(new SheetMapping(buildTranslationPackage(enGB)).get, 'A123', adr('A1'))).toEqual(CellAddress.relative(0, 122)) + expect(cellAddressFromString('A123', adr('A1'), resolveSheetReference)).toEqual(CellAddress.relative(0, 122)) }) it('one letter', () => { - expect(cellAddressFromString(new SheetMapping(buildTranslationPackage(enGB)).get, 'Z1', adr('A1'))).toEqual(CellAddress.relative(25, 0)) + expect(cellAddressFromString('Z1', adr('A1'), resolveSheetReference)).toEqual(CellAddress.relative(25, 0)) }) it('last letter is Z', () => { - expect(cellAddressFromString(new SheetMapping(buildTranslationPackage(enGB)).get, 'AA1', adr('A1'))).toEqual(CellAddress.relative(26, 0)) + expect(cellAddressFromString('AA1', adr('A1'), resolveSheetReference)).toEqual(CellAddress.relative(26, 0)) }) it('works for many letters', () => { - expect(cellAddressFromString(new SheetMapping(buildTranslationPackage(enGB)).get, 'ABC1', adr('A1'))).toEqual(CellAddress.relative(730, 0)) + expect(cellAddressFromString('ABC1', adr('A1'), resolveSheetReference)).toEqual(CellAddress.relative(730, 0)) }) it('is not case sensitive', () => { - expect(cellAddressFromString(new SheetMapping(buildTranslationPackage(enGB)).get, 'abc1', adr('A1'))).toEqual(CellAddress.relative(730, 0)) + expect(cellAddressFromString('abc1', adr('A1'), resolveSheetReference)).toEqual(CellAddress.relative(730, 0)) }) it('when sheet is missing, its took from base address', () => { - expect(cellAddressFromString(new SheetMapping(buildTranslationPackage(enGB)).get, 'B3', adr('A1', 42))).toEqual(CellAddress.relative(1, 2)) + expect(cellAddressFromString('B3', adr('A1', 42), resolveSheetReference)).toEqual(CellAddress.relative(1, 2)) }) it('can into sheets', () => { - const sheetMapping = new SheetMapping(buildTranslationPackage(enGB)) const sheet1 = sheetMapping.addSheet('Sheet1') const sheet2 = sheetMapping.addSheet('Sheet2') const sheet3 = sheetMapping.addSheet('~`!@#$%^&*()_-+_=/|?{}[]\"') - expect(cellAddressFromString(sheetMapping.get, 'Sheet1!B3', adr('A1', sheet1))).toEqual(CellAddress.relative(1, 2, sheet1)) - expect(cellAddressFromString(sheetMapping.get, 'Sheet2!B3', adr('A1', sheet1))).toEqual(CellAddress.relative(1, 2, sheet2)) - expect(cellAddressFromString(sheetMapping.get, "'~`!@#$%^&*()_-+_=/|?{}[]\"'!B3", adr('A1', sheet1))).toEqual(CellAddress.relative(1, 2, sheet3)) + expect(cellAddressFromString('Sheet1!B3', adr('A1', sheet1), resolveSheetReference)).toEqual(CellAddress.relative(1, 2, sheet1)) + expect(cellAddressFromString('Sheet2!B3', adr('A1', sheet1), resolveSheetReference)).toEqual(CellAddress.relative(1, 2, sheet2)) + expect(cellAddressFromString("'~`!@#$%^&*()_-+_=/|?{}[]\"'!B3", adr('A1', sheet1), resolveSheetReference)).toEqual(CellAddress.relative(1, 2, sheet3)) + }) + + it('returns undefined when sheet resolver fails', () => { + const failingResolver = () => undefined + + expect(cellAddressFromString('Ghost!A1', adr('A1'), failingResolver)).toBeUndefined() }) }) diff --git a/test/unit/parser/common.ts b/test/unit/parser/common.ts index 8ec2fd11d..86e66780c 100644 --- a/test/unit/parser/common.ts +++ b/test/unit/parser/common.ts @@ -1,11 +1,18 @@ +import { AlwaysDense } from '../../../src' import {Config} from '../../../src/Config' -import {SheetMapping} from '../../../src/DependencyGraph' +import {AddressMapping, SheetMapping, SheetReferenceRegistrar} from '../../../src/DependencyGraph' import {buildTranslationPackage} from '../../../src/i18n' import {enGB} from '../../../src/i18n/languages' import {FunctionRegistry} from '../../../src/interpreter/FunctionRegistry' import {ParserWithCaching} from '../../../src/parser' -export function buildEmptyParserWithCaching(config: Config, sheetMapping?: SheetMapping): ParserWithCaching { +export function buildEmptyParserWithCaching(config: Config, sheetMapping?: SheetMapping, addressMapping?: AddressMapping): ParserWithCaching { sheetMapping = sheetMapping || new SheetMapping(buildTranslationPackage(enGB)) - return new ParserWithCaching(config, new FunctionRegistry(config), sheetMapping.get) + addressMapping = addressMapping || new AddressMapping(new AlwaysDense()) + const registrar = new SheetReferenceRegistrar(sheetMapping, addressMapping) + return new ParserWithCaching( + config, + new FunctionRegistry(config), + registrar.ensureSheetRegistered.bind(registrar) + ) } diff --git a/test/unit/parser/compute-hash-from-tokens.spec.ts b/test/unit/parser/compute-hash-from-tokens.spec.ts index efe6d4472..1407f0db2 100644 --- a/test/unit/parser/compute-hash-from-tokens.spec.ts +++ b/test/unit/parser/compute-hash-from-tokens.spec.ts @@ -80,7 +80,7 @@ describe('computeHashFromTokens', () => { it('cell ref to not exsiting sheet', () => { const code = '=Sheet3!A1' - expect(computeFunc(code, adr('B2'))).toEqual('=Sheet3!A1') + expect(computeFunc(code, adr('B2'))).toBe('=#2#-1R-1') }) it('cell range', () => { diff --git a/test/unit/parser/parser.spec.ts b/test/unit/parser/parser.spec.ts index 6931b5a19..c49494d0e 100644 --- a/test/unit/parser/parser.spec.ts +++ b/test/unit/parser/parser.spec.ts @@ -24,6 +24,7 @@ import { import {columnIndexToLabel} from '../../../src/parser/addressRepresentationConverters' import { ArrayAst, + buildCellRangeAst, buildCellReferenceAst, buildColumnRangeAst, buildErrorWithRawInputAst, @@ -197,14 +198,13 @@ describe('ParserWithCaching', () => { expect(ast).toEqual(buildCellErrorAst(new CellError(ErrorType.REF))) }) - it('reference to address in nonexisting range returns ref error with data input ast', () => { + it('reference to address in nonexisting sheet returns cell reference ast', () => { const sheetMapping = new SheetMapping(buildTranslationPackage(enGB)) sheetMapping.addSheet('Sheet1') const parser = buildEmptyParserWithCaching(new Config(), sheetMapping) - const ast = parser.parse('=Sheet2!A1', adr('A1')).ast - expect(ast).toEqual(buildErrorWithRawInputAst('Sheet2!A1', new CellError(ErrorType.REF))) + expect(ast).toEqual(buildCellReferenceAst(CellAddress.relative(0, 0, 1))) }) }) @@ -373,16 +373,16 @@ describe('cell references and ranges', () => { const sheetName = 'Sheet3' expect(() => { - sheetMapping.fetch('Sheet3') + sheetMapping.getSheetIdOrThrowError('Sheet3') }).toThrow(new NoSheetWithNameError(sheetName)) }) - it('using unknown sheet gives REF', () => { + it('using unknown sheet gives cell reference ast', () => { const parser = buildEmptyParserWithCaching(new Config()) const ast = parser.parse('=Sheet2!A1', adr('A1')).ast - expect(ast).toEqual(buildErrorWithRawInputAst('Sheet2!A1', new CellError(ErrorType.REF))) + expect(ast).toEqual(buildCellReferenceAst(CellAddress.relative(0, 0, 0))) }) it('sheet name with other characters', () => { @@ -517,7 +517,7 @@ describe('cell references and ranges', () => { expect(ast.reference.sheet).toBe(undefined) }) - it('cell range with nonexsiting start sheet should return REF error with data input', () => { + it('cell range with nonexsiting start sheet should return cell range ast', () => { const sheetMapping = new SheetMapping(buildTranslationPackage(enGB)) sheetMapping.addSheet('Sheet1') sheetMapping.addSheet('Sheet2') @@ -525,10 +525,10 @@ describe('cell references and ranges', () => { const ast = parser.parse('=Sheet3!A1:Sheet2!B2', adr('A1')).ast - expect(ast).toEqual(buildErrorWithRawInputAst('Sheet3!A1:Sheet2!B2', new CellError(ErrorType.REF))) + expect(ast).toEqual(buildCellRangeAst(CellAddress.relative(0, 0, 1), CellAddress.relative(1, 1, 2), RangeSheetReferenceType.BOTH_ABSOLUTE)) }) - it('cell range with nonexsiting end sheet should return REF error with data input', () => { + it('cell range with nonexsiting end sheet should return cell range ast', () => { const sheetMapping = new SheetMapping(buildTranslationPackage(enGB)) sheetMapping.addSheet('Sheet1') sheetMapping.addSheet('Sheet2') @@ -536,7 +536,7 @@ describe('cell references and ranges', () => { const ast = parser.parse('=Sheet2!A1:Sheet3!B2', adr('A1')).ast - expect(ast).toEqual(buildErrorWithRawInputAst('Sheet2!A1:Sheet3!B2', new CellError(ErrorType.REF))) + expect(ast).toEqual(buildCellRangeAst(CellAddress.relative(0, 0, 1), CellAddress.relative(1, 1, 2), RangeSheetReferenceType.BOTH_ABSOLUTE)) }) it('cell reference beyond maximum row limit is #NAME', () => { diff --git a/test/unit/parser/unparse.spec.ts b/test/unit/parser/unparse.spec.ts index 66a29193e..60ec67736 100644 --- a/test/unit/parser/unparse.spec.ts +++ b/test/unit/parser/unparse.spec.ts @@ -4,13 +4,12 @@ import {SheetMapping} from '../../../src/DependencyGraph' import {buildTranslationPackage} from '../../../src/i18n' import {enGB, plPL} from '../../../src/i18n/languages' import {NamedExpressions} from '../../../src/NamedExpressions' -import {AstNodeType, buildLexerConfig, Unparser} from '../../../src/parser' +import {AstNodeType, Unparser} from '../../../src/parser' import {adr, unregisterAllLanguages} from '../testUtils' import {buildEmptyParserWithCaching} from './common' describe('Unparse', () => { - const config = new Config() - const lexerConfig = buildLexerConfig(config) + const config = new Config({ maxRows: 10 }) const sheetMapping = new SheetMapping(buildTranslationPackage(enGB)) sheetMapping.addSheet('Sheet1') sheetMapping.addSheet('Sheet2') @@ -18,7 +17,7 @@ describe('Unparse', () => { sheetMapping.addSheet("Sheet'With'Quotes") const parser = buildEmptyParserWithCaching(config, sheetMapping) const namedExpressions = new NamedExpressions() - const unparser = new Unparser(config, lexerConfig, sheetMapping.fetchDisplayName, namedExpressions) + const unparser = new Unparser(config, sheetMapping, namedExpressions) beforeEach(() => { unregisterAllLanguages() @@ -130,18 +129,20 @@ describe('Unparse', () => { }) it('#unparse error with data input', () => { - const formula = '=NotExistingSheet!A1' - const ast = parser.parse(formula, adr('A1')).ast - const unparsed = unparser.unparse(ast, adr('A1')) + const cellReferenceExceedingMaxRowsLimit = '=A100' + const ast = parser.parse(cellReferenceExceedingMaxRowsLimit, adr('A1')).ast expect(ast.type).toEqual(AstNodeType.ERROR_WITH_RAW_INPUT) - expect(unparsed).toEqual('=NotExistingSheet!A1') + + const unparsed = unparser.unparse(ast, adr('A1')) + + expect(unparsed).toEqual('=A100') }) it('#unparse with known error with translation', () => { const config = new Config({language: 'plPL'}) const parser = buildEmptyParserWithCaching(config, sheetMapping) - const unparser = new Unparser(config, buildLexerConfig(config), sheetMapping.fetchDisplayName, new NamedExpressions()) + const unparser = new Unparser(config, sheetMapping, new NamedExpressions()) const formula = '=#ADR!' const ast = parser.parse(formula, adr('A1')).ast const unparsed = unparser.unparse(ast, adr('A1')) @@ -179,7 +180,7 @@ describe('Unparse', () => { it('#unparse named expression returns original form', () => { const namedExpressions = new NamedExpressions() namedExpressions.addNamedExpression('SomeWEIRD_name', undefined) - const unparser = new Unparser(config, lexerConfig, sheetMapping.fetchDisplayName, namedExpressions) + const unparser = new Unparser(config, sheetMapping, namedExpressions) const formula = '=someWeird_Name' const ast = parser.parse(formula, adr('A1')).ast @@ -192,7 +193,7 @@ describe('Unparse', () => { const namedExpressions = new NamedExpressions() namedExpressions.addNamedExpression('SomeWEIRD_name', undefined) namedExpressions.addNamedExpression('SomeWEIRD_NAME', 0) - const unparser = new Unparser(config, lexerConfig, sheetMapping.fetchDisplayName, namedExpressions) + const unparser = new Unparser(config, sheetMapping, namedExpressions) const formula = '=someWeird_Name' const ast = parser.parse(formula, adr('A1')).ast @@ -203,7 +204,7 @@ describe('Unparse', () => { it('#unparse nonexisting named expression returns original input', () => { const namedExpressions = new NamedExpressions() - const unparser = new Unparser(config, lexerConfig, sheetMapping.fetchDisplayName, namedExpressions) + const unparser = new Unparser(config, sheetMapping, namedExpressions) const formula = '=someWeird_Name' const ast = parser.parse(formula, adr('A1')).ast @@ -216,7 +217,7 @@ describe('Unparse', () => { const namedExpressions = new NamedExpressions() namedExpressions.addNamedExpression('SomeWEIRD_name', undefined) namedExpressions.remove('SomeWEIRD_name', undefined) - const unparser = new Unparser(config, lexerConfig, sheetMapping.fetchDisplayName, namedExpressions) + const unparser = new Unparser(config, sheetMapping, namedExpressions) const formula = '=someWeird_Name' const ast = parser.parse(formula, adr('A1')).ast @@ -347,8 +348,8 @@ describe('Unparse', () => { const parser = buildEmptyParserWithCaching(configPL, sheetMapping) - const unparserPL = new Unparser(configPL, buildLexerConfig(configPL), sheetMapping.fetchDisplayName, new NamedExpressions()) - const unparserEN = new Unparser(configEN, buildLexerConfig(configEN), sheetMapping.fetchDisplayName, new NamedExpressions()) + const unparserPL = new Unparser(configPL, sheetMapping, new NamedExpressions()) + const unparserEN = new Unparser(configEN, sheetMapping, new NamedExpressions()) const formula = '=SUMA(1, 2)' @@ -411,11 +412,10 @@ describe('Unparse', () => { it('unparsing numbers with decimal separator', () => { const config = new Config({decimalSeparator: ',', functionArgSeparator: ';'}) - const lexerConfig = buildLexerConfig(config) const sheetMapping = new SheetMapping(buildTranslationPackage(enGB)) sheetMapping.addSheet('Sheet1') const parser = buildEmptyParserWithCaching(config, sheetMapping) - const unparser = new Unparser(config, lexerConfig, sheetMapping.fetchDisplayName, new NamedExpressions()) + const unparser = new Unparser(config, sheetMapping, new NamedExpressions()) const formula = '=1+1234,567' const ast = parser.parse(formula, adr('A1')).ast @@ -491,13 +491,12 @@ describe('Unparse', () => { describe('whitespaces', () => { const config = new Config() - const lexerConfig = buildLexerConfig(config) const sheetMapping = new SheetMapping(buildTranslationPackage(enGB)) sheetMapping.addSheet('Sheet1') sheetMapping.addSheet('Sheet2') sheetMapping.addSheet('Sheet with spaces') const parser = buildEmptyParserWithCaching(config, sheetMapping) - const unparser = new Unparser(config, lexerConfig, sheetMapping.fetchDisplayName, new NamedExpressions()) + const unparser = new Unparser(config, sheetMapping, new NamedExpressions()) it('should unparse with original whitespaces', () => { const formula = '= 1' @@ -600,9 +599,8 @@ describe('whitespaces', () => { it('when ignoreWhiteSpace = \'any\', should unparse a non-breakable space character', () => { const config = new Config({ ignoreWhiteSpace: 'any' }) - const lexerConfig = buildLexerConfig(config) const parser = buildEmptyParserWithCaching(config, sheetMapping) - const unparser = new Unparser(config, lexerConfig, sheetMapping.fetchDisplayName, new NamedExpressions()) + const unparser = new Unparser(config, sheetMapping, new NamedExpressions()) const formula = '=\u00A01' const ast = parser.parse(formula, adr('A1')).ast diff --git a/test/unit/parser/white-spaces.spec.ts b/test/unit/parser/white-spaces.spec.ts index 526ad9023..39d8d3e9d 100644 --- a/test/unit/parser/white-spaces.spec.ts +++ b/test/unit/parser/white-spaces.spec.ts @@ -119,7 +119,7 @@ describe('processWhitespaces', () => { const tokens = parser.tokenizeFormula('= SUM(A1:A2)').tokens const processed = parser.bindWhitespacesToTokens(tokens) expect(processed.length).toBe(6) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(processed[1].leadingWhitespace!.image).toBe(' ') expectArrayWithSameContent( [EqualsOp, ProcedureName, CellReference, RangeSeparator, CellReference, RParen], @@ -131,7 +131,7 @@ describe('processWhitespaces', () => { const tokens = parser.tokenizeFormula('= SUM(A1:A2)').tokens const processed = parser.bindWhitespacesToTokens(tokens) expect(processed.length).toBe(6) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(processed[1].leadingWhitespace!.image).toBe(' ') expectArrayWithSameContent( [EqualsOp, ProcedureName, CellReference, RangeSeparator, CellReference, RParen], @@ -143,7 +143,7 @@ describe('processWhitespaces', () => { const tokens = parser.tokenizeFormula(' =SUM(A1:A2)').tokens const processed = parser.bindWhitespacesToTokens(tokens) expect(processed.length).toBe(6) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(processed[0].leadingWhitespace!.image).toBe(' ') expectArrayWithSameContent( [EqualsOp, ProcedureName, CellReference, RangeSeparator, CellReference, RParen], diff --git a/test/unit/range-mapping.spec.ts b/test/unit/range-mapping.spec.ts index a048f3234..b3ac938f7 100644 --- a/test/unit/range-mapping.spec.ts +++ b/test/unit/range-mapping.spec.ts @@ -8,7 +8,7 @@ describe('RangeMapping', () => { const start = adr('A1') const end = adr('U50') - expect(mapping.getRange(start, end)).toBe(undefined) + expect(mapping.getRangeVertex(start, end)).toBe(undefined) }) it('setting range mapping', () => { @@ -17,9 +17,9 @@ describe('RangeMapping', () => { const end = adr('U50') const vertex = new RangeVertex(new AbsoluteCellRange(start, end)) - mapping.setRange(vertex) + mapping.addOrUpdateVertex(vertex) - expect(mapping.getRange(start, end)).toBe(vertex) + expect(mapping.getRangeVertex(start, end)).toBe(vertex) }) it('set column range', () => { @@ -28,8 +28,8 @@ describe('RangeMapping', () => { const end = colEnd('U') const vertex = new RangeVertex(new AbsoluteColumnRange(start.sheet, start.col, end.col)) - mapping.setRange(vertex) + mapping.addOrUpdateVertex(vertex) - expect(mapping.getRange(start, end)).toBe(vertex) + expect(mapping.getRangeVertex(start, end)).toBe(vertex) }) }) diff --git a/test/unit/range-vertex.spec.ts b/test/unit/range-vertex.spec.ts index 471883861..c099254ea 100644 --- a/test/unit/range-vertex.spec.ts +++ b/test/unit/range-vertex.spec.ts @@ -24,11 +24,11 @@ describe('RangeVertex with cache', () => { const rangeVertex = new RangeVertex(new AbsoluteCellRange(adr('B2'), adr('B11'))) const criterionString1 = '>=0' - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const criterion1 = buildCriterionLambda(criterionBuilder.parseCriterion(criterionString1, arithmeticHelper)!, arithmeticHelper) const criterionString2 = '=1' - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const criterion2 = buildCriterionLambda(criterionBuilder.parseCriterion(criterionString2, arithmeticHelper)!, arithmeticHelper) const criterionCache: CriterionCache = new Map() diff --git a/test/unit/row-range.spec.ts b/test/unit/row-range.spec.ts index eedc2da51..1c1e36666 100644 --- a/test/unit/row-range.spec.ts +++ b/test/unit/row-range.spec.ts @@ -18,7 +18,7 @@ describe('Row ranges', () => { ['=SUM(C3:D4)'], ]) - const rowRange = engine.rangeMapping.getRange(rowStart(3), rowEnd(4))! + const rowRange = engine.rangeMapping.getRangeVertex(rowStart(3), rowEnd(4))! const c3 = engine.dependencyGraph.fetchCell(adr('C3')) const c4 = engine.dependencyGraph.fetchCell(adr('C4')) @@ -39,8 +39,8 @@ describe('Row ranges', () => { engine.setCellContents(adr('B1'), '=SUM(Z4:Z8)') - const rowRange35 = engine.rangeMapping.getRange(rowStart(3), rowEnd(5))! - const rowRange47 = engine.rangeMapping.getRange(rowStart(4), rowEnd(7))! + const rowRange35 = engine.rangeMapping.getRangeVertex(rowStart(3), rowEnd(5))! + const rowRange47 = engine.rangeMapping.getRangeVertex(rowStart(4), rowEnd(7))! const z4 = engine.dependencyGraph.fetchCell(adr('Z4')) const z5 = engine.dependencyGraph.fetchCell(adr('Z5')) @@ -58,4 +58,17 @@ describe('Row ranges', () => { expect(engine.graph.existsEdge(z7, rowRange47)).toBe(true) expect(engine.graph.existsEdge(z8, rowRange47)).toBe(false) }) + + it('should correctly handle infinite row ranges when setting cell values (line 890)', () => { + const engine = HyperFormula.buildFromArray([ + ['=SUM(3:4)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toBe(0) + + engine.setCellContents(adr('E3'), 100) + engine.setCellContents(adr('F4'), 200) + + expect(engine.getCellValue(adr('A1'))).toBe(300) + }) }) diff --git a/test/unit/serialization.spec.ts b/test/unit/serialization.spec.ts index 2ffd4df3b..38abef07d 100644 --- a/test/unit/serialization.spec.ts +++ b/test/unit/serialization.spec.ts @@ -102,4 +102,23 @@ describe('serialization', () => { expect(engine1.getCellValueFormat(adr('K1'))).toEqual('Date()') expect(engine1.getCellValueDetailedType(adr('K1'))).toEqual(CellValueDetailedType.NUMBER_DATE) }) + + it('should return raw value for array formula spill cells', () => { + const engine = HyperFormula.buildFromArray([ + [1, 2, 3], + ['=TRANSPOSE(A1:C1)'], + ], { useArrayArithmetic: true }) + + expect(engine.getCellSerialized(adr('A2'))).toEqual('=TRANSPOSE(A1:C1)') + }) + + it('should return raw value for array formula non-top-left cells', () => { + const engine = HyperFormula.buildFromArray([ + [1, 2, 3], + ['=TRANSPOSE(A1:C1)'], + ], { useArrayArithmetic: true }) + + expect(engine.getCellSerialized(adr('A3'))).toEqual(2) + expect(engine.getCellSerialized(adr('A4'))).toEqual(3) + }) }) diff --git a/test/unit/testUtils.ts b/test/unit/testUtils.ts index 48cc13a22..41579e5c2 100644 --- a/test/unit/testUtils.ts +++ b/test/unit/testUtils.ts @@ -3,7 +3,7 @@ import {AbsoluteCellRange, AbsoluteColumnRange, AbsoluteRowRange} from '../../sr import {CellError, SimpleCellAddress, simpleCellAddress} from '../../src/Cell' import {Config} from '../../src/Config' import {DateTimeHelper} from '../../src/DateTimeHelper' -import {ArrayVertex, FormulaCellVertex, Graph, RangeVertex} from '../../src/DependencyGraph' +import {ArrayFormulaVertex, ScalarFormulaVertex, Graph, RangeVertex} from '../../src/DependencyGraph' import {ErrorMessage} from '../../src/error-message' import {defaultStringifyDateTime} from '../../src/format/format' import {complex} from '../../src/interpreter/ArithmeticHelper' @@ -21,41 +21,41 @@ import {ColumnRangeAst, RowRangeAst} from '../../src/parser/Ast' import {EngineComparator} from './graphComparator' export const extractReference = (engine: HyperFormula, address: SimpleCellAddress): CellAddress => { - return ((engine.addressMapping.fetchCell(address) as FormulaCellVertex).getFormula(engine.lazilyTransformingAstService) as CellReferenceAst).reference + return ((engine.addressMapping.getCell(address) as ScalarFormulaVertex).getFormula(engine.lazilyTransformingAstService) as CellReferenceAst).reference } export const extractRange = (engine: HyperFormula, address: SimpleCellAddress): AbsoluteCellRange => { - const formula = (engine.addressMapping.fetchCell(address) as FormulaCellVertex).getFormula(engine.lazilyTransformingAstService) as ProcedureAst + const formula = (engine.addressMapping.getCell(address) as ScalarFormulaVertex).getFormula(engine.lazilyTransformingAstService) as ProcedureAst const rangeAst = formula.args[0] as CellRangeAst return new AbsoluteCellRange(rangeAst.start.toSimpleCellAddress(address), rangeAst.end.toSimpleCellAddress(address)) } export const extractColumnRange = (engine: HyperFormula, address: SimpleCellAddress): AbsoluteColumnRange => { - const formula = (engine.addressMapping.fetchCell(address) as FormulaCellVertex).getFormula(engine.lazilyTransformingAstService) as ProcedureAst + const formula = (engine.addressMapping.getCell(address) as ScalarFormulaVertex).getFormula(engine.lazilyTransformingAstService) as ProcedureAst const rangeAst = formula.args[0] as ColumnRangeAst return AbsoluteColumnRange.fromColumnRange(rangeAst, address) } export const extractRowRange = (engine: HyperFormula, address: SimpleCellAddress): AbsoluteRowRange => { - const formula = (engine.addressMapping.fetchCell(address) as FormulaCellVertex).getFormula(engine.lazilyTransformingAstService) as ProcedureAst + const formula = (engine.addressMapping.getCell(address) as ScalarFormulaVertex).getFormula(engine.lazilyTransformingAstService) as ProcedureAst const rangeAst = formula.args[0] as RowRangeAst return AbsoluteRowRange.fromRowRangeAst(rangeAst, address) } export const extractMatrixRange = (engine: HyperFormula, address: SimpleCellAddress): AbsoluteCellRange => { - const formula = (engine.addressMapping.fetchCell(address) as ArrayVertex).getFormula(engine.lazilyTransformingAstService) as ProcedureAst + const formula = (engine.addressMapping.getCell(address) as ArrayFormulaVertex).getFormula(engine.lazilyTransformingAstService) as ProcedureAst const rangeAst = formula.args[0] as CellRangeAst return AbsoluteCellRange.fromCellRange(rangeAst, address) } export const expectReferenceToHaveRefError = (engine: HyperFormula, address: SimpleCellAddress) => { - const errorAst = (engine.addressMapping.fetchCell(address) as FormulaCellVertex).getFormula(engine.lazilyTransformingAstService) as ErrorAst + const errorAst = (engine.addressMapping.getCell(address) as ScalarFormulaVertex).getFormula(engine.lazilyTransformingAstService) as ErrorAst expect(errorAst.type).toEqual(AstNodeType.ERROR) expect(errorAst.error).toEqualError(new CellError(ErrorType.REF)) } export const expectFunctionToHaveRefError = (engine: HyperFormula, address: SimpleCellAddress) => { - const formula = (engine.addressMapping.fetchCell(address) as FormulaCellVertex).getFormula(engine.lazilyTransformingAstService) as ProcedureAst + const formula = (engine.addressMapping.getCell(address) as ScalarFormulaVertex).getFormula(engine.lazilyTransformingAstService) as ProcedureAst const errorAst = formula.args.find((arg) => arg !== undefined && arg.type === AstNodeType.ERROR) as ErrorAst expect(errorAst.type).toEqual(AstNodeType.ERROR) expect(errorAst.error).toEqualError(new CellError(ErrorType.REF)) @@ -117,19 +117,19 @@ export const rowEnd = (input: number, sheet: number = 0): SimpleCellAddress => { } export const colStart = (input: string, sheet: number = 0): SimpleCellAddress => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const result = /^(\$?)([A-Za-z]+)/.exec(input)! return simpleCellAddress(sheet, colNumber(result[2]), 0) } export const colEnd = (input: string, sheet: number = 0): SimpleCellAddress => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const result = /^(\$?)([A-Za-z]+)/.exec(input)! return simpleCellAddress(sheet, colNumber(result[2]), Number.POSITIVE_INFINITY) } export const adr = (stringAddress: string, sheet: number = 0): SimpleCellAddress => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const result = /^(\$([A-Za-z0-9_]+)\.)?(\$?)([A-Za-z]+)(\$?)([0-9]+)$/.exec(stringAddress)! const row = Number(result[6]) - 1 return simpleCellAddress(sheet, colNumber(result[4]), row) diff --git a/test/unit/undo-redo.spec.ts b/test/unit/undo-redo.spec.ts index 3d37a6807..14927f153 100644 --- a/test/unit/undo-redo.spec.ts +++ b/test/unit/undo-redo.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable jest/expect-expect */ import {ErrorType, HyperFormula, NoOperationToRedoError, NoOperationToUndoError} from '../../src' import {AbsoluteCellRange} from '../../src/AbsoluteCellRange' import {ErrorMessage} from '../../src/error-message' @@ -104,7 +105,7 @@ describe('Undo - removing rows', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray(sheet)) }) - it('dummy operation should also be undoable', () => { + it('dummy operation removeRows should also be undoable', () => { const sheet = [ ['1'] ] @@ -133,7 +134,7 @@ describe('Undo - removing rows', () => { }) describe('Undo - adding rows', () => { - it('works', () => { + it('restores original state after adding single row', () => { const sheet = [ ['1'], // add after that ['3'], @@ -146,7 +147,7 @@ describe('Undo - adding rows', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray(sheet)) }) - it('dummy operation should also be undoable', () => { + it('dummy operation addRows should also be undoable', () => { const sheet = [ ['1'] ] @@ -174,7 +175,7 @@ describe('Undo - adding rows', () => { }) describe('Undo - moving rows', () => { - it('works', () => { + it('restores original row order after move', () => { const sheet = [ [0], [1], [2], [3], [4], [5], [6], [7], ] @@ -185,7 +186,7 @@ describe('Undo - moving rows', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray(sheet)) }) - it('works in both directions', () => { + it('restores original row order when moving rows backward', () => { const sheet = [ [0], [1], [2], [3], [4], [5], [6], [7], ] @@ -196,7 +197,7 @@ describe('Undo - moving rows', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray(sheet)) }) - it('should restore range', () => { + it('restores range formula after moving rows forward', () => { const engine = HyperFormula.buildFromArray([ [1, null], [2, '=SUM(A1:A2)'], @@ -204,7 +205,7 @@ describe('Undo - moving rows', () => { engine.moveRows(0, 1, 1, 3) engine.undo() - expect(engine.getCellFormula(adr('B2'))).toEqual('=SUM(A1:A2)') + expect(engine.getCellFormula(adr('B2'))).toBe('=SUM(A1:A2)') }) it('should restore range when moving other way', () => { @@ -216,12 +217,12 @@ describe('Undo - moving rows', () => { engine.moveRows(0, 2, 1, 1) engine.undo() - expect(engine.getCellFormula(adr('B2'))).toEqual('=SUM(A1:A2)') + expect(engine.getCellFormula(adr('B2'))).toBe('=SUM(A1:A2)') }) }) describe('Undo - moving columns', () => { - it('works', () => { + it('restores original column order after move', () => { const sheet = [ [0, 1, 2, 3, 4, 5, 6, 7], ] @@ -232,7 +233,7 @@ describe('Undo - moving columns', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray(sheet)) }) - it('works in both directions', () => { + it('restores original column order when moving columns backward', () => { const sheet = [ [0, 1, 2, 3, 4, 5, 6, 7], ] @@ -243,7 +244,7 @@ describe('Undo - moving columns', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray(sheet)) }) - it('should restore range', () => { + it('restores range formula after moving columns forward', () => { const engine = HyperFormula.buildFromArray([ [1, 2], [null, '=SUM(A1:B1)'], @@ -251,7 +252,7 @@ describe('Undo - moving columns', () => { engine.moveColumns(0, 1, 1, 3) engine.undo() - expect(engine.getCellFormula(adr('B2'))).toEqual('=SUM(A1:B1)') + expect(engine.getCellFormula(adr('B2'))).toBe('=SUM(A1:B1)') }) it('should restore range when moving to left', () => { @@ -263,12 +264,12 @@ describe('Undo - moving columns', () => { engine.moveColumns(0, 2, 1, 1) engine.undo() - expect(engine.getCellFormula(adr('B2'))).toEqual('=SUM(A1:B1)') + expect(engine.getCellFormula(adr('B2'))).toBe('=SUM(A1:B1)') }) }) describe('Undo - adding columns', () => { - it('works', () => { + it('restores original state after adding single column', () => { const sheet = [ ['1', /* */ '3'], ] @@ -280,7 +281,7 @@ describe('Undo - adding columns', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray(sheet)) }) - it('dummy operation should also be undoable', () => { + it('dummy operation addColumns should also be undoable', () => { const sheet = [ ['1'] ] @@ -292,7 +293,7 @@ describe('Undo - adding columns', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray(sheet)) }) - it('works for more addition segments', () => { + it('restores state after adding multiple column segments', () => { const sheet = [ ['1', '2', '3'], ] @@ -318,7 +319,7 @@ describe('Undo - removing columns', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray(sheet)) }) - it('works for simple values', () => { + it('restores column with simple values', () => { const sheet = [ ['1', '2', '3'], ] @@ -342,7 +343,7 @@ describe('Undo - removing columns', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray(sheet)) }) - it('restores dependent cell formulas', () => { + it('restores dependent cell formulas after column removal', () => { const sheet = [ ['=A2', '42', '3'], ] @@ -354,7 +355,7 @@ describe('Undo - removing columns', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray(sheet)) }) - it('formulas are built correctly when there was a pause in computation', () => { + it('builds formulas correctly with suspended evaluation during column removal', () => { const sheet = [ ['=A2', '42', '3'], ] @@ -380,7 +381,7 @@ describe('Undo - removing columns', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray(sheet)) }) - it('dummy operation should also be undoable', () => { + it('dummy operation removeColumns should also be undoable', () => { const sheet = [ ['1'] ] @@ -392,7 +393,7 @@ describe('Undo - removing columns', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray(sheet)) }) - it('works for more removal segments', () => { + it('restores state after removing multiple column segments', () => { const sheet = [ ['1', '2', '3', '4'], ] @@ -439,7 +440,7 @@ describe('Undo - removing sheet', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray(sheet)) }) - it('restores dependent cell formulas', () => { + it('restores cross-sheet formula dependencies after sheet removal', () => { const sheets = { Sheet1: [['=Sheet2!A1']], Sheet2: [['42']], @@ -452,7 +453,7 @@ describe('Undo - removing sheet', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(sheets)) }) - it('formulas are built correctly when there was a pause in computation', () => { + it('builds formulas correctly with suspended evaluation during sheet removal', () => { const sheets = { Sheet1: [['=Sheet2!A1']], Sheet2: [['42']], @@ -466,6 +467,70 @@ describe('Undo - removing sheet', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(sheets)) }) + + it('restores sheet correctly after multiple undo/redo cycles', () => { + const sheets = { + Sheet1: [['1', '2']], + Sheet2: [['3', '4']], + } + const engine = HyperFormula.buildFromSheets(sheets) + engine.removeSheet(1) + + engine.undo() + expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(sheets)) + + engine.redo() + + expect(engine.getSheetNames()).toEqual(['Sheet1']) + + engine.undo() + + expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(sheets)) + + engine.redo() + + expect(engine.getSheetNames()).toEqual(['Sheet1']) + + engine.undo() + + expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(sheets)) + + engine.redo() + + expect(engine.getSheetNames()).toEqual(['Sheet1']) + + engine.undo() + + expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(sheets)) + }) + + it('restores sheet and cross-sheet references after row removal', () => { + const sheets = { + Sheet1: [['1'], ['2'], ['=Sheet2!A1']], + Sheet2: [['42']], + } + const engine = HyperFormula.buildFromSheets(sheets) + engine.removeSheet(1) + engine.undo() + + expect(engine.getSheetNames()).toEqual(['Sheet1', 'Sheet2']) + expect(engine.getCellValue(adr('A1'))).toBe(1) + expect(engine.getCellValue(adr('A2'))).toBe(2) + expect(engine.getCellValue(adr('A3'))).toBe(42) + expect(engine.getCellValue(adr('A1', 1))).toBe(42) + }) + + it('restores scoped named expressions', () => { + const engine = HyperFormula.buildFromSheets({ + Sheet1: [['=MyName']], + Sheet2: [['1']], + }) + engine.addNamedExpression('MyName', '=42', 0) + engine.removeSheet(0) + engine.undo() + + expect(engine.getCellValue(adr('A1'))).toBe(42) + }) }) describe('Undo - renaming sheet', () => { @@ -476,8 +541,8 @@ describe('Undo - renaming sheet', () => { engine.undo() - expect(engine.getCellValue(adr('A1'))).toEqual(1) - expect(engine.getSheetName(0)).toEqual('Sheet1') + expect(engine.getCellValue(adr('A1'))).toBe(1) + expect(engine.getSheetName(0)).toBe('Sheet1') }) it('undo rename sheet', () => { @@ -486,12 +551,405 @@ describe('Undo - renaming sheet', () => { engine.undo() - expect(engine.getSheetName(0)).toEqual('Sheet1') + expect(engine.getSheetName(0)).toBe('Sheet1') + }) + + it('undo rename with case change only', () => { + const engine = HyperFormula.buildFromSheets({'Sheet1': [[1]]}) + engine.renameSheet(0, 'SHEET1') + + expect(engine.getSheetName(0)).toBe('SHEET1') + + engine.undo() + + expect(engine.getSheetName(0)).toBe('Sheet1') + }) + + it('undo rename preserves cell values', () => { + const engine = HyperFormula.buildFromSheets({'Sheet1': [[42], ['=A1*2']]}) + engine.renameSheet(0, 'NewName') + engine.undo() + + expect(engine.getSheetName(0)).toBe('Sheet1') + expect(engine.getCellValue(adr('A1'))).toBe(42) + expect(engine.getCellValue(adr('A2'))).toBe(84) + }) + + it('undo rename with suspended evaluation', () => { + const engine = HyperFormula.buildFromSheets({'Sheet1': [[1]]}) + engine.suspendEvaluation() + engine.renameSheet(0, 'Foo') + engine.undo() + engine.resumeEvaluation() + + expect(engine.getSheetName(0)).toBe('Sheet1') + }) + + it('undo rename that merged with placeholder sheet', () => { + const engine = HyperFormula.buildFromSheets({ + 'Sheet1': [['=OldName!A1', '=NewName!A1']], + 'OldName': [[42]], + }) + const sheet1Id = engine.getSheetId('Sheet1')! + const oldNameId = engine.getSheetId('OldName')! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(42) + expect(engine.getCellValue(adr('B1', sheet1Id))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + + engine.renameSheet(oldNameId, 'NewName') + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(42) + expect(engine.getCellValue(adr('B1', sheet1Id))).toBe(42) + + engine.undo() + + expect(engine.getSheetName(oldNameId)).toBe('OldName') + expect(engine.getCellFormula(adr('A1', sheet1Id))).toBe('=OldName!A1') + expect(engine.getCellFormula(adr('B1', sheet1Id))).toBe('=NewName!A1') + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(42) + expect(engine.getCellValue(adr('B1', sheet1Id))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + }) + + it('undo rename with range reference updates formula (merged with placeholder sheet)', () => { + const engine = HyperFormula.buildFromSheets({ + 'Sheet1': [['=SUM(OldName!A1:B2)', '=SUM(NewName!A1:B2)']], + 'OldName': [[10, 20], [30, 40]], + }) + const sheet1Id = engine.getSheetId('Sheet1')! + const oldNameId = engine.getSheetId('OldName')! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(100) + expect(engine.getCellValue(adr('B1', sheet1Id))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + + engine.renameSheet(oldNameId, 'NewName') + + expect(engine.getCellFormula(adr('A1', sheet1Id))).toBe('=SUM(NewName!A1:B2)') + expect(engine.getCellFormula(adr('B1', sheet1Id))).toBe('=SUM(NewName!A1:B2)') + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(100) + expect(engine.getCellValue(adr('B1', sheet1Id))).toBe(100) + + engine.undo() + + expect(engine.getCellFormula(adr('A1', sheet1Id))).toBe('=SUM(OldName!A1:B2)') + expect(engine.getCellFormula(adr('B1', sheet1Id))).toBe('=SUM(NewName!A1:B2)') + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(100) + expect(engine.getCellValue(adr('B1', sheet1Id))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + }) + + it('restores the dependency graph structure on undo', () => { + const engine = HyperFormula.buildFromSheets({ + 'Sheet1': [ + ['=OldName!A1', '=NewName!A1', '=SUM(OldName!A1:B2)', '=SUM(NewName!A1:B2)'], + ['=A1*2', '=B1+10', '=C1+A1', '=D1+B1'], + ], + 'OldName': [[1, 2], [3, 4]], + }) + const sheet1Id = engine.getSheetId('Sheet1')! + const oldNameId = engine.getSheetId('OldName')! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(1) + expect(engine.getCellValue(adr('B1', sheet1Id))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + expect(engine.getCellValue(adr('C1', sheet1Id))).toBe(10) + expect(engine.getCellValue(adr('D1', sheet1Id))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + expect(engine.getCellValue(adr('A2', sheet1Id))).toBe(2) + expect(engine.getCellValue(adr('B2', sheet1Id))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + expect(engine.getCellValue(adr('C2', sheet1Id))).toBe(11) + expect(engine.getCellValue(adr('D2', sheet1Id))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + + engine.renameSheet(oldNameId, 'NewName') + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(1) + expect(engine.getCellValue(adr('B1', sheet1Id))).toBe(1) + expect(engine.getCellValue(adr('C1', sheet1Id))).toBe(10) + expect(engine.getCellValue(adr('D1', sheet1Id))).toBe(10) + expect(engine.getCellValue(adr('A2', sheet1Id))).toBe(2) + expect(engine.getCellValue(adr('B2', sheet1Id))).toBe(11) + expect(engine.getCellValue(adr('C2', sheet1Id))).toBe(11) + expect(engine.getCellValue(adr('D2', sheet1Id))).toBe(11) + + engine.undo() + + expect(engine.getCellFormula(adr('A1', sheet1Id))).toBe('=OldName!A1') + expect(engine.getCellFormula(adr('B1', sheet1Id))).toBe('=NewName!A1') + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(1) + expect(engine.getCellValue(adr('B1', sheet1Id))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + expect(engine.getCellValue(adr('C1', sheet1Id))).toBe(10) + expect(engine.getCellValue(adr('D1', sheet1Id))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + expect(engine.getCellValue(adr('A2', sheet1Id))).toBe(2) + expect(engine.getCellValue(adr('B2', sheet1Id))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + expect(engine.getCellValue(adr('C2', sheet1Id))).toBe(11) + expect(engine.getCellValue(adr('D2', sheet1Id))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + + engine.setCellContents(adr('A1', oldNameId), 100) + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(100) + expect(engine.getCellValue(adr('C1', sheet1Id))).toBe(109) + expect(engine.getCellValue(adr('A2', sheet1Id))).toBe(200) + expect(engine.getCellValue(adr('C2', sheet1Id))).toBe(209) + expect(engine.getCellValue(adr('B1', sheet1Id))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + expect(engine.getCellValue(adr('D1', sheet1Id))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + expect(engine.getCellValue(adr('B2', sheet1Id))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + expect(engine.getCellValue(adr('D2', sheet1Id))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + }) + + it('multiple undo/redo cycles for rename', () => { + const engine = HyperFormula.buildFromSheets({'Sheet1': [[1]]}) + engine.renameSheet(0, 'Renamed') + engine.undo() + + expect(engine.getSheetName(0)).toBe('Sheet1') + + engine.redo() + + expect(engine.getSheetName(0)).toBe('Renamed') + + engine.undo() + + expect(engine.getSheetName(0)).toBe('Sheet1') + + engine.redo() + + expect(engine.getSheetName(0)).toBe('Renamed') + }) + + it('undo multiple sequential renames', () => { + const engine = HyperFormula.buildFromSheets({'Sheet1': [[1]]}) + engine.renameSheet(0, 'Name1') + engine.renameSheet(0, 'Name2') + engine.renameSheet(0, 'Name3') + + engine.undo() + + expect(engine.getSheetName(0)).toBe('Name2') + + engine.undo() + + expect(engine.getSheetName(0)).toBe('Name1') + + engine.undo() + + expect(engine.getSheetName(0)).toBe('Sheet1') + }) + + it('undo rename combined with cell content changes', () => { + const engine = HyperFormula.buildFromSheets({'Sheet1': [[1]]}) + engine.setCellContents(adr('A1'), 10) + engine.renameSheet(0, 'NewName') + engine.setCellContents(adr('A1'), 100) + engine.undo() + + expect(engine.getCellValue(adr('A1'))).toBe(10) + expect(engine.getSheetName(0)).toBe('NewName') + + engine.undo() + + expect(engine.getSheetName(0)).toBe('Sheet1') + + engine.undo() + + expect(engine.getCellValue(adr('A1'))).toBe(1) + }) + + it('undo rename in batch mode', () => { + const engine = HyperFormula.buildFromSheets({'Sheet1': [[1]], 'Sheet2': [[2]]}) + engine.batch(() => { + engine.renameSheet(0, 'NewName1') + engine.renameSheet(1, 'NewName2') + }) + + engine.undo() + + expect(engine.getSheetName(0)).toBe('Sheet1') + expect(engine.getSheetName(1)).toBe('Sheet2') + }) + + it('undo rename with chained dependencies across sheets', () => { + const engine = HyperFormula.buildFromSheets({ + 'Sheet1': [['=Sheet2!A1+2']], + 'Sheet2': [['=OldName!A1*2']], + 'OldName': [[42]], + }) + const sheet1Id = engine.getSheetId('Sheet1')! + const sheet2Id = engine.getSheetId('Sheet2')! + const oldNameId = engine.getSheetId('OldName')! + + expect(engine.getCellValue(adr('A1', sheet2Id))).toBe(84) + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(86) + + engine.renameSheet(oldNameId, 'NewName') + + expect(engine.getCellFormula(adr('A1', sheet2Id))).toBe('=NewName!A1*2') + + engine.undo() + + expect(engine.getCellFormula(adr('A1', sheet2Id))).toBe('=OldName!A1*2') + expect(engine.getCellValue(adr('A1', sheet2Id))).toBe(84) + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(86) + }) + + it('undo rename with named expressions', () => { + const engine = HyperFormula.buildFromSheets({ + 'Sheet1': [['=MyValue']], + 'OldName': [[99]], + }, {}, [ + { name: 'MyValue', expression: '=OldName!$A$1' } + ]) + const sheet1Id = engine.getSheetId('Sheet1')! + const oldNameId = engine.getSheetId('OldName')! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(99) + + engine.renameSheet(oldNameId, 'NewName') + + expect(engine.getNamedExpressionFormula('MyValue')).toBe('=NewName!$A$1') + + engine.undo() + + expect(engine.getNamedExpressionFormula('MyValue')).toBe('=OldName!$A$1') + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(99) + }) + + it('undo rename after row removal', () => { + const engine = HyperFormula.buildFromSheets({ + 'Sheet1': [[1], [2], ['=OldName!A1']], + 'OldName': [[42]], + }) + const sheet1Id = engine.getSheetId('Sheet1')! + const oldNameId = engine.getSheetId('OldName')! + + engine.removeRows(sheet1Id, [0, 1]) + engine.renameSheet(oldNameId, 'NewName') + + expect(engine.getCellValue(adr('A2', sheet1Id))).toBe(42) + expect(engine.getCellFormula(adr('A2', sheet1Id))).toBe('=NewName!A1') + + engine.undo() + + expect(engine.getCellFormula(adr('A2', sheet1Id))).toBe('=OldName!A1') + expect(engine.getCellValue(adr('A2', sheet1Id))).toBe(42) + + engine.undo() + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(1) + expect(engine.getCellValue(adr('A2', sheet1Id))).toBe(2) + expect(engine.getCellValue(adr('A3', sheet1Id))).toBe(42) + }) + + it('undo rename sheet that merged with placeholder restores placeholder', () => { + const engine = HyperFormula.buildFromSheets({ + 'Sheet1': [['=PlaceholderName!A1']], + 'OldName': [[42]], + }) + const sheet1Id = engine.getSheetId('Sheet1')! + const oldNameId = engine.getSheetId('OldName')! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + + engine.renameSheet(oldNameId, 'PlaceholderName') + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(42) + expect(engine.getSheetName(oldNameId)).toBe('PlaceholderName') + + engine.undo() + + expect(engine.getSheetName(oldNameId)).toBe('OldName') + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + }) + + it('redo rename sheet that merged with placeholder works correctly', () => { + const engine = HyperFormula.buildFromSheets({ + 'Sheet1': [['=PlaceholderName!A1']], + 'OldName': [[42]], + }) + const sheet1Id = engine.getSheetId('Sheet1')! + const oldNameId = engine.getSheetId('OldName')! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + + engine.renameSheet(oldNameId, 'PlaceholderName') + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(42) + + engine.undo() + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + + engine.redo() + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(42) + expect(engine.getSheetName(oldNameId)).toBe('PlaceholderName') + }) + + it('multiple undo/redo cycles with placeholder sheet merge', () => { + const engine = HyperFormula.buildFromSheets({ + 'Sheet1': [['=GhostSheet!A1']], + 'RealSheet': [[100]], + }) + const sheet1Id = engine.getSheetId('Sheet1')! + const realSheetId = engine.getSheetId('RealSheet')! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + + engine.renameSheet(realSheetId, 'GhostSheet') + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(100) + + engine.undo() + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + expect(engine.getSheetName(realSheetId)).toBe('RealSheet') + + engine.redo() + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(100) + expect(engine.getSheetName(realSheetId)).toBe('GhostSheet') + + engine.undo() + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + expect(engine.getSheetName(realSheetId)).toBe('RealSheet') + + engine.redo() + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(100) + expect(engine.getSheetName(realSheetId)).toBe('GhostSheet') + + engine.undo() + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + + engine.redo() + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(100) + }) + + it('undo rename with range reference to placeholder sheet', () => { + const engine = HyperFormula.buildFromSheets({ + 'Sheet1': [['=SUM(PlaceholderName!A1:B2)']], + 'OldName': [[1, 2], [3, 4]], + }) + const sheet1Id = engine.getSheetId('Sheet1')! + const oldNameId = engine.getSheetId('OldName')! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + + engine.renameSheet(oldNameId, 'PlaceholderName') + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(10) + + engine.undo() + + expect(engine.getCellValue(adr('A1', sheet1Id))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + expect(engine.getSheetName(oldNameId)).toBe('OldName') + + engine.redo() + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(10) }) }) describe('Undo - setting cell content', () => { - it('works for simple values', () => { + it('restores simple numeric values', () => { const sheet = [ ['3'], ] @@ -503,7 +961,7 @@ describe('Undo - setting cell content', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray(sheet)) }) - it('works for empty values', () => { + it('restores empty cell state', () => { const sheet = [ [null], ] @@ -515,7 +973,7 @@ describe('Undo - setting cell content', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray(sheet)) }) - it('works for formula values', () => { + it('restores formula cell content', () => { const sheet = [ ['=42'], ] @@ -527,7 +985,7 @@ describe('Undo - setting cell content', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray(sheet)) }) - it('setting multiple cell contents is one operation', () => { + it('undoes multiple cell contents as one operation', () => { const sheet = [ ['3', '4'], ] @@ -541,7 +999,7 @@ describe('Undo - setting cell content', () => { }) describe('Undo - adding sheet', () => { - it('works for basic case', () => { + it('removes named sheet on undo', () => { const engine = HyperFormula.buildFromArray([]) engine.addSheet('SomeSheet') @@ -549,10 +1007,45 @@ describe('Undo - adding sheet', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([])) }) + + it('removes auto-generated sheet on undo', () => { + const engine = HyperFormula.buildFromArray([]) + engine.addSheet() + engine.undo() + + expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([])) + }) + + it('restores cross-sheet reference error after undo', () => { + const engine = HyperFormula.buildFromArray([['=NewSheet!A1']]) + engine.addSheet('NewSheet') + engine.setCellContents({sheet: 1, col: 0, row: 0}, '42') + + expect(engine.getCellValue(adr('A1'))).toBe(42) + expect(engine.getCellValue(adr('A1', 1))).toBe(42) + + engine.undo() + engine.undo() + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + }) + + it('builds formulas correctly with suspended evaluation during sheet addition', () => { + const engine = HyperFormula.buildFromArray([['=NewSheet!A1']]) + engine.suspendEvaluation() + engine.addSheet('NewSheet') + engine.setCellContents({sheet: 1, col: 0, row: 0}, '42') + + engine.undo() + engine.undo() + engine.resumeEvaluation() + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + }) }) describe('Undo - clearing sheet', () => { - it('works for empty sheet', () => { + it('handles undo on already empty sheet', () => { const engine = HyperFormula.buildFromArray([]) engine.clearSheet(0) @@ -561,7 +1054,7 @@ describe('Undo - clearing sheet', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray([])) }) - it('works with restoring simple values', () => { + it('restores simple values after clearing', () => { const sheet = [ ['1'], ] @@ -573,7 +1066,7 @@ describe('Undo - clearing sheet', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray(sheet)) }) - it('works with restoring formulas', () => { + it('restores formulas after clearing', () => { const sheet = [ ['=42'], ] @@ -587,7 +1080,7 @@ describe('Undo - clearing sheet', () => { }) describe('Undo - setting sheet contents', () => { - it('works for basic case', () => { + it('restores original sheet content', () => { const sheet = [['13']] const engine = HyperFormula.buildFromArray(sheet) engine.setSheetContent(0, [['42']]) @@ -611,7 +1104,7 @@ describe('Undo - setting sheet contents', () => { }) describe('Undo - moving cells', () => { - it('works for simple case', () => { + it('restores cell to original position', () => { const sheet = [ ['foo'], [null], @@ -624,7 +1117,7 @@ describe('Undo - moving cells', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray(sheet)) }) - it('restores data', () => { + it('restores overwritten data at target location', () => { const sheet = [ ['foo'], ['42'], @@ -637,7 +1130,7 @@ describe('Undo - moving cells', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray(sheet)) }) - it('restores dependent cell formulas', () => { + it('restores dependent cell formulas after cell move', () => { const sheet = [ ['=A2'], ['42'], @@ -650,7 +1143,7 @@ describe('Undo - moving cells', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray(sheet)) }) - it('formulas are built correctly when there was a pause in computation', () => { + it('builds formulas correctly with suspended evaluation during cell move', () => { const sheet = [ ['=A2'], ['3'], @@ -665,7 +1158,7 @@ describe('Undo - moving cells', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray(sheet)) }) - it('removed added global named expression', () => { + it('removes global named expression promoted during move', () => { const engine = HyperFormula.buildFromSheets({ 'Sheet1': [], 'Sheet2': [] @@ -676,7 +1169,7 @@ describe('Undo - moving cells', () => { engine.undo() - expect(engine.getNamedExpressionValue('foo')).toEqual(undefined) + expect(engine.getNamedExpressionValue('foo')).toBeUndefined() }) it('remove global named expression even if it was added after formula', () => { @@ -689,13 +1182,13 @@ describe('Undo - moving cells', () => { engine.undo() - expect(engine.getNamedExpressionValue('foo', 0)).toEqual('bar') - expect(engine.getNamedExpressionValue('foo')).toEqual(undefined) + expect(engine.getNamedExpressionValue('foo', 0)).toBe('bar') + expect(engine.getNamedExpressionValue('foo')).toBeUndefined() }) }) describe('Undo - cut-paste', () => { - it('works for static content', () => { + it('restores source and target cells after cut-paste', () => { const sheet = [ ['foo'], ['bar'], @@ -709,7 +1202,7 @@ describe('Undo - cut-paste', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray(sheet)) }) - it('undoing doesnt roll back clipboard', () => { + it('does not roll back clipboard on undo', () => { const sheet = [ ['foo'], ['bar'], @@ -722,7 +1215,7 @@ describe('Undo - cut-paste', () => { expect(engine.isClipboardEmpty()).toBe(true) }) - it('removed added global named expression', () => { + it('removes global named expression promoted during cut-paste', () => { const engine = HyperFormula.buildFromSheets({ 'Sheet1': [], 'Sheet2': [] @@ -734,13 +1227,13 @@ describe('Undo - cut-paste', () => { engine.undo() - expect(engine.getNamedExpressionValue('foo', 0)).toEqual('bar') - expect(engine.getNamedExpressionValue('foo')).toEqual(undefined) + expect(engine.getNamedExpressionValue('foo', 0)).toBe('bar') + expect(engine.getNamedExpressionValue('foo')).toBeUndefined() }) }) describe('Undo - copy-paste', () => { - it('works', () => { + it('restores original content after copy-paste', () => { const sheet = [ ['foo'], ['bar'], @@ -754,7 +1247,7 @@ describe('Undo - copy-paste', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray(sheet)) }) - it('removed added global named expression', () => { + it('removes global named expression promoted during copy-paste', () => { const engine = HyperFormula.buildFromSheets({ 'Sheet1': [], 'Sheet2': [] @@ -766,13 +1259,13 @@ describe('Undo - copy-paste', () => { engine.undo() - expect(engine.getNamedExpressionValue('foo', 0)).toEqual('bar') - expect(engine.getNamedExpressionValue('foo')).toEqual(undefined) + expect(engine.getNamedExpressionValue('foo', 0)).toBe('bar') + expect(engine.getNamedExpressionValue('foo')).toBeUndefined() }) }) describe('Undo - add named expression', () => { - it('works', () => { + it('removes named expression and restores error', () => { const engine = HyperFormula.buildFromArray([ ['=foo'] ]) @@ -781,13 +1274,13 @@ describe('Undo - add named expression', () => { engine.undo() - expect(engine.listNamedExpressions().length).toEqual(0) + expect(engine.listNamedExpressions().length).toBe(0) expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.NAME, ErrorMessage.NamedExpressionName('foo'))) }) }) describe('Undo - remove named expression', () => { - it('works', () => { + it('restores removed named expression', () => { const engine = HyperFormula.buildFromArray([ ['=foo'] ]) @@ -797,13 +1290,13 @@ describe('Undo - remove named expression', () => { engine.undo() - expect(engine.listNamedExpressions().length).toEqual(1) - expect(engine.getCellValue(adr('A1'))).toEqual('foo') + expect(engine.listNamedExpressions().length).toBe(1) + expect(engine.getCellValue(adr('A1'))).toBe('foo') }) }) describe('Undo - change named expression', () => { - it('works', () => { + it('restores original expression value', () => { const engine = HyperFormula.buildFromArray([ ['=foo'] ]) @@ -813,13 +1306,13 @@ describe('Undo - change named expression', () => { engine.undo() - expect(engine.listNamedExpressions().length).toEqual(1) - expect(engine.getCellValue(adr('A1'))).toEqual('foo') + expect(engine.listNamedExpressions().length).toBe(1) + expect(engine.getCellValue(adr('A1'))).toBe('foo') }) }) describe('Undo', () => { - it('when there is no operation to undo', () => { + it('throws error when undo stack is empty', () => { const engine = HyperFormula.buildEmpty() expect(() => { @@ -835,7 +1328,7 @@ describe('Undo', () => { const changes = engine.undo() - expect(engine.getCellValue(adr('B1'))).toEqual(3) + expect(engine.getCellValue(adr('B1'))).toBe(3) expect(changes.length).toBe(2) }) @@ -852,6 +1345,7 @@ describe('Undo', () => { engine.undo() expectEngineToBeTheSameAs(engine, HyperFormula.buildFromArray(sheet)) + expect(engine.isThereSomethingToUndo()).toBe(false) }) @@ -904,7 +1398,7 @@ describe('Undo', () => { engine.removeColumns(0, [0, 1]) expect(() => engine.undo()).not.toThrowError() - expect(engine.getCellFormula(adr('F1'))).toEqual('=SUM(A1:C1)') + expect(engine.getCellFormula(adr('F1'))).toBe('=SUM(A1:C1)') }) }) @@ -927,7 +1421,7 @@ describe('UndoRedo', () => { }) describe('UndoRedo - #isThereSomethingToUndo', () => { - it('when there is no operation to undo', () => { + it('returns false when undo stack is empty', () => { const engine = HyperFormula.buildEmpty() expect(engine.isThereSomethingToUndo()).toBe(false) @@ -943,8 +1437,10 @@ describe('UndoRedo - #isThereSomethingToUndo', () => { it('when the undo stack has been cleared', () => { const engine = HyperFormula.buildFromArray([]) engine.removeRows(0, [1, 1]) + expect(engine.isThereSomethingToUndo()).toBe(true) engine.clearUndoStack() + expect(engine.isThereSomethingToUndo()).toBe(false) }) }) @@ -968,8 +1464,10 @@ describe('UndoRedo - #isThereSomethingToRedo', () => { const engine = HyperFormula.buildFromArray([]) engine.removeRows(0, [1, 1]) engine.undo() + expect(engine.isThereSomethingToRedo()).toBe(true) engine.clearRedoStack() + expect(engine.isThereSomethingToRedo()).toBe(false) }) }) @@ -985,11 +1483,14 @@ describe('UndoRedo - at the Operations layer', () => { const lazilyTransformingAstService = new LazilyTransformingAstService(stats) const dependencyGraph = DependencyGraph.buildEmpty(lazilyTransformingAstService, config, functionRegistry, namedExpressions, stats) const columnSearch = buildColumnSearchStrategy(dependencyGraph, config, stats) - const sheetMapping = dependencyGraph.sheetMapping const dateTimeHelper = new DateTimeHelper(config) const numberLiteralHelper = new NumberLiteralHelper(config) const cellContentParser = new CellContentParser(config, dateTimeHelper, numberLiteralHelper) - const parser = new ParserWithCaching(config, functionRegistry, sheetMapping.get) + const parser = new ParserWithCaching( + config, + functionRegistry, + dependencyGraph.sheetReferenceRegistrar.ensureSheetRegistered.bind(dependencyGraph.sheetReferenceRegistrar) + ) const arraySizePredictor = new ArraySizePredictor(config, functionRegistry) const operations = new Operations(config, dependencyGraph, columnSearch, cellContentParser, parser, stats, lazilyTransformingAstService, namedExpressions, arraySizePredictor) undoRedo = new UndoRedo(config, operations) @@ -1003,10 +1504,13 @@ describe('UndoRedo - at the Operations layer', () => { it('clearUndoStack should clear out all undo entries', () => { expect(undoRedo.isUndoStackEmpty()).toBe(true) - undoRedo.saveOperation(new AddSheetUndoEntry('Sheet 1')) - undoRedo.saveOperation(new AddSheetUndoEntry('Sheet 2')) + undoRedo.saveOperation(new AddSheetUndoEntry('Sheet 1', 1)) + undoRedo.saveOperation(new AddSheetUndoEntry('Sheet 2', 2)) + expect(undoRedo.isUndoStackEmpty()).toBe(false) + undoRedo.clearUndoStack() + expect(undoRedo.isUndoStackEmpty()).toBe(true) }) @@ -1024,7 +1528,7 @@ describe('UndoRedo - at the Operations layer', () => { }) describe('Redo - removing rows', () => { - it('works for empty row', () => { + it('re-removes empty row after undo', () => { const engine = HyperFormula.buildFromArray([ ['1'], [null], // remove @@ -1039,7 +1543,7 @@ describe('Redo - removing rows', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(snapshot)) }) - it('works for other values', () => { + it('re-removes row with values and formulas after undo', () => { const engine = HyperFormula.buildFromArray([ ['1'], ['2', '=A1'], // remove @@ -1054,7 +1558,7 @@ describe('Redo - removing rows', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(snapshot)) }) - it('works for more removal segments', () => { + it('re-removes multiple row segments after undo', () => { const engine = HyperFormula.buildFromArray([ ['1'], ['2'], @@ -1070,7 +1574,7 @@ describe('Redo - removing rows', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(snapshot)) }) - it('dummy operation should also be redoable', () => { + it('dummy removeRows operation is redoable', () => { const engine = HyperFormula.buildFromArray([ ['1'] ]) @@ -1083,7 +1587,7 @@ describe('Redo - removing rows', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(snapshot)) }) - it('clears redo stack', () => { + it('removeRows clears redo stack', () => { const engine = HyperFormula.buildFromArray([]) engine.setCellContents(adr('A1'), 42) engine.undo() @@ -1095,7 +1599,7 @@ describe('Redo - removing rows', () => { }) describe('Redo - adding rows', () => { - it('works', () => { + it('re-adds row after undo', () => { const engine = HyperFormula.buildFromArray([ ['1'], // add after that ['3'], @@ -1109,7 +1613,7 @@ describe('Redo - adding rows', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(snapshot)) }) - it('dummy operation should also be redoable', () => { + it('dummy addRows operation is redoable', () => { const engine = HyperFormula.buildFromArray([ ['1'], ]) @@ -1122,7 +1626,7 @@ describe('Redo - adding rows', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(snapshot)) }) - it('works for more addition segments', () => { + it('re-adds multiple row segments after undo', () => { const engine = HyperFormula.buildFromArray([ ['1'], ['2'], @@ -1137,7 +1641,7 @@ describe('Redo - adding rows', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(snapshot)) }) - it('clears redo stack', () => { + it('addRows clears redo stack', () => { const engine = HyperFormula.buildFromArray([]) engine.setCellContents(adr('A1'), 42) engine.undo() @@ -1149,7 +1653,7 @@ describe('Redo - adding rows', () => { }) describe('Redo - moving rows', () => { - it('works', () => { + it('re-applies row move after undo', () => { const engine = HyperFormula.buildFromArray([ ['1'], ['2'], @@ -1164,7 +1668,7 @@ describe('Redo - moving rows', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(snapshot)) }) - it('clears redo stack', () => { + it('moveRows clears redo stack', () => { const engine = HyperFormula.buildFromArray([]) engine.setCellContents(adr('A1'), 42) engine.undo() @@ -1176,7 +1680,7 @@ describe('Redo - moving rows', () => { }) describe('Redo - moving columns', () => { - it('works', () => { + it('re-applies column move after undo', () => { const engine = HyperFormula.buildFromArray([ ['1', '2', '3'], ]) @@ -1189,7 +1693,7 @@ describe('Redo - moving columns', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(snapshot)) }) - it('clears redo stack', () => { + it('moveColumns clears redo stack', () => { const engine = HyperFormula.buildFromArray([]) engine.setCellContents(adr('A1'), 42) engine.undo() @@ -1201,7 +1705,7 @@ describe('Redo - moving columns', () => { }) describe('Redo - moving cells', () => { - it('works', () => { + it('re-applies cell move after undo', () => { const engine = HyperFormula.buildFromArray([ ['42'], ['45'], @@ -1215,7 +1719,7 @@ describe('Redo - moving cells', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(snapshot)) }) - it('clears redo stack', () => { + it('moveCells clears redo stack', () => { const engine = HyperFormula.buildFromArray([]) engine.setCellContents(adr('A1'), 42) engine.undo() @@ -1227,7 +1731,7 @@ describe('Redo - moving cells', () => { }) describe('Redo - setting cell content', () => { - it('works for simple values', () => { + it('re-applies simple value change after undo', () => { const engine = HyperFormula.buildFromArray([ ['3'], ]) @@ -1240,7 +1744,7 @@ describe('Redo - setting cell content', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(snapshot)) }) - it('works for empty values', () => { + it('re-applies cell clearing after undo', () => { const engine = HyperFormula.buildFromArray([ ['3'], ]) @@ -1253,7 +1757,7 @@ describe('Redo - setting cell content', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(snapshot)) }) - it('works for formula values', () => { + it('re-applies formula value change after undo', () => { const engine = HyperFormula.buildFromArray([ ['3'], ]) @@ -1266,7 +1770,7 @@ describe('Redo - setting cell content', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(snapshot)) }) - it('setting multiple cell contents is one operation', () => { + it('redoes multiple cell contents as one operation', () => { const engine = HyperFormula.buildFromArray([ ['3', '4'], ]) @@ -1279,7 +1783,7 @@ describe('Redo - setting cell content', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(snapshot)) }) - it('clears redo stack', () => { + it('setCellContents clears redo stack', () => { const engine = HyperFormula.buildFromArray([]) engine.setCellContents(adr('A1'), 42) engine.undo() @@ -1291,7 +1795,7 @@ describe('Redo - setting cell content', () => { }) describe('Redo - removing sheet', () => { - it('works', () => { + it('re-removes sheet after undo', () => { const engine = HyperFormula.buildFromArray([ ['1'] ]) @@ -1304,7 +1808,7 @@ describe('Redo - removing sheet', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(snapshot)) }) - it('clears redo stack', () => { + it('removeSheet clears redo stack', () => { const engine = HyperFormula.buildFromArray([]) engine.setCellContents(adr('A1'), 42) engine.undo() @@ -1313,10 +1817,55 @@ describe('Redo - removing sheet', () => { expect(engine.isThereSomethingToRedo()).toBe(false) }) + + it('redo with cross-sheet formula dependencies', () => { + const engine = HyperFormula.buildFromSheets({ + Sheet1: [['=Sheet2!A1']], + Sheet2: [['42']], + }) + engine.removeSheet(1) + engine.undo() + + expect(engine.getSheetNames()).toEqual(['Sheet1', 'Sheet2']) + + engine.redo() + + expect(engine.getSheetNames()).toEqual(['Sheet1']) + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + }) + + it('removes sheet correctly after multiple undo/redo cycles', () => { + const engine = HyperFormula.buildFromSheets({ + Sheet1: [['1']], + Sheet2: [['2']], + }) + engine.removeSheet(1) + + engine.undo() + engine.redo() + engine.undo() + engine.redo() + engine.undo() + engine.redo() + + expect(engine.getSheetNames()).toEqual(['Sheet1']) + }) + + it('redo removes scoped named expressions', () => { + const engine = HyperFormula.buildFromSheets({ + Sheet1: [['=MyName']], + }) + engine.addNamedExpression('MyName', '=42', 0) + engine.removeSheet(0) + engine.undo() + engine.redo() + + expect(engine.listNamedExpressions().length).toBe(0) + }) }) describe('Redo - adding sheet', () => { - it('works for basic case', () => { + it('re-adds named sheet after undo', () => { const engine = HyperFormula.buildFromArray([]) engine.addSheet('SomeSheet') const snapshot = engine.getAllSheetsSerialized() @@ -1324,11 +1873,11 @@ describe('Redo - adding sheet', () => { engine.redo() - expect(engine.getSheetName(1)).toEqual('SomeSheet') + expect(engine.getSheetName(1)).toBe('SomeSheet') expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(snapshot)) }) - it('works for automatic naming', () => { + it('re-adds auto-named sheet after undo', () => { const engine = HyperFormula.buildFromArray([]) engine.addSheet() const snapshot = engine.getAllSheetsSerialized() @@ -1336,11 +1885,11 @@ describe('Redo - adding sheet', () => { engine.redo() - expect(engine.getSheetName(1)).toEqual('Sheet2') + expect(engine.getSheetName(1)).toBe('Sheet2') expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(snapshot)) }) - it('clears redo stack', () => { + it('addSheet clears redo stack', () => { const engine = HyperFormula.buildFromArray([]) engine.setCellContents(adr('A1'), 42) engine.undo() @@ -1349,20 +1898,42 @@ describe('Redo - adding sheet', () => { expect(engine.isThereSomethingToRedo()).toBe(false) }) + + it('restores cross-sheet reference after redo', () => { + const engine = HyperFormula.buildFromArray([['=NewSheet!A1']]) + engine.addSheet('NewSheet') + engine.undo() + engine.redo() + + expect(engine.getSheetName(1)).toBe('NewSheet') + }) + + it('builds formulas correctly with suspended evaluation during redo addSheet', () => { + const engine = HyperFormula.buildFromArray([['=NewSheet!A1']]) + engine.addSheet('NewSheet') + const snapshot = engine.getAllSheetsSerialized() + + engine.undo() + engine.suspendEvaluation() + engine.redo() + engine.resumeEvaluation() + + expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(snapshot)) + }) }) describe('Redo - renaming sheet', () => { - it('redo rename sheet', () => { + it('re-applies sheet rename after undo', () => { const engine = HyperFormula.buildFromSheets({'Sheet1': [[1]]}) engine.renameSheet(0, 'Foo') engine.undo() engine.redo() - expect(engine.getSheetName(0)).toEqual('Foo') + expect(engine.getSheetName(0)).toBe('Foo') }) - it('clears redo stack', () => { + it('renameSheet clears redo stack', () => { const engine = HyperFormula.buildFromArray([]) engine.setCellContents(adr('A1'), 42) engine.undo() @@ -1371,23 +1942,261 @@ describe('Redo - renaming sheet', () => { expect(engine.isThereSomethingToRedo()).toBe(false) }) + + it('redo rename with case change only', () => { + const engine = HyperFormula.buildFromSheets({'Sheet1': [[1]]}) + engine.renameSheet(0, 'SHEET1') + engine.undo() + engine.redo() + + expect(engine.getSheetName(0)).toBe('SHEET1') + }) + + it('redo rename preserves cell values', () => { + const engine = HyperFormula.buildFromSheets({'Sheet1': [[42], ['=A1*2']]}) + engine.renameSheet(0, 'NewName') + engine.undo() + engine.redo() + + expect(engine.getSheetName(0)).toBe('NewName') + expect(engine.getCellValue(adr('A1'))).toBe(42) + expect(engine.getCellValue(adr('A2'))).toBe(84) + }) + + it('redo rename with suspended evaluation', () => { + const engine = HyperFormula.buildFromSheets({'Sheet1': [[1]]}) + engine.renameSheet(0, 'Foo') + engine.undo() + engine.suspendEvaluation() + engine.redo() + engine.resumeEvaluation() + + expect(engine.getSheetName(0)).toBe('Foo') + }) + + it('redo rename that merged with placeholder sheet', () => { + const engine = HyperFormula.buildFromSheets({ + 'Sheet1': [['=OldName!A1', '=NewName!A1']], + 'OldName': [[42]], + }) + const sheet1Id = engine.getSheetId('Sheet1')! + const oldNameId = engine.getSheetId('OldName')! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(42) + expect(engine.getCellValue(adr('B1', sheet1Id))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + + engine.renameSheet(oldNameId, 'NewName') + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(42) + expect(engine.getCellValue(adr('B1', sheet1Id))).toBe(42) + + engine.undo() + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(42) + expect(engine.getCellValue(adr('B1', sheet1Id))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + + engine.redo() + + expect(engine.getSheetName(oldNameId)).toBe('NewName') + expect(engine.getCellFormula(adr('A1', sheet1Id))).toBe('=NewName!A1') + expect(engine.getCellFormula(adr('B1', sheet1Id))).toBe('=NewName!A1') + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(42) + expect(engine.getCellValue(adr('B1', sheet1Id))).toBe(42) + }) + + it('redo rename with range reference updates formula (merged with placeholder sheet)', () => { + const engine = HyperFormula.buildFromSheets({ + 'Sheet1': [['=SUM(OldName!A1:B2)', '=SUM(NewName!A1:B2)']], + 'OldName': [[10, 20], [30, 40]], + }) + const sheet1Id = engine.getSheetId('Sheet1')! + const oldNameId = engine.getSheetId('OldName')! + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(100) + expect(engine.getCellValue(adr('B1', sheet1Id))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + + engine.renameSheet(oldNameId, 'NewName') + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(100) + expect(engine.getCellValue(adr('B1', sheet1Id))).toBe(100) + + engine.undo() + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(100) + expect(engine.getCellValue(adr('B1', sheet1Id))).toEqualError(detailedError(ErrorType.REF, ErrorMessage.SheetRef)) + + engine.redo() + + expect(engine.getCellFormula(adr('A1', sheet1Id))).toBe('=SUM(NewName!A1:B2)') + expect(engine.getCellFormula(adr('B1', sheet1Id))).toBe('=SUM(NewName!A1:B2)') + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(100) + expect(engine.getCellValue(adr('B1', sheet1Id))).toBe(100) + }) + + it('redo multiple sequential renames', () => { + const engine = HyperFormula.buildFromSheets({'Sheet1': [[1]]}) + engine.renameSheet(0, 'Name1') + engine.renameSheet(0, 'Name2') + engine.renameSheet(0, 'Name3') + engine.undo() + engine.undo() + engine.undo() + engine.redo() + + expect(engine.getSheetName(0)).toBe('Name1') + + engine.redo() + + expect(engine.getSheetName(0)).toBe('Name2') + + engine.redo() + + expect(engine.getSheetName(0)).toBe('Name3') + }) + + it('redo rename combined with cell content changes', () => { + const engine = HyperFormula.buildFromSheets({'Sheet1': [[1]]}) + engine.setCellContents(adr('A1'), 10) + engine.renameSheet(0, 'NewName') + engine.setCellContents(adr('A1'), 100) + engine.undo() + engine.undo() + engine.undo() + engine.redo() + + expect(engine.getCellValue(adr('A1'))).toBe(10) + + engine.redo() + + expect(engine.getSheetName(0)).toBe('NewName') + + engine.redo() + + expect(engine.getCellValue(adr('A1'))).toBe(100) + }) + + it('redo rename in batch mode', () => { + const engine = HyperFormula.buildFromSheets({'Sheet1': [[1]], 'Sheet2': [[2]]}) + engine.batch(() => { + engine.renameSheet(0, 'NewName1') + engine.renameSheet(1, 'NewName2') + }) + engine.undo() + engine.redo() + + expect(engine.getSheetName(0)).toBe('NewName1') + expect(engine.getSheetName(1)).toBe('NewName2') + }) + + it('redo rename with chained dependencies across sheets', () => { + const engine = HyperFormula.buildFromSheets({ + 'Sheet1': [['=Sheet2!A1+2']], + 'Sheet2': [['=NewName!A1*2']], + 'OldName': [[42]], + }) + const sheet1Id = engine.getSheetId('Sheet1')! + const sheet2Id = engine.getSheetId('Sheet2')! + const oldNameId = engine.getSheetId('OldName')! + + engine.renameSheet(oldNameId, 'NewName') + engine.undo() + engine.redo() + + expect(engine.getCellValue(adr('A1', sheet2Id))).toBe(84) + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(86) + }) + + it('redo rename with named expressions referencing placeholder', () => { + const engine = HyperFormula.buildFromSheets({ + 'Sheet1': [['=MyValue']], + 'OldName': [[99]], + }, {}, [ + { name: 'MyValue', expression: '=NewName!$A$1' } + ]) + const sheet1Id = engine.getSheetId('Sheet1')! + const oldNameId = engine.getSheetId('OldName')! + engine.renameSheet(oldNameId, 'NewName') + engine.undo() + engine.redo() + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(99) + }) + + it('redo rename after undo of combined operations', () => { + const engine = HyperFormula.buildFromSheets({ + 'Sheet1': [['=NewName!A1']], + 'OldName': [[42]], + }) + const sheet1Id = engine.getSheetId('Sheet1')! + const oldNameId = engine.getSheetId('OldName')! + engine.renameSheet(oldNameId, 'NewName') + engine.setCellContents(adr('A1', oldNameId), 100) + engine.undo() + engine.undo() + engine.redo() + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(42) + + engine.redo() + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(100) + }) + + it('redo rename with multiple cells referencing placeholder', () => { + const engine = HyperFormula.buildFromSheets({ + 'Sheet1': [['=NewName!A1', '=NewName!B1']], + 'Sheet2': [['=NewName!A1+10', '=NewName!B1+20']], + 'OldName': [[5, 7]], + }) + const sheet1Id = engine.getSheetId('Sheet1')! + const sheet2Id = engine.getSheetId('Sheet2')! + const oldNameId = engine.getSheetId('OldName')! + engine.renameSheet(oldNameId, 'NewName') + engine.undo() + engine.redo() + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(5) + expect(engine.getCellValue(adr('B1', sheet1Id))).toBe(7) + expect(engine.getCellValue(adr('A1', sheet2Id))).toBe(15) + expect(engine.getCellValue(adr('B1', sheet2Id))).toBe(27) + }) + + it('redo rename with column and row ranges', () => { + const engine = HyperFormula.buildFromSheets({ + 'Sheet1': [ + ['=SUM(NewName!A:A)'], + ['=SUM(NewName!1:2)'], + ], + 'OldName': [ + [1, 2], + [3, 4], + ], + }) + const sheet1Id = engine.getSheetId('Sheet1')! + const oldNameId = engine.getSheetId('OldName')! + engine.renameSheet(oldNameId, 'NewName') + engine.undo() + engine.redo() + + expect(engine.getCellValue(adr('A1', sheet1Id))).toBe(4) + expect(engine.getCellValue(adr('A2', sheet1Id))).toBe(10) + }) }) describe('Redo - clearing sheet', () => { - it('works', () => { + it('re-clears sheet after undo', () => { const engine = HyperFormula.buildFromArray([ ['1'] ]) engine.clearSheet(0) const snapshot = engine.getAllSheetsSerialized() engine.undo() - engine.redo() expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(snapshot)) }) - it('clears redo stack', () => { + it('clearSheet clears redo stack', () => { const engine = HyperFormula.buildFromArray([]) engine.setCellContents(adr('A1'), 42) engine.undo() @@ -1399,7 +2208,7 @@ describe('Redo - clearing sheet', () => { }) describe('Redo - adding columns', () => { - it('works', () => { + it('re-adds column after undo', () => { const engine = HyperFormula.buildFromArray([ ['1', '3'], ]) @@ -1412,7 +2221,7 @@ describe('Redo - adding columns', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(snapshot)) }) - it('dummy operation should also be redoable', () => { + it('dummy addColumns operation is redoable', () => { const engine = HyperFormula.buildFromArray([ ['1'], ]) @@ -1425,7 +2234,7 @@ describe('Redo - adding columns', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(snapshot)) }) - it('works for more addition segments', () => { + it('re-adds multiple column segments after undo', () => { const engine = HyperFormula.buildFromArray([ ['1', '2', '3'], ]) @@ -1438,7 +2247,7 @@ describe('Redo - adding columns', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(snapshot)) }) - it('clears redo stack', () => { + it('addColumns clears redo stack', () => { const engine = HyperFormula.buildFromArray([]) engine.setCellContents(adr('A1'), 42) engine.undo() @@ -1450,7 +2259,7 @@ describe('Redo - adding columns', () => { }) describe('Redo - removing column', () => { - it('works for empty column', () => { + it('re-removes empty column after undo', () => { const engine = HyperFormula.buildFromArray([ ['1', null, '3'], ]) @@ -1463,7 +2272,7 @@ describe('Redo - removing column', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(snapshot)) }) - it('works for other values', () => { + it('re-removes column with values and formulas after undo', () => { const engine = HyperFormula.buildFromArray([ ['1', '2'], ['=B1'] @@ -1477,7 +2286,7 @@ describe('Redo - removing column', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(snapshot)) }) - it('works for more removal segments', () => { + it('re-removes multiple column segments after undo', () => { const engine = HyperFormula.buildFromArray([ ['1', '2', '3', '4'], ]) @@ -1490,7 +2299,7 @@ describe('Redo - removing column', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(snapshot)) }) - it('dummy operation should also be redoable', () => { + it('dummy removeColumns operation is redoable', () => { const engine = HyperFormula.buildFromArray([ ['1'] ]) @@ -1503,7 +2312,7 @@ describe('Redo - removing column', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(snapshot)) }) - it('clears redo stack', () => { + it('removeColumns clears redo stack', () => { const engine = HyperFormula.buildFromArray([]) engine.setCellContents(adr('A1'), 42) engine.undo() @@ -1515,7 +2324,7 @@ describe('Redo - removing column', () => { }) describe('Redo - cut-paste', () => { - it('works', () => { + it('re-applies cut-paste after undo', () => { const engine = HyperFormula.buildFromArray([ ['foo'], ['bar'], @@ -1553,7 +2362,7 @@ describe('Redo - cut-paste', () => { }) describe('Redo - copy-paste', () => { - it('works', () => { + it('re-applies copy-paste after undo', () => { const engine = HyperFormula.buildFromArray([ ['foo', 'baz'], ['bar', 'faz'], @@ -1591,7 +2400,7 @@ describe('Redo - copy-paste', () => { }) describe('Redo - setting sheet contents', () => { - it('works for basic case', () => { + it('re-applies sheet content change after undo', () => { const engine = HyperFormula.buildFromArray([['13']]) engine.setSheetContent(0, [['42']]) const snapshot = engine.getAllSheetsSerialized() @@ -1602,7 +2411,7 @@ describe('Redo - setting sheet contents', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(snapshot)) }) - it('also clears sheet when redoing', () => { + it('clears extra cells when redoing setSheetContent', () => { const engine = HyperFormula.buildFromArray([['13', '14']]) engine.setSheetContent(0, [['42']]) const snapshot = engine.getAllSheetsSerialized() @@ -1613,7 +2422,7 @@ describe('Redo - setting sheet contents', () => { expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(snapshot)) }) - it('clears redo stack', () => { + it('setSheetContent clears redo stack', () => { const engine = HyperFormula.buildFromArray([]) engine.setCellContents(adr('A1'), 42) engine.undo() @@ -1625,7 +2434,7 @@ describe('Redo - setting sheet contents', () => { }) describe('Redo - add named expression', () => { - it('works', () => { + it('re-adds named expression after undo', () => { const engine = HyperFormula.buildFromArray([ ['=foo'] ]) @@ -1635,11 +2444,11 @@ describe('Redo - add named expression', () => { engine.redo() - expect(engine.listNamedExpressions().length).toEqual(1) - expect(engine.getCellValue(adr('A1'))).toEqual('foo') + expect(engine.listNamedExpressions().length).toBe(1) + expect(engine.getCellValue(adr('A1'))).toBe('foo') }) - it('clears redo stack', () => { + it('addNamedExpression clears redo stack', () => { const engine = HyperFormula.buildFromArray([]) engine.setCellContents(adr('A1'), 42) engine.undo() @@ -1651,7 +2460,7 @@ describe('Redo - add named expression', () => { }) describe('Redo - remove named expression', () => { - it('works', () => { + it('re-removes named expression after undo', () => { const engine = HyperFormula.buildFromArray([ ['=foo'] ]) @@ -1662,11 +2471,11 @@ describe('Redo - remove named expression', () => { engine.redo() - expect(engine.listNamedExpressions().length).toEqual(0) + expect(engine.listNamedExpressions().length).toBe(0) expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.NAME, ErrorMessage.NamedExpressionName('foo'))) }) - it('clears redo stack', () => { + it('removeNamedExpression clears redo stack', () => { const engine = HyperFormula.buildFromArray([]) engine.addNamedExpression('foo', 'foo') engine.setCellContents(adr('A1'), 42) @@ -1679,7 +2488,7 @@ describe('Redo - remove named expression', () => { }) describe('Redo - change named expression', () => { - it('works', () => { + it('re-applies expression change after undo', () => { const engine = HyperFormula.buildFromArray([ ['=foo'] ]) @@ -1690,11 +2499,11 @@ describe('Redo - change named expression', () => { engine.redo() - expect(engine.listNamedExpressions().length).toEqual(1) - expect(engine.getCellValue(adr('A1'))).toEqual('bar') + expect(engine.listNamedExpressions().length).toBe(1) + expect(engine.getCellValue(adr('A1'))).toBe('bar') }) - it('clears redo stack', () => { + it('changeNamedExpression clears redo stack', () => { const engine = HyperFormula.buildFromArray([]) engine.addNamedExpression('foo', 'foo') engine.setCellContents(adr('A1'), 42) @@ -1721,6 +2530,7 @@ describe('Redo - batch mode', () => { engine.redo() expectEngineToBeTheSameAs(engine, HyperFormula.buildFromSheets(snapshot)) + expect(engine.isThereSomethingToRedo()).toBe(false) }) @@ -1742,7 +2552,7 @@ describe('Redo - batch mode', () => { }) describe('Redo', () => { - it('when there is no operation to redo', () => { + it('throws error when redo stack is empty', () => { const engine = HyperFormula.buildEmpty() expect(() => { @@ -1759,7 +2569,7 @@ describe('Redo', () => { const changes = engine.redo() - expect(engine.getCellValue(adr('B1'))).toEqual(100) + expect(engine.getCellValue(adr('B1'))).toBe(100) expect(changes.length).toBe(2) }) })