@indiscale/linkahead-webui-ext-map 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/.eslintrc.json +45 -0
  2. package/.gitlab-ci.yml +44 -0
  3. package/CHANGELOG.md +78 -0
  4. package/README.md +97 -0
  5. package/RELEASE_GUIDELINES.md +45 -0
  6. package/__mocks__/fileMock.js +3 -0
  7. package/__mocks__/styleMock.js +1 -0
  8. package/babel.config.js +22 -0
  9. package/cypress/e2e/standalone-map.cy.js +55 -0
  10. package/cypress/support/commands.js +25 -0
  11. package/cypress/support/e2e.js +17 -0
  12. package/cypress.config.js +10 -0
  13. package/dist/2b3e1faf89f94a483539.png +0 -0
  14. package/dist/416d91365b44e4b4f477.png +0 -0
  15. package/dist/8f2c4d11474275fbc161.png +0 -0
  16. package/dist/index.html +1 -0
  17. package/dist/linkahead-webui-ext-map.js +3 -0
  18. package/dist/linkahead-webui-ext-map.js.LICENSE.txt +45 -0
  19. package/dist/linkahead-webui-ext-map.js.map +1 -0
  20. package/iframe/index.html +6 -0
  21. package/indiscale-linkahead-webui-ext-map-0.4.1.tgz +0 -0
  22. package/jest.config.js +23 -0
  23. package/jest.setup.js +2 -0
  24. package/package.json +105 -0
  25. package/public/favicon.ico +0 -0
  26. package/public/index.html +11 -0
  27. package/public/logo192.png +0 -0
  28. package/public/logo512.png +0 -0
  29. package/public/manifest.json +25 -0
  30. package/public/map_tile_caosdb_logo.png +0 -0
  31. package/public/mock.js +41 -0
  32. package/public/robots.txt +3 -0
  33. package/select_query.json +3 -0
  34. package/src/AllMapEntities.tsx +294 -0
  35. package/src/CurrentPageEntities.js +318 -0
  36. package/src/Map.helpers.css +8 -0
  37. package/src/Map.helpers.js +536 -0
  38. package/src/Map.js +288 -0
  39. package/src/Map.test.js +252 -0
  40. package/src/MapConfig.js +75 -0
  41. package/src/__snapshots__/Map.test.js.snap +1725 -0
  42. package/src/components/Coordinates.js +24 -0
  43. package/src/components/ErrorComponent.tsx +2 -0
  44. package/src/components/Graticule.js +27 -0
  45. package/src/components/Loader.module.css +17 -0
  46. package/src/components/Loader.tsx +36 -0
  47. package/src/components/PathDropDown.js +108 -0
  48. package/src/components/SearchControl.js +502 -0
  49. package/src/components/ToggleMapButton.js +194 -0
  50. package/src/components/ViewChangeControl.js +104 -0
  51. package/src/constants/index.js +1 -0
  52. package/src/context/ConfigProvider.test.js +232 -0
  53. package/src/context/ConfigProvider.tsx +189 -0
  54. package/src/context/LoadingProvider.test.js +124 -0
  55. package/src/context/LoadingProvider.tsx +117 -0
  56. package/src/context/PathIdProvider.js +102 -0
  57. package/src/contrib/latlnggraticule/LICENSE +20 -0
  58. package/src/contrib/latlnggraticule/README.md +68 -0
  59. package/src/contrib/latlnggraticule/leaflet.latlng-graticule.js +528 -0
  60. package/src/contrib/simplegraticule/L.Graticule.js +138 -0
  61. package/src/default_config.json +57 -0
  62. package/src/global.d.ts +8 -0
  63. package/src/index.js +6 -0
  64. package/src/index.scss +133 -0
  65. package/src/logging.js +7 -0
  66. package/src/renderHtmlTemplate.test.js +60 -0
  67. package/src/select-search.min.svg +1 -0
  68. package/src/select-search.svg +46 -0
  69. package/src/setupTests.js +5 -0
  70. package/src/utils/GenerateQueryString.js +200 -0
  71. package/src/utils/GenerateQueryString.test.js +304 -0
  72. package/src/utils/index.ts +3 -0
  73. package/standalone.config.js +5 -0
  74. package/static/map_tile_caosdb_logo.png +0 -0
  75. package/tsconfig.json +25 -0
  76. package/webpack.config.js +193 -0
@@ -0,0 +1,104 @@
1
+ import PropTypes from "prop-types";
2
+ import { logger } from "../logging";
3
+ import L from "leaflet";
4
+ import { useMap } from "react-leaflet";
5
+ import { useConfig } from "../context/ConfigProvider";
6
+
7
+ L.Control.ViewChangeControl = L.Control.extend({
8
+ _button: undefined,
9
+ _make_view_menu: function () {
10
+ var form = L.DomUtil.create("form", "viewMenu d-none");
11
+
12
+ form.addEventListener("click", function (e) {
13
+ // just stop the click here, such that it does not change
14
+ // anything else in the map's state.
15
+ e.stopPropagation();
16
+ });
17
+
18
+ return form;
19
+ },
20
+ onAdd: function () {
21
+ const view_menu = this._view_menu;
22
+ var click = (e) => {
23
+ e.stopPropagation();
24
+ if (L.DomUtil.hasClass(view_menu, "d-none")) {
25
+ L.DomUtil.removeClass(view_menu, "d-none");
26
+ } else {
27
+ L.DomUtil.addClass(view_menu, "d-none");
28
+ }
29
+ };
30
+
31
+ var button = L.DomUtil.create(
32
+ "div",
33
+ "leaflet-bar leaflet-control leaflet-control-custom caosdb-f-map-change-view-btn"
34
+ );
35
+ button.title = "Change the view";
36
+ button.addEventListener("click", click);
37
+
38
+ button.appendChild(view_menu);
39
+
40
+ const icon = L.DomUtil.create("i", "bi-three-dots-vertical");
41
+ button.appendChild(icon);
42
+
43
+ this._button = button;
44
+ return this._button;
45
+ },
46
+
47
+ setViews: function (views, current_view, set_map_view_cb) {
48
+ logger.trace("setViews", views, current_view, set_map_view_cb);
49
+ this._view_menu = this._make_view_menu();
50
+ views.forEach((view) => {
51
+ const option = L.DomUtil.create("div", "caosdb-f-map-view-select");
52
+ option.title = view.description || view.name || view.id;
53
+
54
+ const input = L.DomUtil.create("input");
55
+ input.name = "view";
56
+ input.type = "radio";
57
+ input.value = view.id;
58
+ if (view.id === current_view) {
59
+ input.checked = true;
60
+ }
61
+
62
+ const label = L.DomUtil.create("label");
63
+ label.innerHTML = view.name || view.description || view.id;
64
+
65
+ option.appendChild(input);
66
+ option.appendChild(label);
67
+ this._view_menu.appendChild(option);
68
+ });
69
+
70
+ this._view_menu.addEventListener("change", (e) => {
71
+ logger.trace("ViewChangeControl.onChange", e);
72
+ set_map_view_cb(e.target.value);
73
+ });
74
+ },
75
+ });
76
+
77
+ L.control.viewChangeControl = function (options) {
78
+ return new L.Control.ViewChangeControl(options);
79
+ };
80
+
81
+ export function ViewChangeControl({ mapView, setMapView }) {
82
+ const {
83
+ config: { views },
84
+ } = useConfig();
85
+
86
+ const map = useMap();
87
+
88
+ const viewChangeControl = L.control.viewChangeControl({
89
+ position: "bottomleft",
90
+ });
91
+
92
+ if (!map._viewChangeControl) {
93
+ map._viewChangeControl = viewChangeControl;
94
+ viewChangeControl.setViews(views, mapView, setMapView);
95
+ viewChangeControl.addTo(map);
96
+ }
97
+
98
+ return null;
99
+ }
100
+
101
+ ViewChangeControl.propTypes = {
102
+ mapView: PropTypes.string,
103
+ setMapView: PropTypes.func,
104
+ };
@@ -0,0 +1 @@
1
+ export const DEFAULT_IFRAMESETTINGS_PATHID = "same";
@@ -0,0 +1,232 @@
1
+ import React, { useEffect } from "react";
2
+ import { render, act } from "@testing-library/react";
3
+ import { ConfigProvider, useConfig } from "./ConfigProvider";
4
+ import default_config from "../default_config.json";
5
+
6
+ describe("ConfigProvider / setConfig", () => {
7
+ // Keep a copy of the original environment so we can restore it after tests.
8
+ const oldEnv = process.env;
9
+
10
+ beforeEach(() => {
11
+ // Clone process.env for each test so changes don't leak across tests.
12
+ process.env = { ...oldEnv };
13
+
14
+ // Reset any spies/mocks between tests.
15
+ jest.restoreAllMocks();
16
+ });
17
+
18
+ afterAll(() => {
19
+ // Restore the original process.env after the suite finishes.
20
+ process.env = oldEnv;
21
+ });
22
+
23
+ /**
24
+ * Probe is a tiny "test-only" consumer component.
25
+ *
26
+ * It reads the full context value via useConfig():
27
+ * { config, setConfig }
28
+ *
29
+ * Then it passes that object to the test via onValue().
30
+ *
31
+ * Note:
32
+ * - The provider memoizes its context value with useMemo([config, setConfig]).
33
+ * - Whenever `config` changes, the memoized `value` object changes too,
34
+ * causing this effect to run again and letting the test observe updates.
35
+ */
36
+ function Probe({ onValue }) {
37
+ const value = useConfig();
38
+
39
+ useEffect(() => {
40
+ onValue(value);
41
+ }, [value, onValue]);
42
+
43
+ return null;
44
+ }
45
+
46
+ test("non-standalone: normalizes config (injects select.query default, derives datamodel.role/entity)", async () => {
47
+ // In non-standalone mode we do NOT merge views,
48
+ // but we still normalize:
49
+ // - ensure select.query exists (default_config)
50
+ // - ensure datamodel.role/entity are derived from the query
51
+ process.env.STANDALONE_MODE = "false";
52
+
53
+ let last;
54
+ render(
55
+ <ConfigProvider initialConfig={null}>
56
+ <Probe onValue={(v) => (last = v)} />
57
+ </ConfigProvider>
58
+ );
59
+
60
+ const configFromApi = {
61
+ foo: "bar",
62
+ select: {}, // no query provided
63
+ datamodel: {}, // exists but empty
64
+ views: [{ id: "v1" }],
65
+ };
66
+
67
+ await act(async () => {
68
+ last.setConfig(configFromApi);
69
+ });
70
+
71
+ // keeps unrelated fields
72
+ expect(last.config.foo).toBe("bar");
73
+
74
+ // ensures query default is injected
75
+ expect(last.config.select.query).toEqual(default_config.select.query);
76
+
77
+ // ensures datamodel role/entity are derived from query
78
+ expect(last.config.datamodel.role).toBe(default_config.select.query.role);
79
+ expect(last.config.datamodel.entity).toBe(
80
+ default_config.select.query.entity
81
+ );
82
+
83
+ // views unchanged in non-standalone (merge only happens in standalone + default_view)
84
+ expect(last.config.views).toEqual([{ id: "v1" }]);
85
+ });
86
+
87
+ test("standalone: throws when iframeSettings missing", () => {
88
+ // In standalone mode, iframeSettings.formatString is mandatory.
89
+ // If it's missing, setConfig throws.
90
+ process.env.STANDALONE_MODE = "true";
91
+ jest.spyOn(console, "error").mockImplementation(() => {});
92
+
93
+ let last;
94
+ render(
95
+ <ConfigProvider initialConfig={null}>
96
+ <Probe onValue={(v) => (last = v)} />
97
+ </ConfigProvider>
98
+ );
99
+
100
+ expect(() => {
101
+ act(() => {
102
+ last.setConfig({
103
+ select: {},
104
+ datamodel: {},
105
+ views: [],
106
+ // iframeSettings missing
107
+ });
108
+ });
109
+ }).toThrow(/standalone mode/i);
110
+ });
111
+
112
+ test("standalone: merges iframeSettings.view into matching view id (excluding default_view) + normalizes select/datamodel", async () => {
113
+ // In standalone mode, if iframeSettings.view.default_view is present,
114
+ // we merge iframeSettings.view into the view whose id matches default_view,
115
+ // but must NOT copy `default_view` onto the view object.
116
+ process.env.STANDALONE_MODE = "true";
117
+
118
+ let last;
119
+ render(
120
+ <ConfigProvider initialConfig={null}>
121
+ <Probe onValue={(v) => (last = v)} />
122
+ </ConfigProvider>
123
+ );
124
+
125
+ const configFromApi = {
126
+ iframeSettings: {
127
+ formatString: "q",
128
+ view: {
129
+ default_view: "v2",
130
+ zoom: 7,
131
+ center: [1, 2],
132
+ },
133
+ },
134
+ select: {}, // allow default query injection
135
+ datamodel: {}, // allow role/entity injection
136
+ views: [
137
+ { id: "v1", zoom: 1, keep: "a" },
138
+ { id: "v2", zoom: 2, keep: "b" },
139
+ ],
140
+ other: 123,
141
+ };
142
+
143
+ await act(async () => {
144
+ last.setConfig(configFromApi);
145
+ });
146
+
147
+ expect(last.config.other).toBe(123);
148
+
149
+ // non-default view unchanged
150
+ expect(last.config.views[0]).toEqual({ id: "v1", zoom: 1, keep: "a" });
151
+
152
+ // default view merged (default_view removed)
153
+ expect(last.config.views[1]).toEqual({
154
+ id: "v2",
155
+ zoom: 7,
156
+ keep: "b",
157
+ center: [1, 2],
158
+ });
159
+ expect(last.config.views[1]).not.toHaveProperty("default_view");
160
+
161
+ // normalization still happens
162
+ expect(last.config.select.query).toEqual(default_config.select.query);
163
+ expect(last.config.datamodel.role).toBe(default_config.select.query.role);
164
+ expect(last.config.datamodel.entity).toBe(
165
+ default_config.select.query.entity
166
+ );
167
+ });
168
+
169
+ test("standalone: if default_view missing, does NOT merge views but still normalizes select/datamodel", async () => {
170
+ // Standalone mode without iframeSettings.view.default_view:
171
+ // - no view merge
172
+ // - but we still inject default query + derive datamodel role/entity
173
+ process.env.STANDALONE_MODE = "true";
174
+
175
+ let last;
176
+ render(
177
+ <ConfigProvider initialConfig={null}>
178
+ <Probe onValue={(v) => (last = v)} />
179
+ </ConfigProvider>
180
+ );
181
+
182
+ const configFromApi = {
183
+ iframeSettings: { formatString: "q", view: { zoom: 9 } }, // no default_view
184
+ select: {}, // allow default injection
185
+ datamodel: {}, // allow role/entity injection
186
+ views: [{ id: "v1" }],
187
+ };
188
+
189
+ await act(async () => {
190
+ last.setConfig(configFromApi);
191
+ });
192
+
193
+ // views unchanged
194
+ expect(last.config.views).toEqual([{ id: "v1" }]);
195
+
196
+ // normalization still happens
197
+ expect(last.config.select.query).toEqual(default_config.select.query);
198
+ expect(last.config.datamodel.role).toBe(default_config.select.query.role);
199
+ expect(last.config.datamodel.entity).toBe(
200
+ default_config.select.query.entity
201
+ );
202
+ });
203
+
204
+ test("non-standalone: does NOT crash if datamodel is missing (provider creates/normalizes it)", async () => {
205
+ // This test assumes the provider defensively creates `datamodel`
206
+ // if it is missing from the incoming config.
207
+ process.env.STANDALONE_MODE = "false";
208
+
209
+ let last;
210
+ render(
211
+ <ConfigProvider initialConfig={null}>
212
+ <Probe onValue={(v) => (last = v)} />
213
+ </ConfigProvider>
214
+ );
215
+
216
+ const configFromApi = {
217
+ select: {}, // no query
218
+ // datamodel missing entirely
219
+ views: [],
220
+ };
221
+
222
+ await act(async () => {
223
+ last.setConfig(configFromApi);
224
+ });
225
+
226
+ expect(last.config.datamodel).toBeTruthy();
227
+ expect(last.config.datamodel.role).toBe(default_config.select.query.role);
228
+ expect(last.config.datamodel.entity).toBe(
229
+ default_config.select.query.entity
230
+ );
231
+ });
232
+ });
@@ -0,0 +1,189 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useMemo,
5
+ useState,
6
+ useCallback,
7
+ type ReactNode,
8
+ } from "react";
9
+ import { get } from "lodash";
10
+ import default_config from "../default_config.json";
11
+
12
+ type TConfigContextValue = {
13
+ config: TConfig | null;
14
+ setConfig: (origConfigFromApi: TConfigApi) => void;
15
+ };
16
+
17
+ const ConfigContext = createContext<TConfigContextValue | null>(null);
18
+
19
+ type TConfigProviderProps = {
20
+ initialConfig?: TConfig | null;
21
+ children: ReactNode;
22
+ };
23
+
24
+ type TSelectQuery = {
25
+ role?: string;
26
+ entity?: string;
27
+ [key: string]: unknown;
28
+ };
29
+
30
+ type TSelect = {
31
+ query?: TSelectQuery;
32
+ paths?: Record<string, string[]>;
33
+ [key: string]: unknown;
34
+ };
35
+
36
+ type TDatamodel = {
37
+ lat?: string;
38
+ lng?: string;
39
+ role?: string;
40
+ entity?: string;
41
+ [key: string]: unknown;
42
+ };
43
+
44
+ type TIframeSettingsView = {
45
+ default_view?: string;
46
+ [key: string]: unknown;
47
+ };
48
+
49
+ export type TIframeSettings = {
50
+ formatString?: string;
51
+ view?: TIframeSettingsView;
52
+ selector?: string;
53
+ [key: string]: unknown;
54
+ };
55
+
56
+ type TView = {
57
+ id?: string;
58
+ [key: string]: unknown;
59
+ };
60
+
61
+ type TConfig = {
62
+ select?: TSelect;
63
+ datamodel?: TDatamodel;
64
+ iframeSettings?: TIframeSettings;
65
+ views?: TView[];
66
+ entityLayers: any;
67
+ [key: string]: unknown;
68
+ };
69
+
70
+ // What we accept from the API (same shape, but we treat it as optional/partial)
71
+ type TConfigApi = Partial<TConfig>;
72
+
73
+ /**
74
+ * ConfigProvider
75
+ * --------------
76
+ * Holds the configuration object for the Map.
77
+ *
78
+ * Responsibilities:
79
+ * - Normalizes incoming config:
80
+ * - ensures `select.query` exists (falls back to default_config.select.query)
81
+ * - ensures `datamodel.role/entity` are derived from `select.query`
82
+ * - In standalone mode:
83
+ * - requires `iframeSettings.formatString`
84
+ * - merges `iframeSettings.view` into the matching entry in `views`
85
+ * referenced by `iframeSettings.view.default_view` (excluding `default_view`)
86
+ */
87
+ export const ConfigProvider = ({
88
+ initialConfig = null,
89
+ children,
90
+ }: TConfigProviderProps) => {
91
+ const [config, setConfigOrig] = useState<TConfig | null>(initialConfig);
92
+
93
+ // useCallback stops endless loop
94
+ const setConfig = useCallback((origConfigFromApi: TConfigApi) => {
95
+ const isStandaloneMode = String(process.env.STANDALONE_MODE) === "true";
96
+
97
+ // Steps to build base config without mutation
98
+ // 1) Build a safe select/query with default
99
+ const select: TSelect = {
100
+ ...(origConfigFromApi.select || {}),
101
+ query:
102
+ (origConfigFromApi.select && origConfigFromApi.select.query) ||
103
+ (default_config as any).select?.query,
104
+ };
105
+
106
+ // 2) Build datamodel
107
+ const datamodel: TDatamodel = {
108
+ ...(origConfigFromApi.datamodel || {}),
109
+ role: select.query?.role,
110
+ entity: select.query?.entity,
111
+ };
112
+
113
+ // 3) Build base config
114
+ const configFromApi: TConfig = {
115
+ ...(origConfigFromApi as TConfig),
116
+ select,
117
+ datamodel,
118
+ };
119
+
120
+ // Throw error if missing config for Standalone map
121
+ if (
122
+ isStandaloneMode &&
123
+ (!configFromApi?.iframeSettings ||
124
+ !configFromApi?.iframeSettings.formatString)
125
+ ) {
126
+ throw new Error(
127
+ "In standalone mode a query must be defined under iframeSettings.formatString"
128
+ );
129
+ }
130
+
131
+ // Find default view for standalone map
132
+ const standaloneDefaultViewKey = get(
133
+ configFromApi,
134
+ "iframeSettings.view.default_view"
135
+ ) as string | undefined;
136
+
137
+ // If Standalone Map Mode we overwrite the corresponding view in the views array
138
+ // with the view settings from within iframeSettings.view while keeping the rest
139
+ // of its properties intact.
140
+ if (isStandaloneMode && standaloneDefaultViewKey) {
141
+ const views: TView[] = (configFromApi?.views ?? []).map((view) => {
142
+ if (view?.id === standaloneDefaultViewKey) {
143
+ // Destructure so we don't include default_view
144
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
145
+ const { default_view, ...newView } = (configFromApi?.iframeSettings
146
+ ?.view ?? {}) as TIframeSettingsView;
147
+
148
+ // merge
149
+ return {
150
+ ...view,
151
+ ...newView,
152
+ };
153
+ }
154
+
155
+ return view;
156
+ });
157
+
158
+ // config obj with updated views array
159
+ const newConfig: TConfig = {
160
+ ...configFromApi,
161
+ views,
162
+ };
163
+
164
+ // Save to context
165
+ setConfigOrig(newConfig);
166
+ } else {
167
+ // Not standalone mode, simply save config untouched
168
+ setConfigOrig(configFromApi);
169
+ }
170
+ }, []);
171
+
172
+ // Memoize to stop needless / endless renders as passing object
173
+ const value = useMemo<TConfigContextValue>(
174
+ () => ({ config, setConfig }),
175
+ [config, setConfig]
176
+ );
177
+
178
+ return (
179
+ <ConfigContext.Provider value={value}>{children}</ConfigContext.Provider>
180
+ );
181
+ };
182
+
183
+ export const useConfig = (): TConfigContextValue => {
184
+ const context = useContext(ConfigContext);
185
+ if (!context) {
186
+ throw new Error("useConfig must be used within a <ConfigProvider>");
187
+ }
188
+ return context;
189
+ };
@@ -0,0 +1,124 @@
1
+ import React from "react";
2
+ import { render, screen, act } from "@testing-library/react";
3
+ import "@testing-library/jest-dom";
4
+
5
+ import { LoadingProvider, useLoading } from "./LoadingProvider";
6
+
7
+ function TestConsumer() {
8
+ const { loading, startLoading, stopLoading } = useLoading();
9
+
10
+ return (
11
+ <div>
12
+ <div data-testid="loading">{loading ? "true" : "false"}</div>
13
+
14
+ <button onClick={() => startLoading()} data-testid="start">
15
+ start
16
+ </button>
17
+
18
+ <button onClick={() => stopLoading()} data-testid="stop">
19
+ stop
20
+ </button>
21
+ </div>
22
+ );
23
+ }
24
+
25
+ describe("LoadingProvider / useLoading", () => {
26
+ test("defaults to loading=false", () => {
27
+ render(
28
+ <LoadingProvider>
29
+ <TestConsumer />
30
+ </LoadingProvider>
31
+ );
32
+
33
+ expect(screen.getByTestId("loading")).toHaveTextContent("false");
34
+ });
35
+
36
+ test("startLoading sets loading=true", () => {
37
+ render(
38
+ <LoadingProvider>
39
+ <TestConsumer />
40
+ </LoadingProvider>
41
+ );
42
+
43
+ act(() => {
44
+ screen.getByTestId("start").click();
45
+ });
46
+
47
+ expect(screen.getByTestId("loading")).toHaveTextContent("true");
48
+ });
49
+
50
+ test("counter semantics: stays true until all stops are called", () => {
51
+ render(
52
+ <LoadingProvider>
53
+ <TestConsumer />
54
+ </LoadingProvider>
55
+ );
56
+
57
+ // start twice
58
+ act(() => {
59
+ screen.getByTestId("start").click();
60
+ screen.getByTestId("start").click();
61
+ });
62
+
63
+ expect(screen.getByTestId("loading")).toHaveTextContent("true");
64
+
65
+ // stop once -> still true
66
+ act(() => {
67
+ screen.getByTestId("stop").click();
68
+ });
69
+
70
+ expect(screen.getByTestId("loading")).toHaveTextContent("true");
71
+
72
+ // stop second time -> now false
73
+ act(() => {
74
+ screen.getByTestId("stop").click();
75
+ });
76
+
77
+ expect(screen.getByTestId("loading")).toHaveTextContent("false");
78
+ });
79
+
80
+ test("stopLoading cannot make counter negative", () => {
81
+ render(
82
+ <LoadingProvider>
83
+ <TestConsumer />
84
+ </LoadingProvider>
85
+ );
86
+
87
+ // stop without start -> should remain false
88
+ act(() => {
89
+ screen.getByTestId("stop").click();
90
+ screen.getByTestId("stop").click();
91
+ });
92
+
93
+ expect(screen.getByTestId("loading")).toHaveTextContent("false");
94
+
95
+ // start -> true
96
+ act(() => {
97
+ screen.getByTestId("start").click();
98
+ });
99
+ expect(screen.getByTestId("loading")).toHaveTextContent("true");
100
+
101
+ // stop twice -> false and stays false
102
+ act(() => {
103
+ screen.getByTestId("stop").click();
104
+ screen.getByTestId("stop").click();
105
+ });
106
+ expect(screen.getByTestId("loading")).toHaveTextContent("false");
107
+ });
108
+
109
+ test("useLoading throws if used outside provider", () => {
110
+ // Suppress React error output in test logs
111
+ const spy = jest.spyOn(console, "error").mockImplementation(() => {});
112
+
113
+ function BadConsumer() {
114
+ useLoading();
115
+ return null;
116
+ }
117
+
118
+ expect(() => render(<BadConsumer />)).toThrow(
119
+ "useLoading must be used within a <LoadingProvider>"
120
+ );
121
+
122
+ spy.mockRestore();
123
+ });
124
+ });