Skip to content

Commit 2308e8b

Browse files
authored
Merge pull request #5110 from VisActor/feat/add-sheet-formula-types
Feat/add sheet formula types
2 parents 334f607 + 86c0c03 commit 2308e8b

3 files changed

Lines changed: 240 additions & 0 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"changes": [
3+
{
4+
"comment": "feat: add sheet formula type\n\n",
5+
"type": "none",
6+
"packageName": "@visactor/vtable"
7+
}
8+
],
9+
"packageName": "@visactor/vtable",
10+
"email": "892739385@qq.com"
11+
}

packages/vtable-sheet/__tests__/formula-engine/formula-engine-core.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,4 +143,53 @@ describe('FormulaEngine.adjustFormulaReferences - Core Functionality', () => {
143143
expect(engine.isCellFormula(b7Cell)).toBe(true);
144144
});
145145
});
146+
147+
describe('Common function evaluation', () => {
148+
test('math functions FLOOR/CEILING/SQRT/POWER/MOD', () => {
149+
engine.setCellContent({ sheet: 'Sheet1', row: 0, col: 0 }, '=FLOOR(5.7)');
150+
expect(engine.getCellValue({ sheet: 'Sheet1', row: 0, col: 0 }).value).toBe(5);
151+
152+
engine.setCellContent({ sheet: 'Sheet1', row: 1, col: 0 }, '=FLOOR(5.7,0.5)');
153+
expect(engine.getCellValue({ sheet: 'Sheet1', row: 1, col: 0 }).value).toBe(5.5);
154+
155+
engine.setCellContent({ sheet: 'Sheet1', row: 2, col: 0 }, '=CEILING(5.2)');
156+
expect(engine.getCellValue({ sheet: 'Sheet1', row: 2, col: 0 }).value).toBe(6);
157+
158+
engine.setCellContent({ sheet: 'Sheet1', row: 3, col: 0 }, '=CEILING(5.2,0.5)');
159+
expect(engine.getCellValue({ sheet: 'Sheet1', row: 3, col: 0 }).value).toBe(5.5);
160+
161+
engine.setCellContent({ sheet: 'Sheet1', row: 4, col: 0 }, '=SQRT(9)');
162+
expect(engine.getCellValue({ sheet: 'Sheet1', row: 4, col: 0 }).value).toBe(3);
163+
164+
engine.setCellContent({ sheet: 'Sheet1', row: 5, col: 0 }, '=POWER(2,3)');
165+
expect(engine.getCellValue({ sheet: 'Sheet1', row: 5, col: 0 }).value).toBe(8);
166+
167+
engine.setCellContent({ sheet: 'Sheet1', row: 6, col: 0 }, '=MOD(10,3)');
168+
expect(engine.getCellValue({ sheet: 'Sheet1', row: 6, col: 0 }).value).toBe(1);
169+
});
170+
171+
test('date and time functions YEAR/MONTH/DAY/HOUR/MINUTE/SECOND', () => {
172+
const date = new Date(2020, 0, 15, 10, 20, 30); // 2020-01-15 10:20:30
173+
engine.setCellContent({ sheet: 'Sheet1', row: 0, col: 0 }, date); // A1
174+
175+
engine.setCellContent({ sheet: 'Sheet1', row: 1, col: 0 }, '=YEAR(A1)');
176+
engine.setCellContent({ sheet: 'Sheet1', row: 2, col: 0 }, '=MONTH(A1)');
177+
engine.setCellContent({ sheet: 'Sheet1', row: 3, col: 0 }, '=DAY(A1)');
178+
engine.setCellContent({ sheet: 'Sheet1', row: 4, col: 0 }, '=HOUR(A1)');
179+
engine.setCellContent({ sheet: 'Sheet1', row: 5, col: 0 }, '=MINUTE(A1)');
180+
engine.setCellContent({ sheet: 'Sheet1', row: 6, col: 0 }, '=SECOND(A1)');
181+
182+
expect(engine.getCellValue({ sheet: 'Sheet1', row: 1, col: 0 }).value).toBe(2020);
183+
expect(engine.getCellValue({ sheet: 'Sheet1', row: 2, col: 0 }).value).toBe(1);
184+
expect(engine.getCellValue({ sheet: 'Sheet1', row: 3, col: 0 }).value).toBe(15);
185+
expect(engine.getCellValue({ sheet: 'Sheet1', row: 4, col: 0 }).value).toBe(10);
186+
expect(engine.getCellValue({ sheet: 'Sheet1', row: 5, col: 0 }).value).toBe(20);
187+
expect(engine.getCellValue({ sheet: 'Sheet1', row: 6, col: 0 }).value).toBe(30);
188+
189+
// YEAR(NOW()) 至少不报错且返回当前年份
190+
engine.setCellContent({ sheet: 'Sheet1', row: 7, col: 0 }, '=YEAR(NOW())');
191+
const currentYear = new Date().getFullYear();
192+
expect(engine.getCellValue({ sheet: 'Sheet1', row: 7, col: 0 }).value).toBe(currentYear);
193+
});
194+
});
146195
});

packages/vtable-sheet/src/formula/formula-engine.ts

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -953,6 +953,16 @@ export class FormulaEngine {
953953
return this.calculateAbs(args);
954954
case 'ROUND':
955955
return this.calculateRound(args);
956+
case 'FLOOR':
957+
return this.calculateFloor(args);
958+
case 'CEILING':
959+
return this.calculateCeiling(args);
960+
case 'SQRT':
961+
return this.calculateSqrt(args);
962+
case 'POWER':
963+
return this.calculatePower(args);
964+
case 'MOD':
965+
return this.calculateMod(args);
956966
case 'INT':
957967
return this.calculateInt(args);
958968
case 'RAND':
@@ -987,6 +997,18 @@ export class FormulaEngine {
987997
return this.calculateToday(args);
988998
case 'NOW':
989999
return this.calculateNow(args);
1000+
case 'YEAR':
1001+
return this.calculateYear(args);
1002+
case 'MONTH':
1003+
return this.calculateMonth(args);
1004+
case 'DAY':
1005+
return this.calculateDay(args);
1006+
case 'HOUR':
1007+
return this.calculateHour(args);
1008+
case 'MINUTE':
1009+
return this.calculateMinute(args);
1010+
case 'SECOND':
1011+
return this.calculateSecond(args);
9901012

9911013
default:
9921014
return { value: null, error: `Unknown function: ${funcName}` };
@@ -1048,6 +1070,44 @@ export class FormulaEngine {
10481070
return { value: Math.abs(num), error: undefined };
10491071
}
10501072

1073+
private calculateFloor(args: unknown[]): { value: unknown; error?: string } {
1074+
if (args.length < 1 || args.length > 2) {
1075+
return { value: null, error: 'FLOOR requires 1 or 2 arguments' };
1076+
}
1077+
const num = Number(args[0]);
1078+
if (isNaN(num)) {
1079+
return { value: null, error: 'FLOOR first argument must be a number' };
1080+
}
1081+
const significance = args.length === 2 ? Number(args[1]) : 1;
1082+
if (isNaN(significance)) {
1083+
return { value: null, error: 'FLOOR significance must be a number' };
1084+
}
1085+
if (significance === 0) {
1086+
return { value: 0, error: undefined };
1087+
}
1088+
const factor = Math.floor(num / significance);
1089+
return { value: factor * significance, error: undefined };
1090+
}
1091+
1092+
private calculateCeiling(args: unknown[]): { value: unknown; error?: string } {
1093+
if (args.length < 1 || args.length > 2) {
1094+
return { value: null, error: 'CEILING requires 1 or 2 arguments' };
1095+
}
1096+
const num = Number(args[0]);
1097+
if (isNaN(num)) {
1098+
return { value: null, error: 'CEILING first argument must be a number' };
1099+
}
1100+
const significance = args.length === 2 ? Number(args[1]) : 1;
1101+
if (isNaN(significance)) {
1102+
return { value: null, error: 'CEILING significance must be a number' };
1103+
}
1104+
if (significance === 0) {
1105+
return { value: 0, error: undefined };
1106+
}
1107+
const factor = Math.ceil(num / significance);
1108+
return { value: factor * significance, error: undefined };
1109+
}
1110+
10511111
private calculateRound(args: unknown[]): { value: unknown; error?: string } {
10521112
if (args.length < 1 || args.length > 2) {
10531113
return { value: null, error: 'ROUND requires 1 or 2 arguments' };
@@ -1064,6 +1124,44 @@ export class FormulaEngine {
10641124
return { value: Math.round(num * factor) / factor, error: undefined };
10651125
}
10661126

1127+
private calculateSqrt(args: unknown[]): { value: unknown; error?: string } {
1128+
if (args.length !== 1) {
1129+
return { value: null, error: 'SQRT requires exactly 1 argument' };
1130+
}
1131+
const num = Number(args[0]);
1132+
if (isNaN(num) || num < 0) {
1133+
return { value: null, error: 'SQRT argument must be a non-negative number' };
1134+
}
1135+
return { value: Math.sqrt(num), error: undefined };
1136+
}
1137+
1138+
private calculatePower(args: unknown[]): { value: unknown; error?: string } {
1139+
if (args.length !== 2) {
1140+
return { value: null, error: 'POWER requires exactly 2 arguments' };
1141+
}
1142+
const base = Number(args[0]);
1143+
const exponent = Number(args[1]);
1144+
if (isNaN(base) || isNaN(exponent)) {
1145+
return { value: null, error: 'POWER arguments must be numbers' };
1146+
}
1147+
return { value: Math.pow(base, exponent), error: undefined };
1148+
}
1149+
1150+
private calculateMod(args: unknown[]): { value: unknown; error?: string } {
1151+
if (args.length !== 2) {
1152+
return { value: null, error: 'MOD requires exactly 2 arguments' };
1153+
}
1154+
const dividend = Number(args[0]);
1155+
const divisor = Number(args[1]);
1156+
if (isNaN(dividend) || isNaN(divisor)) {
1157+
return { value: null, error: 'MOD arguments must be numbers' };
1158+
}
1159+
if (divisor === 0) {
1160+
return { value: null, error: 'MOD divisor must not be zero' };
1161+
}
1162+
return { value: dividend % divisor, error: undefined };
1163+
}
1164+
10671165
private calculateInt(args: unknown[]): { value: unknown; error?: string } {
10681166
if (args.length !== 1) {
10691167
return { value: null, error: 'INT requires exactly 1 argument' };
@@ -1241,6 +1339,88 @@ export class FormulaEngine {
12411339
return { value: new Date(), error: undefined };
12421340
}
12431341

1342+
private toDate(value: unknown): Date | null {
1343+
if (value instanceof Date) {
1344+
return value;
1345+
}
1346+
if (typeof value === 'number' && !isNaN(value)) {
1347+
const d = new Date(value);
1348+
return isNaN(d.getTime()) ? null : d;
1349+
}
1350+
if (typeof value === 'string') {
1351+
const d = new Date(value);
1352+
return isNaN(d.getTime()) ? null : d;
1353+
}
1354+
return null;
1355+
}
1356+
1357+
private calculateYear(args: unknown[]): { value: unknown; error?: string } {
1358+
if (args.length !== 1) {
1359+
return { value: null, error: 'YEAR requires exactly 1 argument' };
1360+
}
1361+
const date = this.toDate(args[0]);
1362+
if (!date) {
1363+
return { value: null, error: 'YEAR argument must be a valid date' };
1364+
}
1365+
return { value: date.getFullYear(), error: undefined };
1366+
}
1367+
1368+
private calculateMonth(args: unknown[]): { value: unknown; error?: string } {
1369+
if (args.length !== 1) {
1370+
return { value: null, error: 'MONTH requires exactly 1 argument' };
1371+
}
1372+
const date = this.toDate(args[0]);
1373+
if (!date) {
1374+
return { value: null, error: 'MONTH argument must be a valid date' };
1375+
}
1376+
// JavaScript month is 0-based; Excel-style is 1-based
1377+
return { value: date.getMonth() + 1, error: undefined };
1378+
}
1379+
1380+
private calculateDay(args: unknown[]): { value: unknown; error?: string } {
1381+
if (args.length !== 1) {
1382+
return { value: null, error: 'DAY requires exactly 1 argument' };
1383+
}
1384+
const date = this.toDate(args[0]);
1385+
if (!date) {
1386+
return { value: null, error: 'DAY argument must be a valid date' };
1387+
}
1388+
return { value: date.getDate(), error: undefined };
1389+
}
1390+
1391+
private calculateHour(args: unknown[]): { value: unknown; error?: string } {
1392+
if (args.length !== 1) {
1393+
return { value: null, error: 'HOUR requires exactly 1 argument' };
1394+
}
1395+
const date = this.toDate(args[0]);
1396+
if (!date) {
1397+
return { value: null, error: 'HOUR argument must be a valid date' };
1398+
}
1399+
return { value: date.getHours(), error: undefined };
1400+
}
1401+
1402+
private calculateMinute(args: unknown[]): { value: unknown; error?: string } {
1403+
if (args.length !== 1) {
1404+
return { value: null, error: 'MINUTE requires exactly 1 argument' };
1405+
}
1406+
const date = this.toDate(args[0]);
1407+
if (!date) {
1408+
return { value: null, error: 'MINUTE argument must be a valid date' };
1409+
}
1410+
return { value: date.getMinutes(), error: undefined };
1411+
}
1412+
1413+
private calculateSecond(args: unknown[]): { value: unknown; error?: string } {
1414+
if (args.length !== 1) {
1415+
return { value: null, error: 'SECOND requires exactly 1 argument' };
1416+
}
1417+
const date = this.toDate(args[0]);
1418+
if (!date) {
1419+
return { value: null, error: 'SECOND argument must be a valid date' };
1420+
}
1421+
return { value: date.getSeconds(), error: undefined };
1422+
}
1423+
12441424
private flattenArgs(args: unknown[]): unknown[] {
12451425
const result: unknown[] = [];
12461426
for (const arg of args) {

0 commit comments

Comments
 (0)