@platforma-sdk/model 1.68.5 → 1.68.7

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 (52) hide show
  1. package/dist/columns/column_collection_builder.cjs +8 -2
  2. package/dist/columns/column_collection_builder.cjs.map +1 -1
  3. package/dist/columns/column_collection_builder.d.ts +14 -3
  4. package/dist/columns/column_collection_builder.d.ts.map +1 -1
  5. package/dist/columns/column_collection_builder.js +8 -2
  6. package/dist/columns/column_collection_builder.js.map +1 -1
  7. package/dist/columns/ctx_column_sources.d.ts +1 -1
  8. package/dist/columns/index.d.ts +1 -1
  9. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.cjs +50 -50
  10. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.cjs.map +1 -1
  11. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.d.ts +5 -10
  12. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.d.ts.map +1 -1
  13. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.js +50 -50
  14. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.js.map +1 -1
  15. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.cjs +16 -17
  16. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.cjs.map +1 -1
  17. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.d.ts +4 -4
  18. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.d.ts.map +1 -1
  19. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.js +16 -17
  20. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.js.map +1 -1
  21. package/dist/components/PlDataTable/createPlDataTable/utils.cjs +8 -2
  22. package/dist/components/PlDataTable/createPlDataTable/utils.cjs.map +1 -1
  23. package/dist/components/PlDataTable/createPlDataTable/utils.js +8 -2
  24. package/dist/components/PlDataTable/createPlDataTable/utils.js.map +1 -1
  25. package/dist/components/PlDatasetSelector/filter_discovery.d.ts +1 -1
  26. package/dist/index.d.ts +6 -6
  27. package/dist/labels/derive_distinct_labels.cjs +121 -50
  28. package/dist/labels/derive_distinct_labels.cjs.map +1 -1
  29. package/dist/labels/derive_distinct_labels.d.ts +30 -14
  30. package/dist/labels/derive_distinct_labels.d.ts.map +1 -1
  31. package/dist/labels/derive_distinct_labels.js +121 -50
  32. package/dist/labels/derive_distinct_labels.js.map +1 -1
  33. package/dist/labels/derive_distinct_tooltips.cjs +0 -10
  34. package/dist/labels/derive_distinct_tooltips.cjs.map +1 -1
  35. package/dist/labels/derive_distinct_tooltips.d.ts +2 -3
  36. package/dist/labels/derive_distinct_tooltips.d.ts.map +1 -1
  37. package/dist/labels/derive_distinct_tooltips.js +0 -10
  38. package/dist/labels/derive_distinct_tooltips.js.map +1 -1
  39. package/dist/labels/index.d.ts +1 -1
  40. package/dist/package.cjs +1 -1
  41. package/dist/package.js +1 -1
  42. package/package.json +4 -4
  43. package/src/columns/column_collection_builder.test.ts +0 -2
  44. package/src/columns/column_collection_builder.ts +26 -3
  45. package/src/components/PlDataTable/createPlDataTable/createPlDataTableV3.ts +90 -75
  46. package/src/components/PlDataTable/createPlDataTable/discoverColumns.ts +31 -34
  47. package/src/components/PlDataTable/createPlDataTable/utils.test.ts +1 -1
  48. package/src/components/PlDataTable/createPlDataTable/utils.ts +11 -4
  49. package/src/labels/derive_distinct_labels.test.ts +396 -52
  50. package/src/labels/derive_distinct_labels.ts +205 -103
  51. package/src/labels/derive_distinct_tooltips.test.ts +1 -22
  52. package/src/labels/derive_distinct_tooltips.ts +1 -18
@@ -1,5 +1,10 @@
1
- import { Annotation, type PColumnSpec } from "@milaboratories/pl-model-common";
2
- import { expect, test } from "vitest";
1
+ import {
2
+ Annotation,
3
+ type AxisQualification,
4
+ type PColumnSpec,
5
+ type PObjectId,
6
+ } from "@milaboratories/pl-model-common";
7
+ import { describe, expect, test } from "vitest";
3
8
  import { deriveDistinctLabels, type Entry, type Trace } from "./derive_distinct_labels";
4
9
 
5
10
  function tracesToSpecs(traces: Trace[]) {
@@ -284,8 +289,6 @@ test.each<{ name: string; traces: Trace[]; labels: string[]; forceTraceElements:
284
289
  },
285
290
  );
286
291
 
287
- // --- Entry with { spec, extraTrace } ---
288
-
289
292
  test("Entry with extraTrace (suffix, default) appends to labels", () => {
290
293
  const spec = createSpec({
291
294
  annotations: {
@@ -314,92 +317,84 @@ test("Entry with extraTrace position prefix prepends to labels", () => {
314
317
  expect(labels).toEqual(["P1", "P2"]);
315
318
  });
316
319
 
317
- // --- linkerPath ---
318
-
319
- test("linkerPath appends default 'via' suffix", () => {
320
+ test("linkerPath appends default 'via' suffix when needed for uniqueness", () => {
320
321
  const entries: Entry[] = [
321
322
  {
322
323
  spec: createSpec({
323
- annotations: { [Annotation.Trace]: JSON.stringify([{ type: "t1", label: "Col1" }]) },
324
+ annotations: { [Annotation.Trace]: JSON.stringify([{ type: "t1", label: "Col" }]) },
324
325
  }),
325
- linkerPath: [
326
- {
327
- spec: createSpec({
328
- annotations: { [Annotation.LinkLabel]: "MyLinker" },
329
- }),
330
- },
331
- ],
332
326
  },
333
327
  {
334
328
  spec: createSpec({
335
- annotations: { [Annotation.Trace]: JSON.stringify([{ type: "t1", label: "Col2" }]) },
329
+ annotations: { [Annotation.Trace]: JSON.stringify([{ type: "t1", label: "Col" }]) },
336
330
  }),
331
+ linkerPath: [{ spec: createSpec({ annotations: { [Annotation.LinkLabel]: "MyLinker" } }) }],
337
332
  },
338
333
  ];
339
334
  const labels = deriveDistinctLabels(entries);
340
- expect(labels).toEqual(["Col1 via MyLinker", "Col2"]);
335
+ expect(labels).toEqual(["Col", "Col via MyLinker"]);
341
336
  });
342
337
 
343
338
  test("linkerPath with multiple steps joins with ' > '", () => {
344
339
  const entries: Entry[] = [
345
340
  {
346
341
  spec: createSpec({
347
- annotations: { [Annotation.Trace]: JSON.stringify([{ type: "t1", label: "Col1" }]) },
342
+ annotations: { [Annotation.Trace]: JSON.stringify([{ type: "t1", label: "Col" }]) },
348
343
  }),
349
- linkerPath: [
350
- { spec: createSpec({ annotations: { [Annotation.LinkLabel]: "L1" } }) },
351
- { spec: createSpec({ annotations: { [Annotation.LinkLabel]: "L2" } }) },
352
- ],
353
344
  },
354
345
  {
355
346
  spec: createSpec({
356
- annotations: { [Annotation.Trace]: JSON.stringify([{ type: "t1", label: "Col2" }]) },
347
+ annotations: { [Annotation.Trace]: JSON.stringify([{ type: "t1", label: "Col" }]) },
357
348
  }),
349
+ linkerPath: [
350
+ { spec: createSpec({ annotations: { [Annotation.LinkLabel]: "L1" } }) },
351
+ { spec: createSpec({ annotations: { [Annotation.LinkLabel]: "L2" } }) },
352
+ ],
358
353
  },
359
354
  ];
360
355
  const labels = deriveDistinctLabels(entries);
361
- expect(labels).toEqual(["Col1 via L1 > L2", "Col2"]);
356
+ expect(labels).toEqual(["Col", "Col via L1 > L2"]);
362
357
  });
363
358
 
364
359
  test("linkerPath skips steps without labels", () => {
365
360
  const entries: Entry[] = [
366
361
  {
367
362
  spec: createSpec({
368
- annotations: { [Annotation.Trace]: JSON.stringify([{ type: "t1", label: "Col1" }]) },
363
+ annotations: { [Annotation.Trace]: JSON.stringify([{ type: "t1", label: "Col" }]) },
369
364
  }),
370
- linkerPath: [
371
- { spec: createSpec() },
372
- { spec: createSpec({ annotations: { [Annotation.LinkLabel]: "L2" } }) },
373
- ],
374
365
  },
375
366
  {
376
367
  spec: createSpec({
377
- annotations: { [Annotation.Trace]: JSON.stringify([{ type: "t1", label: "Col2" }]) },
368
+ annotations: { [Annotation.Trace]: JSON.stringify([{ type: "t1", label: "Col" }]) },
378
369
  }),
370
+ linkerPath: [
371
+ { spec: createSpec() },
372
+ { spec: createSpec({ annotations: { [Annotation.LinkLabel]: "L2" } }) },
373
+ ],
379
374
  },
380
375
  ];
381
376
  const labels = deriveDistinctLabels(entries);
382
- expect(labels).toEqual(["Col1 via L2", "Col2"]);
377
+ expect(labels).toEqual(["Col", "Col via L2"]);
383
378
  });
384
379
 
385
380
  test("linkerPath with custom linkerLabelFormatter", () => {
386
381
  const entries: Entry[] = [
387
382
  {
388
383
  spec: createSpec({
389
- annotations: { [Annotation.Trace]: JSON.stringify([{ type: "t1", label: "Col1" }]) },
384
+ annotations: { [Annotation.Trace]: JSON.stringify([{ type: "t1", label: "Col" }]) },
390
385
  }),
391
- linkerPath: [{ spec: createSpec({ annotations: { [Annotation.LinkLabel]: "L1" } }) }],
392
386
  },
393
387
  {
394
388
  spec: createSpec({
395
- annotations: { [Annotation.Trace]: JSON.stringify([{ type: "t1", label: "Col2" }]) },
389
+ annotations: { [Annotation.Trace]: JSON.stringify([{ type: "t1", label: "Col" }]) },
396
390
  }),
391
+ linkerPath: [{ spec: createSpec({ annotations: { [Annotation.LinkLabel]: "L1" } }) }],
397
392
  },
398
393
  ];
399
394
  const labels = deriveDistinctLabels(entries, {
400
- linkerLabelFormatter: (linkerLabels) => `[${linkerLabels.join(", ")}]`,
395
+ formatters: { linker: (linkerLabels) => `[${linkerLabels.join(", ")}]` },
401
396
  });
402
- expect(labels).toEqual(["Col1 [L1]", "Col2"]);
397
+ expect(labels).toEqual(["Col", "Col [L1]"]);
403
398
  });
404
399
 
405
400
  test("linkerPath with linkerLabelFormatter returning undefined suppresses suffix", () => {
@@ -417,7 +412,7 @@ test("linkerPath with linkerLabelFormatter returning undefined suppresses suffix
417
412
  },
418
413
  ];
419
414
  const labels = deriveDistinctLabels(entries, {
420
- linkerLabelFormatter: () => undefined,
415
+ formatters: { linker: () => undefined },
421
416
  });
422
417
  expect(labels).toEqual(["Col1", "Col2"]);
423
418
  });
@@ -426,21 +421,140 @@ test("linkerPath falls back to Label when LinkLabel is absent", () => {
426
421
  const entries: Entry[] = [
427
422
  {
428
423
  spec: createSpec({
429
- annotations: { [Annotation.Trace]: JSON.stringify([{ type: "t1", label: "Col1" }]) },
424
+ annotations: { [Annotation.Trace]: JSON.stringify([{ type: "t1", label: "Col" }]) },
430
425
  }),
431
- linkerPath: [{ spec: createSpec({ annotations: { [Annotation.Label]: "FallbackLabel" } }) }],
432
426
  },
433
427
  {
434
428
  spec: createSpec({
435
- annotations: { [Annotation.Trace]: JSON.stringify([{ type: "t1", label: "Col2" }]) },
429
+ annotations: { [Annotation.Trace]: JSON.stringify([{ type: "t1", label: "Col" }]) },
436
430
  }),
431
+ linkerPath: [{ spec: createSpec({ annotations: { [Annotation.Label]: "FallbackLabel" } }) }],
437
432
  },
438
433
  ];
439
434
  const labels = deriveDistinctLabels(entries);
440
- expect(labels).toEqual(["Col1 via FallbackLabel", "Col2"]);
435
+ expect(labels).toEqual(["Col", "Col via FallbackLabel"]);
436
+ });
437
+
438
+ test("formatters.native customizes label rendering", () => {
439
+ const s = ((label: string) =>
440
+ ({
441
+ kind: "PColumn",
442
+ name: "n",
443
+ valueType: "Int",
444
+ axesSpec: [],
445
+ annotations: { [Annotation.Label]: label },
446
+ }) as PColumnSpec)("Counts");
447
+ const labels = deriveDistinctLabels([{ spec: s }, { spec: s }], {
448
+ formatters: { native: (l) => `<<${l}>>` },
449
+ });
450
+ expect(labels).toEqual(["<<Counts>>", "<<Counts>>"]);
451
+ });
452
+
453
+ test("formatters.native returning undefined drops label entry", () => {
454
+ const traces: Trace[] = [[{ type: "t1", label: "X" }], [{ type: "t1", label: "Y" }]];
455
+ const labels = deriveDistinctLabels(tracesToSpecs(traces), {
456
+ includeNativeLabel: true,
457
+ formatters: { native: () => undefined },
458
+ });
459
+ expect(labels).toEqual(["X", "Y"]);
460
+ });
461
+
462
+ test("formatters.hitQualification customizes hit zone", () => {
463
+ const s = {
464
+ kind: "PColumn",
465
+ name: "n",
466
+ valueType: "Int",
467
+ axesSpec: [],
468
+ annotations: { [Annotation.Label]: "Expr" },
469
+ } as PColumnSpec;
470
+ const entries: Entry[] = [
471
+ {
472
+ spec: s,
473
+ qualifications: {
474
+ forQueries: {},
475
+ forHit: [{ axis: { name: "gene" }, contextDomain: { gene: "BRCA1" } }],
476
+ },
477
+ },
478
+ {
479
+ spec: s,
480
+ qualifications: {
481
+ forQueries: {},
482
+ forHit: [{ axis: { name: "gene" }, contextDomain: { gene: "TP53" } }],
483
+ },
484
+ },
485
+ ];
486
+ const labels = deriveDistinctLabels(entries, {
487
+ formatters: { hitQualification: (qs) => `<hit:${qs[0].contextDomain.gene}>` },
488
+ });
489
+ expect(labels).toEqual(["Expr <hit:BRCA1>", "Expr <hit:TP53>"]);
490
+ });
491
+
492
+ test("formatters.anchorQualification receives anchorId", () => {
493
+ const s = {
494
+ kind: "PColumn",
495
+ name: "n",
496
+ valueType: "Int",
497
+ axesSpec: [],
498
+ annotations: { [Annotation.Label]: "Counts" },
499
+ } as PColumnSpec;
500
+ const A = "A" as PObjectId;
501
+ const entries: Entry[] = [
502
+ {
503
+ spec: s,
504
+ qualifications: {
505
+ forQueries: { [A]: [{ axis: { name: "sample" }, contextDomain: { batch: "X" } }] },
506
+ forHit: [],
507
+ },
508
+ },
509
+ {
510
+ spec: s,
511
+ qualifications: {
512
+ forQueries: { [A]: [{ axis: { name: "sample" }, contextDomain: { batch: "Y" } }] },
513
+ forHit: [],
514
+ },
515
+ },
516
+ ];
517
+ const labels = deriveDistinctLabels(entries, {
518
+ formatters: {
519
+ anchorQualification: (id, qs) => `(${id}=${qs[0].contextDomain.batch})`,
520
+ },
521
+ });
522
+ expect(labels).toEqual(["Counts (A=X)", "Counts (A=Y)"]);
441
523
  });
442
524
 
443
- // --- addLabelAsSuffix ---
525
+ test("formatters.linkerStepQualification controls inline step quals", () => {
526
+ const s = {
527
+ kind: "PColumn",
528
+ name: "n",
529
+ valueType: "Int",
530
+ axesSpec: [],
531
+ annotations: { [Annotation.Label]: "Counts" },
532
+ } as PColumnSpec;
533
+ const entries: Entry[] = [
534
+ {
535
+ spec: s,
536
+ linkerPath: [
537
+ {
538
+ spec: createSpec({ annotations: { [Annotation.LinkLabel]: "Mapper" } }),
539
+ qualifications: [{ axis: { name: "sample" }, contextDomain: { batch: "X" } }],
540
+ },
541
+ ],
542
+ },
543
+ {
544
+ spec: s,
545
+ linkerPath: [
546
+ {
547
+ spec: createSpec({ annotations: { [Annotation.LinkLabel]: "Mapper" } }),
548
+ qualifications: [{ axis: { name: "sample" }, contextDomain: { batch: "Y" } }],
549
+ },
550
+ ],
551
+ },
552
+ ];
553
+ const labels = deriveDistinctLabels(entries, {
554
+ formatters: { linkerStepQualification: (qs) => `(${qs[0].contextDomain.batch})` },
555
+ });
556
+ expect(labels).toEqual(["Counts via Mapper (X)", "Counts via Mapper (Y)"]);
557
+ });
444
558
 
445
559
  test("addLabelAsSuffix places native label at the end", () => {
446
560
  const specs = tracesToSpecs([[{ type: "t1", label: "L1" }], [{ type: "t1", label: "L2" }]]);
@@ -451,8 +565,6 @@ test("addLabelAsSuffix places native label at the end", () => {
451
565
  expect(labels).toEqual(["L1 / Label", "L2 / Label"]);
452
566
  });
453
567
 
454
- // --- separator ---
455
-
456
568
  test("custom separator is used between label parts", () => {
457
569
  const specs = tracesToSpecs([
458
570
  [
@@ -472,16 +584,12 @@ test("custom separator is used between label parts", () => {
472
584
  expect(labels).toEqual(["A - X", "A - Y", "B - Y"]);
473
585
  });
474
586
 
475
- // --- single value ---
476
-
477
587
  test("single value gets its trace label", () => {
478
588
  const specs = tracesToSpecs([[{ type: "t1", label: "Only" }]]);
479
589
  const labels = deriveDistinctLabels(specs);
480
590
  expect(labels).toEqual(["Only"]);
481
591
  });
482
592
 
483
- // --- Unlabeled fallback ---
484
-
485
593
  test("Unlabeled fallback when no trace entries match", () => {
486
594
  // Two identical specs with identical traces — fallback path
487
595
  const spec = createSpec({
@@ -502,8 +610,6 @@ test("Unlabeled when no traces and no label", () => {
502
610
  expect(result.every((r) => r === "Unlabeled")).toBe(true);
503
611
  });
504
612
 
505
- // --- repeated type occurrences (secondaryTypes path) ---
506
-
507
613
  test("repeated type occurrences are used as secondary types", () => {
508
614
  // Two records where "t1" appears twice in each, with different labels on 2nd occurrence
509
615
  const specs = tracesToSpecs([
@@ -522,8 +628,6 @@ test("repeated type occurrences are used as secondary types", () => {
522
628
  expect(labels).toEqual(["A", "B"]);
523
629
  });
524
630
 
525
- // --- spec without Annotation.Label (only Trace) ---
526
-
527
631
  test("spec without native label uses only trace entries", () => {
528
632
  const specs = [
529
633
  createSpec({
@@ -557,3 +661,243 @@ test("includeNativeLabel with no native label does not break", () => {
557
661
  const labels = deriveDistinctLabels(specs, { includeNativeLabel: true });
558
662
  expect(labels).toEqual(["X", "Y"]);
559
663
  });
664
+
665
+ describe("deriveDistinctLabels v2 — linker path & qualifications", () => {
666
+ function labeledSpec(label: string, name = "col"): PColumnSpec {
667
+ return {
668
+ kind: "PColumn",
669
+ name,
670
+ valueType: "Int",
671
+ axesSpec: [],
672
+ annotations: { [Annotation.Label]: label },
673
+ } as PColumnSpec;
674
+ }
675
+
676
+ function linkerSpec(label: string, name = "linker"): PColumnSpec {
677
+ return {
678
+ kind: "PColumn",
679
+ name,
680
+ valueType: "Int",
681
+ axesSpec: [],
682
+ annotations: { [Annotation.LinkLabel]: label },
683
+ } as PColumnSpec;
684
+ }
685
+
686
+ function qual(axis: string, ctx: Record<string, string> = {}): AxisQualification {
687
+ return { axis: { name: axis }, contextDomain: ctx };
688
+ }
689
+
690
+ const A = "anchor-main" as PObjectId;
691
+ const B = "anchor-other" as PObjectId;
692
+
693
+ test("linkerPath not appended when name alone is unique", () => {
694
+ const entries: Entry[] = [
695
+ { spec: labeledSpec("Read counts") },
696
+ { spec: labeledSpec("Coverage"), linkerPath: [{ spec: linkerSpec("Sample mapper") }] },
697
+ ];
698
+ expect(deriveDistinctLabels(entries)).toEqual(["Read counts", "Coverage"]);
699
+ });
700
+
701
+ test("linkerPath appended only when needed for uniqueness", () => {
702
+ const entries: Entry[] = [
703
+ { spec: labeledSpec("Read counts") },
704
+ { spec: labeledSpec("Read counts"), linkerPath: [{ spec: linkerSpec("Sample mapper") }] },
705
+ ];
706
+ expect(deriveDistinctLabels(entries)).toEqual(["Read counts", "Read counts via Sample mapper"]);
707
+ });
708
+
709
+ test("two linker paths → both get distinguishing via-suffix", () => {
710
+ const s = labeledSpec("Counts");
711
+ const entries: Entry[] = [
712
+ { spec: s, linkerPath: [{ spec: linkerSpec("Path A") }] },
713
+ { spec: s, linkerPath: [{ spec: linkerSpec("Path B") }] },
714
+ ];
715
+ expect(deriveDistinctLabels(entries)).toEqual(["Counts via Path A", "Counts via Path B"]);
716
+ });
717
+
718
+ test("multi-step paths joined with ' > '", () => {
719
+ const s = labeledSpec("Counts");
720
+ const entries: Entry[] = [
721
+ { spec: s, linkerPath: [{ spec: linkerSpec("Hub") }, { spec: linkerSpec("Tail X") }] },
722
+ { spec: s, linkerPath: [{ spec: linkerSpec("Hub") }, { spec: linkerSpec("Tail Y") }] },
723
+ ];
724
+ expect(deriveDistinctLabels(entries)).toEqual([
725
+ "Counts via Hub > Tail X",
726
+ "Counts via Hub > Tail Y",
727
+ ]);
728
+ });
729
+
730
+ test("hit qualifications used when nothing else differs", () => {
731
+ const s = labeledSpec("Expression");
732
+ const entries: Entry[] = [
733
+ { spec: s, qualifications: { forQueries: {}, forHit: [qual("gene", { gene: "BRCA1" })] } },
734
+ { spec: s, qualifications: { forQueries: {}, forHit: [qual("gene", { gene: "TP53" })] } },
735
+ ];
736
+ expect(deriveDistinctLabels(entries)).toEqual([
737
+ "Expression [gene=BRCA1]",
738
+ "Expression [gene=TP53]",
739
+ ]);
740
+ });
741
+
742
+ test("per-anchor qualifications named by anchor key", () => {
743
+ const s = labeledSpec("Counts");
744
+ const entries: Entry[] = [
745
+ {
746
+ spec: s,
747
+ qualifications: { forQueries: { [A]: [qual("sample", { batch: "X" })] }, forHit: [] },
748
+ },
749
+ {
750
+ spec: s,
751
+ qualifications: { forQueries: { [A]: [qual("sample", { batch: "Y" })] }, forHit: [] },
752
+ },
753
+ ];
754
+ expect(deriveDistinctLabels(entries)).toEqual([
755
+ "Counts [anchor-main: sample batch=X]",
756
+ "Counts [anchor-main: sample batch=Y]",
757
+ ]);
758
+ });
759
+
760
+ test("linker-step qualifications used to disambiguate identical linker labels", () => {
761
+ const s = labeledSpec("Counts");
762
+ const entries: Entry[] = [
763
+ {
764
+ spec: s,
765
+ linkerPath: [
766
+ { spec: linkerSpec("Mapper"), qualifications: [qual("sample", { batch: "X" })] },
767
+ ],
768
+ },
769
+ {
770
+ spec: s,
771
+ linkerPath: [
772
+ { spec: linkerSpec("Mapper"), qualifications: [qual("sample", { batch: "Y" })] },
773
+ ],
774
+ },
775
+ ];
776
+ expect(deriveDistinctLabels(entries)).toEqual([
777
+ "Counts via Mapper [sample batch=X]",
778
+ "Counts via Mapper [sample batch=Y]",
779
+ ]);
780
+ });
781
+
782
+ test("layers compose only as far as needed; no over-decoration", () => {
783
+ const entries: Entry[] = [
784
+ { spec: labeledSpec("Read counts") },
785
+ { spec: labeledSpec("Coverage") },
786
+ {
787
+ spec: labeledSpec("Coverage"),
788
+ qualifications: { forQueries: { [A]: [qual("sample", { batch: "X" })] }, forHit: [] },
789
+ },
790
+ ];
791
+ expect(deriveDistinctLabels(entries)).toEqual([
792
+ "Read counts",
793
+ "Coverage",
794
+ "Coverage [anchor-main: sample batch=X]",
795
+ ]);
796
+ });
797
+
798
+ test("hit and anchor qualifications combined when both needed", () => {
799
+ const s = labeledSpec("Counts");
800
+ const entries: Entry[] = [
801
+ {
802
+ spec: s,
803
+ qualifications: {
804
+ forQueries: { [A]: [qual("sample", { batch: "X" })] },
805
+ forHit: [qual("gene", { gene: "BRCA1" })],
806
+ },
807
+ },
808
+ {
809
+ spec: s,
810
+ qualifications: {
811
+ forQueries: { [A]: [qual("sample", { batch: "X" })] },
812
+ forHit: [qual("gene", { gene: "TP53" })],
813
+ },
814
+ },
815
+ {
816
+ spec: s,
817
+ qualifications: {
818
+ forQueries: { [A]: [qual("sample", { batch: "Y" })] },
819
+ forHit: [qual("gene", { gene: "BRCA1" })],
820
+ },
821
+ },
822
+ ];
823
+ expect(deriveDistinctLabels(entries)).toEqual([
824
+ "Counts [anchor-main: sample batch=X] [gene=BRCA1]",
825
+ "Counts [anchor-main: sample batch=X] [gene=TP53]",
826
+ "Counts [anchor-main: sample batch=Y] [gene=BRCA1]",
827
+ ]);
828
+ });
829
+
830
+ test("only distinctive anchor qualifications appear in the label", () => {
831
+ const s = labeledSpec("Counts");
832
+ const sharedB = [qual("project", { id: "P1" })];
833
+ const entries: Entry[] = [
834
+ {
835
+ spec: s,
836
+ qualifications: {
837
+ forQueries: { [A]: [qual("sample", { batch: "X" })], [B]: sharedB },
838
+ forHit: [],
839
+ },
840
+ },
841
+ {
842
+ spec: s,
843
+ qualifications: {
844
+ forQueries: { [A]: [qual("sample", { batch: "Y" })], [B]: sharedB },
845
+ forHit: [],
846
+ },
847
+ },
848
+ ];
849
+ expect(deriveDistinctLabels(entries)).toEqual([
850
+ "Counts [anchor-main: sample batch=X]",
851
+ "Counts [anchor-main: sample batch=Y]",
852
+ ]);
853
+ });
854
+
855
+ test("full decoration when every layer carries information", () => {
856
+ const sA: PColumnSpec = {
857
+ ...labeledSpec("Counts"),
858
+ annotations: {
859
+ [Annotation.Label]: "Counts",
860
+ [Annotation.Trace]: JSON.stringify([{ type: "stage", label: "RNAseq" }]),
861
+ },
862
+ } as PColumnSpec;
863
+ const sB: PColumnSpec = {
864
+ ...labeledSpec("Counts"),
865
+ annotations: {
866
+ [Annotation.Label]: "Counts",
867
+ [Annotation.Trace]: JSON.stringify([{ type: "stage", label: "ATACseq" }]),
868
+ },
869
+ } as PColumnSpec;
870
+ const entries: Entry[] = [
871
+ { spec: sA, linkerPath: [{ spec: linkerSpec("Mapper") }] },
872
+ {
873
+ spec: sA,
874
+ linkerPath: [{ spec: linkerSpec("Mapper") }],
875
+ qualifications: { forQueries: { [A]: [qual("sample", { batch: "X" })] }, forHit: [] },
876
+ },
877
+ { spec: sB, linkerPath: [{ spec: linkerSpec("Mapper") }] },
878
+ ];
879
+ expect(deriveDistinctLabels(entries)).toEqual([
880
+ "Counts / RNAseq via Mapper",
881
+ "Counts / RNAseq via Mapper [anchor-main: sample batch=X]",
882
+ "Counts / ATACseq via Mapper",
883
+ ]);
884
+ });
885
+
886
+ test("identical variants produce identical labels (cannot disambiguate)", () => {
887
+ const s = labeledSpec("Counts");
888
+ const entries: Entry[] = [{ spec: s }, { spec: s }];
889
+ expect(deriveDistinctLabels(entries)).toEqual(["Counts", "Counts"]);
890
+ });
891
+
892
+ test("axis-only qualification (no contextDomain) renders as axis name", () => {
893
+ const s = labeledSpec("Counts");
894
+ const entries: Entry[] = [
895
+ { spec: s, qualifications: { forQueries: { [A]: [qual("sample")] }, forHit: [] } },
896
+ { spec: s, qualifications: { forQueries: { [A]: [qual("gene")] }, forHit: [] } },
897
+ ];
898
+ expect(deriveDistinctLabels(entries)).toEqual([
899
+ "Counts [anchor-main: sample]",
900
+ "Counts [anchor-main: gene]",
901
+ ]);
902
+ });
903
+ });