1package workbench23import (4 "io"5 "net/http"6 "net/url"7 "os"8 "path/filepath"9 "reflect"10 "strings"11 "testing"12 "time"1314 "github.com/FelineStateMachine/atlas/internal/authoring"15 "github.com/FelineStateMachine/atlas/internal/workbench/oprunner"16)1718func writeSampleProject(t *testing.T, dir string) string {19 t.Helper()20 source := filepath.Join(dir, "sample-region.geojson")21 if err := os.WriteFile(source, []byte(`{"type":"FeatureCollection","features":[{"type":"Feature","id":"marker-1","properties":{"id":"marker-1","name":"Sample marker"},"geometry":{"type":"Point","coordinates":[1,1]}}]}`), 0o644); err != nil {22 t.Fatal(err)23 }24 project := filepath.Join(dir, "sample-region.atlas-project")25 manifest := `schema: atlas-project/v226schema-namespace: example.invalid/atlas/sample-region/v127id: sample-region28title: Sample Region29target:30 world: sample-region31 title: Sample Region32 coordinate-space:33 id: local34 kind: projected35 unit: metre36 definition: local sample grid37 extent: [0, 0, 100, 100]38feature-sets:39 - id: markers40 title: Markers41 semantic-type: place42 geometry: point43sources:44 - id: sample-features45 adapter: geojson46 locator: sample-region.geojson47 media-type: application/geo+json48 license: Example data49 attribution: Sample Region contributors50 mapping:51 feature-set: markers52 identity: id53 feature-title: properties.name54 geometry:55 source-space: local56 transform:57 kind: identity58presentation:59 id: default60 title: Default61 styles:62 - id: marker63 symbol: circle64 layers:65 - id: markers66 feature-set: markers67 style: marker68 label: Markers69 visible: true70 order: 171release:72 revision: 173`74 if err := os.WriteFile(project, []byte(manifest), 0o644); err != nil {75 t.Fatal(err)76 }77 return project78}7980func projectTargets(t *testing.T) Targets {81 t.Helper()82 root := t.TempDir()83 return Targets{84 Atlas: "/opt/atlas", Dir: root, Project: writeSampleProject(t, root),85 Cache: filepath.Join(root, "cache"), Registry: filepath.Join(root, "library"),86 }87}8889func TestBuildOperationUsesOnlyStartupAuthority(t *testing.T) {90 targets := projectTargets(t)91 op, err := buildOperation(targets, false)92 if err != nil {93 t.Fatal(err)94 }95 want := []string{"/opt/atlas", "build", "--log-json", "-cache", targets.Cache, "-bundles", targets.Registry, targets.Project}96 if !reflect.DeepEqual(op.Argv, want) {97 t.Fatalf("argv %v, want %v", op.Argv, want)98 }99 offline, err := buildOperation(targets, true)100 if err != nil {101 t.Fatal(err)102 }103 if !strings.Contains(strings.Join(offline.Argv, " "), "-offline") {104 t.Fatalf("offline argv = %v", offline.Argv)105 }106 missing := targets107 missing.Project = ""108 if _, err := buildOperation(missing, false); err == nil || !strings.Contains(err.Error(), "project") {109 t.Fatalf("missing project refusal = %v", err)110 }111}112113func TestProjectPageMakesTheNativeBuildGraphVisible(t *testing.T) {114 targets := projectTargets(t)115 held, err := New(Options{Targets: targets, Runtime: []byte("/* runtime */")})116 if err != nil {117 t.Fatal(err)118 }119 server := site(t, held)120 response, body := get(t, server, "/project")121 if response.StatusCode != http.StatusOK {122 t.Fatalf("project answered %d: %s", response.StatusCode, body)123 }124 wants(t, "project", body,125 "Sample Region", "One manifest → one native volume", "sample-features", "geojson",126 "0 / 1 requests", "feature sources", "semantic sets", "presentation layers",127 "Build native Atlas", "Portable manifest", "Sample Region contributors",128 "Choose any Earth area", "World", "U.S. source coverage", "USGS Topo", "TIGER roads", "TIGER hydro", "TIGER counties",129 "Typed FeatureSets", "Compiled Atlas truth")130 if response, _ := get(t, server, "/operations"); response.StatusCode != http.StatusNotFound {131 t.Errorf("retired operations route answered %d", response.StatusCode)132 }133 if response, _ := get(t, server, "/sources"); response.StatusCode != http.StatusNotFound {134 t.Errorf("retired sources route answered %d", response.StatusCode)135 }136}137138func TestUSAreaAuthoringReplacesTheSingleManifestWithTheSelectedDefaults(t *testing.T) {139 targets := projectTargets(t)140 held, err := New(Options{Targets: targets, Runtime: []byte("/* runtime */")})141 if err != nil {142 t.Fatal(err)143 }144 server := site(t, held)145 form := usAreaForm()146 form.Set("road-label", "Routes")147 form.Set("road-color", "#c05030")148 form.Set("road-order", "17")149 form.Del("road-visible")150 response, body := postForm(t, noRedirectClient(server.Client()), server.URL+"/project/us-area", form)151 if response.StatusCode != http.StatusSeeOther {152 t.Fatalf("U.S. area authoring answered %d: %s", response.StatusCode, body)153 }154 project, err := authoring.LoadProject(targets.Project)155 if err != nil {156 t.Fatalf("generated manifest: %v", err)157 }158 if project.Title != "Sample Region" || len(project.Rasters) != 1 || len(project.Sources) != 6 {159 t.Fatalf("generated project = %+v", project)160 }161 var roads authoring.Layer162 for _, layer := range project.Presentation.Layers {163 if layer.ID == "roads" {164 roads = layer165 }166 }167 if roads.Label != "Routes" || roads.Visible || roads.Order != 17 {168 t.Fatalf("presentation choices were not authored: %+v", roads)169 }170 if project.Target.CoordinateSpace.OriginX == 0 || project.Target.CoordinateSpace.OriginY == 0 {171 t.Fatalf("generated tile origins = %+v", project.Target.CoordinateSpace)172 }173 _, page := get(t, server, "/project?notice=Official+U.S.+profile+installed")174 wants(t, "generated authoring", page, "Official U.S. profile installed", "Capture requests", "Raster tiles", "Compiled Atlas truth")175}176177func TestUSAreaAuthoringRefusesAnInvalidAreaBeforeReplacingTheManifest(t *testing.T) {178 targets := projectTargets(t)179 original, err := os.ReadFile(targets.Project)180 if err != nil {181 t.Fatal(err)182 }183 held, err := New(Options{Targets: targets})184 if err != nil {185 t.Fatal(err)186 }187 server := site(t, held)188 form := usAreaForm()189 form.Set("west", "2")190 form.Set("south", "86")191 form.Set("east", "3")192 form.Set("north", "89")193 response, body := postForm(t, server.Client(), server.URL+"/project/us-area", form)194 if response.StatusCode != http.StatusBadRequest || !strings.Contains(body, "Web Mercator world") {195 t.Fatalf("invalid area answered %d: %s", response.StatusCode, body)196 }197 after, err := os.ReadFile(targets.Project)198 if err != nil {199 t.Fatal(err)200 }201 if string(after) != string(original) {202 t.Fatal("invalid area replaced the manifest")203 }204}205206func usAreaForm() url.Values {207 return url.Values{208 "west": {"-105.1"}, "south": {"39.6"}, "east": {"-104.9"}, "north": {"39.8"},209 "detail": {"12"}, "topo": {"true"}, "roads": {"true"}, "hydro": {"true"}, "counties": {"true"},210 "road-label": {"Roads"}, "hydro-label": {"Water"}, "county-label": {"Counties"},211 "road-color": {"#b45f3c"}, "hydro-color": {"#4b8db8"}, "hydro-fill": {"#b9d9ea"}, "county-color": {"#766b5b"},212 "road-visible": {"true"}, "hydro-visible": {"true"}, "county-visible": {"true"},213 "road-order": {"40"}, "hydro-order": {"20"}, "county-order": {"10"},214 }215}216217func TestProjectPageShowsTheSourceToContractLaneWithoutOpeningEditors(t *testing.T) {218 targets := projectTargets(t)219 held, err := New(Options{Targets: targets, Runtime: []byte("/* runtime */")})220 if err != nil {221 t.Fatal(err)222 }223 server := site(t, held)224 response, body := get(t, server, "/project")225 if response.StatusCode != http.StatusOK {226 t.Fatalf("project answered %d: %s", response.StatusCode, body)227 }228 wants(t, "contract graph", body,229 "Sources become semantic FeatureSets", "Source", "sample-region.geojson", "Adapter", "geojson",230 "Mapping", "point · identity", "id → properties.name", "FeatureSet contract", "Markers",231 "0 typed properties", "0 relationships", "Artifact", "native vNext · planned",232 "Build stages", "Edit the single configuration file")233 if strings.Contains(body, `<details class="card manifest-editor" open`) {234 t.Fatal("valid manifest editor was open by default")235 }236}237238func TestGraphProjectGroupsSourcesIntoOneTypedContract(t *testing.T) {239 project := authoring.Project{240 Presentation: authoring.Presentation{Styles: []authoring.Style{{ID: "style"}}, Layers: []authoring.Layer{{ID: "layer"}}},241 FeatureSets: []authoring.FeatureSetContract{{242 ID: "places", Title: "Places", SemanticType: "place", Geometry: "point",243 Properties: []authoring.PropertyContract{244 {ID: "name", Name: "Name", Type: "string"},245 {ID: "rank", Name: "Rank", Type: "int64", Optional: true},246 },247 Relationships: []authoring.RelationshipContract{{ID: "area", Predicate: "within", FeatureSet: "areas"}},248 }},249 Sources: []authoring.Source{250 {ID: "primary", Adapter: "geojson", Locator: "primary.geojson", Mapping: authoring.Mapping{251 FeatureSet: "places", Identity: "id", FeatureTitle: "properties.name",252 Geometry: authoring.GeometryMap{SourceSpace: "local", Transform: authoring.CoordinateTransform{Kind: "identity"}},253 Properties: []authoring.PropertyMap{{Field: "name", Source: "properties.name"}},254 Relations: []authoring.RelationMap{{Relationship: "area", Target: "properties.area"}},255 }},256 {ID: "secondary", Adapter: "ogc-api-features", Locator: "https://example.invalid/features", Mapping: authoring.Mapping{257 FeatureSet: "places", Identity: "id", FeatureTitle: "properties.name",258 Geometry: authoring.GeometryMap{SourceSpace: "local", Transform: authoring.CoordinateTransform{Kind: "identity"}},259 Properties: []authoring.PropertyMap{{Field: "rank", Source: "properties.rank"}},260 }},261 },262 }263 plan := authoring.Plan{Requests: []authoring.PlannedRequest{264 {Request: authoring.Request{Kind: authoring.RequestFeatures, Source: "primary"}, Cached: true},265 {Request: authoring.Request{Kind: authoring.RequestFeatures, Source: "secondary"}},266 }}267 graph := graphProject(project, plan)268 if len(graph.FeatureSets) != 1 || len(graph.FeatureSets[0].Sources) != 2 {269 t.Fatalf("feature set graph = %+v", graph.FeatureSets)270 }271 set := graph.FeatureSets[0]272 if len(set.Properties) != 2 || set.Properties[0].ID != "name" || set.Properties[1].ID != "rank" || !set.Properties[1].Optional {273 t.Fatalf("contract properties = %+v", set.Properties)274 }275 if len(set.Relations) != 1 || set.Relations[0].Predicate != "within" || set.Relations[0].Target != "areas" {276 t.Fatalf("contract relations = %+v", set.Relations)277 }278 if !set.Sources[0].Cached || set.Sources[1].Cached {279 t.Fatalf("source cache states = %+v", set.Sources)280 }281}282283func TestProjectStagesDeriveProgressFromBuildEvents(t *testing.T) {284 run := supervisedRun{Name: "build", Running: true, Rows: []oprunner.Row{285 {Attrs: []oprunner.Attr{{Key: "stage", Value: "plan"}}},286 {Attrs: []oprunner.Attr{{Key: "stage", Value: "capture"}}},287 }}288 stages := projectStages(run)289 if stages[0].State != "done" || stages[1].State != "active" || stages[2].State != "pending" {290 t.Fatalf("running stages = %+v", stages)291 }292 run.Running, run.Failed = false, true293 run.Rows = append(run.Rows, oprunner.Row{Failed: true, Attrs: []oprunner.Attr{{Key: "stage", Value: "observe"}}})294 stages = projectStages(run)295 if stages[0].State != "done" || stages[1].State != "done" || stages[2].State != "failed" {296 t.Fatalf("failed stages = %+v", stages)297 }298}299300func TestManifestSaveValidatesBeforeAtomicReplacement(t *testing.T) {301 targets := projectTargets(t)302 held, err := New(Options{Targets: targets})303 if err != nil {304 t.Fatal(err)305 }306 server := site(t, held)307 original, err := os.ReadFile(targets.Project)308 if err != nil {309 t.Fatal(err)310 }311 response, _ := postForm(t, server.Client(), server.URL+"/project/save", url.Values{"manifest": {"schema: wrong\n"}})312 if response.StatusCode != http.StatusBadRequest {313 t.Fatalf("invalid save answered %d", response.StatusCode)314 }315 after, _ := os.ReadFile(targets.Project)316 if string(after) != string(original) {317 t.Fatal("invalid candidate replaced the manifest")318 }319 updated := strings.Replace(string(original), "title: Sample Region", "title: Sample Region Updated", 1)320 response, _ = postForm(t, noRedirectClient(server.Client()), server.URL+"/project/save", url.Values{"manifest": {updated}})321 if response.StatusCode != http.StatusSeeOther {322 t.Fatalf("valid save answered %d", response.StatusCode)323 }324 installed, _ := os.ReadFile(targets.Project)325 if !strings.Contains(string(installed), "Sample Region Updated") {326 t.Fatal("valid candidate was not installed")327 }328}329330func TestBuildSurvivesNavigationAndHandsOffTheArtifact(t *testing.T) {331 targets := projectTargets(t)332 if err := os.MkdirAll(targets.Registry, 0o755); err != nil {333 t.Fatal(err)334 }335 artifact := bundleSpec{slug: "sample-region", worlds: []worldSpec{{slug: "sample-region"}}}.write(t, targets.Registry)336 binary := filepath.Join(targets.Dir, "fake-atlas")337 script := "#!/bin/sh\nsleep 0.05\nprintf '%s\\n' '{\"time\":\"2026-01-01T00:00:00Z\",\"level\":\"INFO\",\"msg\":\"native build installed\",\"artifact\":\"" + artifact + "\"}' >&2\n"338 if err := os.WriteFile(binary, []byte(script), 0o755); err != nil {339 t.Fatal(err)340 }341 targets.Atlas = binary342 var opened string343 held, err := New(Options{Targets: targets, OpenArtifact: func(path string) error { opened = path; return nil }})344 if err != nil {345 t.Fatal(err)346 }347 server := site(t, held)348 response, _ := postForm(t, noRedirectClient(server.Client()), server.URL+"/project/build", url.Values{})349 if response.StatusCode != http.StatusSeeOther {350 t.Fatalf("build answered %d", response.StatusCode)351 }352 if !held.supervisor.Snapshot().Running {353 t.Fatal("build was tied to the completed POST instead of continuing")354 }355 deadline := time.Now().Add(2 * time.Second)356 for held.supervisor.Snapshot().Running && time.Now().Before(deadline) {357 time.Sleep(10 * time.Millisecond)358 }359 run := held.supervisor.Snapshot()360 if run.Running || run.Failed || run.Artifact != artifact {361 t.Fatalf("run = %+v", run)362 }363 _, runBody := get(t, server, "/project/run")364 wants(t, "completed run fragment", runBody, "Build complete", "Inspect packed data", "Open in Atlas", "Drag this file onto an Atlas window", filepath.Base(artifact))365 if strings.Contains(runBody, `hx-get="/project/run"`) {366 t.Fatal("completed run fragment kept polling")367 }368 _, body := get(t, server, "/project")369 wants(t, "completed project", body, "Build complete", "Inspect packed data", "Open in Atlas", "Drag this file onto an Atlas window", filepath.Base(artifact))370 response, _ = postForm(t, noRedirectClient(server.Client()), server.URL+"/project/open", url.Values{})371 if response.StatusCode != http.StatusSeeOther || opened != artifact {372 t.Fatalf("open answered %d and handed off %q", response.StatusCode, opened)373 }374 response, _ = get(t, server, "/project/artifact")375 if response.StatusCode != http.StatusOK {376 t.Fatalf("drag artifact answered %d", response.StatusCode)377 }378}379380func TestArtifactHandoffRefusesAnInvalidAtlasFile(t *testing.T) {381 targets := projectTargets(t)382 if err := os.MkdirAll(targets.Registry, 0o755); err != nil {383 t.Fatal(err)384 }385 artifact := filepath.Join(targets.Registry, "sample-region-invalid.atlas")386 if err := os.WriteFile(artifact, []byte("not an Atlas file"), 0o644); err != nil {387 t.Fatal(err)388 }389 held, err := New(Options{Targets: targets})390 if err != nil {391 t.Fatal(err)392 }393 held.supervisor.last = supervisedRun{Name: "build", Artifact: artifact, FinishedAt: time.Now()}394 if _, err := held.currentArtifact(); err == nil || !strings.Contains(err.Error(), "open completed Atlas artifact") {395 t.Fatalf("currentArtifact = %v, want native validation failure", err)396 }397}398399func TestProjectMutationsRefuseForeignOrigins(t *testing.T) {400 held, err := New(Options{Targets: projectTargets(t)})401 if err != nil {402 t.Fatal(err)403 }404 server := site(t, held)405 request, _ := http.NewRequest(http.MethodPost, server.URL+"/project/build", nil)406 request.Header.Set("Origin", "https://foreign.invalid")407 response, err := server.Client().Do(request)408 if err != nil {409 t.Fatal(err)410 }411 response.Body.Close()412 if response.StatusCode != http.StatusForbidden {413 t.Fatalf("foreign build answered %d", response.StatusCode)414 }415}416417func postForm(t *testing.T, client *http.Client, at string, form url.Values) (*http.Response, string) {418 t.Helper()419 request, err := http.NewRequest(http.MethodPost, at, strings.NewReader(form.Encode()))420 if err != nil {421 t.Fatal(err)422 }423 request.Header.Set("Content-Type", "application/x-www-form-urlencoded")424 response, err := client.Do(request)425 if err != nil {426 t.Fatal(err)427 }428 body, err := io.ReadAll(response.Body)429 response.Body.Close()430 if err != nil {431 t.Fatal(err)432 }433 return response, string(body)434}435436func noRedirectClient(base *http.Client) *http.Client {437 return &http.Client{Transport: base.Transport, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}438}