feat: show immediate administrative subdivisions and map coverage gaps

This commit is contained in:
2026-11-04 10:58:28 +02:00
parent 6dde36b5ca
commit bbc05f1df6
12 changed files with 399 additions and 26 deletions
@@ -0,0 +1,127 @@
<?php
namespace Tests\Feature;
use App\Services\TerritoryGeometry;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
class TerritoryGeometrySubdivisionTest extends TestCase
{
use DatabaseTransactions;
protected function setUp(): void
{
parent::setUp();
$target = DB::selectOne('SELECT current_database() AS name, inet_server_port() AS port');
$this->assertStringEndsWith('_test', $target->name);
$this->assertSame(55441, (int) $target->port);
$this->assertSame('127.0.0.1', config('database.connections.pgsql.host'));
$this->withoutMiddleware(ThrottleRequests::class);
}
private function location(string $type, ?string $parent = null, string $classification = 'public'): string
{
if ($type === 'province' && $parent === null) {
$parent = $this->location('country');
}
$id = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $id, 'name' => 'Synthetic '.$type.' '.$id, 'code' => $id,
'location_type' => $type, 'parent_id' => $parent, 'data_classification' => $classification,
'source_system' => 'cartografia-workbook',
'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$id]);
return $id;
}
private function geometry(string $territory, string $classification = 'public'): string
{
$id = (string) Str::uuid();
DB::insert("INSERT INTO geo_geometries(id,geo_location_id,geometry_type,geom,properties,data_classification) VALUES (?,?,'polygon',ST_GeomFromText('POLYGON((32 -20,33 -20,33 -19,32 -19,32 -20))',4326),?::jsonb,?)",
[$id, $territory, json_encode(['purpose' => 'administrative_boundary', 'authority' => 'Synthetic QA only', 'source_version' => '2026-11-04']), $classification]);
return $id;
}
public function test_optional_children_preserve_default_and_sources_without_grandchildren_or_other_provinces(): void
{
$province = $this->location('province');
$district = $this->location('district', $province);
$post = $this->location('admin_post', $district);
$this->geometry($province);
$this->geometry($district);
$this->geometry($post);
$this->geometry($this->location('district', $this->location('province')));
$url = '/api/v1/investment/territory-geometries?territory_id='.$province;
$this->getJson($url)->assertOk()->assertJsonCount(1, 'areas')->assertJsonPath('child_coverage', null);
$result = $this->getJson($url.'&include_children=1')->assertOk()->assertJsonCount(2, 'areas')
->assertJsonPath('areas.0.territory_id', $province)->assertJsonPath('areas.0.selected_territory', true)
->assertJsonPath('areas.1.territory_id', $district)->assertJsonPath('areas.1.child_territory', true)
->assertJsonPath('areas.1.source_reference', 'Synthetic QA only')->assertJsonPath('areas.1.source_date', '2026-11-04')
->assertJsonPath('child_coverage.registered_count', 1)->assertJsonPath('child_coverage.represented_count', 1)
->assertJsonPath('investment_site', false)->assertJsonPath('truncated', false)->json();
$this->assertFalse($result['areas'][1]['selected_territory']);
$this->getJson('/api/v1/investment/territory-geometries?territory_id='.$district.'&include_children=1')
->assertOk()->assertJsonCount(3, 'areas')->assertJsonPath('child_coverage.location_type', 'admin_post');
}
public function test_coverage_counts_only_accessible_references_and_never_publishes_private_boundaries(): void
{
$province = $this->location('province');
$public = $this->location('district', $province);
$missing = $this->location('district', $province);
$hidden = $this->location('district', $province, 'internal');
$privateGeometry = $this->geometry($public, 'internal');
$this->geometry($hidden);
$before = DB::table('geo_geometries')->where('id', $privateGeometry)->value('data_classification');
$this->getJson('/api/v1/investment/territory-geometries?territory_id='.$province.'&include_children=1')
->assertOk()->assertJsonCount(0, 'areas')->assertJsonPath('selected_geometry_available', false)
->assertJsonPath('child_coverage.registered_count', 2)->assertJsonPath('child_coverage.represented_count', 0);
$internal = app(TerritoryGeometry::class)->context($province, true, true);
$this->assertSame(3, $internal['child_coverage']['registered_count']);
$this->assertSame(2, $internal['child_coverage']['represented_count']);
$this->assertSame($before, DB::table('geo_geometries')->where('id', $privateGeometry)->value('data_classification'));
DB::table('geo_locations')->where('id', $missing)->update(['deleted_at' => now()]);
$this->getJson('/api/v1/investment/territory-geometries?territory_id='.$province.'&include_children=1')
->assertOk()->assertJsonPath('child_coverage.registered_count', 1);
}
public function test_limits_are_explicit_and_selected_boundary_is_retained_ahead_of_children(): void
{
$province = $this->location('province');
$selected = $this->geometry($province);
for ($i = 0; $i < 101; $i++) {
$this->geometry($this->location('district', $province));
}
$this->getJson('/api/v1/investment/territory-geometries?territory_id='.$province.'&include_children=1')
->assertOk()->assertJsonCount(100, 'areas')->assertJsonPath('areas.0.id', $selected)
->assertJsonPath('selected_geometry_available', true)->assertJsonPath('truncated', true)
->assertJsonPath('child_coverage.registered_count', 101)->assertJsonPath('child_coverage.represented_count', 99);
}
public function test_reference_limit_reports_unrepresented_subdivisions_without_claiming_they_do_not_exist(): void
{
$province = $this->location('province');
for ($i = 0; $i < 501; $i++) {
$this->location('district', $province);
}
$this->getJson('/api/v1/investment/territory-geometries?territory_id='.$province.'&include_children=1')
->assertOk()->assertJsonCount(0, 'areas')->assertJsonPath('truncated', true)
->assertJsonPath('child_coverage.registered_count', 501)->assertJsonPath('child_coverage.represented_count', 0);
}
public function test_invalid_child_option_is_rejected_and_locality_has_no_lower_level(): void
{
$province = $this->location('province');
$district = $this->location('district', $province);
$post = $this->location('admin_post', $district);
$locality = $this->location('locality', $post);
$url = '/api/v1/investment/territory-geometries?territory_id='.$locality;
$this->getJson($url.'&include_children=all')->assertUnprocessable();
$this->getJson($url.'&include_children=1')->assertOk()->assertJsonPath('child_coverage.location_type', null)
->assertJsonPath('child_coverage.registered_count', 0)->assertJsonPath('truncated', false);
}
}
+55 -3
View File
@@ -81,7 +81,9 @@ await test('catalogue composes one map with the same opportunity data and separa
'./interactive-investment-map': { default: Map },
'./territory-geometry-preview': { TerritoryGeometryDetails: Info },
'./use-territory-geometry': {
useTerritoryGeometry: (id) => {
useTerritoryGeometry: (id, endpoint, children) => {
assert.equal(endpoint, undefined);
assert.equal(children, true);
requests.push(id);
return { context: { areas: [boundary] }, error: '' };
},
@@ -125,9 +127,9 @@ await test('territorial request changes discard old boundaries immediately and i
const { useTerritoryGeometry } = load('use-territory-geometry.ts', {
react: hooks,
});
const render = (id) => {
const render = (id, children = false) => {
cursor = 0;
return useTerritoryGeometry(id);
return useTerritoryGeometry(id, undefined, children);
};
render('a');
const cleanup = effect();
@@ -150,6 +152,22 @@ await test('territorial request changes discard old boundaries immediately and i
assert.equal(render(undefined).context, null);
assert.equal(pending[0].options.signal.aborted, true);
assert.equal(pending.length, 2);
assert.equal(render('b', true).context, null);
effect();
assert.match(pending[2].url, /include_children=1/);
pending[2].resolve({
ok: true,
json: async () => ({
areas: [boundary],
child_coverage: { registered_count: 2 },
}),
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(
render('b', true).context.child_coverage.registered_count,
2,
);
assert.equal(render('b', false).context, null);
} finally {
globalThis.fetch = previousFetch;
}
@@ -452,3 +470,37 @@ await test('partnership catalogue has one contextual map and keeps the applied t
assert.equal(maps()[0].props.territoryId, 'a');
assert.equal(maps()[0].props.points, props.mapPoints);
});
await test('administrative coverage identifies lower-level subdivisions and distinguishes absent geometry from absent territory', () => {
const Details = load('territory-geometry-preview.tsx', {
'./interactive-investment-map': { default: () => null },
'./use-territory-geometry': {},
}).TerritoryGeometryDetails;
const html = renderToStaticMarkup(
React.createElement(Details, {
error: '',
context: {
selected_geometry_available: false,
context_geometry_count: 1,
truncated: true,
child_coverage: {
location_type: 'district',
registered_count: 3,
represented_count: 1,
},
areas: [
{
...boundary,
selected_territory: false,
child_territory: true,
},
],
},
}),
);
assert.match(html, /1 de 3 referências acessíveis/);
assert.match(html, /Subdivisão do território seleccionado/);
assert.match(html, /não significa inexistência do território/);
assert.match(html, /100 geometrias e 500 subdivisões/);
assert.doesNotMatch(html, /apenas limites dos níveis superiores/);
});