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.
Files changed (47) hide show
  1. package/lib/objects/Axis.d.ts +1 -0
  2. package/lib/objects/Axis.d.ts.map +1 -1
  3. package/lib/objects/Axis.js +3 -0
  4. package/lib/objects/Chore.d.ts +2 -2
  5. package/lib/objects/Chore.d.ts.map +1 -1
  6. package/lib/objects/Chore.js +7 -13
  7. package/lib/objects/Cube.d.ts.map +1 -1
  8. package/lib/objects/Cube.js +2 -1
  9. package/lib/objects/Hierarchy.js +10 -10
  10. package/lib/objects/MDXView.d.ts +2 -0
  11. package/lib/objects/MDXView.d.ts.map +1 -1
  12. package/lib/objects/MDXView.js +30 -9
  13. package/lib/objects/NativeView.d.ts +5 -5
  14. package/lib/objects/NativeView.d.ts.map +1 -1
  15. package/lib/objects/NativeView.js +17 -34
  16. package/lib/objects/Process.d.ts +8 -3
  17. package/lib/objects/Process.d.ts.map +1 -1
  18. package/lib/objects/Process.js +143 -33
  19. package/lib/objects/Subset.d.ts.map +1 -1
  20. package/lib/objects/Subset.js +10 -3
  21. package/lib/objects/User.d.ts +5 -5
  22. package/lib/objects/User.d.ts.map +1 -1
  23. package/lib/objects/User.js +14 -23
  24. package/lib/tests/debuggerService.test.js +3 -1
  25. package/lib/tests/objectModelParity.test.js +362 -11
  26. package/lib/tests/objects.improved.test.js +28 -5
  27. package/lib/tests/user.issue61.test.d.ts +2 -0
  28. package/lib/tests/user.issue61.test.d.ts.map +1 -0
  29. package/lib/tests/user.issue61.test.js +180 -0
  30. package/lib/utils/Utils.d.ts +6 -1
  31. package/lib/utils/Utils.d.ts.map +1 -1
  32. package/lib/utils/Utils.js +56 -7
  33. package/package.json +1 -1
  34. package/src/objects/Axis.ts +4 -0
  35. package/src/objects/Chore.ts +7 -14
  36. package/src/objects/Cube.ts +2 -1
  37. package/src/objects/Hierarchy.ts +11 -11
  38. package/src/objects/MDXView.ts +29 -9
  39. package/src/objects/NativeView.ts +26 -42
  40. package/src/objects/Process.ts +182 -66
  41. package/src/objects/Subset.ts +17 -3
  42. package/src/objects/User.ts +17 -23
  43. package/src/tests/debuggerService.test.ts +3 -1
  44. package/src/tests/objectModelParity.test.ts +456 -11
  45. package/src/tests/objects.improved.test.ts +41 -9
  46. package/src/tests/user.issue61.test.ts +206 -0
  47. 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 parses Properties key', () => {
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
- Properties: { Aliases: ['Default'] }
646
+ Aliases: ['Default'],
647
+ Meta: { foo: 1 },
509
648
  };
510
- const view = MDXView.fromDict(dict, 'Cube');
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 handles missing Properties gracefully', () => {
515
- const view = MDXView.fromDict({ Name: 'V', MDX: 'SELECT ...' }, 'Cube');
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('body includes Properties when dynamicProperties is non-empty', () => {
520
- const view = new MDXView('Cube', 'View', 'SELECT ...', { ContextSets: [] });
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.Properties).toEqual({ ContextSets: [] });
720
+ expect(body.Name).toBe('View');
721
+ expect(body.Foo).toBe('bar');
523
722
  });
524
723
 
525
- test('body omits Properties when dynamicProperties is empty', () => {
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).not.toHaveProperty('Properties');
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(['element1', 'element2']);
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.Dimensions).toHaveLength(2);
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('newgroup'); // lowercase due to CaseAndSpaceInsensitiveSet
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('everyone'); // lowercase
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('poweruser'); // remaining group
413
+ expect(user.groups).toContain('PowerUser');
382
414
  });
383
415
  });
384
416
  });