From 9cdfd2312cc1fa03ec006da3c8f0feb5b989c021 Mon Sep 17 00:00:00 2001 From: Emil Gunnarsson Date: Wed, 15 Oct 2025 14:32:28 +0200 Subject: [PATCH] feat: format metadata floats with significant digits --- src/app/app-config.service.spec.ts | 6 + src/app/app-config.service.ts | 16 ++ .../dataset-table/dataset-table.component.ts | 15 ++ .../base-classes/metadata-input-base.spec.ts | 18 ++- .../base-classes/tree-base.spec.ts | 19 ++- .../base-classes/tree-base.ts | 5 +- .../metadata-input.component.spec.ts | 20 ++- .../tree-edit/tree-edit.component.spec.ts | 19 ++- .../tree-edit/tree-edit.component.ts | 4 +- .../tree-view/tree-view.component.html | 2 +- .../tree-view/tree-view.component.spec.ts | 21 ++- .../tree-view/tree-view.component.ts | 9 +- .../metadata-view.component.spec.ts | 29 ++++ .../metadata-view/metadata-view.component.ts | 12 +- .../shared/pipes/format-number.pipe.spec.ts | 153 ++++++++++++++++++ .../shared/pipes/format-number.pipe.spect.ts | 46 ------ src/app/shared/pipes/format-number.pipe.ts | 49 +++++- 17 files changed, 377 insertions(+), 66 deletions(-) create mode 100644 src/app/shared/pipes/format-number.pipe.spec.ts delete mode 100644 src/app/shared/pipes/format-number.pipe.spect.ts diff --git a/src/app/app-config.service.spec.ts b/src/app/app-config.service.spec.ts index e74b77dcfd..8c28356bef 100644 --- a/src/app/app-config.service.spec.ts +++ b/src/app/app-config.service.spec.ts @@ -50,6 +50,12 @@ const appConfig: AppConfigInterface = { thumbnailFetchLimitPerPage: 500, maxFileUploadSizeInMb: "16mb", maxDirectDownloadSize: 5000000000, + metadataFloatFormatEnabled: true, + metadataFloatFormat: { + significantDigits: 3, + minCutoff: 0.001, + maxCutoff: 1000, + }, metadataPreviewEnabled: true, metadataStructure: "", multipleDownloadAction: "http://localhost:3012/zip", diff --git a/src/app/app-config.service.ts b/src/app/app-config.service.ts index 34645eba49..715fb61d9d 100644 --- a/src/app/app-config.service.ts +++ b/src/app/app-config.service.ts @@ -66,6 +66,12 @@ export class MainMenuConfiguration { authenticatedUser: MainMenuOptions; } +export class MetadataFloatFormat { + significantDigits: number; + minCutoff: number; // using scientific notation below this cutoff + maxCutoff: number; // using scientific notation above this cutoff +} + export interface AppConfigInterface { allowConfigOverrides?: boolean; skipSciCatLoginPageEnabled?: boolean; @@ -111,6 +117,8 @@ export interface AppConfigInterface { maxDirectDownloadSize: number | null; metadataPreviewEnabled: boolean; metadataStructure: string; + metadataFloatFormat?: MetadataFloatFormat; + metadataFloatFormatEnabled?: boolean; multipleDownloadAction: string | null; multipleDownloadEnabled: boolean; multipleDownloadUseAuthToken: boolean; @@ -247,6 +255,14 @@ export class AppConfigService { config.dateFormat = "yyyy-MM-dd HH:mm"; } + if (!config.metadataFloatFormat) { + config.metadataFloatFormat = { + significantDigits: 3, + minCutoff: 0.001, + maxCutoff: 1000, + }; + } + this.appConfig = config; } diff --git a/src/app/datasets/dataset-table/dataset-table.component.ts b/src/app/datasets/dataset-table/dataset-table.component.ts index f253cd3d3e..d5d250b895 100644 --- a/src/app/datasets/dataset-table/dataset-table.component.ts +++ b/src/app/datasets/dataset-table/dataset-table.component.ts @@ -67,6 +67,7 @@ import { FileSizePipe } from "shared/pipes/filesize.pipe"; import { actionMenu } from "shared/modules/dynamic-material-table/utilizes/default-table-settings"; import { TableConfigService } from "shared/services/table-config.service"; import { selectInstruments } from "state-management/selectors/instruments.selectors"; +import { FormatNumberPipe } from "shared/pipes/format-number.pipe"; export interface SortChangeEvent { active: string; @@ -162,6 +163,7 @@ export class DatasetTableComponent implements OnInit, OnDestroy { private datePipe: DatePipe, private fileSize: FileSizePipe, private tableConfigService: TableConfigService, + private formatNumberPipe: FormatNumberPipe, ) {} private getInstrumentName(row: OutputDatasetObsoleteDto): string { @@ -488,6 +490,19 @@ export class DatasetTableComponent implements OnInit, OnDestroy { this.getInstrumentName(row); } + if (column.name.startsWith("scientificMetadata.")) { + convertedColumn.customRender = (col, row) => { + return String( + this.formatNumberPipe.transform(lodashGet(row, col.name)), + ); + }; + convertedColumn.toExport = (row) => { + return String( + this.formatNumberPipe.transform(lodashGet(row, column.name)), + ); + }; + } + return convertedColumn; }); } diff --git a/src/app/shared/modules/scientific-metadata-tree/base-classes/metadata-input-base.spec.ts b/src/app/shared/modules/scientific-metadata-tree/base-classes/metadata-input-base.spec.ts index bfc57d24bf..7488d6d6f8 100644 --- a/src/app/shared/modules/scientific-metadata-tree/base-classes/metadata-input-base.spec.ts +++ b/src/app/shared/modules/scientific-metadata-tree/base-classes/metadata-input-base.spec.ts @@ -6,6 +6,8 @@ import { MetadataInputComponent } from "../metadata-input/metadata-input.compone import { FormatNumberPipe } from "shared/pipes/format-number.pipe"; import { ScientificMetadataTreeModule } from "../scientific-metadata-tree.module"; import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { AppConfigService } from "app-config.service"; +import { provideHttpClient } from "@angular/common/http"; describe("MetadataInputBase", () => { let component: MetadataInputComponent; @@ -15,11 +17,25 @@ describe("MetadataInputBase", () => { TestBed.configureTestingModule({ declarations: [MetadataInputComponent], imports: [ScientificMetadataTreeModule, BrowserAnimationsModule], - providers: [FormBuilder, FormatNumberPipe], + providers: [ + FormBuilder, + FormatNumberPipe, + AppConfigService, + provideHttpClient(), + ], }).compileComponents(); })); beforeEach(() => { + const appConfigService = TestBed.inject(AppConfigService); + (appConfigService as any).appConfig = { + metadataFloatFormatEnabled: true, + metadataFloatFormat: { + significantDigits: 3, + minCutoff: 0.001, + maxCutoff: 1000, + }, + }; fixture = TestBed.createComponent(MetadataInputComponent); component = fixture.componentInstance; const data = new FlatNodeEdit(); diff --git a/src/app/shared/modules/scientific-metadata-tree/base-classes/tree-base.spec.ts b/src/app/shared/modules/scientific-metadata-tree/base-classes/tree-base.spec.ts index 70a4eaa1f7..2a88866314 100644 --- a/src/app/shared/modules/scientific-metadata-tree/base-classes/tree-base.spec.ts +++ b/src/app/shared/modules/scientific-metadata-tree/base-classes/tree-base.spec.ts @@ -7,6 +7,8 @@ import { FlatNode, TreeNode } from "../base-classes/tree-base"; import { TreeEditComponent } from "../tree-edit/tree-edit.component"; import { ScientificMetadataTreeModule } from "../scientific-metadata-tree.module"; import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { AppConfigService } from "app-config.service"; +import { provideHttpClient } from "@angular/common/http"; describe("TreeBaseComponent", () => { let component: TreeEditComponent; @@ -16,11 +18,26 @@ describe("TreeBaseComponent", () => { TestBed.configureTestingModule({ declarations: [TreeEditComponent], imports: [ScientificMetadataTreeModule, BrowserAnimationsModule], - providers: [MatDialog, MatSnackBar, DatePipe], + providers: [ + MatDialog, + MatSnackBar, + DatePipe, + AppConfigService, + provideHttpClient(), + ], }).compileComponents(); })); beforeEach(() => { + const appConfigService = TestBed.inject(AppConfigService); + (appConfigService as any).appConfig = { + metadataFloatFormatEnabled: true, + metadataFloatFormat: { + significantDigits: 3, + minCutoff: 0.001, + maxCutoff: 1000, + }, + }; fixture = TestBed.createComponent(TreeEditComponent); component = fixture.componentInstance; component.metadata = { diff --git a/src/app/shared/modules/scientific-metadata-tree/base-classes/tree-base.ts b/src/app/shared/modules/scientific-metadata-tree/base-classes/tree-base.ts index 79df24ce87..5032d76f0c 100644 --- a/src/app/shared/modules/scientific-metadata-tree/base-classes/tree-base.ts +++ b/src/app/shared/modules/scientific-metadata-tree/base-classes/tree-base.ts @@ -9,6 +9,7 @@ import { FormatNumberPipe } from "shared/pipes/format-number.pipe"; import { PrettyUnitPipe } from "shared/pipes/pretty-unit.pipe"; import { DateTimeService } from "shared/services/date-time.service"; import { UnitsService } from "shared/services/units.service"; +import { AppConfigService } from "app-config.service"; export class TreeNode { children: TreeNode[]; @@ -43,10 +44,10 @@ export class TreeBaseComponent { prettyUnitPipe: PrettyUnitPipe; unitsService: UnitsService; dateTimeService: DateTimeService; - constructor() { + constructor(protected configService: AppConfigService) { this.unitsService = new UnitsService(); this.prettyUnitPipe = new PrettyUnitPipe(this.unitsService); - this.formatNumberPipe = new FormatNumberPipe(); + this.formatNumberPipe = new FormatNumberPipe(this.configService); this.dateTimeService = new DateTimeService(); } buildDataTree(obj: { [key: string]: any }, level: number): TreeNode[] { diff --git a/src/app/shared/modules/scientific-metadata-tree/metadata-input/metadata-input.component.spec.ts b/src/app/shared/modules/scientific-metadata-tree/metadata-input/metadata-input.component.spec.ts index ba1e3773bd..ec5bf73507 100644 --- a/src/app/shared/modules/scientific-metadata-tree/metadata-input/metadata-input.component.spec.ts +++ b/src/app/shared/modules/scientific-metadata-tree/metadata-input/metadata-input.component.spec.ts @@ -9,6 +9,8 @@ import { ScientificMetadataTreeModule } from "../scientific-metadata-tree.module import { MetadataInputComponent } from "./metadata-input.component"; import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { AppConfigService } from "app-config.service"; +import { provideHttpClient } from "@angular/common/http"; describe("MetadataInputComponent", () => { let component: MetadataInputComponent; @@ -18,11 +20,25 @@ describe("MetadataInputComponent", () => { TestBed.configureTestingModule({ declarations: [MetadataInputComponent], imports: [ScientificMetadataTreeModule, BrowserAnimationsModule], - providers: [FormBuilder, FormatNumberPipe], + providers: [ + FormBuilder, + FormatNumberPipe, + AppConfigService, + provideHttpClient(), + ], }).compileComponents(); })); beforeEach(() => { + const appConfigService = TestBed.inject(AppConfigService); + (appConfigService as any).appConfig = { + metadataFloatFormatEnabled: true, + metadataFloatFormat: { + significantDigits: 3, + minCutoff: 0.001, + maxCutoff: 1000, + }, + }; fixture = TestBed.createComponent(MetadataInputComponent); component = fixture.componentInstance; const data = new FlatNodeEdit(); @@ -76,7 +92,7 @@ describe("MetadataInputComponent", () => { component.addCurrentMetadata(component.data); expect(component.metadataForm.get("type").value).toEqual("quantity"); expect(component.metadataForm.get("key").value).toEqual("energy"); - expect(component.metadataForm.get("value").value).toEqual(3); + expect(component.metadataForm.get("value").value).toEqual("3"); expect(component.metadataForm.get("unit").value).toEqual("joule"); }); it("should set values in form control (number)", () => { diff --git a/src/app/shared/modules/scientific-metadata-tree/tree-edit/tree-edit.component.spec.ts b/src/app/shared/modules/scientific-metadata-tree/tree-edit/tree-edit.component.spec.ts index 857ea41827..d2532355f9 100644 --- a/src/app/shared/modules/scientific-metadata-tree/tree-edit/tree-edit.component.spec.ts +++ b/src/app/shared/modules/scientific-metadata-tree/tree-edit/tree-edit.component.spec.ts @@ -10,6 +10,8 @@ import { ScientificMetadataTreeModule } from "../scientific-metadata-tree.module import { FlatNodeEdit, TreeEditComponent } from "./tree-edit.component"; import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { AppConfigService } from "app-config.service"; +import { provideHttpClient } from "@angular/common/http"; describe("TreeEditComponent", () => { let component: TreeEditComponent; @@ -19,11 +21,26 @@ describe("TreeEditComponent", () => { TestBed.configureTestingModule({ declarations: [TreeEditComponent], imports: [ScientificMetadataTreeModule, BrowserAnimationsModule], - providers: [MatDialog, MatSnackBar, DatePipe], + providers: [ + MatDialog, + MatSnackBar, + DatePipe, + AppConfigService, + provideHttpClient(), + ], }).compileComponents(); })); beforeEach(() => { + const appConfigService = TestBed.inject(AppConfigService); + (appConfigService as any).appConfig = { + metadataFloatFormatEnabled: true, + metadataFloatFormat: { + significantDigits: 3, + minCutoff: 0.001, + maxCutoff: 1000, + }, + }; fixture = TestBed.createComponent(TreeEditComponent); component = fixture.componentInstance; component.metadata = { diff --git a/src/app/shared/modules/scientific-metadata-tree/tree-edit/tree-edit.component.ts b/src/app/shared/modules/scientific-metadata-tree/tree-edit/tree-edit.component.ts index 525e962dfd..75dcfc3131 100644 --- a/src/app/shared/modules/scientific-metadata-tree/tree-edit/tree-edit.component.ts +++ b/src/app/shared/modules/scientific-metadata-tree/tree-edit/tree-edit.component.ts @@ -29,6 +29,7 @@ import { MatSnackBar } from "@angular/material/snack-bar"; import { DatePipe } from "@angular/common"; import { Type } from "../base-classes/metadata-input-base"; import { DateTime } from "luxon"; +import { AppConfigService } from "app-config.service"; export class FlatNodeEdit implements FlatNode { key: string; @@ -64,8 +65,9 @@ export class TreeEditComponent public dialog: MatDialog, private snackBar: MatSnackBar, datePipe: DatePipe, + configService: AppConfigService, ) { - super(); + super(configService); this.datePipe = datePipe; this.treeFlattener = new MatTreeFlattener( this.transformer, diff --git a/src/app/shared/modules/scientific-metadata-tree/tree-view/tree-view.component.html b/src/app/shared/modules/scientific-metadata-tree/tree-view/tree-view.component.html index a45e785506..cc38d73b17 100644 --- a/src/app/shared/modules/scientific-metadata-tree/tree-view/tree-view.component.html +++ b/src/app/shared/modules/scientific-metadata-tree/tree-view/tree-view.component.html @@ -38,7 +38,7 @@
-
{{ getValueRepresentation(node) }}
+
{{ getValueRepresentation(node) | formatNumber }}
diff --git a/src/app/shared/modules/scientific-metadata-tree/tree-view/tree-view.component.spec.ts b/src/app/shared/modules/scientific-metadata-tree/tree-view/tree-view.component.spec.ts index d0f99b062b..cb25aef150 100644 --- a/src/app/shared/modules/scientific-metadata-tree/tree-view/tree-view.component.spec.ts +++ b/src/app/shared/modules/scientific-metadata-tree/tree-view/tree-view.component.spec.ts @@ -3,23 +3,40 @@ import { waitForAsync, ComponentFixture, TestBed } from "@angular/core/testing"; import { FormatNumberPipe } from "shared/pipes/format-number.pipe"; import { PrettyUnitPipe } from "shared/pipes/pretty-unit.pipe"; import { ScientificMetadataTreeModule } from "../scientific-metadata-tree.module"; - import { TreeViewComponent } from "./tree-view.component"; import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { AppConfigService } from "app-config.service"; +import { provideHttpClient } from "@angular/common/http"; describe("TreeViewComponent", () => { let component: TreeViewComponent; let fixture: ComponentFixture; beforeEach(waitForAsync(() => { + TestBed.resetTestingModule(); TestBed.configureTestingModule({ declarations: [TreeViewComponent], imports: [ScientificMetadataTreeModule, BrowserAnimationsModule], - providers: [DatePipe, PrettyUnitPipe, FormatNumberPipe], + providers: [ + DatePipe, + PrettyUnitPipe, + FormatNumberPipe, + AppConfigService, + provideHttpClient(), + ], }).compileComponents(); })); beforeEach(() => { + const appConfigService = TestBed.inject(AppConfigService); + (appConfigService as any).appConfig = { + metadataFloatFormatEnabled: true, + metadataFloatFormat: { + significantDigits: 3, + minCutoff: 0.001, + maxCutoff: 1000, + }, + }; fixture = TestBed.createComponent(TreeViewComponent); component = fixture.componentInstance; component.metadata = { diff --git a/src/app/shared/modules/scientific-metadata-tree/tree-view/tree-view.component.ts b/src/app/shared/modules/scientific-metadata-tree/tree-view/tree-view.component.ts index ab0c9e647c..c4712f0481 100644 --- a/src/app/shared/modules/scientific-metadata-tree/tree-view/tree-view.component.ts +++ b/src/app/shared/modules/scientific-metadata-tree/tree-view/tree-view.component.ts @@ -16,6 +16,7 @@ import { TreeNode, } from "shared/modules/scientific-metadata-tree/base-classes/tree-base"; import { DatePipe } from "@angular/common"; +import { AppConfigService } from "app-config.service"; @Component({ selector: "tree-view", templateUrl: "./tree-view.component.html", @@ -27,9 +28,11 @@ export class TreeViewComponent implements OnInit, OnChanges { @Input() metadata: any; - constructor(datePipe: DatePipe) { - super(); - this.datePipe = datePipe; + constructor( + public datePipe: DatePipe, + configService: AppConfigService, + ) { + super(configService); this.treeFlattener = new MatTreeFlattener( this.transformer, this.getLevel, diff --git a/src/app/shared/modules/scientific-metadata/metadata-view/metadata-view.component.spec.ts b/src/app/shared/modules/scientific-metadata/metadata-view/metadata-view.component.spec.ts index 657cfc1902..34d9eed8d3 100644 --- a/src/app/shared/modules/scientific-metadata/metadata-view/metadata-view.component.spec.ts +++ b/src/app/shared/modules/scientific-metadata/metadata-view/metadata-view.component.spec.ts @@ -8,6 +8,9 @@ import { ReplaceUnderscorePipe } from "shared/pipes/replace-underscore.pipe"; import { LinkyPipe } from "ngx-linky"; import { DatePipe, TitleCasePipe } from "@angular/common"; import { PrettyUnitPipe } from "shared/pipes/pretty-unit.pipe"; +import { AppConfigService } from "app-config.service"; +import { provideHttpClient } from "@angular/common/http"; +import { FormatNumberPipe } from "shared/pipes/format-number.pipe"; describe("MetadataViewComponent", () => { let component: MetadataViewComponent; @@ -23,12 +26,25 @@ describe("MetadataViewComponent", () => { DatePipe, LinkyPipe, PrettyUnitPipe, + FormatNumberPipe, + AppConfigService, + provideHttpClient(), ], declarations: [MetadataViewComponent], }).compileComponents(); })); beforeEach(() => { + const appConfigService = TestBed.inject(AppConfigService); + spyOn(appConfigService as any, "getConfig").and.returnValue({ + metadataFloatFormatEnabled: true, + metadataFloatFormat: { + significantDigits: 3, + minCutoff: 0.001, + maxCutoff: 1000, + }, + }); + fixture = TestBed.createComponent(MetadataViewComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -53,6 +69,19 @@ describe("MetadataViewComponent", () => { expect(metadataArray[0]["unit"]).toEqual(""); }); + it("should round float value if float formatting enabled", () => { + const testMetadata = { + someMetadata: { + value: 12.39421321511, + unit: "m", + }, + }; + const metadataArray = component.createMetadataArray(testMetadata); + + expect(metadataArray[0]["value"]).toEqual("12.4"); + expect(metadataArray[0]["unit"]).toEqual("m"); + }); + it("should parse an untyped metadata object to an array", () => { const testMetadata = { untypedTestName: { diff --git a/src/app/shared/modules/scientific-metadata/metadata-view/metadata-view.component.ts b/src/app/shared/modules/scientific-metadata/metadata-view/metadata-view.component.ts index d080ea2841..ffdcef4475 100644 --- a/src/app/shared/modules/scientific-metadata/metadata-view/metadata-view.component.ts +++ b/src/app/shared/modules/scientific-metadata/metadata-view/metadata-view.component.ts @@ -23,6 +23,7 @@ import { DateTime } from "luxon"; import { MetadataTypes } from "../metadata-edit/metadata-edit.component"; import { actionMenu } from "shared/modules/dynamic-material-table/utilizes/default-table-settings"; import { TablePaginationMode } from "shared/modules/dynamic-material-table/models/table-pagination.model"; +import { FormatNumberPipe } from "shared/pipes/format-number.pipe"; @Component({ selector: "metadata-view", @@ -179,6 +180,7 @@ export class MetadataViewComponent implements OnInit, OnChanges { constructor( private unitsService: UnitsService, private datePipe: DatePipe, + private formatNumberPipe: FormatNumberPipe, public linkyPipe: LinkyPipe, public prettyUnit: PrettyUnitPipe, ) {} @@ -195,9 +197,13 @@ export class MetadataViewComponent implements OnInit, OnChanges { typeof metadata[key] === "object" && "value" in (metadata[key] as ScientificMetadata) ) { + const formattedValue = this.formatNumberPipe.transform( + metadata[key]["value"], + ); + metadataObject = { name: key, - value: metadata[key]["value"], + value: formattedValue, unit: metadata[key]["unit"], human_name: humanReadableName, type: metadata[key]["type"], @@ -216,9 +222,11 @@ export class MetadataViewComponent implements OnInit, OnChanges { ? metadata[key] : JSON.stringify(metadata[key]); + const formattedValue = this.formatNumberPipe.transform(metadataValue); + metadataObject = { name: key, - value: metadataValue, + value: formattedValue, unit: "", human_name: humanReadableName, type: metadata[key]["type"], diff --git a/src/app/shared/pipes/format-number.pipe.spec.ts b/src/app/shared/pipes/format-number.pipe.spec.ts new file mode 100644 index 0000000000..c01b7e61de --- /dev/null +++ b/src/app/shared/pipes/format-number.pipe.spec.ts @@ -0,0 +1,153 @@ +import { TestBed } from "@angular/core/testing"; +import { FormatNumberPipe } from "./format-number.pipe"; +import { AppConfigInterface, AppConfigService } from "app-config.service"; + +describe("FormatNumberPipe", () => { + let mockConfigService: jasmine.SpyObj; + + beforeEach(() => { + mockConfigService = jasmine.createSpyObj("AppConfigService", ["getConfig"]); + + TestBed.configureTestingModule({ + providers: [{ provide: AppConfigService, useValue: mockConfigService }], + }); + }); + + function setConfig( + options?: Partial<{ + enabled: boolean; + significantDigits: number; + minCutoff: number; + maxCutoff: number; + }>, + ) { + const mockConfig: Partial = { + metadataFloatFormatEnabled: options?.enabled ?? true, + metadataFloatFormat: { + significantDigits: options?.significantDigits ?? 3, + minCutoff: options?.minCutoff ?? 0.001, + maxCutoff: options?.maxCutoff ?? 1000, + }, + }; + + mockConfigService.getConfig.and.returnValue( + mockConfig as AppConfigInterface, + ); + } + + describe("Legacy formatting", () => { + it("create an instance", () => { + setConfig({ enabled: false }); + const pipe = new FormatNumberPipe(mockConfigService); + expect(pipe).toBeTruthy(); + }); + + it("returns exponential number when number >= 1e5 ", () => { + setConfig({ enabled: false }); + const pipe = new FormatNumberPipe(mockConfigService); + const nbr = 100000; + const formatted = pipe.transform(nbr); + expect(formatted.toString()).toEqual("1e+5"); + }); + + it("returns exponential number when number <= 1e-5", () => { + setConfig({ enabled: false }); + const pipe = new FormatNumberPipe(mockConfigService); + const nbr = 0.00001; + const formatted = pipe.transform(nbr); + expect(formatted.toString()).toEqual("1e-5"); + }); + + it("returns number when 1e-5 <= number <= 1e5", () => { + setConfig({ enabled: false }); + const pipe = new FormatNumberPipe(mockConfigService); + const nbr = 0.0001; + const formatted = pipe.transform(nbr); + expect(formatted).toEqual(String(nbr)); + }); + + it("returns 'null' when number is null", () => { + setConfig({ enabled: false }); + const pipe = new FormatNumberPipe(mockConfigService); + const nbr = null; + const formatted = pipe.transform(nbr); + expect(formatted).toEqual("null"); + }); + + it("returns 'undefined' when number is undefined", () => { + setConfig({ enabled: false }); + const pipe = new FormatNumberPipe(mockConfigService); + const nbr = undefined; + const formatted = pipe.transform(nbr); + expect(formatted).toEqual("undefined"); + }); + + it("returns string when number is a string", () => { + setConfig({ enabled: false }); + const pipe = new FormatNumberPipe(mockConfigService); + const nbr = "test"; + const formatted = pipe.transform(nbr); + expect(formatted).toEqual("test"); + }); + }); + + describe("metadataFloatFormatEnabled is true", () => { + it("should return string as-is for non-number values", () => { + setConfig(); + const pipe = new FormatNumberPipe(mockConfigService); + expect(pipe.transform("abc")).toBe("abc"); + expect(pipe.transform("")).toBe(""); + }); + + it("should return string representation for non-finite numbers", () => { + setConfig(); + const pipe = new FormatNumberPipe(mockConfigService); + expect(pipe.transform(NaN)).toBe("NaN"); + expect(pipe.transform(Infinity)).toBe("Infinity"); + expect(pipe.transform(-Infinity)).toBe("-Infinity"); + }); + + it("should return string representation for integers", () => { + setConfig(); + const pipe = new FormatNumberPipe(mockConfigService); + expect(pipe.transform(42)).toBe("42"); + expect(pipe.transform(-10)).toBe("-10"); + }); + + it("should omit decimals when significant digits fit within the integer part", () => { + setConfig({ significantDigits: 2 }); + const pipe = new FormatNumberPipe(mockConfigService); + expect(pipe.transform(12.3456)).toBe("12"); + }); + + it("should use exponential notation for very small numbers", () => { + setConfig({ significantDigits: 3, minCutoff: 0.001 }); + const pipe = new FormatNumberPipe(mockConfigService); + expect(pipe.transform(0.0000123)).toBe("1.23e-5"); + }); + + it("should use exponential notation for very large numbers", () => { + setConfig({ significantDigits: 4, maxCutoff: 1e6 }); + const pipe = new FormatNumberPipe(mockConfigService); + expect(pipe.transform(50004313.487)).toBe("5.000e+7"); + }); + + it("should handle values just above minCutoff correctly", () => { + setConfig({ minCutoff: 0.001, significantDigits: 4 }); + const pipe = new FormatNumberPipe(mockConfigService); + expect(pipe.transform(0.0023456)).toBe("0.002346"); + }); + + it("should handle values just below maxCutoff correctly", () => { + setConfig({ maxCutoff: 1e6, significantDigits: 3 }); + const pipe = new FormatNumberPipe(mockConfigService); + expect(pipe.transform(999999.99)).toBe("1.00e+6"); + }); + + it("should respect significantDigits when using exponential notation", () => { + setConfig({ significantDigits: 5, minCutoff: 1e-3 }); + const pipe = new FormatNumberPipe(mockConfigService); + expect(pipe.transform(0.0000123)).toBe("1.2300e-5"); + }); + }); +}); diff --git a/src/app/shared/pipes/format-number.pipe.spect.ts b/src/app/shared/pipes/format-number.pipe.spect.ts deleted file mode 100644 index 12c478d039..0000000000 --- a/src/app/shared/pipes/format-number.pipe.spect.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { FormatNumberPipe } from "./format-number.pipe"; - -describe("FormatNumberPipe", () => { - it("create an instance", () => { - const pipe = new FormatNumberPipe(); - expect(pipe).toBeTruthy(); - }); - - it("returns exponential number when number >= 1e5 ", () => { - const pipe = new FormatNumberPipe(); - const nbr = 100000; - const formatted = pipe.transform(nbr); - expect(formatted.toString()).toEqual("1e+5"); - }); - - it("returns exponential number when number <= 1e-5", () => { - const pipe = new FormatNumberPipe(); - const nbr = 0.00001; - const formatted = pipe.transform(nbr); - expect(formatted.toString()).toEqual("1e-5"); - }); - it("returns number when 1e-5 <= number <= 1e5", () => { - const pipe = new FormatNumberPipe(); - const nbr = 0.0001; - const formatted = pipe.transform(nbr); - expect(formatted).toEqual(nbr); - }); - it("returns null when number is null", () => { - const pipe = new FormatNumberPipe(); - const nbr = null; - const formatted = pipe.transform(nbr); - expect(formatted).toBeNull(); - }); - it("returns undefined when number is undefined", () => { - const pipe = new FormatNumberPipe(); - const nbr = undefined; - const formatted = pipe.transform(nbr); - expect(formatted).toBeUndefined(); - }); - it("returns string when number is a string", () => { - const pipe = new FormatNumberPipe(); - const nbr = "test"; - const formatted = pipe.transform(nbr); - expect(formatted).toEqual("test"); - }); -}); diff --git a/src/app/shared/pipes/format-number.pipe.ts b/src/app/shared/pipes/format-number.pipe.ts index 19710d61c2..f7806ff2c1 100644 --- a/src/app/shared/pipes/format-number.pipe.ts +++ b/src/app/shared/pipes/format-number.pipe.ts @@ -1,13 +1,54 @@ import { Pipe, PipeTransform } from "@angular/core"; +import { AppConfigService } from "app-config.service"; @Pipe({ name: "formatNumber", standalone: false, + pure: true, }) export class FormatNumberPipe implements PipeTransform { - transform(value: any) { - if (typeof value === "number" && (value >= 1e5 || value <= 1e-5)) { - return value.toExponential(); + private enabled: boolean; + private significantDigits: number; + private minCutoff: number; + private maxCutoff: number; + + constructor(private configService: AppConfigService) { + const config = this.configService.getConfig(); + this.enabled = config.metadataFloatFormatEnabled ?? false; + + if (this.enabled) { + const format = config.metadataFloatFormat; + this.significantDigits = format.significantDigits; + this.minCutoff = format.minCutoff; + this.maxCutoff = format.maxCutoff; } - return value; + } + + transform(value: unknown): string | number { + // use old way if not enabled + if (!this.enabled) { + if (typeof value === "number" && (value >= 1e5 || value <= 1e-5)) { + return value.toExponential(); + } + return String(value); + } + + if (typeof value !== "number" || !Number.isFinite(value)) { + // value is not a finite number + return String(value); + } + + // Do not format integers + if (Number.isInteger(value)) { + return String(value); + } + + // use scientific notation if float value is large or small + const absoluteValue = Math.abs(value); + if (absoluteValue < this.minCutoff || absoluteValue > this.maxCutoff) { + // use scientific notation with (significantDigits - 1) decimals + return value.toExponential(this.significantDigits - 1); + } + + return value.toPrecision(this.significantDigits); } }