Change detection on group and item level

This commit is contained in:
Johannes Zillmann 2021-02-28 02:07:45 +01:00
parent 229cb53eb0
commit e7574513c5
26 changed files with 562 additions and 241 deletions

View File

@ -1,10 +1,12 @@
import Item from './Item'; import Item from './Item';
import ItemTransformer from './transformer/ItemTransformer'; import ItemTransformer from './transformer/ItemTransformer';
import TransformContext from './transformer/TransformContext'; import TransformContext from './transformer/TransformContext';
import StageResult from './debug/StageResult'; import StageResult, { initialStage } from './debug/StageResult';
import ColumnAnnotation from './debug/ColumnAnnotation'; import ColumnAnnotation from './debug/ColumnAnnotation';
import AnnotatedColumn from './debug/AnnotatedColumn'; import AnnotatedColumn from './debug/AnnotatedColumn';
import { detectChanges } from './debug/detectChanges'; import { detectChanges } from './debug/detectChanges';
import { asPages } from './debug/Page';
import ChangeTracker from './debug/ChangeTracker';
export default class Debugger { export default class Debugger {
private context: TransformContext; private context: TransformContext;
@ -16,42 +18,36 @@ export default class Debugger {
this.transformers = transformers; this.transformers = transformers;
this.context = context; this.context = context;
this.stageNames = ['Parse Result', ...transformers.map((t) => t.name)]; this.stageNames = ['Parse Result', ...transformers.map((t) => t.name)];
this.stageResultCache = [ this.stageResultCache = [initialStage(inputSchema, inputItems)];
{
schema: inputSchema.map((column) => ({ name: column })),
items: inputItems,
changes: new Map(),
messages: [
`Parsed ${inputItems.length === 0 ? 0 : inputItems[inputItems.length - 1].page + 1} pages with ${
inputItems.length
} items`,
],
},
];
} }
//TODO return MarkedItem ? (removed, added, etc..)?
stageResults(stageIndex: number): StageResult { stageResults(stageIndex: number): StageResult {
for (let idx = 0; idx < stageIndex + 1; idx++) { for (let idx = 0; idx < stageIndex + 1; idx++) {
if (!this.stageResultCache[idx]) { if (!this.stageResultCache[idx]) {
const transformer = this.transformers[idx - 1]; const transformer = this.transformers[idx - 1];
const previousStageResult: StageResult = this.stageResultCache[idx - 1]; const previousStageResult: StageResult = this.stageResultCache[idx - 1];
const previousItems = previousStageResult.itemsUnpacked();
const inputSchema = toSimpleSchema(previousStageResult); const inputSchema = toSimpleSchema(previousStageResult);
const outputSchema = transformer.schemaTransformer(inputSchema); const outputSchema = transformer.schemaTransformer(inputSchema);
const itemResult = transformer.transform(this.context, [...previousStageResult.items]); const itemResult = transformer.transform(this.context, [...previousItems]);
const changes = detectChanges(previousStageResult.items, itemResult.items); const changeTracker = new ChangeTracker();
detectChanges(changeTracker, previousItems, itemResult.items);
const stageResult = { const pages = asPages(changeTracker, itemResult.items, transformer.descriptor.debug?.itemMerger);
descriptor: transformer.descriptor, const messages = itemResult.messages;
schema: toAnnotatedSchema(inputSchema, outputSchema), if (changeTracker.changeCount() > 0) {
...itemResult, messages.unshift(`Detected ${changeTracker.changeCount()} changes`);
changes,
};
if (changes.size > 0) {
stageResult.messages.unshift(`Detected ${changes.size} changes`);
} }
this.stageResultCache.push(stageResult);
this.stageResultCache.push(
new StageResult(
transformer.descriptor,
toAnnotatedSchema(inputSchema, outputSchema),
pages,
changeTracker,
messages,
),
);
} }
} }
return this.stageResultCache[stageIndex]; return this.stageResultCache[stageIndex];

View File

@ -1,4 +1,4 @@
import type ItemMerger from './support/ItemMerger'; import type ItemMerger from './debug/ItemMerger';
interface Debug { interface Debug {
/** /**

View File

@ -4,6 +4,12 @@ export function assert(condition: boolean, message: string) {
} }
} }
export function assertNot(condition: boolean, message: string) {
if (condition) {
throw new Error(message || 'Assertion failed');
}
}
export function assertDefined<T>(value: T | undefined, message: string): T { export function assertDefined<T>(value: T | undefined, message: string): T {
if (value === null || typeof value === 'undefined') { if (value === null || typeof value === 'undefined') {
throw new Error(message || 'Assertion failed'); throw new Error(message || 'Assertion failed');

View File

@ -0,0 +1,78 @@
import Item from '../Item';
export default interface ChangeIndex {
/**
* Return the number of tracked changes.
*/
changeCount(): number;
/**
* Returns the change if for the given item if there is any
* @param item
*/
change(item: Item): Change | undefined;
/**
* Returs true if the given item has changed
* @param item
*/
hasChanged(item: Item): boolean;
/**
* Returns true if there is a change and it's category is ChangeCategory.PLUS
* @param item
*/
isPlusChange(item: Item): boolean;
/**
* Returns true if there is a change and it's category is ChangeCategory.NEUTRAL
* @param item
*/
isNeutralChange(item: Item): boolean;
/**
* Returns true if there is a change and it's category is ChangeCategory.MINUS
* @param item
*/
isMinusChange(item: Item): boolean;
}
export abstract class Change {
constructor(public category: ChangeCategory) {}
}
// This is merely for coloring different kind of changes
export enum ChangeCategory {
PLUS = 'PLUS',
MINUS = 'MINUS',
NEUTRAL = 'NEUTRAL',
}
export class Addition extends Change {
constructor() {
super(ChangeCategory.PLUS);
}
}
export class Removal extends Change {
constructor() {
super(ChangeCategory.MINUS);
}
}
export class ContentChange extends Change {
constructor() {
super(ChangeCategory.NEUTRAL);
}
}
export enum Direction {
UP = 'UP',
DOWN = 'DOWN',
}
export class PositionChange extends Change {
constructor(public direction: Direction, public amount: number) {
super(direction === Direction.UP ? ChangeCategory.PLUS : ChangeCategory.MINUS);
}
}

View File

@ -0,0 +1,71 @@
import ChangeIndex, {
Change,
ChangeCategory,
Addition,
Removal,
PositionChange,
ContentChange,
Direction,
} from './ChangeIndex';
import Item from '../Item';
import { assertNot, assertDefined } from '../assert';
const ADDITION = new Addition();
const REMOVAL = new Removal();
const CONTENT_CHANGE = new ContentChange();
export default class ChangeTracker implements ChangeIndex {
private changes: Map<string, Change> = new Map();
private addChange(item: Item, change: Change) {
const uuid = _uuid(item);
assertNot(this.changes.has(uuid), `Change for item ${uuid} already defined`);
this.changes.set(uuid, change);
}
changeCount(): number {
return this.changes.size;
}
trackAddition(item: Item) {
this.addChange(item, ADDITION);
}
trackRemoval(item: Item) {
this.addChange(item, REMOVAL);
}
trackPositionalChange(item: Item, oldPosition: number, newPosition: number) {
const direction = newPosition > oldPosition ? Direction.DOWN : Direction.UP;
const amount = Math.abs(newPosition - oldPosition);
this.addChange(item, new PositionChange(direction, amount));
}
trackContentChange(item: Item) {
this.addChange(item, CONTENT_CHANGE);
}
change(item: Item): Change | undefined {
return this.changes.get(_uuid(item));
}
hasChanged(item: Item): boolean {
return this.changes.has(_uuid(item));
}
isPlusChange(item: Item): boolean {
return this.change(item)?.category === ChangeCategory.PLUS;
}
isNeutralChange(item: Item): boolean {
return this.change(item)?.category === ChangeCategory.NEUTRAL;
}
isMinusChange(item: Item): boolean {
return this.change(item)?.category === ChangeCategory.MINUS;
}
}
function _uuid(item: Item): string {
return assertDefined(item.uuid, 'UUID is not set');
}

View File

@ -1,4 +1,4 @@
import type Item from '../Item'; import type Item from '..//Item';
export default class ItemGroup { export default class ItemGroup {
top: Item; top: Item;
@ -12,4 +12,11 @@ export default class ItemGroup {
hasMany(): boolean { hasMany(): boolean {
return this.elements.length > 0; return this.elements.length > 0;
} }
unpacked(): Item[] {
if (this.elements.length > 0) {
return this.elements;
}
return [this.top];
}
} }

View File

@ -1,3 +1,4 @@
import type ChangeTracker from './ChangeTracker';
import type Item from '../Item'; import type Item from '../Item';
/** /**
@ -5,5 +6,5 @@ import type Item from '../Item';
*/ */
export default abstract class ItemMerger { export default abstract class ItemMerger {
constructor(public groupKey: string) {} constructor(public groupKey: string) {}
abstract merge(items: Item[]): Item; abstract merge(tracker: ChangeTracker, items: Item[]): Item;
} }

View File

@ -1,12 +1,13 @@
import ItemMerger from './ItemMerger'; import ItemMerger from './ItemMerger';
import Item from '../Item'; import Item from '../Item';
import ChangeTracker from './ChangeTracker';
export default class LineItemMerger extends ItemMerger { export default class LineItemMerger extends ItemMerger {
constructor() { constructor(private trackAsNew = false) {
super('line'); super('line');
} }
merge(items: Item[]): Item { merge(tracker: ChangeTracker, items: Item[]): Item {
const page = items[0].page; const page = items[0].page;
const line = items[0].data['line']; const line = items[0].data['line'];
const str = items.map((item) => item.data['str']).join(' '); const str = items.map((item) => item.data['str']).join(' ');
@ -16,7 +17,7 @@ export default class LineItemMerger extends ItemMerger {
const height = Math.max(...items.map((item) => item.data['height'])); const height = Math.max(...items.map((item) => item.data['height']));
const fontNames = [...new Set(items.map((item) => item.data['fontName']))]; const fontNames = [...new Set(items.map((item) => item.data['fontName']))];
const directions = [...new Set(items.map((item) => item.data['dir']))]; const directions = [...new Set(items.map((item) => item.data['dir']))];
return new Item(page, { const newItem = new Item(page, {
str, str,
line, line,
x, x,
@ -26,5 +27,12 @@ export default class LineItemMerger extends ItemMerger {
fontName: fontNames, fontName: fontNames,
dir: directions, dir: directions,
}); });
if (this.trackAsNew) {
tracker.trackAddition(newItem);
} else if (items.find((item) => tracker.hasChanged(item))) {
tracker.trackContentChange(newItem);
}
return newItem;
} }
} }

29
core/src/debug/Page.ts Normal file
View File

@ -0,0 +1,29 @@
import Item from '../Item';
import { groupByElement, groupByPage } from '../support/groupingUtils';
import ChangeTracker from './ChangeTracker';
import ItemGroup from './ItemGroup';
import ItemMerger from './ItemMerger';
export default interface Page {
index: number;
itemGroups: ItemGroup[];
}
export function asPages(tracker: ChangeTracker, items: Item[], itemMerger?: ItemMerger): Page[] {
return groupByPage(items).map((pageItems: Item[]) => {
let itemGroups: ItemGroup[];
if (itemMerger) {
itemGroups = groupByElement(pageItems, itemMerger.groupKey).map((groupItems) => {
if (groupItems.length > 1) {
const top = itemMerger.merge(tracker, groupItems);
return new ItemGroup(top, groupItems);
} else {
return new ItemGroup(groupItems[0]);
}
});
} else {
itemGroups = pageItems.map((item) => new ItemGroup(item));
}
return { index: pageItems[0].page, itemGroups } as Page;
});
}

View File

@ -1,12 +1,35 @@
import TransformDescriptor from '../TransformDescriptor'; import TransformDescriptor, { toDescriptor } from '../TransformDescriptor';
import Item from '../Item';
import AnnotatedColumn from './AnnotatedColumn'; import AnnotatedColumn from './AnnotatedColumn';
import { Change } from './detectChanges'; import Item from '../Item';
import Page, { asPages } from './Page';
import ChangeIndex from './ChangeIndex';
import ChangeTracker from './ChangeTracker';
export default interface StageResult { export default class StageResult {
descriptor?: TransformDescriptor; constructor(
schema: AnnotatedColumn[]; public descriptor: TransformDescriptor,
items: Item[]; public schema: AnnotatedColumn[],
changes: Map<string, Change>; public pages: Page[],
messages: string[]; public changes: ChangeIndex,
public messages: string[],
) {}
itemsUnpacked(): Item[] {
return this.pages.reduce((items: Item[], page: Page) => {
page.itemGroups.forEach((itemGroup) => itemGroup.unpacked().forEach((item) => items.push(item)));
return items;
}, []);
}
}
export function initialStage(inputSchema: string[], inputItems: Item[]): StageResult {
const schema = inputSchema.map((column) => ({ name: column }));
const tracker = new ChangeTracker();
const pages = asPages(tracker, inputItems);
const messages = [
`Parsed ${inputItems.length === 0 ? 0 : inputItems[inputItems.length - 1].page + 1} pages with ${
inputItems.length
} items`,
];
return new StageResult(toDescriptor({}), schema, pages, tracker, messages);
} }

View File

@ -1,67 +1,26 @@
import ChangeTracker from './ChangeTracker';
import { assertDefined } from '../assert'; import { assertDefined } from '../assert';
import Item from '../Item'; import Item from '../Item';
// export enum ChangeType {
// POSITION_CHANGED = 'POSITION_CHANGED',
// ADDED = 'ADDED',
// REMOVED = 'REMOVED',
// CHANGED = 'CHANGED',
// EVALUATED = 'EVALUATED',
// }
// Marker interface for a change
export abstract class Change {
constructor(public category: ChangeCategory) {}
}
// This is merely for coloring different kind of changes
export enum ChangeCategory {
PLUS = 'PLUS',
MINUS = 'MINUS',
NEUTRAL = 'NEUTRAL',
}
export class Addition extends Change {
constructor() {
super(ChangeCategory.PLUS);
}
}
export enum Direction {
UP = 'UP',
DOWN = 'DOWN',
}
export class PositionChange extends Change {
constructor(public direction: Direction, public amount: number) {
super(direction === Direction.UP ? ChangeCategory.PLUS : ChangeCategory.NEUTRAL);
}
}
/** /**
* Compares incomming and outgoing items of a transformer in order to detect changes and to display them in any debug visualization. * Compares incomming and outgoing items of a transformer in order to detect changes and to display them in any debug visualization.
*/ */
export function detectChanges(inputItems: Item[], transformedItems: Item[]): Map<string, Change> { export function detectChanges(tracker: ChangeTracker, inputItems: Item[], transformedItems: Item[]) {
const changes: Map<string, Change> = new Map();
const inputItemsByUuid = inputMap(inputItems); const inputItemsByUuid = inputMap(inputItems);
transformedItems.forEach((item, idx) => { transformedItems.forEach((item, idx) => {
const uuid = getUuid(item); const uuid = _uuid(item);
const oldItem = inputItemsByUuid.get(uuid); const oldItem = inputItemsByUuid.get(uuid);
if (oldItem) { if (oldItem) {
if (idx !== oldItem.position) { if (idx !== oldItem.position) {
const direction = idx > oldItem.position ? Direction.DOWN : Direction.UP; tracker.trackPositionalChange(item, oldItem.position, idx);
const amount = Math.abs(idx - oldItem.position);
changes.set(uuid, new PositionChange(direction, amount));
} }
//TODO check for change //TODO check for change
} else { } else {
changes.set(uuid, new Addition()); tracker.trackAddition(item);
} }
}); });
//TODO detect removals (need to re-add them ?) //TODO detect removals (need to re-add them ?)
return changes;
} }
interface InputItem { interface InputItem {
@ -71,11 +30,11 @@ interface InputItem {
function inputMap(inputItems: Item[]): Map<string, InputItem> { function inputMap(inputItems: Item[]): Map<string, InputItem> {
return inputItems.reduce((map, item, idx) => { return inputItems.reduce((map, item, idx) => {
map.set(getUuid(item), { item, position: idx }); map.set(_uuid(item), { item, position: idx });
return map; return map;
}, new Map()); }, new Map());
} }
function getUuid(item: Item): string { function _uuid(item: Item): string {
return assertDefined(item.uuid, 'UUID is not set'); return assertDefined(item.uuid, 'UUID is not set');
} }

View File

@ -1,6 +0,0 @@
import type ItemGroup from './ItemGroup';
export default interface Page {
index: number;
itemGroups: ItemGroup[];
}

View File

@ -1,7 +1,8 @@
import ItemMerger from './ItemMerger'; import ItemMerger from '../debug/ItemMerger';
import Item from '../Item'; import Item from '../Item';
import ItemGroup from './ItemGroup'; import ItemGroup from '../debug/ItemGroup';
import Page from './Page'; import Page from '../debug/Page';
import ChangeTracker from '../debug/ChangeTracker';
type KeyExtractor = (item: Item) => any; type KeyExtractor = (item: Item) => any;
type PageItemTransformer = (page: number, items: Item[]) => Item[]; type PageItemTransformer = (page: number, items: Item[]) => Item[];
@ -42,22 +43,3 @@ export function transformGroupedByPageAndLine(items: Item[], groupedTransformer:
}); });
return transformedItems; return transformedItems;
} }
export function asPages(items: Item[], itemMerger?: ItemMerger): Page[] {
return groupByPage(items).map((pageItems: Item[]) => {
let itemGroups: ItemGroup[];
if (itemMerger) {
itemGroups = groupByElement(pageItems, itemMerger.groupKey).map((groupItems) => {
if (groupItems.length > 1) {
const top = itemMerger.merge(groupItems);
return new ItemGroup(top, groupItems);
} else {
return new ItemGroup(groupItems[0]);
}
});
} else {
itemGroups = pageItems.map((item) => new ItemGroup(item));
}
return { index: pageItems[0].page, itemGroups } as Page;
});
}

View File

@ -1,9 +1,8 @@
import PageViewport from '../parse/PageViewport';
import Item from '../Item'; import Item from '../Item';
import ItemResult from '../ItemResult'; import ItemResult from '../ItemResult';
import ItemTransformer from './ItemTransformer'; import ItemTransformer from './ItemTransformer';
import TransformContext from './TransformContext'; import TransformContext from './TransformContext';
import { transformGroupedByPage } from '../support/itemUtils'; import { transformGroupedByPage } from '../support/groupingUtils';
export default class AdjustHeight extends ItemTransformer { export default class AdjustHeight extends ItemTransformer {
constructor() { constructor() {

View File

@ -2,8 +2,8 @@ import Item from '../Item';
import ItemResult from '../ItemResult'; import ItemResult from '../ItemResult';
import ItemTransformer from './ItemTransformer'; import ItemTransformer from './ItemTransformer';
import TransformContext from './TransformContext'; import TransformContext from './TransformContext';
import { transformGroupedByPage } from '../support/itemUtils'; import LineItemMerger from '../debug/LineItemMerger';
import LineItemMerger from '../support/LineItemMerger'; import { transformGroupedByPage } from '../support/groupingUtils';
export default class CompactLines extends ItemTransformer { export default class CompactLines extends ItemTransformer {
constructor() { constructor() {
@ -13,7 +13,7 @@ export default class CompactLines extends ItemTransformer {
{ {
requireColumns: ['str', 'y', 'height'], requireColumns: ['str', 'y', 'height'],
debug: { debug: {
itemMerger: new LineItemMerger(), itemMerger: new LineItemMerger(true),
}, },
}, },
(incomingSchema) => { (incomingSchema) => {

View File

@ -2,8 +2,8 @@ import Item from '../Item';
import ItemResult from '../ItemResult'; import ItemResult from '../ItemResult';
import ItemTransformer from './ItemTransformer'; import ItemTransformer from './ItemTransformer';
import TransformContext from './TransformContext'; import TransformContext from './TransformContext';
import LineItemMerger from '../support/LineItemMerger'; import LineItemMerger from '../debug/LineItemMerger';
import { transformGroupedByPageAndLine } from '../support/itemUtils'; import { transformGroupedByPageAndLine } from '../support/groupingUtils';
export default class SortXWithinLines extends ItemTransformer { export default class SortXWithinLines extends ItemTransformer {
constructor() { constructor() {

View File

@ -3,17 +3,19 @@ import Item from 'src/Item';
import ItemTransformer from 'src/transformer/ItemTransformer'; import ItemTransformer from 'src/transformer/ItemTransformer';
import TransformDescriptor from 'src/TransformDescriptor'; import TransformDescriptor from 'src/TransformDescriptor';
import TransformContext from 'src/transformer/TransformContext'; import TransformContext from 'src/transformer/TransformContext';
import LineItemMerger from 'src/debug/LineItemMerger';
import ItemResult from 'src/ItemResult'; import ItemResult from 'src/ItemResult';
import ColumnAnnotation from 'src/debug/ColumnAnnotation'; import ColumnAnnotation from 'src/debug/ColumnAnnotation';
import AnnotatedColumn from 'src/debug/AnnotatedColumn'; import AnnotatedColumn from 'src/debug/AnnotatedColumn';
import { items } from './testItems';
class TestTransformer extends ItemTransformer { class TestTransformer extends ItemTransformer {
items: Item[]; items: Item[];
constructor(name: string, descriptor: Partial<TransformDescriptor>, outputSchema: string[], items: Item[]) { constructor(name: string, descriptor: Partial<TransformDescriptor>, outputSchema: string[], items: Item[]) {
super(name, `Description for ${name}`, descriptor, (incomingSchema) => outputSchema); super(name, `Description for ${name}`, descriptor, (_) => outputSchema);
this.items = items; this.items = items;
} }
transform(_: TransformContext, items: Item[]): ItemResult { transform(_: TransformContext, __: Item[]): ItemResult {
return { return {
items: this.items, items: this.items,
messages: [], messages: [],
@ -21,9 +23,13 @@ class TestTransformer extends ItemTransformer {
} }
} }
test('basic debug', async () => { describe('Transform Items', () => {
test('Basics', async () => {
const parsedSchema = ['A', 'B']; const parsedSchema = ['A', 'B'];
const parsedItems = [new Item(0, { A: 'a_row1', B: 'b_row1' }), new Item(0, { A: 'a_row2', B: 'b_row2' })]; const parsedItems = items(0, [
{ A: 'a_row1', B: 'b_row1' },
{ A: 'a_row2', B: 'b_row2' },
]);
const trans1Desc = { requireColumns: ['A', 'B'] }; const trans1Desc = { requireColumns: ['A', 'B'] };
const trans1Schema = ['C']; const trans1Schema = ['C'];
@ -38,10 +44,91 @@ test('basic debug', async () => {
...parsedSchema.map((column) => ({ name: column, annotation: ColumnAnnotation.REMOVED })), ...parsedSchema.map((column) => ({ name: column, annotation: ColumnAnnotation.REMOVED })),
{ name: 'C', annotation: ColumnAnnotation.ADDED }, { name: 'C', annotation: ColumnAnnotation.ADDED },
]); ]);
expect(debug.stageResults(0).items).toEqual(parsedItems);
expect(debug.stageResults(1).items).toEqual(trans1Items); expect(debug.stageResults(0).itemsUnpacked()).toEqual(parsedItems);
expect(debug.stageResults(1).itemsUnpacked()).toEqual(trans1Items);
}); });
test('Line Merge', async () => {
const parsedSchema = ['id', 'y'];
const parsedItems = items(0, [
{ id: 1, y: 1 },
{ id: 2, y: 2 },
{ id: 3, y: 2 },
]);
const trans1Desc = { requireColumns: ['id', 'y'], debug: { itemMerger: new LineItemMerger(true) } };
const trans1Schema = ['id', 'line'];
const trans1Items = parsedItems.map((item) => item.withData({ line: item.data['y'] }));
const transformers = [new TestTransformer('Trans1', trans1Desc, trans1Schema, trans1Items)];
const debug = new Debugger(parsedSchema, parsedItems, { fontMap: new Map(), pageViewports: [] }, transformers);
expect(debug.stageNames).toEqual(['Parse Result', 'Trans1']);
expect(debug.stageResults(0).schema).toEqual([{ name: 'id' }, { name: 'y' }]);
expect(debug.stageResults(1).schema).toEqual([
{ name: 'id' },
{ name: 'y', annotation: ColumnAnnotation.REMOVED },
{ name: 'line', annotation: ColumnAnnotation.ADDED },
]);
expect(debug.stageResults(0).itemsUnpacked()).toEqual(parsedItems);
expect(debug.stageResults(1).itemsUnpacked()).toEqual(trans1Items);
const lineMergingStage = debug.stageResults(1);
const { changes, pages } = lineMergingStage;
//verify item groups
expect(pages[0].itemGroups.map((itemGroup) => changes.hasChanged(itemGroup.top))).toEqual([false, true]);
//verify unpacked items
expect(lineMergingStage.itemsUnpacked().map((item) => changes.hasChanged(item))).toEqual([false, false, false]);
});
});
test('Change inside of Line', async () => {
const parsedSchema = ['id', 'line'];
const parsedItems = items(0, [
{ id: 1, line: 1 },
{ id: 2, line: 1 },
{ id: 3, line: 2 },
{ id: 4, line: 2 },
]);
const trans1Desc = { requireColumns: ['id', 'line'], debug: { itemMerger: new LineItemMerger() } };
const trans1Schema = parsedSchema;
const trans1Items = swapElements([...parsedItems], 0, 1);
const transformers = [new TestTransformer('Trans1', trans1Desc, trans1Schema, trans1Items)];
const debug = new Debugger(parsedSchema, parsedItems, { fontMap: new Map(), pageViewports: [] }, transformers);
expect(debug.stageNames).toEqual(['Parse Result', 'Trans1']);
expect(debug.stageResults(0).schema).toEqual([{ name: 'id' }, { name: 'line' }]);
expect(debug.stageResults(1).schema).toEqual([{ name: 'id' }, { name: 'line' }]);
expect(debug.stageResults(0).itemsUnpacked()).toEqual(parsedItems);
expect(debug.stageResults(1).itemsUnpacked()).toEqual(trans1Items);
const { changes, pages } = debug.stageResults(1);
//verify item groups
expect(pages[0].itemGroups.map((itemGroup) => changes.hasChanged(itemGroup.top))).toEqual([true, false]);
//verify unpacked items
expect(
debug
.stageResults(1)
.itemsUnpacked()
.map((item) => changes.hasChanged(item)),
).toEqual([true, true, false, false]);
});
var swapElements = function (arr: Item[], indexA: number, indexB: number): Item[] {
var temp = arr[indexA];
arr[indexA] = arr[indexB];
arr[indexB] = temp;
return arr;
};
describe('build schemas', () => { describe('build schemas', () => {
const items: Item[] = []; const items: Item[] = [];

View File

@ -1,13 +1,15 @@
import LineItemMerger from 'src/debug/LineItemMerger'; import LineItemMerger from 'src/debug/LineItemMerger';
import ChangeTracker from 'src/debug/ChangeTracker';
import Item from 'src/Item'; import Item from 'src/Item';
import { items } from '../testItems'; import { items, realisticItems } from '../testItems';
import { Addition, ContentChange } from 'src/debug/ChangeIndex';
const itemMerger = new LineItemMerger();
test('Basics', async () => { test('Basics', async () => {
const itemMerger = new LineItemMerger();
const tracker = new ChangeTracker();
expect(itemMerger.groupKey).toEqual('line'); expect(itemMerger.groupKey).toEqual('line');
const mergedItem = itemMerger.merge(
const mergedItem = itemMerger?.merge( tracker,
items(0, [ items(0, [
{ {
line: 2, line: 2,
@ -41,7 +43,7 @@ test('Basics', async () => {
}, },
]), ]),
); );
expect(mergedItem?.withoutUuid()).toEqual( expect(mergedItem.withoutUuid()).toEqual(
new Item(0, { new Item(0, {
line: 2, line: 2,
x: 240, x: 240,
@ -54,3 +56,22 @@ test('Basics', async () => {
}).withoutUuid(), }).withoutUuid(),
); );
}); });
test('Track all lines as changes', async () => {
const itemMerger = new LineItemMerger(true);
const tracker = new ChangeTracker();
const mergedItem = itemMerger.merge(tracker, realisticItems(0, [{ line: 1 }, { line: 1 }]));
expect(tracker.change(mergedItem)).toEqual(new Addition());
});
test('Mark lines containing changed items as changed', async () => {
const itemMerger = new LineItemMerger();
const tracker = new ChangeTracker();
const items1 = realisticItems(0, [{ line: 1 }, { line: 1 }]);
const items2 = realisticItems(0, [{ line: 2 }, { line: 2 }]);
tracker.trackPositionalChange(items1[1], 1, 0);
const mergedItem1 = itemMerger.merge(tracker, items1);
const mergedItem2 = itemMerger.merge(tracker, items2);
expect(tracker.change(mergedItem1)).toEqual(new ContentChange());
expect(tracker.change(mergedItem2)).toEqual(undefined);
});

View File

@ -0,0 +1,60 @@
import Item from 'src/Item';
import Page, { asPages } from 'src/debug/Page';
import ItemGroup from 'src/debug/ItemGroup';
import ItemMerger from 'src/debug/ItemMerger';
import { items } from 'test/testItems';
import ChangeTracker from 'src/debug/ChangeTracker';
test('empty', async () => {
const tracker = new ChangeTracker();
expect(asPages(tracker, [])).toEqual([]);
});
test('no merger', async () => {
const pageItems = [
items(0, [{ id: 1, line: 1 }]),
items(1, [
{ id: 2, line: 1 },
{ id: 3, line: 1 },
{ id: 4, line: 1 },
]),
items(2, [{ id: 5, line: 1 }]),
];
const flattenedItems = new Array<Item>().concat(...pageItems);
const tracker = new ChangeTracker();
const pages = asPages(tracker, flattenedItems);
expect(pages).toEqual([
{ index: 0, itemGroups: pageItems[0].map((item) => new ItemGroup(item)) },
{ index: 1, itemGroups: pageItems[1].map((item) => new ItemGroup(item)) },
{ index: 2, itemGroups: pageItems[2].map((item) => new ItemGroup(item)) },
] as Page[]);
expect(tracker.changeCount()).toEqual(0);
});
test('merger', async () => {
const pageItems = [
items(0, [{ id: 1, line: 1 }]),
items(1, [
{ id: 2, line: 1 },
{ id: 3, line: 1 },
{ id: 4, line: 2 },
]),
items(2, [{ id: 5, line: 1 }]),
];
const flattenedItems = new Array<Item>().concat(...pageItems);
const merger: ItemMerger = { groupKey: 'line', merge: (items) => items[0] };
const tracker = new ChangeTracker();
const pages = asPages(tracker, flattenedItems, merger);
expect(pages).toEqual([
{ index: 0, itemGroups: pageItems[0].map((item) => new ItemGroup(item)) },
{
index: 1,
itemGroups: [
new ItemGroup(merger.merge(tracker, pageItems[1].slice(0, 2)), pageItems[1].slice(0, 2)),
new ItemGroup(pageItems[1][2]),
],
},
{ index: 2, itemGroups: pageItems[2].map((item) => new ItemGroup(item)) },
] as Page[]);
});

View File

@ -1,4 +1,6 @@
import { detectChanges, PositionChange, Direction } from 'src/debug/detectChanges'; import ChangeTracker from 'src/debug/ChangeTracker';
import { detectChanges } from 'src/debug/detectChanges';
import { PositionChange, Direction } from 'src/debug/ChangeIndex';
import { items } from 'test/testItems'; import { items } from 'test/testItems';
test('No changes', async () => { test('No changes', async () => {
@ -9,8 +11,9 @@ test('No changes', async () => {
{ id: 'B', line: '1', x: 2 }, { id: 'B', line: '1', x: 2 },
]); ]);
const changes = detectChanges(inputItems, inputItems); const tracker = new ChangeTracker();
expect(changes).toEqual(new Map()); detectChanges(tracker, inputItems, inputItems);
expect(tracker.changeCount()).toEqual(0);
}); });
test('Positional changes', async () => { test('Positional changes', async () => {
const inputItems = items(0, [ const inputItems = items(0, [
@ -22,11 +25,9 @@ test('Positional changes', async () => {
const transformedItems = [inputItems[0], inputItems[3], inputItems[2], inputItems[1]]; const transformedItems = [inputItems[0], inputItems[3], inputItems[2], inputItems[1]];
const changes = detectChanges(inputItems, transformedItems); const tracker = new ChangeTracker();
expect(changes).toEqual( const changes = detectChanges(tracker, inputItems, transformedItems);
new Map([ expect(tracker.changeCount()).toEqual(2);
[inputItems[1].uuid, new PositionChange(Direction.DOWN, 2)], expect(tracker.change(inputItems[1])).toEqual(new PositionChange(Direction.DOWN, 2));
[inputItems[3].uuid, new PositionChange(Direction.UP, 2)], expect(tracker.change(inputItems[3])).toEqual(new PositionChange(Direction.UP, 2));
]),
);
}); });

View File

@ -1,14 +1,10 @@
import Item from 'src/Item'; import Item from 'src/Item';
import Page from 'src/support/Page';
import { import {
groupByPage, groupByPage,
groupByElement, groupByElement,
transformGroupedByPage, transformGroupedByPage,
transformGroupedByPageAndLine, transformGroupedByPageAndLine,
asPages, } from 'src/support/groupingUtils';
} from 'src/support/itemUtils';
import ItemGroup from 'src/support/ItemGroup';
import ItemMerger from 'src/support/ItemMerger';
import { items } from 'test/testItems'; import { items } from 'test/testItems';
describe('groupByPage', () => { describe('groupByPage', () => {
@ -97,46 +93,3 @@ describe('transformGroupedByPageAndLine', () => {
expect(transformedItems.map((item) => item.data['group'])).toEqual(['0/1:1', '1/1:2', '1/2:1', '2/1:1']); expect(transformedItems.map((item) => item.data['group'])).toEqual(['0/1:1', '1/1:2', '1/2:1', '2/1:1']);
}); });
}); });
describe('asPages', () => {
test('empty', async () => {
expect(groupByPage([])).toEqual([]);
});
test('no merger', async () => {
const pageItems = [
[new Item(0, { id: 1, line: 1 })],
[new Item(1, { id: 2, line: 1 }), new Item(1, { id: 3, line: 1 }), new Item(1, { id: 4, line: 2 })],
[new Item(2, { id: 5, line: 1 })],
];
const flattenedItems = new Array<Item>().concat(...pageItems);
const pages = asPages(flattenedItems);
expect(pages).toEqual([
{ index: 0, itemGroups: pageItems[0].map((item) => new ItemGroup(item)) },
{ index: 1, itemGroups: pageItems[1].map((item) => new ItemGroup(item)) },
{ index: 2, itemGroups: pageItems[2].map((item) => new ItemGroup(item)) },
] as Page[]);
});
test('merger', async () => {
const pageItems = [
[new Item(0, { id: 1, line: 1 })],
[new Item(1, { id: 2, line: 1 }), new Item(1, { id: 3, line: 1 }), new Item(1, { id: 4, line: 2 })],
[new Item(2, { id: 5, line: 1 })],
];
const flattenedItems = new Array<Item>().concat(...pageItems);
const merger: ItemMerger = { groupKey: 'line', merge: (items) => items[0] };
const pages = asPages(flattenedItems, merger);
expect(pages).toEqual([
{ index: 0, itemGroups: pageItems[0].map((item) => new ItemGroup(item)) },
{
index: 1,
itemGroups: [
new ItemGroup(merger.merge(pageItems[1].slice(0, 2)), pageItems[1].slice(0, 2)),
new ItemGroup(pageItems[1][2]),
],
},
{ index: 2, itemGroups: pageItems[2].map((item) => new ItemGroup(item)) },
] as Page[]);
});
});

View File

@ -3,3 +3,20 @@ import Item from 'src/Item';
export function items(page: number, data: object[]): Item[] { export function items(page: number, data: object[]): Item[] {
return data.map((data) => new Item(page, data)); return data.map((data) => new Item(page, data));
} }
export function realisticItems(page: number, data: object[]): Item[] {
return data.map(
(data, idx) =>
new Item(page, {
line: idx,
x: idx,
y: idx,
str: `text ${idx}`,
fontName: 'g_d0_f2',
dir: 'ltr',
width: 10,
height: 10,
...data,
}),
);
}

View File

@ -0,0 +1,5 @@
import type { SvelteComponent } from 'svelte';
export default class ComponentDefinition {
constructor(public component: object, public args: object = {}) {}
}

View File

@ -1,25 +1,40 @@
<script> <script>
import type { Change } from '@core/debug/detectChanges'; import type ChangeIndex from '@core/debug/ChangeIndex';
import { PositionChange, Direction } from '../../../core/src/debug/detectChanges'; import type Item from '@core/Item';
import { Addition, ContentChange, PositionChange, Direction } from '../../../core/src/debug/ChangeIndex';
import ComponentDefinition from '../components/ComponentDefinition';
import Icon from 'fa-svelte'; import {
import type { IconDefinition } from '@fortawesome/fontawesome-common-types/index'; PlusCircle as Plus,
import { faArrowUp as up } from '@fortawesome/free-solid-svg-icons/faArrowUp'; Adjustments as Changed,
import { faArrowDown as down } from '@fortawesome/free-solid-svg-icons/faArrowDown'; ArrowCircleUp as Up,
ArrowCircleDown as Down,
} from 'svelte-hero-icons';
export let changes: Map<string, Change>; export let changes: ChangeIndex;
export let itemUid: string; export let item: Item;
$: hasChanged = changes.hasChanged(item);
let changeContent: string; let changeContent: string;
let icon: IconDefinition; let iconComp: ComponentDefinition;
$: { $: {
const change = changes.get(itemUid); if (hasChanged) {
if (change) { let args = { size: '14' };
let change = changes.change(item);
switch (change.constructor.name) { switch (change.constructor.name) {
case PositionChange.name: case PositionChange.name:
const positionChange = change as PositionChange; const positionChange = change as PositionChange;
changeContent = `${positionChange.amount}`; changeContent = `${positionChange.amount}`;
icon = positionChange.direction === Direction.UP ? up : down; iconComp =
positionChange.direction === Direction.UP
? new ComponentDefinition(Up, args)
: new ComponentDefinition(Down, args);
break;
case Addition.name:
iconComp = new ComponentDefinition(Plus, args);
break;
case ContentChange.name:
iconComp = new ComponentDefinition(Changed, args);
break; break;
default: default:
throw new Error(`${change.constructor.name}: ${change}`); throw new Error(`${change.constructor.name}: ${change}`);
@ -28,11 +43,13 @@
} }
</script> </script>
{#if changeContent} {#if hasChanged}
<div class="flex space-x-0.5 items-center text-xs"> <div class="flex space-x-0.5 items-center text-xs">
{#if icon} {#if iconComp}
<Icon {icon} /> <svelte:component this={iconComp.component} {...iconComp.args} />
{/if} {/if}
{#if changeContent}
<div>{changeContent}</div> <div>{changeContent}</div>
{/if}
</div> </div>
{/if} {/if}

View File

@ -5,7 +5,6 @@
import { BookOpen, ArrowLeft, ArrowRight } from 'svelte-hero-icons'; import { BookOpen, ArrowLeft, ArrowRight } from 'svelte-hero-icons';
import type Debugger from '@core/Debugger'; import type Debugger from '@core/Debugger';
import { asPages } from '../../../core/src/support/itemUtils';
import Popup from '../components/Popup.svelte'; import Popup from '../components/Popup.svelte';
import PageSelectionPopup from './PageSelectionPopup.svelte'; import PageSelectionPopup from './PageSelectionPopup.svelte';
@ -24,10 +23,9 @@
$: canPrev = currentStage > 0; $: canPrev = currentStage > 0;
$: stageResult = debug.stageResults(currentStage); $: stageResult = debug.stageResults(currentStage);
$: pageIsPinned = !isNaN(pinnedPage); $: pageIsPinned = !isNaN(pinnedPage);
$: pagesNumbers = new Set(stageResult.items.map((item) => item.page)); $: pagesNumbers = new Set(stageResult.pages.map((page) => page.index));
$: pages = asPages(stageResult.items, stageResult.descriptor?.debug?.itemMerger);
$: maxPage = Math.max(...pagesNumbers); $: maxPage = Math.max(...pagesNumbers);
$: visiblePages = pageIsPinned ? pages.filter((page) => page.index === pinnedPage) : pages; $: visiblePages = pageIsPinned ? stageResult.pages.filter((page) => page.index === pinnedPage) : stageResult.pages;
</script> </script>
<div class="mx-4"> <div class="mx-4">

View File

@ -2,11 +2,10 @@
import { scale, fade } from 'svelte/transition'; import { scale, fade } from 'svelte/transition';
import type AnnotatedColumn from '@core/debug/AnnotatedColumn'; import type AnnotatedColumn from '@core/debug/AnnotatedColumn';
import ColumnAnnotation from '../../../core/src/debug/ColumnAnnotation'; import ColumnAnnotation from '../../../core/src/debug/ColumnAnnotation';
import type { Change } from '@core/debug/detectChanges'; import type ChangeIndex from '@core/debug/ChangeIndex';
import { ChangeCategory } from '../../../core/src/debug/detectChanges';
import inView from '../actions/inView'; import inView from '../actions/inView';
import { formatValue } from './formatValues'; import { formatValue } from './formatValues';
import type Page from '@core/support/Page'; import type Page from '@core/debug/Page';
import ChangeSymbol from './ChangeSymbol.svelte'; import ChangeSymbol from './ChangeSymbol.svelte';
export let schema: AnnotatedColumn[]; export let schema: AnnotatedColumn[];
@ -14,7 +13,7 @@
export let maxPage: number; export let maxPage: number;
export let pageIsPinned: boolean; export let pageIsPinned: boolean;
export let onlyRelevantItems: boolean; export let onlyRelevantItems: boolean;
export let changes: Map<string, Change>; export let changes: ChangeIndex;
let maxItemsToRenderInOneLoad = 200; let maxItemsToRenderInOneLoad = 200;
let renderedMaxPage = 0; let renderedMaxPage = 0;
let expandedItemGroup: { pageIndex: number; itemIndex: number }; let expandedItemGroup: { pageIndex: number; itemIndex: number };
@ -75,17 +74,17 @@
{/if} {/if}
<!-- Page items --> <!-- Page items -->
{#each page.itemGroups.filter((group) => !onlyRelevantItems || changes.has(group.top.uuid)) as itemGroup, itemIdx} {#each page.itemGroups.filter((group) => !onlyRelevantItems || changes.hasChanged(group.top)) as itemGroup, itemIdx}
<tr <tr
class:expandable={itemGroup.hasMany()} class:expandable={itemGroup.hasMany()}
class:expanded={expandedItemGroup && isExpanded(page.index, itemIdx)} class:expanded={expandedItemGroup && isExpanded(page.index, itemIdx)}
class:changePlus={changes.get(itemGroup.top.uuid)?.category === ChangeCategory.PLUS} class:changePlus={changes.isPlusChange(itemGroup.top)}
class:changeNeutral={changes.get(itemGroup.top.uuid)?.category === ChangeCategory.NEUTRAL} class:changeNeutral={changes.isNeutralChange(itemGroup.top)}
class:changeMinus={changes.get(itemGroup.top.uuid)?.category === ChangeCategory.MINUS} class:changeMinus={changes.isMinusChange(itemGroup.top)}
in:fade> in:fade>
<!-- Page number in first page item row --> <!-- Page number in first page item row -->
{#if itemIdx === 0} {#if itemIdx === 0}
<td id="page" class="page bg-gray-50"> <td id="page" class="page bg-gray-50 align-top">
<div>Page {page.index} {pageIsPinned ? '' : ' / ' + maxPage}</div> <div>Page {page.index} {pageIsPinned ? '' : ' / ' + maxPage}</div>
</td> </td>
{:else} {:else}
@ -93,9 +92,11 @@
{/if} {/if}
<span class="contents" on:click={() => itemGroup.hasMany() && toggleRow(page.index, itemIdx)}> <span class="contents" on:click={() => itemGroup.hasMany() && toggleRow(page.index, itemIdx)}>
<!-- ID & change marker column --> <!-- ID & change marker column -->
<td class="flex space-x-1"> <td>
<div>{itemIdx}{itemGroup.hasMany() ? '+' : ''}</div> <div class="flex space-x-0.5 items-center">
<ChangeSymbol {changes} itemUid={itemGroup.top.uuid} /> <ChangeSymbol {changes} item={itemGroup.top} />
<div>{itemIdx}{itemGroup.hasMany() ? '…' : ''}</div>
</div>
</td> </td>
<!-- Row values --> <!-- Row values -->
@ -108,9 +109,18 @@
<!-- Expanded childs --> <!-- Expanded childs -->
{#if expandedItemGroup && isExpanded(page.index, itemIdx)} {#if expandedItemGroup && isExpanded(page.index, itemIdx)}
{#each itemGroup.elements as child, childIdx} {#each itemGroup.elements as child, childIdx}
<tr class="childs"> <tr
class="childs"
class:changePlus={changes.isPlusChange(child)}
class:changeNeutral={changes.isNeutralChange(child)}
class:changeMinus={changes.isMinusChange(child)}>
<td id="page" /> <td id="page" />
<td class="whitespace-nowrap">{'└ ' + childIdx}</td> <td class="whitespace-nowrap">
<div class="flex space-x-1">
<div class="w-8">{'└ ' + childIdx}</div>
<ChangeSymbol {changes} item={child} />
</div>
</td>
{#each schema as column} {#each schema as column}
<td class="select-all">{formatValue(child.data[column.name])}</td> <td class="select-all">{formatValue(child.data[column.name])}</td>
{/each} {/each}
@ -122,7 +132,7 @@
</tbody> </tbody>
</table> </table>
{#if onlyRelevantItems && changes.size === 0} {#if onlyRelevantItems && changes.changeCount() === 0}
<div class="flex space-x-1 items-center justify-center text-xl"> <div class="flex space-x-1 items-center justify-center text-xl">
<div>No changes from the transformation.</div> <div>No changes from the transformation.</div>
<div>Want to see</div> <div>Want to see</div>
@ -144,13 +154,12 @@
<style> <style>
.page { .page {
@apply text-lg;
@apply font-semibold; @apply font-semibold;
@apply pr-4; @apply pr-4;
@apply whitespace-nowrap; @apply whitespace-nowrap;
position: -webkit-sticky; position: -webkit-sticky;
position: sticky; position: sticky;
top: 2em; top: 2.4em;
z-index: 2; z-index: 2;
} }
@ -158,7 +167,7 @@
@apply px-1; @apply px-1;
position: -webkit-sticky; position: -webkit-sticky;
position: sticky; position: sticky;
top: 2.4em; top: 2.6em;
z-index: 2; z-index: 2;
} }
th:not(:first-child) { th:not(:first-child) {