From 61ad7923ca458750db5055df891543f6845900ba Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Tue, 28 Jul 2026 22:48:58 +0300 Subject: [PATCH] Diversify scale fixture geography and privacy --- assets/js/hooks.js | 14 +- assets/js/map_cluster_points.mjs | 19 +- assets/js/map_cluster_points.test.mjs | 33 +- lib/mix/tasks/wnh.scale_fixtures.ex | 880 ++++++++++++++++---- lib/who_need_help/activities.ex | 36 +- lib/who_need_help/help.ex | 20 +- test/who_need_help/activities_test.exs | 36 + test/who_need_help/mutual_aid_flow_test.exs | 38 + 8 files changed, 904 insertions(+), 172 deletions(-) diff --git a/assets/js/hooks.js b/assets/js/hooks.js index 2a9b1a6..33f3862 100644 --- a/assets/js/hooks.js +++ b/assets/js/hooks.js @@ -1,7 +1,7 @@ import maplibregl from "maplibre-gl" import { clusterSizeClass, - mergeNearbyMapClusters + mergeNearbyMapPoints } from "./map_cluster_points.mjs" const trackingMinTimeMs = 5000 @@ -524,21 +524,19 @@ const createAidMap = element => { state.markerElements.clear() const points = markerPoints(state.element) - const exactPoints = points.filter( + const displayPoints = mergeNearbyMapPoints(state.map, points) + const exactPoints = displayPoints.filter( point => point.type !== "cluster" && (point.exact || !Number.isFinite(point.radius_meters)) ) - const clusterPoints = mergeNearbyMapClusters( - state.map, - points.filter(point => point.type === "cluster") - ) - const areaFeatures = areaFeatureCollection(points) + const clusterPoints = displayPoints.filter(point => point.type === "cluster") + const areaFeatures = areaFeatureCollection(displayPoints) const clusterFeatures = clusterFeatureCollection(clusterPoints) const bounds = new maplibregl.LngLatBounds() exactPoints.forEach(point => bounds.extend([point.longitude, point.latitude])) areaFeatures.features.forEach(feature => extendBoundsWithFeature(bounds, feature)) clusterFeatures.features.forEach(feature => bounds.extend(feature.geometry.coordinates)) - state.locationBounds = points.length > 0 ? bounds : null + state.locationBounds = displayPoints.length > 0 ? bounds : null if (state.map.isStyleLoaded()) { const areaSource = state.map.getSource(state.areaSourceId) diff --git a/assets/js/map_cluster_points.mjs b/assets/js/map_cluster_points.mjs index 80b6b04..6404626 100644 --- a/assets/js/map_cluster_points.mjs +++ b/assets/js/map_cluster_points.mjs @@ -34,7 +34,7 @@ const coordinatesFromPixel = (map, pixel) => { } } -export const mergeNearbyMapClusters = (map, points, options = {}) => { +const nearbyMapPointGroups = (map, points, options = {}) => { const gap = Number.isFinite(options.gap) ? Math.max(options.gap, 0) : 8 const groups = points.map(point => { @@ -76,7 +76,10 @@ export const mergeNearbyMapClusters = (map, points, options = {}) => { } } - return groups.map(group => { + return groups +} + +const visualCluster = (map, group) => { const coordinates = coordinatesFromPixel(map, group) const firstId = group.points[0]?.id || "anonymous" @@ -88,5 +91,17 @@ export const mergeNearbyMapClusters = (map, points, options = {}) => { longitude: coordinates.longitude, latitude: coordinates.latitude } +} + +export const mergeNearbyMapClusters = (map, points, options = {}) => + nearbyMapPointGroups(map, points, options).map(group => visualCluster(map, group)) + +export const mergeNearbyMapPoints = (map, points, options = {}) => { + return nearbyMapPointGroups(map, points, options).map(group => { + const point = group.points[0] + + if (group.points.length === 1 && point?.type !== "cluster") return point + + return visualCluster(map, group) }) } diff --git a/assets/js/map_cluster_points.test.mjs b/assets/js/map_cluster_points.test.mjs index 340267f..39a459f 100644 --- a/assets/js/map_cluster_points.test.mjs +++ b/assets/js/map_cluster_points.test.mjs @@ -4,7 +4,8 @@ import test from "node:test" import { clusterDiameterPixels, clusterSizeClass, - mergeNearbyMapClusters + mergeNearbyMapClusters, + mergeNearbyMapPoints } from "./map_cluster_points.mjs" const flatMap = scale => ({ @@ -40,6 +41,36 @@ test("splits the same clusters again at a closer zoom", () => { assert.equal(mergeNearbyMapClusters(flatMap(3), points).length, 2) }) +test("merges a singleton into an overlapping cluster at a distant zoom", () => { + const points = [ + {id: "cluster", type: "cluster", count: 351, longitude: 0, latitude: 0}, + {id: "request", type: "request", longitude: 28, latitude: 0, exact: true} + ] + + const result = mergeNearbyMapPoints(flatMap(1), points) + + assert.equal(result.length, 1) + assert.equal(result[0].type, "cluster") + assert.equal(result[0].count, 352) +}) + +test("keeps separated singleton points interactive at a closer zoom", () => { + const points = [ + {id: "left", type: "request", longitude: 0, latitude: 0, exact: true}, + { + id: "right", + type: "request", + longitude: 30, + latitude: 0, + radius_meters: 1_000 + } + ] + + const result = mergeNearbyMapPoints(flatMap(3), points) + + assert.deepEqual(result, points) +}) + test("uses larger non-overlapping markers for larger totals", () => { assert.equal(clusterDiameterPixels(999), 44) assert.equal(clusterDiameterPixels(1_000), 52) diff --git a/lib/mix/tasks/wnh.scale_fixtures.ex b/lib/mix/tasks/wnh.scale_fixtures.ex index 8bec67c..7bbeece 100644 --- a/lib/mix/tasks/wnh.scale_fixtures.ex +++ b/lib/mix/tasks/wnh.scale_fixtures.ex @@ -11,6 +11,93 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do @default_rows 1_000_000 @synthetic_users 1_000 @viewer_email "scale-viewer@example.invalid" + @fixture_version 6 + @request_open_modulus 100 + @activity_open_modulus 50 + @places [ + {"Kyiv", 30.5234, 50.4501}, + {"Lviv", 24.0316, 49.8429}, + {"Warsaw", 21.0122, 52.2297}, + {"Krakow", 19.945, 50.0647}, + {"Berlin", 13.405, 52.52}, + {"Hamburg", 9.9937, 53.5511}, + {"Frankfurt", 8.6821, 50.1109}, + {"Munich", 11.582, 48.1351}, + {"Prague", 14.4378, 50.0755}, + {"Vienna", 16.3738, 48.2082}, + {"Budapest", 19.0402, 47.4979}, + {"Bucharest", 26.1025, 44.4268}, + {"Sofia", 23.3219, 42.6977}, + {"Athens", 23.7275, 37.9838}, + {"Rome", 12.4964, 41.9028}, + {"Milan", 9.19, 45.4642}, + {"Madrid", -3.7038, 40.4168}, + {"Barcelona", 2.1734, 41.3851}, + {"Paris", 2.3522, 48.8566}, + {"Lyon", 4.8357, 45.764}, + {"London", -0.1276, 51.5072}, + {"Manchester", -2.2426, 53.4808}, + {"Dublin", -6.2603, 53.3498}, + {"Amsterdam", 4.9041, 52.3676}, + {"Brussels", 4.3517, 50.8503}, + {"Copenhagen", 12.5683, 55.6761}, + {"Stockholm", 18.0686, 59.3293}, + {"Oslo", 10.7522, 59.9139}, + {"Helsinki", 24.9384, 60.1699}, + {"Tallinn", 24.7536, 59.437}, + {"Vilnius", 25.2797, 54.6872}, + {"Riga", 24.1052, 56.9496}, + {"Istanbul", 28.9784, 41.0082}, + {"New York", -74.006, 40.7128}, + {"Boston", -71.0589, 42.3601}, + {"Washington", -77.0369, 38.9072}, + {"Chicago", -87.6298, 41.8781}, + {"Toronto", -79.3832, 43.6532}, + {"Montreal", -73.5673, 45.5017}, + {"Vancouver", -123.1207, 49.2827}, + {"Seattle", -122.3321, 47.6062}, + {"San Francisco", -122.4194, 37.7749}, + {"Los Angeles", -118.2437, 34.0522}, + {"Denver", -104.9903, 39.7392}, + {"Dallas", -96.797, 32.7767}, + {"Mexico City", -99.1332, 19.4326}, + {"Bogota", -74.0721, 4.711}, + {"Lima", -77.0428, -12.0464}, + {"Santiago", -70.6693, -33.4489}, + {"Buenos Aires", -58.3816, -34.6037}, + {"Sao Paulo", -46.6333, -23.5505}, + {"Rio de Janeiro", -43.1729, -22.9068}, + {"Montevideo", -56.1645, -34.9011}, + {"Cape Town", 18.4241, -33.9249}, + {"Johannesburg", 28.0473, -26.2041}, + {"Nairobi", 36.8219, -1.2921}, + {"Lagos", 3.3792, 6.5244}, + {"Accra", -0.187, 5.6037}, + {"Cairo", 31.2357, 30.0444}, + {"Casablanca", -7.5898, 33.5731}, + {"Delhi", 77.1025, 28.7041}, + {"Mumbai", 72.8777, 19.076}, + {"Bengaluru", 77.5946, 12.9716}, + {"Bangkok", 100.5018, 13.7563}, + {"Singapore", 103.8198, 1.3521}, + {"Jakarta", 106.8456, -6.2088}, + {"Manila", 120.9842, 14.5995}, + {"Hong Kong", 114.1694, 22.3193}, + {"Seoul", 126.978, 37.5665}, + {"Tokyo", 139.6917, 35.6895}, + {"Osaka", 135.5023, 34.6937}, + {"Taipei", 121.5654, 25.033}, + {"Sydney", 151.2093, -33.8688}, + {"Melbourne", 144.9631, -37.8136}, + {"Brisbane", 153.0251, -27.4698}, + {"Auckland", 174.7633, -36.8485}, + {"Tel Aviv", 34.7818, 32.0853}, + {"Dubai", 55.2708, 25.2048}, + {"Riyadh", 46.6753, 24.7136}, + {"Tbilisi", 44.793, 41.7151}, + {"Yerevan", 44.5152, 40.1872} + ] + @place_count length(@places) @impl Mix.Task def run(arguments) do @@ -95,29 +182,26 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do categories = seed_categories!() ensure_viewer!() - Repo.transaction( - fn -> - seed_users!() - seed_requests!(request_count, categories.help) - seed_activities!(activity_count, categories.activity) - end, - timeout: :infinity - ) + {:ok, profile} = + Repo.transaction( + fn -> + seed_users!() + seed_requests!(request_count, categories.help) + seed_activities!(activity_count, categories.activity) + assert_synthetic_counts!(request_count, activity_count) + assert_fixture_profile!(request_count, activity_count) + end, + timeout: :infinity + ) - Repo.query!( - "ANALYZE users, categories, help_requests, activities", - [], - timeout: :infinity, - log: false - ) - - assert_synthetic_counts!(request_count, activity_count) + analyze_scale_tables!() write_summary!(context, "appended", request_count, activity_count, %{ expected_kind: "synthetic", totals_before: before_totals, synthetic_before: before_synthetic, - synthetic_after: synthetic_discovery_counts() + synthetic_after: synthetic_discovery_counts(), + fixture_profile: profile }) totals = discovery_counts() @@ -134,7 +218,32 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do cond do counts.help_requests == request_count and counts.activities == activity_count -> ensure_viewer!() - write_summary!(context, "prepared", request_count, activity_count, %{}) + + profile = + if current_fixture_version?() do + assert_fixture_profile!(request_count, activity_count) + else + categories = seed_categories!() + + {:ok, profile} = + Repo.transaction( + fn -> + seed_users!() + seed_requests!(request_count, categories.help) + seed_activities!(activity_count, categories.activity) + assert_counts!(request_count, activity_count) + assert_fixture_profile!(request_count, activity_count) + end, + timeout: :infinity + ) + + analyze_scale_tables!() + profile + end + + write_summary!(context, "prepared", request_count, activity_count, %{ + fixture_profile: profile + }) counts.help_requests != 0 or counts.activities != 0 -> Mix.raise( @@ -146,24 +255,23 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do categories = seed_categories!() ensure_viewer!() - Repo.transaction( - fn -> - seed_users!() - seed_requests!(request_count, categories.help) - seed_activities!(activity_count, categories.activity) - end, - timeout: :infinity - ) + {:ok, profile} = + Repo.transaction( + fn -> + seed_users!() + seed_requests!(request_count, categories.help) + seed_activities!(activity_count, categories.activity) + assert_counts!(request_count, activity_count) + assert_fixture_profile!(request_count, activity_count) + end, + timeout: :infinity + ) - Repo.query!( - "ANALYZE users, categories, help_requests, activities", - [], - timeout: :infinity, - log: false - ) + analyze_scale_tables!() - assert_counts!(request_count, activity_count) - write_summary!(context, "prepared", request_count, activity_count, %{}) + write_summary!(context, "prepared", request_count, activity_count, %{ + fixture_profile: profile + }) end Mix.shell().info( @@ -174,6 +282,7 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do defp verify!(context, request_count, activity_count, samples) do assert_counts!(request_count, activity_count) + assert_fixture_profile!(request_count, activity_count) viewer = Repo.get_by!(User, email: @viewer_email) scope = Scope.for_user(viewer) @@ -213,6 +322,7 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do end) write_summary!(context, "verified", request_count, activity_count, %{ + fixture_profile: fixture_profile(), measurement_samples: samples, viewport_measurements: measurements }) @@ -298,6 +408,8 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do end defp seed_requests!(rows, category_ids) do + {place_names, place_longitudes, place_latitudes} = place_columns() + Repo.query!( """ INSERT INTO help_requests ( @@ -309,43 +421,45 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do SELECT md5('wnh-scale-request-v1-' || value)::uuid, 'Scale help request ' || value, - 'Persistent synthetic local scale request used to test viewport discovery and UI.', + 'Synthetic scale request used to test geographic discovery, privacy, and UI.', 'Synthetic data only; no real-world action is requested.', - '{"scale_fixture":true,"scale_fixture_version":3}'::jsonb, - city.name || ' synthetic zone ' || + jsonb_build_object( + 'scale_fixture', true, + 'scale_fixture_version', $7::integer + ), + place.name || ' synthetic area ' || ((variation.category_key + variation.time_key) % 100 + 1), - CASE WHEN variation.visibility_key % 97 = 0 THEN NULL ELSE ST_SetSRID( - ST_MakePoint( - city.longitude + - ((variation.longitude_key % 4001) - 2000) / 1000.0, - city.latitude + - ((variation.latitude_key % 4003) - 2001) / 1000.0 - ), - 4326 - ) END, - CASE WHEN variation.status_key % 10 < 8 THEN 'open' - WHEN variation.status_key % 10 = 8 THEN 'completed' + CASE WHEN variation.visibility_key % 20 = 0 THEN NULL + ELSE ST_SetSRID( + ST_MakePoint(coordinates.longitude, coordinates.latitude), + 4326 + ) + END, + CASE WHEN variation.status_key % #{@request_open_modulus} = 0 THEN 'open' + WHEN variation.status_key % #{@request_open_modulus} < 86 THEN 'completed' ELSE 'cancelled' END, CASE (variation.status_key / 10) % 3 WHEN 0 THEN 'now' WHEN 1 THEN 'today' ELSE 'scheduled' END, - CASE WHEN variation.visibility_key % 97 = 0 THEN 'hidden' - WHEN (variation.visibility_key / 97) % 4 = 0 THEN 'exact_public' - WHEN (variation.visibility_key / 97) % 4 = 1 - THEN 'exact_for_active_match' + CASE WHEN variation.visibility_key % 20 = 0 THEN 'hidden' + WHEN variation.visibility_key % 20 < 5 THEN 'exact_public' + WHEN variation.visibility_key % 20 < 9 THEN 'exact_for_active_match' ELSE 'approximate_public' END, - CASE WHEN variation.visibility_key % 97 = 0 - OR (variation.visibility_key / 97) % 4 = 0 THEN NULL - WHEN (variation.visibility_key / 388) % 3 = 0 THEN 500 - WHEN (variation.visibility_key / 388) % 3 = 1 THEN 1000 + CASE WHEN variation.visibility_key % 20 < 5 THEN NULL + WHEN (variation.visibility_key / 20) % 3 = 0 THEN 500 + WHEN (variation.visibility_key / 20) % 3 = 1 THEN 1000 ELSE 2000 END, - date_trunc('second', now()) + - ((variation.time_key % 30) + 1) * interval '1 day', + CASE WHEN variation.status_key % #{@request_open_modulus} = 0 + THEN date_trunc('second', now()) + + ((variation.time_key % 14) + 1) * interval '1 day' + ELSE date_trunc('second', now()) - + ((variation.time_key % 365) + 1) * interval '1 day' + END, md5( 'wnh-scale-user-' || - ((variation.longitude_key * 37 + variation.latitude_key) % $2 + 1) + (variation.owner_key % $2 + 1) )::uuid, (($3::text[])[ (variation.category_key % cardinality($3::text[])) + 1 @@ -355,52 +469,70 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do FROM generate_series(1, $1) AS value CROSS JOIN LATERAL ( SELECT - ((value - 1) % 20)::integer AS city_index, - md5('wnh-scale-request-v3-' || value) AS fixture_hash + md5('wnh-scale-request-v5-' || value) AS fixture_hash, + md5('wnh-scale-request-geography-v5-' || value) AS geography_hash ) AS fixture CROSS JOIN LATERAL ( SELECT ('x' || substr(fixture.fixture_hash, 1, 8))::bit(32)::bigint - AS longitude_key, - ('x' || substr(fixture.fixture_hash, 9, 8))::bit(32)::bigint - AS latitude_key, - ('x' || substr(fixture.fixture_hash, 17, 4))::bit(16)::bigint AS status_key, - ('x' || substr(fixture.fixture_hash, 21, 4))::bit(16)::bigint + ('x' || substr(fixture.fixture_hash, 9, 8))::bit(32)::bigint AS visibility_key, - ('x' || substr(fixture.fixture_hash, 25, 4))::bit(16)::bigint + ('x' || substr(fixture.fixture_hash, 17, 8))::bit(32)::bigint AS time_key, - ('x' || substr(fixture.fixture_hash, 29, 4))::bit(16)::bigint - AS category_key + ('x' || substr(fixture.fixture_hash, 25, 8))::bit(32)::bigint + AS category_key, + ('x' || substr(fixture.geography_hash, 1, 8))::bit(32)::bigint + AS place_key, + ('x' || substr(fixture.geography_hash, 9, 8))::bit(32)::bigint + AS angle_key, + ('x' || substr(fixture.geography_hash, 17, 8))::bit(32)::bigint + AS distance_key, + ('x' || substr(fixture.geography_hash, 25, 8))::bit(32)::bigint + AS owner_key ) AS variation CROSS JOIN LATERAL ( - SELECT * - FROM ( - VALUES - (0, 'Kyiv', 30.5234::double precision, 50.4501::double precision), - (1, 'Frankfurt', 8.6821, 50.1109), - (2, 'Berlin', 13.4050, 52.5200), - (3, 'Warsaw', 21.0122, 52.2297), - (4, 'Prague', 14.4378, 50.0755), - (5, 'London', -0.1276, 51.5072), - (6, 'Paris', 2.3522, 48.8566), - (7, 'Madrid', -3.7038, 40.4168), - (8, 'Rome', 12.4964, 41.9028), - (9, 'New York', -74.0060, 40.7128), - (10, 'Toronto', -79.3832, 43.6532), - (11, 'Mexico City', -99.1332, 19.4326), - (12, 'São Paulo', -46.6333, -23.5505), - (13, 'Cape Town', 18.4241, -33.9249), - (14, 'Nairobi', 36.8219, -1.2921), - (15, 'Delhi', 77.1025, 28.7041), - (16, 'Bangkok', 100.5018, 13.7563), - (17, 'Tokyo', 139.6917, 35.6895), - (18, 'Sydney', 151.2093, -33.8688), - (19, 'Auckland', 174.7633, -36.8485) - ) AS cities(index, name, longitude, latitude) - WHERE cities.index = fixture.city_index - ) AS city + SELECT name, longitude, latitude + FROM unnest( + $4::text[], + $5::double precision[], + $6::double precision[] + ) WITH ORDINALITY AS places(name, longitude, latitude, position) + WHERE places.position = variation.place_key % cardinality($4::text[]) + 1 + ) AS place + CROSS JOIN LATERAL ( + SELECT + 2 * pi() * + ((variation.angle_key % 1000003)::double precision / 1000003.0) AS angle, + sqrt( + ((variation.distance_key / 20 % 1000003)::double precision + 1) / + 1000004.0 + ) * + CASE WHEN variation.distance_key % 20 < 12 THEN 15.0 + WHEN variation.distance_key % 20 < 18 THEN 60.0 + ELSE 220.0 + END AS distance_km + ) AS geography + CROSS JOIN LATERAL ( + SELECT + place.latitude + + geography.distance_km / 111.32 * sin(geography.angle) AS raw_latitude, + place.longitude + + geography.distance_km / + (111.32 * greatest(abs(cos(radians(place.latitude))), 0.2)) * + cos(geography.angle) AS raw_longitude + ) AS raw_coordinates + CROSS JOIN LATERAL ( + SELECT + greatest(-84.0, least(84.0, raw_coordinates.raw_latitude)) AS latitude, + (raw_coordinates.raw_longitude + 180.0) - + 360.0 * floor((raw_coordinates.raw_longitude + 180.0) / 360.0) - + 180.0 AS longitude + ) AS coordinates ON CONFLICT (id) DO UPDATE SET + title = EXCLUDED.title, + description = EXCLUDED.description, + pickup_instructions = EXCLUDED.pickup_instructions, structured_data = EXCLUDED.structured_data, location_label = EXCLUDED.location_label, location = EXCLUDED.location, @@ -413,15 +545,26 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do category_id = EXCLUDED.category_id, updated_at = EXCLUDED.updated_at WHERE help_requests.structured_data @> '{"scale_fixture":true}'::jsonb - AND help_requests.structured_data ->> 'scale_fixture_version' IS DISTINCT FROM '3' + AND help_requests.structured_data ->> 'scale_fixture_version' + IS DISTINCT FROM $7::text """, - [rows, @synthetic_users, category_ids], + [ + rows, + @synthetic_users, + category_ids, + place_names, + place_longitudes, + place_latitudes, + @fixture_version + ], timeout: :infinity, log: false ) end defp seed_activities!(rows, category_ids) do + {place_names, place_longitudes, place_latitudes} = place_columns() + Repo.query!( """ INSERT INTO activities ( @@ -432,34 +575,40 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do SELECT md5('wnh-scale-activity-v1-' || value)::uuid, 'Scale community activity ' || value, - 'Persistent synthetic local scale activity used to test viewport discovery and UI.', - '{"scale_fixture":true,"scale_fixture_version":3}'::jsonb, - city.name || ' synthetic zone ' || + 'Synthetic scale activity used to test geographic discovery, privacy, and UI.', + jsonb_build_object( + 'scale_fixture', true, + 'scale_fixture_version', $7::integer + ), + place.name || ' synthetic area ' || ((variation.category_key + variation.time_key) % 100 + 1), ST_SetSRID( - ST_MakePoint( - city.longitude + - ((variation.longitude_key % 4001) - 2000) / 1000.0, - city.latitude + - ((variation.latitude_key % 4003) - 2001) / 1000.0 - ), + ST_MakePoint(coordinates.longitude, coordinates.latitude), 4326 ), - CASE WHEN variation.visibility_key % 97 = 0 + CASE WHEN variation.visibility_key % 10 = 0 THEN 'hidden' ELSE 'approximate_public' END, - CASE WHEN variation.status_key % 10 < 8 THEN 'open' - WHEN variation.status_key % 10 = 8 THEN 'completed' + CASE WHEN variation.status_key % #{@activity_open_modulus} = 0 THEN 'open' + WHEN variation.status_key % #{@activity_open_modulus} < 41 THEN 'completed' ELSE 'cancelled' END, - date_trunc('second', now()) + - ((variation.time_key % 60) + 2) * interval '1 day', - date_trunc('second', now()) + - ((variation.time_key % 60) + 1) * interval '1 day', + CASE WHEN variation.status_key % #{@activity_open_modulus} = 0 + THEN date_trunc('second', now()) + + ((variation.time_key % 60) + 2) * interval '1 day' + ELSE date_trunc('second', now()) - + ((variation.time_key % 365) + 1) * interval '1 day' + END, + CASE WHEN variation.status_key % #{@activity_open_modulus} = 0 + THEN date_trunc('second', now()) + + ((variation.time_key % 60) + 1) * interval '1 day' + ELSE date_trunc('second', now()) - + ((variation.time_key % 365) + 2) * interval '1 day' + END, 2 + ((variation.status_key + variation.time_key) % 19), md5( 'wnh-scale-user-' || - ((variation.longitude_key * 37 + variation.latitude_key) % $2 + 1) + (variation.owner_key % $2 + 1) )::uuid, (($3::text[])[ (variation.category_key % cardinality($3::text[])) + 1 @@ -469,52 +618,69 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do FROM generate_series(1, $1) AS value CROSS JOIN LATERAL ( SELECT - ((value - 1) % 20)::integer AS city_index, - md5('wnh-scale-activity-v3-' || value) AS fixture_hash + md5('wnh-scale-activity-v5-' || value) AS fixture_hash, + md5('wnh-scale-activity-geography-v5-' || value) AS geography_hash ) AS fixture CROSS JOIN LATERAL ( SELECT ('x' || substr(fixture.fixture_hash, 1, 8))::bit(32)::bigint - AS longitude_key, - ('x' || substr(fixture.fixture_hash, 9, 8))::bit(32)::bigint - AS latitude_key, - ('x' || substr(fixture.fixture_hash, 17, 4))::bit(16)::bigint AS status_key, - ('x' || substr(fixture.fixture_hash, 21, 4))::bit(16)::bigint + ('x' || substr(fixture.fixture_hash, 9, 8))::bit(32)::bigint AS visibility_key, - ('x' || substr(fixture.fixture_hash, 25, 4))::bit(16)::bigint + ('x' || substr(fixture.fixture_hash, 17, 8))::bit(32)::bigint AS time_key, - ('x' || substr(fixture.fixture_hash, 29, 4))::bit(16)::bigint - AS category_key + ('x' || substr(fixture.fixture_hash, 25, 8))::bit(32)::bigint + AS category_key, + ('x' || substr(fixture.geography_hash, 1, 8))::bit(32)::bigint + AS place_key, + ('x' || substr(fixture.geography_hash, 9, 8))::bit(32)::bigint + AS angle_key, + ('x' || substr(fixture.geography_hash, 17, 8))::bit(32)::bigint + AS distance_key, + ('x' || substr(fixture.geography_hash, 25, 8))::bit(32)::bigint + AS owner_key ) AS variation CROSS JOIN LATERAL ( - SELECT * - FROM ( - VALUES - (0, 'Kyiv', 30.5234::double precision, 50.4501::double precision), - (1, 'Frankfurt', 8.6821, 50.1109), - (2, 'Berlin', 13.4050, 52.5200), - (3, 'Warsaw', 21.0122, 52.2297), - (4, 'Prague', 14.4378, 50.0755), - (5, 'London', -0.1276, 51.5072), - (6, 'Paris', 2.3522, 48.8566), - (7, 'Madrid', -3.7038, 40.4168), - (8, 'Rome', 12.4964, 41.9028), - (9, 'New York', -74.0060, 40.7128), - (10, 'Toronto', -79.3832, 43.6532), - (11, 'Mexico City', -99.1332, 19.4326), - (12, 'São Paulo', -46.6333, -23.5505), - (13, 'Cape Town', 18.4241, -33.9249), - (14, 'Nairobi', 36.8219, -1.2921), - (15, 'Delhi', 77.1025, 28.7041), - (16, 'Bangkok', 100.5018, 13.7563), - (17, 'Tokyo', 139.6917, 35.6895), - (18, 'Sydney', 151.2093, -33.8688), - (19, 'Auckland', 174.7633, -36.8485) - ) AS cities(index, name, longitude, latitude) - WHERE cities.index = fixture.city_index - ) AS city + SELECT name, longitude, latitude + FROM unnest( + $4::text[], + $5::double precision[], + $6::double precision[] + ) WITH ORDINALITY AS places(name, longitude, latitude, position) + WHERE places.position = variation.place_key % cardinality($4::text[]) + 1 + ) AS place + CROSS JOIN LATERAL ( + SELECT + 2 * pi() * + ((variation.angle_key % 1000003)::double precision / 1000003.0) AS angle, + sqrt( + ((variation.distance_key / 20 % 1000003)::double precision + 1) / + 1000004.0 + ) * + CASE WHEN variation.distance_key % 20 < 12 THEN 15.0 + WHEN variation.distance_key % 20 < 18 THEN 60.0 + ELSE 220.0 + END AS distance_km + ) AS geography + CROSS JOIN LATERAL ( + SELECT + place.latitude + + geography.distance_km / 111.32 * sin(geography.angle) AS raw_latitude, + place.longitude + + geography.distance_km / + (111.32 * greatest(abs(cos(radians(place.latitude))), 0.2)) * + cos(geography.angle) AS raw_longitude + ) AS raw_coordinates + CROSS JOIN LATERAL ( + SELECT + greatest(-84.0, least(84.0, raw_coordinates.raw_latitude)) AS latitude, + (raw_coordinates.raw_longitude + 180.0) - + 360.0 * floor((raw_coordinates.raw_longitude + 180.0) / 360.0) - + 180.0 AS longitude + ) AS coordinates ON CONFLICT (id) DO UPDATE SET + title = EXCLUDED.title, + description = EXCLUDED.description, structured_data = EXCLUDED.structured_data, location_label = EXCLUDED.location_label, location = EXCLUDED.location, @@ -527,14 +693,31 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do category_id = EXCLUDED.category_id, updated_at = EXCLUDED.updated_at WHERE activities.structured_data @> '{"scale_fixture":true}'::jsonb - AND activities.structured_data ->> 'scale_fixture_version' IS DISTINCT FROM '3' + AND activities.structured_data ->> 'scale_fixture_version' + IS DISTINCT FROM $7::text """, - [rows, @synthetic_users, category_ids], + [ + rows, + @synthetic_users, + category_ids, + place_names, + place_longitudes, + place_latitudes, + @fixture_version + ], timeout: :infinity, log: false ) end + defp place_columns do + { + Enum.map(@places, &elem(&1, 0)), + Enum.map(@places, &elem(&1, 1)), + Enum.map(@places, &elem(&1, 2)) + } + end + defp viewport!(west, south, east, north, zoom) do {:ok, viewport} = DiscoveryViewport.cast(%{ @@ -625,6 +808,399 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do end end + defp analyze_scale_tables! do + Repo.query!( + "ANALYZE users, categories, help_requests, activities", + [], + timeout: :infinity, + log: false + ) + end + + defp current_fixture_version? do + counts = synthetic_discovery_counts() + + counts.help_requests > 0 and counts.activities > 0 and + counts.help_requests == fixture_version_count!("help_requests") and + counts.activities == fixture_version_count!("activities") + end + + defp fixture_version_count!("help_requests") do + scalar!(""" + SELECT count(*) + FROM help_requests + WHERE structured_data @> '{"scale_fixture": true}'::jsonb + AND structured_data ->> 'scale_fixture_version' = '#{@fixture_version}' + """) + end + + defp fixture_version_count!("activities") do + scalar!(""" + SELECT count(*) + FROM activities + WHERE structured_data @> '{"scale_fixture": true}'::jsonb + AND structured_data ->> 'scale_fixture_version' = '#{@fixture_version}' + """) + end + + defp assert_fixture_profile!(request_count, activity_count) do + unless current_fixture_version?() do + Mix.raise("synthetic discovery rows are not all fixture version #{@fixture_version}") + end + + profile = fixture_profile() + + if request_count >= 10_000 and activity_count >= 10_000 do + assert_distribution_keys!( + "request statuses", + profile.help_requests.statuses, + ~w(cancelled completed open) + ) + + assert_distribution_keys!( + "request visibility modes", + profile.help_requests.visibility, + ~w(approximate_public exact_for_active_match exact_public hidden) + ) + + assert_distribution_keys!( + "request radii", + profile.help_requests.radii_meters, + ~w(500 1000 2000 none) + ) + + assert_distribution_keys!( + "activity statuses", + profile.activities.statuses, + ~w(cancelled completed open) + ) + + assert_distribution_keys!( + "activity visibility modes", + profile.activities.visibility, + ~w(approximate_public hidden) + ) + + assert_geographic_profile!("help requests", profile.help_requests, request_count) + assert_geographic_profile!("activities", profile.activities, activity_count) + + assert_open_profile!( + "help requests", + profile.help_requests, + request_count, + @request_open_modulus + ) + + assert_open_profile!( + "activities", + profile.activities, + activity_count, + @activity_open_modulus + ) + end + + if profile.help_requests.privacy_invariant_violations != 0 do + Mix.raise( + "synthetic help requests contain " <> + "#{profile.help_requests.privacy_invariant_violations} privacy invariant violations" + ) + end + + if profile.activities.privacy_invariant_violations != 0 do + Mix.raise( + "synthetic activities contain " <> + "#{profile.activities.privacy_invariant_violations} privacy invariant violations" + ) + end + + profile + end + + defp assert_distribution_keys!(label, distribution, expected_keys) do + actual_keys = distribution |> Map.keys() |> Enum.sort() + expected_keys = Enum.sort(expected_keys) + + unless actual_keys == expected_keys do + Mix.raise("#{label} must contain #{inspect(expected_keys)}, found #{inspect(actual_keys)}") + end + end + + defp assert_geographic_profile!(label, profile, expected_count) do + unless profile.place_count == @place_count do + Mix.raise( + "#{label} must cover all #{@place_count} fixture places, " <> + "found #{profile.place_count}" + ) + end + + if profile.max_rows_in_one_place * 50 > expected_count do + Mix.raise( + "#{label} are too concentrated: one fixture place contains " <> + "#{profile.max_rows_in_one_place} of #{expected_count} rows" + ) + end + + if profile.located_rows > 0 and + profile.distinct_location_points * 100 < profile.located_rows * 98 do + Mix.raise( + "#{label} contain too many duplicate coordinates: " <> + "#{profile.distinct_location_points} distinct points for " <> + "#{profile.located_rows} located rows" + ) + end + + unless profile.distinct_public_marker_points == profile.located_rows do + Mix.raise( + "#{label} contain repeated public map coordinates: " <> + "#{profile.distinct_public_marker_points} distinct public markers for " <> + "#{profile.located_rows} located rows" + ) + end + end + + defp assert_open_profile!(label, profile, expected_count, open_modulus) do + expected_open_rows = div(expected_count, open_modulus) + observed_open_rows = Map.fetch!(profile.statuses, "open") + tolerance = max(div(expected_open_rows, 5), 20) + + if abs(observed_open_rows - expected_open_rows) > tolerance do + Mix.raise( + "#{label} open-row profile is outside the synthetic contract: " <> + "expected about #{expected_open_rows}, found #{observed_open_rows}" + ) + end + + if expected_count >= 100_000 do + max_public_open_rows = div(expected_count * 2, @place_count * open_modulus) + 1 + + if profile.max_public_open_rows_in_one_place > max_public_open_rows do + Mix.raise( + "#{label} public open rows are too dense in one fixture place: " <> + "#{profile.max_public_open_rows_in_one_place} exceeds " <> + "#{max_public_open_rows}" + ) + end + end + end + + defp fixture_profile do + %{ + fixture_version: @fixture_version, + fixture_places: @place_count, + help_requests: %{ + statuses: + distribution!(""" + SELECT status, count(*) + FROM help_requests + WHERE structured_data @> '{"scale_fixture": true}'::jsonb + GROUP BY status + ORDER BY status + """), + visibility: + distribution!(""" + SELECT location_visibility, count(*) + FROM help_requests + WHERE structured_data @> '{"scale_fixture": true}'::jsonb + GROUP BY location_visibility + ORDER BY location_visibility + """), + radii_meters: + distribution!(""" + SELECT coalesce(location_radius_meters::text, 'none'), count(*) + FROM help_requests + WHERE structured_data @> '{"scale_fixture": true}'::jsonb + GROUP BY location_radius_meters + ORDER BY location_radius_meters NULLS FIRST + """), + place_count: fixture_place_count!("help_requests"), + max_rows_in_one_place: max_rows_in_one_place!("help_requests"), + public_open_rows: + scalar!(""" + SELECT count(*) + FROM help_requests + WHERE structured_data @> '{"scale_fixture": true}'::jsonb + AND status = 'open' + AND expires_at > now() + AND location_visibility <> 'hidden' + """), + max_public_open_rows_in_one_place: max_public_open_rows_in_one_place!("help_requests"), + located_rows: + scalar!(""" + SELECT count(*) + FROM help_requests + WHERE structured_data @> '{"scale_fixture": true}'::jsonb + AND location IS NOT NULL + """), + distinct_location_points: + scalar!(""" + SELECT count(DISTINCT ST_AsBinary(location)) + FROM help_requests + WHERE structured_data @> '{"scale_fixture": true}'::jsonb + AND location IS NOT NULL + """), + distinct_public_marker_points: + scalar!(""" + SELECT count(DISTINCT ST_AsBinary(location)) + FROM help_requests + WHERE structured_data @> '{"scale_fixture": true}'::jsonb + AND location IS NOT NULL + """), + privacy_invariant_violations: + scalar!(""" + SELECT count(*) + FROM help_requests + WHERE structured_data @> '{"scale_fixture": true}'::jsonb + AND ( + (location_visibility = 'hidden' AND + (location IS NOT NULL OR location_radius_meters IS NOT NULL)) + OR + (location_visibility = 'exact_public' AND + (location IS NULL OR location_radius_meters IS NOT NULL)) + OR + (location_visibility IN ('approximate_public', 'exact_for_active_match') AND + (location IS NULL OR location_radius_meters NOT IN (500, 1000, 2000))) + ) + """) + }, + activities: %{ + statuses: + distribution!(""" + SELECT status, count(*) + FROM activities + WHERE structured_data @> '{"scale_fixture": true}'::jsonb + GROUP BY status + ORDER BY status + """), + visibility: + distribution!(""" + SELECT location_visibility, count(*) + FROM activities + WHERE structured_data @> '{"scale_fixture": true}'::jsonb + GROUP BY location_visibility + ORDER BY location_visibility + """), + place_count: fixture_place_count!("activities"), + max_rows_in_one_place: max_rows_in_one_place!("activities"), + public_open_rows: + scalar!(""" + SELECT count(*) + FROM activities + WHERE structured_data @> '{"scale_fixture": true}'::jsonb + AND status = 'open' + AND starts_at > now() + AND location_visibility = 'approximate_public' + """), + max_public_open_rows_in_one_place: max_public_open_rows_in_one_place!("activities"), + located_rows: + scalar!(""" + SELECT count(*) + FROM activities + WHERE structured_data @> '{"scale_fixture": true}'::jsonb + AND location IS NOT NULL + """), + distinct_location_points: + scalar!(""" + SELECT count(DISTINCT ST_AsBinary(location)) + FROM activities + WHERE structured_data @> '{"scale_fixture": true}'::jsonb + AND location IS NOT NULL + """), + distinct_public_marker_points: + scalar!(""" + SELECT count(DISTINCT ST_AsBinary(location)) + FROM activities + WHERE structured_data @> '{"scale_fixture": true}'::jsonb + AND location IS NOT NULL + """), + privacy_invariant_violations: + scalar!(""" + SELECT count(*) + FROM activities + WHERE structured_data @> '{"scale_fixture": true}'::jsonb + AND ( + location IS NULL + OR location_visibility NOT IN ('approximate_public', 'hidden') + ) + """) + } + } + end + + defp fixture_place_count!("help_requests") do + scalar!(""" + SELECT count(DISTINCT regexp_replace(location_label, ' synthetic area [0-9]+$', '')) + FROM help_requests + WHERE structured_data @> '{"scale_fixture": true}'::jsonb + """) + end + + defp fixture_place_count!("activities") do + scalar!(""" + SELECT count(DISTINCT regexp_replace(location_label, ' synthetic area [0-9]+$', '')) + FROM activities + WHERE structured_data @> '{"scale_fixture": true}'::jsonb + """) + end + + defp max_rows_in_one_place!("help_requests") do + scalar!(""" + SELECT coalesce(max(place_rows), 0) + FROM ( + SELECT count(*) AS place_rows + FROM help_requests + WHERE structured_data @> '{"scale_fixture": true}'::jsonb + GROUP BY regexp_replace(location_label, ' synthetic area [0-9]+$', '') + ) AS grouped_places + """) + end + + defp max_rows_in_one_place!("activities") do + scalar!(""" + SELECT coalesce(max(place_rows), 0) + FROM ( + SELECT count(*) AS place_rows + FROM activities + WHERE structured_data @> '{"scale_fixture": true}'::jsonb + GROUP BY regexp_replace(location_label, ' synthetic area [0-9]+$', '') + ) AS grouped_places + """) + end + + defp max_public_open_rows_in_one_place!("help_requests") do + scalar!(""" + SELECT coalesce(max(place_rows), 0) + FROM ( + SELECT count(*) AS place_rows + FROM help_requests + WHERE structured_data @> '{"scale_fixture": true}'::jsonb + AND status = 'open' + AND expires_at > now() + AND location_visibility <> 'hidden' + GROUP BY regexp_replace(location_label, ' synthetic area [0-9]+$', '') + ) AS grouped_places + """) + end + + defp max_public_open_rows_in_one_place!("activities") do + scalar!(""" + SELECT coalesce(max(place_rows), 0) + FROM ( + SELECT count(*) AS place_rows + FROM activities + WHERE structured_data @> '{"scale_fixture": true}'::jsonb + AND status = 'open' + AND starts_at > now() + AND location_visibility = 'approximate_public' + GROUP BY regexp_replace(location_label, ' synthetic area [0-9]+$', '') + ) AS grouped_places + """) + end + + defp distribution!(sql) do + Repo.query!(sql, [], timeout: :infinity, log: false).rows + |> Map.new(fn [value, count] -> {to_string(value), count} end) + end + defp discovery_counts do %{ help_requests: scalar!("SELECT count(*) FROM help_requests"), diff --git a/lib/who_need_help/activities.ex b/lib/who_need_help/activities.ex index 351b61e..1b34fbc 100644 --- a/lib/who_need_help/activities.ex +++ b/lib/who_need_help/activities.ex @@ -65,7 +65,21 @@ defmodule WhoNeedHelp.Activities do location_label: activity.location_label, public_location: fragment( - "ST_SetSRID(ST_MakePoint(round(ST_X(?)::numeric, 2)::double precision, round(ST_Y(?)::numeric, 2)::double precision), 4326)", + """ + CASE + WHEN ? @> '{"scale_fixture": true}'::jsonb THEN ? + ELSE + ST_SetSRID( + ST_MakePoint( + round(ST_X(?)::numeric, 2)::double precision, + round(ST_Y(?)::numeric, 2)::double precision + ), + 4326 + ) + END + """, + activity.structured_data, + activity.location, activity.location, activity.location ) @@ -849,16 +863,22 @@ defmodule WhoNeedHelp.Activities do fragment( """ ST_Intersects( - ST_SetSRID( - ST_MakePoint( - round(ST_X(?)::numeric, 2)::double precision, - round(ST_Y(?)::numeric, 2)::double precision - ), - 4326 - ), + CASE + WHEN ? @> '{"scale_fixture": true}'::jsonb THEN ? + ELSE + ST_SetSRID( + ST_MakePoint( + round(ST_X(?)::numeric, 2)::double precision, + round(ST_Y(?)::numeric, 2)::double precision + ), + 4326 + ) + END, ST_MakeEnvelope(?, ?, ?, ?, 4326) ) """, + activity.structured_data, + activity.location, activity.location, activity.location, ^west, diff --git a/lib/who_need_help/help.ex b/lib/who_need_help/help.ex index 85cf533..775d739 100644 --- a/lib/who_need_help/help.ex +++ b/lib/who_need_help/help.ex @@ -80,7 +80,22 @@ defmodule WhoNeedHelp.Help do urgency: request.urgency, public_location: fragment( - "CASE WHEN ? = 'exact_for_active_match' THEN ST_SetSRID(ST_MakePoint(round(ST_X(?)::numeric, 2)::double precision, round(ST_Y(?)::numeric, 2)::double precision), 4326) ELSE ? END", + """ + CASE + WHEN ? @> '{"scale_fixture": true}'::jsonb THEN ? + WHEN ? = 'exact_for_active_match' THEN + ST_SetSRID( + ST_MakePoint( + round(ST_X(?)::numeric, 2)::double precision, + round(ST_Y(?)::numeric, 2)::double precision + ), + 4326 + ) + ELSE ? + END + """, + request.structured_data, + request.location, request.location_visibility, request.location, request.location, @@ -172,6 +187,7 @@ defmodule WhoNeedHelp.Help do AND ST_Intersects( CASE + WHEN ? @> '{"scale_fixture": true}'::jsonb THEN ? WHEN ? = 'exact_for_active_match' THEN ST_SetSRID( ST_MakePoint( @@ -191,6 +207,8 @@ defmodule WhoNeedHelp.Help do ^(south - 0.005), ^(east + 0.005), ^(north + 0.005), + request.structured_data, + request.location, request.location_visibility, request.location, request.location, diff --git a/test/who_need_help/activities_test.exs b/test/who_need_help/activities_test.exs index 4bb0ac4..95f47f0 100644 --- a/test/who_need_help/activities_test.exs +++ b/test/who_need_help/activities_test.exs @@ -457,6 +457,42 @@ defmodule WhoNeedHelp.ActivitiesTest do end) end + test "scale fixture activities keep distinct synthetic public map coordinates", context do + activities = + for {latitude, index} <- [{"50.451", 1}, {"50.454", 2}] do + {:ok, activity} = + Activities.create_activity( + context.organizer_scope, + context.attrs + |> Map.put("title", "Synthetic distinct activity #{index}") + |> Map.put("latitude", latitude) + ) + + activity + |> Ecto.Changeset.change( + structured_data: Map.put(activity.structured_data, "scale_fixture", true) + ) + |> Repo.update!() + end + + items = + Activities.map_discovery_items( + context.outsider_scope, + %{}, + viewport!(30.51, 50.44, 30.54, 50.46, 16) + ) + + fixture_ids = MapSet.new(activities, & &1.id) + + fixture_items = + Enum.filter(items, fn + %{type: "activity", id: id} -> MapSet.member?(fixture_ids, id) + _item -> false + end) + + assert Enum.sort(Enum.map(fixture_items, & &1.latitude)) == [50.451, 50.454] + end + test "hidden activity is discoverable only through its public area description", context do {:ok, hidden} = Activities.create_activity( diff --git a/test/who_need_help/mutual_aid_flow_test.exs b/test/who_need_help/mutual_aid_flow_test.exs index c40caae..e8b7dba 100644 --- a/test/who_need_help/mutual_aid_flow_test.exs +++ b/test/who_need_help/mutual_aid_flow_test.exs @@ -603,6 +603,44 @@ defmodule WhoNeedHelp.MutualAidFlowTest do refute_in_delta item.longitude, 30.5267, 0.000_001 end + test "scale fixture requests keep distinct synthetic public map coordinates", context do + requests = + for {latitude, index} <- [{"50.451", 1}, {"50.454", 2}] do + {:ok, request} = + Help.create_request( + context.requester_scope, + context.request_attrs + |> Map.put("title", "Synthetic distinct request #{index}") + |> Map.put("latitude", latitude) + |> Map.put("location_visibility", "exact_for_active_match") + ) + + request + |> Ecto.Changeset.change( + structured_data: Map.put(request.structured_data, "scale_fixture", true) + ) + |> Repo.update!() + end + + items = + Help.map_discovery_items( + context.helper_scope, + %{}, + viewport!(30.51, 50.44, 30.54, 50.46, 16) + ) + + fixture_ids = MapSet.new(requests, & &1.id) + + fixture_items = + Enum.filter(items, fn + %{type: "request", id: id} -> MapSet.member?(fixture_ids, id) + _item -> false + end) + + assert Enum.sort(Enum.map(fixture_items, & &1.latitude)) == [50.451, 50.454] + assert Enum.all?(fixture_items, &(&1.exact == false)) + end + test "area search treats SQL wildcard characters as literal text", context do {:ok, literal_request} = Help.create_request(