AND/OR/NOT, Fehler-Literale und Zeilen/Spalten einfügen/löschen
- AND(...)/OR(...)/NOT(...) als gewöhnliche Funktionen (kein Kurzschluss- Sonderfall wie IF/XLOOKUP nötig, werten wie in echtem Excel alle Argumente eager aus). Dabei die bisher dreifach duplizierte Wahrheits-Prüfung zu functions.ts#isTruthy() zusammengezogen, jetzt von FUNCTIONS.IF, evalIf() und dem Block-IF-Statement gemeinsam genutzt. Schließt "kein AND/OR für zusammengesetzte Bedingungen" aus den Bekannten Grenzen, auch nutzbar in "Mit Schleife"-IF-Bedingungen. - Neue Grammatik-Ergänzung: ErrorLiteralNode. Jeder der sieben Fehlercodes (#REF, #DIV/0!, #NAME?, #ERR, #CIRC, #VALUE!, #N/A) lässt sich jetzt direkt in eine Formel tippen (=IF(A1=0;#N/A;A1)) und wertet unbedingt zu genau diesem Fehler aus. - Zeilen/Spalten einfügen/löschen mitten im Gitter (Rechtsklick auf einen Zeilen-/Spaltenkopf), nicht nur am Rand wie bisher: Zellinhalte, Formate und Zeilenhöhen/Spaltenbreiten verschieben sich innerhalb der Tabelle, und jede Formel im gesamten Dokument, die die Tabelle referenziert (auch über Tabelle!Zelle/Blatt!Tabelle::Zelle-Qualifier aus anderen Tabellen), wird automatisch mitgezogen. Ein Bereich, der die Stelle überspannt, wächst/schrumpft entsprechend; eine Referenz, die exakt die gelöschte Zeile/Spalte trifft, wird zu einem echten #REF-Literal im Formeltext (nutzt die neue ErrorLiteralNode-Grammatik). $-Sperren bleiben dabei bewusst wirkungslos (anders als beim Kopieren), unveränderte Referenzen bleiben byte-identisch. Token-Level-Splice wie shiftFormula (Kopieren/ Ausschneiden), keine AST-Neuserialisierung. 406 Tests, vue-tsc/eslint/build sauber, Playwright-live-verifiziert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1003,6 +1003,236 @@ describe('named ranges (document-level names for a fixed cell/range)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertRow/deleteRow/insertColumn/deleteColumn (mid-grid, not just edge growth)', () => {
|
||||
const raw = (s: DocumentStore, table: string, a1: string) => {
|
||||
const { col, row } = coords(a1);
|
||||
return s.cellInput(tableId(s, table), col, row);
|
||||
};
|
||||
|
||||
it('insertRow shifts cell data down from the insertion point, leaves earlier rows alone', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
setCell(s, name, 'A1', 'x');
|
||||
setCell(s, name, 'A2', 'y');
|
||||
s.insertRow(id, 1);
|
||||
expect(val(s, name, 'A1')).toBe('x'); // row 0, before the insertion point -- untouched
|
||||
expect(val(s, name, 'A2')).toBeNull(); // new empty row
|
||||
expect(val(s, name, 'A3')).toBe('y'); // shifted down
|
||||
expect(s.tableById(id)!.rows).toBe(13); // default 12 + 1
|
||||
});
|
||||
|
||||
it('deleteRow removes that row and shifts the rest up', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
setCell(s, name, 'A1', 'x');
|
||||
setCell(s, name, 'A2', 'y');
|
||||
setCell(s, name, 'A3', 'z');
|
||||
s.deleteRow(id, 1);
|
||||
expect(val(s, name, 'A1')).toBe('x');
|
||||
expect(val(s, name, 'A2')).toBe('z'); // 'y' is gone, 'z' shifted up
|
||||
expect(s.tableById(id)!.rows).toBe(11);
|
||||
});
|
||||
|
||||
it('deleteRow refuses to remove the last remaining row', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const initialRows = s.tableById(id)!.rows;
|
||||
for (let i = 0; i < initialRows - 1; i++) s.deleteRow(id, 0);
|
||||
expect(s.tableById(id)!.rows).toBe(1);
|
||||
s.deleteRow(id, 0);
|
||||
expect(s.tableById(id)!.rows).toBe(1); // no-op, refused
|
||||
});
|
||||
|
||||
it('insertColumn/deleteColumn mirror the row behaviour, incl. colWidths', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
setCell(s, name, 'A1', 'x');
|
||||
setCell(s, name, 'B1', 'y');
|
||||
s.setColWidth(id, 1, 111); // column B
|
||||
s.insertColumn(id, 1);
|
||||
expect(val(s, name, 'A1')).toBe('x');
|
||||
expect(val(s, name, 'B1')).toBeNull();
|
||||
expect(val(s, name, 'C1')).toBe('y'); // shifted right, width override moved with it
|
||||
expect(s.tableById(id)!.colWidths[2]).toBe(111);
|
||||
|
||||
s.deleteColumn(id, 1); // remove the now-empty B
|
||||
expect(val(s, name, 'A1')).toBe('x');
|
||||
expect(val(s, name, 'B1')).toBe('y'); // shifted back left
|
||||
expect(s.tableById(id)!.colWidths[1]).toBe(111);
|
||||
});
|
||||
|
||||
it('a self-referencing formula (same table) is rewritten to keep pointing at the same logical cell', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
setCell(s, name, 'B5', '20');
|
||||
setCell(s, name, 'C1', '=B5');
|
||||
expect(val(s, name, 'C1')).toBe(20);
|
||||
|
||||
s.insertRow(id, 2); // B5 (row 4) shifts to B6; C1 (row 0) stays put
|
||||
expect(raw(s, name, 'C1')).toBe('=B6');
|
||||
expect(val(s, name, 'C1')).toBe(20);
|
||||
});
|
||||
|
||||
it('a cross-table qualified reference is rewritten too, not just formulas inside the edited table', () => {
|
||||
const s = fresh();
|
||||
const src = s.addTable(s.doc.activeSheetId);
|
||||
s.renameObject(src, 'Quelle');
|
||||
const dst = s.addTable(s.doc.activeSheetId);
|
||||
s.renameObject(dst, 'Ziel');
|
||||
setCell(s, 'Quelle', 'B5', '42');
|
||||
setCell(s, 'Ziel', 'A1', '=Quelle!B5');
|
||||
expect(val(s, 'Ziel', 'A1')).toBe(42);
|
||||
|
||||
s.insertRow(src, 2);
|
||||
expect(raw(s, 'Ziel', 'A1')).toBe('=Quelle!B6');
|
||||
expect(val(s, 'Ziel', 'A1')).toBe(42);
|
||||
});
|
||||
|
||||
it('a range that straddles the insertion point grows by one row instead of just sliding', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
setCell(s, name, 'B1', '1');
|
||||
setCell(s, name, 'B2', '2');
|
||||
setCell(s, name, 'B3', '3');
|
||||
setCell(s, name, 'D1', '=SUM(B1:B3)');
|
||||
expect(val(s, name, 'D1')).toBe(6);
|
||||
|
||||
s.insertRow(id, 1); // inside the range (row 1, between B1 and what was B2)
|
||||
expect(raw(s, name, 'D1')).toBe('=SUM(B1:B4)'); // grew by one
|
||||
setCell(s, name, 'B2', '99'); // the newly inserted row is now part of the range
|
||||
expect(val(s, name, 'D1')).toBe(1 + 99 + 2 + 3);
|
||||
});
|
||||
|
||||
it('a range fully after the deletion point shifts down, keeps its size', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
setCell(s, name, 'B2', '10');
|
||||
setCell(s, name, 'B3', '20');
|
||||
setCell(s, name, 'D5', '=SUM(B2:B3)'); // away from row 0, so it survives the delete itself
|
||||
s.deleteRow(id, 0); // delete row 0, entirely before the range -- range shifts, doesn't shrink in size
|
||||
expect(raw(s, name, 'D4')).toBe('=SUM(B1:B2)');
|
||||
expect(val(s, name, 'D4')).toBe(30);
|
||||
});
|
||||
|
||||
it('deleting a row *inside* a multi-row range shrinks it by one, keeps the rest', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
setCell(s, name, 'B1', '1');
|
||||
setCell(s, name, 'B2', '2');
|
||||
setCell(s, name, 'B3', '3');
|
||||
setCell(s, name, 'D1', '=SUM(B1:B3)');
|
||||
s.deleteRow(id, 1); // remove the middle row of the range
|
||||
expect(raw(s, name, 'D1')).toBe('=SUM(B1:B2)');
|
||||
expect(val(s, name, 'D1')).toBe(1 + 3); // only 1 and (shifted) 3 remain
|
||||
});
|
||||
|
||||
it('a single-cell reference exactly on the deleted row becomes a literal #REF', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
setCell(s, name, 'B2', '10');
|
||||
setCell(s, name, 'C1', '=B2+1');
|
||||
s.deleteRow(id, 1); // row 1 is exactly B2's row
|
||||
expect(raw(s, name, 'C1')).toBe('=#REF+1');
|
||||
expect(isError(val(s, name, 'C1'))).toBe(true);
|
||||
});
|
||||
|
||||
it('a range entirely on the deleted row collapses to a literal #REF', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
setCell(s, name, 'B2', '1');
|
||||
setCell(s, name, 'D2', '2');
|
||||
setCell(s, name, 'D1', '=SUM(B2:D2)'); // a 1-row range, entirely row 1
|
||||
s.deleteRow(id, 1);
|
||||
expect(raw(s, name, 'D1')).toBe('=SUM(#REF)');
|
||||
expect(isError(val(s, name, 'D1'))).toBe(true);
|
||||
});
|
||||
|
||||
it('a $-locked reference still shifts -- unlike copy/paste, $ has no effect on a structural insert/delete', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
setCell(s, name, 'B5', '7');
|
||||
setCell(s, name, 'C1', '=$B$5');
|
||||
s.insertRow(id, 1);
|
||||
expect(raw(s, name, 'C1')).toBe('=$B$6');
|
||||
expect(val(s, name, 'C1')).toBe(7);
|
||||
});
|
||||
|
||||
it('a reference to a row before the insertion/deletion point is left untouched, byte-identical', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
setCell(s, name, 'A1', '5');
|
||||
// lower-case + extra spacing in the formula text -- proves an untouched
|
||||
// reference (row 0, nowhere near the insertion point at row 5) is left
|
||||
// completely alone, not just numerically equivalent
|
||||
setCell(s, name, 'C1', '=a1 + 1');
|
||||
s.insertRow(id, 5);
|
||||
expect(raw(s, name, 'C1')).toBe('=a1 + 1');
|
||||
s.deleteRow(id, 5);
|
||||
expect(raw(s, name, 'C1')).toBe('=a1 + 1');
|
||||
});
|
||||
|
||||
it('the sheet-qualified 3-part form ("Blatt!Tabelle::Zelle") is resolved and rewritten too', () => {
|
||||
const s = fresh();
|
||||
// fresh sheets, names that don't collide with the seed's own "Analyse"/
|
||||
// "Umsatz" (the whole point of the 3-part form is disambiguating a name
|
||||
// used on more than one sheet, but that's not what this test is about)
|
||||
const sheetA = s.addSheet();
|
||||
s.renameSheet(sheetA, 'ErsterBogen');
|
||||
const src = s.addTable(sheetA);
|
||||
s.renameObject(src, 'Quelle3');
|
||||
const sheetB = s.addSheet();
|
||||
s.renameSheet(sheetB, 'ZweiterBogen');
|
||||
const dst = s.addTable(sheetB);
|
||||
s.renameObject(dst, 'Ziel3');
|
||||
|
||||
setCell(s, 'Quelle3', 'B5', '42');
|
||||
setCell(s, 'Ziel3', 'A1', '=ErsterBogen!Quelle3::B5');
|
||||
expect(val(s, 'Ziel3', 'A1')).toBe(42);
|
||||
|
||||
s.insertRow(src, 2);
|
||||
expect(raw(s, 'Ziel3', 'A1')).toBe('=ErsterBogen!Quelle3::B6');
|
||||
expect(val(s, 'Ziel3', 'A1')).toBe(42);
|
||||
});
|
||||
|
||||
it('undo/redo covers a row insert/delete, incl. the document-wide formula rewrite', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
setCell(s, name, 'B5', '10');
|
||||
setCell(s, name, 'C1', '=B5');
|
||||
vi.advanceTimersByTime(600);
|
||||
|
||||
s.insertRow(id, 2);
|
||||
vi.advanceTimersByTime(600);
|
||||
expect(raw(s, name, 'C1')).toBe('=B6');
|
||||
|
||||
s.undo();
|
||||
expect(raw(s, name, 'C1')).toBe('=B5');
|
||||
expect(s.tableById(id)!.rows).toBe(12);
|
||||
|
||||
s.redo();
|
||||
expect(raw(s, name, 'C1')).toBe('=B6');
|
||||
expect(s.tableById(id)!.rows).toBe(13);
|
||||
} finally {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('functionSuggestions (formula autocomplete)', () => {
|
||||
it('filters built-ins by prefix, case-insensitively', () => {
|
||||
const s = fresh();
|
||||
|
||||
Reference in New Issue
Block a user