package studio import ( "os" "path/filepath" "strings" "testing" ) const legendSrc = `{ "_comment": "what the colours mean", "image": "Map3.jpg", "classes": [ { "name": "ocean", "rgb": [91, 175, 185], "sea": true, "depth_m": 512 }, { "_comment_plain": "why the floor is a tenth of the rate, at length", "name": "lowland", "rgb": [153, 204, 102], "uplift_mm_yr": 0.08, "k_mult": 1.0, "massif": { "floor_mm_yr": 0.012, "fraction": 0.16 }, "coastal_plain_km": 1.0 }, { "name": "highland", "rgb": [68, 170, 102], "uplift_mm_yr": 0.25, "k_mult": 1.0 } ] }` // The whole reason this is text surgery and not MarshalIndent: a legend is mostly commentary, and the // commentary has to survive a save byte for byte. func TestPatchingKeepsEverythingItDidNotChange(t *testing.T) { out, err := patchClassNumber(legendSrc, "lowland", "uplift_mm_yr", 0.12) if err != nil { t.Fatal(err) } if !strings.Contains(out, `"uplift_mm_yr": 0.12`) { t.Error("the new value is not there") } if strings.Contains(out, `"uplift_mm_yr": 0.08`) { t.Error("the old value is still there") } if !strings.Contains(out, `"_comment_plain": "why the floor is a tenth of the rate, at length"`) { t.Error("the comment was dropped") } if !strings.Contains(out, `"uplift_mm_yr": 0.25`) { t.Error("the other class's rate was touched") } // And nothing else moved: the only difference from the original is those four characters. if a, b := strings.Replace(out, "0.12", "0.08", 1), legendSrc; a != b { t.Errorf("the file changed somewhere else:\n--- got\n%s\n--- want\n%s", a, b) } } func TestPatchingAddsAKeyThatIsNotThere(t *testing.T) { out, err := patchClassNumber(legendSrc, "highland", "coastal_plain_km", 4) if err != nil { t.Fatal(err) } if !strings.Contains(out, `"coastal_plain_km": 4`) { t.Fatalf("the key was not added:\n%s", out) } if !strings.Contains(out, `"name": "highland", "coastal_plain_km": 4`) { t.Errorf("it did not go in after the name:\n%s", out) } } func TestPatchingAMassifBlock(t *testing.T) { order := []string{"floor_mm_yr", "fraction"} out, err := patchClassObject(legendSrc, "lowland", "massif", map[string]float64{"floor_mm_yr": 0.02, "fraction": 0.25}, order) if err != nil { t.Fatal(err) } if !strings.Contains(out, `"massif": { "floor_mm_yr": 0.02, "fraction": 0.25 }`) { t.Fatalf("the massif block was not rewritten:\n%s", out) } // Adding one to a class that has none. out, err = patchClassObject(legendSrc, "highland", "massif", map[string]float64{"floor_mm_yr": 0.045, "fraction": 0.3}, order) if err != nil { t.Fatal(err) } if !strings.Contains(out, `"massif": { "floor_mm_yr": 0.045, "fraction": 0.3 }`) { t.Fatalf("the massif block was not added:\n%s", out) } } func TestRemovingAMassifBlock(t *testing.T) { out, err := patchClassObject(legendSrc, "lowland", "massif", nil, nil) if err != nil { t.Fatal(err) } if strings.Contains(out, "massif") { t.Fatalf("the massif block is still there:\n%s", out) } // The class has to still parse: no doubled or dangling comma where it was. if strings.Contains(out, ",,") || strings.Contains(out, ", }") && !strings.Contains(legendSrc, ", }") { t.Errorf("the comma was left in a bad state:\n%s", out) } } func TestPatchingARefusesAClassThatIsNotThere(t *testing.T) { if _, err := patchClassNumber(legendSrc, "tundra", "uplift_mm_yr", 0.1); err == nil { t.Fatal("it accepted a class the legend does not have") } } const manifestSrc = `{ "level": "/Game/Maps/L_Planet", "planet": { "_comment_scale": "why the cell is eight metres", "template": "Templates/Map3.jpg", "circumference_km": 100, "coast_jitter_px": 48 } }` func TestPatchingTheManifestPlanetBlock(t *testing.T) { out, err := patchTopNumber(manifestSrc, "planet", "coast_jitter_px", 64) if err != nil { t.Fatal(err) } if !strings.Contains(out, `"coast_jitter_px": 64`) { t.Fatalf("not patched:\n%s", out) } if !strings.Contains(out, `"_comment_scale"`) { t.Error("the comment was dropped") } out, err = patchTopNumber(manifestSrc, "planet", "massif_wavelength_km", 7) if err != nil { t.Fatal(err) } if !strings.Contains(out, `"massif_wavelength_km": 7`) { t.Fatalf("a missing key was not added:\n%s", out) } out, err = patchTopString(manifestSrc, "planet", "template", "Templates/Map3.png") if err != nil { t.Fatal(err) } if !strings.Contains(out, `"template": "Templates/Map3.png"`) { t.Fatalf("the template path was not repointed:\n%s", out) } } // The base map is an input a person made by hand and there is no undo for it outside this process. Saving // versions rather than overwriting is the whole contract, and the second half of it is that saving twice // gives _001 and _002 rather than _001 and _001_002. func TestSavingNeverOverwritesAndNumbersUpwards(t *testing.T) { dir := t.TempDir() base := filepath.Join(dir, "Map3.jpg") if err := os.WriteFile(base, []byte("the base map"), 0o644); err != nil { t.Fatal(err) } first, err := nextVersion(base) if err != nil { t.Fatal(err) } if filepath.Base(first) != "Map3_001.png" { t.Errorf("first save went to %q, want Map3_001.png", filepath.Base(first)) } if err := os.WriteFile(first, []byte("v1"), 0o644); err != nil { t.Fatal(err) } // Asked again from the *versioned* path, which is what the manifest now points at. second, err := nextVersion(first) if err != nil { t.Fatal(err) } if filepath.Base(second) != "Map3_002.png" { t.Errorf("second save went to %q, want Map3_002.png", filepath.Base(second)) } // And the base map is still exactly what it was. if b, err := os.ReadFile(base); err != nil || string(b) != "the base map" { t.Errorf("the base map was touched: %q %v", b, err) } } // The overlay legend is patched by the same three functions - a mark is an object with a "name" like a class // is - so the thing worth testing separately is the one that is new: removing a key. const overlaySrc = `{ "image": "Map3.overlay.png", "marks": [ { "_comment": "why this shore is pinned, at length", "name": "drawn_coast", "rgb": [255, 0, 255], "coast_jitter": 0 }, { "name": "forest", "rgb": [0, 128, 0] }, { "name": "road", "rgb": [90, 60, 30], "kind": "path", "width_m": 8 } ] }` func TestRemovingAKeyLeavesNoTrace(t *testing.T) { // Add one, then take it away: the file has to come back exactly as it started, or every later diff // carries the scar of a setting somebody tried once. with, err := patchClassNumber(overlaySrc, "forest", "coast_jitter", 0.5) if err != nil { t.Fatal(err) } if !strings.Contains(with, `"coast_jitter": 0.5`) { t.Fatalf("the key was not added:\n%s", with) } back, err := patchClassRemove(with, "forest", "coast_jitter") if err != nil { t.Fatal(err) } if back != overlaySrc { t.Errorf("a round trip changed the file:\n--- want ---\n%s\n--- got ---\n%s", overlaySrc, back) } // Removing one that is not there is not an error: the studio sends "this mark says nothing about the // coast" whether or not it ever did. same, err := patchClassRemove(overlaySrc, "road", "coast_jitter") if err != nil || same != overlaySrc { t.Errorf("removing an absent key should be a no-op; err=%v changed=%v", err, same != overlaySrc) } // And the one that is there, on a mark carrying commentary. out, err := patchClassRemove(overlaySrc, "drawn_coast", "coast_jitter") if err != nil { t.Fatal(err) } if strings.Contains(out, "coast_jitter") { t.Error("the key is still there") } if !strings.Contains(out, `"_comment": "why this shore is pinned, at length"`) { t.Error("the comment went with it") } if !strings.Contains(out, `"name": "drawn_coast", "rgb": [255, 0, 255] }`) { t.Errorf("the trailing comma and its space were not cleaned up:\n%s", out) } }