Datum- & Zeit-Formatierung: Serial-Zahlen, Auto-Erkennung, DATE/TIME/TODAY/NOW
Wie in jeder Tabellenkalkulation ist ein Datum intern einfach eine Zahl
("Serial": Tage seit 1899-12-30, Uhrzeit als Bruchteil eines Tages) --
Datums-Arithmetik (=fällig - rechnungsdatum, =rechnungsdatum + 30)
funktioniert dadurch automatisch, ganz ohne Änderung an Operatoren.
- Neues Modul engine/dates.ts: dateToSerial/timeSerial (Bausteine für
DATE()/TIME(), Überlauf wie Excel erlaubt), todaySerial/nowSerial
(lokales Kalenderdatum), serialToUtcDate (Anzeige), parseDateTimeLiteral
(Eingabe-Erkennung -- bewusst nur ISO/Punkt-Daten + Uhrzeit, KEINE
Schrägstrich-Daten, da Tag-/Monat-zuerst über die unterstützten Locales
nicht eindeutig auflösbar ist; ein zu einer ungültigen/rollierten
Kalenderdatum führender Kandidat wird strikt verworfen statt lautlos
zu überlaufen).
- workbook.ts: literalValue() erkennt Datum/Zeit vor dem Zahl-Test und
liefert direkt den Serial.
- functions.ts: neue eingebaute Funktionen DATE(jahr;monat;tag),
TIME(std;min;sek), TODAY(), NOW() -- TODAY/NOW sind bewusst nicht
volatil (dokumentierte Grenze: diese Engine rechnet rein
vorgänger-getrieben neu, ohne Timer).
- store/types.ts + format.ts: CellNumFmt um 'date'/'time'/'datetime'
erweitert; formatValue rendert über Intl.DateTimeFormat (UTC-Zeitzone,
pro Locale), 'plain' bekommt eine manuelle, locale-neutrale
Formatierung statt der Browser-Locale.
- store/document.ts: setCellInput erkennt eine getippte Datums-/
Zeit-Eingabe automatisch und setzt das passende Zellformat -- wie
Excel/Numbers --, überschreibt aber nie ein bereits explizit gesetztes
Format.
- Zahlenformat-Dropdown (SettingsPanel) und JSON-Validierung
(isCellNumFmt) brauchten keine Änderung -- beide sind bereits generisch
über NUM_FORMATS.
Tests: 32 neue (dates.spec.ts neu; workbook.spec.ts: literalValue +
DATE/TIME/TODAY/NOW-Formeln inkl. Datums-Arithmetik; format.spec.ts neu:
formatValue über mehrere Locales; document.spec.ts: Auto-Format beim
Eintippen, überschreibt nie ein gesetztes Format, Namenskollision mit
den neuen Funktionen). 265 -> 297 Tests, alle grün. vue-tsc/eslint/build
sauber. Live per Playwright verifiziert (Auto-Erkennung, Formel-Ergebnisse
ohne Auto-Format, Datums-Arithmetik, Schrägstrich-Daten bleiben Text).
READMEs aktualisiert (Funktionen, Zahlenformat, neuer "Datum & Zeit"-
Abschnitt in engine/README.md, Bekannte Grenzen, Testzahl; nebenbei eine
veraltete Aussage zu Vergleichsoperatoren in "Bekannte Grenzen" korrigiert
-- die gibt es seit der IF-Funktion bereits).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
dateToSerial,
|
||||
timeSerial,
|
||||
serialToUtcDate,
|
||||
todaySerial,
|
||||
parseDateTimeLiteral,
|
||||
} from '../dates';
|
||||
|
||||
describe('dateToSerial / serialToUtcDate', () => {
|
||||
it('epoch is 1899-12-30: serial 1 = 1899-12-31, serial 2 = 1900-01-01', () => {
|
||||
expect(dateToSerial(1899, 12, 31)).toBe(1);
|
||||
expect(dateToSerial(1900, 1, 1)).toBe(2);
|
||||
});
|
||||
|
||||
it('round-trips through serialToUtcDate', () => {
|
||||
const s = dateToSerial(2026, 9, 2);
|
||||
const d = serialToUtcDate(s);
|
||||
expect(d.getUTCFullYear()).toBe(2026);
|
||||
expect(d.getUTCMonth()).toBe(8); // 0-based
|
||||
expect(d.getUTCDate()).toBe(2);
|
||||
});
|
||||
|
||||
it('allows overflow, like Excel DATE() (month 13 -> next January)', () => {
|
||||
const s = dateToSerial(2026, 13, 1);
|
||||
const d = serialToUtcDate(s);
|
||||
expect(d.getUTCFullYear()).toBe(2027);
|
||||
expect(d.getUTCMonth()).toBe(0);
|
||||
expect(d.getUTCDate()).toBe(1);
|
||||
});
|
||||
|
||||
it('carries a fractional part for a time-of-day component', () => {
|
||||
const s = dateToSerial(2026, 9, 2, 12, 0, 0);
|
||||
expect(s % 1).toBeCloseTo(0.5, 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeSerial', () => {
|
||||
it('noon = 0.5, midnight = 0', () => {
|
||||
expect(timeSerial(12, 0, 0)).toBeCloseTo(0.5, 10);
|
||||
expect(timeSerial(0, 0, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('wraps past 24h like Excel TIME()', () => {
|
||||
expect(timeSerial(25, 0, 0)).toBeCloseTo(1 / 24, 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('todaySerial', () => {
|
||||
it('matches the local calendar date', () => {
|
||||
const n = new Date();
|
||||
expect(todaySerial()).toBe(Math.trunc(dateToSerial(n.getFullYear(), n.getMonth() + 1, n.getDate())));
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDateTimeLiteral', () => {
|
||||
it('recognizes ISO dates', () => {
|
||||
const r = parseDateTimeLiteral('2026-09-02');
|
||||
expect(r).toEqual({ kind: 'date', serial: dateToSerial(2026, 9, 2) });
|
||||
});
|
||||
|
||||
it('recognizes ISO datetimes (space or T separator, optional seconds)', () => {
|
||||
expect(parseDateTimeLiteral('2026-09-02 14:30')).toEqual({
|
||||
kind: 'datetime',
|
||||
serial: dateToSerial(2026, 9, 2, 14, 30, 0),
|
||||
});
|
||||
expect(parseDateTimeLiteral('2026-09-02T14:30:15')).toEqual({
|
||||
kind: 'datetime',
|
||||
serial: dateToSerial(2026, 9, 2, 14, 30, 15),
|
||||
});
|
||||
});
|
||||
|
||||
it('recognizes dot dates, 4- and 2-digit years', () => {
|
||||
expect(parseDateTimeLiteral('02.09.2026')).toEqual({
|
||||
kind: 'date',
|
||||
serial: dateToSerial(2026, 9, 2),
|
||||
});
|
||||
expect(parseDateTimeLiteral('02.09.26')).toEqual({
|
||||
kind: 'date',
|
||||
serial: dateToSerial(2026, 9, 2),
|
||||
});
|
||||
expect(parseDateTimeLiteral('02.09.99')).toEqual({
|
||||
kind: 'date',
|
||||
serial: dateToSerial(1999, 9, 2),
|
||||
});
|
||||
});
|
||||
|
||||
it('recognizes dot datetimes', () => {
|
||||
expect(parseDateTimeLiteral('02.09.2026 14:30')).toEqual({
|
||||
kind: 'datetime',
|
||||
serial: dateToSerial(2026, 9, 2, 14, 30, 0),
|
||||
});
|
||||
});
|
||||
|
||||
it('recognizes plain times', () => {
|
||||
expect(parseDateTimeLiteral('14:30')).toEqual({ kind: 'time', serial: timeSerial(14, 30, 0) });
|
||||
expect(parseDateTimeLiteral('14:30:15')).toEqual({ kind: 'time', serial: timeSerial(14, 30, 15) });
|
||||
});
|
||||
|
||||
it('does NOT recognize slash-separated dates (day-first/month-first ambiguity)', () => {
|
||||
expect(parseDateTimeLiteral('02/09/2026')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an out-of-range or rolled-over date instead of silently guessing', () => {
|
||||
expect(parseDateTimeLiteral('31.04.2026')).toBeNull(); // April has 30 days
|
||||
expect(parseDateTimeLiteral('30.02.2026')).toBeNull(); // no Feb 30
|
||||
expect(parseDateTimeLiteral('2026-13-01')).toBeNull(); // month 13
|
||||
});
|
||||
|
||||
it('rejects an out-of-range time', () => {
|
||||
expect(parseDateTimeLiteral('25:00')).toBeNull();
|
||||
expect(parseDateTimeLiteral('12:60')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not misfire on plain numbers or arbitrary text', () => {
|
||||
expect(parseDateTimeLiteral('1234.5')).toBeNull();
|
||||
expect(parseDateTimeLiteral('hello')).toBeNull();
|
||||
expect(parseDateTimeLiteral('')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Workbook, type TableModel } from '../workbook';
|
||||
import { Workbook, literalValue, type TableModel } from '../workbook';
|
||||
import { FormulaError, isError, keyOf } from '../types';
|
||||
import { dateToSerial, timeSerial, todaySerial, nowSerial } from '../dates';
|
||||
|
||||
function sheet(
|
||||
id: string,
|
||||
@@ -223,3 +224,62 @@ describe('Workbook sheet qualifier ("Blatt!Tabelle::Zelle") -- table names uniqu
|
||||
expect((val(wb2, 's-x', 'B2') as FormulaError).code).toBe('#REF');
|
||||
});
|
||||
});
|
||||
|
||||
describe('literalValue: date/time literal recognition', () => {
|
||||
it('turns a recognized date/time literal into its serial number', () => {
|
||||
expect(literalValue('2026-09-02')).toBe(dateToSerial(2026, 9, 2));
|
||||
expect(literalValue('02.09.2026')).toBe(dateToSerial(2026, 9, 2));
|
||||
expect(literalValue('14:30')).toBe(timeSerial(14, 30, 0));
|
||||
});
|
||||
|
||||
it('leaves ordinary numbers and text untouched', () => {
|
||||
expect(literalValue('1234.5')).toBe(1234.5);
|
||||
expect(literalValue('-3')).toBe(-3);
|
||||
expect(literalValue('hello')).toBe('hello');
|
||||
expect(literalValue('02/09/2026')).toBe('02/09/2026'); // slash dates: not recognized, stays text
|
||||
});
|
||||
|
||||
it('empty input is null', () => {
|
||||
expect(literalValue('')).toBeNull();
|
||||
expect(literalValue(' ')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook: DATE/TIME/TODAY/NOW formulas', () => {
|
||||
it('DATE/TIME build a serial usable in cell arithmetic', () => {
|
||||
const wb = new Workbook([
|
||||
sheet('s1', 'S1', {
|
||||
A1: '=DATE(2026;9;2)',
|
||||
A2: '=DATE(2026;9;1)',
|
||||
A3: '=A1-A2', // date arithmetic: just numbers
|
||||
B1: '=TIME(12;0;0)',
|
||||
}),
|
||||
]);
|
||||
expect(val(wb, 's1', 'A1')).toBe(dateToSerial(2026, 9, 2));
|
||||
expect(val(wb, 's1', 'A3')).toBe(1);
|
||||
expect(val(wb, 's1', 'B1')).toBeCloseTo(0.5, 10);
|
||||
});
|
||||
|
||||
it('DATE overflows like Excel, TIME wraps like Excel', () => {
|
||||
const wb = new Workbook([
|
||||
sheet('s1', 'S1', { A1: '=DATE(2026;13;1)', B1: '=TIME(25;0;0)' }),
|
||||
]);
|
||||
expect(val(wb, 's1', 'A1')).toBe(dateToSerial(2027, 1, 1));
|
||||
expect(val(wb, 's1', 'B1')).toBeCloseTo(timeSerial(25, 0, 0), 10);
|
||||
});
|
||||
|
||||
it('DATE/TIME reject the wrong argument count or a non-numeric argument', () => {
|
||||
const wb = new Workbook([
|
||||
sheet('s1', 'S1', { A1: '=DATE(2026;9)', A2: '=DATE(2026;9;"x")' }),
|
||||
]);
|
||||
expect(isError(val(wb, 's1', 'A1'))).toBe(true);
|
||||
expect(isError(val(wb, 's1', 'A2'))).toBe(true);
|
||||
});
|
||||
|
||||
it('TODAY/NOW take no arguments and return a plausible current serial', () => {
|
||||
const wb = new Workbook([sheet('s1', 'S1', { A1: '=TODAY()', A2: '=NOW()', A3: '=TODAY(1)' })]);
|
||||
expect(val(wb, 's1', 'A1')).toBe(todaySerial());
|
||||
expect(val(wb, 's1', 'A2') as number).toBeCloseTo(nowSerial(), 2);
|
||||
expect(isError(val(wb, 's1', 'A3'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user