Add a sheet qualifier: "Blatt!Tabelle::Zelle", table names unique per sheet

Table names were document-unique; now they're unique per sheet only --
two different sheets may each hold a table called "Umsatz". A plain,
unqualified reference (Umsatz!B2 / Umsatz::B2, unchanged from before)
resolves to the table on the SAME sheet as the formula referencing it
when the name is otherwise ambiguous, falling back to the single
document-wide match when it isn't ambiguous at all -- exactly as
before for every existing formula. The new explicit 3-part form
(Analyse!Umsatz::B2, quoted where a name has spaces) always targets
the named sheet's table regardless of where the formula itself lives,
for the case that's still genuinely ambiguous.

Engine: parser.ts's afterFirstQualifierToken() disambiguates via
lookahead -- "X!Y::" (a name, then "::") is the new 3-part sheet+table
form; a bare "X!Y" keeps its original single-qualifier meaning, so
every formula written before this exists still parses identically.
CellNode/RangeNode gain an optional sheetQualifier field.
Workbook.resolveTableName(name, sheetName?) implements the resolution
policy; TableModel gains sheetName. precedentsOf() now also sets
homeTableId (previously only evaluateCell did), so graph edges use the
same same-sheet-preference resolution an evaluation would.

Store: table name uniqueness checks (addTable, renameObject) are now
scoped to the target sheet instead of the whole document. Point-mode's
qualifyRef() inserts the full "Blatt!Tabelle::" form only when the
clicked table's name is genuinely ambiguous and lives on a different
sheet than the editor; otherwise the simple form, unchanged.

200 tests passing (19 new), vue-tsc and eslint clean, verified live in
the running app: renamed a Blatt-2 table to the same name as Analyse's
seed "Umsatz", confirmed an unqualified reference resolves per-sheet,
an explicit sheet-qualified one always targets the named sheet, and
the original sheet's own formulas are unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-02 16:16:21 +02:00
co-authored by Claude Sonnet 5
parent fdde4ed5a7
commit 7340307a70
11 changed files with 483 additions and 67 deletions
+37 -2
View File
@@ -15,9 +15,9 @@ function sexpr(n: Node): string {
case 'func':
return `(${n.name} ${n.args.map(sexpr).join(' ')})`;
case 'cell':
return `${n.qualifier ? n.qualifier + '!' : ''}cell(${n.col},${n.row})`;
return `${n.sheetQualifier ? n.sheetQualifier + '!' : ''}${n.qualifier ? n.qualifier + '!' : ''}cell(${n.col},${n.row})`;
case 'range':
return `${n.qualifier ? n.qualifier + '!' : ''}range(${n.start.col},${n.start.row}:${n.end.col},${n.end.row})`;
return `${n.sheetQualifier ? n.sheetQualifier + '!' : ''}${n.qualifier ? n.qualifier + '!' : ''}range(${n.start.col},${n.start.row}:${n.end.col},${n.end.row})`;
case 'param':
return `param(${n.name})`;
}
@@ -89,6 +89,41 @@ describe('parser references', () => {
});
});
describe('parser sheet qualifier -- "Blatt!Tabelle::Zelle"', () => {
it('parses the explicit 3-part form, bare and quoted', () => {
expect(parse('=Analyse!Umsatz::B2')).toBe('Analyse!Umsatz!cell(1,1)');
expect(parse("='Meine Analyse'!Umsatz::B2")).toBe('Meine Analyse!Umsatz!cell(1,1)');
expect(parse("=Analyse!'Kosten Q3'::B2:E2")).toBe('Analyse!Kosten Q3!range(1,1:4,1)');
expect(parse("='Meine Analyse'!'Kosten Q3'::B2")).toBe('Meine Analyse!Kosten Q3!cell(1,1)');
});
it('is unaffected in a range end / function call', () => {
expect(parse('=SUM(Analyse!Umsatz::B2:E2; 1)')).toBe('(SUM Analyse!Umsatz!range(1,1:4,1) 1)');
});
it('a bare "X!Y" (no further "::") keeps its original, table-only meaning -- fully backward-compatible', () => {
// no "::" ever follows -> "Analyse" is read as the TABLE, "A1" the cell,
// exactly as parseFormula() behaved before the sheet qualifier existed.
expect(parse('=Analyse!A1')).toBe('Analyse!cell(0,0)');
expect(parse('=Umsatz!B2')).toBe(parse('=Umsatz!B2')); // unchanged from the existing suite
});
it('"X::Y" (no leading sheet) is still the plain table qualifier, never a sheet one', () => {
expect(parse('=Umsatz::B2')).toBe('Umsatz!cell(1,1)');
});
it('"X!Y!Z" (a second "!" instead of "::") does not form a sheet qualifier -- "!" only introduces a sheet name when followed by NAME "::"', () => {
// no "::" anywhere ahead of "Y" -> "X!" is read as an ordinary (old-style)
// table qualifier, leaving "Y" to be parsed as the cell -- which it isn't
// shaped like (no digits) -> ParseError, same as e.g. "=X!Y" always was.
expect(() => parseFormula('=X!Y!Z1')).toThrow(ParseError);
});
it('rejects a malformed 3-part reference (missing cell after "::")', () => {
expect(() => parseFormula('=Analyse!Umsatz::')).toThrow(ParseError);
});
});
describe('parseFormula(src, paramNames) -- user-defined function bodies', () => {
it('a name in paramNames parses as a param reference, case-insensitively', () => {
expect(parse2('=revenue - cost', ['revenue', 'cost'])).toBe('(- param(revenue) param(cost))');
+7 -2
View File
@@ -10,7 +10,12 @@ import { Workbook, type TableModel } from '../workbook';
import { FormulaError, isError, keyOf } from '../types';
// same small A1 helper used by workbook.spec.ts
function sheet(id: string, name: string, cells: Record<string, string>): TableModel {
function sheet(
id: string,
name: string,
cells: Record<string, string>,
sheetName = 'Blatt1',
): TableModel {
const inputs = new Map<string, string>();
for (const [a1, raw] of Object.entries(cells)) {
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
@@ -18,7 +23,7 @@ function sheet(id: string, name: string, cells: Record<string, string>): TableMo
const row = Number(m[2]) - 1;
inputs.set(`${col},${row}`, raw);
}
return { id, name, cols: 8, rows: 30, inputs };
return { id, name, sheetName, cols: 8, rows: 30, inputs };
}
const val = (wb: Workbook, sheetId: string, a1: string) => {
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
+81 -2
View File
@@ -2,7 +2,12 @@ import { describe, it, expect } from 'vitest';
import { Workbook, type TableModel } from '../workbook';
import { FormulaError, isError, keyOf } from '../types';
function sheet(id: string, name: string, cells: Record<string, string>): TableModel {
function sheet(
id: string,
name: string,
cells: Record<string, string>,
sheetName = 'Blatt1',
): TableModel {
const inputs = new Map<string, string>();
for (const [a1, raw] of Object.entries(cells)) {
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
@@ -10,7 +15,7 @@ function sheet(id: string, name: string, cells: Record<string, string>): TableMo
const row = Number(m[2]) - 1;
inputs.set(`${col},${row}`, raw);
}
return { id, name, cols: 8, rows: 30, inputs };
return { id, name, sheetName, cols: 8, rows: 30, inputs };
}
/** Rebuilds the small "Umsatz / Kosten / Übersicht" fixture from the brief. */
@@ -144,3 +149,77 @@ describe('Workbook recalculation order', () => {
expect(pos(keyOf('s-ueber', 3, 1))).toBeLessThan(pos(keyOf('s-ueber', 4, 1)));
});
});
describe('Workbook sheet qualifier ("Blatt!Tabelle::Zelle") -- table names unique per sheet only', () => {
/** Two sheets ("Analyse", "Zweitblatt") each hold their own table called
* "Umsatz" -- the same table NAME on two different sheets, only possible
* now that table-name uniqueness is per-sheet rather than document-wide. */
function multiSheetFixture(): Workbook {
const analyseUmsatz = sheet('s-a-umsatz', 'Umsatz', { B2: '100' }, 'Analyse');
const analyseOther = sheet(
's-a-other',
'Auswertung',
{
B2: '=Umsatz!B2', // ambiguous name, home sheet is Analyse -> Analyse's own Umsatz
B3: '=Analyse!Umsatz::B2', // explicit sheet qualifier
B4: '=Zweitblatt!Umsatz::B2', // explicit sheet qualifier, the OTHER sheet's Umsatz
},
'Analyse',
);
const zweitUmsatz = sheet('s-b-umsatz', 'Umsatz', { B2: '999' }, 'Zweitblatt');
const zweitOther = sheet(
's-b-other',
'Check',
{ B2: '=Umsatz!B2' }, // ambiguous name, home sheet is Zweitblatt -> Zweitblatt's own Umsatz
'Zweitblatt',
);
const drittOther = sheet(
's-c-other',
'Fremd',
{
B2: '=Umsatz!B2', // ambiguous, no same-sheet "Umsatz" on Dritt -> #REF
B3: '=Auswertung!B2', // "Auswertung" itself is NOT ambiguous (only on Analyse) -> resolves from anywhere
},
'Dritt',
);
return new Workbook([analyseUmsatz, analyseOther, zweitUmsatz, zweitOther, drittOther]);
}
it('an unqualified, otherwise-ambiguous name resolves to the table on the SAME sheet as the formula', () => {
const wb = multiSheetFixture();
expect(val(wb, 's-a-other', 'B2')).toBe(100);
expect(val(wb, 's-b-other', 'B2')).toBe(999);
});
it('an explicit "Blatt!Tabelle::Zelle" always resolves to that sheet\'s table, regardless of home sheet', () => {
const wb = multiSheetFixture();
expect(val(wb, 's-a-other', 'B3')).toBe(100);
expect(val(wb, 's-a-other', 'B4')).toBe(999);
});
it('an unqualified ambiguous name with no same-sheet match is #REF', () => {
const wb = multiSheetFixture();
expect((val(wb, 's-c-other', 'B2') as FormulaError).code).toBe('#REF');
});
it('a non-ambiguous (single-match) name still resolves without any sheet qualifier, from any sheet', () => {
const wb = multiSheetFixture();
expect(val(wb, 's-c-other', 'B3')).toBe(100); // Auswertung!B2, transitively Analyse's Umsatz!B2
});
it('recalculates correctly through a same-sheet-preferred resolution when the source cell changes', () => {
const wb = multiSheetFixture();
wb.setCell('s-a-umsatz', 1, 1, '250'); // Analyse's Umsatz B2: 100 -> 250
expect(val(wb, 's-a-other', 'B2')).toBe(250);
expect(val(wb, 's-a-other', 'B3')).toBe(250); // Auswertung!B2 chained through
expect(val(wb, 's-b-other', 'B2')).toBe(999); // Zweitblatt's own Umsatz, unaffected
});
it('an explicit sheet qualifier for an unknown sheet or table is #REF', () => {
const wb2 = new Workbook([
sheet('s-x', 'X', { B2: '=Nirgendwo!Umsatz::A1' }, 'Analyse'),
sheet('s-a-umsatz', 'Umsatz', { A1: '5' }, 'Analyse'),
]);
expect((val(wb2, 's-x', 'B2') as FormulaError).code).toBe('#REF');
});
});