mapwright 0.23.1__tar.gz → 0.24.0__tar.gz

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 (132) hide show
  1. {mapwright-0.23.1 → mapwright-0.24.0}/CHANGELOG.md +17 -0
  2. {mapwright-0.23.1 → mapwright-0.24.0}/PKG-INFO +1 -1
  3. {mapwright-0.23.1 → mapwright-0.24.0}/pyproject.toml +1 -1
  4. {mapwright-0.23.1 → mapwright-0.24.0}/src/mapwright/__init__.py +1 -1
  5. mapwright-0.24.0/src/mapwright/py.typed +0 -0
  6. mapwright-0.24.0/tests/test_api_contract.py +297 -0
  7. mapwright-0.23.1/tests/test_api_contract.py +0 -175
  8. {mapwright-0.23.1 → mapwright-0.24.0}/.github/workflows/ci.yml +0 -0
  9. {mapwright-0.23.1 → mapwright-0.24.0}/.github/workflows/publish.yml +0 -0
  10. {mapwright-0.23.1 → mapwright-0.24.0}/.gitignore +0 -0
  11. {mapwright-0.23.1 → mapwright-0.24.0}/LICENSE +0 -0
  12. {mapwright-0.23.1 → mapwright-0.24.0}/NOTICE +0 -0
  13. {mapwright-0.23.1 → mapwright-0.24.0}/README.md +0 -0
  14. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/age-old.png +0 -0
  15. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/age-old.svg +0 -0
  16. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/age-young.png +0 -0
  17. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/age-young.svg +0 -0
  18. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/archipelago.png +0 -0
  19. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/archipelago.svg +0 -0
  20. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/arctic.png +0 -0
  21. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/arctic.svg +0 -0
  22. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/atlas.png +0 -0
  23. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/atlas_pack/README.md +0 -0
  24. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/atlas_pack/city_castle_1.png +0 -0
  25. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/atlas_pack/city_large_1.png +0 -0
  26. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/atlas_pack/city_town_1.png +0 -0
  27. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/atlas_pack/city_village_1.png +0 -0
  28. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/atlas_pack/decoration_compass_1.png +0 -0
  29. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/atlas_pack/decoration_creature_1.png +0 -0
  30. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/atlas_pack/decoration_ship_1.png +0 -0
  31. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/atlas_pack/dune_1.png +0 -0
  32. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/atlas_pack/hill_1.png +0 -0
  33. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/atlas_pack/hill_2.png +0 -0
  34. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/atlas_pack/manifest.json +0 -0
  35. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/atlas_pack/mountain_mid_1.png +0 -0
  36. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/atlas_pack/mountain_old_1.png +0 -0
  37. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/atlas_pack/mountain_old_2.png +0 -0
  38. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/atlas_pack/mountain_young_1.png +0 -0
  39. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/atlas_pack/mountain_young_2.png +0 -0
  40. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/atlas_pack/tree_cactus_1.png +0 -0
  41. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/atlas_pack/tree_deciduous_1.png +0 -0
  42. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/atlas_pack/tree_deciduous_2.png +0 -0
  43. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/atlas_pack/tree_pine_1.png +0 -0
  44. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/atlas_pack/tree_pine_2.png +0 -0
  45. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/citadel.png +0 -0
  46. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/citadel.svg +0 -0
  47. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/continent.png +0 -0
  48. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/continent.svg +0 -0
  49. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/desert.png +0 -0
  50. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/desert.svg +0 -0
  51. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/dungeon.png +0 -0
  52. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/dungeon.svg +0 -0
  53. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/fortress-town.png +0 -0
  54. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/fortress-town.svg +0 -0
  55. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/grid-city.png +0 -0
  56. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/grid-city.svg +0 -0
  57. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/highlands.png +0 -0
  58. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/highlands.svg +0 -0
  59. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/hint.png +0 -0
  60. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/hint.svg +0 -0
  61. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/islands.png +0 -0
  62. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/islands.svg +0 -0
  63. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/metropolis.png +0 -0
  64. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/metropolis.svg +0 -0
  65. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/pangaea.png +0 -0
  66. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/pangaea.svg +0 -0
  67. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/port.png +0 -0
  68. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/port.svg +0 -0
  69. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/regions.png +0 -0
  70. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/regions.svg +0 -0
  71. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/roads.png +0 -0
  72. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/roads.svg +0 -0
  73. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/shantytown.png +0 -0
  74. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/shantytown.svg +0 -0
  75. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/template-atoll.png +0 -0
  76. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/template-atoll.svg +0 -0
  77. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/template-isthmus.png +0 -0
  78. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/template-isthmus.svg +0 -0
  79. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/terrain-town.png +0 -0
  80. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/terrain-town.svg +0 -0
  81. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/theme-blueprint.png +0 -0
  82. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/theme-blueprint.svg +0 -0
  83. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/theme-citadel-neon.png +0 -0
  84. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/theme-citadel-neon.svg +0 -0
  85. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/theme-dune.png +0 -0
  86. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/theme-dune.svg +0 -0
  87. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/theme-dungeon-blueprint.png +0 -0
  88. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/theme-dungeon-blueprint.svg +0 -0
  89. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/theme-neon.png +0 -0
  90. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/theme-neon.svg +0 -0
  91. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/theme-parchment.png +0 -0
  92. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/theme-parchment.svg +0 -0
  93. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/town.png +0 -0
  94. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/town.svg +0 -0
  95. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/tropical.png +0 -0
  96. {mapwright-0.23.1 → mapwright-0.24.0}/docs/gallery/tropical.svg +0 -0
  97. {mapwright-0.23.1 → mapwright-0.24.0}/examples/benchmark.py +0 -0
  98. {mapwright-0.23.1 → mapwright-0.24.0}/examples/gallery.py +0 -0
  99. {mapwright-0.23.1 → mapwright-0.24.0}/src/mapwright/_geometry.py +0 -0
  100. {mapwright-0.23.1 → mapwright-0.24.0}/src/mapwright/_graph.py +0 -0
  101. {mapwright-0.23.1 → mapwright-0.24.0}/src/mapwright/_serde.py +0 -0
  102. {mapwright-0.23.1 → mapwright-0.24.0}/src/mapwright/affordances.py +0 -0
  103. {mapwright-0.23.1 → mapwright-0.24.0}/src/mapwright/atlas_renderer.py +0 -0
  104. {mapwright-0.23.1 → mapwright-0.24.0}/src/mapwright/config.py +0 -0
  105. {mapwright-0.23.1 → mapwright-0.24.0}/src/mapwright/dungeon.py +0 -0
  106. {mapwright-0.23.1 → mapwright-0.24.0}/src/mapwright/dungeon_renderer.py +0 -0
  107. {mapwright-0.23.1 → mapwright-0.24.0}/src/mapwright/names.py +0 -0
  108. {mapwright-0.23.1 → mapwright-0.24.0}/src/mapwright/regions.py +0 -0
  109. {mapwright-0.23.1 → mapwright-0.24.0}/src/mapwright/rng.py +0 -0
  110. {mapwright-0.23.1 → mapwright-0.24.0}/src/mapwright/roads.py +0 -0
  111. {mapwright-0.23.1 → mapwright-0.24.0}/src/mapwright/settlement.py +0 -0
  112. {mapwright-0.23.1 → mapwright-0.24.0}/src/mapwright/settlement_renderer.py +0 -0
  113. {mapwright-0.23.1 → mapwright-0.24.0}/src/mapwright/svg_renderer.py +0 -0
  114. {mapwright-0.23.1 → mapwright-0.24.0}/src/mapwright/terrain.py +0 -0
  115. {mapwright-0.23.1 → mapwright-0.24.0}/src/mapwright/themes.py +0 -0
  116. {mapwright-0.23.1 → mapwright-0.24.0}/tests/test_affordances.py +0 -0
  117. {mapwright-0.23.1 → mapwright-0.24.0}/tests/test_atlas_renderer.py +0 -0
  118. {mapwright-0.23.1 → mapwright-0.24.0}/tests/test_config.py +0 -0
  119. {mapwright-0.23.1 → mapwright-0.24.0}/tests/test_dungeon.py +0 -0
  120. {mapwright-0.23.1 → mapwright-0.24.0}/tests/test_dungeon_renderer.py +0 -0
  121. {mapwright-0.23.1 → mapwright-0.24.0}/tests/test_geometry.py +0 -0
  122. {mapwright-0.23.1 → mapwright-0.24.0}/tests/test_graph.py +0 -0
  123. {mapwright-0.23.1 → mapwright-0.24.0}/tests/test_names.py +0 -0
  124. {mapwright-0.23.1 → mapwright-0.24.0}/tests/test_properties.py +0 -0
  125. {mapwright-0.23.1 → mapwright-0.24.0}/tests/test_regions.py +0 -0
  126. {mapwright-0.23.1 → mapwright-0.24.0}/tests/test_rng.py +0 -0
  127. {mapwright-0.23.1 → mapwright-0.24.0}/tests/test_roads.py +0 -0
  128. {mapwright-0.23.1 → mapwright-0.24.0}/tests/test_serialize.py +0 -0
  129. {mapwright-0.23.1 → mapwright-0.24.0}/tests/test_settlement.py +0 -0
  130. {mapwright-0.23.1 → mapwright-0.24.0}/tests/test_svg_renderer.py +0 -0
  131. {mapwright-0.23.1 → mapwright-0.24.0}/tests/test_terrain.py +0 -0
  132. {mapwright-0.23.1 → mapwright-0.24.0}/tests/test_themes.py +0 -0
@@ -8,6 +8,23 @@ All notable changes to mapwright are documented here. The format follows
8
8
  `tests/test_api_contract.py`). While the version is `0.x`, minor versions may
9
9
  make breaking changes; these will always be noted here.
10
10
 
11
+ ## [0.24.0] — 2026-06-06
12
+
13
+ ### Added
14
+ - **Type information (PEP 561).** Ships a `py.typed` marker, so downstream
15
+ projects' type checkers (mypy, pyright) now verify their use of mapwright's
16
+ public API against its annotations — signature drift is caught at *their*
17
+ build time, not just at runtime.
18
+
19
+ ### Changed
20
+ - **The public contract is now pinned more tightly** (no API change). The
21
+ contract tests freeze, for every exported dataclass, the exact field *names
22
+ and order* (positional construction, attribute access, and `to_dict` keys all
23
+ depend on these), and freeze the `to_dict()` key schema for every serialisable
24
+ type. An internal refactor that renames/reorders/drops a public field or
25
+ changes a serialised key now fails CI loudly instead of silently breaking
26
+ consumers that persist JSON or read fields.
27
+
11
28
  ## [0.23.1] — 2026-06-06
12
29
 
13
30
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mapwright
3
- Version: 0.23.1
3
+ Version: 0.24.0
4
4
  Summary: Domain-neutral procedural fantasy map & world generation: Voronoi terrain, hydraulic erosion, biomes, rivers, Markov place-names, and shaded-relief SVG.
5
5
  Project-URL: Homepage, https://github.com/sligara7/mapwright
6
6
  Project-URL: Repository, https://github.com/sligara7/mapwright
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mapwright"
7
- version = "0.23.1"
7
+ version = "0.24.0"
8
8
  description = "Domain-neutral procedural fantasy map & world generation: Voronoi terrain, hydraulic erosion, biomes, rivers, Markov place-names, and shaded-relief SVG."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -63,7 +63,7 @@ from .terrain import (
63
63
  compute_cell_polygons,
64
64
  )
65
65
 
66
- __version__ = "0.23.1"
66
+ __version__ = "0.24.0"
67
67
 
68
68
  __all__ = [
69
69
  "SeededRNG",
File without changes
@@ -0,0 +1,297 @@
1
+ """API contract tests — the public surface other code may rely on.
2
+
3
+ These pin the public API so a breaking change fails loudly (and CI catches it).
4
+ If a change here is intentional, update the expected sets *and* the version /
5
+ CHANGELOG per semver.
6
+ """
7
+
8
+ import dataclasses
9
+ import inspect
10
+
11
+ import mapwright
12
+ from mapwright import WorldMapConfig
13
+ from mapwright.config import _SPEC
14
+
15
+
16
+ # The frozen public surface (mapwright.__all__). Adding is a minor bump;
17
+ # removing/renaming is a breaking (major, pre-1.0: minor) change.
18
+ EXPECTED_PUBLIC = {
19
+ "SeededRNG",
20
+ "WorldMapConfig",
21
+ "PRESETS",
22
+ "CellSummary",
23
+ "environment_affordances",
24
+ "summarize_cells",
25
+ "NameGenerator",
26
+ "MarkovNameGenerator",
27
+ "NAMEBASES",
28
+ "Biome",
29
+ "River",
30
+ "TerrainCell",
31
+ "TerrainResult",
32
+ "RegionalTerrainGenerator",
33
+ "TERRAIN_TEMPLATES",
34
+ "compute_cell_polygons",
35
+ "Marker",
36
+ "RegionalSVGRenderer",
37
+ "Theme",
38
+ "THEMES",
39
+ "ArtPack",
40
+ "AtlasRenderer",
41
+ "Road",
42
+ "RegionalRoadGenerator",
43
+ "Region",
44
+ "RegionGenerator",
45
+ "Dungeon",
46
+ "DungeonConfig",
47
+ "DungeonGenerator",
48
+ "DungeonSVGRenderer",
49
+ "Rect",
50
+ "Settlement",
51
+ "SettlementConfig",
52
+ "SettlementGenerator",
53
+ "SettlementSVGRenderer",
54
+ "Ward",
55
+ "Lot",
56
+ "Street",
57
+ "Wall",
58
+ "Landmark",
59
+ "TerrainField",
60
+ "world_terrain_field",
61
+ "SETTLEMENT_PRESETS",
62
+ }
63
+
64
+
65
+ # The frozen field layout of every exported dataclass. Field NAMES *and ORDER*
66
+ # are part of the public contract: positional construction, attribute access,
67
+ # and the keys emitted by ``to_dict`` all depend on them. Appending a trailing
68
+ # optional field is a minor bump; renaming/removing/reordering is breaking.
69
+ EXPECTED_FIELDS = {
70
+ "WorldMapConfig": ("sea_level", "continents", "continent_spread",
71
+ "edge_falloff", "mountain_density", "roughness",
72
+ "land_age", "temperature", "moisture", "river_density",
73
+ "lake_density"),
74
+ "CellSummary": ("dominant_biome", "temperature", "moisture", "mean_height",
75
+ "has_river", "has_lake", "water_fraction", "cell_count",
76
+ "affordances"),
77
+ "TerrainCell": ("id", "cx", "cy", "neighbors", "height", "filled", "flux",
78
+ "downhill", "is_water", "is_lake", "is_river", "temperature",
79
+ "moisture", "biome"),
80
+ "River": ("cells", "width"),
81
+ "TerrainResult": ("width", "height", "cells", "cell_of", "rivers",
82
+ "sea_level"),
83
+ "Marker": ("name", "x", "y", "kind"),
84
+ "Theme": ("name", "biomes", "ocean_bg", "coastline", "river", "road",
85
+ "road_casing", "region_border", "region_label", "settlement_fill",
86
+ "settlement_stroke", "label_fill", "label_halo", "biome_names",
87
+ "settlement", "dungeon"),
88
+ "ArtPack": ("slots", "colors", "name"),
89
+ "Road": ("cells",),
90
+ "Region": ("id", "name", "capital", "cells"),
91
+ "Dungeon": ("width", "height", "rooms", "corridors", "grid", "edges"),
92
+ "DungeonConfig": ("min_leaf", "room_min", "room_padding", "split_jitter",
93
+ "extra_corridor_chance"),
94
+ "Rect": ("x", "y", "w", "h"),
95
+ "Settlement": ("width", "height", "name", "footprint", "wards", "lots",
96
+ "streets", "gates", "wall", "landmark", "walled", "coastal",
97
+ "purpose", "water_edge"),
98
+ "SettlementConfig": ("population", "irregularity", "lot_size", "wealth",
99
+ "era", "layout", "purpose", "walled", "coastal"),
100
+ "Ward": ("id", "polygon", "center", "name", "kind"),
101
+ "Lot": ("id", "polygon", "ward"),
102
+ "Street": ("path", "kind"),
103
+ "Wall": ("ring", "closed", "gates"),
104
+ "Landmark": ("ward", "kind", "center", "name"),
105
+ }
106
+
107
+ # Serialisable types whose ``to_dict`` emits an extra ``"schema"`` version tag
108
+ # on top of their field keys (the three top-level documents).
109
+ SCHEMA_TAGGED = {"TerrainResult", "Dungeon", "Settlement"}
110
+
111
+
112
+ class TestPublicSurface:
113
+ def test_all_matches_contract(self):
114
+ assert set(mapwright.__all__) == EXPECTED_PUBLIC
115
+
116
+ def test_everything_in_all_is_importable(self):
117
+ for name in mapwright.__all__:
118
+ assert hasattr(mapwright, name), f"{name} missing from package"
119
+
120
+ def test_version_is_present(self):
121
+ assert isinstance(mapwright.__version__, str)
122
+ assert mapwright.__version__.count(".") >= 2 # semver-ish
123
+
124
+ def test_version_matches_package_metadata(self):
125
+ # __version__ must match the installed (pyproject) version, so a missed
126
+ # bump can't ship a mislabelled wheel. Skips when run from source.
127
+ import importlib.metadata as md
128
+
129
+ try:
130
+ installed = md.version("mapwright")
131
+ except md.PackageNotFoundError:
132
+ import pytest
133
+
134
+ pytest.skip("mapwright not installed; running from source tree")
135
+ assert installed == mapwright.__version__
136
+
137
+
138
+ class TestKeySignatures:
139
+ def test_generate_signature(self):
140
+ params = inspect.signature(
141
+ mapwright.RegionalTerrainGenerator.generate
142
+ ).parameters
143
+ assert ["self", "width", "height", "config"] == list(params)[:4]
144
+ assert params["config"].default is None
145
+
146
+ def test_svg_render_signature(self):
147
+ params = inspect.signature(mapwright.RegionalSVGRenderer.render).parameters
148
+ assert ["self", "terrain", "markers"] == list(params)[:3]
149
+
150
+ def test_marker_fields(self):
151
+ fields = {f.name for f in dataclasses.fields(mapwright.Marker)}
152
+ assert {"name", "x", "y", "kind"} <= fields
153
+
154
+
155
+ class TestSerialisationContract:
156
+ """The (de)serialisation surface is part of the public contract."""
157
+
158
+ def test_roundtrip_types_have_dict_methods(self):
159
+ for cls in (
160
+ mapwright.TerrainResult,
161
+ mapwright.Dungeon,
162
+ mapwright.Marker,
163
+ mapwright.TerrainCell,
164
+ mapwright.River,
165
+ mapwright.Rect,
166
+ mapwright.Road,
167
+ mapwright.Region,
168
+ mapwright.Settlement,
169
+ mapwright.Ward,
170
+ mapwright.Lot,
171
+ mapwright.Street,
172
+ mapwright.Wall,
173
+ mapwright.Landmark,
174
+ mapwright.SettlementConfig,
175
+ ):
176
+ assert hasattr(cls, "to_dict") and callable(cls.to_dict)
177
+ assert hasattr(cls, "from_dict") and callable(cls.from_dict)
178
+
179
+ def test_top_level_types_have_json_methods(self):
180
+ for cls in (mapwright.TerrainResult, mapwright.Dungeon, mapwright.Marker,
181
+ mapwright.Settlement):
182
+ assert hasattr(cls, "to_json") and callable(cls.to_json)
183
+ assert hasattr(cls, "from_json") and callable(cls.from_json)
184
+
185
+
186
+ class TestConfigContract:
187
+ def test_spec_covers_every_field_exactly(self):
188
+ spec_names = {name for name, *_ in _SPEC}
189
+ field_names = {f.name for f in dataclasses.fields(WorldMapConfig)}
190
+ assert spec_names == field_names
191
+
192
+ def test_json_schema_shape(self):
193
+ schema = WorldMapConfig.json_schema()
194
+ assert schema["type"] == "object"
195
+ assert schema["additionalProperties"] is False
196
+ props = schema["properties"]
197
+ assert set(props) == {f.name for f in dataclasses.fields(WorldMapConfig)}
198
+
199
+ def test_json_schema_bounds_and_defaults_match(self):
200
+ schema = WorldMapConfig.json_schema()
201
+ defaults = WorldMapConfig()
202
+ for name, typ, lo, hi, _desc in _SPEC:
203
+ p = schema["properties"][name]
204
+ assert p["type"] == ("integer" if typ is int else "number")
205
+ assert p["minimum"] == lo and p["maximum"] == hi
206
+ assert p["default"] == getattr(defaults, name)
207
+
208
+ def test_schema_bounds_are_actually_enforced(self):
209
+ # A payload that violates the schema bounds is clamped into them.
210
+ for name, _typ, lo, hi, _desc in _SPEC:
211
+ below = WorldMapConfig.from_dict({name: lo - 100})
212
+ above = WorldMapConfig.from_dict({name: hi + 100})
213
+ assert getattr(below, name) >= lo
214
+ assert getattr(above, name) <= hi
215
+
216
+ def test_presets_are_valid_against_schema(self):
217
+ # Every preset must only use known keys within range (from_dict clamps,
218
+ # but presets should already be in-bounds by construction).
219
+ for name in WorldMapConfig.preset_names():
220
+ cfg = WorldMapConfig.preset(name)
221
+ for fname, _typ, lo, hi, _desc in _SPEC:
222
+ assert lo <= getattr(cfg, fname) <= hi
223
+
224
+
225
+ class TestDataclassLayout:
226
+ """Field names + order of every exported dataclass are frozen (see
227
+ ``EXPECTED_FIELDS``). This is what consumers rely on for positional
228
+ construction, attribute access, and serialisation key stability."""
229
+
230
+ def test_every_exported_dataclass_is_pinned(self):
231
+ for name in mapwright.__all__:
232
+ obj = getattr(mapwright, name)
233
+ if not (isinstance(obj, type) and dataclasses.is_dataclass(obj)):
234
+ continue
235
+ assert name in EXPECTED_FIELDS, (
236
+ f"new exported dataclass {name!r} — add it to EXPECTED_FIELDS "
237
+ f"(and bump the version / note it in the CHANGELOG)"
238
+ )
239
+ actual = tuple(f.name for f in dataclasses.fields(obj))
240
+ assert actual == EXPECTED_FIELDS[name], (
241
+ f"{name} field layout changed: {actual} != {EXPECTED_FIELDS[name]}"
242
+ )
243
+
244
+ def test_no_stale_entries_in_expected_fields(self):
245
+ # Every pinned name must still be an exported dataclass.
246
+ for name in EXPECTED_FIELDS:
247
+ obj = getattr(mapwright, name, None)
248
+ assert obj is not None and dataclasses.is_dataclass(obj), (
249
+ f"{name} is pinned in EXPECTED_FIELDS but no longer an exported dataclass"
250
+ )
251
+
252
+
253
+ class TestToDictSchema:
254
+ """``to_dict()`` must emit exactly the pinned field keys (plus a ``schema``
255
+ tag on the top-level documents) — so a consumer persisting JSON is protected
256
+ against a silent key rename/drop that leaves the dataclass field untouched."""
257
+
258
+ def _instances(self):
259
+ from mapwright import (
260
+ SeededRNG, RegionalTerrainGenerator, DungeonGenerator, DungeonConfig,
261
+ SettlementGenerator, SettlementConfig, Marker, River, Road, Region,
262
+ Landmark,
263
+ )
264
+ terrain = RegionalTerrainGenerator(SeededRNG(7)).generate(60, 45)
265
+ dungeon = DungeonGenerator(SeededRNG(7)).generate(48, 40, DungeonConfig())
266
+ town = SettlementGenerator(SeededRNG(3)).generate(
267
+ 900, 700, SettlementConfig(population=12000, walled=True)
268
+ )
269
+ assert town.wards and town.lots and town.streets and town.wall, (
270
+ "test fixture must produce a town with wards/lots/streets/wall"
271
+ )
272
+ return {
273
+ "TerrainResult": terrain,
274
+ "TerrainCell": terrain.cells[0],
275
+ "River": River([0, 1], 1.0),
276
+ "Marker": Marker(name="X", x=1.0, y=2.0, kind="city"),
277
+ "Dungeon": dungeon,
278
+ "Rect": dungeon.rooms[0],
279
+ "Road": Road([0, 1]),
280
+ "Region": Region(id=0, name="R", capital=5, cells=[1, 2, 3]),
281
+ "Settlement": town,
282
+ "Ward": town.wards[0],
283
+ "Lot": town.lots[0],
284
+ "Street": town.streets[0],
285
+ "Wall": town.wall,
286
+ "Landmark": Landmark(ward=0, kind="temple", center=(1.0, 2.0), name="Shrine"),
287
+ "SettlementConfig": SettlementConfig(),
288
+ }
289
+
290
+ def test_to_dict_keys_match_contract(self):
291
+ for name, inst in self._instances().items():
292
+ expected = set(EXPECTED_FIELDS[name])
293
+ if name in SCHEMA_TAGGED:
294
+ expected |= {"schema"}
295
+ assert set(inst.to_dict()) == expected, (
296
+ f"{name}.to_dict() keys drifted: {set(inst.to_dict())} != {expected}"
297
+ )
@@ -1,175 +0,0 @@
1
- """API contract tests — the public surface other code may rely on.
2
-
3
- These pin the public API so a breaking change fails loudly (and CI catches it).
4
- If a change here is intentional, update the expected sets *and* the version /
5
- CHANGELOG per semver.
6
- """
7
-
8
- import dataclasses
9
- import inspect
10
-
11
- import mapwright
12
- from mapwright import WorldMapConfig
13
- from mapwright.config import _SPEC
14
-
15
-
16
- # The frozen public surface (mapwright.__all__). Adding is a minor bump;
17
- # removing/renaming is a breaking (major, pre-1.0: minor) change.
18
- EXPECTED_PUBLIC = {
19
- "SeededRNG",
20
- "WorldMapConfig",
21
- "PRESETS",
22
- "CellSummary",
23
- "environment_affordances",
24
- "summarize_cells",
25
- "NameGenerator",
26
- "MarkovNameGenerator",
27
- "NAMEBASES",
28
- "Biome",
29
- "River",
30
- "TerrainCell",
31
- "TerrainResult",
32
- "RegionalTerrainGenerator",
33
- "TERRAIN_TEMPLATES",
34
- "compute_cell_polygons",
35
- "Marker",
36
- "RegionalSVGRenderer",
37
- "Theme",
38
- "THEMES",
39
- "ArtPack",
40
- "AtlasRenderer",
41
- "Road",
42
- "RegionalRoadGenerator",
43
- "Region",
44
- "RegionGenerator",
45
- "Dungeon",
46
- "DungeonConfig",
47
- "DungeonGenerator",
48
- "DungeonSVGRenderer",
49
- "Rect",
50
- "Settlement",
51
- "SettlementConfig",
52
- "SettlementGenerator",
53
- "SettlementSVGRenderer",
54
- "Ward",
55
- "Lot",
56
- "Street",
57
- "Wall",
58
- "Landmark",
59
- "TerrainField",
60
- "world_terrain_field",
61
- "SETTLEMENT_PRESETS",
62
- }
63
-
64
-
65
- class TestPublicSurface:
66
- def test_all_matches_contract(self):
67
- assert set(mapwright.__all__) == EXPECTED_PUBLIC
68
-
69
- def test_everything_in_all_is_importable(self):
70
- for name in mapwright.__all__:
71
- assert hasattr(mapwright, name), f"{name} missing from package"
72
-
73
- def test_version_is_present(self):
74
- assert isinstance(mapwright.__version__, str)
75
- assert mapwright.__version__.count(".") >= 2 # semver-ish
76
-
77
- def test_version_matches_package_metadata(self):
78
- # __version__ must match the installed (pyproject) version, so a missed
79
- # bump can't ship a mislabelled wheel. Skips when run from source.
80
- import importlib.metadata as md
81
-
82
- try:
83
- installed = md.version("mapwright")
84
- except md.PackageNotFoundError:
85
- import pytest
86
-
87
- pytest.skip("mapwright not installed; running from source tree")
88
- assert installed == mapwright.__version__
89
-
90
-
91
- class TestKeySignatures:
92
- def test_generate_signature(self):
93
- params = inspect.signature(
94
- mapwright.RegionalTerrainGenerator.generate
95
- ).parameters
96
- assert ["self", "width", "height", "config"] == list(params)[:4]
97
- assert params["config"].default is None
98
-
99
- def test_svg_render_signature(self):
100
- params = inspect.signature(mapwright.RegionalSVGRenderer.render).parameters
101
- assert ["self", "terrain", "markers"] == list(params)[:3]
102
-
103
- def test_marker_fields(self):
104
- fields = {f.name for f in dataclasses.fields(mapwright.Marker)}
105
- assert {"name", "x", "y", "kind"} <= fields
106
-
107
-
108
- class TestSerialisationContract:
109
- """The (de)serialisation surface is part of the public contract."""
110
-
111
- def test_roundtrip_types_have_dict_methods(self):
112
- for cls in (
113
- mapwright.TerrainResult,
114
- mapwright.Dungeon,
115
- mapwright.Marker,
116
- mapwright.TerrainCell,
117
- mapwright.River,
118
- mapwright.Rect,
119
- mapwright.Road,
120
- mapwright.Region,
121
- mapwright.Settlement,
122
- mapwright.Ward,
123
- mapwright.Lot,
124
- mapwright.Street,
125
- mapwright.Wall,
126
- mapwright.Landmark,
127
- mapwright.SettlementConfig,
128
- ):
129
- assert hasattr(cls, "to_dict") and callable(cls.to_dict)
130
- assert hasattr(cls, "from_dict") and callable(cls.from_dict)
131
-
132
- def test_top_level_types_have_json_methods(self):
133
- for cls in (mapwright.TerrainResult, mapwright.Dungeon, mapwright.Marker,
134
- mapwright.Settlement):
135
- assert hasattr(cls, "to_json") and callable(cls.to_json)
136
- assert hasattr(cls, "from_json") and callable(cls.from_json)
137
-
138
-
139
- class TestConfigContract:
140
- def test_spec_covers_every_field_exactly(self):
141
- spec_names = {name for name, *_ in _SPEC}
142
- field_names = {f.name for f in dataclasses.fields(WorldMapConfig)}
143
- assert spec_names == field_names
144
-
145
- def test_json_schema_shape(self):
146
- schema = WorldMapConfig.json_schema()
147
- assert schema["type"] == "object"
148
- assert schema["additionalProperties"] is False
149
- props = schema["properties"]
150
- assert set(props) == {f.name for f in dataclasses.fields(WorldMapConfig)}
151
-
152
- def test_json_schema_bounds_and_defaults_match(self):
153
- schema = WorldMapConfig.json_schema()
154
- defaults = WorldMapConfig()
155
- for name, typ, lo, hi, _desc in _SPEC:
156
- p = schema["properties"][name]
157
- assert p["type"] == ("integer" if typ is int else "number")
158
- assert p["minimum"] == lo and p["maximum"] == hi
159
- assert p["default"] == getattr(defaults, name)
160
-
161
- def test_schema_bounds_are_actually_enforced(self):
162
- # A payload that violates the schema bounds is clamped into them.
163
- for name, _typ, lo, hi, _desc in _SPEC:
164
- below = WorldMapConfig.from_dict({name: lo - 100})
165
- above = WorldMapConfig.from_dict({name: hi + 100})
166
- assert getattr(below, name) >= lo
167
- assert getattr(above, name) <= hi
168
-
169
- def test_presets_are_valid_against_schema(self):
170
- # Every preset must only use known keys within range (from_dict clamps,
171
- # but presets should already be in-bounds by construction).
172
- for name in WorldMapConfig.preset_names():
173
- cfg = WorldMapConfig.preset(name)
174
- for fname, _typ, lo, hi, _desc in _SPEC:
175
- assert lo <= getattr(cfg, fname) <= hi
File without changes
File without changes
File without changes
File without changes
File without changes