mirror of
https://github.com/apache/superset.git
synced 2026-05-22 00:05:15 +00:00
Three coordinated fixes for the 25 CI failures on the initial PR push:
1. **Lockfile sync.** Added @superset-ui/plugin-chart-country-map as
a workspace dep in the previous commit but didn't update
package-lock.json. CI's `npm ci` failed across frontend-build,
cypress (12 jobs), playwright (4 jobs), docker (2 jobs), and
frontend-check-translations. Re-ran `npm install --package-lock-only`
to add the new workspace's 71 lock entries.
2. **License headers added** to 13 new files flagged by License Check:
- 5 markdown READMEs / SIP_DRAFT (HTML-comment headers)
- 5 YAML config files (`# Licensed ...`)
- 2 Python files (`# Licensed ...`)
- 1 shell script (preserves shebang)
3. **Reproducible build outputs.** The regen workflow detected drift
on manifest.json + ukr_admin1_CAN.geo.json. Two root causes:
- `build_timestamp_utc` field made manifest non-deterministic →
dropped from the schema
- Floating mapshaper version (`npx --yes mapshaper`) caused subtle
simplification differences across runners → pinned to
`mapshaper@0.7.15` via `npx --yes mapshaper@<version>`
Verified locally: rebuild from clean cache reproduces every output
byte-identically except the manifest (which now also matches once
the timestamp is gone).
Files changed:
.gitignore — re-include rule for static dir
superset-frontend/package-lock.json — +71 lines for new workspace
13 new files — ASF headers
build.py — pin mapshaper, drop timestamp
manifest.json (× 2) — regenerate w/o timestamp
README.md (in static dir) — header
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
294 lines
12 KiB
Python
294 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
# Licensed to the Apache Software Foundation (ASF) under one
|
|
# or more contributor license agreements. See the NOTICE file
|
|
# distributed with this work for additional information
|
|
# regarding copyright ownership. The ASF licenses this file
|
|
# to you under the Apache License, Version 2.0 (the
|
|
# "License"); you may not use this file except in compliance
|
|
# with the License. You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing,
|
|
# software distributed under the License is distributed on an
|
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
# KIND, either express or implied. See the License for the
|
|
# specific language governing permissions and limitations
|
|
# under the License.
|
|
|
|
"""
|
|
Unit tests for the Country Map build pipeline transforms.
|
|
|
|
Run with: python3 -m pytest test_build.py
|
|
or: python3 test_build.py (uses the bundled `unittest` runner)
|
|
|
|
Tests focus on the pure-Python transforms in build.py — the geometry
|
|
helpers and the YAML-config application functions. The mapshaper
|
|
subprocess calls and shapefile downloads are not exercised here
|
|
(they are integration concerns covered by the regen CI workflow).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
# Import the module under test from this directory.
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
import build # noqa: E402 (intentional after sys.path manipulation)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# _matches
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
class TestMatches(unittest.TestCase):
|
|
def test_scalar_equality(self):
|
|
props = {"adm0_a3": "FRA", "name": "Paris"}
|
|
self.assertTrue(build._matches(props, {"adm0_a3": "FRA"}))
|
|
self.assertFalse(build._matches(props, {"adm0_a3": "GBR"}))
|
|
|
|
def test_multiple_conditions_anded(self):
|
|
props = {"adm0_a3": "FRA", "name": "Paris"}
|
|
self.assertTrue(
|
|
build._matches(props, {"adm0_a3": "FRA", "name": "Paris"})
|
|
)
|
|
self.assertFalse(
|
|
build._matches(props, {"adm0_a3": "FRA", "name": "Lyon"})
|
|
)
|
|
|
|
def test_in_list_membership(self):
|
|
props = {"name": "Hawaii"}
|
|
self.assertTrue(build._matches(props, {"name": {"in": ["Hawaii", "Alaska"]}}))
|
|
self.assertFalse(build._matches(props, {"name": {"in": ["Texas", "Alaska"]}}))
|
|
|
|
def test_missing_property(self):
|
|
props = {"adm0_a3": "FRA"}
|
|
self.assertFalse(build._matches(props, {"name": "Paris"}))
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Geometry helpers
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def make_polygon(points):
|
|
"""Helper: build a Polygon GeoJSON dict from a single ring of [x, y]."""
|
|
return {"type": "Polygon", "coordinates": [points]}
|
|
|
|
|
|
def make_multipolygon(polygons):
|
|
"""Helper: build a MultiPolygon GeoJSON dict from a list of single rings."""
|
|
return {"type": "MultiPolygon", "coordinates": [[ring] for ring in polygons]}
|
|
|
|
|
|
class TestBboxCenter(unittest.TestCase):
|
|
def test_unit_square(self):
|
|
geom = make_polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])
|
|
cx, cy = build._bbox_center(geom)
|
|
self.assertEqual((cx, cy), (0.5, 0.5))
|
|
|
|
def test_offset_square(self):
|
|
geom = make_polygon([[10, 20], [12, 20], [12, 22], [10, 22], [10, 20]])
|
|
cx, cy = build._bbox_center(geom)
|
|
self.assertEqual((cx, cy), (11, 21))
|
|
|
|
|
|
class TestTranslateAndScale(unittest.TestCase):
|
|
def test_pure_translate(self):
|
|
geom = make_polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])
|
|
build._translate_and_scale(geom, offset=[10, 20], scale=1.0)
|
|
# Each point should shift by (10, 20)
|
|
self.assertEqual(geom["coordinates"][0][0], [10, 20])
|
|
self.assertEqual(geom["coordinates"][0][2], [11, 21])
|
|
|
|
def test_pure_scale_around_centroid(self):
|
|
# Square centered on origin scaled 2x → corners move outward
|
|
geom = make_polygon([[-1, -1], [1, -1], [1, 1], [-1, 1], [-1, -1]])
|
|
build._translate_and_scale(geom, offset=[0, 0], scale=2.0)
|
|
# Bbox center stays at origin; corners now at ±2
|
|
self.assertEqual(geom["coordinates"][0][0], [-2, -2])
|
|
self.assertEqual(geom["coordinates"][0][2], [2, 2])
|
|
|
|
def test_translate_then_scale_combined(self):
|
|
geom = make_polygon([[0, 0], [2, 0], [2, 2], [0, 2], [0, 0]])
|
|
build._translate_and_scale(geom, offset=[10, 0], scale=0.5)
|
|
# Centroid was (1, 1); each corner first scaled around centroid by
|
|
# 0.5 → corners become (0.5, 0.5)..(1.5, 1.5); then translated +10x
|
|
self.assertEqual(geom["coordinates"][0][0], [10.5, 0.5])
|
|
self.assertEqual(geom["coordinates"][0][2], [11.5, 1.5])
|
|
|
|
def test_multipolygon_handled(self):
|
|
geom = make_multipolygon([
|
|
[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]],
|
|
[[5, 5], [6, 5], [6, 6], [5, 6], [5, 5]],
|
|
])
|
|
build._translate_and_scale(geom, offset=[100, 200], scale=1.0)
|
|
self.assertEqual(geom["coordinates"][0][0][0], [100, 200])
|
|
self.assertEqual(geom["coordinates"][1][0][0], [105, 205])
|
|
|
|
|
|
class TestTranslateAndScaleWithPivot(unittest.TestCase):
|
|
"""The `group: true` case in composite_maps uses a shared pivot
|
|
so multiple features transform as one body."""
|
|
|
|
def test_features_with_shared_pivot_preserve_relative_positions(self):
|
|
# Two unit squares, pivot at the midpoint between them
|
|
a = make_polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])
|
|
b = make_polygon([[3, 0], [4, 0], [4, 1], [3, 1], [3, 0]])
|
|
# Their combined bbox center is (2, 0.5)
|
|
pivot = (2, 0.5)
|
|
# Scale 2x around shared pivot — they move APART
|
|
build._translate_and_scale_with_pivot(a, [0, 0], 2.0, pivot)
|
|
build._translate_and_scale_with_pivot(b, [0, 0], 2.0, pivot)
|
|
# `a`'s right edge was at x=1, distance 1 from pivot → new x=0
|
|
# `b`'s left edge was at x=3, distance 1 from pivot → new x=4
|
|
# Gap between them grows from 2 to 4 (preserved relative position).
|
|
self.assertEqual(a["coordinates"][0][1], [0, -0.5]) # was [1,0]: scaled -1 from pivot x=2
|
|
self.assertEqual(b["coordinates"][0][0], [4, -0.5]) # was [3,0]: scaled +1 from pivot
|
|
|
|
|
|
class TestDropParts(unittest.TestCase):
|
|
def test_drops_specified_indices(self):
|
|
geom = make_multipolygon([
|
|
[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]],
|
|
[[2, 2], [3, 2], [3, 3], [2, 3], [2, 2]],
|
|
[[4, 4], [5, 4], [5, 5], [4, 5], [4, 4]],
|
|
])
|
|
result = build._drop_parts(geom, [1])
|
|
self.assertEqual(result["type"], "MultiPolygon")
|
|
self.assertEqual(len(result["coordinates"]), 2)
|
|
# Kept parts: index 0 and index 2
|
|
self.assertEqual(result["coordinates"][0][0][0], [0, 0])
|
|
self.assertEqual(result["coordinates"][1][0][0], [4, 4])
|
|
|
|
def test_polygon_unchanged(self):
|
|
geom = make_polygon([[0, 0], [1, 0], [1, 1]])
|
|
result = build._drop_parts(geom, [0])
|
|
self.assertEqual(result["type"], "Polygon") # no change
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Transforms
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
class TestApplyNameOverrides(unittest.TestCase):
|
|
def test_applies_to_matching_features_only(self):
|
|
geo = {
|
|
"type": "FeatureCollection",
|
|
"features": [
|
|
{
|
|
"properties": {
|
|
"adm0_a3": "FRA",
|
|
"name": "Seien-et-Marne",
|
|
},
|
|
"geometry": make_polygon([[0, 0], [1, 1]]),
|
|
},
|
|
{
|
|
"properties": {
|
|
"adm0_a3": "FRA",
|
|
"name": "Paris",
|
|
},
|
|
"geometry": make_polygon([[2, 2], [3, 3]]),
|
|
},
|
|
{
|
|
"properties": {
|
|
"adm0_a3": "GBR",
|
|
"name": "Seien-et-Marne", # same string in another country, shouldn't match
|
|
},
|
|
"geometry": make_polygon([[4, 4], [5, 5]]),
|
|
},
|
|
],
|
|
}
|
|
overrides = [
|
|
{
|
|
"match": {"adm0_a3": "FRA", "name": "Seien-et-Marne"},
|
|
"set": {"name": "Seine-et-Marne"},
|
|
},
|
|
]
|
|
build.apply_name_overrides(geo, overrides)
|
|
self.assertEqual(geo["features"][0]["properties"]["name"], "Seine-et-Marne")
|
|
self.assertEqual(geo["features"][1]["properties"]["name"], "Paris")
|
|
self.assertEqual(geo["features"][2]["properties"]["name"], "Seien-et-Marne") # unchanged
|
|
|
|
|
|
class TestApplyFlyingIslands(unittest.TestCase):
|
|
def _square_at(self, x, y, adm0_a3, name):
|
|
return {
|
|
"properties": {"adm0_a3": adm0_a3, "name": name},
|
|
"geometry": make_polygon([
|
|
[x, y], [x + 1, y], [x + 1, y + 1], [x, y + 1], [x, y]
|
|
]),
|
|
}
|
|
|
|
def test_repositions_matched_features(self):
|
|
geo = {
|
|
"type": "FeatureCollection",
|
|
"features": [
|
|
self._square_at(0, 0, "USA", "Hawaii"),
|
|
self._square_at(10, 10, "USA", "Texas"), # not matched
|
|
],
|
|
}
|
|
config = {
|
|
"countries": {
|
|
"USA": {
|
|
"repositions": [
|
|
{
|
|
"match": {"name": "Hawaii"},
|
|
"offset": [100, 200],
|
|
"scale": 1.0,
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
build.apply_flying_islands(geo, config, country_a3=None, admin_level=1)
|
|
# Hawaii moved
|
|
self.assertEqual(geo["features"][0]["geometry"]["coordinates"][0][0], [100, 200])
|
|
# Texas unchanged
|
|
self.assertEqual(geo["features"][1]["geometry"]["coordinates"][0][0], [10, 10])
|
|
|
|
def test_drop_outside_bbox_only_at_admin1(self):
|
|
geo = {
|
|
"type": "FeatureCollection",
|
|
"features": [
|
|
self._square_at(-50, 50, "NLD", "Caribbean territory"),
|
|
self._square_at(5, 52, "NLD", "Mainland"),
|
|
],
|
|
}
|
|
config = {
|
|
"countries": {
|
|
"NLD": {
|
|
"drop_outside_bbox": {"nw": [-20, 60], "se": [20, 20]}
|
|
}
|
|
}
|
|
}
|
|
# Admin 1: drop applies, Caribbean dropped
|
|
geo_a1 = json.loads(json.dumps(geo))
|
|
build.apply_flying_islands(geo_a1, config, country_a3=None, admin_level=1)
|
|
self.assertEqual(len(geo_a1["features"]), 1)
|
|
self.assertEqual(geo_a1["features"][0]["properties"]["name"], "Mainland")
|
|
# Admin 0: drop NOT applied (would otherwise drop entire countries
|
|
# whose multi-polygons extend overseas)
|
|
geo_a0 = json.loads(json.dumps(geo))
|
|
build.apply_flying_islands(geo_a0, config, country_a3=None, admin_level=0)
|
|
self.assertEqual(len(geo_a0["features"]), 2)
|
|
|
|
|
|
class TestBboxContains(unittest.TestCase):
|
|
def test_inside_bbox(self):
|
|
geom = make_polygon([[5, 30], [10, 30], [10, 35], [5, 35], [5, 30]])
|
|
self.assertTrue(build._bbox_contains(geom, nw=[0, 40], se=[20, 20]))
|
|
|
|
def test_outside_bbox_west(self):
|
|
geom = make_polygon([[-30, 30], [-25, 30], [-25, 35], [-30, 35], [-30, 30]])
|
|
self.assertFalse(build._bbox_contains(geom, nw=[0, 40], se=[20, 20]))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|