tm1npm 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/objects/Axis.d.ts +1 -0
- package/lib/objects/Axis.d.ts.map +1 -1
- package/lib/objects/Axis.js +3 -0
- package/lib/objects/Chore.d.ts +2 -2
- package/lib/objects/Chore.d.ts.map +1 -1
- package/lib/objects/Chore.js +7 -13
- package/lib/objects/Cube.d.ts.map +1 -1
- package/lib/objects/Cube.js +2 -1
- package/lib/objects/Hierarchy.js +10 -10
- package/lib/objects/MDXView.d.ts +2 -0
- package/lib/objects/MDXView.d.ts.map +1 -1
- package/lib/objects/MDXView.js +30 -9
- package/lib/objects/NativeView.d.ts +5 -5
- package/lib/objects/NativeView.d.ts.map +1 -1
- package/lib/objects/NativeView.js +17 -34
- package/lib/objects/Process.d.ts +8 -3
- package/lib/objects/Process.d.ts.map +1 -1
- package/lib/objects/Process.js +143 -33
- package/lib/objects/Subset.d.ts.map +1 -1
- package/lib/objects/Subset.js +10 -3
- package/lib/objects/User.d.ts +5 -5
- package/lib/objects/User.d.ts.map +1 -1
- package/lib/objects/User.js +14 -23
- package/lib/tests/debuggerService.test.js +3 -1
- package/lib/tests/objectModelParity.test.js +362 -11
- package/lib/tests/objects.improved.test.js +28 -5
- package/lib/tests/user.issue61.test.d.ts +2 -0
- package/lib/tests/user.issue61.test.d.ts.map +1 -0
- package/lib/tests/user.issue61.test.js +180 -0
- package/lib/utils/Utils.d.ts +6 -1
- package/lib/utils/Utils.d.ts.map +1 -1
- package/lib/utils/Utils.js +56 -7
- package/package.json +1 -1
- package/src/objects/Axis.ts +4 -0
- package/src/objects/Chore.ts +7 -14
- package/src/objects/Cube.ts +2 -1
- package/src/objects/Hierarchy.ts +11 -11
- package/src/objects/MDXView.ts +29 -9
- package/src/objects/NativeView.ts +26 -42
- package/src/objects/Process.ts +182 -66
- package/src/objects/Subset.ts +17 -3
- package/src/objects/User.ts +17 -23
- package/src/tests/debuggerService.test.ts +3 -1
- package/src/tests/objectModelParity.test.ts +456 -11
- package/src/tests/objects.improved.test.ts +41 -9
- package/src/tests/user.issue61.test.ts +206 -0
- package/src/utils/Utils.ts +60 -7
|
@@ -10,6 +10,10 @@ import { MDXView } from '../objects/MDXView';
|
|
|
10
10
|
import { Hierarchy } from '../objects/Hierarchy';
|
|
11
11
|
import { Element, ElementType } from '../objects/Element';
|
|
12
12
|
import { ElementAttribute, ElementAttributeType } from '../objects/ElementAttribute';
|
|
13
|
+
import { Chore } from '../objects/Chore';
|
|
14
|
+
import { ChoreFrequency } from '../objects/ChoreFrequency';
|
|
15
|
+
import { ChoreStartTime } from '../objects/ChoreStartTime';
|
|
16
|
+
import { readObjectNameFromUrl } from '../utils/Utils';
|
|
13
17
|
|
|
14
18
|
// ---------------------------------------------------------------------------
|
|
15
19
|
// Bug 1 & 2 (Axis.ts): ViewAxisSelection.fromDict and ViewTitleSelection.fromDict
|
|
@@ -283,6 +287,139 @@ describe('NativeView.fromDict', () => {
|
|
|
283
287
|
});
|
|
284
288
|
});
|
|
285
289
|
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// Issue #75: NativeView fromDict, remove methods, substituteTitle parity
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
describe('NativeView.fromDict — title validation (issue #75)', () => {
|
|
295
|
+
test("throws when title dict lacks both 'Selected' and 'Selected@odata.bind'", () => {
|
|
296
|
+
const dict = {
|
|
297
|
+
Name: 'V',
|
|
298
|
+
SuppressEmptyColumns: false,
|
|
299
|
+
SuppressEmptyRows: false,
|
|
300
|
+
FormatString: '0.#',
|
|
301
|
+
Titles: [
|
|
302
|
+
{
|
|
303
|
+
'Subset@odata.bind': "Dimensions('Region')/Hierarchies('Region')/Subsets('All')"
|
|
304
|
+
}
|
|
305
|
+
],
|
|
306
|
+
Columns: [],
|
|
307
|
+
Rows: []
|
|
308
|
+
};
|
|
309
|
+
expect(() => NativeView.fromDict(dict, 'Cube')).toThrow(
|
|
310
|
+
"View Title dict must contain 'Selected' or 'Selected@odata.bind' as key"
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe('NativeView.fromDict — cubeName from @odata.context (issue #75)', () => {
|
|
316
|
+
test('extracts cubeName from @odata.context when not provided', () => {
|
|
317
|
+
const dict = {
|
|
318
|
+
'@odata.context': "../$metadata#Cubes('SalesCube')/Views/$entity",
|
|
319
|
+
Name: 'V',
|
|
320
|
+
SuppressEmptyColumns: false,
|
|
321
|
+
SuppressEmptyRows: false,
|
|
322
|
+
FormatString: '0.#',
|
|
323
|
+
Titles: [],
|
|
324
|
+
Columns: [],
|
|
325
|
+
Rows: []
|
|
326
|
+
};
|
|
327
|
+
const view = NativeView.fromDict(dict);
|
|
328
|
+
expect(view.cube).toBe('SalesCube');
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('prefers explicit cubeName over @odata.context', () => {
|
|
332
|
+
const dict = {
|
|
333
|
+
'@odata.context': "../$metadata#Cubes('WrongCube')/Views/$entity",
|
|
334
|
+
Name: 'V',
|
|
335
|
+
SuppressEmptyColumns: false,
|
|
336
|
+
SuppressEmptyRows: false,
|
|
337
|
+
FormatString: '0.#',
|
|
338
|
+
Titles: [],
|
|
339
|
+
Columns: [],
|
|
340
|
+
Rows: []
|
|
341
|
+
};
|
|
342
|
+
const view = NativeView.fromDict(dict, 'CorrectCube');
|
|
343
|
+
expect(view.cube).toBe('CorrectCube');
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe('NativeView.removeColumn / removeRow / removeTitle — remove ALL matches (issue #75)', () => {
|
|
348
|
+
test('removeColumn removes all matching columns', () => {
|
|
349
|
+
const view = new NativeView('Cube', 'View');
|
|
350
|
+
view.addColumn(new ViewAxisSelection('Region', new AnonymousSubset('Region', 'Region', undefined, ['North'])));
|
|
351
|
+
view.addColumn(new ViewAxisSelection('Region', new AnonymousSubset('Region', 'Region', undefined, ['South'])));
|
|
352
|
+
view.addColumn(new ViewAxisSelection('Month', new AnonymousSubset('Month', 'Month', undefined, ['Jan'])));
|
|
353
|
+
view.removeColumn('Region');
|
|
354
|
+
expect(view.columns).toHaveLength(1);
|
|
355
|
+
expect(view.columns[0].dimensionName).toBe('Month');
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test('removeRow removes all matching rows', () => {
|
|
359
|
+
const view = new NativeView('Cube', 'View');
|
|
360
|
+
view.addRow(new ViewAxisSelection('Region', new AnonymousSubset('Region', 'Region', undefined, ['North'])));
|
|
361
|
+
view.addRow(new ViewAxisSelection('Region', new AnonymousSubset('Region', 'Region', undefined, ['South'])));
|
|
362
|
+
view.addRow(new ViewAxisSelection('Month', new AnonymousSubset('Month', 'Month', undefined, ['Jan'])));
|
|
363
|
+
view.removeRow('Region');
|
|
364
|
+
expect(view.rows).toHaveLength(1);
|
|
365
|
+
expect(view.rows[0].dimensionName).toBe('Month');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test('removeTitle removes all matching titles', () => {
|
|
369
|
+
const view = new NativeView('Cube', 'View');
|
|
370
|
+
const sub = new AnonymousSubset('Region', 'Region', undefined, ['North']);
|
|
371
|
+
view.addTitle(new ViewTitleSelection('Region', sub, 'North'));
|
|
372
|
+
view.addTitle(new ViewTitleSelection('Region', sub, 'South'));
|
|
373
|
+
view.addTitle(new ViewTitleSelection('Month', new AnonymousSubset('Month', 'Month', undefined, ['Jan']), 'Jan'));
|
|
374
|
+
view.removeTitle('Region');
|
|
375
|
+
expect(view.titles).toHaveLength(1);
|
|
376
|
+
expect(view.titles[0].dimensionName).toBe('Month');
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test('removeColumn is case and space insensitive', () => {
|
|
380
|
+
const view = new NativeView('Cube', 'View');
|
|
381
|
+
view.addColumn(new ViewAxisSelection('Region', new AnonymousSubset('Region', 'Region', undefined, ['North'])));
|
|
382
|
+
view.removeColumn(' region ');
|
|
383
|
+
expect(view.columns).toHaveLength(0);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
describe('NativeView.substituteTitle — replaces subset and selected (issue #75)', () => {
|
|
388
|
+
test('replaces subset with AnonymousSubset(dim, dim, [element]) and updates selected', () => {
|
|
389
|
+
const view = new NativeView('Cube', 'View');
|
|
390
|
+
view.addTitle(new ViewTitleSelection(
|
|
391
|
+
'Region',
|
|
392
|
+
new AnonymousSubset('Region', 'Region', undefined, ['North', 'South']),
|
|
393
|
+
'North'
|
|
394
|
+
));
|
|
395
|
+
view.substituteTitle('Region', 'South');
|
|
396
|
+
expect(view.titles[0].selected).toBe('South');
|
|
397
|
+
const newSubset = view.titles[0].subset as AnonymousSubset;
|
|
398
|
+
expect(newSubset).toBeInstanceOf(AnonymousSubset);
|
|
399
|
+
expect(newSubset.elements).toEqual(['South']);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test('new subset uses caller-provided dimension as both dimension and hierarchy', () => {
|
|
403
|
+
const view = new NativeView('Cube', 'View');
|
|
404
|
+
view.addTitle(new ViewTitleSelection(
|
|
405
|
+
'Region',
|
|
406
|
+
new AnonymousSubset('Region', 'AltHierarchy', undefined, ['North']),
|
|
407
|
+
'North'
|
|
408
|
+
));
|
|
409
|
+
view.substituteTitle('Region', 'South');
|
|
410
|
+
const newSubset = view.titles[0].subset as AnonymousSubset;
|
|
411
|
+
expect(newSubset.dimensionName).toBe('Region');
|
|
412
|
+
expect(newSubset.hierarchyName).toBe('Region');
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("throws \"Dimension '...' not found in titles\" when dimension missing", () => {
|
|
416
|
+
const view = new NativeView('Cube', 'View');
|
|
417
|
+
expect(() => view.substituteTitle('NonExistent', 'Value')).toThrow(
|
|
418
|
+
"Dimension 'NonExistent' not found in titles"
|
|
419
|
+
);
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
286
423
|
// ---------------------------------------------------------------------------
|
|
287
424
|
// Bug 4 & 5 (Hierarchy.ts): Edge key type + traversal methods
|
|
288
425
|
// ---------------------------------------------------------------------------
|
|
@@ -501,31 +638,93 @@ describe('MDXView.dynamicProperties', () => {
|
|
|
501
638
|
expect(view.dynamicProperties).toEqual(props);
|
|
502
639
|
});
|
|
503
640
|
|
|
504
|
-
test('fromDict
|
|
641
|
+
test('fromDict treats top-level non-excluded keys as dynamicProperties', () => {
|
|
505
642
|
const dict = {
|
|
643
|
+
Cube: { Name: 'C' },
|
|
506
644
|
Name: 'V',
|
|
507
645
|
MDX: 'SELECT ...',
|
|
508
|
-
|
|
646
|
+
Aliases: ['Default'],
|
|
647
|
+
Meta: { foo: 1 },
|
|
509
648
|
};
|
|
510
|
-
const view = MDXView.fromDict(dict
|
|
511
|
-
expect(view.dynamicProperties).toEqual({ Aliases: ['Default'] });
|
|
649
|
+
const view = MDXView.fromDict(dict);
|
|
650
|
+
expect(view.dynamicProperties).toEqual({ Aliases: ['Default'], Meta: { foo: 1 } });
|
|
512
651
|
});
|
|
513
652
|
|
|
514
|
-
test('fromDict
|
|
515
|
-
const
|
|
653
|
+
test('fromDict yields empty dynamicProperties when only excluded keys present', () => {
|
|
654
|
+
const dict = { Cube: { Name: 'C' }, Name: 'V', MDX: 'SELECT ...' };
|
|
655
|
+
const view = MDXView.fromDict(dict);
|
|
516
656
|
expect(view.dynamicProperties).toEqual({});
|
|
517
657
|
});
|
|
518
658
|
|
|
519
|
-
test('
|
|
520
|
-
const
|
|
659
|
+
test('fromDict prefers explicit cubeName over dict.Cube.Name', () => {
|
|
660
|
+
const dict = { Cube: { Name: 'FromDict' }, Name: 'V', MDX: 'SELECT ...' };
|
|
661
|
+
const view = MDXView.fromDict(dict, 'Explicit');
|
|
662
|
+
expect(view.cube).toBe('Explicit');
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test('fromDict falls back to dict.Cube.Name when cubeName is not provided', () => {
|
|
666
|
+
const dict = { Cube: { Name: 'FromDict' }, Name: 'V', MDX: 'SELECT ...' };
|
|
667
|
+
const view = MDXView.fromDict(dict);
|
|
668
|
+
expect(view.cube).toBe('FromDict');
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
test('fromDict throws when no cubeName and no Cube field', () => {
|
|
672
|
+
const dict = { Name: 'V', MDX: 'SELECT ...' };
|
|
673
|
+
expect(() => MDXView.fromDict(dict)).toThrow();
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
test('fromDict preserves a top-level Properties key as dynamicProperties.Properties', () => {
|
|
677
|
+
const dict = {
|
|
678
|
+
Cube: { Name: 'C' },
|
|
679
|
+
Name: 'V',
|
|
680
|
+
MDX: 'SELECT ...',
|
|
681
|
+
Properties: { Aliases: ['Default'] },
|
|
682
|
+
};
|
|
683
|
+
const view = MDXView.fromDict(dict);
|
|
684
|
+
expect(view.dynamicProperties).toEqual({ Properties: { Aliases: ['Default'] } });
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
test.each([
|
|
688
|
+
'@odata.type',
|
|
689
|
+
'@odata.context',
|
|
690
|
+
'@odata.etag',
|
|
691
|
+
'Name',
|
|
692
|
+
'MDX',
|
|
693
|
+
'Cube',
|
|
694
|
+
'Attributes',
|
|
695
|
+
'LocalizedAttributes',
|
|
696
|
+
])('fromDict filters excluded key %s', (excludedKey) => {
|
|
697
|
+
const dict: Record<string, any> = {
|
|
698
|
+
Cube: { Name: 'C' },
|
|
699
|
+
Name: 'V',
|
|
700
|
+
MDX: 'SELECT ...',
|
|
701
|
+
Foo: 1,
|
|
702
|
+
};
|
|
703
|
+
dict[excludedKey] = 'should-be-stripped';
|
|
704
|
+
const view = MDXView.fromDict(dict, 'Cube');
|
|
705
|
+
expect(view.dynamicProperties).not.toHaveProperty(excludedKey);
|
|
706
|
+
expect(view.dynamicProperties).toEqual({ Foo: 1 });
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
test('body merges dynamicProperties as top-level keys', () => {
|
|
710
|
+
const view = new MDXView('Cube', 'View', 'SELECT ...', { ContextSets: [], Meta: 1 });
|
|
711
|
+
const body = JSON.parse(view.body);
|
|
712
|
+
expect(body.ContextSets).toEqual([]);
|
|
713
|
+
expect(body.Meta).toBe(1);
|
|
714
|
+
expect(body.Properties).toBeUndefined();
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
test('body strips excluded keys from dynamicProperties before serializing', () => {
|
|
718
|
+
const view = new MDXView('Cube', 'View', 'SELECT ...', { Name: 'IgnoreMe', Foo: 'bar' });
|
|
521
719
|
const body = JSON.parse(view.body);
|
|
522
|
-
expect(body.
|
|
720
|
+
expect(body.Name).toBe('View');
|
|
721
|
+
expect(body.Foo).toBe('bar');
|
|
523
722
|
});
|
|
524
723
|
|
|
525
|
-
test('body omits
|
|
724
|
+
test('body omits dynamicProperties keys when empty', () => {
|
|
526
725
|
const view = new MDXView('Cube', 'View', 'SELECT ...');
|
|
527
726
|
const body = JSON.parse(view.body);
|
|
528
|
-
expect(body).
|
|
727
|
+
expect(Object.keys(body).sort()).toEqual(['@odata.type', 'MDX', 'Name'].sort());
|
|
529
728
|
});
|
|
530
729
|
|
|
531
730
|
test('dynamicProperties round-trips through body and fromDict', () => {
|
|
@@ -572,3 +771,249 @@ describe('ElementAttribute.equals', () => {
|
|
|
572
771
|
expect(a.equals(b)).toBe(false);
|
|
573
772
|
});
|
|
574
773
|
});
|
|
774
|
+
|
|
775
|
+
describe('ChoreStartTime.add (Issue #73)', () => {
|
|
776
|
+
const buildStartTime = () =>
|
|
777
|
+
new ChoreStartTime(2020, 1, 1, 0, 0, 0);
|
|
778
|
+
|
|
779
|
+
test('adds positive days hours minutes seconds to start time', () => {
|
|
780
|
+
const st = buildStartTime();
|
|
781
|
+
st.add(1, 2, 3, 4);
|
|
782
|
+
expect(st.datetime.getFullYear()).toBe(2020);
|
|
783
|
+
expect(st.datetime.getMonth()).toBe(0);
|
|
784
|
+
expect(st.datetime.getDate()).toBe(2);
|
|
785
|
+
expect(st.datetime.getHours()).toBe(2);
|
|
786
|
+
expect(st.datetime.getMinutes()).toBe(3);
|
|
787
|
+
expect(st.datetime.getSeconds()).toBe(4);
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
test('default arguments are all zero (no mutation)', () => {
|
|
791
|
+
const st = buildStartTime();
|
|
792
|
+
const originalMs = st.datetime.getTime();
|
|
793
|
+
st.add();
|
|
794
|
+
expect(st.datetime.getTime()).toBe(originalMs);
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
test('negative values subtract time', () => {
|
|
798
|
+
const st = new ChoreStartTime(2020, 1, 2, 0, 0, 0);
|
|
799
|
+
st.add(-1, 0, 0, 0);
|
|
800
|
+
expect(st.datetime.getFullYear()).toBe(2020);
|
|
801
|
+
expect(st.datetime.getMonth()).toBe(0);
|
|
802
|
+
expect(st.datetime.getDate()).toBe(1);
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
test('hours overflow into days (parity with Python timedelta normalization)', () => {
|
|
806
|
+
const st = buildStartTime();
|
|
807
|
+
st.add(0, 25, 0, 0);
|
|
808
|
+
expect(st.datetime.getDate()).toBe(2);
|
|
809
|
+
expect(st.datetime.getHours()).toBe(1);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
test('seconds overflow into minutes', () => {
|
|
813
|
+
const st = buildStartTime();
|
|
814
|
+
st.add(0, 0, 0, 75);
|
|
815
|
+
expect(st.datetime.getMinutes()).toBe(1);
|
|
816
|
+
expect(st.datetime.getSeconds()).toBe(15);
|
|
817
|
+
});
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
describe('Chore.reschedule (Issue #73)', () => {
|
|
821
|
+
const buildChore = () =>
|
|
822
|
+
new Chore(
|
|
823
|
+
'TestChore',
|
|
824
|
+
new ChoreStartTime(2020, 1, 1, 0, 0, 0),
|
|
825
|
+
false,
|
|
826
|
+
true,
|
|
827
|
+
Chore.SINGLE_COMMIT,
|
|
828
|
+
new ChoreFrequency(0, 0, 0, 0),
|
|
829
|
+
[]
|
|
830
|
+
);
|
|
831
|
+
|
|
832
|
+
test('reschedule(days, hours, minutes, seconds) mutates start time and leaves frequency unchanged', () => {
|
|
833
|
+
const chore = buildChore();
|
|
834
|
+
const originalFrequency = chore.frequency;
|
|
835
|
+
chore.reschedule(1, 2, 3, 4);
|
|
836
|
+
expect(chore.startTime.datetime.getDate()).toBe(2);
|
|
837
|
+
expect(chore.startTime.datetime.getHours()).toBe(2);
|
|
838
|
+
expect(chore.startTime.datetime.getMinutes()).toBe(3);
|
|
839
|
+
expect(chore.startTime.datetime.getSeconds()).toBe(4);
|
|
840
|
+
expect(chore.frequency).toBe(originalFrequency);
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
test('reschedule defaults all components to 0 (no-op)', () => {
|
|
844
|
+
const chore = buildChore();
|
|
845
|
+
const originalMs = chore.startTime.datetime.getTime();
|
|
846
|
+
chore.reschedule();
|
|
847
|
+
expect(chore.startTime.datetime.getTime()).toBe(originalMs);
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
test('reschedule with negative values subtracts from start time', () => {
|
|
851
|
+
const chore = new Chore(
|
|
852
|
+
'TestChore',
|
|
853
|
+
new ChoreStartTime(2020, 1, 2, 0, 0, 0),
|
|
854
|
+
false,
|
|
855
|
+
true,
|
|
856
|
+
Chore.SINGLE_COMMIT,
|
|
857
|
+
new ChoreFrequency(0, 0, 0, 0),
|
|
858
|
+
[]
|
|
859
|
+
);
|
|
860
|
+
chore.reschedule(-1, 0, 0, 0);
|
|
861
|
+
expect(chore.startTime.datetime.getDate()).toBe(1);
|
|
862
|
+
});
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
describe('Chore.setFrequency (tm1npm-only helper, Issue #73)', () => {
|
|
866
|
+
const buildChore = () =>
|
|
867
|
+
new Chore(
|
|
868
|
+
'TestChore',
|
|
869
|
+
new ChoreStartTime(2020, 1, 1, 0, 0, 0),
|
|
870
|
+
false,
|
|
871
|
+
true,
|
|
872
|
+
Chore.SINGLE_COMMIT,
|
|
873
|
+
new ChoreFrequency(1, 2, 3, 4),
|
|
874
|
+
[]
|
|
875
|
+
);
|
|
876
|
+
|
|
877
|
+
test('replaces frequency without changing start time when startTime omitted', () => {
|
|
878
|
+
const chore = buildChore();
|
|
879
|
+
const originalStartMs = chore.startTime.datetime.getTime();
|
|
880
|
+
const newFreq = new ChoreFrequency(5, 6, 7, 8);
|
|
881
|
+
chore.setFrequency(newFreq);
|
|
882
|
+
expect(chore.frequency).toBe(newFreq);
|
|
883
|
+
expect(chore.startTime.datetime.getTime()).toBe(originalStartMs);
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
test('replaces both frequency and start time when startTime provided', () => {
|
|
887
|
+
const chore = buildChore();
|
|
888
|
+
const newFreq = new ChoreFrequency(5, 6, 7, 8);
|
|
889
|
+
const newStart = new ChoreStartTime(2025, 6, 15, 12, 0, 0);
|
|
890
|
+
chore.setFrequency(newFreq, newStart);
|
|
891
|
+
expect(chore.frequency).toBe(newFreq);
|
|
892
|
+
expect(chore.startTime).toBe(newStart);
|
|
893
|
+
});
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
// ---------------------------------------------------------------------------
|
|
897
|
+
// Issue #70 (Subset.ts): AnonymousSubset.fromDict URL parsing
|
|
898
|
+
//
|
|
899
|
+
// These tests lock in tm1py's literal behavior — INCLUDING the latent bug
|
|
900
|
+
// where read_object_name_from_url always returns match.group(1), causing both
|
|
901
|
+
// segments to resolve to the dimension name. Do NOT "fix" these tests to
|
|
902
|
+
// reflect more "correct" behavior — see CLAUDE.md "Strict tm1py Parity".
|
|
903
|
+
// ---------------------------------------------------------------------------
|
|
904
|
+
|
|
905
|
+
describe('AnonymousSubset.fromDict — Hierarchy@odata.bind parsing (#70)', () => {
|
|
906
|
+
test('parses dimension and hierarchy when names match', () => {
|
|
907
|
+
const sub = AnonymousSubset.fromDict({
|
|
908
|
+
'Hierarchy@odata.bind': "Dimensions('Region')/Hierarchies('Region')",
|
|
909
|
+
Elements: [{ Name: 'North' }]
|
|
910
|
+
});
|
|
911
|
+
expect(sub.dimensionName).toBe('Region');
|
|
912
|
+
expect(sub.hierarchyName).toBe('Region');
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
test('non-default hierarchy resolves to dimension name (tm1py bug parity)', () => {
|
|
916
|
+
// tm1py's read_object_name_from_url returns match.group(1) twice, both
|
|
917
|
+
// capturing the dimension. Hierarchy name in the URL is silently lost.
|
|
918
|
+
const sub = AnonymousSubset.fromDict({
|
|
919
|
+
'Hierarchy@odata.bind': "Dimensions('Region')/Hierarchies('Region_Alt')",
|
|
920
|
+
Expression: '{[Region].[Region_Alt].Members}'
|
|
921
|
+
});
|
|
922
|
+
expect(sub.dimensionName).toBe('Region');
|
|
923
|
+
expect(sub.hierarchyName).toBe('Region');
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
test('parses Hierarchy entity form correctly', () => {
|
|
927
|
+
const sub = AnonymousSubset.fromDict({
|
|
928
|
+
Hierarchy: { Name: 'Region_Alt', Dimension: { Name: 'Region' } },
|
|
929
|
+
Elements: [{ Name: 'North' }]
|
|
930
|
+
});
|
|
931
|
+
expect(sub.dimensionName).toBe('Region');
|
|
932
|
+
expect(sub.hierarchyName).toBe('Region_Alt');
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
test('throws for URL missing Hierarchies segment', () => {
|
|
936
|
+
expect(() => AnonymousSubset.fromDict({
|
|
937
|
+
'Hierarchy@odata.bind': "Dimensions('Region')"
|
|
938
|
+
})).toThrow(/Unexpected value for 'Hierarchy@odata.bind'/);
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
test('throws for non-Dimensions/Hierarchies URL shape (tm1py shape validation)', () => {
|
|
942
|
+
// tm1py's regex anchors on Dimensions(...)/Hierarchies(...) — other shapes fail.
|
|
943
|
+
expect(() => AnonymousSubset.fromDict({
|
|
944
|
+
'Hierarchy@odata.bind': "Foo('A')/Bar('B')"
|
|
945
|
+
})).toThrow(/Unexpected value for 'Hierarchy@odata.bind'/);
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
test('throws when neither Hierarchy nor Hierarchy@odata.bind present', () => {
|
|
949
|
+
expect(() => AnonymousSubset.fromDict({})).toThrow(/must contain 'Hierarchy'/);
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
test('round-trip: alt hierarchy in URL is dropped via tm1py-equivalent parsing', () => {
|
|
953
|
+
// Input has Hierarchies('Region_Alt') but tm1py's parsing collapses both
|
|
954
|
+
// to the dimension name, so bodyAsDict reflects Hierarchies('Region').
|
|
955
|
+
const sub = AnonymousSubset.fromDict({
|
|
956
|
+
'Hierarchy@odata.bind': "Dimensions('Region')/Hierarchies('Region_Alt')",
|
|
957
|
+
Elements: [{ Name: 'North' }]
|
|
958
|
+
});
|
|
959
|
+
const body = sub.bodyAsDict;
|
|
960
|
+
expect(body['Hierarchy@odata.bind']).toBe(
|
|
961
|
+
"Dimensions('Region')/Hierarchies('Region')"
|
|
962
|
+
);
|
|
963
|
+
});
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
describe('ViewAxisSelection.fromDict — anonymous subset with non-default hierarchy (#70)', () => {
|
|
967
|
+
test('hierarchy in URL collapses to dimension name (tm1py bug parity)', () => {
|
|
968
|
+
const dict = {
|
|
969
|
+
Subset: {
|
|
970
|
+
'Hierarchy@odata.bind': "Dimensions('Region')/Hierarchies('Region_Alt')",
|
|
971
|
+
Elements: [{ Name: 'North' }]
|
|
972
|
+
}
|
|
973
|
+
};
|
|
974
|
+
const sel = ViewAxisSelection.fromDict(dict);
|
|
975
|
+
expect(sel.dimensionName).toBe('Region');
|
|
976
|
+
const anon = sel.subset as AnonymousSubset;
|
|
977
|
+
expect(anon.dimensionName).toBe('Region');
|
|
978
|
+
expect(anon.hierarchyName).toBe('Region');
|
|
979
|
+
});
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
describe('readObjectNameFromUrl — tm1py parity (#70)', () => {
|
|
983
|
+
test('with pattern: URL-decodes percent-encoded names (mirrors urllib.parse.unquote)', () => {
|
|
984
|
+
const result = readObjectNameFromUrl(
|
|
985
|
+
"Dimensions('My%20Dim')/Hierarchies('H')",
|
|
986
|
+
/^Dimensions\('(.+?)'\)/
|
|
987
|
+
);
|
|
988
|
+
expect(result).toBe('My Dim');
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
test('with pattern: passes through malformed %XX (urllib.unquote permissive parity)', () => {
|
|
992
|
+
// Python: urllib.parse.unquote('Bad%ZZName') === 'Bad%ZZName'
|
|
993
|
+
// JS decodeURIComponent throws on malformed %XX — we catch and pass through.
|
|
994
|
+
const result = readObjectNameFromUrl(
|
|
995
|
+
"Dimensions('Bad%ZZName')/Hierarchies('H')",
|
|
996
|
+
/^Dimensions\('(.+?)'\)/
|
|
997
|
+
);
|
|
998
|
+
expect(result).toBe('Bad%ZZName');
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
test('with pattern: returns null on no match (mirrors re.match returning None)', () => {
|
|
1002
|
+
const result = readObjectNameFromUrl(
|
|
1003
|
+
"Foo('A')/Bar('B')",
|
|
1004
|
+
/^Dimensions\('(.+?)'\)/
|
|
1005
|
+
);
|
|
1006
|
+
expect(result).toBeNull();
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
test('without pattern: preserves existing string return type (no API break)', () => {
|
|
1010
|
+
// Type-level guarantee via overload — runtime check that result is still a string.
|
|
1011
|
+
const result: string = readObjectNameFromUrl("Dimensions('Region')");
|
|
1012
|
+
expect(result).toBe('Region');
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
test('without pattern: returns empty string on no match (backward compat)', () => {
|
|
1016
|
+
const result: string = readObjectNameFromUrl('');
|
|
1017
|
+
expect(result).toBe('');
|
|
1018
|
+
});
|
|
1019
|
+
});
|
|
@@ -95,7 +95,7 @@ describe('Object Model - Improved Coverage', () => {
|
|
|
95
95
|
const hierarchy = new Hierarchy('TestHierarchy', 'TestDimension', elements);
|
|
96
96
|
|
|
97
97
|
expect(hierarchy.elements).toHaveLength(2);
|
|
98
|
-
expect(hierarchy.elementNames).toEqual(['
|
|
98
|
+
expect(hierarchy.elementNames).toEqual(['Element1', 'Element2']);
|
|
99
99
|
});
|
|
100
100
|
|
|
101
101
|
test('should get hierarchy body', () => {
|
|
@@ -248,11 +248,31 @@ describe('Object Model - Improved Coverage', () => {
|
|
|
248
248
|
describe('Process Object', () => {
|
|
249
249
|
test('should create basic process', () => {
|
|
250
250
|
const process = new Process('TestProcess');
|
|
251
|
-
|
|
251
|
+
|
|
252
252
|
expect(process.name).toBe('TestProcess');
|
|
253
253
|
expect(process.hasSecurityAccess).toBe(false);
|
|
254
254
|
// Process automatically adds generated statements to procedures
|
|
255
255
|
});
|
|
256
|
+
|
|
257
|
+
test('replaces ALL € with \\f in variablesUiData entries (parity with Python str.replace)', () => {
|
|
258
|
+
// tm1py: each entry passes through entry.replace("€", "\f"), which is
|
|
259
|
+
// replace-all in Python. JS String.replace(stringPattern, ...) replaces
|
|
260
|
+
// only the first occurrence — must use a global regex for parity.
|
|
261
|
+
const entry = 'A€B€C€D';
|
|
262
|
+
const process = new Process(
|
|
263
|
+
'TestProcess',
|
|
264
|
+
false,
|
|
265
|
+
'',
|
|
266
|
+
[],
|
|
267
|
+
[],
|
|
268
|
+
[entry, 'no-euro-here', '€€€']
|
|
269
|
+
);
|
|
270
|
+
expect(process.variablesUiData).toEqual([
|
|
271
|
+
'A\fB\fC\fD',
|
|
272
|
+
'no-euro-here',
|
|
273
|
+
'\f\f\f',
|
|
274
|
+
]);
|
|
275
|
+
});
|
|
256
276
|
});
|
|
257
277
|
|
|
258
278
|
describe('Cube Object', () => {
|
|
@@ -269,9 +289,21 @@ describe('Object Model - Improved Coverage', () => {
|
|
|
269
289
|
const dimensions = ['Time', 'Account'];
|
|
270
290
|
const cube = new Cube('TestCube', dimensions);
|
|
271
291
|
const body = JSON.parse(cube.body);
|
|
272
|
-
|
|
292
|
+
|
|
273
293
|
expect(body.Name).toBe('TestCube');
|
|
274
|
-
expect(body.
|
|
294
|
+
expect(body['Dimensions@odata.bind']).toHaveLength(2);
|
|
295
|
+
expect(body['Dimensions@odata.bind'][0]).toBe("Dimensions('Time')");
|
|
296
|
+
expect(body['Dimensions@odata.bind'][1]).toBe("Dimensions('Account')");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('should escape special characters in dimension names (parity with tm1py format_url)', () => {
|
|
300
|
+
const cube = new Cube('TestCube', ["O'Brien", '50%', 'A#B', 'X?Y', 'M&N']);
|
|
301
|
+
const body = JSON.parse(cube.body);
|
|
302
|
+
expect(body['Dimensions@odata.bind'][0]).toBe("Dimensions('O''Brien')");
|
|
303
|
+
expect(body['Dimensions@odata.bind'][1]).toBe("Dimensions('50%25')");
|
|
304
|
+
expect(body['Dimensions@odata.bind'][2]).toBe("Dimensions('A%23B')");
|
|
305
|
+
expect(body['Dimensions@odata.bind'][3]).toBe("Dimensions('X%3FY')");
|
|
306
|
+
expect(body['Dimensions@odata.bind'][4]).toBe("Dimensions('M%26N')");
|
|
275
307
|
});
|
|
276
308
|
|
|
277
309
|
test('should create cube from dictionary', () => {
|
|
@@ -316,7 +348,7 @@ describe('Object Model - Improved Coverage', () => {
|
|
|
316
348
|
const user = new User('TestUser', []);
|
|
317
349
|
user.addGroup('NewGroup');
|
|
318
350
|
|
|
319
|
-
expect(user.groups).toContain('
|
|
351
|
+
expect(user.groups).toContain('NewGroup');
|
|
320
352
|
});
|
|
321
353
|
});
|
|
322
354
|
|
|
@@ -370,15 +402,15 @@ describe('Object Model - Improved Coverage', () => {
|
|
|
370
402
|
|
|
371
403
|
// Add to basic group
|
|
372
404
|
user.addGroup('Everyone');
|
|
373
|
-
expect(user.groups).toContain('
|
|
374
|
-
|
|
405
|
+
expect(user.groups).toContain('Everyone');
|
|
406
|
+
|
|
375
407
|
// Promote to power user
|
|
376
408
|
user.addGroup('PowerUser');
|
|
377
409
|
expect(user.groups.length).toBeGreaterThan(1);
|
|
378
|
-
|
|
410
|
+
|
|
379
411
|
// Remove from basic group
|
|
380
412
|
user.removeGroup('Everyone');
|
|
381
|
-
expect(user.groups).toContain('
|
|
413
|
+
expect(user.groups).toContain('PowerUser');
|
|
382
414
|
});
|
|
383
415
|
});
|
|
384
416
|
});
|