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
@@ -11,6 +11,10 @@ const MDXView_1 = require("../objects/MDXView");
11
11
  const Hierarchy_1 = require("../objects/Hierarchy");
12
12
  const Element_1 = require("../objects/Element");
13
13
  const ElementAttribute_1 = require("../objects/ElementAttribute");
14
+ const Chore_1 = require("../objects/Chore");
15
+ const ChoreFrequency_1 = require("../objects/ChoreFrequency");
16
+ const ChoreStartTime_1 = require("../objects/ChoreStartTime");
17
+ const Utils_1 = require("../utils/Utils");
14
18
  // ---------------------------------------------------------------------------
15
19
  // Bug 1 & 2 (Axis.ts): ViewAxisSelection.fromDict and ViewTitleSelection.fromDict
16
20
  // ---------------------------------------------------------------------------
@@ -257,6 +261,116 @@ describe('NativeView.fromDict', () => {
257
261
  });
258
262
  });
259
263
  // ---------------------------------------------------------------------------
264
+ // Issue #75: NativeView fromDict, remove methods, substituteTitle parity
265
+ // ---------------------------------------------------------------------------
266
+ describe('NativeView.fromDict — title validation (issue #75)', () => {
267
+ test("throws when title dict lacks both 'Selected' and 'Selected@odata.bind'", () => {
268
+ const dict = {
269
+ Name: 'V',
270
+ SuppressEmptyColumns: false,
271
+ SuppressEmptyRows: false,
272
+ FormatString: '0.#',
273
+ Titles: [
274
+ {
275
+ 'Subset@odata.bind': "Dimensions('Region')/Hierarchies('Region')/Subsets('All')"
276
+ }
277
+ ],
278
+ Columns: [],
279
+ Rows: []
280
+ };
281
+ expect(() => NativeView_1.NativeView.fromDict(dict, 'Cube')).toThrow("View Title dict must contain 'Selected' or 'Selected@odata.bind' as key");
282
+ });
283
+ });
284
+ describe('NativeView.fromDict — cubeName from @odata.context (issue #75)', () => {
285
+ test('extracts cubeName from @odata.context when not provided', () => {
286
+ const dict = {
287
+ '@odata.context': "../$metadata#Cubes('SalesCube')/Views/$entity",
288
+ Name: 'V',
289
+ SuppressEmptyColumns: false,
290
+ SuppressEmptyRows: false,
291
+ FormatString: '0.#',
292
+ Titles: [],
293
+ Columns: [],
294
+ Rows: []
295
+ };
296
+ const view = NativeView_1.NativeView.fromDict(dict);
297
+ expect(view.cube).toBe('SalesCube');
298
+ });
299
+ test('prefers explicit cubeName over @odata.context', () => {
300
+ const dict = {
301
+ '@odata.context': "../$metadata#Cubes('WrongCube')/Views/$entity",
302
+ Name: 'V',
303
+ SuppressEmptyColumns: false,
304
+ SuppressEmptyRows: false,
305
+ FormatString: '0.#',
306
+ Titles: [],
307
+ Columns: [],
308
+ Rows: []
309
+ };
310
+ const view = NativeView_1.NativeView.fromDict(dict, 'CorrectCube');
311
+ expect(view.cube).toBe('CorrectCube');
312
+ });
313
+ });
314
+ describe('NativeView.removeColumn / removeRow / removeTitle — remove ALL matches (issue #75)', () => {
315
+ test('removeColumn removes all matching columns', () => {
316
+ const view = new NativeView_1.NativeView('Cube', 'View');
317
+ view.addColumn(new Axis_1.ViewAxisSelection('Region', new Subset_1.AnonymousSubset('Region', 'Region', undefined, ['North'])));
318
+ view.addColumn(new Axis_1.ViewAxisSelection('Region', new Subset_1.AnonymousSubset('Region', 'Region', undefined, ['South'])));
319
+ view.addColumn(new Axis_1.ViewAxisSelection('Month', new Subset_1.AnonymousSubset('Month', 'Month', undefined, ['Jan'])));
320
+ view.removeColumn('Region');
321
+ expect(view.columns).toHaveLength(1);
322
+ expect(view.columns[0].dimensionName).toBe('Month');
323
+ });
324
+ test('removeRow removes all matching rows', () => {
325
+ const view = new NativeView_1.NativeView('Cube', 'View');
326
+ view.addRow(new Axis_1.ViewAxisSelection('Region', new Subset_1.AnonymousSubset('Region', 'Region', undefined, ['North'])));
327
+ view.addRow(new Axis_1.ViewAxisSelection('Region', new Subset_1.AnonymousSubset('Region', 'Region', undefined, ['South'])));
328
+ view.addRow(new Axis_1.ViewAxisSelection('Month', new Subset_1.AnonymousSubset('Month', 'Month', undefined, ['Jan'])));
329
+ view.removeRow('Region');
330
+ expect(view.rows).toHaveLength(1);
331
+ expect(view.rows[0].dimensionName).toBe('Month');
332
+ });
333
+ test('removeTitle removes all matching titles', () => {
334
+ const view = new NativeView_1.NativeView('Cube', 'View');
335
+ const sub = new Subset_1.AnonymousSubset('Region', 'Region', undefined, ['North']);
336
+ view.addTitle(new Axis_1.ViewTitleSelection('Region', sub, 'North'));
337
+ view.addTitle(new Axis_1.ViewTitleSelection('Region', sub, 'South'));
338
+ view.addTitle(new Axis_1.ViewTitleSelection('Month', new Subset_1.AnonymousSubset('Month', 'Month', undefined, ['Jan']), 'Jan'));
339
+ view.removeTitle('Region');
340
+ expect(view.titles).toHaveLength(1);
341
+ expect(view.titles[0].dimensionName).toBe('Month');
342
+ });
343
+ test('removeColumn is case and space insensitive', () => {
344
+ const view = new NativeView_1.NativeView('Cube', 'View');
345
+ view.addColumn(new Axis_1.ViewAxisSelection('Region', new Subset_1.AnonymousSubset('Region', 'Region', undefined, ['North'])));
346
+ view.removeColumn(' region ');
347
+ expect(view.columns).toHaveLength(0);
348
+ });
349
+ });
350
+ describe('NativeView.substituteTitle — replaces subset and selected (issue #75)', () => {
351
+ test('replaces subset with AnonymousSubset(dim, dim, [element]) and updates selected', () => {
352
+ const view = new NativeView_1.NativeView('Cube', 'View');
353
+ view.addTitle(new Axis_1.ViewTitleSelection('Region', new Subset_1.AnonymousSubset('Region', 'Region', undefined, ['North', 'South']), 'North'));
354
+ view.substituteTitle('Region', 'South');
355
+ expect(view.titles[0].selected).toBe('South');
356
+ const newSubset = view.titles[0].subset;
357
+ expect(newSubset).toBeInstanceOf(Subset_1.AnonymousSubset);
358
+ expect(newSubset.elements).toEqual(['South']);
359
+ });
360
+ test('new subset uses caller-provided dimension as both dimension and hierarchy', () => {
361
+ const view = new NativeView_1.NativeView('Cube', 'View');
362
+ view.addTitle(new Axis_1.ViewTitleSelection('Region', new Subset_1.AnonymousSubset('Region', 'AltHierarchy', undefined, ['North']), 'North'));
363
+ view.substituteTitle('Region', 'South');
364
+ const newSubset = view.titles[0].subset;
365
+ expect(newSubset.dimensionName).toBe('Region');
366
+ expect(newSubset.hierarchyName).toBe('Region');
367
+ });
368
+ test("throws \"Dimension '...' not found in titles\" when dimension missing", () => {
369
+ const view = new NativeView_1.NativeView('Cube', 'View');
370
+ expect(() => view.substituteTitle('NonExistent', 'Value')).toThrow("Dimension 'NonExistent' not found in titles");
371
+ });
372
+ });
373
+ // ---------------------------------------------------------------------------
260
374
  // Bug 4 & 5 (Hierarchy.ts): Edge key type + traversal methods
261
375
  // ---------------------------------------------------------------------------
262
376
  describe('Hierarchy edges (Map<string, Map<string, number>>)', () => {
@@ -447,28 +561,84 @@ describe('MDXView.dynamicProperties', () => {
447
561
  const view = new MDXView_1.MDXView('Cube', 'View', 'SELECT ...', props);
448
562
  expect(view.dynamicProperties).toEqual(props);
449
563
  });
450
- test('fromDict parses Properties key', () => {
564
+ test('fromDict treats top-level non-excluded keys as dynamicProperties', () => {
451
565
  const dict = {
566
+ Cube: { Name: 'C' },
452
567
  Name: 'V',
453
568
  MDX: 'SELECT ...',
454
- Properties: { Aliases: ['Default'] }
569
+ Aliases: ['Default'],
570
+ Meta: { foo: 1 },
455
571
  };
456
- const view = MDXView_1.MDXView.fromDict(dict, 'Cube');
457
- expect(view.dynamicProperties).toEqual({ Aliases: ['Default'] });
572
+ const view = MDXView_1.MDXView.fromDict(dict);
573
+ expect(view.dynamicProperties).toEqual({ Aliases: ['Default'], Meta: { foo: 1 } });
458
574
  });
459
- test('fromDict handles missing Properties gracefully', () => {
460
- const view = MDXView_1.MDXView.fromDict({ Name: 'V', MDX: 'SELECT ...' }, 'Cube');
575
+ test('fromDict yields empty dynamicProperties when only excluded keys present', () => {
576
+ const dict = { Cube: { Name: 'C' }, Name: 'V', MDX: 'SELECT ...' };
577
+ const view = MDXView_1.MDXView.fromDict(dict);
461
578
  expect(view.dynamicProperties).toEqual({});
462
579
  });
463
- test('body includes Properties when dynamicProperties is non-empty', () => {
464
- const view = new MDXView_1.MDXView('Cube', 'View', 'SELECT ...', { ContextSets: [] });
580
+ test('fromDict prefers explicit cubeName over dict.Cube.Name', () => {
581
+ const dict = { Cube: { Name: 'FromDict' }, Name: 'V', MDX: 'SELECT ...' };
582
+ const view = MDXView_1.MDXView.fromDict(dict, 'Explicit');
583
+ expect(view.cube).toBe('Explicit');
584
+ });
585
+ test('fromDict falls back to dict.Cube.Name when cubeName is not provided', () => {
586
+ const dict = { Cube: { Name: 'FromDict' }, Name: 'V', MDX: 'SELECT ...' };
587
+ const view = MDXView_1.MDXView.fromDict(dict);
588
+ expect(view.cube).toBe('FromDict');
589
+ });
590
+ test('fromDict throws when no cubeName and no Cube field', () => {
591
+ const dict = { Name: 'V', MDX: 'SELECT ...' };
592
+ expect(() => MDXView_1.MDXView.fromDict(dict)).toThrow();
593
+ });
594
+ test('fromDict preserves a top-level Properties key as dynamicProperties.Properties', () => {
595
+ const dict = {
596
+ Cube: { Name: 'C' },
597
+ Name: 'V',
598
+ MDX: 'SELECT ...',
599
+ Properties: { Aliases: ['Default'] },
600
+ };
601
+ const view = MDXView_1.MDXView.fromDict(dict);
602
+ expect(view.dynamicProperties).toEqual({ Properties: { Aliases: ['Default'] } });
603
+ });
604
+ test.each([
605
+ '@odata.type',
606
+ '@odata.context',
607
+ '@odata.etag',
608
+ 'Name',
609
+ 'MDX',
610
+ 'Cube',
611
+ 'Attributes',
612
+ 'LocalizedAttributes',
613
+ ])('fromDict filters excluded key %s', (excludedKey) => {
614
+ const dict = {
615
+ Cube: { Name: 'C' },
616
+ Name: 'V',
617
+ MDX: 'SELECT ...',
618
+ Foo: 1,
619
+ };
620
+ dict[excludedKey] = 'should-be-stripped';
621
+ const view = MDXView_1.MDXView.fromDict(dict, 'Cube');
622
+ expect(view.dynamicProperties).not.toHaveProperty(excludedKey);
623
+ expect(view.dynamicProperties).toEqual({ Foo: 1 });
624
+ });
625
+ test('body merges dynamicProperties as top-level keys', () => {
626
+ const view = new MDXView_1.MDXView('Cube', 'View', 'SELECT ...', { ContextSets: [], Meta: 1 });
465
627
  const body = JSON.parse(view.body);
466
- expect(body.Properties).toEqual({ ContextSets: [] });
628
+ expect(body.ContextSets).toEqual([]);
629
+ expect(body.Meta).toBe(1);
630
+ expect(body.Properties).toBeUndefined();
467
631
  });
468
- test('body omits Properties when dynamicProperties is empty', () => {
632
+ test('body strips excluded keys from dynamicProperties before serializing', () => {
633
+ const view = new MDXView_1.MDXView('Cube', 'View', 'SELECT ...', { Name: 'IgnoreMe', Foo: 'bar' });
634
+ const body = JSON.parse(view.body);
635
+ expect(body.Name).toBe('View');
636
+ expect(body.Foo).toBe('bar');
637
+ });
638
+ test('body omits dynamicProperties keys when empty', () => {
469
639
  const view = new MDXView_1.MDXView('Cube', 'View', 'SELECT ...');
470
640
  const body = JSON.parse(view.body);
471
- expect(body).not.toHaveProperty('Properties');
641
+ expect(Object.keys(body).sort()).toEqual(['@odata.type', 'MDX', 'Name'].sort());
472
642
  });
473
643
  test('dynamicProperties round-trips through body and fromDict', () => {
474
644
  const props = { Meta: true, Aliases: ['Default'] };
@@ -508,4 +678,185 @@ describe('ElementAttribute.equals', () => {
508
678
  expect(a.equals(b)).toBe(false);
509
679
  });
510
680
  });
681
+ describe('ChoreStartTime.add (Issue #73)', () => {
682
+ const buildStartTime = () => new ChoreStartTime_1.ChoreStartTime(2020, 1, 1, 0, 0, 0);
683
+ test('adds positive days hours minutes seconds to start time', () => {
684
+ const st = buildStartTime();
685
+ st.add(1, 2, 3, 4);
686
+ expect(st.datetime.getFullYear()).toBe(2020);
687
+ expect(st.datetime.getMonth()).toBe(0);
688
+ expect(st.datetime.getDate()).toBe(2);
689
+ expect(st.datetime.getHours()).toBe(2);
690
+ expect(st.datetime.getMinutes()).toBe(3);
691
+ expect(st.datetime.getSeconds()).toBe(4);
692
+ });
693
+ test('default arguments are all zero (no mutation)', () => {
694
+ const st = buildStartTime();
695
+ const originalMs = st.datetime.getTime();
696
+ st.add();
697
+ expect(st.datetime.getTime()).toBe(originalMs);
698
+ });
699
+ test('negative values subtract time', () => {
700
+ const st = new ChoreStartTime_1.ChoreStartTime(2020, 1, 2, 0, 0, 0);
701
+ st.add(-1, 0, 0, 0);
702
+ expect(st.datetime.getFullYear()).toBe(2020);
703
+ expect(st.datetime.getMonth()).toBe(0);
704
+ expect(st.datetime.getDate()).toBe(1);
705
+ });
706
+ test('hours overflow into days (parity with Python timedelta normalization)', () => {
707
+ const st = buildStartTime();
708
+ st.add(0, 25, 0, 0);
709
+ expect(st.datetime.getDate()).toBe(2);
710
+ expect(st.datetime.getHours()).toBe(1);
711
+ });
712
+ test('seconds overflow into minutes', () => {
713
+ const st = buildStartTime();
714
+ st.add(0, 0, 0, 75);
715
+ expect(st.datetime.getMinutes()).toBe(1);
716
+ expect(st.datetime.getSeconds()).toBe(15);
717
+ });
718
+ });
719
+ describe('Chore.reschedule (Issue #73)', () => {
720
+ const buildChore = () => new Chore_1.Chore('TestChore', new ChoreStartTime_1.ChoreStartTime(2020, 1, 1, 0, 0, 0), false, true, Chore_1.Chore.SINGLE_COMMIT, new ChoreFrequency_1.ChoreFrequency(0, 0, 0, 0), []);
721
+ test('reschedule(days, hours, minutes, seconds) mutates start time and leaves frequency unchanged', () => {
722
+ const chore = buildChore();
723
+ const originalFrequency = chore.frequency;
724
+ chore.reschedule(1, 2, 3, 4);
725
+ expect(chore.startTime.datetime.getDate()).toBe(2);
726
+ expect(chore.startTime.datetime.getHours()).toBe(2);
727
+ expect(chore.startTime.datetime.getMinutes()).toBe(3);
728
+ expect(chore.startTime.datetime.getSeconds()).toBe(4);
729
+ expect(chore.frequency).toBe(originalFrequency);
730
+ });
731
+ test('reschedule defaults all components to 0 (no-op)', () => {
732
+ const chore = buildChore();
733
+ const originalMs = chore.startTime.datetime.getTime();
734
+ chore.reschedule();
735
+ expect(chore.startTime.datetime.getTime()).toBe(originalMs);
736
+ });
737
+ test('reschedule with negative values subtracts from start time', () => {
738
+ const chore = new Chore_1.Chore('TestChore', new ChoreStartTime_1.ChoreStartTime(2020, 1, 2, 0, 0, 0), false, true, Chore_1.Chore.SINGLE_COMMIT, new ChoreFrequency_1.ChoreFrequency(0, 0, 0, 0), []);
739
+ chore.reschedule(-1, 0, 0, 0);
740
+ expect(chore.startTime.datetime.getDate()).toBe(1);
741
+ });
742
+ });
743
+ describe('Chore.setFrequency (tm1npm-only helper, Issue #73)', () => {
744
+ const buildChore = () => new Chore_1.Chore('TestChore', new ChoreStartTime_1.ChoreStartTime(2020, 1, 1, 0, 0, 0), false, true, Chore_1.Chore.SINGLE_COMMIT, new ChoreFrequency_1.ChoreFrequency(1, 2, 3, 4), []);
745
+ test('replaces frequency without changing start time when startTime omitted', () => {
746
+ const chore = buildChore();
747
+ const originalStartMs = chore.startTime.datetime.getTime();
748
+ const newFreq = new ChoreFrequency_1.ChoreFrequency(5, 6, 7, 8);
749
+ chore.setFrequency(newFreq);
750
+ expect(chore.frequency).toBe(newFreq);
751
+ expect(chore.startTime.datetime.getTime()).toBe(originalStartMs);
752
+ });
753
+ test('replaces both frequency and start time when startTime provided', () => {
754
+ const chore = buildChore();
755
+ const newFreq = new ChoreFrequency_1.ChoreFrequency(5, 6, 7, 8);
756
+ const newStart = new ChoreStartTime_1.ChoreStartTime(2025, 6, 15, 12, 0, 0);
757
+ chore.setFrequency(newFreq, newStart);
758
+ expect(chore.frequency).toBe(newFreq);
759
+ expect(chore.startTime).toBe(newStart);
760
+ });
761
+ });
762
+ // ---------------------------------------------------------------------------
763
+ // Issue #70 (Subset.ts): AnonymousSubset.fromDict URL parsing
764
+ //
765
+ // These tests lock in tm1py's literal behavior — INCLUDING the latent bug
766
+ // where read_object_name_from_url always returns match.group(1), causing both
767
+ // segments to resolve to the dimension name. Do NOT "fix" these tests to
768
+ // reflect more "correct" behavior — see CLAUDE.md "Strict tm1py Parity".
769
+ // ---------------------------------------------------------------------------
770
+ describe('AnonymousSubset.fromDict — Hierarchy@odata.bind parsing (#70)', () => {
771
+ test('parses dimension and hierarchy when names match', () => {
772
+ const sub = Subset_1.AnonymousSubset.fromDict({
773
+ 'Hierarchy@odata.bind': "Dimensions('Region')/Hierarchies('Region')",
774
+ Elements: [{ Name: 'North' }]
775
+ });
776
+ expect(sub.dimensionName).toBe('Region');
777
+ expect(sub.hierarchyName).toBe('Region');
778
+ });
779
+ test('non-default hierarchy resolves to dimension name (tm1py bug parity)', () => {
780
+ // tm1py's read_object_name_from_url returns match.group(1) twice, both
781
+ // capturing the dimension. Hierarchy name in the URL is silently lost.
782
+ const sub = Subset_1.AnonymousSubset.fromDict({
783
+ 'Hierarchy@odata.bind': "Dimensions('Region')/Hierarchies('Region_Alt')",
784
+ Expression: '{[Region].[Region_Alt].Members}'
785
+ });
786
+ expect(sub.dimensionName).toBe('Region');
787
+ expect(sub.hierarchyName).toBe('Region');
788
+ });
789
+ test('parses Hierarchy entity form correctly', () => {
790
+ const sub = Subset_1.AnonymousSubset.fromDict({
791
+ Hierarchy: { Name: 'Region_Alt', Dimension: { Name: 'Region' } },
792
+ Elements: [{ Name: 'North' }]
793
+ });
794
+ expect(sub.dimensionName).toBe('Region');
795
+ expect(sub.hierarchyName).toBe('Region_Alt');
796
+ });
797
+ test('throws for URL missing Hierarchies segment', () => {
798
+ expect(() => Subset_1.AnonymousSubset.fromDict({
799
+ 'Hierarchy@odata.bind': "Dimensions('Region')"
800
+ })).toThrow(/Unexpected value for 'Hierarchy@odata.bind'/);
801
+ });
802
+ test('throws for non-Dimensions/Hierarchies URL shape (tm1py shape validation)', () => {
803
+ // tm1py's regex anchors on Dimensions(...)/Hierarchies(...) — other shapes fail.
804
+ expect(() => Subset_1.AnonymousSubset.fromDict({
805
+ 'Hierarchy@odata.bind': "Foo('A')/Bar('B')"
806
+ })).toThrow(/Unexpected value for 'Hierarchy@odata.bind'/);
807
+ });
808
+ test('throws when neither Hierarchy nor Hierarchy@odata.bind present', () => {
809
+ expect(() => Subset_1.AnonymousSubset.fromDict({})).toThrow(/must contain 'Hierarchy'/);
810
+ });
811
+ test('round-trip: alt hierarchy in URL is dropped via tm1py-equivalent parsing', () => {
812
+ // Input has Hierarchies('Region_Alt') but tm1py's parsing collapses both
813
+ // to the dimension name, so bodyAsDict reflects Hierarchies('Region').
814
+ const sub = Subset_1.AnonymousSubset.fromDict({
815
+ 'Hierarchy@odata.bind': "Dimensions('Region')/Hierarchies('Region_Alt')",
816
+ Elements: [{ Name: 'North' }]
817
+ });
818
+ const body = sub.bodyAsDict;
819
+ expect(body['Hierarchy@odata.bind']).toBe("Dimensions('Region')/Hierarchies('Region')");
820
+ });
821
+ });
822
+ describe('ViewAxisSelection.fromDict — anonymous subset with non-default hierarchy (#70)', () => {
823
+ test('hierarchy in URL collapses to dimension name (tm1py bug parity)', () => {
824
+ const dict = {
825
+ Subset: {
826
+ 'Hierarchy@odata.bind': "Dimensions('Region')/Hierarchies('Region_Alt')",
827
+ Elements: [{ Name: 'North' }]
828
+ }
829
+ };
830
+ const sel = Axis_1.ViewAxisSelection.fromDict(dict);
831
+ expect(sel.dimensionName).toBe('Region');
832
+ const anon = sel.subset;
833
+ expect(anon.dimensionName).toBe('Region');
834
+ expect(anon.hierarchyName).toBe('Region');
835
+ });
836
+ });
837
+ describe('readObjectNameFromUrl — tm1py parity (#70)', () => {
838
+ test('with pattern: URL-decodes percent-encoded names (mirrors urllib.parse.unquote)', () => {
839
+ const result = (0, Utils_1.readObjectNameFromUrl)("Dimensions('My%20Dim')/Hierarchies('H')", /^Dimensions\('(.+?)'\)/);
840
+ expect(result).toBe('My Dim');
841
+ });
842
+ test('with pattern: passes through malformed %XX (urllib.unquote permissive parity)', () => {
843
+ // Python: urllib.parse.unquote('Bad%ZZName') === 'Bad%ZZName'
844
+ // JS decodeURIComponent throws on malformed %XX — we catch and pass through.
845
+ const result = (0, Utils_1.readObjectNameFromUrl)("Dimensions('Bad%ZZName')/Hierarchies('H')", /^Dimensions\('(.+?)'\)/);
846
+ expect(result).toBe('Bad%ZZName');
847
+ });
848
+ test('with pattern: returns null on no match (mirrors re.match returning None)', () => {
849
+ const result = (0, Utils_1.readObjectNameFromUrl)("Foo('A')/Bar('B')", /^Dimensions\('(.+?)'\)/);
850
+ expect(result).toBeNull();
851
+ });
852
+ test('without pattern: preserves existing string return type (no API break)', () => {
853
+ // Type-level guarantee via overload — runtime check that result is still a string.
854
+ const result = (0, Utils_1.readObjectNameFromUrl)("Dimensions('Region')");
855
+ expect(result).toBe('Region');
856
+ });
857
+ test('without pattern: returns empty string on no match (backward compat)', () => {
858
+ const result = (0, Utils_1.readObjectNameFromUrl)('');
859
+ expect(result).toBe('');
860
+ });
861
+ });
511
862
  //# sourceMappingURL=objectModelParity.test.js.map
@@ -76,7 +76,7 @@ describe('Object Model - Improved Coverage', () => {
76
76
  const elements = [element1, element2];
77
77
  const hierarchy = new Hierarchy_1.Hierarchy('TestHierarchy', 'TestDimension', elements);
78
78
  expect(hierarchy.elements).toHaveLength(2);
79
- expect(hierarchy.elementNames).toEqual(['element1', 'element2']);
79
+ expect(hierarchy.elementNames).toEqual(['Element1', 'Element2']);
80
80
  });
81
81
  test('should get hierarchy body', () => {
82
82
  const hierarchy = new Hierarchy_1.Hierarchy('TestHierarchy', 'TestDimension', []);
@@ -198,6 +198,18 @@ describe('Object Model - Improved Coverage', () => {
198
198
  expect(process.hasSecurityAccess).toBe(false);
199
199
  // Process automatically adds generated statements to procedures
200
200
  });
201
+ test('replaces ALL € with \\f in variablesUiData entries (parity with Python str.replace)', () => {
202
+ // tm1py: each entry passes through entry.replace("€", "\f"), which is
203
+ // replace-all in Python. JS String.replace(stringPattern, ...) replaces
204
+ // only the first occurrence — must use a global regex for parity.
205
+ const entry = 'A€B€C€D';
206
+ const process = new Process_1.Process('TestProcess', false, '', [], [], [entry, 'no-euro-here', '€€€']);
207
+ expect(process.variablesUiData).toEqual([
208
+ 'A\fB\fC\fD',
209
+ 'no-euro-here',
210
+ '\f\f\f',
211
+ ]);
212
+ });
201
213
  });
202
214
  describe('Cube Object', () => {
203
215
  test('should create basic cube', () => {
@@ -212,7 +224,18 @@ describe('Object Model - Improved Coverage', () => {
212
224
  const cube = new Cube_1.Cube('TestCube', dimensions);
213
225
  const body = JSON.parse(cube.body);
214
226
  expect(body.Name).toBe('TestCube');
215
- expect(body.Dimensions).toHaveLength(2);
227
+ expect(body['Dimensions@odata.bind']).toHaveLength(2);
228
+ expect(body['Dimensions@odata.bind'][0]).toBe("Dimensions('Time')");
229
+ expect(body['Dimensions@odata.bind'][1]).toBe("Dimensions('Account')");
230
+ });
231
+ test('should escape special characters in dimension names (parity with tm1py format_url)', () => {
232
+ const cube = new Cube_1.Cube('TestCube', ["O'Brien", '50%', 'A#B', 'X?Y', 'M&N']);
233
+ const body = JSON.parse(cube.body);
234
+ expect(body['Dimensions@odata.bind'][0]).toBe("Dimensions('O''Brien')");
235
+ expect(body['Dimensions@odata.bind'][1]).toBe("Dimensions('50%25')");
236
+ expect(body['Dimensions@odata.bind'][2]).toBe("Dimensions('A%23B')");
237
+ expect(body['Dimensions@odata.bind'][3]).toBe("Dimensions('X%3FY')");
238
+ expect(body['Dimensions@odata.bind'][4]).toBe("Dimensions('M%26N')");
216
239
  });
217
240
  test('should create cube from dictionary', () => {
218
241
  const cubeDict = {
@@ -246,7 +269,7 @@ describe('Object Model - Improved Coverage', () => {
246
269
  test('should add user to group', () => {
247
270
  const user = new User_1.User('TestUser', []);
248
271
  user.addGroup('NewGroup');
249
- expect(user.groups).toContain('newgroup'); // lowercase due to CaseAndSpaceInsensitiveSet
272
+ expect(user.groups).toContain('NewGroup');
250
273
  });
251
274
  });
252
275
  describe('View Object', () => {
@@ -289,13 +312,13 @@ describe('Object Model - Improved Coverage', () => {
289
312
  const user = new User_1.User('BusinessUser', []);
290
313
  // Add to basic group
291
314
  user.addGroup('Everyone');
292
- expect(user.groups).toContain('everyone'); // lowercase
315
+ expect(user.groups).toContain('Everyone');
293
316
  // Promote to power user
294
317
  user.addGroup('PowerUser');
295
318
  expect(user.groups.length).toBeGreaterThan(1);
296
319
  // Remove from basic group
297
320
  user.removeGroup('Everyone');
298
- expect(user.groups).toContain('poweruser'); // remaining group
321
+ expect(user.groups).toContain('PowerUser');
299
322
  });
300
323
  });
301
324
  });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=user.issue61.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"user.issue61.test.d.ts","sourceRoot":"","sources":["../../src/tests/user.issue61.test.ts"],"names":[],"mappings":""}