The experiment answered its own question. A district's rules arrived as one long text on one zone at a time -- a report per district, hand exported, priced by the point -- and a wall of code text on a zone card correlates nothing. The importer, the capture section, the curation markers and the workflow plumbing all go; the archive and the library keep no enriched capture. What the experiment built stays: zones carry descriptions through the format, the viewer opens a card on any zone that has one, and the text payload defers zone prose the way it defers a pin's. Those seams are the format's own, waiting for data that earns them.
- commit
- 68b174f5ad3b39f611df4c40e50e96c8fd1695cf
- parent
- 53883d05201882dca238c5caf388db3735b22a44
- author
- Dami <dami.etoile@gmail.com>
- kind
- refactor risk score 2.6 of 3, Jev's estimate
| M | .github/ | 10 | +- |
| M | docs/ | 27 | +---- |
| M | internal/ | 152 | +----------------------- |
| M | internal/ | 58 | --------- |
| M | tools/ | 12 | -- |
| M | tools/ | 2 | - |
| D | tools/ | 187 | ------------------------------ |
| D | tools/ | 120 | ------------------- |
| M | tools/ | 4 | +- |
@@ -66,15 +66,7 @@ jobs:
6666 6767 - name: Crawl6868 working-directory: atlas69- run: |70- # Zone reports exported from a Zoneomics subscription live beside71- # the archive on the state branch, one directory per city; a72- # directory's presence is the opt-in.73- FLAGS=""74- if [ -d "../state/zoneomics/${{ inputs.city }}" ]; then75- FLAGS="-zoneomics ../state/zoneomics/${{ inputs.city }}"76- fi77- go run ./tools/crawl -archive ../state/fmg-archive -arcgis "${{ inputs.city }}" $FLAGS69+ run: go run ./tools/crawl -archive ../state/fmg-archive -arcgis "${{ inputs.city }}"7870 7971 - name: Compose8072 working-directory: atlas@@ -1,8 +1,8 @@
11 # Scheduled city snapshots22 33 A city's `.atlas` can build itself on a schedule: crawl the city's ArcGIS4-hub (and, with a key, the Zoneomics rules behind its zoning), exit quietly5-when nothing changed, and publish a new versioned bundle when something did.4+hub, exit quietly when nothing changed, and publish a new versioned bundle5+when something did.66 The reusable workflow lives in this repository77 (`.github/workflows/snapshot.yml`); this repository's own scheduler runs it88 weekly over the public proof cities. A city IT team runs the same workflow@@ -59,26 +59,3 @@ private city's curation never has to be published. Employees fetch
5959 `https://github.com/<org>/<repo>/releases/download/snapshot-<city>/<city>-latest.atlas`6060 and drop it into their Atlas bundles directory — or IT mirrors it onto a6161 share the app watches.62-63-## Zoneomics, plainly64-65-Zoning-rule enrichment comes from **zone reports you export from your own66-Zoneomics subscription** — the attribute/value CSVs the platform produces,67-one per zoning district. Place them on the state branch under68-`zoneomics/<city-slug>/` and every snapshot joins them to the city's zoning69-zones by zone code; each district's card then carries its purpose,70-permitted and prohibited uses, and dimensional standards, offline.71-72-- No API and no scraping: the API prices single-point reports and the73- public code pages bar automated access, so neither fits correlating a74- whole town. Files you exported under your own subscription are the75- honest channel, and a snapshot run needs no key and no wire.76-- Reports are *point* reports: below the district rules they describe the77- queried parcel — its owner's name, address, valuation. The importer78- drops those rows unconditionally; they never enter a capture.79-- Enrichment is all-or-nothing per capture: an unreadable report fails the80- run rather than silently minting a version that lost its rules.81-- **Before an organization circulates enriched bundles internally, confirm82- that its Zoneomics subscription permits embedding and internal83- redistribution of exported report content.** Without reports, snapshots84- are pure open data with no strings attached.@@ -84,12 +84,6 @@ type Dataset struct {
8484 ZoneOf func(Fields) ZoneKey8585 StrokeWidth float648686 87- // Zoneomics marks the dataset whose zone buckets are enriched with88- // district rules when a crawl is handed exported zone reports: each89- // report joins its bucket by zone code, and the note lands on the90- // bucket's zone as prose.91- Zoneomics bool92-9387 // Basemap. Emphasis scales a stroke per feature -- an arterial wider9488 // than a lane -- and absent means every feature draws alike.9589 Role string@@ -119,12 +113,11 @@ var Cities = map[string]City{
119113 "redondo-beach-ca": redondoBeach,120114 }121115 122-// redondoBeach is the City of Redondo Beach, California -- the enrichment123-// proof city, chosen because it is the one city the Zoneomics developer124-// sandbox answers for, and it publishes its own ArcGIS hub to be enriched.125-// Its land-use layer is parcel-grained and carries the zoning code on every126-// polygon, so the zoning zones bucket by code and double as the basemap's127-// parcel texture. Field names were verified against the live services.116+// redondoBeach is the City of Redondo Beach, California -- the second117+// proof city. Its land-use layer is parcel-grained and carries the zoning118+// code on every polygon, so the zoning zones bucket by code and double as119+// the basemap's parcel texture, the first use of that role. Field names120+// were verified against the live services.128121 var redondoBeach = City{129122 Slug: "redondo-beach-ca",130123 Title: "Redondo Beach, California",@@ -144,8 +137,7 @@ var redondoBeach = City{
144137 }145138 return ZoneKey{Key: slugify(code), Title: code}146139 },147- Zoneomics: true,148- Role: "parcel",140+ Role: "parcel",149141 },150142 {151143 Slug: "general-plan", Title: "General Plan",@@ -233,7 +225,6 @@ var bend = City{
233225 }234226 return ZoneKey{Key: slugify(code), Title: code}235227 },236- Zoneomics: true,237228 },238229 {239230 Slug: "annexations", Title: "Annexations",@@ -424,21 +415,6 @@ type Capture struct {
424415 Window Window `json:"window"`425416 Basemap MapConfig `json:"basemap"`426417 Datasets []CapturedDataset `json:"datasets"`427- // Zoneomics carries the district rules fetched beside the datasets,428- // one note per zone bucket of the dataset curated for enrichment.429- // Riding the same capture means the enrichment shares the datasets'430- // content addressing: a rules change is a new version the same way a431- // boundary change is, and an unchanged day stays no day at all.432- Zoneomics []ZoneNote `json:"zoneomics,omitempty"`433-}434-435-// ZoneNote is what Zoneomics said about one zone bucket: the bucket's own436-// key, and the answer's fields flattened to text under dotted names --437-// kept as data rather than prose, so re-curating how a zone card reads is438-// a translator change, not a re-fetch.439-type ZoneNote struct {440- Code string `json:"code"`441- Fields Fields `json:"fields,omitempty"`442418 }443419 444420 // MapConfig is the basemap pyramid as rendered: the deepest level drawn, and@@ -490,7 +466,6 @@ func (c *Capture) Normalize() {
490466 features := dataset.Features491467 sort.Slice(features, func(a, b int) bool { return features[a].ID < features[b].ID })492468 }493- sort.Slice(c.Zoneomics, func(a, b int) bool { return c.Zoneomics[a].Code < c.Zoneomics[b].Code })494469 }495470 496471 // MaybeTranslate hands other sources' snapshots through untouched and@@ -766,20 +741,6 @@ const zoneLimit = 256
766741 // region, and line features widen into ribbon polygons first, because a767742 // zone is ground and a line has none.768743 func buildRegions(capture *Capture, pairs []pairing, ids *mgdoc.IDSpace) ([]mgdoc.Region, error) {769- // Notes join the zone buckets under the buckets' own spelling: the770- // slugified code, which is what ZoneOf keys zoning zones by.771- notes := make(map[string]ZoneNote, len(capture.Zoneomics))772- for _, note := range capture.Zoneomics {773- if note.Code == "" {774- return nil, fmt.Errorf("a zoneomics note names no zone")775- }776- key := slugify(note.Code)777- if _, doubled := notes[key]; doubled {778- return nil, fmt.Errorf("zoneomics notes name %q twice", note.Code)779- }780- notes[key] = note781- }782-783744 regions := []mgdoc.Region{}784745 for _, pair := range pairs {785746 curated, data := pair.curated, pair.data@@ -814,13 +775,6 @@ func buildRegions(capture *Capture, pairs []pairing, ids *mgdoc.IDSpace) ([]mgdo
814775 semconv.KeyStrokeWidthPx: strconv.FormatFloat(curated.StrokeWidth, 'f', -1, 64),815776 }816777 }817- // The enriched dataset's zones speak their rules: the818- // captured Zoneomics note composes into the zone's card.819- if curated.Zoneomics {820- if note, told := notes[key.Key]; told {821- zone.Description = composeZoneNote(note)822- }823- }824778 buckets[key.Key] = zone825779 order = append(order, key.Key)826780 }@@ -906,100 +860,6 @@ func zoneGeometry(window Window, curated *Dataset, feature Feature) (*mgdoc.Geom
906860 return &mgdoc.Geometry{Type: "MultiPolygon", Coordinates: coordinates}, nil907861 }908862 909-// composeZoneNote writes a zone's card from the note's flattened fields.910-// Field names arrive dotted by API section -- zoning.*, plu.*, controls.*911-// -- and the exact vocabulary varies by plan, so composition is generic:912-// sections in a fixed order, each field spelled out as its own line, every913-// string scrubbed, and the source named at the end. Re-curating how a card914-// reads is an edit here, re-applied to captures already on disk.915-func composeZoneNote(note ZoneNote) string {916- sections := []struct{ prefix, title string }{917- {"zoning.", "Zoning"},918- {"plu.", "Permitted uses"},919- {"controls.", "Standards"},920- }921- keys := make([]string, 0, len(note.Fields))922- for key := range note.Fields {923- keys = append(keys, key)924- }925- sort.Strings(keys)926-927- var parts []string928- claimed := make(map[string]bool, len(keys))929- for _, section := range sections {930- var lines []string931- for _, key := range keys {932- if !strings.HasPrefix(key, section.prefix) {933- continue934- }935- claimed[key] = true936- if line := noteLine(strings.TrimPrefix(key, section.prefix), note.Fields[key]); line != "" {937- lines = append(lines, line)938- }939- }940- if len(lines) > 0 {941- parts = append(parts, section.title+"\n"+strings.Join(lines, "\n"))942- }943- }944- // Fields outside the known sections still speak, unsectioned: a plan945- // answering in a vocabulary this table has not met loses nothing.946- var loose []string947- for _, key := range keys {948- if claimed[key] {949- continue950- }951- if line := noteLine(key, note.Fields[key]); line != "" {952- loose = append(loose, line)953- }954- }955- if len(loose) > 0 {956- parts = append(parts, strings.Join(loose, "\n"))957- }958- if len(parts) == 0 {959- return ""960- }961- return strings.Join(parts, "\n\n") + "\n\nData: Zoneomics"962-}963-964-// noteLine spells one flattened field as card prose: the name unslugged,965-// the value unpacked and scrubbed -- per line, so losing a smuggled URL966-// never flattens the card's shape.967-func noteLine(name, value string) string {968- value = scrub(unpackControls(strings.TrimSpace(value)))969- if value == "" {970- return ""971- }972- return mgdoc.SpellOut(strings.ReplaceAll(strings.ReplaceAll(name, "_", "-"), ".", "-")) + ": " + value973-}974-975-// unpackControls opens the exports' packed control spelling --976-// "max_building_height_ft-25; min_lot_area_sq_ft-7000" -- into prose, and977-// leaves any value not shaped that way exactly as it came.978-func unpackControls(value string) string {979- segments := strings.Split(value, "; ")980- unpacked := make([]string, 0, len(segments))981- for _, segment := range segments {982- name, rest, found := strings.Cut(segment, "-")983- if !found || name == "" || !packedControlName(name) {984- return value985- }986- unpacked = append(unpacked, mgdoc.SpellOut(strings.ReplaceAll(name, "_", "-"))+" "+rest)987- }988- return strings.Join(unpacked, "; ")989-}990-991-// packedControlName admits only the snake_case names the packed spelling992-// uses, so a prose value that merely contains a hyphen stays prose.993-func packedControlName(name string) bool {994- for _, r := range name {995- if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '_' {996- continue997- }998- return false999- }1000- return true1001-}1002-1003863 // synthetic is one position's whole journey: true degrees to world pixel to1004864 // the synthetic coordinates a zone ring speaks.1005865 func synthetic(window Window, lon, lat float64) []float64 {@@ -5,7 +5,6 @@ import (
55 "encoding/json"66 "math"77 "strconv"8- "strings"98 "testing"109 1110 "github.com/FelineStateMachine/atlas/internal/mgdoc"@@ -370,63 +369,6 @@ func TestTranslateRefusals(t *testing.T) {
370369 })371370 }372371 373-// A captured Zoneomics note composes onto its zone bucket's card, and only374-// onto the dataset curated for enrichment.375-func TestZoneomicsNotesComposeOntoZones(t *testing.T) {376- capture := fixtureCapture()377- capture.Zoneomics = []ZoneNote{378- {Code: "PF", Fields: Fields{379- "zoning.zone_name": "PF",380- "zoning.zone_guide": "Public facilities district",381- "plu.uses": "civic; schools",382- "controls.max_height_ft": "45",383- "controls.notes": "see https://example.com/code",384- }},385- }386- out := translated(t, capture)387- var pf *mgdoc.Region388- for at := range out.Regions {389- if out.Regions[at].Title == "PF" {390- pf = &out.Regions[at]391- }392- }393- if pf == nil {394- t.Fatal("no PF zone")395- }396- description := pf.Description397- for _, want := range []string{"Zoning", "Zone Name: PF", "Permitted uses", "civic; schools", "Standards", "Max Height Ft: 45", "Data: Zoneomics"} {398- if !strings.Contains(description, want) {399- t.Fatalf("card misses %q:\n%s", want, description)400- }401- }402- if strings.Contains(description, "http") {403- t.Fatalf("card carries a live URL:\n%s", description)404- }405- // The un-enriched RS zone and every non-zoning zone stay quiet.406- for _, region := range out.Regions {407- if region.Title != "PF" && region.Description != "" {408- t.Fatalf("zone %q composed a card from nothing", region.Title)409- }410- }411-}412-413-func TestTranslateRefusesBrokenZoneomicsNotes(t *testing.T) {414- refuse := func(name string, notes []ZoneNote) {415- t.Helper()416- capture := fixtureCapture()417- capture.Zoneomics = notes418- raw, err := json.Marshal(&capture)419- if err != nil {420- t.Fatal(err)421- }422- if _, err := Translate(raw); err == nil {423- t.Fatalf("%s: translated anyway", name)424- }425- }426- refuse("nameless note", []ZoneNote{{Code: "", Fields: Fields{"a": "b"}}})427- refuse("doubled note", []ZoneNote{{Code: "PF", Fields: Fields{"a": "b"}}, {Code: "pf", Fields: Fields{"a": "b"}}})428-}429-430372 // One nameless row is the source's hygiene: the pin is left out and the431373 // rest of the dataset stands.432374 func TestTranslateSkipsLoneUntitledPin(t *testing.T) {@@ -89,18 +89,6 @@ func runArcgis(ctx context.Context, fetcher *fetcher, o options) error {
8989 Features: features,9090 })9191 }92- // The Zoneomics leg rides the same capture as the datasets it explains,93- // and it is asked for or absent -- never partial: a capture that lost94- // its enrichment to an unreadable file would read as the city having95- // changed.96- if o.zoneomics != "" {97- notes, err := loadZoneomicsReports(o.zoneomics)98- if err != nil {99- return err100- }101- capture.Zoneomics = notes102- fmt.Printf(" zoneomics %5d district reports\n", len(notes))103- }10492 capture.Normalize()10593 raw, err := json.MarshalIndent(capture, "", " ")10694 if err != nil {@@ -67,7 +67,6 @@ type options struct {
6767 trek string6868 arcgis string6969 captureDate string70- zoneomics string7170 maxZoom int7271 list bool7372 dryRun bool@@ -85,7 +84,6 @@ func parseOptions() options {
8584 flag.StringVar(&o.trek, "trek", "", "planetary body from NASA Trek and the IAU Gazetteer, e.g. mars")8685 flag.StringVar(&o.arcgis, "arcgis", "", "city from an ArcGIS Hub open-data site, e.g. bend-or")8786 flag.StringVar(&o.captureDate, "capture-date", "", "the day an -arcgis capture answers to, YYYY-MM-DD (default: today)")88- flag.StringVar(&o.zoneomics, "zoneomics", "", "directory (or one file) of exported Zoneomics zone-report CSVs to join onto the -arcgis capture")8987 flag.IntVar(&o.maxZoom, "max-zoom", 0, "deepest zoom to capture (default: the layer's own maximum)")9088 flag.BoolVar(&o.list, "list", false, "list games, or the maps of -game, and exit")9189 flag.BoolVar(&o.dryRun, "dry-run", false, "report what would be fetched without writing anything")@@ -1,187 +0,0 @@
1-// The Zoneomics leg of an ArcGIS crawl reads zone reports the user exported2-// from their own Zoneomics account: one CSV per zoning district, each an3-// attribute/value table of the district's identity, its permitted and4-// prohibited uses, and its dimensional controls. The reports join the5-// captured zoning zones by zone code and ride the same content-addressed6-// capture as the boundaries they explain.7-//8-// Files are the only door used. The API prices single-point reports and the9-// public code pages bar automated access, so neither fits correlating a10-// whole town; a subscriber exports each district's report once and drops11-// the files beside the archive, and a snapshot needs no key and no wire.12-//13-// A report is a *point* report, and below the district's rules it carries14-// the queried parcel's own facts -- owner name, address, valuation. Those15-// rows are dropped unconditionally: a person's name and home value have no16-// place in a distributed bundle, and no curation may re-admit them.17-package main18-19-import (20- "encoding/csv"21- "fmt"22- "os"23- "path/filepath"24- "sort"25- "strings"26-27- "github.com/FelineStateMachine/atlas/internal/arcgismap"28-)29-30-// loadZoneomicsReports reads every .csv under path (or path itself when it31-// is one file) into one ZoneNote per district. Any unreadable file, any32-// report without a zone code, and any two reports naming the same code are33-// the caller's failure: a capture must never silently lose or double its34-// enrichment.35-func loadZoneomicsReports(path string) ([]arcgismap.ZoneNote, error) {36- files, err := zoneomicsFiles(path)37- if err != nil {38- return nil, err39- }40- if len(files) == 0 {41- return nil, fmt.Errorf("no zone report CSVs under %s", path)42- }43- seen := make(map[string]string, len(files))44- notes := make([]arcgismap.ZoneNote, 0, len(files))45- for _, file := range files {46- note, err := readZoneReport(file)47- if err != nil {48- return nil, fmt.Errorf("%s: %w", filepath.Base(file), err)49- }50- if prior, doubled := seen[note.Code]; doubled {51- return nil, fmt.Errorf("%s and %s both report zone %s",52- prior, filepath.Base(file), note.Code)53- }54- seen[note.Code] = filepath.Base(file)55- notes = append(notes, note)56- }57- return notes, nil58-}59-60-func zoneomicsFiles(path string) ([]string, error) {61- info, err := os.Stat(path)62- if err != nil {63- return nil, err64- }65- if !info.IsDir() {66- return []string{path}, nil67- }68- entries, err := os.ReadDir(path)69- if err != nil {70- return nil, err71- }72- var files []string73- for _, entry := range entries {74- if !entry.IsDir() && strings.EqualFold(filepath.Ext(entry.Name()), ".csv") {75- files = append(files, filepath.Join(path, entry.Name()))76- }77- }78- sort.Strings(files)79- return files, nil80-}81-82-// readZoneReport reads one exported report into a note: attribute rows83-// mapped into the capture's sectioned field names, parcel rows dropped.84-func readZoneReport(path string) (arcgismap.ZoneNote, error) {85- raw, err := os.ReadFile(path)86- if err != nil {87- return arcgismap.ZoneNote{}, err88- }89- // Exports lead with a byte-order mark.90- text := strings.TrimPrefix(string(raw), "\ufeff")91- reader := csv.NewReader(strings.NewReader(text))92- reader.FieldsPerRecord = -193- rows, err := reader.ReadAll()94- if err != nil {95- return arcgismap.ZoneNote{}, err96- }97-98- note := arcgismap.ZoneNote{Fields: make(arcgismap.Fields)}99- for _, row := range rows {100- if len(row) < 2 {101- continue102- }103- attribute := strings.TrimSpace(row[0])104- value := strings.TrimSpace(row[1])105- if attribute == "" || attribute == "Attributes" || value == "" {106- continue107- }108- if attribute == "zone code" {109- note.Code = value110- }111- name, kept := zoneReportField(attribute)112- if !kept {113- continue114- }115- note.Fields[name] = value116- }117- if note.Code == "" {118- return arcgismap.ZoneNote{}, fmt.Errorf("report names no zone code")119- }120- if len(note.Fields) == 0 {121- return arcgismap.ZoneNote{}, fmt.Errorf("report for zone %s keeps no fields", note.Code)122- }123- return note, nil124-}125-126-// parcelReportRow marks the rows of a point report that describe the queried127-// parcel rather than the district: identity, ownership, valuation. They are128-// dropped before the capture exists, unconditionally -- this list is a129-// floor, not a curation surface.130-func parcelReportRow(attribute string) bool {131- lowered := strings.ToLower(attribute)132- for _, banned := range []string{133- "parcel identification", "property location", "ownership", "owner",134- "valuation", "lot / land characteristics", "land use",135- } {136- if strings.Contains(lowered, banned) {137- return true138- }139- }140- return false141-}142-143-// zoneReportField maps one report attribute onto the capture's sectioned144-// spelling: the district's identity under zoning., its use rules under145-// plu., its dimensional standards under controls. Rows that are links or146-// parcel facts are dropped; anything else unrecognized keeps its own name,147-// unsectioned, so a new report vocabulary loses nothing.148-func zoneReportField(attribute string) (string, bool) {149- if parcelReportRow(attribute) {150- return "", false151- }152- lowered := strings.ToLower(attribute)153- if droppedZoneomicsKey(lowered) {154- return "", false155- }156- slug := strings.ReplaceAll(lowered, " ", "_")157- switch {158- case strings.HasPrefix(lowered, "zone "):159- return "zoning." + slug, true160- case lowered == "prohibited" || lowered == "as of right" ||161- strings.Contains(lowered, "use") || strings.Contains(lowered, "permitted") ||162- strings.HasPrefix(lowered, "short term rental") || lowered == "definitions section":163- return "plu." + slug, true164- case strings.HasSuffix(lowered, "control") || lowered == "assorted" ||165- strings.Contains(attribute, ">"):166- return "controls." + slug, true167- }168- return slug, true169-}170-171-// droppedZoneomicsKey names the report parts a capture leaves behind:172-// links and identifiers, matched by whole token so "guide" and173-// "residential" survive "guid" and "id".174-func droppedZoneomicsKey(key string) bool {175- banned := map[string]bool{176- "url": true, "link": true, "links": true, "href": true, "guid": true,177- "id": true, "ids": true,178- }179- for _, token := range strings.FieldsFunc(strings.ToLower(key), func(r rune) bool {180- return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9')181- }) {182- if banned[token] {183- return true184- }185- }186- return false187-}@@ -1,120 +0,0 @@
1-package main2-3-import (4- "os"5- "path/filepath"6- "strings"7- "testing"8-)9-10-// A fabricated report in the export's shape: BOM, attribute/value rows,11-// district rules above, the queried parcel's own facts below.12-const sampleReport = "\ufeff\"Attributes\",\"Value\"\n" +13- "\"zone code\",\"X-1\"\n" +14- "\"zone name\",\"Example District\"\n" +15- "\"zone guide\",\"A district for examples.\"\n" +16- "\"link\",\"https://example.com/code\"\n" +17- "\"building height control\",\"max_building_height_ft-25\"\n" +18- "\"assorted\",\"minimum_floor_area_sf_per_du_unit-850\"\n" +19- "\"prohibited\",\"Rock quarries, Foundries\"\n" +20- "\"as of right\",\"Parks, Libraries\"\n" +21- "\"short term rental permitted\",\"True\"\n" +22- "\"Parcel Identification\",\"APN: R1234567\"\n" +23- "\"Property Location (Situs / Geospatial)\",\"Address: 1 Example Way\"\n" +24- "\"Ownership Information\",\"Owner Full Name: SOME PERSON\"\n" +25- "\"Valuation (Assessed & Market)\",\"Assd Total Value: 12345\"\n"26-27-func writeReport(t *testing.T, name, content string) string {28- t.Helper()29- dir := t.TempDir()30- path := filepath.Join(dir, name)31- if err := os.WriteFile(path, []byte(content), 0o644); err != nil {32- t.Fatal(err)33- }34- return path35-}36-37-func TestReadZoneReportKeepsRulesAndDropsTheParcel(t *testing.T) {38- note, err := readZoneReport(writeReport(t, "x1.csv", sampleReport))39- if err != nil {40- t.Fatal(err)41- }42- if note.Code != "X-1" {43- t.Fatalf("code = %q", note.Code)44- }45- want := map[string]string{46- "zoning.zone_code": "X-1",47- "zoning.zone_name": "Example District",48- "zoning.zone_guide": "A district for examples.",49- "controls.building_height_control": "max_building_height_ft-25",50- "controls.assorted": "minimum_floor_area_sf_per_du_unit-850",51- "plu.prohibited": "Rock quarries, Foundries",52- "plu.as_of_right": "Parks, Libraries",53- "plu.short_term_rental_permitted": "True",54- }55- if len(note.Fields) != len(want) {56- t.Fatalf("fields = %v", note.Fields)57- }58- for key, value := range want {59- if note.Fields[key] != value {60- t.Fatalf("fields[%q] = %q, want %q", key, note.Fields[key], value)61- }62- }63- // The parcel's facts -- and any owner's name -- must never survive.64- flat := strings.ToLower(spellFields(note.Fields))65- for _, leaked := range []string{"some person", "r1234567", "example way", "12345", "example.com"} {66- if strings.Contains(flat, leaked) {67- t.Fatalf("parcel fact %q leaked into the note", leaked)68- }69- }70-}71-72-func spellFields(fields map[string]string) string {73- var parts []string74- for key, value := range fields {75- parts = append(parts, key+"="+value)76- }77- return strings.Join(parts, " ")78-}79-80-func TestLoadZoneomicsReportsRefusals(t *testing.T) {81- // A report without a zone code is refused.82- if _, err := readZoneReport(writeReport(t, "no-code.csv",83- "\"Attributes\",\"Value\"\n\"zone name\",\"Nameless\"\n")); err == nil {84- t.Fatal("a codeless report was accepted")85- }86-87- // Two reports naming the same code are refused together.88- dir := t.TempDir()89- for _, name := range []string{"a.csv", "b.csv"} {90- if err := os.WriteFile(filepath.Join(dir, name), []byte(sampleReport), 0o644); err != nil {91- t.Fatal(err)92- }93- }94- if _, err := loadZoneomicsReports(dir); err == nil {95- t.Fatal("doubled zone codes were accepted")96- }97-98- // An empty directory is a mistake, not a quiet no-op.99- if _, err := loadZoneomicsReports(t.TempDir()); err == nil {100- t.Fatal("an empty directory was accepted")101- }102-}103-104-func TestLoadZoneomicsReportsReadsAWholeDirectory(t *testing.T) {105- dir := t.TempDir()106- second := strings.Replace(sampleReport, "X-1", "X-2", 1)107- if err := os.WriteFile(filepath.Join(dir, "x1.csv"), []byte(sampleReport), 0o644); err != nil {108- t.Fatal(err)109- }110- if err := os.WriteFile(filepath.Join(dir, "x2.csv"), []byte(second), 0o644); err != nil {111- t.Fatal(err)112- }113- notes, err := loadZoneomicsReports(dir)114- if err != nil {115- t.Fatal(err)116- }117- if len(notes) != 2 || notes[0].Code != "X-1" || notes[1].Code != "X-2" {118- t.Fatalf("notes = %+v", notes)119- }120-}@@ -11,14 +11,14 @@ import (
1111 func TestBuildPayloadDefersZoneProse(t *testing.T) {1212 world := catalogWorld{1313 Zones: []zone{14- {ID: 71, Title: "PF", Description: "Public facilities.\n\nData: Zoneomics"},14+ {ID: 71, Title: "PF", Description: "Public facilities district."},1515 {ID: 72, Title: "RS"},1616 },1717 }1818 detail, _, text := buildPayload(world)1919 2020 entry, told := text["71"]21- if !told || entry.Description != "Public facilities.\n\nData: Zoneomics" {21+ if !told || entry.Description != "Public facilities district." {2222 t.Fatalf("zone text = %+v, %v", entry, told)2323 }2424 if _, told := text["72"]; told {