diff --git a/internal/sources/osm/a11y.go b/internal/sources/osm/a11y.go new file mode 100644 index 0000000..1d16a51 --- /dev/null +++ b/internal/sources/osm/a11y.go @@ -0,0 +1,179 @@ +/* + * Copyright (C) 2026 InWheel Contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package osm + +import ( + "strconv" + + "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. +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) + } + + if !hasOverall && len(components) == 0 { + 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 + } +} + +func mapRestroom(tags map[string]string) (models.A11yComponent, bool) { + v, ok := tags["toilets:wheelchair"] + if !ok { + return models.A11yComponent{}, false + } + switch v { + case "yes": + yes := true + return models.A11yComponent{ + Type: models.ComponentRestroom, + OverallStatus: models.StatusAccessible, + Restroom: &models.RestroomProperties{WheelchairAccessible: &yes}, + }, true + case "no": + no := false + return models.A11yComponent{ + Type: models.ComponentRestroom, + OverallStatus: models.StatusInaccessible, + Restroom: &models.RestroomProperties{WheelchairAccessible: &no}, + }, true + } + return models.A11yComponent{}, false +} + +func mapParking(tags map[string]string) (models.A11yComponent, bool) { + 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 + } + no := false + return models.A11yComponent{ + Type: models.ComponentParking, + OverallStatus: models.StatusInaccessible, + Parking: &models.ParkingProperties{HasDisabledSpaces: &no, Count: &n}, + }, true + } + if tags["parking:disabled"] == "no" { + no := false + return models.A11yComponent{ + Type: models.ComponentParking, + OverallStatus: models.StatusInaccessible, + Parking: &models.ParkingProperties{HasDisabledSpaces: &no}, + }, true + } + return models.A11yComponent{}, false +} + +func mapEntrance(tags map[string]string) (models.A11yComponent, bool) { + props := &models.EntranceProperties{} + any := false + + if v := tags["automatic_door"]; v != "" && v != "no" { + t := true + props.IsAutomatic = &t + any = true + } + + if stepCountPositive(tags["step_count"]) || stepCountPositive(tags["entrance:step_count"]) { + t := true + props.HasStep = &t + any = 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 + case "no": + f := false + props.HasRamp = &f + any = true + } + } else if tags["ramp"] == "no" { + f := false + props.HasRamp = &f + any = true + } + + if !any { + return models.A11yComponent{}, false + } + return models.A11yComponent{ + Type: models.ComponentEntrance, + OverallStatus: models.StatusUnknown, + Entrance: props, + }, true +} + +func stepCountPositive(v string) bool { + if v == "" { + return false + } + n, err := strconv.Atoi(v) + return err == nil && n > 0 +} + +func mapElevator(tags map[string]string) (models.A11yComponent, bool) { + if tags["elevator"] == "yes" { + return models.A11yComponent{ + Type: models.ComponentElevator, + OverallStatus: models.StatusAccessible, + Elevator: &models.ElevatorProperties{}, + }, true + } + return models.A11yComponent{}, false +} diff --git a/internal/sources/osm/a11y_test.go b/internal/sources/osm/a11y_test.go new file mode 100644 index 0000000..e5af8d9 --- /dev/null +++ b/internal/sources/osm/a11y_test.go @@ -0,0 +1,232 @@ +/* + * Copyright (C) 2026 InWheel Contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package osm + +import ( + "testing" + + "github.com/InWheelOrg/inwheel-api/pkg/models" +) + +func TestMapTagsToProfile_ReturnsNilWhenNoA11ySignal(t *testing.T) { + got := mapTagsToProfile(map[string]string{ + "amenity": "cafe", + "name": "Café Pascal", + }) + if got != nil { + t.Errorf("expected nil profile when no a11y tags present, got %+v", got) + } +} + +func TestMapTagsToProfile_WheelchairValues(t *testing.T) { + cases := []struct { + tag string + want models.A11yStatus + }{ + {"yes", models.StatusAccessible}, + {"designated", models.StatusAccessible}, + {"limited", models.StatusLimited}, + {"no", models.StatusInaccessible}, + } + for _, c := range cases { + t.Run(c.tag, func(t *testing.T) { + got := mapTagsToProfile(map[string]string{"wheelchair": c.tag}) + if got == nil { + t.Fatalf("expected profile, got nil") + } + if got.OverallStatus != c.want { + t.Errorf("OverallStatus = %q, want %q", got.OverallStatus, c.want) + } + }) + } +} + +func TestMapTagsToProfile_UnknownStatusWhenComponentOnlyTags(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.Components) != 1 || got.Components[0].Type != models.ComponentRestroom { + t.Errorf("Components = %+v, want one restroom", got.Components) + } +} + +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) + } + }) + } +} + +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 p.Parking == nil || p.Parking.HasDisabledSpaces == nil || !*p.Parking.HasDisabledSpaces { + t.Errorf("HasDisabledSpaces not set true") + } + if p.Parking.Count == nil || *p.Parking.Count != 3 { + t.Errorf("Count = %v, want 3", p.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 p.Parking == nil || p.Parking.HasDisabledSpaces == nil || *p.Parking.HasDisabledSpaces { + t.Errorf("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) + } + }) +} + +func TestMapTagsToProfile_Entrance(t *testing.T) { + t.Run("automatic_door=button sets IsAutomatic", 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") + } + }) + t.Run("automatic_door=no does not set IsAutomatic", 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.Run("step_count creates HasStep", 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") + } + }) + 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") + } + }) + t.Run("ramp:wheelchair=yes sets HasRamp 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") + } + }) + t.Run("ramp:wheelchair=no sets HasRamp 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) + } + }) + 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) + } + }) + t.Run("ramp=yes alone returns nil (non-wheelchair-specific ramp is not a signal)", 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) + } + }) +} + +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) + } +} + +func TestMapTagsToProfile_AggregatesMultipleComponents(t *testing.T) { + got := mapTagsToProfile(map[string]string{ + "wheelchair": "yes", + "toilets:wheelchair": "yes", + "capacity:disabled": "2", + "automatic_door": "yes", + "elevator": "yes", + }) + if got == nil { + t.Fatalf("expected profile") + } + if got.OverallStatus != models.StatusAccessible { + t.Errorf("OverallStatus = %q, want accessible", got.OverallStatus) + } + want := map[models.A11yComponentType]bool{ + models.ComponentRestroom: false, + models.ComponentParking: false, + models.ComponentEntrance: false, + models.ComponentElevator: false, + } + for _, c := range got.Components { + if _, ok := want[c.Type]; ok { + want[c.Type] = true + } + } + for k, v := range want { + if !v { + t.Errorf("missing %q component", k) + } + } +} + +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 + } + } + 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/source.go b/internal/sources/osm/source.go index 4721843..9668ced 100644 --- a/internal/sources/osm/source.go +++ b/internal/sources/osm/source.go @@ -48,7 +48,7 @@ func (s *Source) FullImport(ctx context.Context, sink sources.Sink) error { return nil } - p, err := TransformNode(node.ID, node.Lat, node.Lng, node.Tags, category) + p, _, err := TransformNode(node.ID, node.Lat, node.Lng, node.Tags, category) if err != nil { skipped++ slog.Warn("skipping node", diff --git a/internal/sources/osm/transformer.go b/internal/sources/osm/transformer.go index 74b0690..50f4f82 100644 --- a/internal/sources/osm/transformer.go +++ b/internal/sources/osm/transformer.go @@ -11,11 +11,12 @@ import ( "github.com/InWheelOrg/inwheel-api/pkg/models" ) -// TransformNode converts a filtered OSM node into a models.Place ready for upsert. +// TransformNode converts a filtered OSM node into a models.Place and an optional +// AccessibilityProfile. Profile is nil when no accessibility tags are present. // The category must come from a prior call to Evaluate. -func TransformNode(osmID int64, lat, lng float64, tags map[string]string, category models.Category) (*models.Place, error) { +func TransformNode(osmID int64, lat, lng float64, tags map[string]string, category models.Category) (*models.Place, *models.AccessibilityProfile, error) { if category == "" { - return nil, fmt.Errorf("transform: category is empty for node %d", osmID) + return nil, nil, fmt.Errorf("transform: category is empty for node %d", osmID) } placeTags := make(models.PlaceTags, len(tags)) @@ -23,7 +24,7 @@ func TransformNode(osmID int64, lat, lng float64, tags map[string]string, catego placeTags[k] = v } - return &models.Place{ + place := &models.Place{ OSMID: osmID, OSMType: models.OSMNode, Name: tags["name"], @@ -40,5 +41,6 @@ func TransformNode(osmID int64, lat, lng float64, tags map[string]string, catego }, Source: "osm", Status: models.PlaceStatusActive, - }, nil + } + return place, mapTagsToProfile(tags), nil } diff --git a/internal/sources/osm/transformer_test.go b/internal/sources/osm/transformer_test.go index 6da614f..0b168f9 100644 --- a/internal/sources/osm/transformer_test.go +++ b/internal/sources/osm/transformer_test.go @@ -14,10 +14,10 @@ import ( func TestTransformNode(t *testing.T) { tags := map[string]string{ "amenity": "restaurant", - "name": "Ravintola Tor", + "name": "Le Buffet de la Gare", } - place, err := TransformNode(123, 60.1699, 24.9384, tags, models.CategoryRestaurant) + place, _, err := TransformNode(123, 46.4628, 6.8417, tags, models.CategoryRestaurant) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -28,10 +28,10 @@ func TestTransformNode(t *testing.T) { if place.OSMType != models.OSMNode { t.Errorf("osm_type: got %q want %q", place.OSMType, models.OSMNode) } - if place.Name != "Ravintola Tor" { + if place.Name != "Le Buffet de la Gare" { t.Errorf("name: got %q", place.Name) } - if place.Lat != 60.1699 || place.Lng != 24.9384 { + if place.Lat != 46.4628 || place.Lng != 6.8417 { t.Errorf("coords: got (%v, %v)", place.Lat, place.Lng) } if place.Category != models.CategoryRestaurant { @@ -59,12 +59,13 @@ func TestTransformNode(t *testing.T) { func TestTransformNode_PreservesTags(t *testing.T) { tags := map[string]string{ - "amenity": "restaurant", - "name": "Burger Place", - "addr:city": "Helsinki", + "amenity": "restaurant", + "name": "Le Buffet de la Gare", + "addr:city": "Vevey", + "addr:country": "CH", } - place, err := TransformNode(1, 60, 24, tags, models.CategoryRestaurant) + place, _, err := TransformNode(1, 46.4628, 6.8417, tags, models.CategoryRestaurant) if err != nil { t.Fatal(err) } @@ -77,12 +78,47 @@ func TestTransformNode_PreservesTags(t *testing.T) { } func TestTransformNode_EmptyCategoryReturnsError(t *testing.T) { - _, err := TransformNode(1, 60, 24, map[string]string{"amenity": "restaurant"}, "") + _, _, err := TransformNode(1, 46.4628, 6.8417, map[string]string{"amenity": "restaurant"}, "") if err == nil { t.Fatal("expected error for empty category, got nil") } } +func TestTransformNode_ReturnsProfileWhenA11yTagsPresent(t *testing.T) { + tags := map[string]string{ + "amenity": "cafe", + "name": "Café Pascal", + "wheelchair": "yes", + } + place, profile, err := TransformNode(1, 46.4628, 6.8417, tags, models.CategoryCafe) + if err != nil { + t.Fatalf("TransformNode: %v", err) + } + if place.Accessibility != nil { + 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) + } +} + +func TestTransformNode_ReturnsNilProfileWhenNoA11yTags(t *testing.T) { + tags := map[string]string{ + "amenity": "cafe", + "name": "Café Pascal", + } + _, profile, err := TransformNode(1, 46.4628, 6.8417, tags, models.CategoryCafe) + if err != nil { + t.Fatalf("TransformNode: %v", err) + } + if profile != nil { + t.Errorf("profile = %+v, want nil when no a11y tags", profile) + } +} + func TestDeriveRank(t *testing.T) { cases := []struct { name string