Skip to content

Commit afbd298

Browse files
committed
feat: application improvement
1 parent 0957ce4 commit afbd298

48 files changed

Lines changed: 2410 additions & 1626 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

jest.config.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Copyright 2023-2026 Amazon.com, Inc. or its affiliates.
2+
3+
/** @type {import('jest').Config} */
4+
const config = {
5+
preset: "ts-jest",
6+
testEnvironment: "node",
7+
roots: ["<rootDir>/src"],
8+
testMatch: ["**/__tests__/**/*.test.ts", "**/__tests__/**/*.test.tsx"],
9+
moduleNameMapper: {
10+
"^@/(.*)$": "<rootDir>/src/$1"
11+
},
12+
transform: {
13+
"^.+\\.tsx?$": [
14+
"ts-jest",
15+
{
16+
tsconfig: "tsconfig.json",
17+
diagnostics: false
18+
}
19+
]
20+
},
21+
// Ignore CSS imports in tests
22+
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json"]
23+
};
24+
25+
module.exports = config;

package-lock.json

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
"dev": "vite",
1616
"build": "tsc && vite build && electron-builder",
1717
"preview": "vite preview",
18+
"test": "jest --passWithNoTests",
19+
"test:watch": "jest --watch",
1820
"clean": "rm -rf node_modules dist-electron",
1921
"postinstall": "patch-package"
2022
},
@@ -38,6 +40,7 @@
3840
},
3941
"devDependencies": {
4042
"@playwright/test": "^1.57.0",
43+
"@types/jest": "^30.0.0",
4144
"@types/react": "^19.2.9",
4245
"@types/react-dom": "^19.2.3",
4346
"@types/uuid": "^11.0.0",

scripts/calculate_extents.py

Lines changed: 17 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -112,33 +112,25 @@ def get_extents(dataset_path: str) -> dict:
112112
# Last resort: use coordinates as-is (assuming they're already WGS84)
113113
geo_corners.append((x, y))
114114
else:
115-
# Use geotransform to calculate corner coordinates
116-
# Using the geotransform: [x_origin, pixel_width, rotation, y_origin, rotation, pixel_height]
117-
x_origin = geotransform[0]
118-
pixel_width = geotransform[1]
119-
y_origin = geotransform[3]
120-
pixel_height = geotransform[5]
121-
122-
# Calculate the four corners in source coordinates
123-
x_min = x_origin
124-
x_max = x_origin + x_size * pixel_width
125-
y_min = y_origin + y_size * pixel_height # Note: pixel_height is usually negative
126-
y_max = y_origin
127-
128-
# Ensure we have the correct min/max values
129-
if y_min > y_max:
130-
y_min, y_max = y_max, y_min
131-
if x_min > x_max:
132-
x_min, x_max = x_max, x_min
133-
134-
# Transform the four corners to WGS84
135-
corners = [
136-
(x_min, y_max), # Top-left
137-
(x_max, y_max), # Top-right
138-
(x_max, y_min), # Bottom-right
139-
(x_min, y_min) # Bottom-left
115+
# Use the full geotransform to calculate corner coordinates.
116+
# GeoTransform: [x_origin, pixel_width, x_rotation, y_origin, y_rotation, pixel_height]
117+
# Full formula:
118+
# x_geo = gt[0] + col * gt[1] + row * gt[2]
119+
# y_geo = gt[3] + col * gt[4] + row * gt[5]
120+
# This correctly handles images with rotation (non-zero gt[2] and gt[4]).
121+
corners_pixel = [
122+
(0, 0), # Top-left
123+
(x_size, 0), # Top-right
124+
(x_size, y_size), # Bottom-right
125+
(0, y_size) # Bottom-left
140126
]
141127

128+
corners = []
129+
for col, row in corners_pixel:
130+
x = geotransform[0] + col * geotransform[1] + row * geotransform[2]
131+
y = geotransform[3] + col * geotransform[4] + row * geotransform[5]
132+
corners.append((x, y))
133+
142134
# Transform all corners to WGS84
143135
geo_corners = []
144136
for i, (x, y) in enumerate(corners):

src/App.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import Logo from "@/components/Logo";
1212
import FeaturePopup, { type FeaturePopupData } from "@/components/FeaturePopup";
1313
import ConfigWarnings from "@/components/alert/ConfigWarnings";
1414
import { ResourceProvider } from "@/context/ResourceContext";
15+
import type { ImageRequestState } from "@/types";
1516

1617
/** Natural Earth II fallback (offline, bundled with Cesium) */
1718
function naturalEarthLayer(): Cesium.ImageryLayer {
@@ -35,7 +36,7 @@ async function resolveBaseLayer(): Promise<Cesium.ImageryLayer> {
3536
}
3637

3738
const App = () => {
38-
const [imageRequestStatus, setImageRequestStatus] = useState({
39+
const [imageRequestStatus, setImageRequestStatus] = useState<ImageRequestState>({
3940
state: "idle",
4041
data: {}
4142
});
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Copyright 2023-2026 Amazon.com, Inc. or its affiliates.
2+
3+
import { formatFeatureProperties, type PropertyGroup } from "@/utils/cesiumFormatters";
4+
5+
describe("formatFeatureProperties", () => {
6+
it("returns empty array for empty properties", () => {
7+
const result = formatFeatureProperties({});
8+
expect(result).toEqual([]);
9+
});
10+
11+
it("classifies properties into correct groups", () => {
12+
const result = formatFeatureProperties({
13+
class: "building",
14+
score: 0.95,
15+
latitude: 38.0,
16+
longitude: -77.0,
17+
timestamp: "2025-01-01T00:00:00Z",
18+
custom_field: "hello"
19+
});
20+
21+
const groupNames = result.map((g: PropertyGroup) => g.group);
22+
expect(groupNames).toContain("CLASSIFICATION");
23+
expect(groupNames).toContain("LOCATION");
24+
expect(groupNames).toContain("METADATA");
25+
expect(groupNames).toContain("OTHER");
26+
});
27+
28+
it("formats numeric values correctly", () => {
29+
const result = formatFeatureProperties({
30+
score: 0.8542,
31+
count: 42
32+
});
33+
34+
const classGroup = result.find((g) => g.group === "CLASSIFICATION");
35+
const scoreEntry = classGroup?.entries.find((e) => e.key.toLowerCase().includes("score"));
36+
// Score < 0.99, so should have 4 decimal places
37+
expect(scoreEntry?.value).toBe("0.8542");
38+
39+
const otherGroup = result.find((g) => g.group === "OTHER");
40+
const countEntry = otherGroup?.entries.find((e) => e.key.toLowerCase().includes("count"));
41+
expect(countEntry?.value).toBe("42");
42+
});
43+
44+
it("skips coordinate-like properties", () => {
45+
const result = formatFeatureProperties({
46+
coordinates: [1, 2, 3],
47+
geometry: { type: "Point" },
48+
type: "Feature",
49+
class: "vehicle"
50+
});
51+
52+
// Should only have classification group (from class)
53+
const allEntries = result.flatMap((g) => g.entries);
54+
expect(allEntries.some((e) => e.key.toLowerCase() === "coordinates")).toBe(false);
55+
expect(allEntries.some((e) => e.key.toLowerCase() === "geometry")).toBe(false);
56+
expect(allEntries.some((e) => e.key.toLowerCase() === "type")).toBe(false);
57+
});
58+
59+
it("handles nested objects with children", () => {
60+
const result = formatFeatureProperties({
61+
metadata: {
62+
version: "1.0",
63+
author: "test"
64+
}
65+
});
66+
67+
// Should have an entry with children
68+
const allEntries = result.flatMap((g) => g.entries);
69+
const nested = allEntries.find((e) => e.children && e.children.length > 0);
70+
expect(nested).toBeDefined();
71+
expect(nested?.children?.length).toBeGreaterThan(0);
72+
});
73+
74+
it("truncates long values", () => {
75+
const longValue = "a".repeat(200);
76+
const result = formatFeatureProperties({
77+
long_field: longValue
78+
});
79+
80+
const allEntries = result.flatMap((g) => g.entries);
81+
const entry = allEntries.find((e) => e.key.toLowerCase().includes("long"));
82+
expect(entry?.value.length).toBeLessThanOrEqual(104); // 100 + "..."
83+
expect(entry?.value.endsWith("...")).toBe(true);
84+
});
85+
86+
it("handles null and undefined values", () => {
87+
const result = formatFeatureProperties({
88+
empty_field: null,
89+
undefined_field: undefined
90+
});
91+
92+
const allEntries = result.flatMap((g) => g.entries);
93+
const nullEntry = allEntries.find((e) => e.key.toLowerCase().includes("empty"));
94+
expect(nullEntry?.value).toBe("N/A");
95+
});
96+
97+
it("handles feature_classes special case with IRI and Score", () => {
98+
const result = formatFeatureProperties({
99+
feature_classes: [
100+
{ iri: "http://example.com/building", score: 0.92 }
101+
]
102+
});
103+
104+
const classGroup = result.find((g) => g.group === "CLASSIFICATION");
105+
expect(classGroup).toBeDefined();
106+
const iriEntry = classGroup?.entries.find((e) => e.key === "IRI");
107+
expect(iriEntry?.value).toBe("http://example.com/building");
108+
const scoreEntry = classGroup?.entries.find((e) => e.key === "Score");
109+
expect(scoreEntry?.value).toBe("0.9200");
110+
});
111+
});

src/__tests__/config.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Copyright 2023-2026 Amazon.com, Inc. or its affiliates.
2+
3+
import { isCredentialError } from "@/config";
4+
5+
describe("isCredentialError", () => {
6+
it("returns true for known credential error names", () => {
7+
const knownNames = [
8+
"ExpiredToken",
9+
"ExpiredTokenException",
10+
"RequestExpired",
11+
"InvalidClientTokenId",
12+
"UnrecognizedClientException",
13+
"InvalidIdentityToken",
14+
"AccessDeniedException",
15+
"AuthFailure",
16+
"SignatureDoesNotMatch",
17+
"IncompleteSignature"
18+
];
19+
20+
for (const name of knownNames) {
21+
expect(isCredentialError({ name })).toBe(true);
22+
}
23+
});
24+
25+
it("returns true for HTTP 401 status codes", () => {
26+
const err = { $metadata: { httpStatusCode: 401 } };
27+
expect(isCredentialError(err)).toBe(true);
28+
});
29+
30+
it("returns true for HTTP 403 status codes", () => {
31+
const err = { $metadata: { httpStatusCode: 403 } };
32+
expect(isCredentialError(err)).toBe(true);
33+
});
34+
35+
it("returns true for HTTP 400 status codes", () => {
36+
const err = { $metadata: { httpStatusCode: 400 } };
37+
expect(isCredentialError(err)).toBe(true);
38+
});
39+
40+
it("returns false for regular errors", () => {
41+
expect(isCredentialError(new Error("Network timeout"))).toBe(false);
42+
expect(isCredentialError({ name: "NotFoundError" })).toBe(false);
43+
expect(isCredentialError(null)).toBe(false);
44+
expect(isCredentialError(undefined)).toBe(false);
45+
});
46+
47+
it("returns false for HTTP 500 status codes", () => {
48+
const err = { $metadata: { httpStatusCode: 500 } };
49+
expect(isCredentialError(err)).toBe(false);
50+
});
51+
52+
it("returns false for HTTP 200 status codes", () => {
53+
const err = { $metadata: { httpStatusCode: 200 } };
54+
expect(isCredentialError(err)).toBe(false);
55+
});
56+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright 2023-2026 Amazon.com, Inc. or its affiliates.
2+
3+
import { handleAwsError } from "@/hooks/useS3Browser";
4+
5+
describe("handleAwsError", () => {
6+
it("calls setShowCredsExpiredAlert(true) for credential errors", () => {
7+
const setAlert = jest.fn();
8+
handleAwsError({ name: "ExpiredToken" }, setAlert);
9+
expect(setAlert).toHaveBeenCalledWith(true);
10+
});
11+
12+
it("calls setShowCredsExpiredAlert(true) for HTTP 403", () => {
13+
const setAlert = jest.fn();
14+
handleAwsError({ $metadata: { httpStatusCode: 403 } }, setAlert);
15+
expect(setAlert).toHaveBeenCalledWith(true);
16+
});
17+
18+
it("does not call setShowCredsExpiredAlert for non-credential errors", () => {
19+
const setAlert = jest.fn();
20+
handleAwsError(new Error("Network timeout"), setAlert);
21+
expect(setAlert).not.toHaveBeenCalled();
22+
});
23+
24+
it("handles null error without throwing", () => {
25+
const setAlert = jest.fn();
26+
expect(() => handleAwsError(null, setAlert)).not.toThrow();
27+
expect(setAlert).not.toHaveBeenCalled();
28+
});
29+
30+
it("handles undefined error without throwing", () => {
31+
const setAlert = jest.fn();
32+
expect(() => handleAwsError(undefined, setAlert)).not.toThrow();
33+
expect(setAlert).not.toHaveBeenCalled();
34+
});
35+
});

src/__tests__/logger.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright 2023-2026 Amazon.com, Inc. or its affiliates.
2+
3+
import { logger } from "@/utils/logger";
4+
5+
describe("logger", () => {
6+
let consoleSpy: {
7+
log: jest.SpyInstance;
8+
warn: jest.SpyInstance;
9+
error: jest.SpyInstance;
10+
};
11+
12+
beforeEach(() => {
13+
consoleSpy = {
14+
log: jest.spyOn(console, "log").mockImplementation(),
15+
warn: jest.spyOn(console, "warn").mockImplementation(),
16+
error: jest.spyOn(console, "error").mockImplementation()
17+
};
18+
});
19+
20+
afterEach(() => {
21+
jest.restoreAllMocks();
22+
});
23+
24+
it("logger.info calls console.log with prefix", () => {
25+
logger.info("test message");
26+
expect(consoleSpy.log).toHaveBeenCalledWith("[INFO]", "test message");
27+
});
28+
29+
it("logger.warn calls console.warn with prefix", () => {
30+
logger.warn("warning message");
31+
expect(consoleSpy.warn).toHaveBeenCalledWith("[WARN]", "warning message");
32+
});
33+
34+
it("logger.error calls console.error with prefix", () => {
35+
logger.error("error message");
36+
expect(consoleSpy.error).toHaveBeenCalledWith("[ERROR]", "error message");
37+
});
38+
39+
it("passes extra arguments through", () => {
40+
const extra = { key: "value" };
41+
logger.info("message", extra);
42+
expect(consoleSpy.log).toHaveBeenCalledWith("[INFO]", "message", extra);
43+
});
44+
45+
it("logger.error passes Error objects", () => {
46+
const err = new Error("test error");
47+
logger.error("something failed", err);
48+
expect(consoleSpy.error).toHaveBeenCalledWith("[ERROR]", "something failed", err);
49+
});
50+
});

0 commit comments

Comments
 (0)