465 lines
18 KiB
Go
465 lines
18 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
//go:build linux && cgo
|
|
|
|
package terminal
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
"unsafe"
|
|
|
|
"git.stormux.org/storm/fenrir/internal/applicationoverrides/ninjam"
|
|
"git.stormux.org/storm/fenrir/internal/command"
|
|
"git.stormux.org/storm/fenrir/internal/prompt"
|
|
"git.stormux.org/storm/fenrir/internal/reader"
|
|
)
|
|
|
|
// Live server addresses and acceptance of the server's connection license are
|
|
// explicit opt-ins. Chat is a private message to this fixture's own account.
|
|
// The required preload clocks/zeros ALSA null capture; no real audio is used.
|
|
func TestNinjamLiveOverride(t *testing.T) {
|
|
host, port := os.Getenv("FENRIR_NINJAM_TEST_HOST"), os.Getenv("FENRIR_NINJAM_TEST_PORT")
|
|
preload := os.Getenv("FENRIR_NINJAM_NULL_AUDIO")
|
|
if host == "" || port == "" || preload == "" || os.Getenv("FENRIR_NINJAM_TEST_LICENSE") != "1" {
|
|
t.Skip("set FENRIR_NINJAM_TEST_HOST/PORT, FENRIR_NINJAM_NULL_AUDIO and FENRIR_NINJAM_TEST_LICENSE=1 for an authorized live server test")
|
|
}
|
|
program, err := exec.LookPath("cninjam")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
root := os.Getenv("FENRIR_NINJAM_TEST_ARTIFACTS")
|
|
if root == "" {
|
|
root, err = os.MkdirTemp("", "fenrir-ninjam-live-")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := os.MkdirAll(root, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Log("Retained live-test artifacts:", root)
|
|
// Every invocation gets a distinct private client directory and identity.
|
|
clientDir, err := os.MkdirTemp(root, "client-")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
username := fmt.Sprintf("Username%04x", time.Now().UnixNano()&0xffff)
|
|
configuration := "master mastervol 1 masterpan 0 metrovol 0 metropan 0 mastermute 0 metromute 1\nlocal 0 source 0 bc 0 mute 1 volume 0 pan 0 name \"Fixture Channel\"\n"
|
|
if err := os.WriteFile(filepath.Join(clientDir, "ninjam.config"), []byte(configuration), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, err := os.OpenFile(filepath.Join(clientDir, "terminal.ansi"), os.O_CREATE|os.O_WRONLY, 0600)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer raw.Close()
|
|
trace, err := os.OpenFile(filepath.Join(clientDir, "presentation.jsonl"), os.O_CREATE|os.O_WRONLY, 0600)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer trace.Close()
|
|
encoder := json.NewEncoder(trace)
|
|
master, slave, err := open_pty(24, 100)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer syscall.Close(master)
|
|
slaveFile := os.NewFile(uintptr(slave), "private cninjam PTY")
|
|
defer slaveFile.Close()
|
|
args := []string{"-c", `"$@"; result=$?; printf '%s' '[Username@host test] $ '; exit "$result"`, "fixture", program, host + ":" + port, "-user", "anonymous:" + username, "-alsaconfig", "in null out null srate 48000 nch 2 bps 16 bsize 2048 nblock 4", "-nosavesourcefiles", "-nosavelocal", "-nowritelog", "-sessiondir", filepath.Join(clientDir, "session")}
|
|
child := exec.Command("/bin/sh", args...)
|
|
child.Dir = clientDir
|
|
child.Env = append(os.Environ(), "HOME="+clientDir, "TERM=xterm-256color", "LANG=C", "LC_ALL=C", "ALSA_CONFIG_PATH=/usr/share/alsa/alsa.conf", "LD_PRELOAD="+preload)
|
|
child.Stdin, child.Stdout, child.Stderr = slaveFile, slaveFile, slaveFile
|
|
child.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 0}
|
|
if err := child.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = syscall.Kill(-child.Process.Pid, syscall.SIGTERM); _ = child.Wait() }()
|
|
if err := syscall.SetNonblock(master, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
emulator, err := new_emulator(24, 100)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer emulator.close()
|
|
engine := reader.New()
|
|
var adapter *ninjam.Adapter
|
|
var output []string
|
|
peerPump := func() {}
|
|
pump := func() {
|
|
peerPump()
|
|
var buffer [32768]byte
|
|
for total := 0; total < 131072; {
|
|
count, err := syscall.Read(master, buffer[:])
|
|
if count > 0 {
|
|
total += count
|
|
emulator.feed(buffer[:count])
|
|
if _, err := raw.Write(buffer[:count]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if errors.Is(err, syscall.EAGAIN) || errors.Is(err, syscall.EIO) || count == 0 {
|
|
break
|
|
}
|
|
if err != nil && !errors.Is(err, syscall.EINTR) {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
snapshot := emulator.snapshot(true, false)
|
|
if adapter != nil {
|
|
frame := adapter.Observe(snapshot)
|
|
for _, message := range engine.Update_application(snapshot, frame) {
|
|
if message.Text != "" {
|
|
output = append(output, message.Text)
|
|
if err := encoder.Encode(map[string]any{"at": time.Now().UTC(), "text": message.Text}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
screenText := func() string {
|
|
snapshot := emulator.snapshot(true, false)
|
|
var lines []string
|
|
for row := range snapshot.Rows {
|
|
lines = append(lines, snapshot.Text(row))
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
saveScreen := func(name string) {
|
|
if err := os.WriteFile(filepath.Join(clientDir, name+".txt"), []byte(screenText()), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
defer saveScreen("final-screen")
|
|
waitFor := func(label string, timeout time.Duration, predicate func() bool) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
pump()
|
|
if predicate() {
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
saveScreen("failure-screen")
|
|
t.Fatalf("%s not observed; see retained screen and presentation trace", label)
|
|
}
|
|
write := func(data string) {
|
|
t.Helper()
|
|
if _, err := syscall.Write(master, []byte(data)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
focus := func() string {
|
|
if adapter == nil {
|
|
return ""
|
|
}
|
|
result, _ := adapter.Command(command.ReadLiveRegion2)
|
|
return result.Text
|
|
}
|
|
waitFor("server license or connection", 12*time.Second, func() bool {
|
|
return strings.Contains(screenText(), "Hit Y to agree") || strings.Contains(screenText(), "Use the arrows or pagedown to scroll down")
|
|
})
|
|
for page := 0; page < 20 && !strings.Contains(screenText(), "Hit Y to agree"); page++ {
|
|
write("\x1b[6~")
|
|
deadline := time.Now().Add(100 * time.Millisecond)
|
|
for time.Now().Before(deadline) {
|
|
pump()
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
saveScreen("license")
|
|
if !strings.Contains(screenText(), "Hit Y to agree") {
|
|
t.Fatal("server license could not be fully displayed")
|
|
}
|
|
write("y")
|
|
waitFor("connected main view", 12*time.Second, func() bool {
|
|
return strings.Contains(screenText(), "[QUIT NINJAM]") && !strings.Contains(screenText(), ": 0.0BPM")
|
|
})
|
|
saveScreen("connected-screen")
|
|
adapter = &ninjam.Adapter{}
|
|
engine.Configure_tracking("highlight")
|
|
pump()
|
|
if focus() != "Master unmuted" {
|
|
t.Fatal("initial master control:", focus())
|
|
}
|
|
send := func(data string) { engine.Note_input(true, false, false); write(data) }
|
|
send("\r")
|
|
waitFor("master muted", 2*time.Second, func() bool { return focus() == "Master muted" })
|
|
send("\r")
|
|
waitFor("master unmuted", 2*time.Second, func() bool { return focus() == "Master unmuted" })
|
|
engine.Command(command.CurrentLine)
|
|
start := len(output)
|
|
send("\x1bOC")
|
|
waitFor("volume focus after manual review", 2*time.Second, func() bool {
|
|
return strings.Contains(focus(), "Master Volume and pan") && strings.Contains(strings.Join(output[start:], "\n"), focus())
|
|
})
|
|
start = len(output)
|
|
send("\r")
|
|
waitFor("volume editing instructions", 2*time.Second, func() bool { return strings.Contains(strings.Join(output[start:], "\n"), "USE UP AND DOWN FOR VOLUME") })
|
|
oldValue := focus()
|
|
start = len(output)
|
|
send("\x1bOA")
|
|
waitFor("edited volume", 2*time.Second, func() bool {
|
|
return focus() != oldValue && strings.Contains(focus(), "Master Volume and pan") && slices.Contains(output[start:], "plus 0.5dB")
|
|
})
|
|
oldValue = focus()
|
|
start = len(output)
|
|
send("\x1bOD")
|
|
waitFor("edited pan", 2*time.Second, func() bool {
|
|
return focus() != oldValue && strings.Contains(focus(), "Master Volume and pan") && slices.Contains(output[start:], "1 percent left")
|
|
})
|
|
send("\r")
|
|
start = len(output)
|
|
deadline := time.Now().Add(1300 * time.Millisecond)
|
|
for time.Now().Before(deadline) {
|
|
pump()
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
for _, text := range output[start:] {
|
|
if strings.Contains(text, "###") || strings.Contains(text, "<-120.0dB>") {
|
|
t.Fatal("meter chatter:", text)
|
|
}
|
|
}
|
|
send("\x1bOB")
|
|
waitFor("local channel", 2*time.Second, func() bool { return strings.Contains(focus(), "Local channel Fixture Channel") })
|
|
send("\x1bOD")
|
|
waitFor("local channel name", 2*time.Second, func() bool { return strings.Contains(focus(), "Channel name Fixture Channel") })
|
|
send("\r")
|
|
waitFor("rename prompt", 2*time.Second, func() bool { return focus() == "Rename channel" })
|
|
write("Fixture Renamed\r")
|
|
waitFor("renamed local channel", 2*time.Second, func() bool { return strings.Contains(focus(), "Channel name Fixture Renamed") })
|
|
send("\x1bOC")
|
|
waitFor("local transmit control", 2*time.Second, func() bool { return strings.HasSuffix(focus(), " not transmitting") })
|
|
send("\x1bOC")
|
|
waitFor("local input control", 2*time.Second, func() bool { return strings.Contains(focus(), " Input ") })
|
|
start = len(output)
|
|
send("\r")
|
|
waitFor("input selection prompt", 2*time.Second, func() bool {
|
|
return strings.Contains(strings.Join(output[start:], "\n"), "USE ARROW KEYS TO SELECT THE INPUT CHANNEL")
|
|
})
|
|
oldValue = focus()
|
|
send("\x1bOB")
|
|
waitFor("changed input selection", 2*time.Second, func() bool { return focus() != oldValue && strings.Contains(focus(), " Input ") })
|
|
send("\r")
|
|
send("\x1bOC")
|
|
waitFor("local mute control", 2*time.Second, func() bool { return strings.HasSuffix(focus(), " muted") && !strings.HasSuffix(focus(), " unmuted") })
|
|
send("\r")
|
|
waitFor("local unmuted", 2*time.Second, func() bool { return strings.HasSuffix(focus(), " unmuted") })
|
|
send("\r")
|
|
waitFor("local muted", 2*time.Second, func() bool { return strings.HasSuffix(focus(), " muted") && !strings.HasSuffix(focus(), " unmuted") })
|
|
if os.Getenv("FENRIR_NINJAM_TEST_PEER") == "1" {
|
|
peerStep, peerFocus, peerWrite := start_ninjam_live_peer(t, program, host+":"+port, preload, root)
|
|
peerPump = peerStep
|
|
waitFor("silent test peer channel", 12*time.Second, func() bool { return strings.Contains(screenText(), "Peer Fixture") })
|
|
for count := 0; count < 64 && !strings.Contains(focus(), "channel Peer Fixture"); count++ {
|
|
send("\x1bOB")
|
|
deadline := time.Now().Add(60 * time.Millisecond)
|
|
for time.Now().Before(deadline) {
|
|
pump()
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
if !strings.Contains(focus(), "channel Peer Fixture") {
|
|
t.Fatal("remote test channel not reached")
|
|
}
|
|
for count := 0; count < 3; count++ {
|
|
send("\x1bOD")
|
|
deadline := time.Now().Add(30 * time.Millisecond)
|
|
for time.Now().Before(deadline) {
|
|
pump()
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
if !strings.HasSuffix(focus(), " receiving") {
|
|
t.Fatal("remote receive control", focus())
|
|
}
|
|
send("\r")
|
|
waitFor("remote not receiving", 2*time.Second, func() bool { return strings.HasSuffix(focus(), " not receiving") })
|
|
send("\r")
|
|
waitFor("remote receiving", 2*time.Second, func() bool {
|
|
return strings.HasSuffix(focus(), " receiving") && !strings.HasSuffix(focus(), " not receiving")
|
|
})
|
|
send("\x1bOC")
|
|
waitFor("remote mute control", 2*time.Second, func() bool { return strings.HasSuffix(focus(), " unmuted") })
|
|
send("\r")
|
|
waitFor("remote muted", 2*time.Second, func() bool { return strings.HasSuffix(focus(), " muted") && !strings.HasSuffix(focus(), " unmuted") })
|
|
send("\r")
|
|
waitFor("remote unmuted", 2*time.Second, func() bool { return strings.HasSuffix(focus(), " unmuted") })
|
|
for count := 0; count < 64 && peerFocus() != "Chat message"; count++ {
|
|
peerWrite("\x1bOB")
|
|
deadline := time.Now().Add(60 * time.Millisecond)
|
|
for time.Now().Before(deadline) {
|
|
pump()
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
if peerFocus() != "Chat message" {
|
|
t.Fatal("peer chat input not reached")
|
|
}
|
|
engine.Command(command.CurrentLine)
|
|
marker := fmt.Sprintf("Fenrir-peer-test-%x", time.Now().UnixNano()&0xffff)
|
|
start = len(output)
|
|
peerWrite("/msg " + username + " " + marker + "\r")
|
|
waitFor("passive incoming peer chat", 5*time.Second, func() bool { return strings.Contains(strings.Join(output[start:], "\n"), marker) })
|
|
if report := engine.Command(command.CursorLine); !strings.Contains(report.After.Text, "review cursor") {
|
|
t.Fatal("passive chat ended manual review", report)
|
|
}
|
|
result, _ := adapter.Command(command.ReadLiveRegion1)
|
|
if !strings.Contains(result.Text, marker) {
|
|
t.Fatal("peer chat missing from recall")
|
|
}
|
|
saveScreen("remote-peer-screen")
|
|
t.Log("PASS: remote receive/mute states and private peer chat during manual review; test peer transmits only clocked zeros")
|
|
}
|
|
// Match both the actual PTY and libvterm geometry. cninjam receives SIGWINCH
|
|
// from the kernel, then redraws its own dynamically positioned chat area.
|
|
dimensions := struct{ Rows, Cols, X, Y uint16 }{Rows: 30, Cols: 120}
|
|
if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(master), syscall.TIOCSWINSZ, uintptr(unsafe.Pointer(&dimensions))); errno != 0 {
|
|
t.Fatal(errno)
|
|
}
|
|
emulator.resize(30, 120)
|
|
waitFor("resized application regions", 3*time.Second, func() bool {
|
|
result, _ := adapter.Command(command.ReadLiveRegion3)
|
|
return strings.Contains(result.Text, "of 120") && strings.Contains(emulator.snapshot(true, false).Text(29), "[QUIT NINJAM]")
|
|
})
|
|
saveScreen("resized-screen")
|
|
for count := 0; count < 64 && focus() != "Chat message"; count++ {
|
|
send("\x1bOB")
|
|
deadline := time.Now().Add(60 * time.Millisecond)
|
|
for time.Now().Before(deadline) {
|
|
pump()
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
if focus() != "Chat message" {
|
|
t.Fatal("chat input not reached:", focus())
|
|
}
|
|
marker := fmt.Sprintf("Fenrir-private-test-%x", time.Now().UnixNano()&0xffff)
|
|
start = len(output)
|
|
write("/msg " + username + " " + marker + "\r")
|
|
waitFor("server-delivered private self-message", 5*time.Second, func() bool {
|
|
result, _ := adapter.Command(command.ReadLiveRegion1)
|
|
return strings.Contains(result.Text, marker) && strings.HasPrefix(result.Text, "*")
|
|
})
|
|
if !strings.Contains(strings.Join(output[start:], "\n"), marker) {
|
|
t.Fatal("chat updated recall without automatic output")
|
|
}
|
|
for _, id := range []command.ID{command.ReadLiveRegion1, command.ReadLiveRegion2, command.ReadLiveRegion3} {
|
|
result, handled := adapter.Command(id)
|
|
if !handled || result.Text == "" {
|
|
t.Fatal("empty semantic region", id)
|
|
}
|
|
}
|
|
saveScreen("chat-screen")
|
|
var shell prompt.ShellDetector
|
|
shell.Observe(emulator.snapshot(true, false))
|
|
send("\x1bOB")
|
|
waitFor("quit control", 2*time.Second, func() bool { return focus() == "Quit Ninjam" })
|
|
send("\r")
|
|
waitFor("real shell return", 5*time.Second, func() bool { return strings.Contains(screenText(), "[Username@host test] $") })
|
|
if !shell.Observe(emulator.snapshot(true, false)) {
|
|
t.Fatal("fresh shell prompt after real cninjam exit was not recognized")
|
|
}
|
|
t.Log("PASS: live master/local mute, volume/pan, navigation after review, quiet meters, rename/input prompts, resize, private incoming chat, semantic region reads, and shell return")
|
|
}
|
|
|
|
// start_ninjam_live_peer adds a clocked-silence test channel, never a microphone.
|
|
// It exists only in the explicit two-account test and is cleaned up by t.Cleanup.
|
|
func start_ninjam_live_peer(t *testing.T, program, address, preload, root string) (func(), func() string, func(string)) {
|
|
t.Helper()
|
|
directory, err := os.MkdirTemp(root, "peer-")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
configuration := "master mastervol 0 masterpan 0 metrovol 0 metropan 0 mastermute 1 metromute 1\nlocal 0 source 0 bc 1 mute 1 volume 0 pan 0 name \"Peer Fixture\"\n"
|
|
if err := os.WriteFile(filepath.Join(directory, "ninjam.config"), []byte(configuration), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
master, slave, err := open_pty(24, 100)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = syscall.Close(master) })
|
|
slaveFile := os.NewFile(uintptr(slave), "private cninjam peer PTY")
|
|
t.Cleanup(func() { _ = slaveFile.Close() })
|
|
name := fmt.Sprintf("UsernamePeer%04x", time.Now().UnixNano()&0xffff)
|
|
child := exec.Command(program, address, "-user", "anonymous:"+name, "-alsaconfig", "in null out null srate 48000 nch 2 bps 16 bsize 2048 nblock 4", "-nosavesourcefiles", "-nosavelocal", "-nowritelog", "-sessiondir", filepath.Join(directory, "session"))
|
|
child.Dir = directory
|
|
child.Env = append(os.Environ(), "HOME="+directory, "TERM=xterm-256color", "LANG=C", "LC_ALL=C", "ALSA_CONFIG_PATH=/usr/share/alsa/alsa.conf", "LD_PRELOAD="+preload)
|
|
child.Stdin, child.Stdout, child.Stderr = slaveFile, slaveFile, slaveFile
|
|
child.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 0}
|
|
if err := child.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = syscall.Kill(-child.Process.Pid, syscall.SIGTERM); _ = child.Wait() })
|
|
if err := syscall.SetNonblock(master, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
emulator, err := new_emulator(24, 100)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(emulator.close)
|
|
adapter := &ninjam.Adapter{}
|
|
raw, err := os.OpenFile(filepath.Join(directory, "terminal.ansi"), os.O_CREATE|os.O_WRONLY, 0600)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = raw.Close() })
|
|
write := func(data string) {
|
|
t.Helper()
|
|
if _, err := syscall.Write(master, []byte(data)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
accepted := false
|
|
lastPage := time.Time{}
|
|
pump := func() {
|
|
var buffer [32768]byte
|
|
for total := 0; total < 131072; {
|
|
count, err := syscall.Read(master, buffer[:])
|
|
if count > 0 {
|
|
total += count
|
|
emulator.feed(buffer[:count])
|
|
if _, err := raw.Write(buffer[:count]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if errors.Is(err, syscall.EAGAIN) || errors.Is(err, syscall.EIO) || count == 0 {
|
|
break
|
|
}
|
|
if err != nil && !errors.Is(err, syscall.EINTR) {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
snapshot := emulator.snapshot(true, false)
|
|
text := string(snapshot.Cells)
|
|
if !accepted && strings.Contains(text, "Hit Y to agree") {
|
|
write("y")
|
|
accepted = true
|
|
}
|
|
if !accepted && strings.Contains(text, "Use the arrows or pagedown to scroll down") && time.Since(lastPage) > 100*time.Millisecond {
|
|
write("\x1b[6~")
|
|
lastPage = time.Now()
|
|
}
|
|
adapter.Observe(snapshot)
|
|
}
|
|
focus := func() string { result, _ := adapter.Command(command.ReadLiveRegion2); return result.Text }
|
|
return pump, focus, write
|
|
}
|