diff --git a/package.json b/package.json index 4d6eb88..568d351 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,8 @@ "@types/react": "18.3.16", "@types/react-dom": "18.3.5", "@types/recharts": "^1.8.29", - "deep-equal": "^2.2.3", "i18next": "^23.9.0", + "immer": "^10.1.1", "lucide-react": "^0.456.0", "papaparse": "^5.4.1", "react": "18.3.1", @@ -37,7 +37,8 @@ "recharts": "^2.15.0", "three": "^0.169.0", "typescript": "5.7.2", - "use-debounce": "^10.0.4" + "use-debounce": "^10.0.4", + "use-immer": "^0.11.0" }, "scripts": { "dev": "yarn vite", @@ -69,8 +70,10 @@ "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.12.0", "@playwright/test": "^1.49.0", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", "@trivago/prettier-plugin-sort-imports": "^4.3.0", - "@types/deep-equal": "^1.0.4", "@types/i18n": "0.13.12", "@types/papaparse": "^5.3.15", "@types/three": "^0", @@ -95,6 +98,7 @@ "eslint-plugin-react-hooks": "5.0.0", "globals": "^15.11.0", "husky": "9.1.7", + "jsdom": "^25.0.1", "nock": "^13.5.3", "nyc": "17.1.0", "playwright-test-coverage": "^1.2.12", diff --git a/src/context/ChartContext.tsx b/src/context/ChartContext.tsx new file mode 100644 index 0000000..ccb639b --- /dev/null +++ b/src/context/ChartContext.tsx @@ -0,0 +1,65 @@ +import { ReactNode, createContext, useContext, useMemo, useState } from 'react'; + +import { undefinedContextErrorFactory } from '@/utils/context'; + +type Period = { + numberOfDays: number; + labelKey: + | 'ONE_MONTH' + | 'THREE_MONTHS' + | 'SIX_MONTHS' + | 'ONE_YEAR' + | 'THREE_YEARS'; +}; + +export const PERIODS: Period[] = [ + { numberOfDays: 30, labelKey: 'ONE_MONTH' }, + { numberOfDays: 90, labelKey: 'THREE_MONTHS' }, + { numberOfDays: 180, labelKey: 'SIX_MONTHS' }, + { numberOfDays: 365, labelKey: 'ONE_YEAR' }, + { numberOfDays: 1_095, labelKey: 'THREE_YEARS' }, +] as const; + +type ChartContextType = { + period: Period; + updatePeriod: (periodValue: number) => void; +}; + +const ChartContext = createContext(null); + +type Props = { + children: ReactNode; +}; + +export const ChartProvider = ({ children }: Props): ReactNode => { + const [period, setPeriod] = useState(PERIODS[0]); + + const updatePeriod = (periodValue: number): void => + setPeriod( + PERIODS.find((p) => periodValue === p.numberOfDays) ?? PERIODS[0], + ); + + const contextValue = useMemo( + () => ({ + period, + updatePeriod, + }), + [period], + ); + + return ( + + {children} + + ); +}; + +export const useChart = (): ChartContextType => { + const context = useContext(ChartContext); + + if (!context) { + throw undefinedContextErrorFactory('Chart'); + } + + return context; +}; diff --git a/src/context/SimulationContext.tsx b/src/context/SimulationContext.tsx index 239e272..8e3f2ac 100644 --- a/src/context/SimulationContext.tsx +++ b/src/context/SimulationContext.tsx @@ -18,20 +18,25 @@ import { UseHouseComponentsReturnType, useHouseComponents, } from '@/hooks/useHouseComponents'; -import { HouseComponentsConfigurator } from '@/models/HouseComponentsConfigurator'; import { SimulationDay, createDefault, simulationHistory, } from '@/reducer/simulationHistoryReducer'; import { FormattedHeatLoss } from '@/types/heatLoss'; -import { HeatLossPerComponent } from '@/types/houseComponent'; +import { + HeatLossPerComponent, + HeatLossPerComponentEntries, +} from '@/types/houseComponent'; import { SimulationStatus } from '@/types/simulation'; import { TemperatureRow, UserOutdoorTemperature } from '@/types/temperatures'; import { NonEmptyArray } from '@/types/utils'; import { WindowScaleSize, WindowSizeType } from '@/types/window'; import { undefinedContextErrorFactory } from '@/utils/context'; -import { formatHeatLossRate } from '@/utils/heatLoss'; +import { + calculateHeatLossConstantFactor, + formatHeatLossRate, +} from '@/utils/heatLoss'; import { getOutdoorTemperature, loadTemperaturesFromCSV, @@ -98,7 +103,6 @@ type SimulationContextType = { }; numberOfFloors: number; updateNumberOfFloors: (numberOfFloors: number) => void; - componentsConfigurator: HouseComponentsConfigurator; }; }; @@ -143,14 +147,35 @@ export const SimulationProvider = ({ const currentDay = simulationDays[currentDayIdx]; // Hooks - const houseComponentsHook = useHouseComponents({ - onChange: (newHouseConfigurator) => { + const houseComponentsHook = useHouseComponents(); + + // Transform in array here for performances in the SimulationHeatLoss. + // Otherwise, the transformation will be executed on each changes vs once here. + const heatLossConstantFactors: HeatLossPerComponentEntries = useMemo( + () => + Object.entries( + houseComponentsHook.all.reduce( + (acc, c) => ({ + ...acc, + [c.houseComponentId]: calculateHeatLossConstantFactor({ + area: c.actualArea, + materials: c.buildingMaterials, + }), + }), + {}, + ), + ), + [houseComponentsHook.all], + ); + + useEffect( + () => dispatchHistory({ - type: 'updateHouseConfigurator', - houseConfigurator: newHouseConfigurator, - }); - }, - }); + type: 'updateConstantFactors', + heatLossConstantFactors, + }), + [heatLossConstantFactors], + ); // Load CSV useEffect(() => { @@ -328,7 +353,6 @@ export const SimulationProvider = ({ pricekWh, windowSize, numberOfFloors, - houseConfigurator, } = simulationSettings; return { @@ -388,7 +412,6 @@ export const SimulationProvider = ({ }, numberOfFloors, updateNumberOfFloors, - componentsConfigurator: houseConfigurator, ...houseComponentsHook, }, }; diff --git a/src/hooks/useHouseComponents.test.tsx b/src/hooks/useHouseComponents.test.tsx new file mode 100644 index 0000000..fda400c --- /dev/null +++ b/src/hooks/useHouseComponents.test.tsx @@ -0,0 +1,455 @@ +import { act, renderHook } from '@testing-library/react'; +import { enableMapSet } from 'immer'; +import { describe, expect, it } from 'vitest'; + +import { HOUSE_INSULATIONS } from '@/config/houseInsulations'; +import { + SIMULATION_DEFAULT_WALL_COMPONENT_INSULATION, + SIMULATION_DEFAULT_WINDOW_COMPONENT_INSULATION, +} from '@/config/simulation'; +import { HouseComponent } from '@/types/houseComponent'; + +import { useHouseComponents } from './useHouseComponents'; + +// allow to manipulate Map state as a mutable state +enableMapSet(); + +describe('useHouseComponents', () => { + it('should register and unregister a component', () => { + const { result } = renderHook(() => useHouseComponents()); + + act(() => { + result.current.registerComponent({ + componentId: 'wall1', + size: { height: 5, width: 2 }, + componentType: HouseComponent.Wall, + }); + + result.current.registerComponent({ + componentId: 'wall2', + size: { height: 5, width: 2 }, + componentType: HouseComponent.Wall, + }); + }); + + let allComponents = result.current.all; + expect(allComponents).toHaveLength(2); + expect(allComponents[0].houseComponentId).toBe('wall1'); + expect(allComponents[1].houseComponentId).toBe('wall2'); + + act(() => { + result.current.unregisterComponent({ componentId: 'wall1' }); + }); + + allComponents = result.current.all; + expect(allComponents).toHaveLength(1); + expect(allComponents[0].houseComponentId).toBe('wall2'); + }); + + it('should unregister a child component when removing parent', () => { + const { result } = renderHook(() => useHouseComponents()); + + act(() => { + result.current.registerComponent({ + componentId: 'wall1', + size: { height: 5, width: 2 }, + componentType: HouseComponent.Wall, + }); + + result.current.registerComponent({ + componentId: 'wall2', + size: { height: 5, width: 2 }, + componentType: HouseComponent.Wall, + }); + + result.current.registerComponent({ + parentId: 'wall1', + componentId: 'window1', + size: { height: 1, width: 1 }, + componentType: HouseComponent.Window, + }); + }); + + let allComponents = result.current.all; + expect(allComponents).toHaveLength(3); + expect(allComponents[0].houseComponentId).toBe('wall1'); + expect(allComponents[1].houseComponentId).toBe('wall2'); + expect(allComponents[2].houseComponentId).toBe('window1'); + + act(() => { + result.current.unregisterComponent({ componentId: 'wall1' }); + }); + + allComponents = result.current.all; + expect(allComponents).toHaveLength(1); + expect(allComponents[0].houseComponentId).toBe('wall2'); + }); + + it('should get all component with correct area', () => { + const { result } = renderHook(() => useHouseComponents()); + + act(() => { + result.current.registerComponent({ + componentId: 'wall1', + size: { height: 5, width: 2 }, + componentType: HouseComponent.Wall, + }); + + result.current.registerComponent({ + parentId: 'wall1', + componentId: 'window1', + size: { height: 2, width: 1 }, + componentType: HouseComponent.Window, + }); + }); + + const allComponents = result.current.all; + expect(allComponents).toHaveLength(2); + expect(allComponents).toEqual([ + { + houseComponentId: 'wall1', + ...SIMULATION_DEFAULT_WALL_COMPONENT_INSULATION, + componentType: HouseComponent.Wall, + size: { height: 5, width: 2 }, + actualArea: 5 * 2 - 2 * 1, + }, + { + houseComponentId: 'window1', + ...SIMULATION_DEFAULT_WINDOW_COMPONENT_INSULATION, + componentType: HouseComponent.Window, + size: { height: 2, width: 1 }, + actualArea: 2 * 1, + }, + ]); + }); + + it('should get component by type', () => { + const { result } = renderHook(() => useHouseComponents()); + + act(() => { + result.current.registerComponent({ + componentId: 'wall1', + size: { height: 5, width: 2 }, + componentType: HouseComponent.Wall, + }); + + result.current.registerComponent({ + componentId: 'wall2', + size: { height: 5, width: 2 }, + componentType: HouseComponent.Wall, + }); + + result.current.registerComponent({ + parentId: 'wall1', + componentId: 'window1', + size: { height: 2, width: 1 }, + componentType: HouseComponent.Window, + }); + }); + + const allComponents = result.current.all; + expect(allComponents).toHaveLength(3); + expect( + result.current.getFirstOfType(HouseComponent.Wall)?.houseComponentId, + ).toEqual('wall1'); + expect( + result.current.getFirstOfType(HouseComponent.Wall)?.actualArea, + ).toEqual(5 * 2 - 2 * 1); + expect( + result.current.getFirstOfType(HouseComponent.Window)?.houseComponentId, + ).toEqual('window1'); + }); + + it('should change the wall insulations but not the windows', () => { + const { result } = renderHook(() => useHouseComponents()); + + act(() => { + result.current.registerComponent({ + componentId: 'wall1', + size: { height: 5, width: 2 }, + componentType: HouseComponent.Wall, + }); + + result.current.registerComponent({ + parentId: 'wall1', + componentId: 'window1', + size: { height: 2, width: 1 }, + componentType: HouseComponent.Window, + }); + }); + + let allComponents = result.current.all; + expect(allComponents).toHaveLength(2); + expect(result.current.getFirstOfType(HouseComponent.Wall)).toEqual({ + houseComponentId: 'wall1', + ...SIMULATION_DEFAULT_WALL_COMPONENT_INSULATION, + componentType: HouseComponent.Wall, + size: { height: 5, width: 2 }, + actualArea: 5 * 2 - 2 * 1, + }); + expect(result.current.getFirstOfType(HouseComponent.Window)).toEqual({ + houseComponentId: 'window1', + ...SIMULATION_DEFAULT_WINDOW_COMPONENT_INSULATION, + componentType: HouseComponent.Window, + size: { height: 2, width: 1 }, + actualArea: 2 * 1, + }); + + act(() => { + result.current.changeComponentInsulation({ + componentType: HouseComponent.Wall, + newInsulation: 'Brick', + }); + }); + + allComponents = result.current.all; + expect(allComponents).toHaveLength(2); + expect(result.current.getFirstOfType(HouseComponent.Wall)).toEqual({ + houseComponentId: 'wall1', + insulationName: 'Brick', + buildingMaterials: HOUSE_INSULATIONS.Wall.Brick, + componentType: HouseComponent.Wall, + size: { height: 5, width: 2 }, + actualArea: 5 * 2 - 2 * 1, + }); + expect(result.current.getFirstOfType(HouseComponent.Window)).toEqual({ + houseComponentId: 'window1', + ...SIMULATION_DEFAULT_WINDOW_COMPONENT_INSULATION, + componentType: HouseComponent.Window, + size: { height: 2, width: 1 }, + actualArea: 2 * 1, + }); + }); + + it('should change the window insulations but not the walls', () => { + const { result } = renderHook(() => useHouseComponents()); + + act(() => { + result.current.registerComponent({ + componentId: 'wall1', + size: { height: 5, width: 2 }, + componentType: HouseComponent.Wall, + }); + + result.current.registerComponent({ + parentId: 'wall1', + componentId: 'window1', + size: { height: 2, width: 1 }, + componentType: HouseComponent.Window, + }); + }); + + let allComponents = result.current.all; + expect(allComponents).toHaveLength(2); + expect(result.current.getFirstOfType(HouseComponent.Wall)).toEqual({ + houseComponentId: 'wall1', + ...SIMULATION_DEFAULT_WALL_COMPONENT_INSULATION, + componentType: HouseComponent.Wall, + size: { height: 5, width: 2 }, + actualArea: 5 * 2 - 2 * 1, + }); + expect(result.current.getFirstOfType(HouseComponent.Window)).toEqual({ + houseComponentId: 'window1', + ...SIMULATION_DEFAULT_WINDOW_COMPONENT_INSULATION, + componentType: HouseComponent.Window, + size: { height: 2, width: 1 }, + actualArea: 2 * 1, + }); + + act(() => { + result.current.changeComponentInsulation({ + componentType: HouseComponent.Window, + newInsulation: 'SinglePane', + }); + }); + + allComponents = result.current.all; + expect(allComponents).toHaveLength(2); + expect(result.current.getFirstOfType(HouseComponent.Wall)).toEqual({ + houseComponentId: 'wall1', + ...SIMULATION_DEFAULT_WALL_COMPONENT_INSULATION, + componentType: HouseComponent.Wall, + size: { height: 5, width: 2 }, + actualArea: 5 * 2 - 2 * 1, + }); + expect(result.current.getFirstOfType(HouseComponent.Window)).toEqual({ + houseComponentId: 'window1', + insulationName: 'SinglePane', + buildingMaterials: HOUSE_INSULATIONS.Window.SinglePane, + componentType: HouseComponent.Window, + size: { height: 2, width: 1 }, + actualArea: 2 * 1, + }); + }); + + it('should update the wall components but not the windows', () => { + const { result } = renderHook(() => useHouseComponents()); + + act(() => { + result.current.registerComponent({ + componentId: 'wall1', + size: { height: 5, width: 2 }, + componentType: HouseComponent.Wall, + }); + + result.current.registerComponent({ + parentId: 'wall1', + componentId: 'window1', + size: { height: 2, width: 1 }, + componentType: HouseComponent.Window, + }); + }); + + let allComponents = result.current.all; + expect(allComponents).toHaveLength(2); + expect(result.current.getFirstOfType(HouseComponent.Wall)).toEqual({ + houseComponentId: 'wall1', + ...SIMULATION_DEFAULT_WALL_COMPONENT_INSULATION, + componentType: HouseComponent.Wall, + size: { height: 5, width: 2 }, + actualArea: 5 * 2 - 2 * 1, + }); + expect(result.current.getFirstOfType(HouseComponent.Window)).toEqual({ + houseComponentId: 'window1', + ...SIMULATION_DEFAULT_WINDOW_COMPONENT_INSULATION, + componentType: HouseComponent.Window, + size: { height: 2, width: 1 }, + actualArea: 2 * 1, + }); + + // update the house component + act(() => { + result.current.updateCompositionOfInsulation({ + componentType: HouseComponent.Wall, + materialProps: { + name: 'Aerogel', + price: 15, + thickness: 2, + }, + }); + }); + + allComponents = result.current.all; + expect(allComponents).toHaveLength(2); + expect(result.current.getFirstOfType(HouseComponent.Wall)).toEqual({ + houseComponentId: 'wall1', + insulationName: 'Aerogel', + buildingMaterials: HOUSE_INSULATIONS.Wall.Aerogel.map((d) => + d.name === 'Aerogel' ? d.from({ price: 15, thickness: 2 }) : d, + ), + componentType: HouseComponent.Wall, + size: { height: 5, width: 2 }, + actualArea: 5 * 2 - 2 * 1, + }); + expect(result.current.getFirstOfType(HouseComponent.Window)).toEqual({ + houseComponentId: 'window1', + ...SIMULATION_DEFAULT_WINDOW_COMPONENT_INSULATION, + componentType: HouseComponent.Window, + size: { height: 2, width: 1 }, + actualArea: 2 * 1, + }); + }); + + it('should update the wall components but not the walls', () => { + const { result } = renderHook(() => useHouseComponents()); + + act(() => { + result.current.registerComponent({ + componentId: 'wall1', + size: { height: 5, width: 2 }, + componentType: HouseComponent.Wall, + }); + + result.current.registerComponent({ + parentId: 'wall1', + componentId: 'window1', + size: { height: 2, width: 1 }, + componentType: HouseComponent.Window, + }); + }); + + let allComponents = result.current.all; + expect(allComponents).toHaveLength(2); + expect(result.current.getFirstOfType(HouseComponent.Wall)).toEqual({ + houseComponentId: 'wall1', + ...SIMULATION_DEFAULT_WALL_COMPONENT_INSULATION, + componentType: HouseComponent.Wall, + size: { height: 5, width: 2 }, + actualArea: 5 * 2 - 2 * 1, + }); + expect(result.current.getFirstOfType(HouseComponent.Window)).toEqual({ + houseComponentId: 'window1', + ...SIMULATION_DEFAULT_WINDOW_COMPONENT_INSULATION, + componentType: HouseComponent.Window, + size: { height: 2, width: 1 }, + actualArea: 2 * 1, + }); + + // update the house component + act(() => { + result.current.updateCompositionOfInsulation({ + componentType: HouseComponent.Window, + materialProps: { + name: 'DoublePane', + price: 15, + thickness: 2, + }, + }); + }); + + allComponents = result.current.all; + expect(allComponents).toHaveLength(2); + expect(result.current.getFirstOfType(HouseComponent.Wall)).toEqual({ + houseComponentId: 'wall1', + ...SIMULATION_DEFAULT_WALL_COMPONENT_INSULATION, + componentType: HouseComponent.Wall, + size: { height: 5, width: 2 }, + actualArea: 5 * 2 - 2 * 1, + }); + expect(result.current.getFirstOfType(HouseComponent.Window)).toEqual({ + houseComponentId: 'window1', + insulationName: 'DoublePane', + buildingMaterials: HOUSE_INSULATIONS.Window.DoublePane.map((d) => + d.name === 'DoublePane' ? d.from({ price: 15, thickness: 2 }) : d, + ), + componentType: HouseComponent.Window, + size: { height: 2, width: 1 }, + actualArea: 2 * 1, + }); + }); + + it('should throw an error if a component is its own parent', () => { + const { result } = renderHook(() => useHouseComponents()); + + act(() => { + expect(() => { + result.current.registerComponent({ + parentId: 'comp1', + componentId: 'comp1', + size: { height: 5, width: 2 }, + componentType: HouseComponent.Wall, + }); + }).toThrowError('A component cannot be its own parent!'); + }); + }); + + it('should throw an error if actual area is incorrect after accounting for children', () => { + const { result } = renderHook(() => useHouseComponents()); + + expect(() => { + act(() => { + result.current.registerComponent({ + componentId: 'wall1', + size: { width: 1, height: 1 }, + componentType: HouseComponent.Wall, + }); + result.current.registerComponent({ + parentId: 'wall1', + componentId: 'window1', + size: { width: 2, height: 2 }, + componentType: HouseComponent.Window, + }); + }); + }).toThrow("The actual area of house component 'wall1' is incorrect!"); + }); +}); diff --git a/src/hooks/useHouseComponents.tsx b/src/hooks/useHouseComponents.tsx index 1db0615..49cb445 100644 --- a/src/hooks/useHouseComponents.tsx +++ b/src/hooks/useHouseComponents.tsx @@ -1,17 +1,23 @@ -import { useCallback, useMemo, useRef } from 'react'; +import { useCallback, useMemo } from 'react'; + +import { useImmer } from 'use-immer'; import { HOUSE_INSULATIONS, + HouseInsulation, HouseInsulationPerComponent, } from '@/config/houseInsulations'; import { SIMULATION_DEFAULT_WALL_COMPONENT_INSULATION, SIMULATION_DEFAULT_WINDOW_COMPONENT_INSULATION, } from '@/config/simulation'; -import { FromBuildingMaterial } from '@/models/BuildingMaterial'; -import { HouseComponentsConfigurator } from '@/models/HouseComponentsConfigurator'; +import { + BuildingMaterial, + FromBuildingMaterial, +} from '@/models/BuildingMaterial'; import { HouseComponent, Size } from '@/types/houseComponent'; -import { CreateNonEmptyArray } from '@/types/utils'; +import { HouseComponentInsulation } from '@/types/houseComponentInsulation'; +import { CreateNonEmptyArray, NonEmptyArray } from '@/types/utils'; export type RegisterComponentParams = { componentId: string; @@ -20,6 +26,10 @@ export type RegisterComponentParams = { componentType: HouseComponent.Wall | HouseComponent.Window; }; +type HouseComponentInsulationResult = HouseComponentInsulation & { + houseComponentId: string; +}; + export type UseHouseComponentsReturnType = { registerComponent: (params: RegisterComponentParams) => void; unregisterComponent: ({ @@ -42,6 +52,14 @@ export type UseHouseComponentsReturnType = { componentType: T; materialProps: { name: string } & FromBuildingMaterial; }) => void; + + all: HouseComponentInsulationResult[]; + getByType: ( + componentType: HouseComponent, + ) => HouseComponentInsulationResult[]; + getFirstOfType: ( + componentType: HouseComponent, + ) => HouseComponentInsulationResult | undefined; }; // An house component can be composed with multiple materials @@ -51,15 +69,147 @@ const DEFAULT_COMPONENTS_INSULATION = { [HouseComponent.Window]: SIMULATION_DEFAULT_WINDOW_COMPONENT_INSULATION, }; -type Props = { - onChange: (newHouseComponents: HouseComponentsConfigurator) => void; -}; +// Helpful aliases +type ComponentId = string; +type ChildrenId = string; +type ParentId = string; + +/** + * Manages a tree-like structure of house component insulations. + */ +export const useHouseComponents = (): UseHouseComponentsReturnType => { + const [state, setState] = useImmer<{ + /** + * A map storing all house component insulations, keyed by their unique ID. + */ + components: Map; + /** + * A map storing the parent ID of each component. + * It is useful to know which wall the window is associated with. + */ + componentParents: Map; + }>({ components: new Map(), componentParents: new Map() }); + + const componentParentsEntries = useMemo( + () => Array.from(state.componentParents.entries()), + [state.componentParents], + ); + + const componentKeys = useMemo( + () => Array.from(state.components.keys()), + [state.components], + ); + + const componentEntries = useMemo( + () => Array.from(state.components.entries()), + [state.components], + ); -export const useHouseComponents = ({ - onChange, -}: Props): UseHouseComponentsReturnType => { - const houseComponentsConfigurator = useRef( - HouseComponentsConfigurator.create(), + /** + * Retrieves all children components of a given parent component. + * @param parentId The ID of the parent component. + * @returns An array of child components. Returns an empty array if no children are found or the parent doesn't exist. + */ + const getChildren = useCallback( + (parentId: string): HouseComponentInsulation[] => + componentParentsEntries + .filter(([_, v]) => v === parentId) + .map(([k, _]) => state.components.get(k)) + .filter((c): c is HouseComponentInsulation => Boolean(c)), + [componentParentsEntries, state.components], + ); + + /** + * Retrieves a component and calculates its actual area by subtracting the area of its children like the windows for a wall. + * @param componentId The ID of the component to retrieve. + * @returns The component object with its actual area calculated. + * @throws Error if the component is not found or if its actual area is incorrect (less than or equal to zero after accounting for children). + */ + const get = useCallback( + (componentId: string): HouseComponentInsulation => { + const component = state.components.get(componentId); + + if (!component) { + throw new Error(`The house component '${componentId}' was not found!`); + } + + const children = getChildren(componentId); + + const totalChildrenArea = children.reduce( + (acc, comp) => acc + comp.actualArea, + 0, + ); + const actualArea = component.actualArea - totalChildrenArea; + + if (actualArea <= 0) { + throw new Error( + `The actual area of house component '${componentId}' is incorrect!`, + ); + } + + return { + ...component, + actualArea, + }; + }, + [getChildren, state.components], + ); + + /** + * Retrieves all components along with their IDs. + * @returns An array of all components, each with its ID. + */ + const all = useMemo( + (): HouseComponentInsulationResult[] => + componentKeys.map((k) => ({ + houseComponentId: k, + // Use the get method to return with the correct actual area. + ...get(k), + })), + [componentKeys, get], + ); + + /** + * Retrieves all the components of the given type. + * @param componentType The type of the searched components. + * @returns An array of components, each with its ID. + */ + const getByType = useCallback( + (componentType: HouseComponent): HouseComponentInsulationResult[] => + componentEntries + .filter(([_, v]) => v.componentType === componentType) + .map(([k, _]) => ({ + houseComponentId: k, + // Use the get method to return with the correct actual area. + ...get(k), + })), + [componentEntries, get], + ); + + /** + * Retrieves the first component of the given type or undefined. + * @param componentType The type of the searched components. + * @returns The first component with its ID or undefined. + */ + const getFirstOfType = useCallback( + ( + componentType: HouseComponent, + ): HouseComponentInsulationResult | undefined => { + const first = componentEntries.find( + ([_, v]) => v.componentType === componentType, + ); + + if (!first) { + return undefined; + } + + return { + houseComponentId: first[0], + // Use the get method to return with the correct actual area. + ...get(first[0]), + }; + }, + [componentEntries, get], ); const registerComponent = useCallback( @@ -69,8 +219,12 @@ export const useHouseComponents = ({ size, componentType, }: RegisterComponentParams): void => { + if (parentId === componentId) { + throw new Error('A component cannot be its own parent!'); + } + const { buildingMaterials, insulationName } = - houseComponentsConfigurator.current.getFirstOfType(componentType) ?? + getFirstOfType(componentType) ?? DEFAULT_COMPONENTS_INSULATION[componentType]; if (!buildingMaterials?.length) { @@ -79,28 +233,108 @@ export const useHouseComponents = ({ ); } - onChange( - houseComponentsConfigurator.current.add({ - componentId, - parentId, - component: { - size, - insulationName, - buildingMaterials, - componentType, - actualArea: size.height * size.width, - }, - }), - ); + const component = { + size, + insulationName, + buildingMaterials, + componentType, + actualArea: size.height * size.width, + }; + + // Adds or updates the given component. + setState((curr) => { + curr.components.set(componentId, component); + + if (parentId) { + curr.componentParents.set(componentId, parentId); + } else { + curr.componentParents.delete(componentId); + } + + return curr; + }); }, - [houseComponentsConfigurator, onChange], + [getFirstOfType, setState], ); const unregisterComponent = useCallback( ({ componentId }: Pick): void => { - onChange(houseComponentsConfigurator.current.remove({ componentId })); + // Removes the given component and all its descendants. + setState((curr) => { + curr.components.delete(componentId); + curr.componentParents.delete(componentId); + + // Start with the initial component + const componentsToRemove: string[] = [componentId]; + + while (componentsToRemove.length > 0) { + const currentComponentId = componentsToRemove.pop(); + + if (!currentComponentId) { + break; + } + + const parents = Array.from(curr.componentParents.entries()); + + for (let i = 0; i < parents.length; i += 1) { + const [childId, parentId] = parents[i]; + + if (parentId === currentComponentId) { + curr.componentParents.delete(childId); + curr.components.delete(childId); + // Add to the main stack for processing children's children + componentsToRemove.push(childId); + } + } + } + + return curr; + }); + }, + [setState], + ); + + /** + * Updates the given component's insulation. + * @param componentType The component type to udpate with the new insulation. + * @param insulation The new insulation to use to update the components of the given type. + */ + const updateInsulation = useCallback( + < + T extends HouseComponent, + K extends keyof (typeof HouseInsulationPerComponent)[T], + >({ + componentType, + insulation, + }: { + componentType: T; + insulation: { + name: K; + buildingMaterials: NonEmptyArray; + }; + }): void => { + if (!insulation.name) { + throw new Error( + `The insulation should be defined for component ${componentType}!`, + ); + } + + setState((curr) => { + curr.components.forEach((component, key) => { + if (component.componentType === componentType) { + const updatedComponent = { + ...component, + insulationName: insulation.name as HouseInsulation, + buildingMaterials: insulation.buildingMaterials, + }; + curr.components.set(key, updatedComponent); + } + }); + + return curr; + }); }, - [houseComponentsConfigurator, onChange], + [setState], ); const changeComponentInsulation = useCallback( @@ -123,15 +357,10 @@ export const useHouseComponents = ({ } const buildingMaterials = HOUSE_INSULATIONS[componentType][newInsulation]; - - onChange( - houseComponentsConfigurator.current.updateInsulation({ - componentType, - insulation: { name: newInsulation, buildingMaterials }, - }), - ); + const insulation = { name: newInsulation, buildingMaterials }; + updateInsulation({ componentType, insulation }); }, - [houseComponentsConfigurator, onChange], + [updateInsulation], ); const updateCompositionOfInsulation = useCallback( @@ -142,8 +371,7 @@ export const useHouseComponents = ({ componentType: T; materialProps: { name: string } & FromBuildingMaterial; }): void => { - const component = - houseComponentsConfigurator.current.getFirstOfType(componentType); + const component = getFirstOfType(componentType); if (!component) { throw new Error(`No ${componentType} component was found!`); @@ -167,17 +395,14 @@ export const useHouseComponents = ({ return m; }); - onChange( - houseComponentsConfigurator.current.updateInsulation({ - componentType, - insulation: { - name: insulationName, - buildingMaterials: CreateNonEmptyArray(newMaterials), - }, - }), - ); + const insulation = { + name: insulationName, + buildingMaterials: CreateNonEmptyArray(newMaterials), + }; + + updateInsulation({ componentType, insulation }); }, - [houseComponentsConfigurator, onChange], + [getFirstOfType, updateInsulation], ); return useMemo( @@ -186,12 +411,19 @@ export const useHouseComponents = ({ unregisterComponent, changeComponentInsulation, updateCompositionOfInsulation, + + all, + getByType, + getFirstOfType, }), [ registerComponent, unregisterComponent, changeComponentInsulation, updateCompositionOfInsulation, + all, + getByType, + getFirstOfType, ], ); }; diff --git a/src/hooks/useHouseMaterial.tsx b/src/hooks/useHouseMaterial.tsx index e1eac14..f897e29 100644 --- a/src/hooks/useHouseMaterial.tsx +++ b/src/hooks/useHouseMaterial.tsx @@ -18,12 +18,12 @@ export const useHouseMaterial = ({ colors, defaultColor, }: Props): Material => { - const { componentsConfigurator } = useSimulation('house'); + const { getFirstOfType } = useSimulation('house'); // Use memo to avoid too many renrenders and optimize performances const houseComponentMaterials = useMemo( - () => componentsConfigurator.getFirstOfType(houseComponent), - [houseComponent, componentsConfigurator], + () => getFirstOfType(houseComponent), + [getFirstOfType, houseComponent], ); const copiedMaterial = new MeshStandardMaterial().copy(houseMaterial); diff --git a/src/models/HouseComponentsConfigurator.test.ts b/src/models/HouseComponentsConfigurator.test.ts deleted file mode 100644 index 94403e6..0000000 --- a/src/models/HouseComponentsConfigurator.test.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { BuildingMaterial } from '@/models/BuildingMaterial'; -import { HouseComponent } from '@/types/houseComponent'; -import { HouseComponentInsulation } from '@/types/houseComponentInsulation'; - -import { HouseComponentsConfigurator } from './HouseComponentsConfigurator'; - -const WALL_MATERIAL_1 = BuildingMaterial.create({ - thermalConductivity: 0.25, - thickness: 0.02, - price: 10, - name: 'random wall brick', -}); - -const WALL_COMPONENT_INSULATION: HouseComponentInsulation = { - insulationName: 'Brick', - buildingMaterials: [WALL_MATERIAL_1], - actualArea: 10, - componentType: HouseComponent.Wall, - size: { width: 5, height: 2 }, -}; - -const WINDOW_MATERIAL = BuildingMaterial.create({ - thermalConductivity: 0.25, - thickness: 0.02, - price: 10, - name: 'glass', -}); - -const WINDOW_COMPONENT_INSULATION: HouseComponentInsulation = { - insulationName: 'SinglePane', - buildingMaterials: [WINDOW_MATERIAL], - actualArea: 2, - componentType: HouseComponent.Window, - size: { width: 1, height: 2 }, -}; - -describe('HouseComponentsConfigurator', () => { - it('should create an empty instance using the static create method', () => { - const houseComponents = HouseComponentsConfigurator.create(); - expect(houseComponents.getAll().length).eq(0); - }); - - it('should add a new component', () => { - const houseComponents = HouseComponentsConfigurator.create().add({ - componentId: 'wall1', - component: WALL_COMPONENT_INSULATION, - }); - - expect(houseComponents.getAll().length).eq(1); - expect(houseComponents.get('wall1')).toEqual(WALL_COMPONENT_INSULATION); - }); - - it('should create a copy component', () => { - const houseComponents = HouseComponentsConfigurator.create(); - - const newHouseComponents = houseComponents.clone().add({ - componentId: 'wall1', - component: WALL_COMPONENT_INSULATION, - }); - - expect(newHouseComponents.getAll().length).eq(1); - expect(newHouseComponents.get('wall1')).toEqual(WALL_COMPONENT_INSULATION); - - // Original instance should remain unchanged - expect(houseComponents.getAll().length).eq(0); - }); - - it('should add a child component', () => { - const houseComponents = HouseComponentsConfigurator.create() - .add({ - componentId: 'wall1', - component: WALL_COMPONENT_INSULATION, - }) - .add({ - parentId: 'wall1', - componentId: 'window1', - component: WINDOW_COMPONENT_INSULATION, - }); - - expect(houseComponents.getAll().length).eq(2); - expect(houseComponents.get('window1')).toEqual(WINDOW_COMPONENT_INSULATION); - expect(houseComponents.get('wall1').actualArea).eq( - WALL_COMPONENT_INSULATION.actualArea - - WINDOW_COMPONENT_INSULATION.actualArea, - ); - }); - - it('should remove component', () => { - const houseComponents = HouseComponentsConfigurator.create() - .add({ - componentId: 'wall1', - component: WALL_COMPONENT_INSULATION, - }) - .add({ - parentId: 'wall1', - componentId: 'window1', - component: WINDOW_COMPONENT_INSULATION, - }) - .remove({ componentId: 'window1' }); - - expect(houseComponents.getAll().length).eq(1); - expect(houseComponents.getAll()[0].houseComponentId).eq('wall1'); - }); - - it('should remove component and its children', () => { - const houseComponents = HouseComponentsConfigurator.create() - .add({ - componentId: 'wall1', - component: WALL_COMPONENT_INSULATION, - }) - .add({ - parentId: 'wall1', - componentId: 'window1', - component: WINDOW_COMPONENT_INSULATION, - }) - .add({ - parentId: 'wall1', - componentId: 'window2', - component: WINDOW_COMPONENT_INSULATION, - }) - .add({ - parentId: 'window2', - componentId: 'window3', - component: WINDOW_COMPONENT_INSULATION, - }) - .remove({ componentId: 'wall1' }); - - expect(houseComponents.getAll().length).eq(0); - }); - - it('should remove component and its children of the clone only', () => { - const houseComponents = HouseComponentsConfigurator.create() - .add({ - componentId: 'wall1', - component: WALL_COMPONENT_INSULATION, - }) - .add({ - parentId: 'wall1', - componentId: 'window1', - component: WINDOW_COMPONENT_INSULATION, - }) - .add({ - parentId: 'wall1', - componentId: 'window2', - component: WINDOW_COMPONENT_INSULATION, - }) - .add({ - parentId: 'window2', - componentId: 'window3', - component: { - ...WINDOW_COMPONENT_INSULATION, - actualArea: 1, - size: { width: 1, height: 1 }, - }, - }); - - const newHouseComponents = houseComponents - .clone() - .remove({ componentId: 'wall1' }); - - expect(houseComponents.getAll().length).eq(4); - expect(newHouseComponents.getAll().length).eq(0); - }); - - it('should get a component', () => { - const houseComponents = HouseComponentsConfigurator.create() - .add({ componentId: 'wall1', component: WALL_COMPONENT_INSULATION }) - .add({ - parentId: 'wall1', - componentId: 'window1', - component: WINDOW_COMPONENT_INSULATION, - }); - - const wall = houseComponents.get('wall1'); - expect(wall.actualArea).eq( - WALL_COMPONENT_INSULATION.actualArea - - WINDOW_COMPONENT_INSULATION.actualArea, - ); - }); - - it('should get all components', () => { - const houseComponents = HouseComponentsConfigurator.create() - .add({ componentId: 'wall1', component: WALL_COMPONENT_INSULATION }) - .add({ - parentId: 'wall1', - componentId: 'window1', - component: WINDOW_COMPONENT_INSULATION, - }); - - const allComponents = houseComponents.getAll(); - expect(allComponents).toEqual([ - { - houseComponentId: 'wall1', - ...WALL_COMPONENT_INSULATION, - actualArea: - WALL_COMPONENT_INSULATION.actualArea - - WINDOW_COMPONENT_INSULATION.actualArea, - }, - { houseComponentId: 'window1', ...WINDOW_COMPONENT_INSULATION }, - ]); - }); - - it('should get the good component by type', () => { - const houseConfigurator = HouseComponentsConfigurator.create() - .add({ - componentId: 'wall1', - component: WALL_COMPONENT_INSULATION, - }) - .add({ - parentId: 'wall1', - componentId: 'window1', - component: WINDOW_COMPONENT_INSULATION, - }); - - expect(houseConfigurator.getAll().length).eq(2); - expect( - houseConfigurator.getFirstOfType(HouseComponent.Wall)?.houseComponentId, - ).toEqual('wall1'); - expect( - houseConfigurator.getFirstOfType(HouseComponent.Wall)?.actualArea, - ).toEqual( - WALL_COMPONENT_INSULATION.actualArea - - WINDOW_COMPONENT_INSULATION.actualArea, - ); - expect( - houseConfigurator.getFirstOfType(HouseComponent.Window)?.houseComponentId, - ).toEqual('window1'); - }); - - it('should update the wall components but not the windows', () => { - const houseComponents = HouseComponentsConfigurator.create().add({ - componentId: 'wall1', - component: WALL_COMPONENT_INSULATION, - }); - const newHouseComponents = houseComponents.add({ - parentId: 'wall1', - componentId: 'window1', - component: WINDOW_COMPONENT_INSULATION, - }); - - expect(newHouseComponents.getAll().length).eq(2); - expect(newHouseComponents.get('window1')).toEqual( - WINDOW_COMPONENT_INSULATION, - ); - expect(newHouseComponents.get('wall1').actualArea).eq( - WALL_COMPONENT_INSULATION.actualArea - - WINDOW_COMPONENT_INSULATION.actualArea, - ); - - // update the house component - const newMaterial = WALL_MATERIAL_1.from({ price: 15, thickness: 2 }); - const updatedHouseComponents = newHouseComponents.updateInsulation({ - componentType: HouseComponent.Wall, - insulation: { - name: 'Aerogel', - buildingMaterials: [newMaterial], - }, - }); - - // check that the walls have been updated - const allWalls = updatedHouseComponents.getByType(HouseComponent.Wall); - - expect(allWalls.length).toBe(1); - expect(allWalls[0].buildingMaterials.length).toBe(1); - expect(allWalls[0].buildingMaterials[0]).toBe(newMaterial); - - // check that the windows have not been updated - const allWindows = updatedHouseComponents.getByType(HouseComponent.Window); - - expect(allWindows.length).toBe(1); - expect(allWindows[0].buildingMaterials.length).toBe(1); - expect(allWindows[0].buildingMaterials[0]).not.toBe(newMaterial); - expect(allWindows[0].buildingMaterials[0]).toBe(WINDOW_MATERIAL); - }); - - it('should update the windows components but not the walls', () => { - const houseComponents = HouseComponentsConfigurator.create().add({ - componentId: 'wall1', - component: WALL_COMPONENT_INSULATION, - }); - const newHouseComponents = houseComponents.add({ - parentId: 'wall1', - componentId: 'window1', - component: WINDOW_COMPONENT_INSULATION, - }); - - expect(newHouseComponents.getAll().length).eq(2); - expect(newHouseComponents.get('window1')).toEqual( - WINDOW_COMPONENT_INSULATION, - ); - expect(newHouseComponents.get('wall1').actualArea).eq( - WALL_COMPONENT_INSULATION.actualArea - - WINDOW_COMPONENT_INSULATION.actualArea, - ); - - // update the house component - const newMaterial = WINDOW_MATERIAL.from({ price: 15, thickness: 2 }); - const updatedHouseComponents = newHouseComponents.updateInsulation({ - componentType: HouseComponent.Window, - insulation: { - name: 'DoublePane', - buildingMaterials: [newMaterial], - }, - }); - - // check that the walls have not been updated - const allWalls = updatedHouseComponents.getByType(HouseComponent.Wall); - - expect(allWalls.length).toBe(1); - expect(allWalls[0].buildingMaterials.length).toBe(1); - expect(allWalls[0].buildingMaterials[0]).not.toBe(newMaterial); - expect(allWalls[0].buildingMaterials[0]).toBe(WALL_MATERIAL_1); - - // check that the windows have been updated - const allWindows = updatedHouseComponents.getByType(HouseComponent.Window); - - expect(allWindows.length).toBe(1); - expect(allWindows[0].buildingMaterials.length).toBe(1); - expect(allWindows[0].buildingMaterials[0]).toBe(newMaterial); - }); - - it('should throw an error if a component is its own parent', () => { - const houseComponents = HouseComponentsConfigurator.create(); - expect(() => - houseComponents.add({ - parentId: 'comp1', - componentId: 'comp1', - component: WALL_COMPONENT_INSULATION, - }), - ).throw('A component cannot be its own parent!'); - }); - - it('should throw an error if a component is already assigned to a different parent', () => { - const houseComponents = HouseComponentsConfigurator.create() - .add({ - parentId: 'wall1', - componentId: 'window1', - component: WINDOW_COMPONENT_INSULATION, - }) - .add({ - componentId: 'wall2', - component: WINDOW_COMPONENT_INSULATION, - }); - - expect(() => - houseComponents.add({ - parentId: 'wall2', - componentId: 'window1', - component: WALL_COMPONENT_INSULATION, - }), - ).throw("The component 'window1' is already assigned to a parent."); - }); - - it('should throw an error if a component does not exist', () => { - expect(() => HouseComponentsConfigurator.create().get('nonExistent')).throw( - "The house component 'nonExistent' was not found!", - ); - }); - - it('should throw an error if actual area is incorrect after accounting for children', () => { - const houseComponents = HouseComponentsConfigurator.create() - .add({ - componentId: 'wall1', - component: { ...WALL_COMPONENT_INSULATION, actualArea: 2 }, - }) - .add({ - parentId: 'wall1', - componentId: 'window1', - component: { ...WINDOW_COMPONENT_INSULATION, actualArea: 3 }, - }); - - expect(() => houseComponents.get('wall1')).throw( - "The actual area of house component 'wall1' is incorrect!", - ); - }); -}); diff --git a/src/models/HouseComponentsConfigurator.ts b/src/models/HouseComponentsConfigurator.ts deleted file mode 100644 index c1da386..0000000 --- a/src/models/HouseComponentsConfigurator.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { - HouseInsulation, - HouseInsulationPerComponent, -} from '@/config/houseInsulations'; -import { HouseComponent } from '@/types/houseComponent'; -import { HouseComponentInsulation } from '@/types/houseComponentInsulation'; -import { NonEmptyArray } from '@/types/utils'; - -import { BuildingMaterial } from './BuildingMaterial'; - -type HouseComponentInsulationResult = HouseComponentInsulation & { - houseComponentId: string; -}; - -// Helpful aliases -type ComponentId = string; -type ChildrenId = string; -type ParentId = string; - -/** - * Manages a tree-like structure of house component insulations. - * - * **WARNING**: As React use memory adress to detect changes, we have to clone the HouseComponentsConfigurator. - * This is to ensure that React can detect internal changes and re-render. - */ -export class HouseComponentsConfigurator { - /** - * A map storing all house component insulations, keyed by their unique ID. - */ - private readonly components: Map = - new Map(); - - /** - * A map storing the parent ID of each component. - * It is useful to know which wall the window is associated with. - */ - private readonly componentParents: Map = new Map(); - - /** - * Private constructor; instances should be created using the `create()` factory method. - * This allows to abstract the internal structure of the Class and to faciliate the instantiation of immutable object. - * @param initialComponents An optional initial set of components. - * @param initialComponentParents An optional initial set of child-parent relationships. - */ - private constructor( - initialComponents?: Map, - initialComponentParents?: Map, - ) { - this.components = new Map(initialComponents); - this.componentParents = new Map(initialComponentParents); - } - - public static create(): HouseComponentsConfigurator { - return new HouseComponentsConfigurator(); - } - - /** - * Copy the current `HouseComponentsConfigurator` to ensure that React detect internal changes. - * @returns a copy of the current `HouseComponentsConfigurator`. - */ - public clone(): HouseComponentsConfigurator { - return new HouseComponentsConfigurator( - this.components, - this.componentParents, - ); - } - - /** - * Adds or updates the given component. - * @param parentId The ID of the parent component, or `undefined` if it's a root component (like a wall). - * @param componentId The unique ID of the component. - * @param component The component object itself. - * @throws {Error} If `parentId` is the same as `componentId` (a component cannot be its own parent), or if the component is already assigned to a different parent. - * @returns The `HouseComponentsConfigurator` instance with the component added or updated. - */ - public add({ - parentId, - componentId, - component, - }: { - parentId?: string; - componentId: string; - component: HouseComponentInsulation; - }): HouseComponentsConfigurator { - if (parentId === componentId) { - throw new Error('A component cannot be its own parent!'); - } - - this.components.set(componentId, component); - const currentParentId = this.componentParents.get(componentId); - - if (currentParentId && currentParentId !== parentId) { - throw new Error( - `The component '${componentId}' is already assigned to a parent.`, - ); - } - - if (parentId) { - this.componentParents.set(componentId, parentId); - } else { - this.componentParents.delete(componentId); - } - - return this; - } - - /** - * Removes the specific component and its children removed. - * @param componentId The unique ID of the component. - * @returns The `HouseComponentsConfigurator` instance with the component added or updated. - */ - public remove({ - componentId, - }: { - componentId: string; - }): HouseComponentsConfigurator { - this.components.delete(componentId); - this.componentParents.delete(componentId); - this.removeComponent(componentId); - - return this; - } - - /** - * Recursively removes a component and its children. - * - * @param componentId - The ID of the component to remove. - */ - private removeComponent(componentId: string): void { - Array.from(this.componentParents.entries()).forEach( - ([childId, parentId]) => { - if (parentId === componentId) { - this.componentParents.delete(childId); - this.components.delete(childId); - - this.removeComponent(childId); - } - }, - ); - } - - /** - * Updates the given component's insulation. - * @param componentType The component type to udpate with the new insulation. - * @param insulation The new insulation to use to update the components of the given type. - * @returns The `HouseComponentsConfigurator` instance with the components' insulation of the given type updated. - */ - public updateInsulation< - T extends HouseComponent, - K extends keyof (typeof HouseInsulationPerComponent)[T], - >({ - componentType, - insulation, - }: { - componentType: T; - insulation: { - name: K; - buildingMaterials: NonEmptyArray; - }; - }): HouseComponentsConfigurator { - if (!insulation.name) { - throw new Error( - `The insulation should be defined for component ${componentType}!`, - ); - } - - this.components.forEach((component, key) => { - if (component.componentType === componentType) { - const updatedComponent = { - ...component, - insulationName: insulation.name as HouseInsulation, - buildingMaterials: insulation.buildingMaterials, - }; - this.components.set(key, updatedComponent); - } - }); - - return this; - } - - /** - * Retrieves all children components of a given parent component. - * @param parentId The ID of the parent component. - * @returns An array of child components. Returns an empty array if no children are found or the parent doesn't exist. - */ - private getChildren(parentId: string): HouseComponentInsulation[] { - return Array.from(this.componentParents.entries()) - .filter(([_, v]) => v === parentId) - .map(([k, _]) => this.components.get(k)) - .filter((c): c is HouseComponentInsulation => Boolean(c)); - } - - /** - * Retrieves a component and calculates its actual area by subtracting the area of its children like the windows for a wall. - * @param componentId The ID of the component to retrieve. - * @returns The component object with its actual area calculated. - * @throws Error if the component is not found or if its actual area is incorrect (less than or equal to zero after accounting for children). - */ - public get(componentId: string): HouseComponentInsulation { - const component = this.components.get(componentId); - - if (!component) { - throw new Error(`The house component '${componentId}' was not found!`); - } - - const children = this.getChildren(componentId); - - const totalChildrenArea = children.reduce( - (acc, comp) => acc + comp.actualArea, - 0, - ); - const actualArea = component.actualArea - totalChildrenArea; - - if (actualArea <= 0) { - throw new Error( - `The actual area of house component '${componentId}' is incorrect!`, - ); - } - - return { - ...component, - actualArea, - }; - } - - /** - * Retrieves all components along with their IDs. - * @returns An array of all components, each with its ID. - */ - public getAll(): HouseComponentInsulationResult[] { - return Array.from(this.components.keys()).map((k) => ({ - houseComponentId: k, - // Use the get method to return with the correct actual area. - ...this.get(k), - })); - } - - /** - * Retrieves all the components of the given type. - * @param componentType The type of the searched components. - * @returns An array of components, each with its ID. - */ - public getByType( - componentType: HouseComponent, - ): HouseComponentInsulationResult[] { - return Array.from(this.components.entries()) - .filter(([_, v]) => v.componentType === componentType) - .map(([k, _]) => ({ - houseComponentId: k, - // Use the get method to return with the correct actual area. - ...this.get(k), - })); - } - - /** - * Retrieves the first component of the given type or undefined. - * @param componentType The type of the searched components. - * @returns The first component with its ID or undefined. - */ - public getFirstOfType( - componentType: HouseComponent, - ): HouseComponentInsulationResult | undefined { - const first = Array.from(this.components.entries()).find( - ([_, v]) => v.componentType === componentType, - ); - - if (!first) { - return undefined; - } - - return { - houseComponentId: first[0], - // Use the get method to return with the correct actual area. - ...this.get(first[0]), - }; - } -} diff --git a/src/models/SimulationHeatLoss.ts b/src/models/SimulationHeatLoss.ts index 53d6940..1056b6f 100644 --- a/src/models/SimulationHeatLoss.ts +++ b/src/models/SimulationHeatLoss.ts @@ -1,15 +1,13 @@ -import { HeatLossPerComponent } from '@/types/houseComponent'; import { - calculateHeatLossConstantFactor, - sumHeatLossRateForDay, -} from '@/utils/heatLoss'; - -import { HouseComponentsConfigurator } from './HouseComponentsConfigurator'; + HeatLossPerComponent, + HeatLossPerComponentEntries, +} from '@/types/houseComponent'; +import { sumHeatLossRateForDay } from '@/utils/heatLoss'; type Constructor = { indoorTemperature: number; outdoorTemperature: number; - houseConfigurator: HouseComponentsConfigurator; + heatLossConstantFactors: HeatLossPerComponentEntries; }; export class SimulationHeatLoss { @@ -20,38 +18,24 @@ export class SimulationHeatLoss { constructor({ indoorTemperature, outdoorTemperature, - houseConfigurator, + heatLossConstantFactors, }: Constructor) { - const heatLossConstantFactors = houseConfigurator - .getAll() - .reduce( - (acc, c) => ({ - ...acc, - [c.houseComponentId]: calculateHeatLossConstantFactor({ - area: c.actualArea, - materials: c.buildingMaterials, - }), - }), - {}, - ); + const perComponent: HeatLossPerComponent = {}; + let global = 0; - this.perComponent = Object.entries( - heatLossConstantFactors, - ).reduce( - (acc, [componentId, heatLossConstantFactor]) => ({ - ...acc, - [componentId]: sumHeatLossRateForDay({ - temperature: outdoorTemperature, - constantFactor: heatLossConstantFactor, - indoorTemperature, - }), - }), - {}, - ); + // Have to use for-loop for performances issues with reduce. + for (let i = 0; i < heatLossConstantFactors.length; i += 1) { + const [componentId, heatLossConstantFactor] = heatLossConstantFactors[i]; + const heatLoss = sumHeatLossRateForDay({ + temperature: outdoorTemperature, + constantFactor: heatLossConstantFactor, + indoorTemperature, + }); + perComponent[componentId] = heatLoss; + global += heatLoss; + } - this.global = Object.values(this.perComponent).reduce( - (acc, heatLoss) => acc + heatLoss, - 0, - ); + this.perComponent = perComponent; + this.global = global; } } diff --git a/src/modules/Root.tsx b/src/modules/Root.tsx index 70d72c4..b64ed3a 100644 --- a/src/modules/Root.tsx +++ b/src/modules/Root.tsx @@ -9,6 +9,8 @@ import { StyledEngineProvider } from '@mui/material/styles'; import '@graasp/apps-query-client'; +import { enableMapSet } from 'immer'; + import i18nConfig from '@/config/i18n'; import { QueryClientProvider, @@ -19,6 +21,9 @@ import { import ErrorBoundary from './ErrorBoundary'; import App from './main/App'; +// allow to manipulate Map state as a mutable state +enableMapSet(); + // declare the module to enable theme modification declare module '@mui/material/styles' { interface Theme { diff --git a/src/modules/common/SimulationInformations/SimulationInformations.tsx b/src/modules/common/SimulationInformations/SimulationInformations.tsx index 11a6c3e..aaf530c 100644 --- a/src/modules/common/SimulationInformations/SimulationInformations.tsx +++ b/src/modules/common/SimulationInformations/SimulationInformations.tsx @@ -46,7 +46,7 @@ export const SimulationInformations = (): JSX.Element => { const iconSize = md ? 24 : 16; return ( - + getFirstOfType(HouseComponent.Wall), + [getFirstOfType], ); - const wallPrices = componentsConfigurator - .getByType(HouseComponent.Wall) - .reduce( - (totCost, houseComponent) => - totCost + - houseComponent.actualArea * - houseComponent.buildingMaterials.reduce( - (componentCost, material) => - componentCost + material.price * material.thickness, - 0, - ), - 0, - ); + const wallPrices = useMemo( + () => + getByType(HouseComponent.Wall).reduce( + (totCost, houseComponent) => + totCost + + houseComponent.actualArea * + houseComponent.buildingMaterials.reduce( + (componentCost, material) => + componentCost + material.price * material.thickness, + 0, + ), + 0, + ), + [getByType], + ); return { heatLoss: formatHeatLossRate(heatLoss), diff --git a/src/modules/common/SimulationSettingsPanel/HouseSettings.tsx b/src/modules/common/SimulationSettingsPanel/HouseSettings.tsx index 140b73a..7034729 100644 --- a/src/modules/common/SimulationSettingsPanel/HouseSettings.tsx +++ b/src/modules/common/SimulationSettingsPanel/HouseSettings.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { @@ -30,7 +31,7 @@ export const HouseSettings = (): JSX.Element => { const { t } = useTranslation('SIMULATION_SETTINGS_PANEL'); const { t: tInsulations } = useTranslation('INSULATIONS'); const { - componentsConfigurator, + getFirstOfType, changeComponentInsulation, numberOfFloors, updateNumberOfFloors, @@ -44,15 +45,19 @@ export const HouseSettings = (): JSX.Element => { HOUSE_INSULATIONS.Window, ) as (keyof typeof HOUSE_INSULATIONS.Window)[]; - const currWallInsulation = - componentsConfigurator.getFirstOfType(HouseComponent.Wall) - ?.insulationName ?? - SIMULATION_DEFAULT_WALL_COMPONENT_INSULATION.insulationName; + const currWallInsulation = useMemo( + () => + getFirstOfType(HouseComponent.Wall)?.insulationName ?? + SIMULATION_DEFAULT_WALL_COMPONENT_INSULATION.insulationName, + [getFirstOfType], + ); - const currWindowInsulation = - componentsConfigurator.getFirstOfType(HouseComponent.Window) - ?.insulationName ?? - SIMULATION_DEFAULT_WINDOW_COMPONENT_INSULATION.insulationName; + const currWindowInsulation = useMemo( + () => + getFirstOfType(HouseComponent.Window)?.insulationName ?? + SIMULATION_DEFAULT_WINDOW_COMPONENT_INSULATION.insulationName, + [getFirstOfType], + ); const { open: openMaterials, diff --git a/src/modules/common/SimulationSettingsPanel/MaterialSettingsDialog/useMaterialSettingsDialog.tsx b/src/modules/common/SimulationSettingsPanel/MaterialSettingsDialog/useMaterialSettingsDialog.tsx index 5efdbd2..d35ae62 100644 --- a/src/modules/common/SimulationSettingsPanel/MaterialSettingsDialog/useMaterialSettingsDialog.tsx +++ b/src/modules/common/SimulationSettingsPanel/MaterialSettingsDialog/useMaterialSettingsDialog.tsx @@ -17,13 +17,13 @@ type UseMaterialSettingsDialogReturnType = { export const useMaterialSettingsDialog = (): UseMaterialSettingsDialogReturnType => { - const { componentsConfigurator, updateCompositionOfInsulation } = + const { getFirstOfType, updateCompositionOfInsulation } = useSimulation('house'); const [currTab, setCurrTab] = useState(''); const wallComponents = useMemo( - () => componentsConfigurator.getFirstOfType(HouseComponent.Wall), - [componentsConfigurator], + () => getFirstOfType(HouseComponent.Wall), + [getFirstOfType], ); const wallMaterials = wallComponents?.buildingMaterials; const wallInsulation = wallComponents?.insulationName; diff --git a/src/modules/common/SimulationSettingsPanel/WindowControlSettings.tsx b/src/modules/common/SimulationSettingsPanel/WindowControlSettings.tsx index 16b94de..dd707df 100644 --- a/src/modules/common/SimulationSettingsPanel/WindowControlSettings.tsx +++ b/src/modules/common/SimulationSettingsPanel/WindowControlSettings.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { @@ -43,9 +44,10 @@ export const WindowControlSettings = ({ const { t: tInsulations } = useTranslation('INSULATIONS'); const { t: tMaterials } = useTranslation('MATERIALS'); - const { window, componentsConfigurator } = useSimulation('house'); - const windowComponent = componentsConfigurator.getFirstOfType( - HouseComponent.Window, + const { window, getFirstOfType } = useSimulation('house'); + const windowComponent = useMemo( + () => getFirstOfType(HouseComponent.Window), + [getFirstOfType], ); if (!windowComponent) { diff --git a/src/modules/common/charts/HeatLossCharts.tsx b/src/modules/common/charts/HeatLossCharts.tsx index 040223c..be5beae 100644 --- a/src/modules/common/charts/HeatLossCharts.tsx +++ b/src/modules/common/charts/HeatLossCharts.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { @@ -18,26 +18,9 @@ import { YAxis, } from 'recharts'; +import { PERIODS, useChart } from '@/context/ChartContext'; import { useSimulation } from '@/context/SimulationContext'; -type Period = { - numberOfDays: number; - labelKey: - | 'ONE_MONTH' - | 'THREE_MONTHS' - | 'SIX_MONTHS' - | 'ONE_YEAR' - | 'THREE_YEARS'; -}; - -const PERIODS: Period[] = [ - { numberOfDays: 30, labelKey: 'ONE_MONTH' }, - { numberOfDays: 90, labelKey: 'THREE_MONTHS' }, - { numberOfDays: 180, labelKey: 'SIX_MONTHS' }, - { numberOfDays: 365, labelKey: 'ONE_YEAR' }, - { numberOfDays: 1_095, labelKey: 'THREE_YEARS' }, -] as const; - type Props = { width: number }; export const HeatLossCharts = ({ width }: Props): JSX.Element => { const { t } = useTranslation('SIMULATION_GRAPHICS', { @@ -46,27 +29,29 @@ export const HeatLossCharts = ({ width }: Props): JSX.Element => { const { days: { simulationDays, currentIdx }, } = useSimulation('simulation'); + const { period, updatePeriod } = useChart(); - const [period, setPeriod] = useState(PERIODS[0]); - - const data = simulationDays - .slice(Math.max(currentIdx - period.numberOfDays, 0), currentIdx + 1) - .map((d) => ({ - name: d.date.toLocaleDateString(), - hl: Number.parseFloat((d.heatLoss.global / 1000).toFixed(1)), - outdoor: Math.round(d.weatherTemperature), - })); + const chartData = useMemo( + () => + simulationDays.map((d) => ({ + name: d.date.toLocaleDateString(), + hl: Number.parseFloat((d.heatLoss.global / 1000).toFixed(1)), + outdoor: Math.round(d.weatherTemperature), + })), + [simulationDays], + ); - const handlePeriodChange = (value: number): void => { - setPeriod(PERIODS.find((p) => value === p.numberOfDays) ?? PERIODS[0]); - }; + const data = chartData.slice( + Math.max(currentIdx - period.numberOfDays, 0), + currentIdx + 1, + ); return ( handlePeriodChange(v)} + onChange={(_, v) => updatePeriod(v)} aria-label="graphic period" > {PERIODS.map((p) => ( diff --git a/src/modules/main/PlayerView.tsx b/src/modules/main/PlayerView.tsx index 9622584..b5688dd 100644 --- a/src/modules/main/PlayerView.tsx +++ b/src/modules/main/PlayerView.tsx @@ -11,6 +11,7 @@ import { import { ChartLine, Rotate3d } from 'lucide-react'; import { SIMULATION_FRAME_MS } from '@/config/simulation'; +import { ChartProvider } from '@/context/ChartContext'; import { SeasonProvider } from '@/context/SeasonContext'; import { SimulationProvider, useSimulation } from '@/context/SimulationContext'; import { SimulationStatus } from '@/types/simulation'; @@ -59,23 +60,26 @@ const PlayerViewComponent = (): JSX.Element => { > - , - element: , - }, - { - label: tTabs('ANALYZE'), - icon: , - element: , - unmountOnExit: false, - }, - ]} - /> + + , + element: , + }, + { + label: tTabs('ANALYZE'), + icon: , + element: , + // unmount to optimize performances + unmountOnExit: true, + }, + ]} + /> + diff --git a/src/modules/models/House/ResidentialHouse/Floor.tsx b/src/modules/models/House/ResidentialHouse/Floor.tsx index 5072764..c8b3c25 100644 --- a/src/modules/models/House/ResidentialHouse/Floor.tsx +++ b/src/modules/models/House/ResidentialHouse/Floor.tsx @@ -1,3 +1,5 @@ +import { useMemo } from 'react'; + import { useSimulation } from '@/context/SimulationContext'; import { HouseComponent } from '@/types/houseComponent'; @@ -15,15 +17,16 @@ export const Floor = ({ floor: number; }): JSX.Element => { const { wallGeometries } = useWallGeometries(); - const { componentsConfigurator } = useSimulation('house'); + const { getFirstOfType } = useSimulation('house'); if (floor < 0) { throw new Error('The floor number can be < 0!'); } - const wallHeight = - componentsConfigurator.getFirstOfType(HouseComponent.Wall)?.size.height ?? - 0; + const wallHeight = useMemo( + () => getFirstOfType(HouseComponent.Wall)?.size.height ?? 0, + [getFirstOfType], + ); const offSetY = wallHeight * floor; diff --git a/src/modules/models/House/ResidentialHouse/Roof.tsx b/src/modules/models/House/ResidentialHouse/Roof.tsx index a3850c3..1178b00 100644 --- a/src/modules/models/House/ResidentialHouse/Roof.tsx +++ b/src/modules/models/House/ResidentialHouse/Roof.tsx @@ -1,3 +1,5 @@ +import { useMemo } from 'react'; + import { useSimulation } from '@/context/SimulationContext'; import { useWallMaterial } from '@/hooks/useWallMaterial'; import { useWindowMaterial } from '@/hooks/useWindowMaterial'; @@ -60,15 +62,16 @@ export const Roof = ({ nFloors: number; }): JSX.Element => { const wallMaterial = useWallMaterial({ wallMaterial: materials.Wall }); - const { componentsConfigurator } = useSimulation('house'); + const { getFirstOfType } = useSimulation('house'); if (nFloors <= 0) { throw new Error('The house must at least have one floor!'); } - const wallHeight = - componentsConfigurator.getFirstOfType(HouseComponent.Wall)?.size.height ?? - 0; + const wallHeight = useMemo( + () => getFirstOfType(HouseComponent.Wall)?.size.height ?? 0, + [getFirstOfType], + ); const offsetY = wallHeight * (nFloors - 1); diff --git a/src/reducer/simulationHistoryReducer.tsx b/src/reducer/simulationHistoryReducer.tsx index fe62375..8f34e25 100644 --- a/src/reducer/simulationHistoryReducer.tsx +++ b/src/reducer/simulationHistoryReducer.tsx @@ -1,12 +1,10 @@ -import equal from 'deep-equal'; - import { SIMULATION_INDOOR_TEMPERATURE_CELCIUS, SIMULATION_OUTDOOR_TEMPERATURE_CELCIUS, SIMULATION_PRICE_KWH, } from '@/config/simulation'; -import { HouseComponentsConfigurator } from '@/models/HouseComponentsConfigurator'; import { SimulationHeatLoss } from '@/models/SimulationHeatLoss'; +import { HeatLossPerComponentEntries } from '@/types/houseComponent'; import { TemperatureRow, UserOutdoorTemperature } from '@/types/temperatures'; import { CreateNonEmptyArray, NonEmptyArray } from '@/types/utils'; import { WindowSizeType } from '@/types/window'; @@ -27,8 +25,8 @@ type SimulationSettings = { outdoorTemperature: UserOutdoorTemperature; pricekWh: number; numberOfFloors: number; - houseConfigurator: HouseComponentsConfigurator; windowSize: WindowSizeType; + heatLossConstantFactors: HeatLossPerComponentEntries; }; type SimulationHistory = { @@ -43,14 +41,43 @@ const computeSimulation = ( ): SimulationHistory => { const { currentDayIdx, simulationDays, simulationSettings, ...otherStates } = state; - const { outdoorTemperature, indoorTemperature, houseConfigurator, pricekWh } = - { ...simulationSettings, ...newSettings }; - - // If the new value does not modify the current day, do noting! - // This check is necessary, because when we navigate through the history and the inputs are modified, - // the changes cause a call to recompute the whole simulation, even though the value is identical. - if (equal(simulationDays[currentDayIdx], newSettings)) { - return state; + const { + outdoorTemperature, + indoorTemperature, + pricekWh, + heatLossConstantFactors, + } = { + ...simulationSettings, + ...newSettings, + }; + + // Using reducer degrade the performances, so we should use a simple for-loop. + const newSimulationDays: SimulationDay[] = []; + let prevTotHeatLoss = 0; + for (let i = 0; i < simulationDays.length; i += 1) { + const currDay = simulationDays[i]; + + const heatLoss = new SimulationHeatLoss({ + indoorTemperature, + outdoorTemperature: getOutdoorTemperature({ + userTemperature: outdoorTemperature, + weather: currDay.weatherTemperature, + }), + heatLossConstantFactors, + }); + + prevTotHeatLoss += heatLoss.global; + + newSimulationDays.push({ + heatLoss, + totalHeatLoss: prevTotHeatLoss, + totalElectricityCost: electricityCost({ + pricekWh, + energyConsumptionkWh: prevTotHeatLoss / powerConversionFactors.KiloWatt, + }), + weatherTemperature: currDay.weatherTemperature, + date: currDay.date, + }); } return { @@ -60,39 +87,7 @@ const computeSimulation = ( ...simulationSettings, ...newSettings, }, - simulationDays: CreateNonEmptyArray( - simulationDays.reduce((acc, currDay) => { - const prevDay = acc[acc.length - 1]; - const prevTotHeatLoss = - prevDay?.totalHeatLoss ?? prevDay?.heatLoss ?? 0; - - const heatLoss = new SimulationHeatLoss({ - indoorTemperature, - outdoorTemperature: getOutdoorTemperature({ - userTemperature: outdoorTemperature, - weather: currDay.weatherTemperature, - }), - houseConfigurator, - }); - - const totalHeatLoss = prevTotHeatLoss + heatLoss.global; - - return [ - ...acc, - { - heatLoss, - totalHeatLoss, - totalElectricityCost: electricityCost({ - pricekWh, - energyConsumptionkWh: - totalHeatLoss / powerConversionFactors.KiloWatt, - }), - weatherTemperature: currDay.weatherTemperature, - date: currDay.date, - }, - ]; - }, []), - ), + simulationDays: CreateNonEmptyArray(newSimulationDays), }; }; @@ -125,8 +120,8 @@ type Action = pricekWh: number; } | { - type: 'updateHouseConfigurator'; - houseConfigurator: HouseComponentsConfigurator; + type: 'updateConstantFactors'; + heatLossConstantFactors: HeatLossPerComponentEntries; } | { type: 'updateWindowSize'; @@ -152,8 +147,8 @@ export const createDefault = (): SimulationHistory => ({ }, pricekWh: SIMULATION_PRICE_KWH, numberOfFloors: 1, - houseConfigurator: HouseComponentsConfigurator.create(), // will be replaced on component register windowSize: 'Medium', + heatLossConstantFactors: [], }, }); @@ -230,12 +225,9 @@ export const simulationHistory = ( return computeSimulation(state, { pricekWh: action.pricekWh, }); - case 'updateHouseConfigurator': + case 'updateConstantFactors': return computeSimulation(state, { - // As React compare the memory adress to - // dectect changes, we have to clone the configurator - // to ensure re-render when necessary. - houseConfigurator: action.houseConfigurator.clone(), + heatLossConstantFactors: action.heatLossConstantFactors, }); case 'updateWindowSize': return computeSimulation(state, { diff --git a/src/types/houseComponent.ts b/src/types/houseComponent.ts index 3986f74..a0c8b80 100644 --- a/src/types/houseComponent.ts +++ b/src/types/houseComponent.ts @@ -8,6 +8,11 @@ export type HeatLossPerComponent = { [componentId: string]: number; }; +/** + * Result of Object.entries(HeatLossPerComponent) used for performance. + */ +export type HeatLossPerComponentEntries = [string, number][]; + export type Size = { width: number; height: number; diff --git a/src/types/utils.ts b/src/types/utils.ts index 7969cfd..acb50e7 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -1,4 +1,4 @@ -export type NonEmptyArray = readonly [T, ...T[]]; +export type NonEmptyArray = [T, ...T[]]; export const CreateNonEmptyArray = (arr: T[]): NonEmptyArray => { const [first, ...rest] = arr; if (!first) { diff --git a/tests/player/HousePage.ts b/tests/player/HousePage.ts index fcc537c..bc6aa94 100644 --- a/tests/player/HousePage.ts +++ b/tests/player/HousePage.ts @@ -25,5 +25,7 @@ export class HousePage { await this.page.goto('/'); // wait to have loaded the simulation correctly await this.info.expectIsVisible(); + // try to mitigate flacky tests + await this.page.waitForTimeout(50); } } diff --git a/tests/player/SimulationSettingsPage.ts b/tests/player/SimulationSettingsPage.ts index 264e8b5..f13c385 100644 --- a/tests/player/SimulationSettingsPage.ts +++ b/tests/player/SimulationSettingsPage.ts @@ -29,6 +29,7 @@ export class SimulationSettingsPage { await expect( this.page.getByRole('combobox', { name: selectLabel }), ).toHaveText(selectOption); + await this.page.waitForTimeout(50); } async selectWindowInsulation(newInsulation: string): Promise { diff --git a/tests/player/electricity-cost.spec.ts b/tests/player/electricity-cost.spec.ts index a19656a..26e931e 100644 --- a/tests/player/electricity-cost.spec.ts +++ b/tests/player/electricity-cost.spec.ts @@ -23,6 +23,7 @@ test('should not accept text', async ({ page }) => { const housePage = new HousePage(page); const { info, settings } = housePage; await housePage.goto(); + await page.waitForTimeout(50); const electricityCost = await info.totElectricityCostInfo.textContent(); await settings.electricityCost.click(); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..141cd45 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,9 @@ +import * as matchers from '@testing-library/jest-dom/matchers'; +import { cleanup } from '@testing-library/react'; +import { afterEach, expect } from 'vitest'; + +expect.extend(matchers); + +afterEach(() => { + cleanup(); +}); diff --git a/vite.config.ts b/vite.config.ts index de58ed0..81ccf9d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -53,6 +53,7 @@ export default ({ mode }: { mode: string }): UserConfigExport => { '.nyc_output', 'coverage', '**/*.test.ts', + '**/*.test.tsx', ], extension: ['.js', '.ts', '.tsx'], requireEnv: false, @@ -61,7 +62,10 @@ export default ({ mode }: { mode: string }): UserConfigExport => { }), ], test: { - include: ['**/*.test.ts'], + include: ['**/*.test.{ts,tsx}'], + globals: true, + environment: 'jsdom', + setupFiles: './tests/setup.ts', }, resolve: { alias: { diff --git a/yarn.lock b/yarn.lock index e53b03d..978df36 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,6 +5,13 @@ __metadata: version: 8 cacheKey: 10 +"@adobe/css-tools@npm:^4.4.0": + version: 4.4.1 + resolution: "@adobe/css-tools@npm:4.4.1" + checksum: 10/a0ea05517308593a52728936a833b1075c4cf1a6b68baaea817063f34e75faa1dba1209dd285003c4f8072804227dfa563e7e903f72ae2d39cb520aaee3f4bcc + languageName: node + linkType: hard + "@ag-grid-community/client-side-row-model@npm:31.2.1": version: 31.2.1 resolution: "@ag-grid-community/client-side-row-model@npm:31.2.1" @@ -64,7 +71,7 @@ __metadata: languageName: node linkType: hard -"@babel/code-frame@npm:^7.25.9, @babel/code-frame@npm:^7.26.0, @babel/code-frame@npm:^7.26.2": +"@babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.25.9, @babel/code-frame@npm:^7.26.0, @babel/code-frame@npm:^7.26.2": version: 7.26.2 resolution: "@babel/code-frame@npm:7.26.2" dependencies: @@ -4624,6 +4631,57 @@ __metadata: languageName: node linkType: hard +"@testing-library/dom@npm:^10.4.0": + version: 10.4.0 + resolution: "@testing-library/dom@npm:10.4.0" + dependencies: + "@babel/code-frame": "npm:^7.10.4" + "@babel/runtime": "npm:^7.12.5" + "@types/aria-query": "npm:^5.0.1" + aria-query: "npm:5.3.0" + chalk: "npm:^4.1.0" + dom-accessibility-api: "npm:^0.5.9" + lz-string: "npm:^1.5.0" + pretty-format: "npm:^27.0.2" + checksum: 10/05825ee9a15b88cbdae12c137db7111c34069ed3c7a1bd03b6696cb1b37b29f6f2d2de581ebf03033e7df1ab7ebf08399310293f440a4845d95c02c0a9ecc899 + languageName: node + linkType: hard + +"@testing-library/jest-dom@npm:^6.6.3": + version: 6.6.3 + resolution: "@testing-library/jest-dom@npm:6.6.3" + dependencies: + "@adobe/css-tools": "npm:^4.4.0" + aria-query: "npm:^5.0.0" + chalk: "npm:^3.0.0" + css.escape: "npm:^1.5.1" + dom-accessibility-api: "npm:^0.6.3" + lodash: "npm:^4.17.21" + redent: "npm:^3.0.0" + checksum: 10/1f3427e45870eab9dcc59d6504b780d4a595062fe1687762ae6e67d06a70bf439b40ab64cf58cbace6293a99e3764d4647fdc8300a633b721764f5ce39dade18 + languageName: node + linkType: hard + +"@testing-library/react@npm:^16.1.0": + version: 16.1.0 + resolution: "@testing-library/react@npm:16.1.0" + dependencies: + "@babel/runtime": "npm:^7.12.5" + peerDependencies: + "@testing-library/dom": ^10.0.0 + "@types/react": ^18.0.0 || ^19.0.0 + "@types/react-dom": ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10/2a20e0dbfadbc93d45a84e82281ed47deed54a6a5fc1461a523172d7fbc0481e8502cf98a2080f38aba94290b3d745671a1c9e320e6f76ad6afcca67c580b963 + languageName: node + linkType: hard + "@trivago/prettier-plugin-sort-imports@npm:^4.3.0": version: 4.3.0 resolution: "@trivago/prettier-plugin-sort-imports@npm:4.3.0" @@ -4651,6 +4709,13 @@ __metadata: languageName: node linkType: hard +"@types/aria-query@npm:^5.0.1": + version: 5.0.4 + resolution: "@types/aria-query@npm:5.0.4" + checksum: 10/c0084c389dc030daeaf0115a92ce43a3f4d42fc8fef2d0e22112d87a42798d4a15aac413019d4a63f868327d52ad6740ab99609462b442fe6b9286b172d2e82e + languageName: node + linkType: hard + "@types/babel__core@npm:^7.0.0, @types/babel__core@npm:^7.18.0, @types/babel__core@npm:^7.20.5": version: 7.20.5 resolution: "@types/babel__core@npm:7.20.5" @@ -4828,13 +4893,6 @@ __metadata: languageName: node linkType: hard -"@types/deep-equal@npm:^1.0.4": - version: 1.0.4 - resolution: "@types/deep-equal@npm:1.0.4" - checksum: 10/db905bcb051bc9cf5dceb231a6b0b64ab2318073939a1769e043454e8c46b1eeaac1376338751d2ee7ca9294398dd62e03d4b120361b56f96f89dc87aec753e4 - languageName: node - linkType: hard - "@types/doctrine@npm:^0.0.3": version: 0.0.3 resolution: "@types/doctrine@npm:0.0.3" @@ -5772,6 +5830,13 @@ __metadata: languageName: node linkType: hard +"agent-base@npm:^7.1.2": + version: 7.1.3 + resolution: "agent-base@npm:7.1.3" + checksum: 10/3db6d8d4651f2aa1a9e4af35b96ab11a7607af57a24f3bc721a387eaa3b5f674e901f0a648b0caefd48f3fd117c7761b79a3b55854e2aebaa96c3f32cf76af84 + languageName: node + linkType: hard + "aggregate-error@npm:^3.0.0": version: 3.1.0 resolution: "aggregate-error@npm:3.1.0" @@ -5910,7 +5975,16 @@ __metadata: languageName: node linkType: hard -"aria-query@npm:^5.3.2": +"aria-query@npm:5.3.0": + version: 5.3.0 + resolution: "aria-query@npm:5.3.0" + dependencies: + dequal: "npm:^2.0.3" + checksum: 10/c3e1ed127cc6886fea4732e97dd6d3c3938e64180803acfb9df8955517c4943760746ffaf4020ce8f7ffaa7556a3b5f85c3769a1f5ca74a1288e02d042f9ae4e + languageName: node + linkType: hard + +"aria-query@npm:^5.0.0, aria-query@npm:^5.3.2": version: 5.3.2 resolution: "aria-query@npm:5.3.2" checksum: 10/b2fe9bc98bd401bc322ccb99717c1ae2aaf53ea0d468d6e7aebdc02fac736e4a99b46971ee05b783b08ade23c675b2d8b60e4a1222a95f6e27bc4d2a0bfdcc03 @@ -6483,6 +6557,16 @@ __metadata: languageName: node linkType: hard +"chalk@npm:^3.0.0": + version: 3.0.0 + resolution: "chalk@npm:3.0.0" + dependencies: + ansi-styles: "npm:^4.1.0" + supports-color: "npm:^7.1.0" + checksum: 10/37f90b31fd655fb49c2bd8e2a68aebefddd64522655d001ef417e6f955def0ed9110a867ffc878a533f2dafea5f2032433a37c8a7614969baa7f8a1cd424ddfc + languageName: node + linkType: hard + "chalk@npm:^4.0.0, chalk@npm:^4.1.0, chalk@npm:^4.1.1, chalk@npm:^4.1.2": version: 4.1.2 resolution: "chalk@npm:4.1.2" @@ -6909,6 +6993,22 @@ __metadata: languageName: node linkType: hard +"css.escape@npm:^1.5.1": + version: 1.5.1 + resolution: "css.escape@npm:1.5.1" + checksum: 10/f6d38088d870a961794a2580b2b2af1027731bb43261cfdce14f19238a88664b351cc8978abc20f06cc6bbde725699dec8deb6fe9816b139fc3f2af28719e774 + languageName: node + linkType: hard + +"cssstyle@npm:^4.1.0": + version: 4.1.0 + resolution: "cssstyle@npm:4.1.0" + dependencies: + rrweb-cssom: "npm:^0.7.1" + checksum: 10/8ca9e2d1f1b24f93bb5f3f20a7a1e271e58060957880e985ee55614e196a798ffab309ec6bac105af8a439a6764546761813835ebb7f929d60823637ee838a8f + languageName: node + linkType: hard + "csstype@npm:^3.0.2, csstype@npm:^3.1.3": version: 3.1.3 resolution: "csstype@npm:3.1.3" @@ -7023,6 +7123,16 @@ __metadata: languageName: node linkType: hard +"data-urls@npm:^5.0.0": + version: 5.0.0 + resolution: "data-urls@npm:5.0.0" + dependencies: + whatwg-mimetype: "npm:^4.0.0" + whatwg-url: "npm:^14.0.0" + checksum: 10/5c40568c31b02641a70204ff233bc4e42d33717485d074244a98661e5f2a1e80e38fe05a5755dfaf2ee549f2ab509d6a3af2a85f4b2ad2c984e5d176695eaf46 + languageName: node + linkType: hard + "data-view-buffer@npm:^1.0.1": version: 1.0.1 resolution: "data-view-buffer@npm:1.0.1" @@ -7128,6 +7238,13 @@ __metadata: languageName: node linkType: hard +"decimal.js@npm:^10.4.3": + version: 10.4.3 + resolution: "decimal.js@npm:10.4.3" + checksum: 10/de663a7bc4d368e3877db95fcd5c87b965569b58d16cdc4258c063d231ca7118748738df17cd638f7e9dd0be8e34cec08d7234b20f1f2a756a52fc5a38b188d0 + languageName: node + linkType: hard + "deep-eql@npm:^4.1.3": version: 4.1.4 resolution: "deep-eql@npm:4.1.4" @@ -7158,7 +7275,7 @@ __metadata: languageName: node linkType: hard -"deep-equal@npm:^2.0.5, deep-equal@npm:^2.2.3": +"deep-equal@npm:^2.0.5": version: 2.2.3 resolution: "deep-equal@npm:2.2.3" dependencies: @@ -7245,7 +7362,7 @@ __metadata: languageName: node linkType: hard -"dequal@npm:^2.0.2": +"dequal@npm:^2.0.2, dequal@npm:^2.0.3": version: 2.0.3 resolution: "dequal@npm:2.0.3" checksum: 10/6ff05a7561f33603df87c45e389c9ac0a95e3c056be3da1a0c4702149e3a7f6fe5ffbb294478687ba51a9e95f3a60e8b6b9005993acd79c292c7d15f71964b6b @@ -7309,6 +7426,20 @@ __metadata: languageName: node linkType: hard +"dom-accessibility-api@npm:^0.5.9": + version: 0.5.16 + resolution: "dom-accessibility-api@npm:0.5.16" + checksum: 10/377b4a7f9eae0a5d72e1068c369c99e0e4ca17fdfd5219f3abd32a73a590749a267475a59d7b03a891f9b673c27429133a818c44b2e47e32fec024b34274e2ca + languageName: node + linkType: hard + +"dom-accessibility-api@npm:^0.6.3": + version: 0.6.3 + resolution: "dom-accessibility-api@npm:0.6.3" + checksum: 10/83d3371f8226487fbad36e160d44f1d9017fb26d46faba6a06fcad15f34633fc827b8c3e99d49f71d5f3253d866e2131826866fd0a3c86626f8eccfc361881ff + languageName: node + linkType: hard + "dom-helpers@npm:^5.0.1": version: 5.2.1 resolution: "dom-helpers@npm:5.2.1" @@ -7417,6 +7548,13 @@ __metadata: languageName: node linkType: hard +"entities@npm:^4.5.0": + version: 4.5.0 + resolution: "entities@npm:4.5.0" + checksum: 10/ede2a35c9bce1aeccd055a1b445d41c75a14a2bb1cd22e242f20cf04d236cdcd7f9c859eb83f76885327bfae0c25bf03303665ee1ce3d47c5927b98b0e3e3d48 + languageName: node + linkType: hard + "env-cmd@npm:10.1.0": version: 10.1.0 resolution: "env-cmd@npm:10.1.0" @@ -9244,8 +9382,10 @@ __metadata: "@sentry/react": "npm:7.120.2" "@tanstack/react-query": "npm:^4.36.1" "@tanstack/react-query-devtools": "npm:^4.36.1" + "@testing-library/dom": "npm:^10.4.0" + "@testing-library/jest-dom": "npm:^6.6.3" + "@testing-library/react": "npm:^16.1.0" "@trivago/prettier-plugin-sort-imports": "npm:^4.3.0" - "@types/deep-equal": "npm:^1.0.4" "@types/i18n": "npm:0.13.12" "@types/node": "npm:20.17.10" "@types/papaparse": "npm:^5.3.15" @@ -9259,7 +9399,6 @@ __metadata: "@vitejs/plugin-react": "npm:^4.2.1" axios: "npm:1.7.9" concurrently: "npm:8.2.2" - deep-equal: "npm:^2.2.3" dotenv: "npm:^16.4.5" env-cmd: "npm:10.1.0" eslint: "npm:9.13.0" @@ -9276,6 +9415,8 @@ __metadata: globals: "npm:^15.11.0" husky: "npm:9.1.7" i18next: "npm:^23.9.0" + immer: "npm:^10.1.1" + jsdom: "npm:^25.0.1" lucide-react: "npm:^0.456.0" nock: "npm:^13.5.3" nyc: "npm:17.1.0" @@ -9291,6 +9432,7 @@ __metadata: three-stdlib: "npm:2.33.0" typescript: "npm:5.7.2" use-debounce: "npm:^10.0.4" + use-immer: "npm:^0.11.0" uuid: "npm:9.0.1" vite: "npm:^6.0.0" vite-plugin-checker: "npm:^0.8.0" @@ -9433,6 +9575,15 @@ __metadata: languageName: node linkType: hard +"html-encoding-sniffer@npm:^4.0.0": + version: 4.0.0 + resolution: "html-encoding-sniffer@npm:4.0.0" + dependencies: + whatwg-encoding: "npm:^3.1.1" + checksum: 10/e86efd493293a5671b8239bd099d42128433bb3c7b0fdc7819282ef8e118a21f5dead0ad6f358e024a4e5c84f17ebb7a9b36075220fac0a6222b207248bede6f + languageName: node + linkType: hard + "html-escaper@npm:^2.0.0": version: 2.0.2 resolution: "html-escaper@npm:2.0.2" @@ -9476,7 +9627,7 @@ __metadata: languageName: node linkType: hard -"http-proxy-agent@npm:^7.0.0": +"http-proxy-agent@npm:^7.0.0, http-proxy-agent@npm:^7.0.2": version: 7.0.2 resolution: "http-proxy-agent@npm:7.0.2" dependencies: @@ -9503,6 +9654,16 @@ __metadata: languageName: node linkType: hard +"https-proxy-agent@npm:^7.0.5": + version: 7.0.6 + resolution: "https-proxy-agent@npm:7.0.6" + dependencies: + agent-base: "npm:^7.1.2" + debug: "npm:4" + checksum: 10/784b628cbd55b25542a9d85033bdfd03d4eda630fb8b3c9477959367f3be95dc476ed2ecbb9836c359c7c698027fc7b45723a302324433590f45d6c1706e8c13 + languageName: node + linkType: hard + "human-signals@npm:^5.0.0": version: 5.0.0 resolution: "human-signals@npm:5.0.0" @@ -9537,7 +9698,7 @@ __metadata: languageName: node linkType: hard -"iconv-lite@npm:^0.6.2": +"iconv-lite@npm:0.6.3, iconv-lite@npm:^0.6.2": version: 0.6.3 resolution: "iconv-lite@npm:0.6.3" dependencies: @@ -9567,6 +9728,13 @@ __metadata: languageName: node linkType: hard +"immer@npm:^10.1.1": + version: 10.1.1 + resolution: "immer@npm:10.1.1" + checksum: 10/9dacf1e8c201d69191ccd88dc5d733bafe166cd45a5a360c5d7c88f1de0dff974a94114d72b35f3106adfe587fcfb131c545856184a2247d89d735ad25589863 + languageName: node + linkType: hard + "import-fresh@npm:^3.2.1, import-fresh@npm:^3.3.0": version: 3.3.0 resolution: "import-fresh@npm:3.3.0" @@ -9921,6 +10089,13 @@ __metadata: languageName: node linkType: hard +"is-potential-custom-element-name@npm:^1.0.1": + version: 1.0.1 + resolution: "is-potential-custom-element-name@npm:1.0.1" + checksum: 10/ced7bbbb6433a5b684af581872afe0e1767e2d1146b2207ca0068a648fb5cab9d898495d1ac0583524faaf24ca98176a7d9876363097c2d14fee6dd324f3a1ab + languageName: node + linkType: hard + "is-promise@npm:^2.1.0": version: 2.2.2 resolution: "is-promise@npm:2.2.2" @@ -10285,6 +10460,40 @@ __metadata: languageName: node linkType: hard +"jsdom@npm:^25.0.1": + version: 25.0.1 + resolution: "jsdom@npm:25.0.1" + dependencies: + cssstyle: "npm:^4.1.0" + data-urls: "npm:^5.0.0" + decimal.js: "npm:^10.4.3" + form-data: "npm:^4.0.0" + html-encoding-sniffer: "npm:^4.0.0" + http-proxy-agent: "npm:^7.0.2" + https-proxy-agent: "npm:^7.0.5" + is-potential-custom-element-name: "npm:^1.0.1" + nwsapi: "npm:^2.2.12" + parse5: "npm:^7.1.2" + rrweb-cssom: "npm:^0.7.1" + saxes: "npm:^6.0.0" + symbol-tree: "npm:^3.2.4" + tough-cookie: "npm:^5.0.0" + w3c-xmlserializer: "npm:^5.0.0" + webidl-conversions: "npm:^7.0.0" + whatwg-encoding: "npm:^3.1.1" + whatwg-mimetype: "npm:^4.0.0" + whatwg-url: "npm:^14.0.0" + ws: "npm:^8.18.0" + xml-name-validator: "npm:^5.0.0" + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + checksum: 10/e6bf7250ddd2fbcf68da0ea041a0dc63545dc4bf77fa3ff40a46ae45b1dac1ca55b87574ab904d1f8baeeb547c52cec493a22f545d7d413b320011f41150ec49 + languageName: node + linkType: hard + "jsesc@npm:^2.5.1": version: 2.5.2 resolution: "jsesc@npm:2.5.2" @@ -10696,6 +10905,15 @@ __metadata: languageName: node linkType: hard +"lz-string@npm:^1.5.0": + version: 1.5.0 + resolution: "lz-string@npm:1.5.0" + bin: + lz-string: bin/bin.js + checksum: 10/e86f0280e99a8d8cd4eef24d8601ddae15ce54e43ac9990dfcb79e1e081c255ad24424a30d78d2ad8e51a8ce82a66a930047fed4b4aa38c6f0b392ff9300edfc + languageName: node + linkType: hard + "maath@npm:^0.10.8": version: 0.10.8 resolution: "maath@npm:0.10.8" @@ -10894,7 +11112,7 @@ __metadata: languageName: node linkType: hard -"min-indent@npm:^1.0.1": +"min-indent@npm:^1.0.0, min-indent@npm:^1.0.1": version: 1.0.1 resolution: "min-indent@npm:1.0.1" checksum: 10/bfc6dd03c5eaf623a4963ebd94d087f6f4bbbfd8c41329a7f09706b0cb66969c4ddd336abeb587bc44bc6f08e13bf90f0b374f9d71f9f01e04adc2cd6f083ef1 @@ -11232,6 +11450,13 @@ __metadata: languageName: node linkType: hard +"nwsapi@npm:^2.2.12": + version: 2.2.16 + resolution: "nwsapi@npm:2.2.16" + checksum: 10/1e5e086cdd4ca4a45f414d37f49bf0ca81d84ed31c6871ac68f531917d2910845db61f77c6d844430dc90fda202d43fce9603024e74038675de95229eb834dba + languageName: node + linkType: hard + "nyc@npm:17.1.0": version: 17.1.0 resolution: "nyc@npm:17.1.0" @@ -11580,6 +11805,15 @@ __metadata: languageName: node linkType: hard +"parse5@npm:^7.1.2": + version: 7.2.1 + resolution: "parse5@npm:7.2.1" + dependencies: + entities: "npm:^4.5.0" + checksum: 10/fd1a8ad1540d871e1ad6ca9bf5b67e30280886f1ce4a28052c0cb885723aa984d8cb1ec3da998349a6146960c8a84aa87b1a42600eb3b94495c7303476f2f88e + languageName: node + linkType: hard + "parseurl@npm:~1.3.3": version: 1.3.3 resolution: "parseurl@npm:1.3.3" @@ -11835,6 +12069,17 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:^27.0.2": + version: 27.5.1 + resolution: "pretty-format@npm:27.5.1" + dependencies: + ansi-regex: "npm:^5.0.1" + ansi-styles: "npm:^5.0.0" + react-is: "npm:^17.0.1" + checksum: 10/248990cbef9e96fb36a3e1ae6b903c551ca4ddd733f8d0912b9cc5141d3d0b3f9f8dfb4d799fb1c6723382c9c2083ffbfa4ad43ff9a0e7535d32d41fd5f01da6 + languageName: node + linkType: hard + "pretty-format@npm:^29.7.0": version: 29.7.0 resolution: "pretty-format@npm:29.7.0" @@ -11924,7 +12169,7 @@ __metadata: languageName: node linkType: hard -"punycode@npm:^2.1.0": +"punycode@npm:^2.1.0, punycode@npm:^2.3.1": version: 2.3.1 resolution: "punycode@npm:2.3.1" checksum: 10/febdc4362bead22f9e2608ff0171713230b57aff9dddc1c273aa2a651fbd366f94b7d6a71d78342a7c0819906750351ca7f2edd26ea41b626d87d6a13d1bd059 @@ -12130,6 +12375,13 @@ __metadata: languageName: node linkType: hard +"react-is@npm:^17.0.1": + version: 17.0.2 + resolution: "react-is@npm:17.0.2" + checksum: 10/73b36281e58eeb27c9cc6031301b6ae19ecdc9f18ae2d518bdb39b0ac564e65c5779405d623f1df9abf378a13858b79442480244bd579968afc1faf9a2ce5e05 + languageName: node + linkType: hard + "react-is@npm:^18.0.0, react-is@npm:^18.2.0, react-is@npm:^18.3.1": version: 18.3.1 resolution: "react-is@npm:18.3.1" @@ -12306,6 +12558,16 @@ __metadata: languageName: node linkType: hard +"redent@npm:^3.0.0": + version: 3.0.0 + resolution: "redent@npm:3.0.0" + dependencies: + indent-string: "npm:^4.0.0" + strip-indent: "npm:^3.0.0" + checksum: 10/fa1ef20404a2d399235e83cc80bd55a956642e37dd197b4b612ba7327bf87fa32745aeb4a1634b2bab25467164ab4ed9c15be2c307923dd08b0fe7c52431ae6b + languageName: node + linkType: hard + "reflect.getprototypeof@npm:^1.0.4": version: 1.0.6 resolution: "reflect.getprototypeof@npm:1.0.6" @@ -12691,6 +12953,13 @@ __metadata: languageName: node linkType: hard +"rrweb-cssom@npm:^0.7.1": + version: 0.7.1 + resolution: "rrweb-cssom@npm:0.7.1" + checksum: 10/e80cf25c223a823921d7ab57c0ce78f5b7ebceab857b400cce99dd4913420ce679834bc5707e8ada47d062e21ad368108a9534c314dc8d72c20aa4a4fa0ed16a + languageName: node + linkType: hard + "run-async@npm:^2.4.0": version: 2.4.1 resolution: "run-async@npm:2.4.1" @@ -12753,6 +13022,15 @@ __metadata: languageName: node linkType: hard +"saxes@npm:^6.0.0": + version: 6.0.0 + resolution: "saxes@npm:6.0.0" + dependencies: + xmlchars: "npm:^2.2.0" + checksum: 10/97b50daf6ca3a153e89842efa18a862e446248296622b7473c169c84c823ee8a16e4a43bac2f73f11fc8cb9168c73fbb0d73340f26552bac17970e9052367aa9 + languageName: node + linkType: hard + "scheduler@npm:^0.21.0": version: 0.21.0 resolution: "scheduler@npm:0.21.0" @@ -13274,6 +13552,15 @@ __metadata: languageName: node linkType: hard +"strip-indent@npm:^3.0.0": + version: 3.0.0 + resolution: "strip-indent@npm:3.0.0" + dependencies: + min-indent: "npm:^1.0.0" + checksum: 10/18f045d57d9d0d90cd16f72b2313d6364fd2cb4bf85b9f593523ad431c8720011a4d5f08b6591c9d580f446e78855c5334a30fb91aa1560f5d9f95ed1b4a0530 + languageName: node + linkType: hard + "strip-indent@npm:^4.0.0": version: 4.0.0 resolution: "strip-indent@npm:4.0.0" @@ -13358,6 +13645,13 @@ __metadata: languageName: node linkType: hard +"symbol-tree@npm:^3.2.4": + version: 3.2.4 + resolution: "symbol-tree@npm:3.2.4" + checksum: 10/c09a00aadf279d47d0c5c46ca3b6b2fbaeb45f0a184976d599637d412d3a70bbdc043ff33effe1206dea0e36e0ad226cb957112e7ce9a4bf2daedf7fa4f85c53 + languageName: node + linkType: hard + "synchronous-promise@npm:^2.0.15": version: 2.0.17 resolution: "synchronous-promise@npm:2.0.17" @@ -13555,6 +13849,24 @@ __metadata: languageName: node linkType: hard +"tldts-core@npm:^6.1.69": + version: 6.1.69 + resolution: "tldts-core@npm:6.1.69" + checksum: 10/4f38497705dbb80e4d4d3cbdf6f931650371575fa27479ec6fba4e3527fb2929f703126a1026713674d882a7cb519ee1dd8f3f2d33c0dce284b3994e8b0177cc + languageName: node + linkType: hard + +"tldts@npm:^6.1.32": + version: 6.1.69 + resolution: "tldts@npm:6.1.69" + dependencies: + tldts-core: "npm:^6.1.69" + bin: + tldts: bin/cli.js + checksum: 10/a918ca1020985d708aa79e139e7c7a0f4bdf634c10ca4d03cad04ddd6145d576832a76f2dfab0583c45f5ad0f3d17d2c812c8b39421d6a0273e22a05e4a9e083 + languageName: node + linkType: hard + "tmp@npm:^0.0.33": version: 0.0.33 resolution: "tmp@npm:0.0.33" @@ -13587,6 +13899,24 @@ __metadata: languageName: node linkType: hard +"tough-cookie@npm:^5.0.0": + version: 5.0.0 + resolution: "tough-cookie@npm:5.0.0" + dependencies: + tldts: "npm:^6.1.32" + checksum: 10/a98d3846ed386e399e8b470c1eb08a6a296944246eabc55c9fe79d629bd2cdaa62f5a6572f271fe0060987906bd20468d72a219a3b4cbe51086bea48d2d677b6 + languageName: node + linkType: hard + +"tr46@npm:^5.0.0": + version: 5.0.0 + resolution: "tr46@npm:5.0.0" + dependencies: + punycode: "npm:^2.3.1" + checksum: 10/29155adb167d048d3c95d181f7cb5ac71948b4e8f3070ec455986e1f34634acae50ae02a3c8d448121c3afe35b76951cd46ed4c128fd80264280ca9502237a3e + languageName: node + linkType: hard + "tr46@npm:~0.0.3": version: 0.0.3 resolution: "tr46@npm:0.0.3" @@ -13989,6 +14319,16 @@ __metadata: languageName: node linkType: hard +"use-immer@npm:^0.11.0": + version: 0.11.0 + resolution: "use-immer@npm:0.11.0" + peerDependencies: + immer: ">=8.0.0" + react: ^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 + checksum: 10/09ddbddec5bd5a939dc462de4c9d1ce47989bc60daa712c5235664650b132e246b8e61b6bd3dc8179649445b7cf389065373c75f23829a676dbda6b5d2428928 + languageName: node + linkType: hard + "use-sync-external-store@npm:1.2.2, use-sync-external-store@npm:^1.2.0": version: 1.2.2 resolution: "use-sync-external-store@npm:1.2.2" @@ -14453,6 +14793,15 @@ __metadata: languageName: node linkType: hard +"w3c-xmlserializer@npm:^5.0.0": + version: 5.0.0 + resolution: "w3c-xmlserializer@npm:5.0.0" + dependencies: + xml-name-validator: "npm:^5.0.0" + checksum: 10/d78f59e6b4f924aa53b6dfc56949959229cae7fe05ea9374eb38d11edcec01398b7f5d7a12576bd5acc57ff446abb5c9115cd83b9d882555015437cf858d42f0 + languageName: node + linkType: hard + "wcwidth@npm:^1.0.1": version: 1.0.1 resolution: "wcwidth@npm:1.0.1" @@ -14496,6 +14845,13 @@ __metadata: languageName: node linkType: hard +"webidl-conversions@npm:^7.0.0": + version: 7.0.0 + resolution: "webidl-conversions@npm:7.0.0" + checksum: 10/4c4f65472c010eddbe648c11b977d048dd96956a625f7f8b9d64e1b30c3c1f23ea1acfd654648426ce5c743c2108a5a757c0592f02902cf7367adb7d14e67721 + languageName: node + linkType: hard + "webpack-virtual-modules@npm:^0.6.2": version: 0.6.2 resolution: "webpack-virtual-modules@npm:0.6.2" @@ -14503,6 +14859,32 @@ __metadata: languageName: node linkType: hard +"whatwg-encoding@npm:^3.1.1": + version: 3.1.1 + resolution: "whatwg-encoding@npm:3.1.1" + dependencies: + iconv-lite: "npm:0.6.3" + checksum: 10/bbef815eb67f91487c7f2ef96329743f5fd8357d7d62b1119237d25d41c7e452dff8197235b2d3c031365a17f61d3bb73ca49d0ed1582475aa4a670815e79534 + languageName: node + linkType: hard + +"whatwg-mimetype@npm:^4.0.0": + version: 4.0.0 + resolution: "whatwg-mimetype@npm:4.0.0" + checksum: 10/894a618e2d90bf444b6f309f3ceb6e58cf21b2beaa00c8b333696958c4076f0c7b30b9d33413c9ffff7c5832a0a0c8569e5bb347ef44beded72aeefd0acd62e8 + languageName: node + linkType: hard + +"whatwg-url@npm:^14.0.0": + version: 14.1.0 + resolution: "whatwg-url@npm:14.1.0" + dependencies: + tr46: "npm:^5.0.0" + webidl-conversions: "npm:^7.0.0" + checksum: 10/3afd325de6cf3a367820ce7c3566a1f78eb1409c4f27b1867c74c76dab096d26acedf49a8b9b71db53df7d806ec2e9ae9ed96990b2f7d1abe6ecf1fe753af6eb + languageName: node + linkType: hard + "whatwg-url@npm:^5.0.0": version: 5.0.0 resolution: "whatwg-url@npm:5.0.0" @@ -14678,6 +15060,35 @@ __metadata: languageName: node linkType: hard +"ws@npm:^8.18.0": + version: 8.18.0 + resolution: "ws@npm:8.18.0" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10/70dfe53f23ff4368d46e4c0b1d4ca734db2c4149c6f68bc62cb16fc21f753c47b35fcc6e582f3bdfba0eaeb1c488cddab3c2255755a5c3eecb251431e42b3ff6 + languageName: node + linkType: hard + +"xml-name-validator@npm:^5.0.0": + version: 5.0.0 + resolution: "xml-name-validator@npm:5.0.0" + checksum: 10/43f30f3f6786e406dd665acf08cd742d5f8a46486bd72517edb04b27d1bcd1599664c2a4a99fc3f1e56a3194bff588b12f178b7972bc45c8047bdc4c3ac8d4a1 + languageName: node + linkType: hard + +"xmlchars@npm:^2.2.0": + version: 2.2.0 + resolution: "xmlchars@npm:2.2.0" + checksum: 10/4ad5924974efd004a47cce6acf5c0269aee0e62f9a805a426db3337af7bcbd331099df174b024ace4fb18971b8a56de386d2e73a1c4b020e3abd63a4a9b917f1 + languageName: node + linkType: hard + "y18n@npm:^4.0.0": version: 4.0.3 resolution: "y18n@npm:4.0.3"