Skip to content

Commit a7e9b3f

Browse files
[sync] test(v2-core): expand table spec visitor coverage (#1409) (#2742)
Synced from teableio/teable-ee@6987d4a Co-authored-by: nichenqin <nichenqin@hotmail.com>
1 parent 49401e9 commit a7e9b3f

15 files changed

Lines changed: 4969 additions & 69 deletions
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { DeleteFieldsCommand } from './DeleteFieldsCommand';
4+
5+
const baseId = `bse${'a'.repeat(16)}`;
6+
const tableId = `tbl${'b'.repeat(16)}`;
7+
const fieldIdA = `fld${'c'.repeat(16)}`;
8+
const fieldIdB = `fld${'d'.repeat(16)}`;
9+
10+
describe('DeleteFieldsCommand', () => {
11+
it('creates a command and dedupes field ids while preserving order', () => {
12+
const commandResult = DeleteFieldsCommand.create({
13+
baseId,
14+
tableId,
15+
fieldIds: [fieldIdA, fieldIdB, fieldIdA],
16+
});
17+
18+
expect(commandResult.isOk()).toBe(true);
19+
if (commandResult.isErr()) {
20+
return;
21+
}
22+
23+
const command = commandResult.value;
24+
expect(command.baseId.toString()).toBe(baseId);
25+
expect(command.tableId.toString()).toBe(tableId);
26+
expect(command.fieldIds.map((fieldId) => fieldId.toString())).toEqual([fieldIdA, fieldIdB]);
27+
});
28+
29+
it('rejects invalid input', () => {
30+
const commandResult = DeleteFieldsCommand.create({
31+
baseId,
32+
tableId,
33+
fieldIds: [],
34+
});
35+
36+
expect(commandResult.isErr()).toBe(true);
37+
});
38+
});
Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
import { err, ok } from 'neverthrow';
2+
import type { Result } from 'neverthrow';
3+
import { describe, expect, it } from 'vitest';
4+
5+
import type { FieldUndoRedoSnapshotService } from '../application/services/FieldUndoRedoSnapshotService';
6+
import type { UndoRedoService } from '../application/services/UndoRedoService';
7+
import { BaseId } from '../domain/base/BaseId';
8+
import { ActorId } from '../domain/shared/ActorId';
9+
import { domainError, type DomainError } from '../domain/shared/DomainError';
10+
import type { IDomainEvent } from '../domain/shared/DomainEvent';
11+
import { DomainEventName } from '../domain/shared/DomainEventName';
12+
import { OccurredAt } from '../domain/shared/OccurredAt';
13+
import type { ISpecification } from '../domain/shared/specification/ISpecification';
14+
import { FieldId } from '../domain/table/fields/FieldId';
15+
import { FieldName } from '../domain/table/fields/FieldName';
16+
import type { ITableSpecVisitor } from '../domain/table/specs/ITableSpecVisitor';
17+
import { Table } from '../domain/table/Table';
18+
import { TableId } from '../domain/table/TableId';
19+
import { TableName } from '../domain/table/TableName';
20+
import type { TableSortKey } from '../domain/table/TableSortKey';
21+
import type { ICommandBus } from '../ports/CommandBus';
22+
import type { IExecutionContext } from '../ports/ExecutionContext';
23+
import type { IFindOptions } from '../ports/RepositoryQuery';
24+
import type { ITableRepository } from '../ports/TableRepository';
25+
import {
26+
composeUndoRedoCommands,
27+
createUndoRedoCommand,
28+
flattenUndoRedoCommands,
29+
} from '../ports/UndoRedoStore';
30+
import { DeleteFieldCommand } from './DeleteFieldCommand';
31+
import { DeleteFieldResult } from './DeleteFieldHandler';
32+
import { DeleteFieldsCommand } from './DeleteFieldsCommand';
33+
import { DeleteFieldsHandler } from './DeleteFieldsHandler';
34+
35+
const createContext = (): IExecutionContext => ({
36+
actorId: ActorId.create('system')._unsafeUnwrap(),
37+
});
38+
39+
const createBaseId = (seed: string) => BaseId.create(`bse${seed.repeat(16)}`)._unsafeUnwrap();
40+
const createTableId = (seed: string) => TableId.create(`tbl${seed.repeat(16)}`)._unsafeUnwrap();
41+
const createFieldId = (seed: string) => FieldId.create(`fld${seed.repeat(16)}`)._unsafeUnwrap();
42+
43+
const buildEvent = (name = 'Field deleted'): IDomainEvent => ({
44+
name: DomainEventName.fieldDeleted(),
45+
occurredAt: OccurredAt.now(),
46+
payload: { name },
47+
});
48+
49+
const buildTable = (baseId: BaseId, tableId: TableId, fieldIds: readonly FieldId[]) => {
50+
const builder = Table.builder()
51+
.withBaseId(baseId)
52+
.withId(tableId)
53+
.withName(TableName.create('Delete Fields Table')._unsafeUnwrap());
54+
builder
55+
.field()
56+
.singleLineText()
57+
.withId(createFieldId('p'))
58+
.withName(FieldName.create('Primary')._unsafeUnwrap())
59+
.primary()
60+
.done();
61+
62+
for (const [index, fieldId] of fieldIds.entries()) {
63+
builder
64+
.field()
65+
.number()
66+
.withId(fieldId)
67+
.withName(FieldName.create(`Field ${index + 1}`)._unsafeUnwrap())
68+
.done();
69+
}
70+
71+
builder.view().defaultGrid().done();
72+
return builder.build()._unsafeUnwrap();
73+
};
74+
75+
class FakeTableRepository implements ITableRepository {
76+
constructor(private readonly table: Table) {}
77+
78+
async insert(_: IExecutionContext, table: Table): Promise<Result<Table, DomainError>> {
79+
return ok(table);
80+
}
81+
82+
async insertMany(
83+
_: IExecutionContext,
84+
tables: ReadonlyArray<Table>
85+
): Promise<Result<ReadonlyArray<Table>, DomainError>> {
86+
return ok([...tables]);
87+
}
88+
89+
async findOne(
90+
_: IExecutionContext,
91+
__: ISpecification<Table, ITableSpecVisitor>
92+
): Promise<Result<Table, DomainError>> {
93+
return ok(this.table);
94+
}
95+
96+
async find(
97+
_: IExecutionContext,
98+
__: ISpecification<Table, ITableSpecVisitor>,
99+
___?: IFindOptions<TableSortKey>
100+
): Promise<Result<ReadonlyArray<Table>, DomainError>> {
101+
return ok([this.table]);
102+
}
103+
104+
async updateOne(
105+
_: IExecutionContext,
106+
__: Table,
107+
___: ISpecification<Table, ITableSpecVisitor>
108+
): Promise<Result<void, DomainError>> {
109+
return ok(undefined);
110+
}
111+
112+
async delete(_: IExecutionContext, __: Table): Promise<Result<void, DomainError>> {
113+
return ok(undefined);
114+
}
115+
}
116+
117+
class FakeCommandBus implements ICommandBus {
118+
calls: DeleteFieldCommand[] = [];
119+
120+
constructor(private readonly results: Array<Result<DeleteFieldResult, DomainError>>) {}
121+
122+
async execute<TCommand, TResult>(
123+
_: IExecutionContext,
124+
command: TCommand
125+
): Promise<Result<TResult, DomainError>> {
126+
this.calls.push(command as DeleteFieldCommand);
127+
const next = this.results.shift();
128+
if (!next) {
129+
return err(domainError.invariant({ message: 'Missing nested delete result' }));
130+
}
131+
return next as Result<TResult, DomainError>;
132+
}
133+
}
134+
135+
class FakeFieldUndoRedoSnapshotService {
136+
calls: string[] = [];
137+
138+
async capture(_: IExecutionContext, __: Table, fieldId: FieldId) {
139+
this.calls.push(fieldId.toString());
140+
return ok({
141+
field: {
142+
id: fieldId.toString(),
143+
name: `Snapshot ${fieldId.toString()}`,
144+
type: 'singleLineText',
145+
},
146+
views: [],
147+
});
148+
}
149+
}
150+
151+
class FakeUndoRedoService {
152+
entries: Array<{
153+
tableId: TableId;
154+
entry: {
155+
undoCommand: unknown;
156+
redoCommand: unknown;
157+
};
158+
}> = [];
159+
160+
async recordEntry(
161+
_: IExecutionContext,
162+
tableId: TableId,
163+
entry: { undoCommand: unknown; redoCommand: unknown }
164+
) {
165+
this.entries.push({ tableId, entry });
166+
return ok(undefined);
167+
}
168+
}
169+
170+
const buildApplyFieldSnapshotLeaf = (baseId: BaseId, tableId: TableId, fieldId: FieldId) =>
171+
createUndoRedoCommand('ApplyFieldSnapshot', {
172+
baseId: baseId.toString(),
173+
tableId: tableId.toString(),
174+
snapshot: {
175+
field: {
176+
id: fieldId.toString(),
177+
name: `Snapshot ${fieldId.toString()}`,
178+
type: 'singleLineText',
179+
},
180+
views: [],
181+
},
182+
});
183+
184+
describe('DeleteFieldsHandler', () => {
185+
it('deletes fields, dedupes related snapshots, and records undo/redo commands', async () => {
186+
const baseId = createBaseId('a');
187+
const tableId = createTableId('a');
188+
const targetFieldA = createFieldId('b');
189+
const targetFieldB = createFieldId('c');
190+
const relatedFieldX = createFieldId('d');
191+
const relatedFieldY = createFieldId('e');
192+
const initialTable = buildTable(baseId, tableId, [targetFieldA, targetFieldB, relatedFieldX]);
193+
const latestTable = buildTable(baseId, tableId, [relatedFieldY]);
194+
195+
const nestedResults: Array<Result<DeleteFieldResult, DomainError>> = [
196+
ok(
197+
DeleteFieldResult.create(
198+
initialTable,
199+
[buildEvent('first')],
200+
composeUndoRedoCommands([
201+
buildApplyFieldSnapshotLeaf(baseId, tableId, targetFieldA),
202+
buildApplyFieldSnapshotLeaf(baseId, tableId, relatedFieldX),
203+
createUndoRedoCommand('DeleteField', {
204+
baseId: baseId.toString(),
205+
tableId: tableId.toString(),
206+
fieldId: targetFieldA.toString(),
207+
}),
208+
]),
209+
createUndoRedoCommand('DeleteField', {
210+
baseId: baseId.toString(),
211+
tableId: tableId.toString(),
212+
fieldId: targetFieldA.toString(),
213+
})
214+
)
215+
),
216+
ok(
217+
DeleteFieldResult.create(
218+
latestTable,
219+
[buildEvent('second')],
220+
composeUndoRedoCommands([
221+
buildApplyFieldSnapshotLeaf(baseId, tableId, targetFieldB),
222+
buildApplyFieldSnapshotLeaf(baseId, tableId, relatedFieldX),
223+
buildApplyFieldSnapshotLeaf(baseId, tableId, relatedFieldY),
224+
]),
225+
createUndoRedoCommand('DeleteField', {
226+
baseId: baseId.toString(),
227+
tableId: tableId.toString(),
228+
fieldId: targetFieldB.toString(),
229+
})
230+
)
231+
),
232+
];
233+
234+
const commandBus = new FakeCommandBus(nestedResults);
235+
const tableRepository = new FakeTableRepository(initialTable);
236+
const snapshotService = new FakeFieldUndoRedoSnapshotService();
237+
const undoRedoService = new FakeUndoRedoService();
238+
const handler = new DeleteFieldsHandler(
239+
commandBus,
240+
tableRepository,
241+
snapshotService as unknown as FieldUndoRedoSnapshotService,
242+
undoRedoService as unknown as UndoRedoService
243+
);
244+
245+
const commandResult = DeleteFieldsCommand.create({
246+
baseId: baseId.toString(),
247+
tableId: tableId.toString(),
248+
fieldIds: [targetFieldA.toString(), targetFieldB.toString(), targetFieldA.toString()],
249+
});
250+
expect(commandResult.isOk()).toBe(true);
251+
if (commandResult.isErr()) {
252+
return;
253+
}
254+
255+
const result = await handler.handle(createContext(), commandResult.value);
256+
257+
expect(result.isOk()).toBe(true);
258+
if (result.isErr()) {
259+
return;
260+
}
261+
262+
expect(commandBus.calls).toHaveLength(2);
263+
expect(commandBus.calls.map((command) => command.fieldId.toString())).toEqual([
264+
targetFieldA.toString(),
265+
targetFieldB.toString(),
266+
]);
267+
expect(commandBus.calls.every((command) => command.skipUndoRedo())).toBe(true);
268+
expect(snapshotService.calls).toEqual([targetFieldA.toString(), targetFieldB.toString()]);
269+
expect(result.value.table).toBe(latestTable);
270+
expect(result.value.events).toHaveLength(2);
271+
272+
expect(undoRedoService.entries).toHaveLength(1);
273+
const entry = undoRedoService.entries[0];
274+
expect(entry.tableId.equals(latestTable.id())).toBe(true);
275+
276+
const undoLeaves = flattenUndoRedoCommands(entry.entry.undoCommand as any);
277+
expect(
278+
undoLeaves
279+
.filter((leaf) => leaf.type === 'ApplyFieldSnapshot')
280+
.map((leaf) => leaf.payload.snapshot.field.id)
281+
).toEqual([
282+
targetFieldA.toString(),
283+
targetFieldB.toString(),
284+
relatedFieldX.toString(),
285+
relatedFieldY.toString(),
286+
]);
287+
288+
const redoLeaves = flattenUndoRedoCommands(entry.entry.redoCommand as any);
289+
expect(redoLeaves.map((leaf) => leaf.type)).toEqual(['DeleteField', 'DeleteField']);
290+
expect(redoLeaves.map((leaf) => leaf.payload.fieldId)).toEqual([
291+
targetFieldA.toString(),
292+
targetFieldB.toString(),
293+
]);
294+
});
295+
296+
it('returns the nested delete error and skips undo/redo recording', async () => {
297+
const baseId = createBaseId('f');
298+
const tableId = createTableId('f');
299+
const targetFieldA = createFieldId('g');
300+
const targetFieldB = createFieldId('h');
301+
const initialTable = buildTable(baseId, tableId, [targetFieldA, targetFieldB]);
302+
303+
const commandBus = new FakeCommandBus([
304+
err(domainError.validation({ message: 'nested delete failed' })),
305+
]);
306+
const tableRepository = new FakeTableRepository(initialTable);
307+
const snapshotService = new FakeFieldUndoRedoSnapshotService();
308+
const undoRedoService = new FakeUndoRedoService();
309+
const handler = new DeleteFieldsHandler(
310+
commandBus,
311+
tableRepository,
312+
snapshotService as unknown as FieldUndoRedoSnapshotService,
313+
undoRedoService as unknown as UndoRedoService
314+
);
315+
316+
const commandResult = DeleteFieldsCommand.create({
317+
baseId: baseId.toString(),
318+
tableId: tableId.toString(),
319+
fieldIds: [targetFieldA.toString(), targetFieldB.toString()],
320+
});
321+
expect(commandResult.isOk()).toBe(true);
322+
if (commandResult.isErr()) {
323+
return;
324+
}
325+
326+
const result = await handler.handle(createContext(), commandResult.value);
327+
328+
expect(result.isErr()).toBe(true);
329+
expect(result._unsafeUnwrapErr().message).toContain('nested delete failed');
330+
expect(undoRedoService.entries).toHaveLength(0);
331+
});
332+
});

0 commit comments

Comments
 (0)