diff --git a/.gitignore b/.gitignore index 97f0076..8adea7c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ postman/ .worktrees/ docs/superpowers/ .claude/ -!.claude/settings.json \ No newline at end of file +!.claude/settings.json +graphify-out/ diff --git a/README.md b/README.md index e013dfe..e48689d 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,17 @@ For a deeper tour, start at the per-package READMEs linked in the [Repository la **`Place`**: a physical location with coordinates, category, OSM metadata, and an optional parent (e.g. a shop inside a mall). External references (Wheelmap, future sources) attach as an `external_ids` JSONB map. See [`pkg/models`](./pkg/models). -**`AccessibilityProfile`**: attached to a place. Contains: -- `overall_status`: `accessible` | `limited` | `inaccessible` | `unknown` -- `components`: array of typed features (`entrance`, `restroom`, `parking`, `elevator`, `other`) +**`AccessibilityProfile`**: attached to a place. Named component fields hold measured facts: -Each component carries its own `overall_status`, raw property values (widths, heights, booleans), and machine-computed `audit_flags` (e.g. `"narrow width (0.8m required)"`). Child places inherit parent components for any type they don't provide themselves, so a shop inside a mall inherits the mall's parking. See [`internal/a11y`](./internal/a11y). +| Field | What it captures | +|---|---| +| `entrance` | Level access, ramp, door width, door type, intercom | +| `pathways` | Width, surface, kerb-free, steps | +| `restroom` | Accessible toilet, grab rails, door width, turning radius | +| `parking` | Disabled spaces, count, distance to entrance | +| `elevator` | Cabin dimensions, braille, audio | + +Each component carries typed property values and machine-computed `audit_flags` written on every save (e.g. `"entrance_narrow_width"`). `source_reports` stores raw opinions from external sources (e.g. OSM `wheelchair=yes`) verbatim — no server-side judgment is applied. Child places inherit parent components for any slot they don't provide themselves, so a shop inside a mall inherits the mall's parking. See [`internal/a11y`](./internal/a11y). **`ExternalRef`**: confidence-scored attachment of an external source's ID (Wheelmap etc.) to a Place. Produced by the [identity matcher](./internal/identity). @@ -92,15 +98,13 @@ flowchart LR Schema -- no --> R400[400 validation failed] Schema -- yes --> Struct{Structural
internal/validation} Struct -- errors --> R400 - Struct -- ok --> Engine[a11y engine] - Engine --> Conflicts{Hard conflicts?} - Conflicts -- yes --> R422[422 conflicts] - Conflicts -- no --> Persist[(persist + audit log)] + Struct -- ok --> Engine[a11y engine
computes audit flags] + Engine --> Persist[(persist + audit log)] ``` - **Spec validation** at the middleware layer catches type, format, and required-field errors from the OpenAPI spec. - **Structural validation** ([`internal/validation`](./internal/validation)) catches multi-field business validity: mutually exclusive query params, range checks, UUID format. -- **The a11y engine** ([`internal/a11y`](./internal/a11y)) computes audit flags from component properties, then detects *hard* conflicts. Submitting `overall_status: accessible` while also submitting `has_step: true, has_ramp: false` returns 422. Threshold flags (narrow width, missing braille) are stored but never block. +- **The a11y engine** ([`internal/a11y`](./internal/a11y)) computes `audit_flags` from component property values and writes them alongside the record. Flags are informational — no write is ever rejected on accessibility grounds. The API never decides accessibility. It records facts. @@ -149,7 +153,7 @@ See [`cmd/ingestion`](./cmd/ingestion) for the full pipeline diagrams. | [`cmd/api/`](./cmd/api) | REST API binary: routing, auth, OpenAPI strict server wiring | | [`cmd/ingestion/`](./cmd/ingestion) | Batch ingestion CLI: canonical and external pipelines, batcher, sweep wiring | | [`pkg/models/`](./pkg/models) | Domain types shared across binaries | -| [`internal/a11y/`](./internal/a11y) | Accessibility rule engine: audit flag computation, conflict detection, parent inheritance | +| [`internal/a11y/`](./internal/a11y) | Accessibility rule engine: audit flag computation, parent component inheritance | | [`internal/api/v1/`](./internal/api/v1) | Code-generated OpenAPI types and strict server interface (do not edit by hand) | | `internal/audit/` | Append-only write log of "who created/modified what" | | [`internal/db/`](./internal/db) | GORM connection, migration runner, PostGIS index setup | diff --git a/api/openapi.yaml b/api/openapi.yaml index e578483..b934247 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -24,13 +24,13 @@ components: # ── Enum helpers ────────────────────────────────────────────────────────── - A11yStatus: + DoorType: type: string - enum: [accessible, limited, inaccessible, unknown] + enum: [automatic, manual, revolving, none] - A11yComponentType: + SurfaceType: type: string - enum: [entrance, restroom, parking, elevator, other] + enum: [asphalt, paving_stones, cobblestone, gravel, concrete, wood, carpet, tiles] Category: type: string @@ -58,51 +58,118 @@ components: # x-go-type tells oapi-codegen "don't generate a new struct, use this one". # The spec schema still drives middleware validation; the Go type is imported. - EntranceProperties: - x-go-type: models.EntranceProperties + DoorProps: + x-go-type: models.DoorProps x-go-type-import: name: models path: github.com/InWheelOrg/inwheel-api/pkg/models type: object properties: + type: + $ref: "#/components/schemas/DoorType" width: type: number format: double minimum: 0 maximum: 10 - has_ramp: + + EntranceProps: + x-go-type: models.EntranceProps + x-go-type-import: + name: models + path: github.com/InWheelOrg/inwheel-api/pkg/models + type: object + properties: + is_level: type: boolean - is_automatic: + has_fixed_ramp: type: boolean - has_step: + has_removable_ramp: type: boolean - step_height: + slope_percent: type: number format: double minimum: 0 - maximum: 1 + maximum: 100 + width: + type: number + format: double + minimum: 0 + maximum: 10 + door: + $ref: "#/components/schemas/DoorProps" + has_intercom: + type: boolean + audit_flags: + type: array + items: + type: string + readOnly: true - RestroomProperties: - x-go-type: models.RestroomProperties + PathwayProps: + x-go-type: models.PathwayProps x-go-type-import: name: models path: github.com/InWheelOrg/inwheel-api/pkg/models type: object properties: - wheelchair_accessible: + width: + type: number + format: double + minimum: 0 + maximum: 20 + surface: + $ref: "#/components/schemas/SurfaceType" + is_kerbstone_free: type: boolean - grab_rails: + has_steps: type: boolean - changing_table: + audit_flags: + type: array + items: + type: string + readOnly: true + + RestroomProps: + x-go-type: models.RestroomProps + x-go-type-import: + name: models + path: github.com/InWheelOrg/inwheel-api/pkg/models + type: object + properties: + is_accessible: type: boolean door_width: type: number format: double minimum: 0 maximum: 10 + turning_radius: + type: number + format: double + minimum: 0 + maximum: 10 + has_grab_rails: + type: boolean + has_roll_in_shower: + type: boolean + toilet_seat_height: + type: number + format: double + minimum: 0 + maximum: 1 + has_emergency_pull: + type: boolean + has_changing_table: + type: boolean + audit_flags: + type: array + items: + type: string + readOnly: true - ParkingProperties: - x-go-type: models.ParkingProperties + ParkingProps: + x-go-type: models.ParkingProps x-go-type-import: name: models path: github.com/InWheelOrg/inwheel-api/pkg/models @@ -114,9 +181,26 @@ components: type: integer minimum: 0 maximum: 10000 + distance_to_entrance: + type: number + format: double + minimum: 0 + maximum: 10000 + width: + type: number + format: double + minimum: 0 + maximum: 20 + has_dedicated_signage: + type: boolean + audit_flags: + type: array + items: + type: string + readOnly: true - ElevatorProperties: - x-go-type: models.ElevatorProperties + ElevatorProps: + x-go-type: models.ElevatorProps x-go-type-import: name: models path: github.com/InWheelOrg/inwheel-api/pkg/models @@ -132,43 +216,34 @@ components: format: double minimum: 0 maximum: 10 - braille: + door_width: + type: number + format: double + minimum: 0 + maximum: 10 + has_braille: type: boolean - audio: + has_audio: type: boolean - - A11yComponent: - x-go-type: models.A11yComponent - x-go-type-import: - name: models - path: github.com/InWheelOrg/inwheel-api/pkg/models - type: object - required: [type] - properties: - type: - $ref: "#/components/schemas/A11yComponentType" - is_inherited: - type: boolean - source_id: - type: string - format: uuid - overall_status: - $ref: "#/components/schemas/A11yStatus" audit_flags: type: array items: type: string - entrance: - $ref: "#/components/schemas/EntranceProperties" - restroom: - $ref: "#/components/schemas/RestroomProperties" - parking: - $ref: "#/components/schemas/ParkingProperties" - elevator: - $ref: "#/components/schemas/ElevatorProperties" - metadata: - type: object - additionalProperties: true + readOnly: true + + SourceReport: + type: object + required: [source, value, recorded_at] + properties: + source: + type: string + example: "osm" + value: + type: string + example: "yes" + recorded_at: + type: string + format: date-time AccessibilityProfile: x-go-type: models.AccessibilityProfile @@ -176,7 +251,6 @@ components: name: models path: github.com/InWheelOrg/inwheel-api/pkg/models type: object - required: [overall_status] properties: id: type: string @@ -186,12 +260,20 @@ components: type: string format: uuid readOnly: true - overall_status: - $ref: "#/components/schemas/A11yStatus" - components: + source_reports: type: array items: - $ref: "#/components/schemas/A11yComponent" + $ref: "#/components/schemas/SourceReport" + entrance: + $ref: "#/components/schemas/EntranceProps" + pathways: + $ref: "#/components/schemas/PathwayProps" + restroom: + $ref: "#/components/schemas/RestroomProps" + parking: + $ref: "#/components/schemas/ParkingProps" + elevator: + $ref: "#/components/schemas/ElevatorProps" updated_at: type: string format: date-time @@ -331,27 +413,6 @@ components: items: $ref: "#/components/schemas/FieldError" - ConflictItem: - type: object - required: [component, reason] - properties: - component: - type: string - reason: - type: string - - ConflictError: - type: object - required: [error, conflicts] - properties: - error: - type: string - example: "accessibility data contains conflicts" - conflicts: - type: array - items: - $ref: "#/components/schemas/ConflictItem" - ErrorResponse: type: object required: [error] @@ -504,12 +565,6 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorResponse" - "422": - description: Accessibility data contains conflicts - content: - application/json: - schema: - $ref: "#/components/schemas/ConflictError" /places/{id}: get: @@ -553,8 +608,7 @@ paths: summary: Update or create an accessibility profile description: | Upserts the accessibility profile for the given place. - Audit flags are computed from submitted component properties. - Returns 422 if any component's status directly contradicts its audit flags. + Audit flags are computed server-side from submitted property values. security: - ApiKeyAuth: [] parameters: @@ -595,12 +649,6 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorResponse" - "422": - description: Accessibility data contains conflicts - content: - application/json: - schema: - $ref: "#/components/schemas/ConflictError" # ── Auth ───────────────────────────────────────────────────────────────── diff --git a/cmd/api/auth_integration_test.go b/cmd/api/auth_integration_test.go index f9b548e..dd5bdbe 100644 --- a/cmd/api/auth_integration_test.go +++ b/cmd/api/auth_integration_test.go @@ -246,7 +246,7 @@ func TestHandlePatchAccessibility_WithValidKey(t *testing.T) { rawKey := registerKey(t, srv, "patcher@example.com") - profile := models.AccessibilityProfile{OverallStatus: models.StatusAccessible} + profile := models.AccessibilityProfile{Entrance: &models.EntranceProps{IsLevel: boolPtr(true)}} body, _ := json.Marshal(profile) r := httptest.NewRequest(http.MethodPatch, "/v1/places/"+place.ID+"/accessibility", bytes.NewReader(body)) r.Header.Set("Content-Type", "application/json") diff --git a/cmd/api/integration_test.go b/cmd/api/integration_test.go index caaf609..5d41326 100644 --- a/cmd/api/integration_test.go +++ b/cmd/api/integration_test.go @@ -35,7 +35,6 @@ func TestMain(m *testing.M) { os.Exit(run(m)) } -// run is extracted from TestMain so defer-based cleanup executes before os.Exit. func run(m *testing.M) int { ctx := context.Background() var cleanup func() @@ -50,12 +49,13 @@ func run(m *testing.M) int { return m.Run() } -// truncate clears all test data between tests to prevent state bleed. func truncate(t *testing.T) { t.Helper() testDB.Exec("TRUNCATE places, accessibility_profiles, api_keys, write_logs CASCADE") } +func boolPtr(b bool) *bool { return &b } + func newTestServer(t *testing.T) *Server { t.Helper() ctx := t.Context() @@ -79,7 +79,7 @@ func TestHandlePostPlace_WithAccessibility(t *testing.T) { Rank: models.RankEstablishment, Source: "test", Accessibility: &models.AccessibilityProfile{ - OverallStatus: models.StatusAccessible, + Entrance: &models.EntranceProps{IsLevel: boolPtr(true)}, }, }) @@ -129,64 +129,10 @@ func TestHandlePostPlace_WithoutAccessibility(t *testing.T) { } } -func TestHandlePostPlace_HardConflictReturns422(t *testing.T) { - t.Cleanup(func() { truncate(t) }) - - // step with no ramp + accessible = hard self-contradiction - stepHeight := 0.1 - hasStep := true - hasRamp := false - body, _ := json.Marshal(models.Place{ - Name: "Conflicting Cafe", - Lat: 52.5, - Lng: 13.4, - Category: models.CategoryCafe, - Rank: models.RankEstablishment, - Source: "test", - Accessibility: &models.AccessibilityProfile{ - OverallStatus: models.StatusAccessible, - Components: models.A11yComponents{ - { - Type: models.ComponentEntrance, - OverallStatus: models.StatusAccessible, - Entrance: &models.EntranceProperties{ - HasStep: &hasStep, - StepHeight: &stepHeight, - HasRamp: &hasRamp, - }, - }, - }, - }, - }) - - r := httptest.NewRequest(http.MethodPost, "/v1/places", bytes.NewReader(body)) - r.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handlerNoAuth(t, newTestServer(t)).ServeHTTP(w, r) - - if w.Code != http.StatusUnprocessableEntity { - t.Fatalf("status = %d, want 422; body: %s", w.Code, w.Body.String()) - } - - var resp struct { - Error string `json:"error"` - Conflicts []struct { - Component string `json:"component"` - Reason string `json:"reason"` - } `json:"conflicts"` - } - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("decode 422 response: %v", err) - } - if len(resp.Conflicts) == 0 { - t.Error("expected conflicts in 422 response body") - } -} - func TestHandlePostPlace_InformationalFlagsAllowed(t *testing.T) { t.Cleanup(func() { truncate(t) }) - // narrow width is informational only — should not block the write + // narrow width sets an audit flag but must not block the write narrowWidth := 0.75 body, _ := json.Marshal(models.Place{ Name: "Narrow Cafe", @@ -196,14 +142,7 @@ func TestHandlePostPlace_InformationalFlagsAllowed(t *testing.T) { Rank: models.RankEstablishment, Source: "test", Accessibility: &models.AccessibilityProfile{ - OverallStatus: models.StatusAccessible, - Components: models.A11yComponents{ - { - Type: models.ComponentEntrance, - OverallStatus: models.StatusAccessible, - Entrance: &models.EntranceProperties{Width: &narrowWidth}, - }, - }, + Entrance: &models.EntranceProps{Width: &narrowWidth}, }, }) @@ -216,21 +155,19 @@ func TestHandlePostPlace_InformationalFlagsAllowed(t *testing.T) { t.Fatalf("status = %d, want 201; body: %s", w.Code, w.Body.String()) } - // Verify the flag was stored var place models.Place testDB.Preload("Accessibility").Last(&place) - if place.Accessibility == nil || len(place.Accessibility.Components) == 0 { - t.Fatal("expected accessibility with components") + if place.Accessibility == nil || place.Accessibility.Entrance == nil { + t.Fatal("expected accessibility with entrance component") } - flags := place.Accessibility.Components[0].AuditFlags found := false - for _, f := range flags { + for _, f := range place.Accessibility.Entrance.AuditFlags { if f == "narrow width (0.8m required)" { found = true } } if !found { - t.Errorf("expected narrow width flag to be stored, got flags: %v", flags) + t.Errorf("expected narrow width flag to be stored, got flags: %v", place.Accessibility.Entrance.AuditFlags) } } @@ -238,7 +175,7 @@ func TestHandlePatchAccessibility_PlaceNotFound(t *testing.T) { t.Cleanup(func() { truncate(t) }) const nonExistentID = "00000000-0000-0000-0000-000000000000" - body, _ := json.Marshal(models.AccessibilityProfile{OverallStatus: models.StatusAccessible}) + body, _ := json.Marshal(models.AccessibilityProfile{Entrance: &models.EntranceProps{IsLevel: boolPtr(true)}}) r := httptest.NewRequest(http.MethodPatch, "/v1/places/"+nonExistentID+"/accessibility", bytes.NewReader(body)) r.Header.Set("Content-Type", "application/json") @@ -258,7 +195,7 @@ func TestHandlePatchAccessibility_CreatePath(t *testing.T) { testDB.Create(&place) body, _ := json.Marshal(models.AccessibilityProfile{ - OverallStatus: models.StatusLimited, + Entrance: &models.EntranceProps{IsLevel: boolPtr(false)}, }) r := httptest.NewRequest(http.MethodPatch, "/v1/places/"+place.ID+"/accessibility", bytes.NewReader(body)) @@ -277,8 +214,8 @@ func TestHandlePatchAccessibility_CreatePath(t *testing.T) { if profile.PlaceID != place.ID { t.Errorf("PlaceID = %s, want %s", profile.PlaceID, place.ID) } - if profile.OverallStatus != models.StatusLimited { - t.Errorf("OverallStatus = %s, want limited", profile.OverallStatus) + if profile.Entrance == nil || profile.Entrance.IsLevel == nil || *profile.Entrance.IsLevel { + t.Error("expected Entrance.IsLevel=false") } } @@ -293,13 +230,13 @@ func TestHandlePatchAccessibility_UpdatesExistingProfile(t *testing.T) { Rank: models.RankEstablishment, Source: "test", Accessibility: &models.AccessibilityProfile{ - OverallStatus: models.StatusAccessible, + Entrance: &models.EntranceProps{IsLevel: boolPtr(true)}, }, } testDB.Create(&place) body, _ := json.Marshal(models.AccessibilityProfile{ - OverallStatus: models.StatusLimited, + Entrance: &models.EntranceProps{IsLevel: boolPtr(false)}, }) r := httptest.NewRequest(http.MethodPatch, "/v1/places/"+place.ID+"/accessibility", bytes.NewReader(body)) @@ -315,54 +252,8 @@ func TestHandlePatchAccessibility_UpdatesExistingProfile(t *testing.T) { var profile models.AccessibilityProfile testDB.Where("place_id = ?", place.ID).First(&profile) - if profile.OverallStatus != models.StatusLimited { - t.Errorf("OverallStatus = %s, want limited", profile.OverallStatus) - } -} - -func TestHandlePatchAccessibility_ConflictReturns422(t *testing.T) { - t.Cleanup(func() { truncate(t) }) - - place := models.Place{Name: "Test Place", Lat: 52.5, Lng: 13.4, Category: models.CategoryCafe, Rank: models.RankEstablishment, Source: "test"} - testDB.Create(&place) - - stepHeight := 0.1 - hasStep := true - hasRamp := false - body, _ := json.Marshal(models.AccessibilityProfile{ - OverallStatus: models.StatusAccessible, - Components: models.A11yComponents{ - { - Type: models.ComponentEntrance, - OverallStatus: models.StatusAccessible, - Entrance: &models.EntranceProperties{ - HasStep: &hasStep, - StepHeight: &stepHeight, - HasRamp: &hasRamp, - }, - }, - }, - }) - - r := httptest.NewRequest(http.MethodPatch, "/v1/places/"+place.ID+"/accessibility", bytes.NewReader(body)) - r.Header.Set("Content-Type", "application/json") - r.SetPathValue("id", place.ID) - w := httptest.NewRecorder() - handlerNoAuth(t, newTestServer(t)).ServeHTTP(w, r) - - if w.Code != http.StatusUnprocessableEntity { - t.Fatalf("status = %d, want 422; body: %s", w.Code, w.Body.String()) - } - - var resp struct { - Error string `json:"error"` - Conflicts []any `json:"conflicts"` - } - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("decode 422 response: %v", err) - } - if len(resp.Conflicts) == 0 { - t.Error("expected conflicts in 422 response body") + if profile.Entrance == nil || profile.Entrance.IsLevel == nil || *profile.Entrance.IsLevel { + t.Error("expected Entrance.IsLevel=false after update") } } @@ -377,7 +268,7 @@ func TestHandleGetPlace_ReturnsPlaceWithAccessibility(t *testing.T) { Rank: models.RankEstablishment, Source: "test", Accessibility: &models.AccessibilityProfile{ - OverallStatus: models.StatusAccessible, + Entrance: &models.EntranceProps{IsLevel: boolPtr(true)}, }, } testDB.Create(&place) @@ -417,7 +308,6 @@ func TestHandleGetPlace_NotFound(t *testing.T) { func TestHandleGetPlace_InheritsParentComponents(t *testing.T) { t.Cleanup(func() { truncate(t) }) - hasSpaces := true parent := models.Place{ Name: "Test Mall", Lat: 52.5, @@ -426,10 +316,7 @@ func TestHandleGetPlace_InheritsParentComponents(t *testing.T) { Rank: models.RankEstablishment, Source: "test", Accessibility: &models.AccessibilityProfile{ - OverallStatus: models.StatusAccessible, - Components: models.A11yComponents{ - {Type: models.ComponentParking, OverallStatus: models.StatusAccessible, Parking: &models.ParkingProperties{HasDisabledSpaces: &hasSpaces}}, - }, + Parking: &models.ParkingProps{HasDisabledSpaces: boolPtr(true)}, }, } testDB.Create(&parent) @@ -461,22 +348,14 @@ func TestHandleGetPlace_InheritsParentComponents(t *testing.T) { if got.Accessibility == nil { t.Fatal("expected accessibility profile in response") } - - var inherited *models.A11yComponent - for i := range got.Accessibility.Components { - if got.Accessibility.Components[i].Type == models.ComponentParking { - inherited = &got.Accessibility.Components[i] - break - } - } - if inherited == nil { - t.Fatal("expected inherited parking component in effective profile") + if got.Accessibility.Parking == nil { + t.Fatal("expected inherited parking in effective profile") } - if !inherited.IsInherited { - t.Error("parking component should be marked is_inherited=true") + if !got.Accessibility.Parking.IsInherited { + t.Error("parking should be marked is_inherited=true") } - if inherited.SourceID != parent.ID { - t.Errorf("source_id = %q, want %q", inherited.SourceID, parent.ID) + if got.Accessibility.Parking.SourceID != parent.ID { + t.Errorf("source_id = %q, want %q", got.Accessibility.Parking.SourceID, parent.ID) } } @@ -491,10 +370,7 @@ func TestHandleGetPlace_ChildOverridesParentComponent(t *testing.T) { Rank: models.RankEstablishment, Source: "test", Accessibility: &models.AccessibilityProfile{ - OverallStatus: models.StatusAccessible, - Components: models.A11yComponents{ - {Type: models.ComponentEntrance, OverallStatus: models.StatusAccessible}, - }, + Entrance: &models.EntranceProps{IsLevel: boolPtr(true)}, }, } testDB.Create(&parent) @@ -508,10 +384,7 @@ func TestHandleGetPlace_ChildOverridesParentComponent(t *testing.T) { Source: "test", ParentID: &parent.ID, Accessibility: &models.AccessibilityProfile{ - OverallStatus: models.StatusInaccessible, - Components: models.A11yComponents{ - {Type: models.ComponentEntrance, OverallStatus: models.StatusInaccessible}, - }, + Entrance: &models.EntranceProps{IsLevel: boolPtr(false)}, }, } testDB.Create(&child) @@ -529,24 +402,14 @@ func TestHandleGetPlace_ChildOverridesParentComponent(t *testing.T) { if err := json.NewDecoder(w.Body).Decode(&got); err != nil { t.Fatalf("decode response: %v", err) } - if got.Accessibility == nil { - t.Fatal("expected accessibility profile in response") + if got.Accessibility == nil || got.Accessibility.Entrance == nil { + t.Fatal("expected entrance in response") } - - var entranceCount int - for _, c := range got.Accessibility.Components { - if c.Type == models.ComponentEntrance { - entranceCount++ - if c.IsInherited { - t.Error("entrance should not be inherited — child owns it") - } - if c.OverallStatus != models.StatusInaccessible { - t.Errorf("entrance status = %q, want inaccessible", c.OverallStatus) - } - } + if got.Accessibility.Entrance.IsInherited { + t.Error("entrance should not be inherited; child owns it") } - if entranceCount != 1 { - t.Errorf("expected exactly 1 entrance component, got %d", entranceCount) + if got.Accessibility.Entrance.IsLevel == nil || *got.Accessibility.Entrance.IsLevel { + t.Error("expected child's is_level=false to override parent's is_level=true") } } @@ -561,10 +424,7 @@ func TestHandleGetPlace_NoParentReturnsRawData(t *testing.T) { Rank: models.RankEstablishment, Source: "test", Accessibility: &models.AccessibilityProfile{ - OverallStatus: models.StatusAccessible, - Components: models.A11yComponents{ - {Type: models.ComponentEntrance, OverallStatus: models.StatusAccessible}, - }, + Entrance: &models.EntranceProps{IsLevel: boolPtr(true)}, }, } testDB.Create(&place) @@ -582,14 +442,11 @@ func TestHandleGetPlace_NoParentReturnsRawData(t *testing.T) { if err := json.NewDecoder(w.Body).Decode(&got); err != nil { t.Fatalf("decode response: %v", err) } - if got.Accessibility == nil { - t.Fatal("expected accessibility profile") - } - if len(got.Accessibility.Components) != 1 { - t.Errorf("expected 1 component, got %d", len(got.Accessibility.Components)) + if got.Accessibility == nil || got.Accessibility.Entrance == nil { + t.Fatal("expected entrance in accessibility profile") } - if got.Accessibility.Components[0].IsInherited { - t.Error("component should not be inherited for place with no parent") + if got.Accessibility.Entrance.IsInherited { + t.Error("entrance should not be inherited for place with no parent") } } @@ -692,7 +549,7 @@ func TestHandlePatchAccessibility_UserVerifiedDefaultsFalse(t *testing.T) { place := models.Place{Name: "Test Cafe", Lat: 52.5, Lng: 13.4, Category: models.CategoryCafe, Rank: models.RankEstablishment, Source: "test"} testDB.Create(&place) - body, _ := json.Marshal(models.AccessibilityProfile{OverallStatus: models.StatusAccessible}) + body, _ := json.Marshal(models.AccessibilityProfile{Entrance: &models.EntranceProps{IsLevel: boolPtr(true)}}) r := httptest.NewRequest(http.MethodPatch, "/v1/places/"+place.ID+"/accessibility", bytes.NewReader(body)) r.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() diff --git a/cmd/api/main.go b/cmd/api/main.go index e9c6df7..046b64c 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -257,9 +257,6 @@ func (s *Server) CreatePlace(ctx context.Context, request apiv1.CreatePlaceReque } place.Accessibility.SubmittedAt = &now s.engine.WithAuditFlags(place.Accessibility) - if conflicts := s.engine.DetectConflicts(place.Accessibility); len(conflicts) > 0 { - return apiv1.CreatePlace422JSONResponse(conflictError(conflicts)), nil - } } if err := s.db.Create(&place).Error; err != nil { @@ -303,9 +300,6 @@ func (s *Server) PatchPlaceAccessibility(ctx context.Context, request apiv1.Patc keyID := middleware.APIKeyIDFromCtx(ctx) s.engine.WithAuditFlags(&input) - if conflicts := s.engine.DetectConflicts(&input); len(conflicts) > 0 { - return apiv1.PatchPlaceAccessibility422JSONResponse(conflictError(conflicts)), nil - } now := time.Now() if keyID != "" { @@ -361,14 +355,6 @@ func validationError(errs []validation.FieldError) apiv1.ValidationError { return apiv1.ValidationError{Error: "validation failed", Fields: fields} } -func conflictError(conflicts []a11y.Conflict) apiv1.ConflictError { - items := make([]apiv1.ConflictItem, len(conflicts)) - for i, c := range conflicts { - items[i] = apiv1.ConflictItem{Component: string(c.Component), Reason: c.Reason} - } - return apiv1.ConflictError{Error: "accessibility data contains conflicts", Conflicts: items} -} - func writeJSON(w http.ResponseWriter, data any, code int) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) diff --git a/cmd/api/places_integration_test.go b/cmd/api/places_integration_test.go index b0aca62..c770537 100644 --- a/cmd/api/places_integration_test.go +++ b/cmd/api/places_integration_test.go @@ -335,7 +335,7 @@ func TestHandleGetPlaces_PreloadsAccessibility(t *testing.T) { Rank: models.RankEstablishment, Source: "test", Accessibility: &models.AccessibilityProfile{ - OverallStatus: models.StatusAccessible, + Entrance: &models.EntranceProps{IsLevel: boolPtr(true)}, }, } testDB.Create(&p) diff --git a/cmd/ingestion/batcher.go b/cmd/ingestion/batcher.go index c1de556..726b6ac 100644 --- a/cmd/ingestion/batcher.go +++ b/cmd/ingestion/batcher.go @@ -7,7 +7,6 @@ package main import ( "context" - "fmt" "github.com/InWheelOrg/inwheel-api/pkg/models" ) @@ -15,17 +14,15 @@ import ( // batcher buffers (place, profile) pairs, writes places first via flush, then // writes profiles using the UUIDs returned by flush. Single-goroutine. type batcher struct { - size int - flush func(context.Context, []models.Place) error - writeProfile func(context.Context, string, *models.AccessibilityProfile) (bool, error) - downgradeProfile func(*models.AccessibilityProfile) int + size int + flush func(context.Context, []models.Place) error + writeProfile func(context.Context, string, *models.AccessibilityProfile) (bool, error) - buffer []models.Place - pendingProfiles []*models.AccessibilityProfile - written int - touchedIDs []string - profilesWritten int - profilesDowngraded int + buffer []models.Place + pendingProfiles []*models.AccessibilityProfile + written int + touchedIDs []string + profilesWritten int } func (b *batcher) sink(ctx context.Context, p models.Place, profile *models.AccessibilityProfile) error { @@ -41,9 +38,6 @@ func (b *batcher) flushNow(ctx context.Context) error { if len(b.buffer) == 0 { return nil } - if b.writeProfile != nil && b.downgradeProfile == nil { - return fmt.Errorf("batcher: writeProfile set without downgradeProfile") - } if err := b.flush(ctx, b.buffer); err != nil { return err } @@ -57,9 +51,6 @@ func (b *batcher) flushNow(ctx context.Context) error { if profile == nil || b.writeProfile == nil { continue } - if b.downgradeProfile != nil { - b.profilesDowngraded += b.downgradeProfile(profile) - } ok, err := b.writeProfile(ctx, p.ID, profile) if err != nil { return err diff --git a/cmd/ingestion/batcher_test.go b/cmd/ingestion/batcher_test.go index 03d73b1..c09c2fd 100644 --- a/cmd/ingestion/batcher_test.go +++ b/cmd/ingestion/batcher_test.go @@ -161,13 +161,12 @@ func TestBatcher_WritesProfileWhenAttached(t *testing.T) { captured = append(captured, capturedProfile{placeID: placeID, profile: p}) return true, nil }, - downgradeProfile: func(_ *models.AccessibilityProfile) int { return 0 }, } if err := b.sink(ctx, models.Place{Name: "no-a11y"}, nil); err != nil { t.Fatalf("sink p1: %v", err) } - profile := &models.AccessibilityProfile{OverallStatus: models.StatusAccessible} + profile := &models.AccessibilityProfile{} if err := b.sink(ctx, models.Place{Name: "with-a11y"}, profile); err != nil { t.Fatalf("sink p2: %v", err) } @@ -185,33 +184,6 @@ func TestBatcher_WritesProfileWhenAttached(t *testing.T) { } } -func TestBatcher_DowngradeCounter(t *testing.T) { - ctx := context.Background() - b := &batcher{ - size: 10, - flush: func(_ context.Context, ps []models.Place) error { - for i := range ps { - ps[i].ID = fmt.Sprintf("place-%d", i) - } - return nil - }, - writeProfile: func(_ context.Context, _ string, _ *models.AccessibilityProfile) (bool, error) { - return true, nil - }, - downgradeProfile: func(_ *models.AccessibilityProfile) int { return 2 }, - } - profile := &models.AccessibilityProfile{OverallStatus: models.StatusAccessible} - if err := b.sink(ctx, models.Place{Name: "p"}, profile); err != nil { - t.Fatalf("sink: %v", err) - } - if err := b.flushNow(ctx); err != nil { - t.Fatalf("flushNow: %v", err) - } - if b.profilesDowngraded != 2 { - t.Errorf("profilesDowngraded = %d, want 2", b.profilesDowngraded) - } -} - func TestBatcher_PlaceHasNoAccessibilityFieldInFlush(t *testing.T) { ctx := context.Background() var batchSeen []models.Place @@ -228,9 +200,8 @@ func TestBatcher_PlaceHasNoAccessibilityFieldInFlush(t *testing.T) { writeProfile: func(_ context.Context, _ string, _ *models.AccessibilityProfile) (bool, error) { return true, nil }, - downgradeProfile: func(_ *models.AccessibilityProfile) int { return 0 }, } - profile := &models.AccessibilityProfile{OverallStatus: models.StatusAccessible} + profile := &models.AccessibilityProfile{} if err := b.sink(ctx, models.Place{Name: "with-a11y"}, profile); err != nil { t.Fatalf("sink: %v", err) } @@ -245,30 +216,6 @@ func TestBatcher_PlaceHasNoAccessibilityFieldInFlush(t *testing.T) { } } -func TestBatcher_FlushNow_ErrorWhenWriteProfileWithoutDowngrade(t *testing.T) { - ctx := context.Background() - b := &batcher{ - size: 10, - flush: func(_ context.Context, ps []models.Place) error { - for i := range ps { - ps[i].ID = fmt.Sprintf("place-%d", i) - } - return nil - }, - writeProfile: func(_ context.Context, _ string, _ *models.AccessibilityProfile) (bool, error) { - return true, nil - }, - } - profile := &models.AccessibilityProfile{OverallStatus: models.StatusAccessible} - if err := b.sink(ctx, models.Place{Name: "p"}, profile); err != nil { - t.Fatalf("sink: %v", err) - } - err := b.flushNow(ctx) - if err == nil { - t.Fatal("expected error, got nil") - } -} - type capturedProfile struct { placeID string profile *models.AccessibilityProfile diff --git a/cmd/ingestion/main.go b/cmd/ingestion/main.go index 1815d31..1429918 100644 --- a/cmd/ingestion/main.go +++ b/cmd/ingestion/main.go @@ -92,7 +92,6 @@ func run(ctx context.Context, sourceName, command string, cfg config) error { return runPipeline(ctx, src, command, gormDB) } -// runPipeline routes src to its pipeline based on Kind. func runPipeline(ctx context.Context, src sources.Source, command string, gormDB *gorm.DB) error { switch src.Kind() { case sources.SourceKindCanonical: @@ -104,8 +103,6 @@ func runPipeline(ctx context.Context, src sources.Source, command string, gormDB } } -// runCanonical drives a canonical source through the batched upsert path, -// then runs the retry sweep against the IDs of places the batcher touched. func runCanonical(ctx context.Context, src sources.Source, command string, gormDB *gorm.DB) error { placesRepo := place.NewRepository(gormDB) unmatchedRepo := unmatched.NewRepository(gormDB) @@ -115,11 +112,9 @@ func runCanonical(ctx context.Context, src sources.Source, command string, gormD size: batchSize, flush: placesRepo.UpsertBatch, writeProfile: func(ctx context.Context, placeID string, p *models.AccessibilityProfile) (bool, error) { + engine.WithAuditFlags(p) return placesRepo.UpsertProfileIngestion(ctx, placeID, p) }, - downgradeProfile: func(p *models.AccessibilityProfile) int { - return resolveIngestionConflicts(engine, p) - }, } if err := dispatchCanonical(ctx, src, command, b.sink); err != nil { return fmt.Errorf("source %q: %w", src.Name(), err) @@ -148,7 +143,6 @@ func runCanonical(ctx context.Context, src sources.Source, command string, gormD "command", command, "written", b.written, "profiles_written", b.profilesWritten, - "profiles_downgraded", b.profilesDowngraded, "sweep_failed", sweepErr != nil, } if sweepErr == nil { @@ -164,8 +158,6 @@ func runCanonical(ctx context.Context, src sources.Source, command string, gormD return nil } -// runExternal drives an external source through identity.Resolver, attaching -// matched external refs and queueing the rest. func runExternal(ctx context.Context, src sources.Source, command string, gormDB *gorm.DB) error { placesRepo := place.NewRepository(gormDB) unmatchedRepo := unmatched.NewRepository(gormDB) @@ -229,34 +221,6 @@ func dispatchExternal(ctx context.Context, src sources.Source, command string, s } } -// resolveIngestionConflicts applies audit flags and demotes conflicting components -// from accessible to limited. -func resolveIngestionConflicts(engine *a11y.Engine, profile *models.AccessibilityProfile) int { - if profile == nil { - return 0 - } - engine.WithAuditFlags(profile) - conflicts := engine.DetectConflicts(profile) - if len(conflicts) == 0 { - return 0 - } - conflicted := make(map[models.A11yComponentType]bool, len(conflicts)) - for _, c := range conflicts { - conflicted[c.Component] = true - } - downgraded := 0 - for i := range profile.Components { - c := &profile.Components[i] - if conflicted[c.Type] && c.OverallStatus == models.StatusAccessible { - c.OverallStatus = models.StatusLimited - downgraded++ - slog.Info("ingestion downgraded component", "component", c.Type, "flags", c.AuditFlags) - } - } - return downgraded -} - -// resolveCounters tallies external-source outcomes for the run summary. type resolveCounters struct { confident int lowConfidence int diff --git a/cmd/ingestion/main_integration_test.go b/cmd/ingestion/main_integration_test.go index 7651476..56060dd 100644 --- a/cmd/ingestion/main_integration_test.go +++ b/cmd/ingestion/main_integration_test.go @@ -12,6 +12,7 @@ import ( "fmt" "math" "testing" + "time" "github.com/InWheelOrg/inwheel-api/internal/place" "github.com/InWheelOrg/inwheel-api/internal/sources" @@ -27,7 +28,7 @@ const fixturePBFPath = "../../testdata/andorra-sample.osm.pbf" const expectedPOICount = 976 // pinned is a known POI from the Andorra fixture used to verify that the -// transform → upsert pipeline produces the expected place row. +// transform -> upsert pipeline produces the expected place row. type pinned struct { osmID int64 name string @@ -71,7 +72,7 @@ var pinnedPOIs = []pinned{ osmID: 323129883, name: "Telecabina La Massana", category: models.CategoryTransport, - rank: models.RankLandmark, // public_transport=station promotes transport to landmark + rank: models.RankLandmark, lat: 42.547295, lng: 1.513858, tagSubset: map[string]string{ @@ -184,8 +185,6 @@ func TestRunCanonical_WritesAccessibilityProfiles(t *testing.T) { } defer cleanup() - hasStep := true - hasRamp := false src := &fakeCanonicalSource{ emit: []fakeEmit{ { @@ -199,29 +198,14 @@ func TestRunCanonical_WritesAccessibilityProfiles(t *testing.T) { }, { place: models.Place{ - OSMID: 1002, OSMType: models.OSMNode, Name: "Accessible", + OSMID: 1002, OSMType: models.OSMNode, Name: "With Source Report", Lat: 46.4621, Lng: 6.8401, Category: models.CategoryCafe, Rank: models.RankEstablishment, Status: models.PlaceStatusActive, ExternalIDs: models.ExternalIDs{"osm": {ID: "node/1002", Confidence: 1.0}}, }, - profile: &models.AccessibilityProfile{OverallStatus: models.StatusAccessible}, - }, - { - place: models.Place{ - OSMID: 1003, OSMType: models.OSMNode, Name: "Hard Conflict", - Lat: 46.4622, Lng: 6.8402, Category: models.CategoryCafe, - Rank: models.RankEstablishment, - Status: models.PlaceStatusActive, - ExternalIDs: models.ExternalIDs{"osm": {ID: "node/1003", Confidence: 1.0}}, - }, profile: &models.AccessibilityProfile{ - OverallStatus: models.StatusAccessible, - Components: models.A11yComponents{{ - Type: models.ComponentEntrance, - OverallStatus: models.StatusAccessible, - Entrance: &models.EntranceProperties{HasStep: &hasStep, HasRamp: &hasRamp}, - }}, + SourceReports: models.SourceReports{{Source: "osm", Value: "yes", RecordedAt: time.Now()}}, }, }, }, @@ -235,21 +219,11 @@ func TestRunCanonical_WritesAccessibilityProfiles(t *testing.T) { if err := db.Find(&profiles).Error; err != nil { t.Fatalf("read profiles: %v", err) } - if len(profiles) != 2 { - t.Fatalf("expected 2 profiles (Plain has none), got %d", len(profiles)) - } - - var conflictProfile models.AccessibilityProfile - if err := db.Joins("JOIN places ON places.id = accessibility_profiles.place_id"). - Where("places.osm_id = ?", 1003). - First(&conflictProfile).Error; err != nil { - t.Fatalf("read conflict profile: %v", err) + if len(profiles) != 1 { + t.Fatalf("expected 1 profile (Plain has none), got %d", len(profiles)) } - if len(conflictProfile.Components) != 1 { - t.Fatalf("conflict profile components = %d, want 1", len(conflictProfile.Components)) - } - if conflictProfile.Components[0].OverallStatus != models.StatusLimited { - t.Errorf("component status = %q, want limited (downgraded from accessible)", conflictProfile.Components[0].OverallStatus) + if len(profiles[0].SourceReports) == 0 || profiles[0].SourceReports[0].Value != "yes" { + t.Errorf("expected source report value 'yes', got %v", profiles[0].SourceReports) } } @@ -261,6 +235,7 @@ func TestRunCanonical_DoesNotOverwriteUserVerified(t *testing.T) { } defer cleanup() + isLevel := false repo := place.NewRepository(db) seed := models.Place{ OSMID: 2001, OSMType: models.OSMNode, Name: "Verified", @@ -273,8 +248,8 @@ func TestRunCanonical_DoesNotOverwriteUserVerified(t *testing.T) { t.Fatalf("seed place: %v", err) } _, err = repo.UpsertProfile(ctx, seed.ID, &models.AccessibilityProfile{ - OverallStatus: models.StatusInaccessible, - UserVerified: true, + Entrance: &models.EntranceProps{IsLevel: &isLevel}, + UserVerified: true, }) if err != nil { t.Fatalf("seed profile: %v", err) @@ -289,7 +264,9 @@ func TestRunCanonical_DoesNotOverwriteUserVerified(t *testing.T) { Status: models.PlaceStatusActive, ExternalIDs: models.ExternalIDs{"osm": {ID: "node/2001", Confidence: 1.0}}, }, - profile: &models.AccessibilityProfile{OverallStatus: models.StatusAccessible}, + profile: &models.AccessibilityProfile{ + SourceReports: models.SourceReports{{Source: "osm", Value: "yes", RecordedAt: time.Now()}}, + }, }}, } if err := runCanonical(ctx, src, "full-import", db); err != nil { @@ -300,11 +277,11 @@ func TestRunCanonical_DoesNotOverwriteUserVerified(t *testing.T) { if err := db.Where("place_id = ?", seed.ID).First(&stored).Error; err != nil { t.Fatalf("read: %v", err) } - if stored.OverallStatus != models.StatusInaccessible { - t.Errorf("user-verified profile got overwritten: status = %q, want inaccessible", stored.OverallStatus) + if stored.Entrance == nil || stored.Entrance.IsLevel == nil || *stored.Entrance.IsLevel { + t.Error("user-verified profile got overwritten: Entrance.IsLevel should still be false") } if !stored.UserVerified { - t.Errorf("user_verified flag was cleared") + t.Error("user_verified flag was cleared") } } diff --git a/internal/a11y/engine.go b/internal/a11y/engine.go index a3677b4..2dcdaa3 100644 --- a/internal/a11y/engine.go +++ b/internal/a11y/engine.go @@ -3,198 +3,226 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -// Package a11y implements the accessibility rule engine: audit flag computation and conflict detection. +// Package a11y implements the accessibility rule engine: audit flag computation +// and parent-component inheritance. Runs synchronously on every profile write. package a11y import ( - "fmt" - "slices" - "strings" - "github.com/InWheelOrg/inwheel-api/pkg/models" ) -// Audit flag constants for each technical violation detected by WithAuditFlags. +// Audit flag constants computed from submitted property values. const ( FlagEntranceNarrowWidth = "narrow width (0.8m required)" - FlagEntranceContainsStep = "contains step" - FlagEntranceHighStep = "high step (>0.05m)" - FlagEntranceStepNoRamp = "step with no ramp" + FlagEntranceNoLevelRoute = "no level route (no ramp and not level)" - FlagRestroomNotAccessible = "not wheelchair accessible" FlagRestroomNarrowDoor = "narrow door (0.8m required)" + FlagRestroomSmallTurning = "small turning radius (1.5m required)" FlagRestroomNoGrabRails = "missing grab rails" - FlagElevatorNarrowWidth = "small cabin width (0.8m required)" - FlagElevatorShallowDep = "small cabin depth (1.1m required)" - FlagElevatorNoBraille = "missing braille" - FlagElevatorNoAudio = "missing audio" + FlagElevatorNarrowWidth = "small cabin width (0.8m required)" + FlagElevatorShallowDepth = "small cabin depth (1.1m required)" + FlagElevatorNarrowDoor = "narrow door (0.8m required)" + FlagElevatorNoBraille = "missing braille" + FlagElevatorNoAudio = "missing audio" FlagParkingNoDisabledSpaces = "no disabled spaces" + + FlagPathwayNarrowWidth = "narrow pathway (0.9m required)" ) -// Engine provides logic for accessibility data processing and inheritance. type Engine struct{} -// Conflict represents a self-contradiction in submitted data: a component is marked accessible -// but contains a fact (submitted by the client themselves) that makes that impossible. -type Conflict struct { - Component models.A11yComponentType - Reason string -} - -// hardConflictFlags are flags that represent direct self-contradictions in the submitted data — -// either the client explicitly stated inaccessibility, or described a physical barrier with no workaround. -// These are distinct from informational threshold flags (narrow width, missing braille, etc.) which -// are stored for clients to use but never block a write. -var hardConflictFlags = map[string]bool{ - FlagEntranceStepNoRamp: true, // has_step=true + has_ramp=false = impassable physical barrier - FlagRestroomNotAccessible: true, // wheelchair_accessible=false contradicts overall_status=accessible - FlagParkingNoDisabledSpaces: true, // has_disabled_spaces=false contradicts overall_status=accessible -} - -// DetectConflicts checks each component for hard self-contradictions between its submitted -// OverallStatus and the facts the client submitted. Must be called after WithAuditFlags. -// Only hard contradiction flags (not informational threshold flags) can block a write. -func (e *Engine) DetectConflicts(profile *models.AccessibilityProfile) []Conflict { +// WithAuditFlags computes AuditFlags on each non-nil component of the profile. +func (e *Engine) WithAuditFlags(profile *models.AccessibilityProfile) { if profile == nil { - return nil + return } - var conflicts []Conflict - for _, comp := range profile.Components { - if comp.OverallStatus != models.StatusAccessible { - continue - } - var hardFlags []string - for _, f := range comp.AuditFlags { - if hardConflictFlags[f] { - hardFlags = append(hardFlags, f) - } - } - if len(hardFlags) > 0 { - conflicts = append(conflicts, Conflict{ - Component: comp.Type, - Reason: fmt.Sprintf("status is accessible but: %s", strings.Join(hardFlags, ", ")), - }) - } + + if profile.Entrance != nil { + profile.Entrance.AuditFlags = entranceFlags(profile.Entrance) + } + if profile.Pathways != nil { + profile.Pathways.AuditFlags = pathwayFlags(profile.Pathways) + } + if profile.Restroom != nil { + profile.Restroom.AuditFlags = restroomFlags(profile.Restroom) + } + if profile.Parking != nil { + profile.Parking.AuditFlags = parkingFlags(profile.Parking) + } + if profile.Elevator != nil { + profile.Elevator.AuditFlags = elevatorFlags(profile.Elevator) } - return conflicts } -// ComputeEffectiveProfile merges accessibility components from a child place and its parent. -// Child places inherit parent components they don't own (e.g., a shop inherits a mall's parking). -// For any component taken from the parent, IsInherited is set to true and SourceID is set to parent.ID. +// ComputeEffectiveProfile merges components from a child place and its parent. +// Child places inherit parent components they don't own (e.g. a shop inherits a +// mall's parking). Inherited components have IsInherited=true and SourceID=parent.ID. +// Neither the child nor parent record is mutated. func (e *Engine) ComputeEffectiveProfile(child, parent *models.Place) *models.AccessibilityProfile { if child == nil { return nil } - childCount := 0 + effective := &models.AccessibilityProfile{} + if child.Accessibility != nil { - childCount = len(child.Accessibility.Components) + ca := child.Accessibility + effective.ID = ca.ID + effective.PlaceID = ca.PlaceID + effective.SourceReports = ca.SourceReports + effective.UserVerified = ca.UserVerified + effective.SubmittedAt = ca.SubmittedAt + effective.UpdatedAt = ca.UpdatedAt + effective.Entrance = copyEntrance(ca.Entrance) + effective.Pathways = copyPathways(ca.Pathways) + effective.Restroom = copyRestroom(ca.Restroom) + effective.Parking = copyParking(ca.Parking) + effective.Elevator = copyElevator(ca.Elevator) } - parentCount := 0 - if parent != nil && parent.Accessibility != nil { - parentCount = len(parent.Accessibility.Components) + if parent == nil || parent.Accessibility == nil { + return effective } - effective := &models.AccessibilityProfile{ - OverallStatus: models.StatusUnknown, - Components: make([]models.A11yComponent, 0, childCount+parentCount), + pa := parent.Accessibility + + if effective.Entrance == nil && pa.Entrance != nil { + c := copyEntrance(pa.Entrance) + c.IsInherited = true + c.SourceID = parent.ID + effective.Entrance = c + } + if effective.Pathways == nil && pa.Pathways != nil { + c := copyPathways(pa.Pathways) + c.IsInherited = true + c.SourceID = parent.ID + effective.Pathways = c + } + if effective.Restroom == nil && pa.Restroom != nil { + c := copyRestroom(pa.Restroom) + c.IsInherited = true + c.SourceID = parent.ID + effective.Restroom = c + } + if effective.Parking == nil && pa.Parking != nil { + c := copyParking(pa.Parking) + c.IsInherited = true + c.SourceID = parent.ID + effective.Parking = c + } + if effective.Elevator == nil && pa.Elevator != nil { + c := copyElevator(pa.Elevator) + c.IsInherited = true + c.SourceID = parent.ID + effective.Elevator = c } - if child.Accessibility != nil { - effective.OverallStatus = child.Accessibility.OverallStatus - effective.Components = append(effective.Components, child.Accessibility.Components...) + return effective +} + +func entranceFlags(p *models.EntranceProps) []string { + var flags []string + if p.Width != nil && *p.Width < 0.8 { + flags = append(flags, FlagEntranceNarrowWidth) + } + // Only flag when IsLevel is explicitly false; nil means unknown, no flag. + if p.IsLevel != nil && !*p.IsLevel { + hasRamp := (p.HasFixedRamp != nil && *p.HasFixedRamp) || (p.HasRemovableRamp != nil && *p.HasRemovableRamp) + if !hasRamp { + flags = append(flags, FlagEntranceNoLevelRoute) + } + } + return flags +} + +func pathwayFlags(p *models.PathwayProps) []string { + var flags []string + if p.Width != nil && *p.Width < 0.9 { + flags = append(flags, FlagPathwayNarrowWidth) } + return flags +} - if parent == nil || parent.Accessibility == nil { - return effective +func restroomFlags(p *models.RestroomProps) []string { + var flags []string + if p.DoorWidth != nil && *p.DoorWidth < 0.8 { + flags = append(flags, FlagRestroomNarrowDoor) + } + if p.TurningRadius != nil && *p.TurningRadius < 1.5 { + flags = append(flags, FlagRestroomSmallTurning) } + if p.HasGrabRails != nil && !*p.HasGrabRails { + flags = append(flags, FlagRestroomNoGrabRails) + } + return flags +} - // Iterate through parent components and inherit those the child doesn't have. - for _, pc := range parent.Accessibility.Components { - if !hasComponent(child, pc.Type) { - inherited := pc - inherited.IsInherited = true - inherited.SourceID = parent.ID - effective.Components = append(effective.Components, inherited) - } +func parkingFlags(p *models.ParkingProps) []string { + var flags []string + if p.HasDisabledSpaces != nil && !*p.HasDisabledSpaces { + flags = append(flags, FlagParkingNoDisabledSpaces) } + return flags +} - return effective +func elevatorFlags(p *models.ElevatorProps) []string { + var flags []string + if p.Width != nil && *p.Width < 0.8 { + flags = append(flags, FlagElevatorNarrowWidth) + } + if p.Depth != nil && *p.Depth < 1.1 { + flags = append(flags, FlagElevatorShallowDepth) + } + if p.DoorWidth != nil && *p.DoorWidth < 0.8 { + flags = append(flags, FlagElevatorNarrowDoor) + } + if p.HasBraille != nil && !*p.HasBraille { + flags = append(flags, FlagElevatorNoBraille) + } + if p.HasAudio != nil && !*p.HasAudio { + flags = append(flags, FlagElevatorNoAudio) + } + return flags } -// hasComponent checks if a place already has a component of a specific type. -func hasComponent(place *models.Place, cType models.A11yComponentType) bool { - if place == nil || place.Accessibility == nil { - return false +func copyEntrance(p *models.EntranceProps) *models.EntranceProps { + if p == nil { + return nil } - return slices.ContainsFunc(place.Accessibility.Components, func(c models.A11yComponent) bool { - return c.Type == cType - }) + c := *p + return &c } -// WithAuditFlags performs a technical validation of each component and populates the AuditFlags field. -func (e *Engine) WithAuditFlags(profile *models.AccessibilityProfile) { - if profile == nil { - return +func copyPathways(p *models.PathwayProps) *models.PathwayProps { + if p == nil { + return nil } + c := *p + return &c +} - for i := range profile.Components { - comp := &profile.Components[i] - comp.AuditFlags = nil // Reset - - switch comp.Type { - case models.ComponentEntrance: - if e := comp.Entrance; e != nil { - if e.Width != nil && *e.Width < 0.8 { - comp.AuditFlags = append(comp.AuditFlags, FlagEntranceNarrowWidth) - } - if e.HasStep != nil && *e.HasStep { - comp.AuditFlags = append(comp.AuditFlags, FlagEntranceContainsStep) - if e.StepHeight != nil && *e.StepHeight > 0.05 { - comp.AuditFlags = append(comp.AuditFlags, FlagEntranceHighStep) - } - if e.HasRamp != nil && !*e.HasRamp { - comp.AuditFlags = append(comp.AuditFlags, FlagEntranceStepNoRamp) - } - } - } - case models.ComponentRestroom: - if r := comp.Restroom; r != nil { - if r.WheelchairAccessible != nil && !*r.WheelchairAccessible { - comp.AuditFlags = append(comp.AuditFlags, FlagRestroomNotAccessible) - } - if r.DoorWidth != nil && *r.DoorWidth < 0.8 { - comp.AuditFlags = append(comp.AuditFlags, FlagRestroomNarrowDoor) - } - if r.GrabRails != nil && !*r.GrabRails { - comp.AuditFlags = append(comp.AuditFlags, FlagRestroomNoGrabRails) - } - } - case models.ComponentElevator: - if el := comp.Elevator; el != nil { - if el.Width != nil && *el.Width < 0.8 { - comp.AuditFlags = append(comp.AuditFlags, FlagElevatorNarrowWidth) - } - if el.Depth != nil && *el.Depth < 1.1 { - comp.AuditFlags = append(comp.AuditFlags, FlagElevatorShallowDep) - } - if el.Braille != nil && !*el.Braille { - comp.AuditFlags = append(comp.AuditFlags, FlagElevatorNoBraille) - } - if el.Audio != nil && !*el.Audio { - comp.AuditFlags = append(comp.AuditFlags, FlagElevatorNoAudio) - } - } - case models.ComponentParking: - if p := comp.Parking; p != nil { - if p.HasDisabledSpaces != nil && !*p.HasDisabledSpaces { - comp.AuditFlags = append(comp.AuditFlags, FlagParkingNoDisabledSpaces) - } - } - } +func copyRestroom(p *models.RestroomProps) *models.RestroomProps { + if p == nil { + return nil + } + c := *p + return &c +} + +func copyParking(p *models.ParkingProps) *models.ParkingProps { + if p == nil { + return nil + } + c := *p + return &c +} + +func copyElevator(p *models.ElevatorProps) *models.ElevatorProps { + if p == nil { + return nil } + c := *p + return &c } diff --git a/internal/a11y/engine_test.go b/internal/a11y/engine_test.go index 151c035..31a52dc 100644 --- a/internal/a11y/engine_test.go +++ b/internal/a11y/engine_test.go @@ -18,572 +18,264 @@ func floatPtr(v float64) *float64 { return &v } func TestComputeEffectiveProfile(t *testing.T) { engine := &Engine{} - tests := []struct { - name string - child *models.Place - parent *models.Place - wantStatus models.A11yStatus - wantCompCount int - check func(t *testing.T, res *models.AccessibilityProfile, parent *models.Place) - }{ - { - name: "nil child returns nil", - child: nil, - parent: nil, - wantStatus: "", // nil expected - }, - { - name: "child without accessibility and no parent", - child: &models.Place{ID: "child-1"}, - parent: nil, - wantStatus: models.StatusUnknown, - wantCompCount: 0, - }, - { - name: "child inherits from parent", - parent: &models.Place{ - ID: "parent-1", - Accessibility: &models.AccessibilityProfile{ - OverallStatus: models.StatusAccessible, - Components: []models.A11yComponent{ - { - Type: models.ComponentParking, - OverallStatus: models.StatusAccessible, - }, - }, - }, - }, - child: &models.Place{ - ID: "child-1", - Accessibility: &models.AccessibilityProfile{ - OverallStatus: models.StatusLimited, - Components: []models.A11yComponent{ - { - Type: models.ComponentEntrance, - OverallStatus: models.StatusLimited, - }, - }, - }, - }, - wantStatus: models.StatusLimited, - wantCompCount: 2, - check: func(t *testing.T, res *models.AccessibilityProfile, parent *models.Place) { - // Check for child's own component - var entranceFound bool - for _, c := range res.Components { - if c.Type == models.ComponentEntrance { - entranceFound = true - if c.IsInherited { - t.Error("Child entrance component should not be marked as inherited") - } - } - } - if !entranceFound { - t.Error("Child entrance component missing from effective profile") - } + t.Run("nil child returns nil", func(t *testing.T) { + if engine.ComputeEffectiveProfile(nil, nil) != nil { + t.Error("expected nil for nil child") + } + }) - // Check for inherited parent component - var parkingFound bool - for _, c := range res.Components { - if c.Type == models.ComponentParking { - parkingFound = true - if !c.IsInherited { - t.Error("Parent parking component should be marked as inherited") - } - if c.SourceID != parent.ID { - t.Errorf("Expected SourceID %s, got %s", parent.ID, c.SourceID) - } - } - } - if !parkingFound { - t.Error("Parent parking component missing from effective profile") - } - }, - }, - { - name: "child component overrides parent component", - parent: &models.Place{ - ID: "parent-1", - Accessibility: &models.AccessibilityProfile{ - Components: []models.A11yComponent{ - { - Type: models.ComponentEntrance, - OverallStatus: models.StatusAccessible, - }, - }, - }, - }, - child: &models.Place{ - ID: "child-1", - Accessibility: &models.AccessibilityProfile{ - OverallStatus: models.StatusUnknown, - Components: []models.A11yComponent{ - { - Type: models.ComponentEntrance, - OverallStatus: models.StatusInaccessible, - }, - }, - }, + t.Run("child with no accessibility and no parent", func(t *testing.T) { + res := engine.ComputeEffectiveProfile(&models.Place{ID: "c"}, nil) + if res == nil { + t.Fatal("expected non-nil profile") + } + if res.Entrance != nil || res.Parking != nil || res.Restroom != nil { + t.Error("expected all components nil for place with no accessibility") + } + }) + + t.Run("child inherits parent parking when child has none", func(t *testing.T) { + parent := &models.Place{ + ID: "parent-1", + Accessibility: &models.AccessibilityProfile{ + Parking: &models.ParkingProps{HasDisabledSpaces: boolPtr(true)}, }, - wantStatus: models.StatusUnknown, - wantCompCount: 1, - check: func(t *testing.T, res *models.AccessibilityProfile, _ *models.Place) { - if res.Components[0].OverallStatus != models.StatusInaccessible { - t.Errorf("Expected child status %s to override parent, got %s", models.StatusInaccessible, res.Components[0].OverallStatus) - } - if res.Components[0].IsInherited { - t.Error("Child component should not be marked as inherited") - } + } + child := &models.Place{ + ID: "child-1", + Accessibility: &models.AccessibilityProfile{ + Entrance: &models.EntranceProps{IsLevel: boolPtr(true)}, }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - res := engine.ComputeEffectiveProfile(tt.child, tt.parent) - - if tt.child == nil { - if res != nil { - t.Errorf("Expected nil for nil child, got %v", res) - } - return - } - - if res == nil { - t.Fatal("Expected non-nil profile") - } - - if res.OverallStatus != tt.wantStatus { - t.Errorf("OverallStatus = %s, want %s", res.OverallStatus, tt.wantStatus) - } - - if len(res.Components) != tt.wantCompCount { - t.Errorf("len(Components) = %d, want %d", len(res.Components), tt.wantCompCount) - } - - if tt.check != nil { - tt.check(t, res, tt.parent) - } - }) - } -} + } + res := engine.ComputeEffectiveProfile(child, parent) -func TestDetectConflicts(t *testing.T) { - engine := &Engine{} + if res.Entrance == nil || res.Entrance.IsInherited { + t.Error("child's own entrance should be present and not inherited") + } + if res.Parking == nil { + t.Fatal("parent parking should be inherited") + } + if !res.Parking.IsInherited { + t.Error("inherited parking should have IsInherited=true") + } + if res.Parking.SourceID != parent.ID { + t.Errorf("inherited parking SourceID = %q, want %q", res.Parking.SourceID, parent.ID) + } + }) - tests := []struct { - name string - components []models.A11yComponent - wantConflicts int - }{ - { - name: "nil profile", - components: nil, - wantConflicts: 0, - }, - { - name: "no components", - components: []models.A11yComponent{}, - wantConflicts: 0, - }, - // Hard contradictions — must block - { - name: "entrance: step with no ramp + accessible", - components: []models.A11yComponent{ - { - Type: models.ComponentEntrance, - OverallStatus: models.StatusAccessible, - AuditFlags: []string{FlagEntranceStepNoRamp}, - }, + t.Run("child entrance overrides parent entrance", func(t *testing.T) { + parent := &models.Place{ + ID: "parent-1", + Accessibility: &models.AccessibilityProfile{ + Entrance: &models.EntranceProps{IsLevel: boolPtr(true)}, }, - wantConflicts: 1, - }, - { - name: "restroom: not wheelchair accessible + accessible", - components: []models.A11yComponent{ - { - Type: models.ComponentRestroom, - OverallStatus: models.StatusAccessible, - AuditFlags: []string{FlagRestroomNotAccessible}, - }, - }, - wantConflicts: 1, - }, - { - name: "parking: no disabled spaces + accessible", - components: []models.A11yComponent{ - { - Type: models.ComponentParking, - OverallStatus: models.StatusAccessible, - AuditFlags: []string{FlagParkingNoDisabledSpaces}, - }, - }, - wantConflicts: 1, - }, - // Informational threshold flags — must not block - { - name: "entrance: narrow width + accessible — informational only", - components: []models.A11yComponent{ - { - Type: models.ComponentEntrance, - OverallStatus: models.StatusAccessible, - AuditFlags: []string{FlagEntranceNarrowWidth}, - }, - }, - wantConflicts: 0, - }, - { - name: "entrance: high step + accessible — informational only", - components: []models.A11yComponent{ - { - Type: models.ComponentEntrance, - OverallStatus: models.StatusAccessible, - AuditFlags: []string{FlagEntranceHighStep}, - }, - }, - wantConflicts: 0, - }, - { - name: "restroom: narrow door + accessible — informational only", - components: []models.A11yComponent{ - { - Type: models.ComponentRestroom, - OverallStatus: models.StatusAccessible, - AuditFlags: []string{FlagRestroomNarrowDoor}, - }, - }, - wantConflicts: 0, - }, - { - name: "elevator: narrow width + accessible — informational only", - components: []models.A11yComponent{ - { - Type: models.ComponentElevator, - OverallStatus: models.StatusAccessible, - AuditFlags: []string{FlagElevatorNarrowWidth}, - }, - }, - wantConflicts: 0, - }, - // Status other than accessible — never a conflict - { - name: "step with no ramp + limited — no conflict", - components: []models.A11yComponent{ - { - Type: models.ComponentEntrance, - OverallStatus: models.StatusLimited, - AuditFlags: []string{FlagEntranceStepNoRamp}, - }, - }, - wantConflicts: 0, - }, - { - name: "restroom not accessible + inaccessible — no conflict", - components: []models.A11yComponent{ - { - Type: models.ComponentRestroom, - OverallStatus: models.StatusInaccessible, - AuditFlags: []string{FlagRestroomNotAccessible}, - }, + } + child := &models.Place{ + ID: "child-1", + Accessibility: &models.AccessibilityProfile{ + Entrance: &models.EntranceProps{IsLevel: boolPtr(false)}, }, - wantConflicts: 0, - }, - // Multiple components — only conflicting ones reported - { - name: "two components, one conflict", - components: []models.A11yComponent{ - { - Type: models.ComponentEntrance, - OverallStatus: models.StatusAccessible, - AuditFlags: []string{FlagEntranceNarrowWidth}, // informational only - }, - { - Type: models.ComponentRestroom, - OverallStatus: models.StatusAccessible, - AuditFlags: []string{FlagRestroomNotAccessible}, // hard conflict - }, + } + res := engine.ComputeEffectiveProfile(child, parent) + + if res.Entrance == nil { + t.Fatal("expected entrance") + } + if res.Entrance.IsInherited { + t.Error("child's own entrance should not be inherited") + } + if res.Entrance.IsLevel == nil || *res.Entrance.IsLevel { + t.Error("expected child's is_level=false to override parent's is_level=true") + } + }) + + t.Run("original child and parent records are not mutated", func(t *testing.T) { + parent := &models.Place{ + ID: "p", + Accessibility: &models.AccessibilityProfile{ + Parking: &models.ParkingProps{HasDisabledSpaces: boolPtr(true)}, }, - wantConflicts: 1, - }, - } + } + child := &models.Place{ID: "c", Accessibility: &models.AccessibilityProfile{}} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var profile *models.AccessibilityProfile - if tt.components != nil { - profile = &models.AccessibilityProfile{Components: tt.components} - } - conflicts := engine.DetectConflicts(profile) - if len(conflicts) != tt.wantConflicts { - t.Errorf("DetectConflicts() = %d conflicts, want %d: %v", len(conflicts), tt.wantConflicts, conflicts) - } - }) - } + engine.ComputeEffectiveProfile(child, parent) + + if parent.Accessibility.Parking.IsInherited { + t.Error("parent parking should not be mutated") + } + }) } func TestWithAuditFlags(t *testing.T) { engine := &Engine{} + t.Run("nil profile does not panic", func(_ *testing.T) { + engine.WithAuditFlags(nil) + }) + tests := []struct { name string - component models.A11yComponent - wantFlags []string + profile models.AccessibilityProfile + wantFlags map[string][]string // component → expected flags }{ - // --- nil / empty --- - { - name: "nil profile does not panic", - component: models.A11yComponent{}, // tested separately below via nil call - }, - // --- entrance --- { - name: "entrance: no properties set — no flags", - component: models.A11yComponent{ - Type: models.ComponentEntrance, - Entrance: &models.EntranceProperties{}, - }, - wantFlags: nil, + name: "entrance: no properties, no flags", + profile: models.AccessibilityProfile{Entrance: &models.EntranceProps{}}, + wantFlags: map[string][]string{"entrance": nil}, }, { - name: "entrance: width below minimum", - component: models.A11yComponent{ - Type: models.ComponentEntrance, - Entrance: &models.EntranceProperties{Width: floatPtr(0.75)}, - }, - wantFlags: []string{FlagEntranceNarrowWidth}, + name: "entrance: width below 0.8m", + profile: models.AccessibilityProfile{Entrance: &models.EntranceProps{Width: floatPtr(0.75)}}, + wantFlags: map[string][]string{"entrance": {FlagEntranceNarrowWidth}}, }, { - name: "entrance: width exactly at minimum — no flag", - component: models.A11yComponent{ - Type: models.ComponentEntrance, - Entrance: &models.EntranceProperties{Width: floatPtr(0.8)}, - }, - wantFlags: nil, + name: "entrance: width exactly 0.8m, no flag", + profile: models.AccessibilityProfile{Entrance: &models.EntranceProps{Width: floatPtr(0.8)}}, + wantFlags: map[string][]string{"entrance": nil}, }, { - name: "entrance: has step", - component: models.A11yComponent{ - Type: models.ComponentEntrance, - Entrance: &models.EntranceProperties{HasStep: boolPtr(true)}, - }, - wantFlags: []string{FlagEntranceContainsStep}, - }, - { - name: "entrance: step height above threshold", - component: models.A11yComponent{ - Type: models.ComponentEntrance, - Entrance: &models.EntranceProperties{ - HasStep: boolPtr(true), - StepHeight: floatPtr(0.1), - }, - }, - wantFlags: []string{FlagEntranceContainsStep, FlagEntranceHighStep}, + name: "entrance: not level and no ramp", + profile: models.AccessibilityProfile{Entrance: &models.EntranceProps{IsLevel: boolPtr(false)}}, + wantFlags: map[string][]string{"entrance": {FlagEntranceNoLevelRoute}}, }, { - name: "entrance: step height at threshold — no high-step flag", - component: models.A11yComponent{ - Type: models.ComponentEntrance, - Entrance: &models.EntranceProperties{ - HasStep: boolPtr(true), - StepHeight: floatPtr(0.05), - }, - }, - wantFlags: []string{FlagEntranceContainsStep}, + name: "entrance: not level but has fixed ramp, no flag", + profile: models.AccessibilityProfile{Entrance: &models.EntranceProps{IsLevel: boolPtr(false), HasFixedRamp: boolPtr(true)}}, + wantFlags: map[string][]string{"entrance": nil}, }, { - name: "entrance: step with no ramp", - component: models.A11yComponent{ - Type: models.ComponentEntrance, - Entrance: &models.EntranceProperties{ - HasStep: boolPtr(true), - HasRamp: boolPtr(false), - }, - }, - wantFlags: []string{FlagEntranceContainsStep, FlagEntranceStepNoRamp}, + name: "entrance: is level, no flag", + profile: models.AccessibilityProfile{Entrance: &models.EntranceProps{IsLevel: boolPtr(true)}}, + wantFlags: map[string][]string{"entrance": nil}, }, + + // --- pathways --- { - name: "entrance: step with ramp — no ramp flag", - component: models.A11yComponent{ - Type: models.ComponentEntrance, - Entrance: &models.EntranceProperties{ - HasStep: boolPtr(true), - HasRamp: boolPtr(true), - }, - }, - wantFlags: []string{FlagEntranceContainsStep}, + name: "pathway: width below 0.9m", + profile: models.AccessibilityProfile{Pathways: &models.PathwayProps{Width: floatPtr(0.8)}}, + wantFlags: map[string][]string{"pathways": {FlagPathwayNarrowWidth}}, }, { - name: "entrance: nil entrance — no flags", - component: models.A11yComponent{ - Type: models.ComponentEntrance, - Entrance: nil, - }, - wantFlags: nil, + name: "pathway: width at 0.9m, no flag", + profile: models.AccessibilityProfile{Pathways: &models.PathwayProps{Width: floatPtr(0.9)}}, + wantFlags: map[string][]string{"pathways": nil}, }, // --- restroom --- { - name: "restroom: not wheelchair accessible", - component: models.A11yComponent{ - Type: models.ComponentRestroom, - Restroom: &models.RestroomProperties{WheelchairAccessible: boolPtr(false)}, - }, - wantFlags: []string{FlagRestroomNotAccessible}, + name: "restroom: door width below 0.8m", + profile: models.AccessibilityProfile{Restroom: &models.RestroomProps{DoorWidth: floatPtr(0.75)}}, + wantFlags: map[string][]string{"restroom": {FlagRestroomNarrowDoor}}, }, { - name: "restroom: door width below minimum", - component: models.A11yComponent{ - Type: models.ComponentRestroom, - Restroom: &models.RestroomProperties{DoorWidth: floatPtr(0.7)}, - }, - wantFlags: []string{FlagRestroomNarrowDoor}, + name: "restroom: turning radius below 1.5m", + profile: models.AccessibilityProfile{Restroom: &models.RestroomProps{TurningRadius: floatPtr(1.2)}}, + wantFlags: map[string][]string{"restroom": {FlagRestroomSmallTurning}}, }, { - name: "restroom: door width exactly at minimum — no flag", - component: models.A11yComponent{ - Type: models.ComponentRestroom, - Restroom: &models.RestroomProperties{DoorWidth: floatPtr(0.8)}, - }, - wantFlags: nil, + name: "restroom: no grab rails", + profile: models.AccessibilityProfile{Restroom: &models.RestroomProps{HasGrabRails: boolPtr(false)}}, + wantFlags: map[string][]string{"restroom": {FlagRestroomNoGrabRails}}, }, { - name: "restroom: missing grab rails", - component: models.A11yComponent{ - Type: models.ComponentRestroom, - Restroom: &models.RestroomProperties{GrabRails: boolPtr(false)}, - }, - wantFlags: []string{FlagRestroomNoGrabRails}, - }, - { - name: "restroom: nil restroom — no flags", - component: models.A11yComponent{ - Type: models.ComponentRestroom, - Restroom: nil, - }, - wantFlags: nil, + name: "restroom: has grab rails, no flag", + profile: models.AccessibilityProfile{Restroom: &models.RestroomProps{HasGrabRails: boolPtr(true)}}, + wantFlags: map[string][]string{"restroom": nil}, }, - // --- elevator --- - { - name: "elevator: cabin width below minimum", - component: models.A11yComponent{ - Type: models.ComponentElevator, - Elevator: &models.ElevatorProperties{Width: floatPtr(0.7)}, - }, - wantFlags: []string{FlagElevatorNarrowWidth}, - }, - { - name: "elevator: cabin depth below minimum", - component: models.A11yComponent{ - Type: models.ComponentElevator, - Elevator: &models.ElevatorProperties{Depth: floatPtr(1.0)}, - }, - wantFlags: []string{FlagElevatorShallowDep}, - }, + // --- parking --- { - name: "elevator: missing braille", - component: models.A11yComponent{ - Type: models.ComponentElevator, - Elevator: &models.ElevatorProperties{Braille: boolPtr(false)}, - }, - wantFlags: []string{FlagElevatorNoBraille}, + name: "parking: no disabled spaces", + profile: models.AccessibilityProfile{Parking: &models.ParkingProps{HasDisabledSpaces: boolPtr(false)}}, + wantFlags: map[string][]string{"parking": {FlagParkingNoDisabledSpaces}}, }, { - name: "elevator: missing audio", - component: models.A11yComponent{ - Type: models.ComponentElevator, - Elevator: &models.ElevatorProperties{Audio: boolPtr(false)}, - }, - wantFlags: []string{FlagElevatorNoAudio}, + name: "parking: has disabled spaces, no flag", + profile: models.AccessibilityProfile{Parking: &models.ParkingProps{HasDisabledSpaces: boolPtr(true)}}, + wantFlags: map[string][]string{"parking": nil}, }, + + // --- elevator --- { - name: "elevator: nil elevator — no flags", - component: models.A11yComponent{ - Type: models.ComponentElevator, - Elevator: nil, - }, - wantFlags: nil, + name: "elevator: width below 0.8m", + profile: models.AccessibilityProfile{Elevator: &models.ElevatorProps{Width: floatPtr(0.7)}}, + wantFlags: map[string][]string{"elevator": {FlagElevatorNarrowWidth}}, }, - - // --- parking --- { - name: "parking: no disabled spaces", - component: models.A11yComponent{ - Type: models.ComponentParking, - Parking: &models.ParkingProperties{HasDisabledSpaces: boolPtr(false)}, - }, - wantFlags: []string{FlagParkingNoDisabledSpaces}, + name: "elevator: depth below 1.1m", + profile: models.AccessibilityProfile{Elevator: &models.ElevatorProps{Depth: floatPtr(1.0)}}, + wantFlags: map[string][]string{"elevator": {FlagElevatorShallowDepth}}, }, { - name: "parking: has disabled spaces — no flag", - component: models.A11yComponent{ - Type: models.ComponentParking, - Parking: &models.ParkingProperties{HasDisabledSpaces: boolPtr(true)}, - }, - wantFlags: nil, + name: "elevator: door width below 0.8m", + profile: models.AccessibilityProfile{Elevator: &models.ElevatorProps{DoorWidth: floatPtr(0.75)}}, + wantFlags: map[string][]string{"elevator": {FlagElevatorNarrowDoor}}, }, { - name: "parking: nil parking — no flags", - component: models.A11yComponent{ - Type: models.ComponentParking, - Parking: nil, - }, - wantFlags: nil, + name: "elevator: no braille", + profile: models.AccessibilityProfile{Elevator: &models.ElevatorProps{HasBraille: boolPtr(false)}}, + wantFlags: map[string][]string{"elevator": {FlagElevatorNoBraille}}, }, - - // --- other --- { - name: "component type other — no flags regardless of data", - component: models.A11yComponent{ - Type: models.ComponentOther, - }, - wantFlags: nil, + name: "elevator: no audio", + profile: models.AccessibilityProfile{Elevator: &models.ElevatorProps{HasAudio: boolPtr(false)}}, + wantFlags: map[string][]string{"elevator": {FlagElevatorNoAudio}}, }, } - // Nil profile must not panic. - t.Run("nil profile does not panic", func(_ *testing.T) { - engine.WithAuditFlags(nil) - }) - - // Existing flags are cleared before re-evaluation. - t.Run("existing flags are reset", func(t *testing.T) { - profile := &models.AccessibilityProfile{ - Components: []models.A11yComponent{ - { - Type: models.ComponentEntrance, - AuditFlags: []string{"stale flag"}, - Entrance: &models.EntranceProperties{Width: floatPtr(1.0)}, - }, - }, - } - engine.WithAuditFlags(profile) - if len(profile.Components[0].AuditFlags) != 0 { - t.Errorf("expected stale flags to be cleared, got %v", profile.Components[0].AuditFlags) - } - }) - for _, tt := range tests { - if tt.name == "nil profile does not panic" { - continue // handled above - } t.Run(tt.name, func(t *testing.T) { - profile := &models.AccessibilityProfile{ - Components: []models.A11yComponent{tt.component}, - } - engine.WithAuditFlags(profile) - got := profile.Components[0].AuditFlags + p := tt.profile + engine.WithAuditFlags(&p) - if len(tt.wantFlags) == 0 && len(got) == 0 { - return - } - if len(got) != len(tt.wantFlags) { - t.Errorf("flags = %v, want %v", got, tt.wantFlags) - return + checkFlags := func(component string, got []string, want []string) { + if len(want) == 0 && len(got) == 0 { + return + } + if len(got) != len(want) { + t.Errorf("%s: flags = %v, want %v", component, got, want) + return + } + for _, wf := range want { + if !slices.Contains(got, wf) { + t.Errorf("%s: missing flag %q in %v", component, wf, got) + } + } } - for _, wf := range tt.wantFlags { - if !slices.Contains(got, wf) { - t.Errorf("missing expected flag %q in %v", wf, got) + + for comp, want := range tt.wantFlags { + switch comp { + case "entrance": + var got []string + if p.Entrance != nil { + got = p.Entrance.AuditFlags + } + checkFlags(comp, got, want) + case "pathways": + var got []string + if p.Pathways != nil { + got = p.Pathways.AuditFlags + } + checkFlags(comp, got, want) + case "restroom": + var got []string + if p.Restroom != nil { + got = p.Restroom.AuditFlags + } + checkFlags(comp, got, want) + case "parking": + var got []string + if p.Parking != nil { + got = p.Parking.AuditFlags + } + checkFlags(comp, got, want) + case "elevator": + var got []string + if p.Elevator != nil { + got = p.Elevator.AuditFlags + } + checkFlags(comp, got, want) } } }) diff --git a/internal/api/v1/server.gen.go b/internal/api/v1/server.gen.go index ca8c0a1..01189b2 100644 --- a/internal/api/v1/server.gen.go +++ b/internal/api/v1/server.gen.go @@ -29,93 +29,66 @@ const ( ApiKeyAuthScopes apiKeyAuthContextKey = "ApiKeyAuth.Scopes" ) -// Defines values for A11yComponentType. +// Defines values for Category. const ( - A11yComponentTypeElevator A11yComponentType = "elevator" - A11yComponentTypeEntrance A11yComponentType = "entrance" - A11yComponentTypeOther A11yComponentType = "other" - A11yComponentTypeParking A11yComponentType = "parking" - A11yComponentTypeRestroom A11yComponentType = "restroom" + Airport Category = "airport" + Cafe Category = "cafe" + Entrance Category = "entrance" + Mall Category = "mall" + Other Category = "other" + Parking Category = "parking" + Restaurant Category = "restaurant" + Shop Category = "shop" + Toilet Category = "toilet" + TrainStation Category = "train_station" ) -// Valid indicates whether the value is a known member of the A11yComponentType enum. -func (e A11yComponentType) Valid() bool { +// Valid indicates whether the value is a known member of the Category enum. +func (e Category) Valid() bool { switch e { - case A11yComponentTypeElevator: + case Airport: return true - case A11yComponentTypeEntrance: + case Cafe: return true - case A11yComponentTypeOther: + case Entrance: return true - case A11yComponentTypeParking: + case Mall: return true - case A11yComponentTypeRestroom: + case Other: return true - default: - return false - } -} - -// Defines values for A11yStatus. -const ( - Accessible A11yStatus = "accessible" - Inaccessible A11yStatus = "inaccessible" - Limited A11yStatus = "limited" - Unknown A11yStatus = "unknown" -) - -// Valid indicates whether the value is a known member of the A11yStatus enum. -func (e A11yStatus) Valid() bool { - switch e { - case Accessible: + case Parking: + return true + case Restaurant: return true - case Inaccessible: + case Shop: return true - case Limited: + case Toilet: return true - case Unknown: + case TrainStation: return true default: return false } } -// Defines values for Category. +// Defines values for DoorType. const ( - CategoryAirport Category = "airport" - CategoryCafe Category = "cafe" - CategoryEntrance Category = "entrance" - CategoryMall Category = "mall" - CategoryOther Category = "other" - CategoryParking Category = "parking" - CategoryRestaurant Category = "restaurant" - CategoryShop Category = "shop" - CategoryToilet Category = "toilet" - CategoryTrainStation Category = "train_station" + Automatic DoorType = "automatic" + Manual DoorType = "manual" + None DoorType = "none" + Revolving DoorType = "revolving" ) -// Valid indicates whether the value is a known member of the Category enum. -func (e Category) Valid() bool { +// Valid indicates whether the value is a known member of the DoorType enum. +func (e DoorType) Valid() bool { switch e { - case CategoryAirport: - return true - case CategoryCafe: + case Automatic: return true - case CategoryEntrance: + case Manual: return true - case CategoryMall: + case None: return true - case CategoryOther: - return true - case CategoryParking: - return true - case CategoryRestaurant: - return true - case CategoryShop: - return true - case CategoryToilet: - return true - case CategoryTrainStation: + case Revolving: return true default: return false @@ -164,14 +137,41 @@ func (e PlaceStatus) Valid() bool { } } -// A11yComponent defines model for A11yComponent. -type A11yComponent = models.A11yComponent - -// A11yComponentType defines model for A11yComponentType. -type A11yComponentType string +// Defines values for SurfaceType. +const ( + Asphalt SurfaceType = "asphalt" + Carpet SurfaceType = "carpet" + Cobblestone SurfaceType = "cobblestone" + Concrete SurfaceType = "concrete" + Gravel SurfaceType = "gravel" + PavingStones SurfaceType = "paving_stones" + Tiles SurfaceType = "tiles" + Wood SurfaceType = "wood" +) -// A11yStatus defines model for A11yStatus. -type A11yStatus string +// Valid indicates whether the value is a known member of the SurfaceType enum. +func (e SurfaceType) Valid() bool { + switch e { + case Asphalt: + return true + case Carpet: + return true + case Cobblestone: + return true + case Concrete: + return true + case Gravel: + return true + case PavingStones: + return true + case Tiles: + return true + case Wood: + return true + default: + return false + } +} // AccessibilityProfile defines model for AccessibilityProfile. type AccessibilityProfile = models.AccessibilityProfile @@ -179,23 +179,17 @@ type AccessibilityProfile = models.AccessibilityProfile // Category defines model for Category. type Category string -// ConflictError defines model for ConflictError. -type ConflictError struct { - Conflicts []ConflictItem `json:"conflicts"` - Error string `json:"error"` -} +// DoorProps defines model for DoorProps. +type DoorProps = models.DoorProps -// ConflictItem defines model for ConflictItem. -type ConflictItem struct { - Component string `json:"component"` - Reason string `json:"reason"` -} +// DoorType defines model for DoorType. +type DoorType string -// ElevatorProperties defines model for ElevatorProperties. -type ElevatorProperties = models.ElevatorProperties +// ElevatorProps defines model for ElevatorProps. +type ElevatorProps = models.ElevatorProps -// EntranceProperties defines model for EntranceProperties. -type EntranceProperties = models.EntranceProperties +// EntranceProps defines model for EntranceProps. +type EntranceProps = models.EntranceProps // ErrorResponse defines model for ErrorResponse. type ErrorResponse struct { @@ -214,8 +208,11 @@ type FieldError struct { // OSMType defines model for OSMType. type OSMType string -// ParkingProperties defines model for ParkingProperties. -type ParkingProperties = models.ParkingProperties +// ParkingProps defines model for ParkingProps. +type ParkingProps = models.ParkingProps + +// PathwayProps defines model for PathwayProps. +type PathwayProps = models.PathwayProps // Place defines model for Place. type Place = models.Place @@ -244,8 +241,18 @@ type RegisterResponse struct { Email string `json:"email"` } -// RestroomProperties defines model for RestroomProperties. -type RestroomProperties = models.RestroomProperties +// RestroomProps defines model for RestroomProps. +type RestroomProps = models.RestroomProps + +// SourceReport defines model for SourceReport. +type SourceReport struct { + RecordedAt time.Time `json:"recorded_at"` + Source string `json:"source"` + Value string `json:"value"` +} + +// SurfaceType defines model for SurfaceType. +type SurfaceType string // ValidationError defines model for ValidationError. type ValidationError struct { @@ -867,20 +874,6 @@ func (response CreatePlace401JSONResponse) VisitCreatePlaceResponse(w http.Respo return err } -type CreatePlace422JSONResponse ConflictError - -func (response CreatePlace422JSONResponse) VisitCreatePlaceResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { - return err - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(422) - _, err := buf.WriteTo(w) - return err -} - type GetPlaceRequestObject struct { Id openapi_types.UUID `json:"id"` } @@ -996,20 +989,6 @@ func (response PatchPlaceAccessibility404JSONResponse) VisitPatchPlaceAccessibil return err } -type PatchPlaceAccessibility422JSONResponse ConflictError - -func (response PatchPlaceAccessibility422JSONResponse) VisitPatchPlaceAccessibilityResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { - return err - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(422) - _, err := buf.WriteTo(w) - return err -} - // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // Revoke the authenticated API key @@ -1237,52 +1216,54 @@ func (sh *strictHandler) PatchPlaceAccessibility(w http.ResponseWriter, r *http. // const string: with thousands of chunks the chained `+` fold is several // times slower for the Go compiler than parsing a slice literal. var swaggerSpec = []string{ - "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", - "NDIZJ6PlSbK+Xv8rAAD//w==", + "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", + "AP//", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/internal/db/migrations/000002_create_accessibility_profiles.up.sql b/internal/db/migrations/000002_create_accessibility_profiles.up.sql index d72232f..d5c37c3 100644 --- a/internal/db/migrations/000002_create_accessibility_profiles.up.sql +++ b/internal/db/migrations/000002_create_accessibility_profiles.up.sql @@ -1,7 +1,11 @@ CREATE TABLE IF NOT EXISTS accessibility_profiles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), place_id UUID NOT NULL UNIQUE REFERENCES places(id), - overall_status TEXT, - components JSONB, + source_reports JSONB, + entrance JSONB, + pathways JSONB, + restroom JSONB, + parking JSONB, + elevator JSONB, updated_at TIMESTAMPTZ ); diff --git a/internal/place/repository.go b/internal/place/repository.go index 28c117c..d276bd3 100644 --- a/internal/place/repository.go +++ b/internal/place/repository.go @@ -112,8 +112,8 @@ func (r *Repository) AttachExternalRef( return nil } -// UpsertProfile creates or replaces the accessibility profile. Always overwrites — API write path. -// Returns created=true when a new row was inserted, false when an existing row was updated. +// UpsertProfile creates or replaces the accessibility profile. Always overwrites. +// Returns created=true when a new row was inserted, false on update. func (r *Repository) UpsertProfile(ctx context.Context, placeID string, profile *models.AccessibilityProfile) (created bool, err error) { if profile == nil { return false, fmt.Errorf("upsert profile: nil profile") @@ -138,8 +138,12 @@ func (r *Repository) UpsertProfile(ctx context.Context, placeID string, profile return tx.Create(profile).Error } updates := map[string]any{ - "overall_status": profile.OverallStatus, - "components": profile.Components, + "source_reports": profile.SourceReports, + "entrance": profile.Entrance, + "pathways": profile.Pathways, + "restroom": profile.Restroom, + "parking": profile.Parking, + "elevator": profile.Elevator, "updated_at": now, "submitted_by": profile.SubmittedBy, "submitted_at": profile.SubmittedAt, @@ -182,8 +186,12 @@ func (r *Repository) UpsertProfileIngestion(ctx context.Context, placeID string, return nil } updates := map[string]any{ - "overall_status": profile.OverallStatus, - "components": profile.Components, + "source_reports": profile.SourceReports, + "entrance": profile.Entrance, + "pathways": profile.Pathways, + "restroom": profile.Restroom, + "parking": profile.Parking, + "elevator": profile.Elevator, "updated_at": now, "submitted_by": nil, "submitted_at": nil, diff --git a/internal/place/repository_integration_test.go b/internal/place/repository_integration_test.go index 9da2aa5..d0c74a3 100644 --- a/internal/place/repository_integration_test.go +++ b/internal/place/repository_integration_test.go @@ -21,6 +21,8 @@ import ( "github.com/InWheelOrg/inwheel-api/pkg/models" ) +func boolPtr(b bool) *bool { return &b } + func TestUpsertBatch_InsertsNewPlaces(t *testing.T) { ctx := context.Background() db, cleanup, err := testhelpers.StartPostgres(ctx) @@ -242,7 +244,7 @@ func TestRepository_UpsertProfile_CreatesWhenAbsent(t *testing.T) { placeID := mustCreatePlace(ctx, t, gormDB, 1001, "Profile Test Place") profile := &models.AccessibilityProfile{ - OverallStatus: models.StatusAccessible, + Entrance: &models.EntranceProps{IsLevel: boolPtr(true)}, } created, err := repo.UpsertProfile(ctx, placeID, profile) if err != nil { @@ -256,8 +258,8 @@ func TestRepository_UpsertProfile_CreatesWhenAbsent(t *testing.T) { if err := gormDB.Where("place_id = ?", placeID).First(&got).Error; err != nil { t.Fatalf("load profile: %v", err) } - if got.OverallStatus != models.StatusAccessible { - t.Errorf("OverallStatus = %q, want %q", got.OverallStatus, models.StatusAccessible) + if got.Entrance == nil || got.Entrance.IsLevel == nil || !*got.Entrance.IsLevel { + t.Errorf("Entrance.IsLevel = %v, want true", got.Entrance) } if got.PlaceID != placeID { t.Errorf("PlaceID = %q, want %q", got.PlaceID, placeID) @@ -275,7 +277,7 @@ func TestRepository_UpsertProfile_UpdatesWhenPresent(t *testing.T) { repo := place.NewRepository(gormDB) placeID := mustCreatePlace(ctx, t, gormDB, 1002, "Profile Update Place") - first := &models.AccessibilityProfile{OverallStatus: models.StatusUnknown} + first := &models.AccessibilityProfile{Entrance: &models.EntranceProps{IsLevel: boolPtr(false)}} created, err := repo.UpsertProfile(ctx, placeID, first) if err != nil { t.Fatalf("first UpsertProfile: %v", err) @@ -284,7 +286,7 @@ func TestRepository_UpsertProfile_UpdatesWhenPresent(t *testing.T) { t.Errorf("created = false, want true on first insert") } - second := &models.AccessibilityProfile{OverallStatus: models.StatusLimited} + second := &models.AccessibilityProfile{Entrance: &models.EntranceProps{IsLevel: boolPtr(true)}} created, err = repo.UpsertProfile(ctx, placeID, second) if err != nil { t.Fatalf("second UpsertProfile: %v", err) @@ -297,8 +299,8 @@ func TestRepository_UpsertProfile_UpdatesWhenPresent(t *testing.T) { if err := gormDB.Where("place_id = ?", placeID).First(&got).Error; err != nil { t.Fatalf("load profile: %v", err) } - if got.OverallStatus != models.StatusLimited { - t.Errorf("OverallStatus = %q, want %q", got.OverallStatus, models.StatusLimited) + if got.Entrance == nil || got.Entrance.IsLevel == nil || !*got.Entrance.IsLevel { + t.Errorf("Entrance.IsLevel = %v, want true (updated)", got.Entrance) } var count int64 gormDB.Model(&models.AccessibilityProfile{}).Where("place_id = ?", placeID).Count(&count) @@ -318,13 +320,13 @@ func TestRepository_UpsertProfile_OverwritesUserVerified(t *testing.T) { repo := place.NewRepository(gormDB) placeID := mustCreatePlace(ctx, t, gormDB, 1003, "User Verified Overwrite Place") - if _, err := repo.UpsertProfile(ctx, placeID, &models.AccessibilityProfile{OverallStatus: models.StatusAccessible, UserVerified: true}); err != nil { + if _, err := repo.UpsertProfile(ctx, placeID, &models.AccessibilityProfile{Entrance: &models.EntranceProps{IsLevel: boolPtr(true)}, UserVerified: true}); err != nil { t.Fatalf("seed: %v", err) } override := &models.AccessibilityProfile{ - OverallStatus: models.StatusInaccessible, - UserVerified: false, + Entrance: &models.EntranceProps{IsLevel: boolPtr(false)}, + UserVerified: false, } if _, err := repo.UpsertProfile(ctx, placeID, override); err != nil { t.Fatalf("override UpsertProfile: %v", err) @@ -334,8 +336,8 @@ func TestRepository_UpsertProfile_OverwritesUserVerified(t *testing.T) { if err := gormDB.Where("place_id = ?", placeID).First(&got).Error; err != nil { t.Fatalf("load profile: %v", err) } - if got.OverallStatus != models.StatusInaccessible { - t.Errorf("OverallStatus = %q, want %q", got.OverallStatus, models.StatusInaccessible) + if got.Entrance == nil || got.Entrance.IsLevel == nil || *got.Entrance.IsLevel { + t.Errorf("Entrance.IsLevel = %v, want false (overwritten)", got.Entrance) } } @@ -348,7 +350,7 @@ func TestRepository_UpsertProfile_PlaceNotFound(t *testing.T) { defer cleanup() repo := place.NewRepository(db) - _, err = repo.UpsertProfile(ctx, "00000000-0000-0000-0000-000000000000", &models.AccessibilityProfile{OverallStatus: models.StatusAccessible}) + _, err = repo.UpsertProfile(ctx, "00000000-0000-0000-0000-000000000000", &models.AccessibilityProfile{}) if !errors.Is(err, place.ErrPlaceNotFound) { t.Errorf("err = %v, want ErrPlaceNotFound", err) } @@ -364,7 +366,7 @@ func TestRepository_UpsertProfileIngestion_InsertsWhenAbsent(t *testing.T) { repo := place.NewRepository(db) placeID := mustCreatePlace(ctx, t, db, 9004, "Café Pascal Ingestion") - written, err := repo.UpsertProfileIngestion(ctx, placeID, &models.AccessibilityProfile{OverallStatus: models.StatusAccessible}) + written, err := repo.UpsertProfileIngestion(ctx, placeID, &models.AccessibilityProfile{Entrance: &models.EntranceProps{IsLevel: boolPtr(true)}}) if err != nil { t.Fatalf("UpsertProfileIngestion: %v", err) } @@ -373,8 +375,8 @@ func TestRepository_UpsertProfileIngestion_InsertsWhenAbsent(t *testing.T) { } var stored models.AccessibilityProfile db.Where("place_id = ?", placeID).First(&stored) - if stored.OverallStatus != models.StatusAccessible { - t.Errorf("OverallStatus = %q, want accessible", stored.OverallStatus) + if stored.Entrance == nil || stored.Entrance.IsLevel == nil || !*stored.Entrance.IsLevel { + t.Errorf("Entrance.IsLevel = %v, want true", stored.Entrance) } } @@ -388,10 +390,10 @@ func TestRepository_UpsertProfileIngestion_OverwritesNonVerified(t *testing.T) { repo := place.NewRepository(db) placeID := mustCreatePlace(ctx, t, db, 9005, "Café Pascal Ingestion2") - if _, err := repo.UpsertProfileIngestion(ctx, placeID, &models.AccessibilityProfile{OverallStatus: models.StatusLimited}); err != nil { + if _, err := repo.UpsertProfileIngestion(ctx, placeID, &models.AccessibilityProfile{Entrance: &models.EntranceProps{IsLevel: boolPtr(false)}}); err != nil { t.Fatalf("seed: %v", err) } - written, err := repo.UpsertProfileIngestion(ctx, placeID, &models.AccessibilityProfile{OverallStatus: models.StatusAccessible}) + written, err := repo.UpsertProfileIngestion(ctx, placeID, &models.AccessibilityProfile{Entrance: &models.EntranceProps{IsLevel: boolPtr(true)}}) if err != nil { t.Fatalf("second: %v", err) } @@ -400,8 +402,8 @@ func TestRepository_UpsertProfileIngestion_OverwritesNonVerified(t *testing.T) { } var stored models.AccessibilityProfile db.Where("place_id = ?", placeID).First(&stored) - if stored.OverallStatus != models.StatusAccessible { - t.Errorf("OverallStatus = %q, want accessible", stored.OverallStatus) + if stored.Entrance == nil || stored.Entrance.IsLevel == nil || !*stored.Entrance.IsLevel { + t.Errorf("Entrance.IsLevel = %v, want true (overwritten)", stored.Entrance) } } @@ -415,10 +417,10 @@ func TestRepository_UpsertProfileIngestion_SkipsUserVerified(t *testing.T) { repo := place.NewRepository(db) placeID := mustCreatePlace(ctx, t, db, 9006, "Café Pascal Ingestion3") - if _, err := repo.UpsertProfile(ctx, placeID, &models.AccessibilityProfile{OverallStatus: models.StatusAccessible, UserVerified: true}); err != nil { + if _, err := repo.UpsertProfile(ctx, placeID, &models.AccessibilityProfile{Entrance: &models.EntranceProps{IsLevel: boolPtr(true)}, UserVerified: true}); err != nil { t.Fatalf("seed: %v", err) } - written, err := repo.UpsertProfileIngestion(ctx, placeID, &models.AccessibilityProfile{OverallStatus: models.StatusInaccessible}) + written, err := repo.UpsertProfileIngestion(ctx, placeID, &models.AccessibilityProfile{Entrance: &models.EntranceProps{IsLevel: boolPtr(false)}}) if err != nil { t.Fatalf("machine: %v", err) } @@ -427,8 +429,8 @@ func TestRepository_UpsertProfileIngestion_SkipsUserVerified(t *testing.T) { } var stored models.AccessibilityProfile db.Where("place_id = ?", placeID).First(&stored) - if stored.OverallStatus != models.StatusAccessible { - t.Errorf("user-verified row must survive; got %q", stored.OverallStatus) + if stored.Entrance == nil || stored.Entrance.IsLevel == nil || !*stored.Entrance.IsLevel { + t.Errorf("user-verified row must survive; Entrance.IsLevel = %v, want true", stored.Entrance) } } diff --git a/internal/sources/osm/a11y.go b/internal/sources/osm/a11y.go index 1d16a51..cf68978 100644 --- a/internal/sources/osm/a11y.go +++ b/internal/sources/osm/a11y.go @@ -7,156 +7,117 @@ package osm import ( "strconv" + "time" "github.com/InWheelOrg/inwheel-api/pkg/models" ) -// mapTagsToProfile derives an AccessibilityProfile from accessibility tags -// present on an OSM POI node. Returns nil when no accessibility signal is -// found. Reads only tags on the POI itself; no graph traversal. +// mapTagsToProfile derives an AccessibilityProfile from OSM accessibility tags +// on a POI node. Returns nil when no accessibility signal is found. +// The OSM wheelchair tag is stored as a SourceReport, not interpreted as truth. func mapTagsToProfile(tags map[string]string) *models.AccessibilityProfile { - overall, hasOverall := wheelchairToStatus(tags["wheelchair"]) - - var components []models.A11yComponent - if c, ok := mapRestroom(tags); ok { - components = append(components, c) - } - if c, ok := mapParking(tags); ok { - components = append(components, c) - } - if c, ok := mapEntrance(tags); ok { - components = append(components, c) - } - if c, ok := mapElevator(tags); ok { - components = append(components, c) + var reports models.SourceReports + if v, ok := tags["wheelchair"]; ok { + switch v { + case "yes", "designated", "limited", "no": + reports = append(reports, models.SourceReport{ + Source: "osm", + Value: v, + RecordedAt: time.Now(), + }) + } } - if !hasOverall && len(components) == 0 { + entrance := mapEntrance(tags) + restroom := mapRestroom(tags) + parking := mapParking(tags) + elevator := mapElevator(tags) + + if len(reports) == 0 && entrance == nil && restroom == nil && parking == nil && elevator == nil { return nil } - if !hasOverall { - overall = models.StatusUnknown - } - return &models.AccessibilityProfile{ - OverallStatus: overall, - Components: components, - } -} -func wheelchairToStatus(v string) (models.A11yStatus, bool) { - switch v { - case "yes", "designated": - return models.StatusAccessible, true - case "limited": - return models.StatusLimited, true - case "no": - return models.StatusInaccessible, true - default: - return "", false + return &models.AccessibilityProfile{ + SourceReports: reports, + Entrance: entrance, + Restroom: restroom, + Parking: parking, + Elevator: elevator, } } -func mapRestroom(tags map[string]string) (models.A11yComponent, bool) { +func mapRestroom(tags map[string]string) *models.RestroomProps { v, ok := tags["toilets:wheelchair"] if !ok { - return models.A11yComponent{}, false + return nil } switch v { case "yes": - yes := true - return models.A11yComponent{ - Type: models.ComponentRestroom, - OverallStatus: models.StatusAccessible, - Restroom: &models.RestroomProperties{WheelchairAccessible: &yes}, - }, true + t := true + return &models.RestroomProps{IsAccessible: &t} case "no": - no := false - return models.A11yComponent{ - Type: models.ComponentRestroom, - OverallStatus: models.StatusInaccessible, - Restroom: &models.RestroomProperties{WheelchairAccessible: &no}, - }, true + f := false + return &models.RestroomProps{IsAccessible: &f} } - return models.A11yComponent{}, false + return nil } -func mapParking(tags map[string]string) (models.A11yComponent, bool) { +func mapParking(tags map[string]string) *models.ParkingProps { if v, ok := tags["capacity:disabled"]; ok { n, err := strconv.Atoi(v) if err != nil { - return models.A11yComponent{}, false - } - if n > 0 { - yes := true - return models.A11yComponent{ - Type: models.ComponentParking, - OverallStatus: models.StatusAccessible, - Parking: &models.ParkingProperties{ - HasDisabledSpaces: &yes, - Count: &n, - }, - }, true + return nil } - no := false - return models.A11yComponent{ - Type: models.ComponentParking, - OverallStatus: models.StatusInaccessible, - Parking: &models.ParkingProperties{HasDisabledSpaces: &no, Count: &n}, - }, true + hasSpaces := n > 0 + return &models.ParkingProps{HasDisabledSpaces: &hasSpaces, Count: &n} } if tags["parking:disabled"] == "no" { - no := false - return models.A11yComponent{ - Type: models.ComponentParking, - OverallStatus: models.StatusInaccessible, - Parking: &models.ParkingProperties{HasDisabledSpaces: &no}, - }, true + f := false + return &models.ParkingProps{HasDisabledSpaces: &f} } - return models.A11yComponent{}, false + return nil } -func mapEntrance(tags map[string]string) (models.A11yComponent, bool) { - props := &models.EntranceProperties{} - any := false +func mapEntrance(tags map[string]string) *models.EntranceProps { + props := &models.EntranceProps{} + found := false if v := tags["automatic_door"]; v != "" && v != "no" { - t := true - props.IsAutomatic = &t - any = true + auto := models.DoorAutomatic + if props.Door == nil { + props.Door = &models.DoorProps{} + } + props.Door.Type = auto + found = true } if stepCountPositive(tags["step_count"]) || stepCountPositive(tags["entrance:step_count"]) { - t := true - props.HasStep = &t - any = true + f := false + props.IsLevel = &f + found = true } - // ramp:wheelchair takes precedence over the generic ramp key. if v, ok := tags["ramp:wheelchair"]; ok { switch v { case "yes": t := true - props.HasRamp = &t - any = true + props.HasFixedRamp = &t + found = true case "no": f := false - props.HasRamp = &f - any = true + props.HasFixedRamp = &f + found = true } } else if tags["ramp"] == "no" { f := false - props.HasRamp = &f - any = true + props.HasFixedRamp = &f + found = true } - if !any { - return models.A11yComponent{}, false + if !found { + return nil } - return models.A11yComponent{ - Type: models.ComponentEntrance, - OverallStatus: models.StatusUnknown, - Entrance: props, - }, true + return props } func stepCountPositive(v string) bool { @@ -167,13 +128,9 @@ func stepCountPositive(v string) bool { return err == nil && n > 0 } -func mapElevator(tags map[string]string) (models.A11yComponent, bool) { +func mapElevator(tags map[string]string) *models.ElevatorProps { if tags["elevator"] == "yes" { - return models.A11yComponent{ - Type: models.ComponentElevator, - OverallStatus: models.StatusAccessible, - Elevator: &models.ElevatorProperties{}, - }, true + return &models.ElevatorProps{} } - return models.A11yComponent{}, false + return nil } diff --git a/internal/sources/osm/a11y_test.go b/internal/sources/osm/a11y_test.go index e5af8d9..9b77525 100644 --- a/internal/sources/osm/a11y_test.go +++ b/internal/sources/osm/a11y_test.go @@ -11,6 +11,8 @@ import ( "github.com/InWheelOrg/inwheel-api/pkg/models" ) +func boolPtr(b bool) *bool { return &b } + func TestMapTagsToProfile_ReturnsNilWhenNoA11ySignal(t *testing.T) { got := mapTagsToProfile(map[string]string{ "amenity": "cafe", @@ -23,13 +25,13 @@ func TestMapTagsToProfile_ReturnsNilWhenNoA11ySignal(t *testing.T) { func TestMapTagsToProfile_WheelchairValues(t *testing.T) { cases := []struct { - tag string - want models.A11yStatus + tag string + wantValue string }{ - {"yes", models.StatusAccessible}, - {"designated", models.StatusAccessible}, - {"limited", models.StatusLimited}, - {"no", models.StatusInaccessible}, + {"yes", "yes"}, + {"designated", "designated"}, + {"limited", "limited"}, + {"no", "no"}, } for _, c := range cases { t.Run(c.tag, func(t *testing.T) { @@ -37,137 +39,159 @@ func TestMapTagsToProfile_WheelchairValues(t *testing.T) { if got == nil { t.Fatalf("expected profile, got nil") } - if got.OverallStatus != c.want { - t.Errorf("OverallStatus = %q, want %q", got.OverallStatus, c.want) + if len(got.SourceReports) != 1 { + t.Fatalf("expected 1 source report, got %d", len(got.SourceReports)) + } + sr := got.SourceReports[0] + if sr.Source != "osm" { + t.Errorf("Source = %q, want osm", sr.Source) + } + if sr.Value != c.wantValue { + t.Errorf("Value = %q, want %q", sr.Value, c.wantValue) } }) } } -func TestMapTagsToProfile_UnknownStatusWhenComponentOnlyTags(t *testing.T) { - got := mapTagsToProfile(map[string]string{ - "toilets:wheelchair": "yes", - }) +func TestMapTagsToProfile_UnknownWheelchairTagSkipped(t *testing.T) { + got := mapTagsToProfile(map[string]string{"wheelchair": "permissive"}) + if got != nil { + t.Errorf("unknown wheelchair value should not emit a profile, got %+v", got) + } +} + +func TestMapTagsToProfile_ComponentOnlyNoSourceReport(t *testing.T) { + got := mapTagsToProfile(map[string]string{"toilets:wheelchair": "yes"}) if got == nil { t.Fatalf("expected profile when toilet tag present") } - if got.OverallStatus != models.StatusUnknown { - t.Errorf("OverallStatus = %q, want unknown", got.OverallStatus) + if len(got.SourceReports) != 0 { + t.Errorf("expected no source reports, got %v", got.SourceReports) } - if len(got.Components) != 1 || got.Components[0].Type != models.ComponentRestroom { - t.Errorf("Components = %+v, want one restroom", got.Components) + if got.Restroom == nil { + t.Error("expected Restroom to be set") } } func TestMapTagsToProfile_Restroom(t *testing.T) { - cases := []struct { - name string - val string - want models.A11yStatus - access *bool - }{ - {"yes", "yes", models.StatusAccessible, boolPtr(true)}, - {"no", "no", models.StatusInaccessible, boolPtr(false)}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - got := mapTagsToProfile(map[string]string{"toilets:wheelchair": c.val}) - if got == nil { - t.Fatalf("expected profile") - } - r := findComponent(t, got, models.ComponentRestroom) - if r.OverallStatus != c.want { - t.Errorf("OverallStatus = %q, want %q", r.OverallStatus, c.want) - } - if r.Restroom == nil || r.Restroom.WheelchairAccessible == nil { - t.Fatalf("Restroom.WheelchairAccessible nil") - } - if *r.Restroom.WheelchairAccessible != *c.access { - t.Errorf("WheelchairAccessible = %v, want %v", *r.Restroom.WheelchairAccessible, *c.access) - } - }) - } + t.Run("yes sets IsAccessible true", func(t *testing.T) { + got := mapTagsToProfile(map[string]string{"toilets:wheelchair": "yes"}) + if got == nil || got.Restroom == nil { + t.Fatal("expected restroom") + } + if got.Restroom.IsAccessible == nil || !*got.Restroom.IsAccessible { + t.Error("IsAccessible should be true") + } + }) + t.Run("no sets IsAccessible false", func(t *testing.T) { + got := mapTagsToProfile(map[string]string{"toilets:wheelchair": "no"}) + if got == nil || got.Restroom == nil { + t.Fatal("expected restroom") + } + if got.Restroom.IsAccessible == nil || *got.Restroom.IsAccessible { + t.Error("IsAccessible should be false") + } + }) } func TestMapTagsToProfile_Parking(t *testing.T) { t.Run("capacity:disabled positive", func(t *testing.T) { got := mapTagsToProfile(map[string]string{"capacity:disabled": "3"}) - p := findComponent(t, got, models.ComponentParking) - if p.OverallStatus != models.StatusAccessible { - t.Errorf("OverallStatus = %q, want accessible", p.OverallStatus) + if got == nil || got.Parking == nil { + t.Fatal("expected parking") } - if p.Parking == nil || p.Parking.HasDisabledSpaces == nil || !*p.Parking.HasDisabledSpaces { - t.Errorf("HasDisabledSpaces not set true") + if got.Parking.HasDisabledSpaces == nil || !*got.Parking.HasDisabledSpaces { + t.Error("HasDisabledSpaces should be true") } - if p.Parking.Count == nil || *p.Parking.Count != 3 { - t.Errorf("Count = %v, want 3", p.Parking.Count) + if got.Parking.Count == nil || *got.Parking.Count != 3 { + t.Errorf("Count = %v, want 3", got.Parking.Count) } }) t.Run("capacity:disabled zero", func(t *testing.T) { got := mapTagsToProfile(map[string]string{"capacity:disabled": "0"}) - p := findComponent(t, got, models.ComponentParking) - if p.OverallStatus != models.StatusInaccessible { - t.Errorf("OverallStatus = %q, want inaccessible", p.OverallStatus) + if got == nil || got.Parking == nil { + t.Fatal("expected parking") } - if p.Parking == nil || p.Parking.HasDisabledSpaces == nil || *p.Parking.HasDisabledSpaces { - t.Errorf("HasDisabledSpaces should be false") + if got.Parking.HasDisabledSpaces == nil || *got.Parking.HasDisabledSpaces { + t.Error("HasDisabledSpaces should be false") } - if p.Parking.Count == nil || *p.Parking.Count != 0 { - t.Errorf("Count = %v, want 0 (preserves confirmed-zero from source tag)", p.Parking.Count) + if got.Parking.Count == nil || *got.Parking.Count != 0 { + t.Errorf("Count = %v, want 0", got.Parking.Count) + } + }) + t.Run("parking:disabled=no", func(t *testing.T) { + got := mapTagsToProfile(map[string]string{"parking:disabled": "no"}) + if got == nil || got.Parking == nil { + t.Fatal("expected parking") + } + if got.Parking.HasDisabledSpaces == nil || *got.Parking.HasDisabledSpaces { + t.Error("HasDisabledSpaces should be false") } }) } func TestMapTagsToProfile_Entrance(t *testing.T) { - t.Run("automatic_door=button sets IsAutomatic", func(t *testing.T) { + t.Run("automatic_door=button sets door type", func(t *testing.T) { got := mapTagsToProfile(map[string]string{"automatic_door": "button"}) - e := findComponent(t, got, models.ComponentEntrance) - if e.Entrance == nil || e.Entrance.IsAutomatic == nil || !*e.Entrance.IsAutomatic { - t.Errorf("IsAutomatic not set true") + if got == nil || got.Entrance == nil { + t.Fatal("expected entrance") + } + if got.Entrance.Door == nil || got.Entrance.Door.Type != models.DoorAutomatic { + t.Errorf("Door.Type = %q, want automatic", got.Entrance.Door.Type) } }) - t.Run("automatic_door=no does not set IsAutomatic", func(t *testing.T) { + t.Run("automatic_door=no does not produce entrance", func(t *testing.T) { got := mapTagsToProfile(map[string]string{"automatic_door": "no"}) if got != nil { - t.Errorf("automatic_door=no alone should not emit an entrance component, got %+v", got) + t.Errorf("automatic_door=no alone should not emit a profile") } }) - t.Run("step_count creates HasStep", func(t *testing.T) { + t.Run("step_count sets IsLevel false", func(t *testing.T) { got := mapTagsToProfile(map[string]string{"step_count": "2"}) - e := findComponent(t, got, models.ComponentEntrance) - if e.Entrance == nil || e.Entrance.HasStep == nil || !*e.Entrance.HasStep { - t.Errorf("HasStep not set true") + if got == nil || got.Entrance == nil { + t.Fatal("expected entrance") + } + if got.Entrance.IsLevel == nil || *got.Entrance.IsLevel { + t.Error("IsLevel should be false") } }) t.Run("entrance:step_count is honoured", func(t *testing.T) { got := mapTagsToProfile(map[string]string{"entrance:step_count": "1"}) - e := findComponent(t, got, models.ComponentEntrance) - if e.Entrance == nil || e.Entrance.HasStep == nil || !*e.Entrance.HasStep { - t.Errorf("HasStep not set true via entrance:step_count") + if got == nil || got.Entrance == nil { + t.Fatal("expected entrance") + } + if got.Entrance.IsLevel == nil || *got.Entrance.IsLevel { + t.Error("IsLevel should be false") } }) - t.Run("ramp:wheelchair=yes sets HasRamp true", func(t *testing.T) { + t.Run("ramp:wheelchair=yes sets HasFixedRamp true", func(t *testing.T) { got := mapTagsToProfile(map[string]string{"ramp:wheelchair": "yes"}) - e := findComponent(t, got, models.ComponentEntrance) - if e.Entrance == nil || e.Entrance.HasRamp == nil || !*e.Entrance.HasRamp { - t.Errorf("HasRamp not set true") + if got == nil || got.Entrance == nil { + t.Fatal("expected entrance") + } + if got.Entrance.HasFixedRamp == nil || !*got.Entrance.HasFixedRamp { + t.Error("HasFixedRamp should be true") } }) - t.Run("ramp:wheelchair=no sets HasRamp false", func(t *testing.T) { + t.Run("ramp:wheelchair=no sets HasFixedRamp false", func(t *testing.T) { got := mapTagsToProfile(map[string]string{"step_count": "1", "ramp:wheelchair": "no"}) - e := findComponent(t, got, models.ComponentEntrance) - if e.Entrance == nil || e.Entrance.HasRamp == nil || *e.Entrance.HasRamp { - t.Errorf("HasRamp should be false, got %v", e.Entrance.HasRamp) + if got == nil || got.Entrance == nil { + t.Fatal("expected entrance") + } + if got.Entrance.HasFixedRamp == nil || *got.Entrance.HasFixedRamp { + t.Errorf("HasFixedRamp should be false, got %v", got.Entrance.HasFixedRamp) } }) t.Run("ramp:wheelchair takes precedence over ramp", func(t *testing.T) { got := mapTagsToProfile(map[string]string{"ramp": "yes", "ramp:wheelchair": "no", "step_count": "1"}) - e := findComponent(t, got, models.ComponentEntrance) - if e.Entrance == nil || e.Entrance.HasRamp == nil || *e.Entrance.HasRamp { - t.Errorf("ramp:wheelchair=no should win, got HasRamp=%v", e.Entrance.HasRamp) + if got == nil || got.Entrance == nil { + t.Fatal("expected entrance") + } + if got.Entrance.HasFixedRamp == nil || *got.Entrance.HasFixedRamp { + t.Errorf("ramp:wheelchair=no should win, HasFixedRamp=%v", got.Entrance.HasFixedRamp) } }) - t.Run("ramp=yes alone returns nil (non-wheelchair-specific ramp is not a signal)", func(t *testing.T) { + t.Run("ramp=yes alone returns nil", func(t *testing.T) { got := mapTagsToProfile(map[string]string{"ramp": "yes"}) if got != nil { t.Errorf("ramp=yes alone should return nil, got %+v", got) @@ -177,13 +201,12 @@ func TestMapTagsToProfile_Entrance(t *testing.T) { func TestMapTagsToProfile_Elevator(t *testing.T) { got := mapTagsToProfile(map[string]string{"elevator": "yes"}) - e := findComponent(t, got, models.ComponentElevator) - if e.OverallStatus != models.StatusAccessible { - t.Errorf("elevator status = %q, want accessible", e.OverallStatus) + if got == nil || got.Elevator == nil { + t.Fatal("expected elevator") } } -func TestMapTagsToProfile_AggregatesMultipleComponents(t *testing.T) { +func TestMapTagsToProfile_AggregatesAllComponents(t *testing.T) { got := mapTagsToProfile(map[string]string{ "wheelchair": "yes", "toilets:wheelchair": "yes", @@ -192,41 +215,21 @@ func TestMapTagsToProfile_AggregatesMultipleComponents(t *testing.T) { "elevator": "yes", }) if got == nil { - t.Fatalf("expected profile") + t.Fatal("expected profile") } - if got.OverallStatus != models.StatusAccessible { - t.Errorf("OverallStatus = %q, want accessible", got.OverallStatus) + if len(got.SourceReports) != 1 || got.SourceReports[0].Value != "yes" { + t.Errorf("expected one OSM source report with value 'yes', got %v", got.SourceReports) } - want := map[models.A11yComponentType]bool{ - models.ComponentRestroom: false, - models.ComponentParking: false, - models.ComponentEntrance: false, - models.ComponentElevator: false, + if got.Restroom == nil { + t.Error("expected Restroom") } - for _, c := range got.Components { - if _, ok := want[c.Type]; ok { - want[c.Type] = true - } + if got.Parking == nil { + t.Error("expected Parking") } - for k, v := range want { - if !v { - t.Errorf("missing %q component", k) - } + if got.Entrance == nil { + t.Error("expected Entrance") } -} - -func findComponent(t *testing.T, p *models.AccessibilityProfile, ct models.A11yComponentType) models.A11yComponent { - t.Helper() - if p == nil { - t.Fatalf("profile is nil") - } - for _, c := range p.Components { - if c.Type == ct { - return c - } + if got.Elevator == nil { + t.Error("expected Elevator") } - t.Fatalf("component %q not found, components = %+v", ct, p.Components) - return models.A11yComponent{} } - -func boolPtr(b bool) *bool { return &b } diff --git a/internal/sources/osm/transformer_test.go b/internal/sources/osm/transformer_test.go index 0b168f9..391c344 100644 --- a/internal/sources/osm/transformer_test.go +++ b/internal/sources/osm/transformer_test.go @@ -95,13 +95,13 @@ func TestTransformNode_ReturnsProfileWhenA11yTagsPresent(t *testing.T) { t.Fatalf("TransformNode: %v", err) } if place.Accessibility != nil { - t.Errorf("place.Accessibility should be nil — profile is returned separately") + t.Errorf("place.Accessibility should be nil; profile is returned separately") } if profile == nil { t.Fatalf("profile = nil, want non-nil when wheelchair=yes present") } - if profile.OverallStatus != models.StatusAccessible { - t.Errorf("OverallStatus = %q, want accessible", profile.OverallStatus) + if len(profile.SourceReports) == 0 || profile.SourceReports[0].Value != "yes" { + t.Errorf("expected source report with value 'yes', got %v", profile.SourceReports) } } diff --git a/internal/validation/validation.go b/internal/validation/validation.go index 7adc4f6..f8662ec 100644 --- a/internal/validation/validation.go +++ b/internal/validation/validation.go @@ -11,7 +11,6 @@ package validation import ( - "encoding/json" "fmt" "regexp" "strings" @@ -32,8 +31,6 @@ const ( maxTagEntries = 50 maxTagKeyLength = 64 maxTagValueLength = 256 - maxMetadataEntries = 50 - maxMetadataBytes = 4 * 1024 maxParkingCount = 10000 ) @@ -62,12 +59,6 @@ func Place(p *models.Place) []FieldError { errs = append(errs, validateTags(p.Tags)...) - if p.Accessibility != nil { - for _, comp := range p.Accessibility.Components { - errs = append(errs, validateMetadata(comp.Metadata)...) - } - } - return errs } @@ -138,23 +129,6 @@ func validateTags(tags models.PlaceTags) []FieldError { return errs } -func validateMetadata(md map[string]any) []FieldError { - if len(md) > maxMetadataEntries { - return []FieldError{{Field: "metadata", Reason: fmt.Sprintf("must contain ≤ %d entries", maxMetadataEntries)}} - } - if len(md) == 0 { - return nil - } - b, err := json.Marshal(md) - if err != nil { - return []FieldError{{Field: "metadata", Reason: "must be JSON-serialisable"}} - } - if len(b) > maxMetadataBytes { - return []FieldError{{Field: "metadata", Reason: fmt.Sprintf("serialised size exceeds %d bytes", maxMetadataBytes)}} - } - return nil -} - func countNonNilFloats(ptrs ...*float64) int { n := 0 for _, p := range ptrs { diff --git a/pkg/models/accessibility.go b/pkg/models/accessibility.go index c35492d..14262ad 100644 --- a/pkg/models/accessibility.go +++ b/pkg/models/accessibility.go @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -// Package models defines the domain types shared across InWheel services. package models import ( @@ -13,144 +12,155 @@ import ( "time" ) -// A11yStatus defines the overall accessibility state of a place. -type A11yStatus string +// AccessibilityProfile stores factual accessibility data for a place. +// Component fields hold measured facts; SourceReports carries raw opinions from +// external sources (e.g. OSM wheelchair=yes). No server-side accessibility +// judgment is made; clients apply their own logic per user need. +type AccessibilityProfile struct { + ID string `json:"id,omitempty" gorm:"primaryKey;type:uuid;default:gen_random_uuid()"` + PlaceID string `json:"place_id,omitempty" gorm:"uniqueIndex;type:uuid"` + SourceReports SourceReports `json:"source_reports,omitempty" gorm:"type:jsonb"` + Entrance *EntranceProps `json:"entrance,omitempty" gorm:"type:jsonb"` + Pathways *PathwayProps `json:"pathways,omitempty" gorm:"type:jsonb"` + Restroom *RestroomProps `json:"restroom,omitempty" gorm:"type:jsonb"` + Parking *ParkingProps `json:"parking,omitempty" gorm:"type:jsonb"` + Elevator *ElevatorProps `json:"elevator,omitempty" gorm:"type:jsonb"` + UserVerified bool `json:"user_verified,omitempty"` + SubmittedBy *string `json:"-" gorm:"type:uuid"` + SubmittedAt *time.Time `json:"submitted_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitzero"` +} + +// SourceReport is a raw opinion about accessibility from a named external source. +// Records what a source said without interpreting it. Clients decide trust level. +type SourceReport struct { + Source string `json:"source"` // e.g. "osm", "wheelmap", "user" + Value string `json:"value"` // raw: "yes", "limited", "no" + RecordedAt time.Time `json:"recorded_at"` +} + +type SourceReports []SourceReport + +func (s *SourceReports) Scan(value interface{}) error { return scanJSONB(s, value) } +func (s SourceReports) Value() (driver.Value, error) { return marshalJSONB(s) } + +type DoorType string const ( - // StatusAccessible means the place is fully accessible. - StatusAccessible A11yStatus = "accessible" - // StatusLimited means the place is partially accessible (e.g., requires assistance). - StatusLimited A11yStatus = "limited" - // StatusInaccessible means the place is not accessible. - StatusInaccessible A11yStatus = "inaccessible" - // StatusUnknown means accessibility information is not available. - StatusUnknown A11yStatus = "unknown" + DoorAutomatic DoorType = "automatic" + DoorManual DoorType = "manual" + DoorRevolving DoorType = "revolving" + DoorNone DoorType = "none" ) -// A11yComponentType identifies the kind of accessibility feature. -type A11yComponentType string +type SurfaceType string const ( - ComponentEntrance A11yComponentType = "entrance" - ComponentRestroom A11yComponentType = "restroom" - ComponentParking A11yComponentType = "parking" - ComponentElevator A11yComponentType = "elevator" - ComponentOther A11yComponentType = "other" + SurfaceAsphalt SurfaceType = "asphalt" + SurfacePavingStones SurfaceType = "paving_stones" + SurfaceCobblestone SurfaceType = "cobblestone" + SurfaceGravel SurfaceType = "gravel" + SurfaceConcrete SurfaceType = "concrete" + SurfaceWood SurfaceType = "wood" + SurfaceCarpet SurfaceType = "carpet" + SurfaceTiles SurfaceType = "tiles" ) -// AccessibilityProfile summarizes the accessibility of a place. -type AccessibilityProfile struct { - // ID is the unique identifier for the profile. - ID string `json:"id,omitzero" gorm:"primaryKey;type:uuid;default:gen_random_uuid()"` - // PlaceID is the identifier of the related place. - PlaceID string `json:"place_id,omitzero" gorm:"uniqueIndex;type:uuid"` - // OverallStatus is the client-submitted accessibility rating, validated against component flags on write. - OverallStatus A11yStatus `json:"overall_status"` - // Components are the individual accessibility features (entrance, etc). - Components A11yComponents `json:"components,omitzero" gorm:"type:jsonb"` - // SubmittedBy is the ID of the API key that last wrote this record. Internal only. - SubmittedBy *string `json:"-" gorm:"type:uuid"` - // SubmittedAt is the timestamp of the last submission. - SubmittedAt *time.Time `json:"submitted_at,omitzero"` - // UserVerified indicates that a human user explicitly submitted this accessibility data. - // When true, the ingestion service will not overwrite this profile with automated data. - UserVerified bool `json:"user_verified,omitzero"` - // UpdatedAt is the timestamp when the profile was last updated. - UpdatedAt time.Time `json:"updated_at,omitzero"` +type DoorProps struct { + Type DoorType `json:"type,omitempty"` + Width *float64 `json:"width,omitempty"` // metres, clear opening } -// A11yComponent represents a modular accessibility feature. -type A11yComponent struct { - // Type is the kind of component. - Type A11yComponentType `json:"type"` - // IsInherited is true if the component data is inherited from a parent place. - IsInherited bool `json:"is_inherited"` - // SourceID is the ID of the Place that owns this specific data. - SourceID string `json:"source_id,omitzero"` - // OverallStatus is the summary rating of this specific component. - OverallStatus A11yStatus `json:"overall_status"` - // AuditFlags contains technical violations detected. - AuditFlags []string `json:"audit_flags,omitzero"` - // Entrance contains properties for an entrance component. - Entrance *EntranceProperties `json:"entrance,omitzero"` - // Restroom contains properties for a restroom component. - Restroom *RestroomProperties `json:"restroom,omitzero"` - // Parking contains properties for a parking component. - Parking *ParkingProperties `json:"parking,omitzero"` - // Elevator contains properties for an elevator component. - Elevator *ElevatorProperties `json:"elevator,omitzero"` - // Metadata contains additional un-modeled tags or source-specific data. - Metadata map[string]any `json:"metadata,omitzero"` +// EntranceProps. All dimensions in metres. AuditFlags are computed on write. +// IsInherited and SourceID are set by ComputeEffectiveProfile at read time, never stored. +type EntranceProps struct { + IsLevel *bool `json:"is_level,omitempty"` + HasFixedRamp *bool `json:"has_fixed_ramp,omitempty"` + HasRemovableRamp *bool `json:"has_removable_ramp,omitempty"` + SlopePercent *float64 `json:"slope_percent,omitempty"` + Width *float64 `json:"width,omitempty"` + Door *DoorProps `json:"door,omitempty"` + HasIntercom *bool `json:"has_intercom,omitempty"` + AuditFlags []string `json:"audit_flags,omitempty"` + IsInherited bool `json:"is_inherited,omitempty"` + SourceID string `json:"source_id,omitempty"` } -// A11yComponents is a custom type, so we can implement SQL scanning. -type A11yComponents []A11yComponent - -// EntranceProperties contains technical details about an entrance. -type EntranceProperties struct { - // Width is the clear opening width in meters. - Width *float64 `json:"width,omitzero"` - // HasRamp indicates if a ramp is present. - HasRamp *bool `json:"has_ramp,omitzero"` - // IsAutomatic indicates if the door is automatic. - IsAutomatic *bool `json:"is_automatic,omitzero"` - // HasStep indicates if there is a step at the entrance. - HasStep *bool `json:"has_step,omitzero"` - // StepHeight is the height of the step in meters. - StepHeight *float64 `json:"step_height,omitzero"` +func (p *EntranceProps) Scan(value interface{}) error { return scanJSONB(p, value) } +func (p EntranceProps) Value() (driver.Value, error) { return marshalJSONB(p) } + +type PathwayProps struct { + Width *float64 `json:"width,omitempty"` // narrowest passage in metres + Surface SurfaceType `json:"surface,omitempty"` + IsKerbstoneFree *bool `json:"is_kerbstone_free,omitempty"` + HasSteps *bool `json:"has_steps,omitempty"` + AuditFlags []string `json:"audit_flags,omitempty"` + IsInherited bool `json:"is_inherited,omitempty"` + SourceID string `json:"source_id,omitempty"` } -// RestroomProperties contains details about a restroom feature. -type RestroomProperties struct { - // WheelchairAccessible indicates if the restroom is accessible to wheelchairs. - WheelchairAccessible *bool `json:"wheelchair_accessible,omitzero"` - // GrabRails indicates if grab rails are installed. - GrabRails *bool `json:"grab_rails,omitzero"` - // ChangingTable indicates if a diaper changing table is available. - ChangingTable *bool `json:"changing_table,omitzero"` - // DoorWidth is the width of the restroom door in meters. - DoorWidth *float64 `json:"door_width,omitzero"` +func (p *PathwayProps) Scan(value interface{}) error { return scanJSONB(p, value) } +func (p PathwayProps) Value() (driver.Value, error) { return marshalJSONB(p) } + +// RestroomProps. All dimensions in metres. +type RestroomProps struct { + IsAccessible *bool `json:"is_accessible,omitempty"` + DoorWidth *float64 `json:"door_width,omitempty"` + TurningRadius *float64 `json:"turning_radius,omitempty"` + HasGrabRails *bool `json:"has_grab_rails,omitempty"` + HasRollInShower *bool `json:"has_roll_in_shower,omitempty"` + ToiletSeatHeight *float64 `json:"toilet_seat_height,omitempty"` + HasEmergencyPull *bool `json:"has_emergency_pull,omitempty"` + HasChangingTable *bool `json:"has_changing_table,omitempty"` + AuditFlags []string `json:"audit_flags,omitempty"` + IsInherited bool `json:"is_inherited,omitempty"` + SourceID string `json:"source_id,omitempty"` } -// ParkingProperties contains details about disabled parking. -type ParkingProperties struct { - // HasDisabledSpaces indicates if there are dedicated disabled parking spots. - HasDisabledSpaces *bool `json:"has_disabled_spaces,omitzero"` - // Count is the number of disabled parking spaces available. - Count *int `json:"count,omitzero"` +func (p *RestroomProps) Scan(value interface{}) error { return scanJSONB(p, value) } +func (p RestroomProps) Value() (driver.Value, error) { return marshalJSONB(p) } + +// ParkingProps. DistanceToEntrance and Width in metres. +type ParkingProps struct { + HasDisabledSpaces *bool `json:"has_disabled_spaces,omitempty"` + Count *int `json:"count,omitempty"` + DistanceToEntrance *float64 `json:"distance_to_entrance,omitempty"` + Width *float64 `json:"width,omitempty"` + HasDedicatedSignage *bool `json:"has_dedicated_signage,omitempty"` + AuditFlags []string `json:"audit_flags,omitempty"` + IsInherited bool `json:"is_inherited,omitempty"` + SourceID string `json:"source_id,omitempty"` } -// ElevatorProperties contains technical details about an elevator. -type ElevatorProperties struct { - // Width is the elevator cabin width in meters. - Width *float64 `json:"width,omitzero"` - // Depth is the elevator cabin depth in meters. - Depth *float64 `json:"depth,omitzero"` - // Braille indicates if there are braille labels on the buttons. - Braille *bool `json:"braille,omitzero"` - // Audio indicates if there are audio announcements. - Audio *bool `json:"audio,omitzero"` +func (p *ParkingProps) Scan(value interface{}) error { return scanJSONB(p, value) } +func (p ParkingProps) Value() (driver.Value, error) { return marshalJSONB(p) } + +// ElevatorProps. Width, Depth, and DoorWidth in metres. +type ElevatorProps struct { + Width *float64 `json:"width,omitempty"` + Depth *float64 `json:"depth,omitempty"` + DoorWidth *float64 `json:"door_width,omitempty"` + HasBraille *bool `json:"has_braille,omitempty"` + HasAudio *bool `json:"has_audio,omitempty"` + AuditFlags []string `json:"audit_flags,omitempty"` + IsInherited bool `json:"is_inherited,omitempty"` + SourceID string `json:"source_id,omitempty"` } -// Scan tells the SQL driver how to read the JSONB bytes into the slice. -func (c *A11yComponents) Scan(value interface{}) error { +func (p *ElevatorProps) Scan(value interface{}) error { return scanJSONB(p, value) } +func (p ElevatorProps) Value() (driver.Value, error) { return marshalJSONB(p) } + +func scanJSONB(dest any, value interface{}) error { if value == nil { - *c = make(A11yComponents, 0) return nil } - - bytes, ok := value.([]byte) + b, ok := value.([]byte) if !ok { return errors.New("type assertion to []byte failed") } - - return json.Unmarshal(bytes, c) + return json.Unmarshal(b, dest) } -// Value tells the SQL driver how to write the slice to the database as JSONB. -func (c A11yComponents) Value() (driver.Value, error) { - if c == nil { - return json.Marshal(make(A11yComponents, 0)) - } - return json.Marshal(c) +func marshalJSONB(v any) (driver.Value, error) { + return json.Marshal(v) }