render3d: a renderer that cannot draw says so and stops - LUDIC_HOME's leading ~ expanded (an unexpanded one left a run without shaders, logging 27 missing variants and drawing on), the SPIR-V manifest ends with E <count> and a cut or miscounted one is refused, and every such failure is a fault: r3d_open / r3d_load_step fail on it, r3d_fault() / r3d_fault_exit(code) for the game; tests/manifest_test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
aac58ec6fa
commit
c3fc896dea
13 changed files with 227 additions and 54 deletions
|
|
@ -120,6 +120,8 @@ export state Render3dState {
|
|||
gpu_kind: int = 0 # the backend running; 0 until gpu_select
|
||||
gpu_wanted: int = 0 # what was asked for (setting or R3D_GFX)
|
||||
gpu_fallback_reason: string = null # why the wanted backend is not the one running
|
||||
r3d_fault: string = "" # why the renderer cannot draw what it was asked to (gpu_fault.ludic)
|
||||
gvk_missing: []string = null # the programs asked for with no SPIR-V variant
|
||||
gpu_s_depth_test: int = -1
|
||||
gpu_s_depth_func: int = -1
|
||||
gpu_s_depth_write: int = -1
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ function gpu_select(render3d_st: mut Render3dState) -> int {
|
|||
else if not gvk_init(render3d_st) { render3d_st.gpu_fallback_reason = render3d_st.gvk_why }
|
||||
else if not gvk_manifest(render3d_st) { render3d_st.gpu_fallback_reason = "the renderer's SPIR-V manifest is missing" }
|
||||
else { render3d_st.gpu_kind = GPU_VK }
|
||||
if render3d_st.gpu_kind != GPU_VK { print(`r3d: vulkan cannot start: {render3d_st.gpu_fallback_reason}`) }
|
||||
if render3d_st.gpu_kind != GPU_VK { gpu_fault_set(render3d_st, `vulkan cannot start: {render3d_st.gpu_fallback_reason}`) }
|
||||
return render3d_st.gpu_kind
|
||||
}
|
||||
function gpu_backend(render3d_st: Render3dState) -> string { return "vulkan" }
|
||||
|
|
|
|||
39
packages/ludic.render3d/gpu_fault.ludic
Normal file
39
packages/ludic.render3d/gpu_fault.ludic
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# gpu_fault.ludic - the renderer at fault: its files not found, its manifest missing or cut short, a
|
||||
# program the game asked for with no SPIR-V variant. A fault is never a quiet start on nothing - a
|
||||
# world with no terrain, shadows or impostors that only logged it. The game asks r3d_fault() and
|
||||
# stops: r3d_fault_exit in a headless or dev build, its own failure screen in a player's.
|
||||
|
||||
# the first fault's reason, printed once; later ones only add to the list of missing programs
|
||||
@alloc_ok("once a run: a fault ends the run")
|
||||
function gpu_fault_set(render3d_st: mut Render3dState, why: string) -> void {
|
||||
if len(render3d_st.r3d_fault) > 0 { return }
|
||||
render3d_st.r3d_fault = why
|
||||
print(`r3d: FAULT: {why}`)
|
||||
}
|
||||
# a program asked for that the manifest has no variant of
|
||||
@alloc_ok("once a run: a fault ends the run")
|
||||
function gpu_variant_missing(render3d_st: mut Render3dState, key: string) -> void {
|
||||
if render3d_st.gvk_missing == null { render3d_st.gvk_missing = new []string }
|
||||
push(render3d_st.gvk_missing, key)
|
||||
print(`r3d: vulkan: no SPIR-V variant for {key}`)
|
||||
gpu_fault_set(render3d_st, "a program the game asked for has no SPIR-V variant (ludic-dev shaders, and its variants.list)")
|
||||
}
|
||||
|
||||
# why the renderer cannot draw what it was asked to, or "" when it can
|
||||
export function r3d_fault(render3d_st: Render3dState) -> string { return render3d_st.r3d_fault }
|
||||
# how many programs had no variant
|
||||
export function r3d_fault_missing(render3d_st: Render3dState) -> int {
|
||||
if render3d_st.gvk_missing == null { return 0 }
|
||||
return len(render3d_st.gvk_missing)
|
||||
}
|
||||
# the i-th of them, as `vertex|fragment|defines`
|
||||
export function r3d_fault_missing_at(render3d_st: Render3dState, i: int) -> string { return render3d_st.gvk_missing[i] }
|
||||
|
||||
# the fault and every missing program printed, then the run ends with `code` (never 0)
|
||||
export function r3d_fault_exit(render3d_st: Render3dState, code: int) -> void {
|
||||
print(`r3d: the renderer cannot run: {render3d_st.r3d_fault}`)
|
||||
for i in 0 .. r3d_fault_missing(render3d_st) { print(`r3d: missing {r3d_fault_missing_at(render3d_st, i)}`) }
|
||||
var c = code
|
||||
if c == 0 { c = 1 }
|
||||
Os.exit(c)
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
# U <id> <stage> <name> <offset> <type> <count> <stride>
|
||||
# T <id> <name> <binding> a sampler, one binding across both stages
|
||||
# I <id> <name> <location> <type> a vertex input
|
||||
# E <count> the end: how many P lines came before it
|
||||
# This reads it, and answers the questions a backend asks while drawing: which variant a
|
||||
# program is, where a uniform lives in its stage's block, which binding a sampler has.
|
||||
# ============================================================================
|
||||
|
|
@ -46,47 +47,74 @@ function gpu_variant_new(id: string, vs: string, fs: string, defs: string) -> Gp
|
|||
return v
|
||||
}
|
||||
|
||||
# Read a manifest; returns how many programs it holds (0: none, or no file).
|
||||
# Read a manifest; returns how many programs it holds (0: none, no file, or refused - and then
|
||||
# the renderer is at fault, so nothing starts on a partial list)
|
||||
@alloc_ok("start-up: the device, its tables, the programs, the passes and the world's first textures are made once, before play")
|
||||
function gpu_manifest_load(render3d_st: mut Render3dState, path: string) -> int {
|
||||
render3d_st.gpu_variants = new []GpuVariant
|
||||
let text = Fs.read_text(path)
|
||||
if text == null { return 0 }
|
||||
if text == null { gpu_fault_set(render3d_st, `no SPIR-V manifest at {path}`); return 0 }
|
||||
let vs = gpu_manifest_parse(text)
|
||||
if vs == null { gpu_fault_set(render3d_st, `the SPIR-V manifest at {path} is cut short or not a manifest (no E line, or its count is not the programs')`); return 0 }
|
||||
render3d_st.gpu_variants = vs
|
||||
return len(vs)
|
||||
}
|
||||
|
||||
# A manifest's programs, or null when it is refused: `ludic-dev shaders` ends one with `E <count>`,
|
||||
# so a file cut off anywhere (a torn copy, a short read) is told apart from one with fewer programs.
|
||||
@alloc_ok("start-up: the device, its tables, the programs, the passes and the world's first textures are made once, before play")
|
||||
function gpu_manifest_parse(text: string) -> []GpuVariant {
|
||||
let out = new []GpuVariant
|
||||
let lines = Text.split(text, "\n")
|
||||
var cur: GpuVariant = null
|
||||
var ended = -1
|
||||
for li in 0 .. len(lines) {
|
||||
let ln = lines[li]
|
||||
let lp: pointer = ln
|
||||
if len(lp) < 2 or lp[0] == '#' { continue }
|
||||
if ended >= 0 { return null } # nothing may follow the end line
|
||||
let f = Text.split(ln, " ")
|
||||
if lp[0] == 'P' and len(f) >= 4 {
|
||||
# the defines are the rest of the line, spaces and all
|
||||
var defs = ""
|
||||
var k = 4
|
||||
while k < len(f) {
|
||||
if k > 4 { defs = defs + " " }
|
||||
defs = defs + f[k]
|
||||
k += 1
|
||||
}
|
||||
cur = gpu_variant_new(f[1], f[2], f[3], defs)
|
||||
push(render3d_st.gpu_variants, cur)
|
||||
if lp[0] == 'E' and len(f) == 2 {
|
||||
ended = Text.to_int(f[1])
|
||||
continue
|
||||
}
|
||||
if cur == null { continue }
|
||||
if lp[0] == 'B' and len(f) >= 5 {
|
||||
if f[2] == "vert" { cur.vblock = Text.to_int(f[4]) } else { cur.fblock = Text.to_int(f[4]) }
|
||||
} else if lp[0] == 'U' and len(f) >= 8 {
|
||||
var stage = 1
|
||||
if f[2] == "vert" { stage = 0 }
|
||||
push(cur.u_stage, stage); push(cur.u_name, f[3]); push(cur.u_off, Text.to_int(f[4]))
|
||||
push(cur.u_type, f[5]); push(cur.u_count, Text.to_int(f[6])); push(cur.u_stride, Text.to_int(f[7]))
|
||||
} else if lp[0] == 'T' and len(f) >= 4 {
|
||||
push(cur.t_name, f[2]); push(cur.t_bind, Text.to_int(f[3]))
|
||||
} else if lp[0] == 'I' and len(f) >= 5 {
|
||||
push(cur.i_name, f[2]); push(cur.i_loc, Text.to_int(f[3])); push(cur.i_type, f[4])
|
||||
if lp[0] == 'P' and len(f) >= 4 {
|
||||
cur = gpu_variant_new(f[1], f[2], f[3], gpu_manifest_defs(f))
|
||||
push(out, cur)
|
||||
continue
|
||||
}
|
||||
if cur != null { gpu_manifest_row(cur, lp[0], f) }
|
||||
}
|
||||
if ended < 0 or ended != len(out) { return null }
|
||||
return out
|
||||
}
|
||||
# a P line's defines: the rest of the line, spaces and all
|
||||
@alloc_ok("start-up: the device, its tables, the programs, the passes and the world's first textures are made once, before play")
|
||||
function gpu_manifest_defs(f: []string) -> string {
|
||||
var defs = ""
|
||||
var k = 4
|
||||
while k < len(f) {
|
||||
if k > 4 { defs = defs + " " }
|
||||
defs = defs + f[k]
|
||||
k += 1
|
||||
}
|
||||
return defs
|
||||
}
|
||||
# one B / U / T / I line into the variant it follows
|
||||
@alloc_ok("start-up: the device, its tables, the programs, the passes and the world's first textures are made once, before play")
|
||||
function gpu_manifest_row(cur: GpuVariant, kind: int, f: []string) -> void {
|
||||
if kind == 'B' and len(f) >= 5 {
|
||||
if f[2] == "vert" { cur.vblock = Text.to_int(f[4]) } else { cur.fblock = Text.to_int(f[4]) }
|
||||
} else if kind == 'U' and len(f) >= 8 {
|
||||
var stage = 1
|
||||
if f[2] == "vert" { stage = 0 }
|
||||
push(cur.u_stage, stage); push(cur.u_name, f[3]); push(cur.u_off, Text.to_int(f[4]))
|
||||
push(cur.u_type, f[5]); push(cur.u_count, Text.to_int(f[6])); push(cur.u_stride, Text.to_int(f[7]))
|
||||
} else if kind == 'T' and len(f) >= 4 {
|
||||
push(cur.t_name, f[2]); push(cur.t_bind, Text.to_int(f[3]))
|
||||
} else if kind == 'I' and len(f) >= 5 {
|
||||
push(cur.i_name, f[2]); push(cur.i_loc, Text.to_int(f[3])); push(cur.i_type, f[4])
|
||||
}
|
||||
return len(render3d_st.gpu_variants)
|
||||
}
|
||||
|
||||
# the variant for a program as r3d_program names it (defines with real newlines)
|
||||
|
|
@ -95,9 +123,13 @@ function gpu_variant_find(render3d_st: Render3dState, vs: string, fs: string, de
|
|||
}
|
||||
# ... or as variants.list writes it (';' for newlines)
|
||||
function gpu_variant_find_key(render3d_st: Render3dState, vs: string, fs: string, defs: string) -> GpuVariant {
|
||||
if render3d_st.gpu_variants == null { return null }
|
||||
for i in 0 .. len(render3d_st.gpu_variants) {
|
||||
let v = render3d_st.gpu_variants[i]
|
||||
return gpu_variant_in(render3d_st.gpu_variants, vs, fs, defs)
|
||||
}
|
||||
# ... in a list of variants (a parsed manifest), or null
|
||||
function gpu_variant_in(vars: []GpuVariant, vs: string, fs: string, defs: string) -> GpuVariant {
|
||||
if vars == null { return null }
|
||||
for i in 0 .. len(vars) {
|
||||
let v = vars[i]
|
||||
if v.vs == vs and v.fs == fs and v.defs == defs { return v }
|
||||
}
|
||||
return null
|
||||
|
|
|
|||
|
|
@ -66,8 +66,10 @@ function gvk_program(render3d_st: mut Render3dState, p: int, key: string, spv_di
|
|||
let parts = Text.split(key, "|")
|
||||
var defs = ""
|
||||
if len(parts) > 2 { defs = parts[2] }
|
||||
# no manifest at all is one fault already said, not a line for every program
|
||||
if render3d_st.gpu_variants == null or len(render3d_st.gpu_variants) == 0 { return false }
|
||||
let v = gpu_variant_find_key(render3d_st, parts[0], parts[1], defs)
|
||||
if v == null { print(`r3d: vulkan: no SPIR-V variant for {key}`); return false }
|
||||
if v == null { gpu_variant_missing(render3d_st, key); return false }
|
||||
for q in 0 .. len(parts) { free(parts[q]) }
|
||||
free(parts)
|
||||
let vpath = `{spv_dir}/{v.id}.vert.spv`
|
||||
|
|
|
|||
|
|
@ -14,28 +14,6 @@
|
|||
|
||||
function r3d_set_paths(render3d_st: mut Render3dState, root: string, assets: string) -> void { render3d_st.r3d_root = root; render3d_st.r3d_assets = assets }
|
||||
|
||||
# The install root, as the compiler computes it: $LUDIC_HOME, else the directory of the
|
||||
# `ludic` on PATH. A game running from its own tree finds this package there.
|
||||
@alloc_ok("once a run: the install root, asked only when the shaders are not beside the program")
|
||||
function r3d_home() -> string {
|
||||
let env = Os.env("LUDIC_HOME")
|
||||
if env != null and env != "" { return env }
|
||||
return `{Os.env("HOME")}/.ludic`
|
||||
}
|
||||
|
||||
# Settle r3d_root on first use: the package as checked out beside the project, else the
|
||||
# copy that ships with the toolchain.
|
||||
@alloc_ok("start-up: the device, its tables, the programs, the passes and the world's first textures are made once, before play")
|
||||
function r3d_find_root(render3d_st: mut Render3dState) -> void {
|
||||
if render3d_st.r3d_root_found { return }
|
||||
render3d_st.r3d_root_found = true
|
||||
if Fs.exists(`{render3d_st.r3d_root}/shaders/lighting.glsl`) { return }
|
||||
let home = r3d_home()
|
||||
let alt = `{home}/packages/ludic.render3d`
|
||||
if Fs.exists(`{alt}/shaders/lighting.glsl`) { render3d_st.r3d_root = alt; return }
|
||||
print(`r3d: cannot find the renderer's shaders (looked in {render3d_st.r3d_root}/shaders and {alt}/shaders)`)
|
||||
}
|
||||
|
||||
# Build a program from a vertex + fragment file pair (defines apply to both).
|
||||
# R3D_PROGRAMS_LOG=<file>: append every program built (vertex, fragment, defines) - the list a
|
||||
# backend that cannot compile shaders at run time (Vulkan: SPIR-V is built ahead) must cover
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import "env.ludic"
|
|||
import "hooks.ludic"
|
||||
import "fmath.ludic"
|
||||
import "gpu.ludic"
|
||||
import "gpu_fault.ludic"
|
||||
import "gpu_manifest.ludic"
|
||||
import "gpu_vk.ludic"
|
||||
import "vkmem_report.ludic"
|
||||
|
|
@ -18,6 +19,7 @@ import "gpu_vk_readback.ludic"
|
|||
import "texture_dds.ludic"
|
||||
import "prof.ludic"
|
||||
import "drawstats.ludic"
|
||||
import "r3d_root.ludic"
|
||||
import "programs.ludic"
|
||||
import "texture.ludic"
|
||||
import "baked_tex.ludic"
|
||||
|
|
|
|||
33
packages/ludic.render3d/r3d_root.ludic
Normal file
33
packages/ludic.render3d/r3d_root.ludic
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# r3d_root.ludic - where the renderer's own files are: the package checked out beside the project,
|
||||
# else the copy in the toolchain ($LUDIC_HOME, else ~/.ludic). Not finding them is a fault
|
||||
# (gpu_fault.ludic), never a quiet start on nothing.
|
||||
|
||||
# The install root, as the compiler computes it: $LUDIC_HOME, else ~/.ludic. A leading "~" is
|
||||
# the home directory: `env LUDIC_HOME=~/x` hands one over unexpanded, and nothing was found there.
|
||||
@alloc_ok("once a run: the install root, asked only when the shaders are not beside the program")
|
||||
function r3d_home() -> string {
|
||||
let env = Os.env("LUDIC_HOME")
|
||||
if env != null and env != "" { return r3d_tilde(env, Os.env("HOME")) }
|
||||
return `{Os.env("HOME")}/.ludic`
|
||||
}
|
||||
# a path with a leading "~" or "~/" put under `home`; any other path as it is
|
||||
@alloc_ok("once a run: the install root, asked only when the shaders are not beside the program")
|
||||
function r3d_tilde(path: string, home: string) -> string {
|
||||
if home == null or len(path) == 0 or path[0] != '~' { return path }
|
||||
if len(path) == 1 { return home }
|
||||
if path[1] != '/' { return path } # ~user is another user's, not ours to guess
|
||||
return home + path[1 .. len(path)]
|
||||
}
|
||||
|
||||
# Settle r3d_root on first use: the package as checked out beside the project, else the
|
||||
# copy that ships with the toolchain.
|
||||
@alloc_ok("start-up: the device, its tables, the programs, the passes and the world's first textures are made once, before play")
|
||||
function r3d_find_root(render3d_st: mut Render3dState) -> void {
|
||||
if render3d_st.r3d_root_found { return }
|
||||
render3d_st.r3d_root_found = true
|
||||
if Fs.exists(`{render3d_st.r3d_root}/shaders/lighting.glsl`) { return }
|
||||
let home = r3d_home()
|
||||
let alt = `{home}/packages/ludic.render3d`
|
||||
if Fs.exists(`{alt}/shaders/lighting.glsl`) { render3d_st.r3d_root = alt; return }
|
||||
gpu_fault_set(render3d_st, `cannot find the renderer's shaders (looked in {render3d_st.r3d_root}/shaders and {alt}/shaders)`)
|
||||
}
|
||||
|
|
@ -119,7 +119,7 @@ function r3d_open(render3d_st: mut Render3dState, w: int, h: int, title: string)
|
|||
print(`r3d: {gl_width()}x{gl_height()} on {renderer}`)
|
||||
prof_init(render3d_st)
|
||||
cam_init(render3d_st, float(gl_width()) / float(gl_height()))
|
||||
return true
|
||||
return len(render3d_st.r3d_fault) == 0
|
||||
}
|
||||
|
||||
# The load, as steps a loading screen can show between: 0 the sky and its image-based light,
|
||||
|
|
@ -128,6 +128,10 @@ function r3d_open(render3d_st: mut Render3dState, w: int, h: int, title: string)
|
|||
# grass and the sky pass. The order is r3d_init's.
|
||||
function r3d_load_count() -> int { return 4 + TERRAIN_INIT_STEPS }
|
||||
function r3d_load_step(render3d_st: mut Render3dState, i: int) -> bool {
|
||||
if not r3d_load_step_run(render3d_st, i) { return false }
|
||||
return len(render3d_st.r3d_fault) == 0 # a step that made a program with no variant fails
|
||||
}
|
||||
function r3d_load_step_run(render3d_st: mut Render3dState, i: int) -> bool {
|
||||
let t = i - 1
|
||||
if i == 0 {
|
||||
# the default sky's path is made only when no path was given, and goes once the sky is read
|
||||
|
|
|
|||
|
|
@ -3995,3 +3995,4 @@ T b20fdcb5 u_shadow 9
|
|||
T b20fdcb5 u_tershadow 10
|
||||
T b20fdcb5 u_tershadow_aux 11
|
||||
T b20fdcb5 u_ts_height 12
|
||||
E 59
|
||||
|
|
|
|||
67
packages/ludic.render3d/tests/manifest_test.ludic
Normal file
67
packages/ludic.render3d/tests/manifest_test.ludic
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# manifest_test.ludic - the SPIR-V manifest read as the renderer reads it, with no device: a whole one
|
||||
# parses, one cut short or miscounted is refused, every program variants.list names resolves in the
|
||||
# shipped manifest, and a LUDIC_HOME with a leading "~" is found (it once started a run on nothing)
|
||||
import "ludic.render3d/r3d.ludic"
|
||||
program ManifestTest {
|
||||
# never called: naming Input links the engine's runtime, which render3d's textures stand on
|
||||
function links_runtime() -> bool { return Input.key_pressed(0) }
|
||||
|
||||
function two() -> string {
|
||||
var t = "# a manifest\nP aa fullscreen.vert sky.frag \nB aa frag 1 64\nU aa frag u_sun 0 vec3 1 0\n"
|
||||
t = t + "P bb model.vert shadow.frag #define SHADOW_PASS;#define WIND;\nT bb u_tex 2\nI bb a_pos 0 vec3\n"
|
||||
return t
|
||||
}
|
||||
|
||||
test "a whole manifest parses, and a program is found by its key" {
|
||||
let vs = gpu_manifest_parse(two() + "E 2\n")
|
||||
expect(vs != null)
|
||||
expect_eq(len(vs), 2)
|
||||
expect(gpu_variant_in(vs, "fullscreen.vert", "sky.frag", "") != null)
|
||||
let sh = gpu_variant_in(vs, "model.vert", "shadow.frag", "#define SHADOW_PASS;#define WIND;")
|
||||
expect(sh != null)
|
||||
expect_eq(gpu_variant_sampler(sh, "u_tex"), 2)
|
||||
expect(gpu_variant_in(vs, "model.vert", "shadow.frag", "#define SHADOW_PASS;") == null)
|
||||
}
|
||||
|
||||
test "a manifest cut short is refused" {
|
||||
expect(gpu_manifest_parse(two()) == null) # no end line
|
||||
let whole = two() + "E 2\n"
|
||||
expect(gpu_manifest_parse(whole[0 .. len(whole) - 30]) == null) # cut mid-program
|
||||
expect(gpu_manifest_parse("") == null)
|
||||
}
|
||||
|
||||
test "an end line that miscounts, or anything after it, is refused" {
|
||||
expect(gpu_manifest_parse(two() + "E 3\n") == null)
|
||||
expect(gpu_manifest_parse(two() + "E 1\n") == null)
|
||||
expect(gpu_manifest_parse(two() + "E 2\nP cc a.vert b.frag \n") == null)
|
||||
}
|
||||
|
||||
test "every program variants.list names has a variant in the shipped manifest" {
|
||||
let root = "packages/ludic.render3d/shaders"
|
||||
let text = Fs.read_text(`{root}/spv/manifest.txt`)
|
||||
expect(text != null)
|
||||
let vs = gpu_manifest_parse(text)
|
||||
expect(vs != null)
|
||||
let list = Text.split(Fs.read_text(`{root}/variants.list`), "\n")
|
||||
var asked = 0
|
||||
for i in 0 .. len(list) {
|
||||
let ln = Text.trim(list[i])
|
||||
if len(ln) == 0 or ln[0] == '#' { continue }
|
||||
let p = Text.split(ln, "|")
|
||||
var defs = ""
|
||||
if len(p) > 2 { defs = p[2] }
|
||||
if gpu_variant_in(vs, p[0], p[1], defs) == null { print(`no variant for {ln}`) }
|
||||
expect(gpu_variant_in(vs, p[0], p[1], defs) != null)
|
||||
asked += 1
|
||||
}
|
||||
expect_eq(asked, len(vs))
|
||||
}
|
||||
|
||||
test "a leading tilde is the home directory" {
|
||||
expect_eq(r3d_tilde("~/workspace/gpp-lang", "/Users/me"), "/Users/me/workspace/gpp-lang")
|
||||
expect_eq(r3d_tilde("~", "/Users/me"), "/Users/me")
|
||||
expect_eq(r3d_tilde("/opt/ludic", "/Users/me"), "/opt/ludic")
|
||||
expect_eq(r3d_tilde("~other/x", "/Users/me"), "~other/x")
|
||||
expect_eq(r3d_tilde("~/x", null), "~/x")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue