okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
cc6ab84
main
1// Editor failures must not lose saved edits or leave prompt files behind, 2// and a clicked row must name the right file and line in every way a path is 3// written. The daemon E2E driver does not run the prompt editor, and E2E 4// clicks one row of each kind rather than every spelling of a path. 5package tui 6 7import ( 8"errors" 9"io" 10"os" 11"os/exec" 12"path/filepath" 13"strings" 14"testing" 15) 16 17type restorationWriter struct { 18err error 19short bool 20} 21 22func (writer restorationWriter )Write (data []byte ) (int ,error ) { 23if writer .err != nil || writer .short { 24return 0 ,writer .err 25 } 26return len (data ),nil 27} 28 29func TestPromptEditorPreservesEditsAndCleansUp (t * testing.T ) { 30shell ,err := exec .LookPath ("sh" ) 31if err != nil { 32t .Skip ("shell unavailable" ) 33 } 34restoreFailure := errors .New ("output unavailable" ) 35for _ ,scenario := range []struct { 36name ,script ,text string 37writer restorationWriter 38wantRestore error 39wantEdited bool 40wantExit bool 41 }{ 42 {"success" ,`printf '%s' "$1" >&2; printf 'edited' > "$1"` ,"edited" ,restorationWriter {},nil ,true ,false }, 43 {"saved edits" ,`printf '%s' "$1" >&2; printf 'edited' > "$1"` ,"edited" ,restorationWriter {err :restoreFailure },restoreFailure ,true ,false }, 44 {"empty edits" ,`printf '%s' "$1" >&2; : > "$1"` ,"" ,restorationWriter {err :restoreFailure },restoreFailure ,true ,false }, 45 {"short write" ,`printf '%s' "$1" >&2; printf 'edited' > "$1"` ,"edited" ,restorationWriter {short :true },io .ErrShortWrite ,true ,false }, 46 {"execution and restoration" ,`printf '%s' "$1" >&2; exit 7` ,"" ,restorationWriter {err :restoreFailure },restoreFailure ,false ,true }, 47 } { 48t .Run (scenario .name ,func (t * testing.T ) { 49t .Setenv ("TMPDIR" ,t .TempDir ()) 50process := & promptEditorProcess { 51editorProcess :editorProcess {Cmd :exec .Command (shell ,"-c" ,scenario .script ,"editor" )}, 52prompt :"original" , 53 } 54var path strings.Builder 55process .SetStderr (& path ) 56process .SetStdout (scenario .writer ) 57err := process .Run () 58if ! errors .Is (err ,scenario .wantRestore ) { 59t .Fatalf ("restoration error lost: %v" ,err ) 60 } 61_ ,hasExit := errors.AsType [* exec.ExitError ](err ) 62if hasExit != scenario .wantExit { 63t .Fatalf ("execution error lost: %v" ,err ) 64 } 65if process .edited != scenario .wantEdited || process .text != scenario .text { 66t .Fatalf ("edited=%v text=%q" ,process .edited ,process .text ) 67 } 68if path .String ()== "" { 69t .Fatal ("editor did not receive a temporary file" ) 70 } 71if _ ,err := os .Stat (path .String ());! errors .Is (err ,os .ErrNotExist ) { 72t .Fatalf ("temporary file survived: %v" ,err ) 73 } 74 }) 75 } 76} 77 78func TestEditorCompletionPreservesTextAndReportsRestorationFailure (t * testing.T ) { 79for _ ,scenario := range []struct { 80name ,text string 81stale bool 82 }{ 83 {"saved edits" ,"edited" ,false }, 84 {"empty edits" ,"" ,false }, 85 {"stale completion" ,"edited" ,true }, 86 } { 87t .Run (scenario .name ,func (t * testing.T ) { 88model := composer (t ,80 ,24 ) 89model .TextArea .SetValue ("original" ) 90message := ChatEditorFinishedMsg { 91SessionID :model .SessionID ,Generation :model .Generation , 92Text :scenario .text ,Edited :true ,Err :errors .New ("restore failed" ), 93 } 94if scenario .stale { 95message .Generation ++ 96 } 97model ,_ = model .Update (message ) 98wantText := scenario .text 99if scenario .stale { 100wantText = "original" 101 } 102if model .TextArea .Value ()!= wantText || model .Notices .HasError ()== scenario .stale { 103t .Fatalf ("text=%q, error notice=%v" ,model .TextArea .Value (),model .Notices .HasError ()) 104 } 105 }) 106 } 107} 108 109func TestFileTargetAtReadsEverySpellingOfAPath (t * testing.T ) { 110ws := t .TempDir () 111for _ ,name := range []string {"main.go" ,"sub/util.go" } { 112path := filepath .Join (ws ,name ) 113if err := os .MkdirAll (filepath .Dir (path ),0o755 );err != nil { 114t .Fatal (err ) 115 } 116if err := os .WriteFile (path ,nil ,0o644 );err != nil { 117t .Fatal (err ) 118 } 119 } 120main ,util := filepath .Join (ws ,"main.go" ),filepath .Join (ws ,"sub" ,"util.go" ) 121for _ ,c := range []struct { 122line string 123col int 124want FileTarget // zero when nothing opens 125 }{ 126 {"error at main.go:42:15: failed" ,12 ,FileTarget {main ,42 ,15 }}, 127 {"main.go:42:" ,0 ,FileTarget {main ,42 ,0 }}, 128 {`files.read(path="sub/util.go")` ,20 ,FileTarget {util ,0 ,0 }}, 129 {"see `sub/util.go#L10`." ,8 ,FileTarget {util ,10 ,0 }}, 130 {"+++ b/sub/util.go" ,8 ,FileTarget {util ,0 ,0 }}, 131 {"file://" + main + ":3" ,10 ,FileTarget {main ,3 ,0 }}, 132 {"compare main.go with sub/util.go" ,10 ,FileTarget {main ,0 ,0 }}, 133 {"compare main.go with sub/util.go" ,25 ,FileTarget {util ,0 ,0 }}, 134 {"compare main.go with sub/util.go" ,0 ,FileTarget {}}, 135 {"I updated sub/util.go here" ,0 ,FileTarget {util ,0 ,0 }}, 136 {"wide 漢字 main.go" ,10 ,FileTarget {main ,0 ,0 }}, 137 {"see https://example.com/main.go" ,10 ,FileTarget {}}, 138 {"missing.go and plain words" ,2 ,FileTarget {}}, 139 } { 140got ,_ := fileTargetAt (c .line ,c .col ,ws ) 141if got != c .want { 142t .Errorf ("%q at %d: got %+v, want %+v" ,c .line ,c .col ,got ,c .want ) 143 } 144 } 145}