Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 39 additions & 4 deletions locations/management/commands/import_village.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from locations.services.population_centre_admin import delete_population_centre
from locations.services.road_connections import connect_nearest_village_roads
from locations.services.watabou_import import import_watabou_village
from locations.village_layout import VILLAGE_LAYOUT
from locations.village_names import VILLAGE_NAMES


Expand All @@ -27,13 +28,17 @@ def add_arguments(self, parser):
"--x",
type=int,
help="Origin X coordinate (metres, SRID 3857) to centre the village on. "
"Required unless --overwrite is reusing an existing centre's location.",
"Pass both --x and --y together, or omit both to auto-pick the "
"first unoccupied village_layout.VILLAGE_LAYOUT slot (ignored if "
"--overwrite ends up reusing an existing centre's location).",
)
parser.add_argument(
"--y",
type=int,
help="Origin Y coordinate (metres, SRID 3857) to centre the village on. "
"Required unless --overwrite is reusing an existing centre's location.",
"Pass both --x and --y together, or omit both to auto-pick the "
"first unoccupied village_layout.VILLAGE_LAYOUT slot (ignored if "
"--overwrite ends up reusing an existing centre's location).",
)
parser.add_argument(
"--overwrite",
Expand Down Expand Up @@ -154,9 +159,39 @@ def _pick_village_name(self) -> str:
)

def _resolve_origin(self, x: int | None, y: int | None) -> Point:
if x is None and y is None:
return self._pick_unused_layout_slot()
if x is None or y is None:
raise CommandError(
"Pass both --x and --y for the village's origin (or --overwrite "
"an existing centre to reuse its location)."
"Pass both --x and --y together for the village's origin, or "
"neither to auto-pick an unoccupied village_layout.VILLAGE_LAYOUT "
"slot."
)
return Point(x, y, srid=3857)

def _pick_unused_layout_slot(self) -> Point:
"""
First VILLAGE_LAYOUT slot with no existing PopulationCentre already
sitting on it - lets an ad-hoc import (e.g. trying out a village file
outside locations/data/, so outside the setup_world/import_villages
pipeline) claim spare grid space without hand-picking coordinates.

Not persistent across a setup_world rerun: that command deletes every
existing PopulationCentre before reimporting only locations/data/'s
files (see setup_world.py), so an ad-hoc import placed here will need
to be redone afterwards - and may land on a different free slot next
time, since which slots are "unoccupied" depends on whatever other
centres exist at that moment.
"""
occupied = {
(round(centre.location.x), round(centre.location.y))
for centre in PopulationCentre.objects.only("location")
}
for x, y in VILLAGE_LAYOUT:
if (x, y) not in occupied:
return Point(x, y, srid=3857)
raise CommandError(
f"Every village_layout.VILLAGE_LAYOUT slot ({len(VILLAGE_LAYOUT)}) is "
"already occupied by a PopulationCentre - pass --x/--y explicitly, "
"or add more slots (GRID_COLUMNS/GRID_ROWS)."
)
73 changes: 73 additions & 0 deletions locations/tests/test_import_village_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from django.contrib.gis.geos import Point
from django.core.management.base import CommandError
from django.test import TestCase

from locations.management.commands.import_village import Command
from locations.models import PopulationCentre
from locations.village_layout import VILLAGE_LAYOUT


class ResolveOriginTest(TestCase):
def setUp(self):
self.command = Command()

def test_both_x_and_y_given_uses_them_directly(self):
origin = self.command._resolve_origin(123, 456)
self.assertEqual(origin, Point(123, 456, srid=3857))

def test_only_x_given_raises(self):
with self.assertRaises(CommandError):
self.command._resolve_origin(123, None)

def test_only_y_given_raises(self):
with self.assertRaises(CommandError):
self.command._resolve_origin(None, 456)

def test_neither_given_auto_picks_a_slot(self):
origin = self.command._resolve_origin(None, None)
x, y = VILLAGE_LAYOUT[0]
self.assertEqual(origin, Point(x, y, srid=3857))


class PickUnusedLayoutSlotTest(TestCase):
def setUp(self):
self.command = Command()

def test_picks_first_slot_when_none_occupied(self):
origin = self.command._pick_unused_layout_slot()
x, y = VILLAGE_LAYOUT[0]
self.assertEqual(origin, Point(x, y, srid=3857))

def test_skips_occupied_slots(self):
first_x, first_y = VILLAGE_LAYOUT[0]
second_x, second_y = VILLAGE_LAYOUT[1]
PopulationCentre.objects.create(
name="Occupied village",
location=Point(first_x, first_y, srid=3857),
)

origin = self.command._pick_unused_layout_slot()

self.assertEqual(origin, Point(second_x, second_y, srid=3857))

def test_raises_when_every_slot_is_occupied(self):
for i, (x, y) in enumerate(VILLAGE_LAYOUT):
PopulationCentre.objects.create(
name=f"Village {i}",
location=Point(x, y, srid=3857),
)

with self.assertRaises(CommandError):
self.command._pick_unused_layout_slot()

def test_ignores_centres_not_on_a_layout_slot(self):
# A centre placed off-grid (e.g. hand-picked --x/--y) shouldn't
# affect which layout slots count as unoccupied.
PopulationCentre.objects.create(
name="Off-grid village",
location=Point(999_999, 999_999, srid=3857),
)

origin = self.command._pick_unused_layout_slot()
x, y = VILLAGE_LAYOUT[0]
self.assertEqual(origin, Point(x, y, srid=3857))
Loading