A city's open data says where each zoning district is; what a district means lives in the code, and Zoneomics sells it structured. Correlating a whole town does not fit their API -- it prices single-point reports -- and their public pages bar automated access, so the importer takes the one honest channel: zone reports a subscriber exported themselves, one attribute/value CSV per district, handed to the crawl as files. Each report joins its zoning zone by code and rides the same content- addressed capture as the boundaries it explains, so a rules change is a new version the same way a boundary change is, and a snapshot run needs no key and no wire. The reports are point reports, and below the district's rules they describe the queried parcel -- its owner's name, address, valuation. Those rows are dropped unconditionally, before the capture exists: a person's name and home value have no place in a distributed bundle, and the denylist is a floor, not a curation surface. The translator composes what remains into the zone's card -- identity, permitted and prohibited uses, dimensional standards unpacked into prose -- scrubbed line by line so no live URL survives, and signed Data: Zoneomics.
- commit
- 0ae0dc4d945b8e4e9e6c8829d1ebfe5d969086b8
- parent
- 1a8d8319398364515c8195db7de9ad267050bd05
- author
- Dami <dami.etoile@gmail.com>
- kind
- feat risk score 1.0 of 3, Jev's estimate
| M | internal/ | 138 | ++++++++++++++++++++++ |
| M | internal/ | 58 | +++++++++ |
| M | tools/ | 12 | ++ |
| M | tools/ | 2 | + |
| A | tools/ | 187 | ++++++++++++++++++++++++++++++ |
| A | tools/ | 120 | +++++++++++++++++++ |
@@ -84,6 +84,12 @@ 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+8793 // Basemap. Emphasis scales a stroke per feature -- an arterial wider8894 // than a lane -- and absent means every feature draws alike.8995 Role string@@ -148,6 +154,7 @@ var bend = City{
148154 }149155 return ZoneKey{Key: slugify(code), Title: code}150156 },157+ Zoneomics: true,151158 },152159 {153160 Slug: "annexations", Title: "Annexations",@@ -338,6 +345,21 @@ type Capture struct {
338345 Window Window `json:"window"`339346 Basemap MapConfig `json:"basemap"`340347 Datasets []CapturedDataset `json:"datasets"`348+ // Zoneomics carries the district rules fetched beside the datasets,349+ // one note per zone bucket of the dataset curated for enrichment.350+ // Riding the same capture means the enrichment shares the datasets'351+ // content addressing: a rules change is a new version the same way a352+ // boundary change is, and an unchanged day stays no day at all.353+ Zoneomics []ZoneNote `json:"zoneomics,omitempty"`354+}355+356+// ZoneNote is what Zoneomics said about one zone bucket: the bucket's own357+// key, and the answer's fields flattened to text under dotted names --358+// kept as data rather than prose, so re-curating how a zone card reads is359+// a translator change, not a re-fetch.360+type ZoneNote struct {361+ Code string `json:"code"`362+ Fields Fields `json:"fields,omitempty"`341363 }342364 343365 // MapConfig is the basemap pyramid as rendered: the deepest level drawn, and@@ -389,6 +411,7 @@ func (c *Capture) Normalize() {
389411 features := dataset.Features390412 sort.Slice(features, func(a, b int) bool { return features[a].ID < features[b].ID })391413 }414+ sort.Slice(c.Zoneomics, func(a, b int) bool { return c.Zoneomics[a].Code < c.Zoneomics[b].Code })392415 }393416 394417 // MaybeTranslate hands other sources' snapshots through untouched and@@ -664,6 +687,20 @@ const zoneLimit = 256
664687 // region, and line features widen into ribbon polygons first, because a665688 // zone is ground and a line has none.666689 func buildRegions(capture *Capture, pairs []pairing, ids *mgdoc.IDSpace) ([]mgdoc.Region, error) {690+ // Notes join the zone buckets under the buckets' own spelling: the691+ // slugified code, which is what ZoneOf keys zoning zones by.692+ notes := make(map[string]ZoneNote, len(capture.Zoneomics))693+ for _, note := range capture.Zoneomics {694+ if note.Code == "" {695+ return nil, fmt.Errorf("a zoneomics note names no zone")696+ }697+ key := slugify(note.Code)698+ if _, doubled := notes[key]; doubled {699+ return nil, fmt.Errorf("zoneomics notes name %q twice", note.Code)700+ }701+ notes[key] = note702+ }703+667704 regions := []mgdoc.Region{}668705 for _, pair := range pairs {669706 curated, data := pair.curated, pair.data@@ -698,6 +735,13 @@ func buildRegions(capture *Capture, pairs []pairing, ids *mgdoc.IDSpace) ([]mgdo
698735 semconv.KeyStrokeWidthPx: strconv.FormatFloat(curated.StrokeWidth, 'f', -1, 64),699736 }700737 }738+ // The enriched dataset's zones speak their rules: the739+ // captured Zoneomics note composes into the zone's card.740+ if curated.Zoneomics {741+ if note, told := notes[key.Key]; told {742+ zone.Description = composeZoneNote(note)743+ }744+ }701745 buckets[key.Key] = zone702746 order = append(order, key.Key)703747 }@@ -783,6 +827,100 @@ func zoneGeometry(window Window, curated *Dataset, feature Feature) (*mgdoc.Geom
783827 return &mgdoc.Geometry{Type: "MultiPolygon", Coordinates: coordinates}, nil784828 }785829 830+// composeZoneNote writes a zone's card from the note's flattened fields.831+// Field names arrive dotted by API section -- zoning.*, plu.*, controls.*832+// -- and the exact vocabulary varies by plan, so composition is generic:833+// sections in a fixed order, each field spelled out as its own line, every834+// string scrubbed, and the source named at the end. Re-curating how a card835+// reads is an edit here, re-applied to captures already on disk.836+func composeZoneNote(note ZoneNote) string {837+ sections := []struct{ prefix, title string }{838+ {"zoning.", "Zoning"},839+ {"plu.", "Permitted uses"},840+ {"controls.", "Standards"},841+ }842+ keys := make([]string, 0, len(note.Fields))843+ for key := range note.Fields {844+ keys = append(keys, key)845+ }846+ sort.Strings(keys)847+848+ var parts []string849+ claimed := make(map[string]bool, len(keys))850+ for _, section := range sections {851+ var lines []string852+ for _, key := range keys {853+ if !strings.HasPrefix(key, section.prefix) {854+ continue855+ }856+ claimed[key] = true857+ if line := noteLine(strings.TrimPrefix(key, section.prefix), note.Fields[key]); line != "" {858+ lines = append(lines, line)859+ }860+ }861+ if len(lines) > 0 {862+ parts = append(parts, section.title+"\n"+strings.Join(lines, "\n"))863+ }864+ }865+ // Fields outside the known sections still speak, unsectioned: a plan866+ // answering in a vocabulary this table has not met loses nothing.867+ var loose []string868+ for _, key := range keys {869+ if claimed[key] {870+ continue871+ }872+ if line := noteLine(key, note.Fields[key]); line != "" {873+ loose = append(loose, line)874+ }875+ }876+ if len(loose) > 0 {877+ parts = append(parts, strings.Join(loose, "\n"))878+ }879+ if len(parts) == 0 {880+ return ""881+ }882+ return strings.Join(parts, "\n\n") + "\n\nData: Zoneomics"883+}884+885+// noteLine spells one flattened field as card prose: the name unslugged,886+// the value unpacked and scrubbed -- per line, so losing a smuggled URL887+// never flattens the card's shape.888+func noteLine(name, value string) string {889+ value = scrub(unpackControls(strings.TrimSpace(value)))890+ if value == "" {891+ return ""892+ }893+ return mgdoc.SpellOut(strings.ReplaceAll(strings.ReplaceAll(name, "_", "-"), ".", "-")) + ": " + value894+}895+896+// unpackControls opens the exports' packed control spelling --897+// "max_building_height_ft-25; min_lot_area_sq_ft-7000" -- into prose, and898+// leaves any value not shaped that way exactly as it came.899+func unpackControls(value string) string {900+ segments := strings.Split(value, "; ")901+ unpacked := make([]string, 0, len(segments))902+ for _, segment := range segments {903+ name, rest, found := strings.Cut(segment, "-")904+ if !found || name == "" || !packedControlName(name) {905+ return value906+ }907+ unpacked = append(unpacked, mgdoc.SpellOut(strings.ReplaceAll(name, "_", "-"))+" "+rest)908+ }909+ return strings.Join(unpacked, "; ")910+}911+912+// packedControlName admits only the snake_case names the packed spelling913+// uses, so a prose value that merely contains a hyphen stays prose.914+func packedControlName(name string) bool {915+ for _, r := range name {916+ if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '_' {917+ continue918+ }919+ return false920+ }921+ return true922+}923+786924 // synthetic is one position's whole journey: true degrees to world pixel to787925 // the synthetic coordinates a zone ring speaks.788926 func synthetic(window Window, lon, lat float64) []float64 {@@ -5,6 +5,7 @@ import (
55 "encoding/json"66 "math"77 "strconv"8+ "strings"89 "testing"910 1011 "github.com/FelineStateMachine/atlas/internal/mgdoc"@@ -369,6 +370,63 @@ func TestTranslateRefusals(t *testing.T) {
369370 })370371 }371372 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+372430 // One nameless row is the source's hygiene: the pin is left out and the373431 // rest of the dataset stands.374432 func TestTranslateSkipsLoneUntitledPin(t *testing.T) {@@ -89,6 +89,18 @@ 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+ }92104 capture.Normalize()93105 raw, err := json.MarshalIndent(capture, "", " ")94106 if err != nil {@@ -67,6 +67,7 @@ type options struct {
6767 trek string6868 arcgis string6969 captureDate string70+ zoneomics string7071 maxZoom int7172 list bool7273 dryRun bool@@ -84,6 +85,7 @@ func parseOptions() options {
8485 flag.StringVar(&o.trek, "trek", "", "planetary body from NASA Trek and the IAU Gazetteer, e.g. mars")8586 flag.StringVar(&o.arcgis, "arcgis", "", "city from an ArcGIS Hub open-data site, e.g. bend-or")8687 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")8789 flag.IntVar(&o.maxZoom, "max-zoom", 0, "deepest zoom to capture (default: the layer's own maximum)")8890 flag.BoolVar(&o.list, "list", false, "list games, or the maps of -game, and exit")8991 flag.BoolVar(&o.dryRun, "dry-run", false, "report what would be fetched without writing anything")@@ -0,0 +1,187 @@
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+}@@ -0,0 +1,120 @@
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+}