diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..bd68ea0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# 実機から採った受信バイトは、1 バイトも変えずに持つ。 +# +# core.autocrlf が有効な環境では、git が中身を見てテキストと判断した +# ファイルの CRLF を LF へ正規化する。cisco_ios_vt100.bin は制御文字が +# CR/LF/BS しか無く平文に近いため、これに巻き込まれて CR が 147 個 +# すべて剥がされていた。作業ツリーでは元へ戻るので気づけず、 +# Linux での clone や、リリースに添付される Source code アーカイブ +# (GPL の対応ソース)だけが壊れた状態で配られていた。 +tests/fixtures/*.bin -text diff --git a/.github/release-body.md b/.github/release-body.md new file mode 100644 index 0000000..c17742d --- /dev/null +++ b/.github/release-body.md @@ -0,0 +1,49 @@ +## NetBelt ポータブル版 + +### 📄 ライセンスと対応するソースコード + +NetBelt は GNU General Public License v3.0 で配布しています。 + +この実行ファイルには PyQt6 (GPL-3.0)、Qt (LGPL-3.0)、paramiko (LGPL-2.1-or-later) +などが組み込まれています。**この実行ファイルに対応するソースコード**は、 +本リリースに自動添付される Source code アーカイブ、および +https://github.com/gomanizm/NetBelt のタグ `{{TAG}}` で入手できます。 +ビルドは同梱の `requirements.txt`(バージョン固定済み)と `NetBelt.spec`、 +Python 3.11 で再現できます。 + +組み込まれた第三者パッケージの一覧・ライセンス本文・配布元は、 +ZIP 内の `THIRD-PARTY-NOTICES.txt` を参照してください。 + +### 📦 パッケージ内容 +- `NetBelt.exe` - アプリケーション本体 +- `README.txt` - 使い方ガイド +- `LICENSE.txt` - NetBelt のライセンス(GPL-3.0) +- `THIRD-PARTY-NOTICES.txt` - 同梱した第三者パッケージのライセンス表示 +- `CHANGELOG.txt` - この版で変わったこと +- `.sha256` - ZIP のチェックサム。自動更新がダウンロード後に照合し、 + 一致しなければ更新を中止します(破損の検知が目的で、GitHub 自体が + 侵害された場合の改ざんは検知できません) +- `mibs/` - MIB ファイルの配置先(既定では空。README を参照) + +### 🚀 インストール方法 +1. ZIPファイルをダウンロード +2. 任意のフォルダに展開 +3. `NetBelt.exe` をダブルクリックして起動 + +### 💻 動作環境 +- Windows 10 / Windows 11 (64bit) +- Pythonのインストールは不要 +- すべての依存ファイルが含まれています + +### ✨ 主な機能 +- SSH、Telnet、シリアル接続に対応 +- デバイス管理とグループ化 +- マクロ機能 +- SNMP Trap / Syslog 受信(カスタムMIB対応) +- SFTP ファイル転送 + +### 📝 初回起動 +- 初回起動時に `config.json` が自動生成されます +- MIB ファイルは同梱していません。`mibs/README.md` の手順で配置してください + +詳しい使い方は [README.md](https://github.com/gomanizm/NetBelt/blob/main/README.md) をご覧ください。 diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 64c4a5d..cd7b7bd 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -101,6 +101,38 @@ jobs: name: NetBelt-Portable path: NetBelt-v*-Windows-Portable.zip + # 更新の通知はリリース本文をそのまま出す。案内文だけだと、利用者は + # 何が変わったのか分からない。CHANGELOG のこの版の節を先頭へ置き、 + # 案内文 (.github/release-body.md) をその下へ続ける。 + # タグのときだけに絞らない。公開はしないので手動実行でも走らせ、 + # 本文の組み立てが壊れていないことをタグを打つ前に確かめられるようにする + - name: Build release notes + shell: pwsh + run: | + $version = '${{ steps.version.outputs.version }}' + $lines = Get-Content CHANGELOG.md -Encoding utf8 + $start = $null + $end = $lines.Count + # -like は [ ] を文字クラスとして解釈するので使えない。 + # "## [1.2.0]*" は「1 . 2 0 のどれか1文字」という意味になり一致しない + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i].StartsWith("## [$version]")) { $start = $i + 1; continue } + if ($null -ne $start -and $lines[$i].StartsWith('## [')) { $end = $i; break } + } + if ($null -eq $start) { throw "CHANGELOG.md に [$version] の節がありません" } + $section = ($lines[$start..($end - 1)] -join "`n").Trim() + if (-not $section) { throw "[$version] の節が空です" } + + $template = (Get-Content .github/release-body.md -Encoding utf8) -join "`n" + $template = $template.Replace('{{TAG}}', $env:GITHUB_REF_NAME) + + $body = "## v$version で変わったこと`n`n$section`n`n---`n`n$template" + Set-Content -Path release_notes.md -Value $body -Encoding utf8 + Write-Host "本文 $($body.Length) 文字 (うち変更点 $($section.Length) 文字)" + Write-Host "--- 先頭 8 行 ---" + Get-Content release_notes.md -Encoding utf8 -TotalCount 8 | Write-Host + if ($body -match '\{\{TAG\}\}') { throw 'テンプレートの置換が残っています' } + - name: Create Release if: startsWith(github.ref, 'refs/tags/') uses: softprops/action-gh-release@v3 @@ -108,56 +140,7 @@ jobs: files: | NetBelt-v*-Windows-Portable.zip NetBelt-v*-Windows-Portable.zip.sha256 - body: | - ## NetBelt ポータブル版 - - ### 📄 ライセンスと対応するソースコード - - NetBelt は GNU General Public License v3.0 で配布しています。 - - この実行ファイルには PyQt6 (GPL-3.0)、Qt (LGPL-3.0)、paramiko (LGPL-2.1-or-later) - などが組み込まれています。**この実行ファイルに対応するソースコード**は、 - 本リリースに自動添付される Source code アーカイブ、および - https://github.com/gomanizm/NetBelt のタグ `${{ github.ref_name }}` で入手できます。 - ビルドは同梱の `requirements.txt`(バージョン固定済み)と `NetBelt.spec`、 - Python 3.11 で再現できます。 - - 組み込まれた第三者パッケージの一覧・ライセンス本文・配布元は、 - ZIP 内の `THIRD-PARTY-NOTICES.txt` を参照してください。 - - ### 📦 パッケージ内容 - - `NetBelt.exe` - アプリケーション本体 - - `README.txt` - 使い方ガイド - - `LICENSE.txt` - NetBelt のライセンス(GPL-3.0) - - `THIRD-PARTY-NOTICES.txt` - 同梱した第三者パッケージのライセンス表示 - - `CHANGELOG.txt` - この版で変わったこと - - `.sha256` - ZIP のチェックサム。自動更新がダウンロード後に照合し、 - 一致しなければ更新を中止します(破損の検知が目的で、GitHub 自体が - 侵害された場合の改ざんは検知できません) - - `mibs/` - MIB ファイルの配置先(既定では空。README を参照) - - ### 🚀 インストール方法 - 1. ZIPファイルをダウンロード - 2. 任意のフォルダに展開 - 3. `NetBelt.exe` をダブルクリックして起動 - - ### 💻 動作環境 - - Windows 10 / Windows 11 (64bit) - - Pythonのインストールは不要 - - すべての依存ファイルが含まれています - - ### ✨ 主な機能 - - SSH、Telnet、シリアル接続に対応 - - デバイス管理とグループ化 - - マクロ機能 - - SNMP Trap / Syslog 受信(カスタムMIB対応) - - SFTP ファイル転送 - - ### 📝 初回起動 - - 初回起動時に `config.json` が自動生成されます - - MIB ファイルは同梱していません。`mibs/README.md` の手順で配置してください - - 詳しい使い方は [README.md](https://github.com/gomanizm/NetBelt/blob/main/README.md) をご覧ください。 + body_path: release_notes.md draft: false prerelease: false env: diff --git a/CHANGELOG.md b/CHANGELOG.md index ca9f9da..e610f7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,84 @@ このファイルの形式は [Keep a Changelog](https://keepachangelog.com/ja/1.1.0/) に、 バージョン番号は [Semantic Versioning](https://semver.org/lang/ja/) に従います。 +## [1.2.0] - 2026-08-27 + +ターミナルの描画を作り直しました。これまでは受信した文字を書き足していく +だけで、行と桁からなる「画面」という概念を持っていませんでした。そのため +`nano` や `vi` が「23 行目の 1 桁目へ」と指示しても行き先が無く、画面ぶんの +描画がすべて同じ場所に重なっていました。 + +**設定や接続先の作り直しは不要です。** これまでの `config.json` をそのまま +使えます。 + +### 追加 +- **`nano` や `vi` の画面が崩れなくなった。** これまでは「対応していません」と + 案内を出すのが精一杯でした。タイトルバー・編集領域・下部のショートカット + 2 段が、実際の画面として組み上がります。`less` や `top` のような画面を + 作るコマンドも同じ仕組みで扱いますが、すべてのコマンドを試したわけでは + ないため、崩れるものが残っている可能性はあります。 +- **全画面のコマンドの画面は、記録にも残ります。** `nano` や `vi` を閉じても + 開く前の画面は戻らず、編集中の画面がそのままスクロールバックと + セッションログに残ります。NetBelt は端末種別を `vt100` として接続するため、 + これらのコマンドが画面を退避する仕組み(代替画面)を使わないためです。 + 編集内容を記録に残したくない場合は、ログ記録を止めてください + (スクロールバックには残りますが、画面上だけでファイルには残りません)。 +- **色と装飾を表示するようになった。** 文字色・背景色・太字・下線・反転に + 対応しました。基本 16 色に加えて 256 色パレットと RGB 直接指定も扱えます。 + 反転表示は、設定した文字色と背景色を入れ替えて表示します。 +- **ウィンドウの大きさを機器へ伝えるようになった。** これまでは 80×24 固定で、 + ウィンドウを広げても `show` の出力が 80 桁で折り返していました。接続時と + リサイズ時に実際の行数・桁数を伝えます(RFC 4254 §6.2 / §6.7)。 + この通知は SSH のみです。Telnet とシリアルでは通知しませんが、 + 画面の描画・色・折り返しはこれまでどおり同じように働きます。 +- **アプリケーションが指定する入力方式に従うようになった。** `nano` が + 要求するカーソルキーの形式(DECCKM)と、bash のブラケットペーストに + 対応しました。貼り付けが 1 行ずつ実行されず、まとめて編集できます。 +- **ステータスバーに端末の大きさを表示するようにした。** いま表示中の + ターミナルの桁数と行数が右端に出ます(SSH ではこの値を機器へ伝えて + います。Telnet とシリアルでは表示だけです)。 +- **ターミナルのタブをドラッグで並べ替えられるようにした。** +- **接続先リストを隠せるようにした。** ツールエリアと同じく、仕切りを + ドラッグする・右クリックする・表示メニューから切り替える、の 3 通りです。 +- **更新の通知に、何が変わったかを出すようにした。** これまでは使い方の + 案内しか出ておらず、更新して何が変わるのか分かりませんでした。 + 何世代か飛ばしている場合は、その間のぶんもまとめて表示します + (多すぎると読まれないため、5 世代までとし、残りは件数だけ添えます)。 + +### 修正 +- **`nano` や `vi` を開くと画面が 1 行に潰れていた。** 上の「追加」の + とおり、画面として組み上がるようになりました。 +- **ウィンドウを広げても、出力が 80 桁で折り返したままだった。** + 端末の大きさを 80×24 に固定して機器へ伝えていたためです。 + +### 変更 +- 受信の解釈を、正規表現による逐次処理から仕様どおりの状態機械へ置き換え + ました。受信がどこで途切れても解釈が変わりません。実機から採取した + データを分割して検証しています(Linux 側の 3 種は全ての切れ目、 + 機器から採った 7058 バイトは 7 バイトおき)。 +- 画面の大きさが変わったとき、すでに書かれた行は組み直しません(実端末と + 同じ挙動です)。狭い状態で出力された行は、その折り返しのまま残ります。 + ただし表示領域から流れて記録に入った行は 1 本の行として保存されるため、 + ウィンドウの幅に合わせて折り返し直されます。 +- 全画面アプリを検知して案内を出す仕組みを廃止しました。実際に表示できる + ようになったため不要になりました。 +- 機器から端末への問い合わせ(カーソル位置・装置種別)に、自動で答える + ようになりました。答えないと待ち続けるアプリケーションがあるためです。 + 送るのは問い合わせへの応答だけで、こちらから何かを送ることはありません。 +- 端末の高さを機器へ伝えるようになったため、機器側のページャ(`--More--`) + の区切りがウィンドウの高さで変わります。ウィンドウが高いほど一度に + 多く表示されます。 +- 全角文字は 1 桁として数えます。日本語を含む行の折り返し位置が、 + 実際の見た目とずれることがあります。機器の出力は英数字が中心のため、 + 影響は Linux 側での日本語編集に限られます。 + +### 開発メモ +作り直しは仕様から行っています。既存のターミナル実装のソースコードは +読んでいません(読んで書き直すと、構造や命名が似たときに派生物とみなされる +余地が残るため)。参照したのは Paul Williams の DEC 互換パーサ状態遷移図、 +ECMA-48(= ISO/IEC 6429)、ISO/IEC 2022、RFC 4254、および標準に無い部分に +ついては XTerm Control Sequences です。 + ## [1.1.1] - 2026-08-26 公開後に見つかった不具合の修正です。 diff --git a/src/__version__.py b/src/__version__.py index 7983402..3b899db 100644 --- a/src/__version__.py +++ b/src/__version__.py @@ -2,6 +2,6 @@ NetBelt バージョン情報 """ -__version__ = "1.1.1" +__version__ = "1.2.0" GITHUB_REPO = "gomanizm/NetBelt" APP_NAME = "NetBelt" \ No newline at end of file diff --git a/src/core/serial_connection.py b/src/core/serial_connection.py index 610ab8e..158afc9 100644 --- a/src/core/serial_connection.py +++ b/src/core/serial_connection.py @@ -63,7 +63,8 @@ def connect(self) -> bool: self._should_stop = False # 接続成功メッセージ - self.output_received.emit(f"\n接続しました: {self.port} ({self.baudrate} baud)\n") + self.output_received.emit( + f"\r\n接続しました: {self.port} ({self.baudrate} baud)\r\n") self.connected.emit() # 読み取りスレッドを開始 @@ -73,12 +74,12 @@ def connect(self) -> bool: except serial.SerialException as e: error_msg = f"接続失敗: {str(e)}" - self.output_received.emit(f"\n{error_msg}\n") + self.output_received.emit(f"\r\n{error_msg}\r\n") self.error_occurred.emit(error_msg) return False except Exception as e: error_msg = f"予期しないエラー: {str(e)}" - self.output_received.emit(f"\n{error_msg}\n") + self.output_received.emit(f"\r\n{error_msg}\r\n") self.error_occurred.emit(error_msg) return False diff --git a/src/core/ssh_connection.py b/src/core/ssh_connection.py index 31d1242..04c34bc 100644 --- a/src/core/ssh_connection.py +++ b/src/core/ssh_connection.py @@ -54,6 +54,9 @@ def __init__(self, host: str, port: int, username: str, password: str = "", self.client: Optional[paramiko.SSHClient] = None self.channel: Optional[paramiko.Channel] = None self.is_connected = False + # 端末の大きさ。接続前に set_terminal_size で上書きされる + self.term_cols = 80 + self.term_rows = 24 self._read_thread: Optional[threading.Thread] = None self._stop_reading = False @@ -166,8 +169,9 @@ def connect(self) -> bool: # SSH接続を実行 self.client.connect(**connect_kwargs) - # インタラクティブシェルを開始 - self.channel = self.client.invoke_shell(term='vt100', width=80, height=24) + # インタラクティブシェルを開始 (RFC 4254 6.2 pty-req) + self.channel = self.client.invoke_shell( + term='vt100', width=self.term_cols, height=self.term_rows) self.channel.settimeout(0.1) self.is_connected = True @@ -230,7 +234,22 @@ def send_command(self, command: str): self.channel.send(command.encode('utf-8')) except Exception as e: self.error_occurred.emit(f"送信エラー: {str(e)}") - + + def set_terminal_size(self, cols: int, rows: int): + """端末の大きさを機器へ伝える (RFC 4254 6.7 window-change)。 + + 接続前に呼ばれたら、接続時の pty 要求 (6.2) に使う。 + 通知に失敗しても接続はそのまま続ける。 + """ + self.term_cols = cols + self.term_rows = rows + if not self.is_connected or not self.channel: + return + try: + self.channel.resize_pty(width=cols, height=rows) + except Exception: + pass + def _read_output(self): """バックグラウンドで出力を読み取る""" while not self._stop_reading and self.is_connected: diff --git a/src/core/terminal/__init__.py b/src/core/terminal/__init__.py new file mode 100644 index 0000000..8613955 --- /dev/null +++ b/src/core/terminal/__init__.py @@ -0,0 +1 @@ +# 端末エミュレーション。パーサと画面は UI を知らない。 diff --git a/src/core/terminal/attrs.py b/src/core/terminal/attrs.py new file mode 100644 index 0000000..19d5726 --- /dev/null +++ b/src/core/terminal/attrs.py @@ -0,0 +1,71 @@ +"""文字属性 (ECMA-48 の SGR)。画面のセルがそのまま持つ値オブジェクト。 + +色は番号のまま持つ。0-7 が基本色、8-15 が明色、16-255 が 256 色 +パレット、(r, g, b) のタプルが直接指定。番号をどの実色に写すかは +描画側 (テーマ) の仕事で、ここでは決めない。 +""" +import collections + +Attr = collections.namedtuple("Attr", "fg bg bold underline reverse") +DEFAULT = Attr(None, None, False, False, False) + + +def apply_sgr(attr, params): + """SGR パラメータ列を attr へ適用した結果を返す。 + + 知らない番号は黙って飛ばす (ECMA-48 にはこちらが対応しない + 属性も多い)。省略されたパラメータは 0 (全解除) として扱う。 + """ + if not params: + return DEFAULT + params = list(params) + i = 0 + while i < len(params): + p = params[i] or 0 + if p == 0: + attr = DEFAULT + elif p == 1: + attr = attr._replace(bold=True) + elif p == 4: + attr = attr._replace(underline=True) + elif p == 7: + attr = attr._replace(reverse=True) + elif p == 22: + attr = attr._replace(bold=False) + elif p == 24: + attr = attr._replace(underline=False) + elif p == 27: + attr = attr._replace(reverse=False) + elif 30 <= p <= 37: + attr = attr._replace(fg=p - 30) + elif p == 39: + attr = attr._replace(fg=None) + elif 40 <= p <= 47: + attr = attr._replace(bg=p - 40) + elif p == 49: + attr = attr._replace(bg=None) + elif 90 <= p <= 97: + attr = attr._replace(fg=p - 90 + 8) + elif 100 <= p <= 107: + attr = attr._replace(bg=p - 100 + 8) + elif p in (38, 48): + colour, used = _extended_colour(params[i + 1:]) + if colour is None: + break # 形が崩れていたら残りごと捨てる + if p == 38: + attr = attr._replace(fg=colour) + else: + attr = attr._replace(bg=colour) + i += used + i += 1 + return attr + + +def _extended_colour(rest): + """38/48 に続く色指定を読む。(色, 消費した個数) を返す。""" + if len(rest) >= 2 and rest[0] == 5 and rest[1] is not None: + return min(max(rest[1], 0), 255), 2 + if len(rest) >= 4 and rest[0] == 2: + r, g, b = (min(max(v or 0, 0), 255) for v in rest[1:4]) + return (r, g, b), 4 + return None, 0 diff --git a/src/core/terminal/parser.py b/src/core/terminal/parser.py new file mode 100644 index 0000000..aa41ab3 --- /dev/null +++ b/src/core/terminal/parser.py @@ -0,0 +1,306 @@ +"""受信文字列を端末命令の列へ直す状態機械。 + +Paul Williams の DEC 互換パーサ状態遷移図 +(https://vt100.net/emu/dec_ansi_parser) を実装する。UI を知らない。 +受信の切れ目は状態として持ち越されるので、呼び出し側での +継ぎ足し処理は要らない。 + +図からの意図的な違いは 3 つ: +- OSC を BEL (0x07) でも終端する。図の終端は ST だけだが、実際の + シェルは ``ESC ] 0 ; title BEL`` を送ってくる (xterm の挙動)。 +- 入力はデコード済みの str。0x80-0x9F の C1 制御は UTF-8 経由では + 現れないので、C1 の遷移は実装しない。図に無い 0x7E 超えの文字が + 列の途中に来たら、列を捨ててその文字を印字する。 +- CAN/SUB で中断した OSC/DCS は、途中までの中身を届けず捨てる。 +""" +import collections + +Print = collections.namedtuple("Print", "text") +Ctrl = collections.namedtuple("Ctrl", "char") +Esc = collections.namedtuple("Esc", "intermediate final") +Csi = collections.namedtuple("Csi", "private params intermediate final") +Osc = collections.namedtuple("Osc", "text") +Dcs = collections.namedtuple("Dcs", "private params intermediate final text") + +(GROUND, ESCAPE, ESCAPE_INTERMEDIATE, + CSI_ENTRY, CSI_PARAM, CSI_INTERMEDIATE, CSI_IGNORE, + OSC_STRING, SOS_PM_APC, + DCS_ENTRY, DCS_PARAM, DCS_INTERMEDIATE, DCS_PASSTHROUGH, + DCS_IGNORE) = range(14) + +MAX_PARAMS = 256 # 暴走した列でメモリを食わないための上限 +MAX_STRING = 4096 +MAX_INTERMEDIATE = 32 + + +class Parser(object): + def __init__(self): + self.state = GROUND + self._clear() + self._string = [] + self._hook = None + + def _clear(self): + self._private = "" + self._params = "" + self._intermediate = "" + + def _split_params(self): + if not self._params: + return () + return tuple(int(p) if p else None for p in self._params.split(";")) + + def _end_string(self, out): + """ESC / ST で文字列列 (OSC/DCS) を閉じ、確定した命令を出す。""" + if self.state == OSC_STRING: + out.append(Osc("".join(self._string))) + elif self.state == DCS_PASSTHROUGH: + private, params, intermediate, final = self._hook + out.append(Dcs(private, params, intermediate, final, + "".join(self._string))) + self._string = [] + self._hook = None + + def feed(self, text): + """デコード済み文字列を食わせ、確定した命令のリストを返す。""" + out = [] + printable = [] + + def flush(): + if printable: + out.append(Print("".join(printable))) + del printable[:] + + def abort_and_print(ch): + # 図に無い文字 (0x7E 超え) が列の途中に来た + self.state = GROUND + printable.append(ch) + + for ch in text: + code = ord(ch) + + # anywhere: どの状態でも同じ扱い + if code == 0x1B: + flush() + self._end_string(out) + self._clear() + self.state = ESCAPE + continue + if code in (0x18, 0x1A): # CAN / SUB は列を捨てる + flush() + self._string = [] + self._hook = None + out.append(Ctrl(ch)) + self.state = GROUND + continue + + state = self.state + if state == GROUND: + if code >= 0x20: # 0x7F(DEL) の無視は画面側で + printable.append(ch) + else: + flush() + out.append(Ctrl(ch)) + continue + flush() + + if state == ESCAPE: + if code < 0x20: + out.append(Ctrl(ch)) + elif code <= 0x2F: + if len(self._intermediate) < MAX_INTERMEDIATE: + self._intermediate += ch + self.state = ESCAPE_INTERMEDIATE + elif ch == "[": + self._clear() + self.state = CSI_ENTRY + elif ch == "]": + self._string = [] + self.state = OSC_STRING + elif ch == "P": + self._clear() + self.state = DCS_ENTRY + elif ch in "X^_": + self.state = SOS_PM_APC + elif code <= 0x7E: + out.append(Esc(self._intermediate, ch)) + self.state = GROUND + elif code == 0x7F: + pass + else: + abort_and_print(ch) + + elif state == ESCAPE_INTERMEDIATE: + if code < 0x20: + out.append(Ctrl(ch)) + elif code <= 0x2F: + if len(self._intermediate) < MAX_INTERMEDIATE: + self._intermediate += ch + elif code <= 0x7E: + out.append(Esc(self._intermediate, ch)) + self.state = GROUND + elif code == 0x7F: + pass + else: + abort_and_print(ch) + + elif state == CSI_ENTRY: + if code < 0x20: + out.append(Ctrl(ch)) + elif code <= 0x2F: + if len(self._intermediate) < MAX_INTERMEDIATE: + self._intermediate += ch + self.state = CSI_INTERMEDIATE + elif ch == ":": + self.state = CSI_IGNORE + elif code <= 0x39 or ch == ";": + self._params += ch + self.state = CSI_PARAM + elif code <= 0x3F: # < = > ? + self._private += ch + self.state = CSI_PARAM + elif code <= 0x7E: + out.append(Csi(self._private, self._split_params(), + self._intermediate, ch)) + self.state = GROUND + elif code == 0x7F: + pass + else: + abort_and_print(ch) + + elif state == CSI_PARAM: + if code < 0x20: + out.append(Ctrl(ch)) + elif code <= 0x2F: + if len(self._intermediate) < MAX_INTERMEDIATE: + self._intermediate += ch + self.state = CSI_INTERMEDIATE + elif (0x30 <= code <= 0x39 or ch == ";") and \ + len(self._params) < MAX_PARAMS: + self._params += ch + elif code <= 0x3F: # : < = > ? と長すぎる列 + self.state = CSI_IGNORE + elif code <= 0x7E: + out.append(Csi(self._private, self._split_params(), + self._intermediate, ch)) + self.state = GROUND + elif code == 0x7F: + pass + else: + abort_and_print(ch) + + elif state == CSI_INTERMEDIATE: + if code < 0x20: + out.append(Ctrl(ch)) + elif code <= 0x2F: + if len(self._intermediate) < MAX_INTERMEDIATE: + self._intermediate += ch + elif code <= 0x3F: + self.state = CSI_IGNORE + elif code <= 0x7E: + out.append(Csi(self._private, self._split_params(), + self._intermediate, ch)) + self.state = GROUND + elif code == 0x7F: + pass + else: + abort_and_print(ch) + + elif state == CSI_IGNORE: + if code < 0x20: + out.append(Ctrl(ch)) + elif 0x40 <= code <= 0x7E: + self.state = GROUND + elif code <= 0x7F: + pass + else: + abort_and_print(ch) + + elif state == OSC_STRING: + if code == 0x07: # xterm 拡張: BEL 終端 + self._end_string(out) + self.state = GROUND + elif code < 0x20: + pass + elif len(self._string) < MAX_STRING: + self._string.append(ch) + + elif state == SOS_PM_APC: + pass # ST/ESC/CAN/SUB まで無視 + + elif state == DCS_ENTRY: + if code < 0x20: + pass + elif code <= 0x2F: + if len(self._intermediate) < MAX_INTERMEDIATE: + self._intermediate += ch + self.state = DCS_INTERMEDIATE + elif ch == ":": + self.state = DCS_IGNORE + elif code <= 0x39 or ch == ";": + self._params += ch + self.state = DCS_PARAM + elif code <= 0x3F: + self._private += ch + self.state = DCS_PARAM + elif code <= 0x7E: + self._hook = (self._private, self._split_params(), + self._intermediate, ch) + self._string = [] + self.state = DCS_PASSTHROUGH + elif code == 0x7F: + pass + else: + abort_and_print(ch) + + elif state == DCS_PARAM: + if code < 0x20: + pass + elif code <= 0x2F: + if len(self._intermediate) < MAX_INTERMEDIATE: + self._intermediate += ch + self.state = DCS_INTERMEDIATE + elif (0x30 <= code <= 0x39 or ch == ";") and \ + len(self._params) < MAX_PARAMS: + self._params += ch + elif code <= 0x3F: + self.state = DCS_IGNORE + elif code <= 0x7E: + self._hook = (self._private, self._split_params(), + self._intermediate, ch) + self._string = [] + self.state = DCS_PASSTHROUGH + elif code == 0x7F: + pass + else: + abort_and_print(ch) + + elif state == DCS_INTERMEDIATE: + if code < 0x20: + pass + elif code <= 0x2F: + if len(self._intermediate) < MAX_INTERMEDIATE: + self._intermediate += ch + elif code <= 0x3F: + self.state = DCS_IGNORE + elif code <= 0x7E: + self._hook = (self._private, self._split_params(), + self._intermediate, ch) + self._string = [] + self.state = DCS_PASSTHROUGH + elif code == 0x7F: + pass + else: + abort_and_print(ch) + + elif state == DCS_PASSTHROUGH: + if code == 0x7F: + pass + elif len(self._string) < MAX_STRING: + self._string.append(ch) + + elif state == DCS_IGNORE: + pass # ST/ESC/CAN/SUB まで無視 + + flush() + return out diff --git a/src/core/terminal/screen.py b/src/core/terminal/screen.py new file mode 100644 index 0000000..1eefb40 --- /dev/null +++ b/src/core/terminal/screen.py @@ -0,0 +1,513 @@ +"""行×桁の画面。パーサの命令を適用して格子を更新する。UI を知らない。 + +セルは (文字, Attr) のタプル。履歴 (上から押し出された行) は +メイン画面でだけ増える。動きの根拠は ECMA-48 と、標準に無い部分は +XTerm Control Sequences。 + +制限: 全角文字も 1 セルとして扱う (桁計算は ASCII 前提。機器 CLI の +出力は ASCII で、ここが崩れる実害は Linux 側の全角編集のみ)。 +""" +import collections + +from core.terminal import parser +from core.terminal.attrs import DEFAULT, apply_sgr + +BLANK = (" ", DEFAULT) + +# DEC Special Graphics (ESC ( 0 で指示される罫線用文字集合) +DEC_GRAPHICS = dict(zip( + "`abcdefghijklmnopqrstuvwxyz{|}~", + "◆▒␉␌␍␊°±␤␋┘┐┌└┼⎺⎻─⎼⎽├┤┴┬│≤≥π≠£·")) + + +def _param(params, i, default): + if i < len(params) and params[i] is not None: + return params[i] + return default + + +class Screen(object): + def __init__(self, rows=24, cols=80, max_history=5000): + self.rows = rows + self.cols = cols + self.history = collections.deque(maxlen=max_history) + self._new_history = [] + self.title = "" + self.responses = [] # 機器へ送り返す応答 (DSR/DA) + self.reset() + + def reset(self): + """RIS 相当。履歴とタイトルだけは残す (セッションの記録なので)。""" + self.lines = self._blank_lines() + self._other = self._blank_lines() # 裏画面 (代替画面用) + # wrapped[r] = その行は折り返しで次の行へ続いている。 + # 機器が送った改行との区別が付かないと、窓を戻したときに + # 繋ぎ直せず、出力が刻まれたまま残る + self.wrapped = [False] * self.rows + self._other_wrapped = [False] * self.rows + self._reflowed = False + self.alt_active = False + self.cursor_row = 0 + self.cursor_col = 0 + self.attr = DEFAULT + self.scroll_top = 0 + self.scroll_bottom = self.rows - 1 + self._pending_wrap = False + self._saved = (0, 0, DEFAULT) # ESC 7 / ESC 8 + self._saved_main = None # ?1049 用 + self.autowrap = True + self.cursor_visible = True + self.application_cursor_keys = False # ?1 DECCKM (入力側が見る) + self.bracketed_paste = False # ?2004 (入力側が見る) + self._g = {"(": "B", ")": "B"} # G0/G1 の指示文字 + self._charset = "(" # SI/SO でどちらを使うか + self.dirty = set(range(self.rows)) + + def _blank_lines(self): + return [self._blank_line() for _ in range(self.rows)] + + def _blank_line(self): + return [BLANK] * self.cols + + # ---- 外から使う口 ---------------------------------------------- + + def apply(self, events): + """パーサの命令列を画面へ適用する。""" + for event in events: + if isinstance(event, parser.Print): + self._print(event.text) + elif isinstance(event, parser.Ctrl): + self._ctrl(event.char) + elif isinstance(event, parser.Csi): + self._csi(event) + elif isinstance(event, parser.Esc): + self._esc(event) + elif isinstance(event, parser.Osc): + self._osc(event.text) + # Dcs は画面に出るものではないので捨てる + + def text(self): + """画面の見た目を行のリストで返す (検証と描画の共通口)。""" + return ["".join(cell[0] for cell in line).rstrip() + for line in self.lines] + + def take_new_history(self): + """前回から増えた履歴を (行, 折り返しで続くか) で返して忘れる。""" + new = self._new_history + self._new_history = [] + return new + + def take_dirty(self): + """描き直しが要る行番号を返して忘れる。""" + dirty = self.dirty + self.dirty = set() + return dirty + + def take_reflowed(self): + """前回の描画のあとに組み直しが起きたかを返して忘れる。 + + 組み直すと行の切れ目が全部変わるので、描画側が持っている + 「前回描いた内容」との突き合わせが当てにならなくなる。 + """ + reflowed = self._reflowed + self._reflowed = False + return reflowed + + def take_responses(self): + """機器へ送り返すべき応答 (DSR/DA) を返して忘れる。""" + responses = self.responses + self.responses = [] + return responses + + def set_size(self, rows, cols): + """端末の大きさを変える。書かれた行には触らない。 + + 桁が広がったら足りない分を空白で埋めるだけ。狭くなっても + 切らないし、割り直しもしない。割り直すと窓を往復するたびに + 行の切れ目が変わり、そのたびに中身が削れていった。実端末も + 既に書かれた行は組み直さない。狭いときの見た目は表示側が + 折り返して面倒を見る。 + + 行数が減ったぶんは、まず下の空行を捨て、足りなければ上の行を + 履歴へ送る (書かれた行を黙って消さないため)。 + """ + if (rows, cols) == (self.rows, self.cols) or rows < 1 or cols < 1: + return + # 折り返しで次へ続く行は埋めない。行の長さがそのまま「どこで + # 折り返したか」なので、埋めると繋いだときに埋め草ぶんの隙間が + # 開く (窓を広げると鍵の途中に空白が入る、として報告された) + for lines, marks in ((self.lines, self.wrapped), + (self._other, self._other_wrapped)): + for r, line in enumerate(lines): + if len(line) < cols and not (r < len(marks) and marks[r]): + line.extend([BLANK] * (cols - len(line))) + + # メイン画面は記録なので、あふれたら履歴へ送る。代替画面 + # (vi 等) はアプリが描き直すので、切っても構わない + if self.alt_active: + main, main_marks = self._other, self._other_wrapped + alt, alt_marks = self.lines, self.wrapped + keep_row = (self._saved_main[0] if self._saved_main else 0) + else: + main, main_marks = self.lines, self.wrapped + alt, alt_marks = self._other, self._other_wrapped + keep_row = self.cursor_row + + while len(main) > rows: + if keep_row < len(main) - 1 and all(c == BLANK for c in main[-1]): + main.pop() + main_marks.pop() + else: + self.history.append(main.pop(0)) + self._new_history.append((self.history[-1], + main_marks.pop(0))) + keep_row = max(0, keep_row - 1) + while len(main) < rows: + main.append([BLANK] * cols) + main_marks.append(False) + if self.alt_active: + if self._saved_main: + self._saved_main = (min(keep_row, rows - 1), + min(self._saved_main[1], cols - 1), + self._saved_main[2]) + else: + self.cursor_row = keep_row + + del alt[rows:] + del alt_marks[rows:] + while len(alt) < rows: + alt.append([BLANK] * cols) + alt_marks.append(False) + + self.rows, self.cols = rows, cols + self.scroll_top, self.scroll_bottom = 0, rows - 1 + self.cursor_row = min(self.cursor_row, rows - 1) + self.cursor_col = min(self.cursor_col, cols - 1) + self._pending_wrap = False + self.dirty = set(range(rows)) + self._reflowed = True + + # ---- 印字と C0 ------------------------------------------------- + + def _print(self, text): + for ch in text: + if ch == "\x7f": # DEL は表示しない + continue + if self._pending_wrap: # 右端の 1 文字あとの折り返し + self.cursor_col = 0 + self._linefeed(from_wrap=True) + if self._g[self._charset] == "0": + ch = DEC_GRAPHICS.get(ch, ch) + line = self.lines[self.cursor_row] + if self.cursor_col >= len(line): + # 折り返し行は埋めていないので、書くときに伸ばす + line.extend([BLANK] * (self.cursor_col + 1 - len(line))) + line[self.cursor_col] = (ch, self.attr) + self.dirty.add(self.cursor_row) + if self.cursor_col + 1 < self.cols: + self.cursor_col += 1 + elif self.autowrap: + self._pending_wrap = True + # autowrap 無効なら右端で上書きを続ける + + def _ctrl(self, ch): + if ch == "\r": + self.cursor_col = 0 + self._pending_wrap = False + elif ch in "\n\x0b\x0c": + self._linefeed() + elif ch == "\b": + if self.cursor_col: + self.cursor_col -= 1 + self._pending_wrap = False + elif ch == "\t": + self.cursor_col = min(self.cols - 1, + (self.cursor_col // 8 + 1) * 8) + self._pending_wrap = False + elif ch == "\x0e": # SO: G1 へ + self._charset = ")" + elif ch == "\x0f": # SI: G0 へ + self._charset = "(" + # BEL・NUL などは何もしない + + def _linefeed(self, from_wrap=False): + self._pending_wrap = False + # 折り返しで送られたのか、機器が改行を送ったのかを覚える + self.wrapped[self.cursor_row] = from_wrap + if self.cursor_row == self.scroll_bottom: + self._scroll_up(1) + elif self.cursor_row + 1 < self.rows: + self.cursor_row += 1 + + def _scroll_up(self, n): + for _ in range(n): + removed = self.lines.pop(self.scroll_top) + removed_wrap = self.wrapped.pop(self.scroll_top) + self.lines.insert(self.scroll_bottom, self._blank_line()) + self.wrapped.insert(self.scroll_bottom, False) + if (not self.alt_active and self.scroll_top == 0 + and self.scroll_bottom == self.rows - 1): + self.history.append(removed) + self._new_history.append((removed, removed_wrap)) + self.dirty.update(range(self.scroll_top, self.scroll_bottom + 1)) + + def _scroll_down(self, n): + for _ in range(n): + self.lines.pop(self.scroll_bottom) + self.wrapped.pop(self.scroll_bottom) + self.lines.insert(self.scroll_top, self._blank_line()) + self.wrapped.insert(self.scroll_top, False) + self.dirty.update(range(self.scroll_top, self.scroll_bottom + 1)) + + # ---- CSI ------------------------------------------------------- + + def _move(self, row, col): + self.cursor_row = max(0, min(self.rows - 1, row)) + self.cursor_col = max(0, min(self.cols - 1, col)) + self._pending_wrap = False + self.dirty.add(self.cursor_row) + + def _csi(self, seq): + if seq.private == "?" and seq.final in "hl": + return self._private_mode(seq) + if seq.private or seq.intermediate: + return # DECSCUSR 等、表示に関わらない + p = seq.params + f = seq.final + n = max(1, _param(p, 0, 1)) + # 繰り返す命令の回数は、画面より大きくても意味を持たない。 + # 頭打ちにしないと ESC[999999999S だけで画面処理が何十分も + # 回り、その間 GUI が固まる (パラメータは 256 桁まで書ける)。 + # カーソル移動は _move が行桁で丸めるので、ここでは触らない + rows_n = min(n, self.rows) + cols_n = min(n, self.cols) + if f == "A": + limit = (self.scroll_top + if self.cursor_row >= self.scroll_top else 0) + self._move(max(limit, self.cursor_row - n), self.cursor_col) + elif f in "Be": + limit = (self.scroll_bottom + if self.cursor_row <= self.scroll_bottom + else self.rows - 1) + self._move(min(limit, self.cursor_row + n), self.cursor_col) + elif f in "Ca": + self._move(self.cursor_row, self.cursor_col + n) + elif f == "D": + self._move(self.cursor_row, self.cursor_col - n) + elif f == "E": + self._move(self.cursor_row + n, 0) + elif f == "F": + self._move(self.cursor_row - n, 0) + elif f in "G`": + self._move(self.cursor_row, _param(p, 0, 1) - 1) + elif f == "d": + self._move(_param(p, 0, 1) - 1, self.cursor_col) + elif f in "Hf": + self._move(_param(p, 0, 1) - 1, _param(p, 1, 1) - 1) + elif f == "J": + self._erase_display(_param(p, 0, 0)) + elif f == "K": + self._erase_line(_param(p, 0, 0)) + elif f == "L": + self._shift_lines(rows_n, insert=True) + elif f == "M": + self._shift_lines(rows_n, insert=False) + elif f == "@": + self._shift_chars(cols_n, insert=True) + elif f == "P": + self._shift_chars(cols_n, insert=False) + elif f == "X": + self._erase_chars(cols_n) + elif f == "S": + self._scroll_up(rows_n) + elif f == "T": + self._scroll_down(rows_n) + elif f == "r": + self._set_margins(p) + elif f == "m": + self.attr = apply_sgr(self.attr, p) + elif f == "n": + if _param(p, 0, 0) == 6: # DSR: カーソル位置を答える + self.responses.append("\x1b[%d;%dR" % ( + self.cursor_row + 1, self.cursor_col + 1)) + elif _param(p, 0, 0) == 5: + self.responses.append("\x1b[0n") + elif f == "c": # DA: VT100 with AVO と名乗る + self.responses.append("\x1b[?1;2c") + # 知らない最終文字は黙って捨てる (画面を壊さないことが仕事) + + def _esc(self, seq): + if seq.intermediate in ("(", ")"): # 文字集合の指示 + self._g[seq.intermediate] = seq.final + elif seq.final == "7": + self._saved = (self.cursor_row, self.cursor_col, self.attr) + elif seq.final == "8": + row, col, attr = self._saved + self.attr = attr + self._move(row, col) + elif seq.final == "D": # IND + self._linefeed() + elif seq.final == "M": # RI: 上端では下へスクロール + if self.cursor_row == self.scroll_top: + self._scroll_down(1) + elif self.cursor_row: + self.cursor_row -= 1 + self._pending_wrap = False + elif seq.final == "E": # NEL + self.cursor_col = 0 + self._linefeed() + elif seq.final == "c": # RIS。履歴は reset が残す + self.reset() + # = > \ H などは表示を変えない + + def _osc(self, text): + num, _, rest = text.partition(";") + if num in ("0", "2"): + self.title = rest + + def _private_mode(self, seq): + on = seq.final == "h" + for mode in seq.params: + if mode in (1049, 1047, 47): + self._switch_screen(on, with_cursor=(mode == 1049)) + elif mode == 7: + self.autowrap = on + if not on: + # 右端で保留していた折り返しも一緒に捨てる。 + # 残すと、折り返しを切った直後の 1 文字だけが + # 次の行へ流れる + self._pending_wrap = False + elif mode == 25: + self.cursor_visible = on + elif mode == 1: + self.application_cursor_keys = on + elif mode == 2004: + self.bracketed_paste = on + # ほかの私用モードは表示に効かないので無視 + + def _switch_screen(self, to_alt, with_cursor): + if to_alt == self.alt_active: + return + if to_alt and with_cursor: + self._saved_main = (self.cursor_row, self.cursor_col, self.attr) + self.lines, self._other = self._other, self.lines + # 折り返しの印も画面と一緒に入れ替える。裏へ回ったメイン画面の + # 印を失うと、戻ってきたときに組み直しで繋ぎ直せなくなる + self.wrapped, self._other_wrapped = ( + self._other_wrapped, self.wrapped) + self.alt_active = to_alt + if to_alt: # 代替画面は白紙で始まる + for r in range(self.rows): + self.lines[r] = self._blank_line() + self.wrapped[r] = False + self._move(0, 0) + elif with_cursor and self._saved_main: + row, col, attr = self._saved_main + self.attr = attr + self._move(row, col) + self.dirty.update(range(self.rows)) + self._pending_wrap = False + + def _set_margins(self, p): + top = _param(p, 0, 1) - 1 + bottom = _param(p, 1, self.rows) - 1 + if 0 <= top < bottom <= self.rows - 1: + self.scroll_top = top + self.scroll_bottom = bottom + self._move(0, 0) # DECSTBM はカーソルも戻す + + def _erase_display(self, mode): + if mode == 3: + # ED 3 は履歴 (スクロールバック) を消す命令で、可視画面には + # 触らない。NetBelt はセッションの記録を消さない方針なので + # 何もしない。画面まで消すと clear -x で表示が飛ぶ + return + # 画面全体が消えるとき (clear は ESC[H ESC[J、つまり home からの + # mode 0 で来る) は、消す前に見えていた中身を履歴へ送る。 + # clear でセッションの記録を失わない、という v1.1.1 の方針 + wipes_all = (mode >= 2 or + (mode == 0 and (self.cursor_row, self.cursor_col) + == (0, 0))) + if wipes_all and not self.alt_active: + last = -1 + for r in range(self.rows): + if any(c != BLANK for c in self.lines[r]): + last = r + for r, line in enumerate(self.lines[:last + 1]): + self.history.append(line) + self._new_history.append((line, self.wrapped[r])) + if wipes_all: + rng = range(0, self.rows) + elif mode == 0: + self._erase_line(0) + rng = range(self.cursor_row + 1, self.rows) + else: + self._erase_line(1) + rng = range(0, self.cursor_row) + for r in rng: + self.lines[r] = self._blank_line() + self.wrapped[r] = False + self.dirty.update(rng) + self._pending_wrap = False + + def _erase_line(self, mode): + line = self.lines[self.cursor_row] + # 行の長さは桁数と一致しない。窓を縮めても切らないので長いことが + # あり、折り返しで続く行は広げても埋めないので短いこともある。 + # カーソルは桁数まで動けるので、必ず行の実際の長さで抑える + end = min(self.cursor_col + 1, len(line)) + if mode == 0: + rng = range(min(self.cursor_col, len(line)), len(line)) + elif mode == 1: + rng = range(0, end) + else: + rng = range(0, len(line)) + for c in rng: + line[c] = BLANK + if mode != 1: # 行末まで消したら続きは無い + self.wrapped[self.cursor_row] = False + self.dirty.add(self.cursor_row) + self._pending_wrap = False + + def _shift_lines(self, n, insert): + """IL / DL。スクロール範囲の中でだけ効く。""" + if not self.scroll_top <= self.cursor_row <= self.scroll_bottom: + return + for _ in range(n): + if insert: + self.lines.pop(self.scroll_bottom) + self.wrapped.pop(self.scroll_bottom) + self.lines.insert(self.cursor_row, self._blank_line()) + self.wrapped.insert(self.cursor_row, False) + else: + self.lines.pop(self.cursor_row) + self.wrapped.pop(self.cursor_row) + self.lines.insert(self.scroll_bottom, self._blank_line()) + self.wrapped.insert(self.scroll_bottom, False) + self.dirty.update(range(self.cursor_row, self.scroll_bottom + 1)) + self._pending_wrap = False + + def _shift_chars(self, n, insert): + """ICH / DCH。行の右端は詰まる・押し出される。""" + line = self.lines[self.cursor_row] + # カーソルが行の実際の長さより右にあることがある (折り返しで + # 続く行は広げても埋めないため)。そこで詰めても意味が無い + if self.cursor_col >= len(line): + return + for _ in range(n): + if insert: + line.pop() + line.insert(self.cursor_col, BLANK) + else: + line.pop(self.cursor_col) + line.append(BLANK) + self.dirty.add(self.cursor_row) + self._pending_wrap = False + + def _erase_chars(self, n): + line = self.lines[self.cursor_row] + for c in range(self.cursor_col, + min(len(line), self.cursor_col + n)): + line[c] = BLANK + self.dirty.add(self.cursor_row) + self._pending_wrap = False diff --git a/src/core/version_manager.py b/src/core/version_manager.py index e372f80..a2e5d5e 100644 --- a/src/core/version_manager.py +++ b/src/core/version_manager.py @@ -24,7 +24,13 @@ class VersionManager: """バージョン管理とアップデート機能を提供するクラス""" CURRENT_VERSION = __version__ - GITHUB_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest" + # 1回で複数世代ぶんを取る。/releases/latest だと最新1件しか返らず、 + # 何世代か飛ばしている利用者へ「その間に何が変わったか」を出せない。 + # 一覧なら要求は同じ1回のままで、下書きと事前公開は自分で除ける。 + GITHUB_API_URL = ( + f"https://api.github.com/repos/{GITHUB_REPO}/releases?per_page=10") + # 通知に載せる世代数の上限。全部並べると読まれない + MAX_NOTES_GENERATIONS = 5 UPDATE_DIR = os.path.join(tempfile.gettempdir(), f"{APP_NAME}Updates") def __init__(self, github_token: Optional[str] = None): @@ -82,6 +88,63 @@ def _seg(s): return 0 + @staticmethod + def _published(payload) -> list: + """公開済みのリリースだけを、新しい順で返す。 + + 一覧には下書きと事前公開も混ざる。並び順は GitHub が新しい順で + 返すが、当てにせず版で並べ直す。 + """ + if isinstance(payload, dict): # 単一リリースを渡された場合 + payload = [payload] + if not isinstance(payload, list): + return [] + published = [r for r in payload + if isinstance(r, dict) + and not r.get('draft') and not r.get('prerelease') + and r.get('tag_name')] + return sorted( + published, + key=lambda r: VersionManager._version_key( + r.get('tag_name', '').lstrip('v')), + reverse=True) + + @staticmethod + def _version_key(version: str) -> tuple: + """並べ替え用に版を数値の組へ。読めない部分は 0 として扱う。""" + parts = [] + for chunk in str(version).split('.')[:4]: + digits = ''.join(c for c in chunk if c.isdigit()) + parts.append(int(digits) if digits else 0) + while len(parts) < 4: + parts.append(0) + return tuple(parts) + + def collect_notes(self, releases: list) -> str: + """いま入っている版より新しいリリースの内容を、まとめて返す。 + + リリースノートに README や告知しか無いと、利用者は何が変わったのか + 分からない。何世代か飛ばしている場合は、その間のぶんも並べる。 + 多すぎると読まれないので上限を設ける。 + """ + newer = [r for r in releases + if self.compare_versions( + self.CURRENT_VERSION, + r.get('tag_name', '').lstrip('v')) < 0] + if not newer: + return releases[0].get('body', '') if releases else '' + + shown = newer[:self.MAX_NOTES_GENERATIONS] + blocks = [] + for release in shown: + version = release.get('tag_name', '').lstrip('v') + body = (release.get('body') or '').strip() + blocks.append("# v%s\n\n%s" % (version, body or '(内容なし)')) + if len(newer) > len(shown): + blocks.append("(さらに古い %d 世代ぶんは省略しました)" + % (len(newer) - len(shown))) + return "\n\n---\n\n".join(blocks) + def check_for_updates(self, timeout: int = 10) -> Optional[Dict]: """ GitHubから最新バージョンを確認 @@ -107,9 +170,13 @@ def check_for_updates(self, timeout: int = 10) -> Optional[Dict]: response = requests.get(self.GITHUB_API_URL, headers=headers, timeout=timeout) response.raise_for_status() - - data = response.json() - + + payload = response.json() + releases = self._published(payload) + if not releases: + return None + data = releases[0] + # 最新バージョンを取得 latest_version = data.get('tag_name', '').lstrip('v') @@ -146,7 +213,7 @@ def check_for_updates(self, timeout: int = 10) -> Optional[Dict]: return { 'available': is_newer, 'version': latest_version, - 'release_notes': data.get('body', ''), + 'release_notes': self.collect_notes(releases), 'download_url': download_url, 'sha256_url': sha256_url, 'published_at': data.get('published_at', '') diff --git a/src/ui/device_tree.py b/src/ui/device_tree.py index f05dec5..c2d76b5 100644 --- a/src/ui/device_tree.py +++ b/src/ui/device_tree.py @@ -18,6 +18,7 @@ class DeviceTree(QWidget): group_add_requested = pyqtSignal() # グループ追加要求 group_edit_requested = pyqtSignal(str) # グループ編集要求(グループ名) group_delete_requested = pyqtSignal(str) # グループ削除要求(グループ名) + hide_requested = pyqtSignal() # このエリアを隠す要求(戻すのは表示メニュー) # 一般的なボーレート値 BAUD_RATES = [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600] @@ -173,10 +174,19 @@ def refresh_serial_ports(self): # 再度追加 self._add_serial_ports_group() + def _add_hide_action(self, menu): + """どの右クリックメニューにも「非表示」を足す。 + + 機器の上・グループの上・空欄のどこで押しても同じように隠せる + ようにする。戻すのは表示メニューから。 + """ + menu.addSeparator() + return menu.addAction("接続先リストを非表示") + def _show_context_menu(self, position): """ 右クリックメニューを表示 - + Args: position: クリック位置 """ @@ -237,11 +247,15 @@ def _show_context_menu(self, position): duplicate_action = menu.addAction("複製") baudrate_actions = [] + hide_action = self._add_hide_action(menu) + # メニュー実行 action = menu.exec(self.tree.viewport().mapToGlobal(position)) - + # アクション処理 - if action == connect_action: + if action == hide_action: + self.hide_requested.emit() + elif action == connect_action: self.device_connect.emit(group_name, device_data) elif action == edit_action and edit_action is not None: self.device_edit.emit(group_name, device_data) @@ -287,11 +301,14 @@ def _show_empty_area_menu(self, position): """ menu = QMenu(self) add_group_action = menu.addAction("グループを追加") - + hide_action = self._add_hide_action(menu) + action = menu.exec(self.tree.viewport().mapToGlobal(position)) - + if action == add_group_action: self.group_add_requested.emit() + elif action == hide_action: + self.hide_requested.emit() def _show_group_menu(self, position, group_item: QTreeWidgetItem): """ @@ -302,22 +319,22 @@ def _show_group_menu(self, position, group_item: QTreeWidgetItem): group_item: グループアイテム """ group_name = group_item.text(0) - - # コンソール接続グループは編集・削除不可 - if group_name == "コンソール接続": - return - menu = QMenu(self) - edit_action = menu.addAction("グループを編集") - - # Defaultグループは削除不可 - delete_action = None - if group_name != "Default": - delete_action = menu.addAction("グループを削除") - + + # コンソール接続グループは編集・削除不可。それでも非表示は出す + edit_action = delete_action = None + if group_name != "コンソール接続": + edit_action = menu.addAction("グループを編集") + # Defaultグループは削除不可 + if group_name != "Default": + delete_action = menu.addAction("グループを削除") + hide_action = self._add_hide_action(menu) + action = menu.exec(self.tree.viewport().mapToGlobal(position)) - - if action == edit_action: + + if action == hide_action: + self.hide_requested.emit() + elif action == edit_action and edit_action is not None: self.group_edit_requested.emit(group_name) elif action == delete_action and delete_action is not None: self.group_delete_requested.emit(group_name) diff --git a/src/ui/dialogs/update_dialog.py b/src/ui/dialogs/update_dialog.py index 39f1a80..7646e0b 100644 --- a/src/ui/dialogs/update_dialog.py +++ b/src/ui/dialogs/update_dialog.py @@ -139,7 +139,13 @@ def _setup_ui(self): # リリースノート表示エリア self.notes_text = QTextEdit() self.notes_text.setReadOnly(True) - self.notes_text.setPlainText(self.update_info.get('release_notes', 'リリースノートがありません')) + # リリースノートは Markdown で書かれている。そのまま出すと + # 見出しの # や箇条書きの - が記号のまま並んで読みにくい + notes = self.update_info.get('release_notes') or 'リリースノートがありません' + try: + self.notes_text.setMarkdown(notes) + except Exception: + self.notes_text.setPlainText(notes) self.notes_text.setMaximumHeight(150) layout.addWidget(self.notes_text) diff --git a/src/ui/main_window.py b/src/ui/main_window.py index 97d84c9..964e1ab 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -247,6 +247,11 @@ def _create_menu_bar(self): self.snmp_panel_action.triggered.connect(self._toggle_snmp_panel) view_menu.addSeparator() + self.toggle_device_list_action = view_menu.addAction("接続先リスト表示/非表示") + self.toggle_device_list_action.setCheckable(True) + self.toggle_device_list_action.setChecked(True) + self.toggle_device_list_action.triggered.connect( + self._toggle_device_list) self.toggle_tool_area_action = view_menu.addAction("ツールエリア表示/非表示") self.toggle_tool_area_action.setCheckable(True) self.toggle_tool_area_action.setChecked(True) @@ -304,6 +309,7 @@ def _create_main_widget(self): self.device_tree.group_add_requested.connect(self._on_add_group) self.device_tree.group_edit_requested.connect(self._on_edit_group) self.device_tree.group_delete_requested.connect(self._on_delete_group) + self.device_tree.hide_requested.connect(self._toggle_device_list) splitter.addWidget(self.device_tree) # 右側: ターミナル @@ -317,6 +323,7 @@ def _create_main_widget(self): self.terminal_widget.macro_settings_requested.connect(self._on_macro_settings_from_context) self.terminal_widget.keepalive_start_requested.connect(self._on_keepalive_start_requested) self.terminal_widget.keepalive_stop_requested.connect(self._on_keepalive_stop_requested) + self.terminal_widget.terminal_resized.connect(self._on_terminal_resized) splitter.addWidget(self.terminal_widget) # デフォルトの分割比率を設定(30% : 70%) @@ -329,9 +336,11 @@ def _create_main_widget(self): self.tool_tabs.tabBar().customContextMenuRequested.connect(self._tool_area_context_menu) splitter.addWidget(self.tool_tabs) splitter.setSizes([250, 650, 300]) - splitter.setCollapsible(0, False) + # 接続先リストとツールエリアはハンドルを引いて畳める。 + # ターミナルは本体なので畳ませない + splitter.setCollapsible(0, True) splitter.setCollapsible(1, False) - splitter.setCollapsible(2, True) # ツールエリアはハンドルで折り畳み可 + splitter.setCollapsible(2, True) layout.addWidget(splitter) @@ -368,9 +377,25 @@ def _create_main_widget(self): def _create_status_bar(self): """ステータスバー作成""" + from PyQt6.QtWidgets import QLabel + self.status_bar = QStatusBar() self.setStatusBar(self.status_bar) + # 端末の大きさ。右端の常設欄に置くので、showMessage の + # 一時メッセージとは場所を取り合わない + self.terminal_size_label = QLabel("") + self.terminal_size_label.setToolTip( + "いま機器へ伝えている端末の大きさ(桁×行)") + self.status_bar.addPermanentWidget(self.terminal_size_label) self.status_bar.showMessage("準備完了") + + def _show_terminal_size(self, device_name: str) -> None: + """表示中のターミナルの大きさを、ステータスバーへ出す。""" + if not device_name: + self.terminal_size_label.setText("") + return + cols, rows = self.terminal_widget.grid_size_for(device_name) + self.terminal_size_label.setText("%d x %d" % (cols, rows)) def _load_devices(self): """設定ファイルから接続先リストを読み込み""" @@ -598,6 +623,9 @@ def _connect_ssh(self, device_data: dict): # SSH接続を作成 ssh = SSHConnection(host, port, username, password, ssh_key, self) + # pty 要求 (RFC 4254 6.2) に、いまの表示領域の行数・桁数を使う + cols, rows = self.terminal_widget.grid_size_for(device_name) + ssh.set_terminal_size(cols, rows) # シグナル接続 ssh.output_received.connect(lambda text: self.terminal_widget.append_output(device_name, text)) @@ -638,7 +666,7 @@ def connect_thread(): if not success: # GUIスレッド外からウィジェットを直接触らない。 # 既に append_output へ接続済みのシグナルへ流す。 - ssh.output_received.emit("\n接続失敗\n") + ssh.output_received.emit("\r\n接続失敗\r\n") except Exception as e: ssh.error_occurred.emit(f"接続スレッドエラー: {str(e)}") @@ -709,7 +737,7 @@ def connect_thread(): if not success: # GUIスレッド外からウィジェットを直接触らない。 # 既に append_output へ接続済みのシグナルへ流す。 - serial_conn.output_received.emit("\n接続失敗\n") + serial_conn.output_received.emit("\r\n接続失敗\r\n") except Exception as e: serial_conn.error_occurred.emit(f"接続スレッドエラー: {str(e)}") @@ -778,7 +806,7 @@ def connect_thread(): if not success: # GUIスレッド外からウィジェットを直接触らない。 # 既に append_output へ接続済みのシグナルへ流す。 - telnet.output_received.emit("\n接続失敗\n") + telnet.output_received.emit("\r\n接続失敗\r\n") except Exception as e: telnet.error_occurred.emit(f"接続スレッドエラー: {str(e)}") @@ -854,6 +882,8 @@ def _on_terminal_tab_changed(self, device_name: str) -> None: # いま見ている機器に SFTP が無いなら、前の機器のものを残さない self.sftp_panel.clear() + self._show_terminal_size(device_name) + def _find_group_of_device(self, device_name: str): """機器名から所属グループを返す(見つからなければNone)""" for group in self.config_manager.get_groups(): @@ -901,7 +931,7 @@ def _on_connection_closed(self, device_name: str): del self.connections[device_name] # 切断メッセージと再接続方法を表示 - self.terminal_widget.append_output( + self.terminal_widget.show_notice( device_name, "\n\n========================================\n" "セッションが切断されました\n" @@ -922,7 +952,7 @@ def _on_connection_error(self, device_name: str, error: str): self._on_connection_closed(device_name) else: # その他のエラー - self.terminal_widget.append_output(device_name, f"\nエラー: {error}\n") + self.terminal_widget.show_notice(device_name, f"\nエラー: {error}\n") if device_name in self.connections: del self.connections[device_name] @@ -935,11 +965,11 @@ def _reconnect_device(self, device_name: str): """ # 機器情報を取得 if device_name not in self.device_info: - self.terminal_widget.append_output(device_name, "\n再接続情報が見つかりません\n") + self.terminal_widget.show_notice(device_name, "\n再接続情報が見つかりません\n") return # 再接続メッセージ - self.terminal_widget.append_output(device_name, "再接続中...\n\n") + self.terminal_widget.show_notice(device_name, "再接続中...\n\n") # 接続処理を実行 device_data = self.device_info[device_name] @@ -961,6 +991,18 @@ def _on_connect_button_clicked(self): # 接続処理を実行(ダブルクリックと同じ処理) self._on_connect_requested(device_data) + def _on_terminal_resized(self, device_name: str, cols: int, rows: int): + """端末の行数・桁数の変化を機器へ伝える (RFC 4254 6.7)。 + + SSH だけが対応している。Telnet (RFC 1073) と シリアルは未対応 + なので、伝えられない接続では黙って何もしない。 + """ + conn = self.connections.get(device_name) + if conn is not None and hasattr(conn, "set_terminal_size"): + conn.set_terminal_size(cols, rows) + if device_name == self.terminal_widget.get_current_tab_name(): + self.terminal_size_label.setText("%d x %d" % (cols, rows)) + def _on_tab_closed(self, device_name: str): """ タブが閉じられたときの処理 @@ -1470,6 +1512,29 @@ def _toggle_tool_area(self): if hasattr(self, "toggle_tool_area_action"): self.toggle_tool_area_action.setChecked(show) + # 接続先リストを戻すときの幅。畳んだ状態から出すと 0 のままなので、 + # 何も見えず「戻らない」と受け取られる + DEVICE_LIST_WIDTH = 250 + + def _toggle_device_list(self): + """接続先リストの表示/非表示を切り替える。 + + 仕切りを幅 0 まで引いた状態は、見た目は隠れているのに + ウィジェットとしては表示中。分割位置は次回起動へ持ち越されるので、 + そのまま終了すると「表示メニューを押しても何も起きない」 + (実際には一度隠してから出し直している)ように見える。 + 幅が無いものは隠れていると見なす。 + """ + sizes = self.main_splitter.sizes() + hidden = self.device_tree.isHidden() or (sizes and sizes[0] < 40) + self.device_tree.setVisible(hidden) + if hidden and sizes and sizes[0] < 40: + spare = max(sizes[1] - self.DEVICE_LIST_WIDTH, 100) + self.main_splitter.setSizes( + [self.DEVICE_LIST_WIDTH, spare] + sizes[2:]) + if hasattr(self, "toggle_device_list_action"): + self.toggle_device_list_action.setChecked(hidden) + def _toggle_sftp_panel(self): """SFTPクライアントパネルの表示/非表示を切り替え""" self._select_tool_tab("sftp") diff --git a/src/ui/terminal_widget.py b/src/ui/terminal_widget.py index cbe9a9a..00baeb8 100644 --- a/src/ui/terminal_widget.py +++ b/src/ui/terminal_widget.py @@ -1,9 +1,20 @@ -import re from PyQt6.QtWidgets import QWidget, QVBoxLayout, QTextEdit, QTabWidget, QMenu -from PyQt6.QtGui import QFont, QColor, QPalette, QKeyEvent, QAction +from PyQt6.QtGui import (QFont, QColor, QPalette, QKeyEvent, QAction, + QTextCursor, QTextCharFormat) from PyQt6.QtCore import Qt, pyqtSignal from typing import Dict, Optional +from core.terminal import parser as vt +from core.terminal.attrs import DEFAULT +from core.terminal.screen import Screen, BLANK + +# SGR の基本 16 色 (xterm の既定値)。0-7 が基本、8-15 が明色 +ANSI_COLOURS = ( + "#000000", "#cd0000", "#00cd00", "#cdcd00", + "#0000ee", "#cd00cd", "#00cdcd", "#e5e5e5", + "#7f7f7f", "#ff0000", "#00ff00", "#ffff00", + "#5c5cff", "#ff00ff", "#00ffff", "#ffffff") + class InteractiveTerminal(QTextEdit): """キー入力を処理できるインタラクティブなターミナル""" @@ -16,7 +27,13 @@ class InteractiveTerminal(QTextEdit): keepalive_stop_requested = pyqtSignal() # キープアライブ停止要求シグナル # Ctrl+ホイールでのフォントサイズ変更要求(回した向き: +1 / -1) font_size_change_requested = pyqtSignal(int) - + # ウィジェットの大きさが変わった(行数・桁数の再計算が要る) + resized = pyqtSignal() + + def resizeEvent(self, event): + super().resizeEvent(event) + self.resized.emit() + def __init__(self, parent=None): super().__init__(parent) self.setReadOnly(False) @@ -77,12 +94,21 @@ def send_text(self, text: str) -> bool: """ if not text or not self.can_send_input(): return False + # アプリが ESC[?2004h を送ってきていたら、貼り付けを目印で + # 包む (xterm のブラケットペースト)。bash はこれで貼り付けを + # 即実行せず 1 かたまりの編集として扱える + screen = getattr(self, "_screen", None) + bracketed = screen is not None and screen.bracketed_paste + if bracketed: + self.key_pressed.emit("\x1b[200~") # 1文字ずつ送る。改行は端末と同じく CR で送る for char in text: if char == '\n' or char == '\r': self.key_pressed.emit('\r') else: self.key_pressed.emit(char) + if bracketed: + self.key_pressed.emit("\x1b[201~") return True def custom_paste(self): @@ -243,6 +269,17 @@ def wheelEvent(self, event): return super().wheelEvent(event) + def _cursor_key(self, letter: str) -> str: + """カーソルキーの送り方。 + + アプリが ESC[?1h (DECCKM) を立てている間は SS3 形式 + (ESC O A) で送る。nano は矢印キーをこの形で待っている。 + """ + screen = getattr(self, "_screen", None) + if screen is not None and screen.application_cursor_keys: + return "\x1bO" + letter + return "\x1b[" + letter + def keyPressEvent(self, event: QKeyEvent): """キーイベントを処理""" if not self._input_enabled: @@ -270,13 +307,13 @@ def keyPressEvent(self, event: QKeyEvent): elif key == Qt.Key.Key_Escape: self.key_pressed.emit('\x1b') elif key == Qt.Key.Key_Up: - self.key_pressed.emit('\x1b[A') + self.key_pressed.emit(self._cursor_key('A')) elif key == Qt.Key.Key_Down: - self.key_pressed.emit('\x1b[B') + self.key_pressed.emit(self._cursor_key('B')) elif key == Qt.Key.Key_Right: - self.key_pressed.emit('\x1b[C') + self.key_pressed.emit(self._cursor_key('C')) elif key == Qt.Key.Key_Left: - self.key_pressed.emit('\x1b[D') + self.key_pressed.emit(self._cursor_key('D')) else: # 通常の文字入力 text = event.text() @@ -315,7 +352,9 @@ class TerminalWidget(QWidget): keepalive_start_requested = pyqtSignal(str) # キープアライブ停止要求シグナル(機器名) keepalive_stop_requested = pyqtSignal(str) - + # 端末の行数・桁数が変わった(機器名, 桁, 行)。機器への通知に使う + terminal_resized = pyqtSignal(str, int, int) + def __init__(self): super().__init__() self._terminals: Dict[str, InteractiveTerminal] = {} # 機器名 -> ターミナル @@ -323,6 +362,13 @@ def __init__(self): self._log_dialogs: Dict[str, object] = {} # 機器名 -> ログ記録ダイアログ # ターミナルの外観設定。_create_terminal が参照するので _create_ui より先に持つ self._terminal_settings = dict(self.DEFAULT_TERMINAL_SETTINGS) + # ウィンドウをドラッグ中のリサイズ連打を 1 回にまとめる + from PyQt6.QtCore import QTimer + self._pending_resizes = set() + self._resize_timer = QTimer(self) + self._resize_timer.setSingleShot(True) + self._resize_timer.setInterval(200) + self._resize_timer.timeout.connect(self._apply_pending_resizes) self._create_ui() def _create_ui(self): @@ -333,6 +379,9 @@ def _create_ui(self): # タブウィジェット self.tab_widget = QTabWidget() self.tab_widget.setTabsClosable(True) + # 横ドラッグで並べ替えられる。機器の対応付けはタブ名と + # ウィジェットで持っていて、位置には依存しない + self.tab_widget.setMovable(True) self.tab_widget.tabCloseRequested.connect(self._close_tab) self.tab_widget.currentChanged.connect(self._on_current_tab_changed) @@ -446,6 +495,10 @@ def apply_terminal_settings(self, settings: dict): palette.setColor(QPalette.ColorRole.Text, text_color) terminal.setPalette(palette) + # 文字の大きさが変われば収まる行数・桁数も変わる + for device_name in self._terminals: + self._schedule_grid_update(device_name) + def current_terminal_settings(self) -> dict: """ いま実際に適用されている外観設定を返す @@ -477,10 +530,9 @@ def create_terminal_tab(self, device_name: str) -> InteractiveTerminal: for i in range(self.tab_widget.count()): if self.tab_widget.tabText(i) == device_name: self.tab_widget.setCurrentIndex(i) - # 案内は 1 接続につき一度。タブは機器名で使い回す - # ため、ここで戻さないと再接続後に出なくなる。 - self._terminals[device_name]._fullscreen_warned = False - self._terminals[device_name]._pending_escape = "" + # 再接続は新しいセッション。前の画面はそのまま記録と + # して文書に残し、端末状態 (パーサ・画面) は作り直す + self._attach_screen(self._terminals[device_name]) return self._terminals[device_name] # 接続機器がなく、ホームタブが残っている場合は、ホームタブを再利用 @@ -513,39 +565,305 @@ def create_terminal_tab(self, device_name: str) -> InteractiveTerminal: terminal.keepalive_stop_requested.connect( lambda: self.keepalive_stop_requested.emit(device_name) ) + + # ウィンドウの大きさに画面の格子を追従させる + terminal.resized.connect( + lambda: self._schedule_grid_update(device_name)) # タブとして追加 index = self.tab_widget.addTab(terminal, device_name) self.tab_widget.setCurrentIndex(index) - + + self._attach_screen(terminal) return terminal - + + # 端末の格子の大きさ。pty 要求 (vt100, 80x24) と合わせてある + SCREEN_ROWS = 24 + SCREEN_COLS = 80 + + def _attach_screen(self, terminal: QTextEdit) -> None: + """端末状態 (パーサと画面) をこのタブへ付け直す。 + + 文書のいまの末尾から後ろを「今の画面」の領域とする。それより + 前は確定した記録で、二度と書き換えない。 + """ + rows, cols = self._grid_size(terminal) + terminal._parser = vt.Parser() + terminal._screen = Screen(rows, cols) + region = QTextCursor(terminal.document()) + region.movePosition(QTextCursor.MoveOperation.End) + terminal._region = region + + def _grid_size(self, terminal: QTextEdit): + """いまの表示領域に収まる (行数, 桁数)。測れないときは 24x80。""" + from PyQt6.QtGui import QFontMetrics + metrics = QFontMetrics(terminal.font()) + char_w = metrics.horizontalAdvance("0") + line_h = metrics.lineSpacing() + width = terminal.viewport().width() + height = terminal.viewport().height() + if char_w <= 0 or line_h <= 0 or width < 2 * char_w or height < line_h: + return self.SCREEN_ROWS, self.SCREEN_COLS + return (max(5, min(200, height // line_h)), + max(20, min(500, width // char_w))) + + def grid_size_for(self, device_name: str): + """接続時の pty 要求 (RFC 4254 6.2) に使う (桁, 行)。""" + terminal = self._terminals.get(device_name) + if terminal is None: + return self.SCREEN_COLS, self.SCREEN_ROWS + rows, cols = self._grid_size(terminal) + return cols, rows + + def _schedule_grid_update(self, device_name: str) -> None: + self._pending_resizes.add(device_name) + self._resize_timer.start() + + def _apply_pending_resizes(self) -> None: + pending, self._pending_resizes = self._pending_resizes, set() + for device_name in sorted(pending): + self._apply_grid_size(device_name) + + def _apply_grid_size(self, device_name: str) -> None: + """画面の格子を表示領域に合わせ、変わったら機器へ知らせる。""" + terminal = self._terminals.get(device_name) + if terminal is None: + return + screen = terminal._screen + rows, cols = self._grid_size(terminal) + if (rows, cols) == (screen.rows, screen.cols): + return + screen.set_size(rows, cols) + self._render_screen(terminal) + self.terminal_resized.emit(device_name, cols, rows) + + @staticmethod + def _visible_cells(line): + """行のセル列から、既定属性の末尾空白だけを落とす。 + + 色や反転の付いた空白 (nano のタイトルバーの帯) は表示に + 意味があるので残す。 + """ + n = len(line) + while n and line[n - 1] == BLANK: + n -= 1 + return line[:n] + + @staticmethod + def _runs(cells): + """同じ属性が続く区間ごとに (文字列, 属性) を返す。""" + runs = [] + for ch, attr in cells: + if runs and runs[-1][1] == attr: + runs[-1][0].append(ch) + else: + runs.append(([ch], attr)) + return [("".join(chars), attr) for chars, attr in runs] + + def _colour(self, value, default): + """セルの色番号を実際の色にする。""" + if value is None: + return default + if isinstance(value, tuple): + return QColor(*value) + if value < 16: + return QColor(ANSI_COLOURS[value]) + if value < 232: # 6x6x6 の色立方体 + value -= 16 + parts = (value // 36, value // 6 % 6, value % 6) + return QColor(*(0 if p == 0 else 55 + p * 40 for p in parts)) + gray = 8 + (value - 232) * 10 # グレースケール + return QColor(gray, gray, gray) + + def _char_format(self, attr) -> QTextCharFormat: + fmt = QTextCharFormat() + if attr == DEFAULT: + return fmt # 空の書式 = パレットに従う + settings = self._terminal_settings + fg = self._colour(attr.fg, QColor(settings["text_color"])) + bg = self._colour(attr.bg, QColor(settings["background_color"])) + if attr.reverse: + fg, bg = bg, fg + fmt.setForeground(fg) + if attr.bg is not None or attr.reverse: + fmt.setBackground(bg) + if attr.bold: + fmt.setFontWeight(QFont.Weight.Bold) + if attr.underline: + fmt.setFontUnderline(True) + return fmt + + def _paint_row(self, terminal: QTextEdit, offset: int, cells) -> None: + """1 行ぶんのセル属性を、文書のその範囲へ塗る。 + + 書式だけを変えるので文字位置は動かず、範囲選択も壊れない。 + """ + painter = QTextCursor(terminal.document()) + for text, attr in self._runs(cells): + painter.setPosition(offset) + painter.setPosition(offset + len(text), + QTextCursor.MoveMode.KeepAnchor) + painter.setCharFormat(self._char_format(attr)) + offset += len(text) + + def _render_screen(self, terminal: QTextEdit) -> None: + """画面の中身を文書へ写す。 + + 文書 = [確定した記録 (履歴)] + [今の画面]。上から押し出された + 行を記録側へ差し込み、画面領域は変わった範囲だけ描き直す。 + """ + screen = terminal._screen + region = terminal._region + + # 組み直しの直後は、文書に残っている「今の画面」が組み直し前の + # ものなので、内容が一致しても同じ行とは限らない。境目を進める + # 近道は使えない (使うと押し出された行が書かれずに消える) + reflowed = screen.take_reflowed() + + for line, wrapped in screen.take_new_history(): + # 折り返しで続いている行は、改行で切らずに次の行と繋げる。 + # 切ると、窓を縮めている間に流れた出力が刻まれたまま記録に + # 残り、窓を戻しても直らない + cells = list(line) if wrapped else self._visible_cells(line) + text = "".join(cell[0] for cell in cells) + # 押し出された行は、画面領域の先頭として文書にもう書いて + # ある。同じ内容なら書き直さず、記録との境目を進めるだけに + # する。書き直すと画面領域が丸ごと入れ替わり、そこにある + # 範囲選択が消える (機器がログを 1 行吐くだけで起きる) + probe = QTextCursor(terminal.document()) + probe.setPosition(region.position()) + probe.movePosition(QTextCursor.MoveOperation.EndOfBlock, + QTextCursor.MoveMode.KeepAnchor) + if (not reflowed and not wrapped + and probe.selectedText() == text + and not probe.atEnd()): + region.setPosition(probe.position() + 1) + continue + for run, attr in self._runs(cells): + region.insertText(run, self._char_format(attr)) + if not wrapped: + region.insertText("\n", QTextCharFormat()) + + cell_rows = [self._visible_cells(line) for line in screen.lines] + # カーソルの行は、カーソルの桁まで空白を残す。プロンプト末尾の + # 空白 ("Router# ") を落とすとキャレットが $ に張り付いて見える + pad = screen.cursor_col - len(cell_rows[screen.cursor_row]) + if pad > 0: + cell_rows[screen.cursor_row] = ( + cell_rows[screen.cursor_row] + [BLANK] * pad) + rows = ["".join(cell[0] for cell in cells) for cells in cell_rows] + # 画面は必ず行数ぶんの高さで描く。末尾の空行を詰めると、clear の + # あとに履歴が下端へせり上がり「消えていない」ように見える。 + # 全画面アプリも、画面の一部しか窓に入らなくなる + new_text = "\n".join(rows) + start = region.position() + + # 変わった範囲だけ置き換える。全部消して入れ直すと、機器が + # ログを吐くたびに画面内の範囲選択が消えてしまう + probe = QTextCursor(terminal.document()) + probe.setPosition(start) + probe.movePosition(QTextCursor.MoveOperation.End, + QTextCursor.MoveMode.KeepAnchor) + old_text = probe.selectedText().replace("\u2029", "\n") + touched = (0, -1) + if old_text != new_text: + prefix = 0 + limit = min(len(old_text), len(new_text)) + while prefix < limit and old_text[prefix] == new_text[prefix]: + prefix += 1 + suffix = 0 + while (suffix < limit - prefix + and old_text[-1 - suffix] == new_text[-1 - suffix]): + suffix += 1 + probe.setPosition(start + prefix) + probe.setPosition(start + len(old_text) - suffix, + QTextCursor.MoveMode.KeepAnchor) + # 書式は空で入れる。insertText は挿入位置の書式を引き継ぐので、 + # 指定しないと直前の色や反転が新しい文字へ伝染する + probe.insertText(new_text[prefix:len(new_text) - suffix], + QTextCharFormat()) + touched = (prefix, len(new_text) - suffix) + region.setPosition(start) + + # 変わった行に色・太字・反転を塗り直す + dirty = screen.take_dirty() + dirty.add(screen.cursor_row) # カーソル桁の空白の伸縮ぶん + # 書き換わった範囲の行も塗り直す。画面側が「変わっていない」と + # 思っていても、入れ直した文字は書式を失っている + at = 0 + for r, line in enumerate(rows): + if at <= touched[1] and at + len(line) >= touched[0]: + dirty.add(r) + at += len(line) + 1 + offset = start + for r in range(len(rows)): + if r in dirty: + self._paint_row(terminal, offset, cell_rows[r]) + offset += len(rows[r]) + 1 + + # キャレット (点滅カーソル) を画面カーソルの位置へ。範囲選択の + # 最中に動かすと選択が消えるので、そのときは触らない + if not terminal.textCursor().hasSelection(): + pos = start + for r in range(screen.cursor_row): + pos += len(rows[r]) + 1 + pos += min(screen.cursor_col, len(rows[screen.cursor_row])) + caret = QTextCursor(terminal.document()) + caret.setPosition(pos) + terminal.setTextCursor(caret) + # 端末と同じく、常に下端へ寄せる。画面は文書の末尾 rows 行 + # なので、ここを見せることが「いま端末に映っているもの」を + # 見せることになる。カーソルへ寄せると、全画面アプリでは + # 画面の上半分しか窓に入らない + bar = terminal.verticalScrollBar() + bar.setValue(bar.maximum()) + + def show_notice(self, device_name: str, text: str) -> None: + """アプリ自身の案内 (切断バナー・エラー文) を画面へ出す。 + + 端末では LF は「1 行下へ」であって行頭へは戻らない (戻すのは + CR)。アプリの文言は普通の改行で書かれているので、ここで + CRLF へ直す。直さないと案内が階段状にずれて出る。 + """ + self.append_output(device_name, + text.replace("\r\n", "\n") + .replace("\n", "\r\n")) + def append_output(self, device_name: str, text: str) -> None: """ 指定した機器のターミナルにテキストを追加 - + Args: device_name: 機器名 text: 追加するテキスト """ - if device_name in self._terminals: - terminal = self._terminals[device_name] - from PyQt6.QtGui import QTextCursor - - # 制御コードを処理(カーソル位置も変更される可能性がある) - processed_text = self._process_control_codes(terminal, text) - - # テキストがある場合のみ挿入 - # 描画は _process_control_codes 内で完了している(戻り値はログ用) - if processed_text: - # ログ記録中の場合はファイルに書き込み - if device_name in self._log_files: - try: - self._log_files[device_name].write(processed_text) - self._log_files[device_name].flush() # 即座にディスクに書き込む - except Exception as e: - import sys - print(f"[ERROR] ログ書き込みエラー: {str(e)}", file=sys.stderr) + if device_name not in self._terminals: + return + terminal = self._terminals[device_name] + + events = terminal._parser.feed(text) + terminal._screen.apply(events) + self._render_screen(terminal) + + # 機器からの問い合わせ (カーソル位置・装置識別) に答える。 + # key_pressed はキー入力と同じ「機器へ送る文字」の経路 + for response in terminal._screen.take_responses(): + terminal.key_pressed.emit(response) + + if device_name in self._log_files: + # タブは桁を作る文字なので落とすと表が潰れる + logged = "".join( + e.text if isinstance(e, vt.Print) else e.char + for e in events + if isinstance(e, vt.Print) + or (isinstance(e, vt.Ctrl) and e.char in "\n\t")) + if logged: + try: + self._log_files[device_name].write(logged) + self._log_files[device_name].flush() + except Exception as e: + import sys + print(f"[ERROR] ログ書き込みエラー: {str(e)}", file=sys.stderr) def enable_reconnect(self, device_name: str, reconnect_callback): """ @@ -565,302 +883,6 @@ def enable_reconnect(self, device_name: str, reconnect_callback): pass terminal.reconnect_requested.connect(lambda: reconnect_callback(device_name)) - # 全画面アプリが代替画面へ切り替えるときのモード番号 - # (1049 が現行、1047 / 47 は古い実装)。ただし端末種別を vt100 と - # 名乗っているため、nano や vi はこれを送ってこない。実機で採った - # 限り、目印になるのは下の 2 つだった。 - ALT_SCREEN_MODES = ("1049", "1047", "47") - - ALT_SCREEN_NOTICE = ( - "[NetBelt] nano や vi のような全画面アプリの表示には対応していません。" - "画面が崩れますが、機器との接続は切れていません。" - ) - - # 端末が名乗っている画面の高さ。実際の表示から取れないときに使う - DEFAULT_SCREEN_LINES = 24 - - # 途中で切れたエスケープを次の受信まで持ち越す上限。これを超えたら - # 完成する見込みが無いとみなして、今までどおり読み飛ばす。 - # ウィンドウタイトル(OSC)は長い。パスまで含めると 64 では足りない - # (`ESC]0;user@host: /home/user/configs/2026` で 75 バイト)。 - MAX_PENDING_ESCAPE = 128 - - # 「まだ伸びうる」形。終端の文字がまだ来ていないもの。 - # ESC 単体 / ESC[ とパラメータ / ESC] と本文 / ESC と中間文字 - # OSC の本文のあとに ESC を 1 つ許すのは、終端が ESC + backslash の - # 形のときに、ちょうどその間で切れるとタイトルが画面へ漏れるため。 - _INCOMPLETE_ESCAPE = re.compile( - "\x1b(?:\\[[0-9:;<=>?]*[ -/]*|][^\x07\x1b]*\x1b?|[ -/]*)?$") - - def _visible_lines(self, terminal: QTextEdit) -> int: - """画面に見えているおおよその行数を返す。""" - from PyQt6.QtGui import QFontMetrics - try: - spacing = QFontMetrics(terminal.font()).lineSpacing() - height = terminal.viewport().height() - if spacing > 0 and height > 0: - return max(1, min(200, height // spacing)) - except Exception: - pass - return self.DEFAULT_SCREEN_LINES - - def _is_full_screen_draw(self, private: bool, params_str: str, - command: str) -> bool: - """全画面アプリが画面を組み立て始めた合図かを判定する。 - - 代替画面への切り替え(ESC[?1049h など)が本来の合図だが、 - こちらは端末種別を vt100 と名乗っており、その terminfo には - 代替画面が無い。そのため nano も vi も送ってこない。 - - 実機で採った限り、代わりに届くのは次の 2 つ。 - ESC[<上>;<下>r スクロール範囲の設定。描画より前に届く - ESC[<行>;<桁>H 行と桁を指定したカーソル移動 - 同じ条件で採った普通のシェル操作には、どちらも現れなかった。 - """ - if private: - codes = params_str[1:].split(';') if len(params_str) > 1 else [] - return (command == 'h' - and any(c in self.ALT_SCREEN_MODES for c in codes)) - if not params_str: - return False # 引数なしの ESC[H / ESC[r は画面の初期化 - if command == 'r': - return True # スクロール範囲を切るのは全画面アプリだけ - return command in ('H', 'f') and ';' in params_str - - def _note_full_screen_app(self, terminal: QTextEdit, cursor) -> None: - """崩れる理由を、一度だけ画面に出す。 - - このターミナルは行を追記していく作りで、行・桁を指定して - 描く仕組みを持たない。全画面アプリは画面のどこにでも書くので、 - すべてが同じ場所に重なって出る。黙って崩れた画面を見せるより、 - 崩れる理由を伝えるほうがよい。 - - 抜けたことを知る手立てが無い(代替画面を使っていないので - 戻りの合図も来ない)ため、1 接続につき一度だけにする。 - """ - from PyQt6.QtGui import QTextCursor - - if getattr(terminal, "_fullscreen_warned", False): - return - terminal._fullscreen_warned = True - cursor.movePosition(QTextCursor.MoveOperation.End) - cursor.insertText('\n' + self.ALT_SCREEN_NOTICE + '\n') - - def _render_cursor_for(self, terminal: QTextEdit): - """機器出力の書き込み位置を返す(ユーザーの選択とは独立) - - 端末は行編集をカーソル移動と上書きで行い、カーソルを戻す指示と - 上書きする文字が別々のパケットで届く。そのため書き込み位置は - append_output を跨いで保つ必要がある。一方で、この位置に - terminal.textCursor() を使うと、利用者が範囲選択している最中に - 出力が届いたとき insertText が選択範囲を置き換えてしまう。 - 書き込み位置は選択とは別に持つ。 - """ - from PyQt6.QtGui import QTextCursor - - cursor = getattr(terminal, "_render_cursor", None) - if cursor is None or cursor.document() is not terminal.document(): - cursor = QTextCursor(terminal.document()) - cursor.movePosition(QTextCursor.MoveOperation.End) - terminal._render_cursor = cursor - cursor.clearSelection() - return cursor - - def _process_control_codes(self, terminal: QTextEdit, text: str) -> str: - """機器から届いた制御コードを解釈して画面へ反映する。 - - 端末(Cisco IOS 等)は行編集を「上書き描画」で行う。 - - \b はカーソルを左へ動かすだけで、文字は消さない - - \b + 空白 + \b が来たときだけ、その位置の文字が消える - - 行の途中に文字を入れると、残り全体を送り直してから \b で戻ってくる - そのため通常文字は、行末なら追記、途中なら既存文字を置換する。 - - Args: - terminal: 描画先のターミナル - text: 受信テキスト - - Returns: - str: ログ記録用に、画面へ出したテキスト - """ - import re - from PyQt6.QtGui import QTextCursor - - text = text.replace('\x00', '') # NUL は捨てる(BEL は OSC 終端に使うため後段で処理) - - # 前回の受信が途中で切れていたら、その続きとして繋ぐ - pending = getattr(terminal, "_pending_escape", "") - if pending: - terminal._pending_escape = "" - text = pending + text - - logged = [] # ログへ残す内容(画面に出した文字と改行) - cursor = self._render_cursor_for(terminal) - - def put(s): - """カーソル位置へ書く。行末なら追記、途中なら上書き(端末と同じ挙動)。""" - for ch in s: - if not cursor.atBlockEnd(): - cursor.deleteChar() # 上書き(挿入すると行が伸びてしまう) - cursor.insertText(ch) - - i = 0 - n = len(text) - while i < n: - ch = text[i] - - # --- エスケープシーケンス --- - if ch == '\x1b': - # CSI: ESC [ <パラメータ 0x30-0x3F> <中間 0x20-0x2F> <終端 0x40-0x7E> - match = re.match(r'\x1b\[([0-9:;<=>?]*)([ -/]*)([@-~])', text[i:]) - if match: - params_str, _intermediate, command = match.groups() - # '?' 等で始まるプライベートパラメータ(DECSET/DECRST。bash の - # ブラケットペースト ESC[?2004h など)は解釈せず読み飛ばす - private = bool(params_str) and params_str[0] in '<=>?' - params = [] if private else (params_str.split(';') if params_str else []) - first = params[0] if params else '' - try: - # パラメータ省略時、移動系は 1、消去系は 0 が既定 - num = int(first) if first else 1 - mode = int(first) if first else 0 - except ValueError: - # SGR の ':' 区切り副パラメータなど。移動・消去系では起きない - num, mode = 1, 0 - if self._is_full_screen_draw(private, params_str, - command): - self._note_full_screen_app(terminal, cursor) - - if private: - pass # 私用パラメータは解釈しない - elif command == 'D': # カーソル左 - for _ in range(num): - cursor.movePosition(QTextCursor.MoveOperation.Left) - elif command == 'C': # カーソル右 - for _ in range(num): - cursor.movePosition(QTextCursor.MoveOperation.Right) - elif command == 'K': # 行内の消去 - if mode == 1: # 行頭からカーソルまで - cursor.movePosition(QTextCursor.MoveOperation.StartOfLine, - QTextCursor.MoveMode.KeepAnchor) - elif mode == 2: # 行全体 - cursor.movePosition(QTextCursor.MoveOperation.StartOfLine) - cursor.movePosition(QTextCursor.MoveOperation.EndOfLine, - QTextCursor.MoveMode.KeepAnchor) - else: # 0: カーソルから行末まで - cursor.movePosition(QTextCursor.MoveOperation.EndOfLine, - QTextCursor.MoveMode.KeepAnchor) - cursor.removeSelectedText() - elif command == 'J': # 画面の消去(NX-OS が行編集で使う) - if mode == 2: # 画面全体 - # 端末では「今見えている画面」を消す指示だが、 - # ここは追記していく文書で、画面という区切りが - # 無い。消すとセッションの記録ごと失われ、 - # Linux で clear を打っただけでそれまでの - # show 出力が全部消える。端末と同じように、 - # 上へ送り出して見えなくするだけにする。 - # なお端末種別を vt100 と名乗っているため、 - # Linux の clear は ESC[2J ではなく ESC[H ESC[J - # を送る。ここへは届かない。備えとして残す。 - cursor.movePosition( - QTextCursor.MoveOperation.End) - cursor.insertText( - '\n' * self._visible_lines(terminal)) - elif mode == 3: # スクロールバックの消去 - pass # 記録は消さない - elif mode == 1: # 先頭からカーソルまで - cursor.movePosition(QTextCursor.MoveOperation.Start, - QTextCursor.MoveMode.KeepAnchor) - cursor.removeSelectedText() - else: # 0: カーソルから末尾まで - cursor.movePosition(QTextCursor.MoveOperation.End, - QTextCursor.MoveMode.KeepAnchor) - cursor.removeSelectedText() - i += len(match.group()) - continue - # OSC: ESC ] ... 終端は BEL または ST(ESC \)。ウィンドウタイトル等 - match = re.match(r'\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)', text[i:]) - if match: - i += len(match.group()) - continue - # 受信の切れ目にかかっただけかもしれない。次と繋げれば - # 完成する形なら、捨てずに持ち越す。捨てると残りが文字と - # して画面へ漏れる。 - # 2バイト系より前に見ること。あちらは終端に 0x30-0x7E を - # 許すので、ESC[ や ESC] で切れていると「2バイトの命令」 - # として食べてしまう(実測で 161 箇所中 45 箇所しか減らず)。 - if (n - i <= self.MAX_PENDING_ESCAPE - and self._INCOMPLETE_ESCAPE.match(text, i)): - terminal._pending_escape = text[i:] - i = n - continue - # 2バイト系: ESC ( B(文字集合指定), ESC = / ESC >(キーパッドモード)等 - match = re.match(r'\x1b[ -/]*[0-~]', text[i:]) - if match: - i += len(match.group()) - continue - # 本当に未知のシーケンス。ESC 1文字だけ捨てて必ず前進する - # (ここで前進しないと無限ループになり、アプリが固まる) - i += 1 - continue - - # --- ベル --- - if ch == '\x07': - i += 1 - continue - - # --- バックスペース --- - # 端末では \b は「カーソルを左へ動かす」だけ。文字を消すときは、機器が - # 空白で上書きしてから \b で戻る列を送ってくるので、特別扱いは不要。 - if ch in ('\b', '\x08'): - bs = 0 - j = i - while j < n and text[j] in ('\b', '\x08'): - bs += 1 - j += 1 - for _ in range(bs): - cursor.movePosition(QTextCursor.MoveOperation.Left) - i = j - continue - - # --- 改行 --- - if ch == '\n': - cursor.movePosition(QTextCursor.MoveOperation.End) - cursor.insertText('\n') - logged.append('\n') - i += 1 - continue - - # --- 復帰 --- - if ch == '\r': - j = i - while j < n and text[j] == '\r': - j += 1 - if j < n and text[j] == '\n': - cursor.movePosition(QTextCursor.MoveOperation.End) - cursor.insertText('\n') - logged.append('\n') - i = j + 1 - continue - cursor.movePosition(QTextCursor.MoveOperation.StartOfLine) - i = j - continue - - # --- 通常文字(連続分をまとめて書く)--- - j = i - while j < n and text[j] not in ('\x1b', '\x07', '\b', '\x08', '\r', '\n'): - j += 1 - chunk = text[i:j] - put(chunk) - logged.append(chunk) - i = j - - # 選択中に視覚カーソルを動かすと選択が外れる。コピーしようと - # している最中に機器がログを送ってくるだけで範囲が消えてしまう。 - if not terminal.textCursor().hasSelection(): - terminal.setTextCursor(cursor) - terminal.ensureCursorVisible() - return ''.join(logged) - def _close_tab(self, index: int) -> None: """ diff --git a/tests/fixtures/cisco_ios_vt100.bin b/tests/fixtures/cisco_ios_vt100.bin new file mode 100644 index 0000000..5d373c8 --- /dev/null +++ b/tests/fixtures/cisco_ios_vt100.bin @@ -0,0 +1,147 @@ + + + +lab-rtr>show version +Cisco IOS XE Software, Version 17.18.02 +Cisco IOS Software [IOSXE], Virtual XE Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 17.18.2, RELEASE SOFTWARE (fc3) +Technical Support: http://www.cisco.com/techsupport +Copyright (c) 1986-2025 by Cisco Systems, Inc. +Compiled Fri 19-Dec-25 03:49 by mcpre + + +Cisco IOS-XE software, Copyright (c) 2005-2025 by cisco Systems, Inc. +All rights reserved. Certain components of Cisco IOS-XE software are +licensed under the GNU General Public License ("GPL") Version 2.0. The +software code licensed under GPL Version 2.0 is free software that comes +with ABSOLUTELY NO WARRANTY. You can redistribute and/or modify such +GPL code under the terms of GPL Version 2.0. For more details, see the +documentation or "License Notice" file accompanying the IOS-XE software, +or the applicable URL provided on the flyer accompanying the IOS-XE +software. + + +ROM: IOS-XE ROMMON + +lab-rtr uptime is 2 days, 11 hours, 43 minutes +Uptime for this control processor is 2 days, 11 hours, 46 minutes + --More--   +lab-rtr>how ip interface brief + ^ +% Invalid input detected at '^' marker. + +lab-rtr>show ip route +Codes: L - local, C - connected, S - static, R - RIP, M - mobile, B - BGP + D - EIGRP, EX - EIGRP external, O - OSPF, IA - OSPF inter area + N1 - OSPF NSSA external type 1, N2 - OSPF NSSA external type 2 + E1 - OSPF external type 1, E2 - OSPF external type 2, m - OMP + n - NAT, Ni - NAT inside, No - NAT outside, Nd - NAT DIA + i - IS-IS, su - IS-IS summary, L1 - IS-IS level-1, L2 - IS-IS level-2 + ia - IS-IS inter area, * - candidate default, U - per-user static route + H - NHRP, G - NHRP registered, g - NHRP registration summary + o - ODR, P - periodic downloaded static route, l - LISP + a - application route + + - replicated route, % - next hop override, p - overrides from PfR + & - replicated local route overrides by connected + +Gateway of last resort is 192.0.2.1 to network 0.0.0.0 + +S* 0.0.0.0/0 [1/0] via 192.0.2.1 + 192.0.2.0/8 is variably subnetted, 3 subnets, 2 masks +C 192.0.2.0/24 is directly connected, GigabitEthernet1 +L 192.0.2.187/32 is directly connected, GigabitEthernet1 +L 192.0.2.234/32 is directly connected, GigabitEthernet1 +lab-rtr>show interfaces +GigabitEthernet1 is up, line protocol is up + Hardware is vNIC, address is 0000.5e00.5302 (bia 0000.5e00.5302) + Internet address is 192.0.2.234/24 + MTU 1500 bytes, BW 1000000 Kbit/sec, DLY 10 usec, + reliability 255/255, txload 1/255, rxload 1/255 + Encapsulation ARPA, loopback not set + Keepalive set (10 sec) + Full Duplex, 1000Mbps, link type is auto, media type is Virtual + output flow-control is unsupported, input flow-control is unsupported + ARP type: ARPA, ARP Timeout 04:00:00 + Last input 00:00:00, output 00:00:00, output hang never + Last clearing of "show interface" counters never + Input queue: 0/375/0/0 (size/max/drops/flushes); Total output drops: 0 + Queueing strategy: fifo + Output queue: 0/40 (size/max) + 5 minute input rate 1000 bits/sec, 2 packets/sec + 5 minute output rate 3000 bits/sec, 3 packets/sec + 80122 packets input, 7124598 bytes, 0 no buffer + Received 0 broadcasts (0 IP multicasts) + 0 runts, 0 giants, 0 throttles + 0 input errors, 0 CRC, 0 frame, 0 overrun, 0 ignored + 0 watchdog, 0 multicast, 0 pause input + --More--   74728 packets output, 33646647 bytes, 0 underruns + Output 0 broadcasts (0 IP multicasts) + 0 output errors, 0 collisions, 0 interface resets + 0 unknown protocol drops + 0 babbles, 0 late collision, 0 deferred + 0 lost carrier, 0 no carrier, 0 pause output + 0 output buffer failures, 0 output buffers swapped out +GigabitEthernet2 is administratively down, line protocol is down + Hardware is vNIC, address is 0000.5e00.5303 (bia 0000.5e00.5303) + MTU 1500 bytes, BW 1000000 Kbit/sec, DLY 10 usec, + reliability 255/255, txload 1/255, rxload 1/255 + Encapsulation ARPA, loopback not set + Keepalive set (10 sec) + Full Duplex, 1000Mbps, link type is auto, media type is Virtual + output flow-control is unsupported, input flow-control is unsupported + ARP type: ARPA, ARP Timeout 04:00:00 + Last input never, output 2d11h, output hang never + Last clearing of "show interface" counters never + Input queue: 0/375/0/0 (size/max/drops/flushes); Total output drops: 0 + Queueing strategy: fifo + Output queue: 0/40 (size/max) + 5 minute input rate 0 bits/sec, 0 packets/sec + 5 minute output rate 0 bits/sec, 0 packets/sec + --More--   0 packets input, 0 bytes, 0 no buffer + Received 0 broadcasts (0 IP multicasts) + 0 runts, 0 giants, 0 throttles + 0 input errors, 0 CRC, 0 frame, 0 overrun, 0 ignored + 0 watchdog, 0 multicast, 0 pause input + 1 packets output, 364 bytes, 0 underruns + Output 0 broadcasts (0 IP multicasts) + 0 output errors, 0 collisions, 0 interface resets + 0 unknown protocol drops + 0 babbles, 0 late collision, 0 deferred + 1 lost carrier, 0 no carrier, 0 pause output + 0 output buffer failures, 0 output buffers swapped out +GigabitEthernet3 is administratively down, line protocol is down + Hardware is vNIC, address is 0000.5e00.5301 (bia 0000.5e00.5301) + MTU 1500 bytes, BW 1000000 Kbit/sec, DLY 10 usec, + reliability 255/255, txload 1/255, rxload 1/255 + Encapsulation ARPA, loopback not set + Keepalive set (10 sec) + Full Duplex, 1000Mbps, link type is auto, media type is Virtual + output flow-control is unsupported, input flow-control is unsupported + ARP type: ARPA, ARP Timeout 04:00:00 + Last input never, output 2d11h, output hang never + Last clearing of "show interface" counters never + --More--   +lab-rtr>show verzz  sion +Cisco IOS XE Software, Version 17.18.02 +Cisco IOS Software [IOSXE], Virtual XE Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 17.18.2, RELEASE SOFTWARE (fc3) +Technical Support: http://www.cisco.com/techsupport +Copyright (c) 1986-2025 by Cisco Systems, Inc. +Compiled Fri 19-Dec-25 03:49 by mcpre + + +Cisco IOS-XE software, Copyright (c) 2005-2025 by cisco Systems, Inc. +All rights reserved. Certain components of Cisco IOS-XE software are +licensed under the GNU General Public License ("GPL") Version 2.0. The +software code licensed under GPL Version 2.0 is free software that comes +with ABSOLUTELY NO WARRANTY. You can redistribute and/or modify such +GPL code under the terms of GPL Version 2.0. For more details, see the +documentation or "License Notice" file accompanying the IOS-XE software, +or the applicable URL provided on the flyer accompanying the IOS-XE +software. + + +ROM: IOS-XE ROMMON + +lab-rtr uptime is 2 days, 11 hours, 44 minutes +Uptime for this control processor is 2 days, 11 hours, 46 minutes + --More--   +lab-rtr>xit diff --git a/tests/test_connect_thread_safety.py b/tests/test_connect_thread_safety.py index 953fbca..9b3f9f0 100644 --- a/tests/test_connect_thread_safety.py +++ b/tests/test_connect_thread_safety.py @@ -67,14 +67,22 @@ def test_worker_threads_do_not_touch_widgets(self): + "\n ".join(offenders)) def test_connection_failure_goes_through_signal(self): - """接続失敗の通知が3種ともシグナル経由であること。""" + """接続失敗の通知が3種ともシグナル経由であること。 + + 見ているのはスレッドの安全性であって文言ではないので、 + 改行の書き方 (端末向けの CRLF) には依存させない。 + """ + import re + + through_signal = re.findall( + r'\.output_received\.emit\("[\\rn]*接続失敗', self.src) self.assertEqual( - self.src.count('.output_received.emit("\\n接続失敗\\n")'), 3, + len(through_signal), 3, "接続失敗の通知がシグナル経由になっていない") - self.assertNotIn( - 'self.terminal_widget.append_output(device_name, "\\n接続失敗\\n")', - self.src, - "ウィジェットの直接呼び出しが残っている") + direct = re.findall( + r'terminal_widget\.\w+\([^)]*接続失敗', self.src) + self.assertEqual( + direct, [], "ウィジェットの直接呼び出しが残っている") if __name__ == "__main__": diff --git a/tests/test_device_list_hiding.py b/tests/test_device_list_hiding.py new file mode 100644 index 0000000..8cce7dd --- /dev/null +++ b/tests/test_device_list_hiding.py @@ -0,0 +1,121 @@ +"""接続先リストを隠せること。 + +ツールエリアと同じ 3 つの経路を用意する: + - 仕切りをドラッグして畳む + - 右クリックメニューから隠す + - 表示メニューで出し入れする(隠したあと戻す唯一の道) + +隠すのは簡単でも戻せないと困るので、戻したときに幅が 0 のままに +ならないことも見る。 +""" +import os +import sys +import unittest + +sys.path.insert(0, "src") + + +class DeviceListHidingTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PyQt6.QtWidgets import QApplication + cls.app = QApplication.instance() or QApplication([]) + + def window(self): + from ui.main_window import MainWindow + return MainWindow() + + def test_the_handle_can_collapse_the_list(self): + """仕切りをドラッグして畳めること。""" + w = self.window() + self.assertTrue(w.main_splitter.isCollapsible(0)) + + def test_the_terminal_can_never_be_collapsed(self): + """ターミナルは本体なので畳ませないこと。""" + w = self.window() + self.assertFalse(w.main_splitter.isCollapsible(1)) + + def test_the_context_menu_asks_the_window_to_hide_it(self): + """右クリックの「非表示」がウィンドウへ伝わること。""" + w = self.window() + self.assertFalse(w.device_tree.isHidden()) + w.device_tree.hide_requested.emit() + self.assertTrue(w.device_tree.isHidden()) + + def test_the_view_menu_brings_it_back(self): + w = self.window() + w._toggle_device_list() + self.assertTrue(w.device_tree.isHidden()) + self.assertFalse(w.toggle_device_list_action.isChecked()) + w._toggle_device_list() + self.assertFalse(w.device_tree.isHidden()) + self.assertTrue(w.toggle_device_list_action.isChecked()) + + def test_one_click_brings_back_a_list_collapsed_to_zero(self): + """幅 0 まで畳んだ状態からは、1 回で戻ること。 + + 分割位置は次回起動へ持ち越される。幅 0 のまま終了したあと、 + 表示メニューを押しても何も起きない(実際には一度隠してから + 出し直している)ように見えると、戻せないと受け取られる。 + """ + w = self.window() + sizes = w.main_splitter.sizes() + w.main_splitter.setSizes([0, sum(sizes[:2])] + sizes[2:]) + w._toggle_device_list() # 1 回で戻る + self.assertFalse(w.device_tree.isHidden()) + self.assertGreaterEqual(w.main_splitter.sizes()[0], 100) + + def test_coming_back_from_hiding_has_width(self): + """隠してから戻したときも、幅があること。""" + w = self.window() + sizes = w.main_splitter.sizes() + w.main_splitter.setSizes([0, sum(sizes[:2])] + sizes[2:]) + w.device_tree.setVisible(False) + w._toggle_device_list() + self.assertFalse(w.device_tree.isHidden()) + self.assertGreaterEqual(w.main_splitter.sizes()[0], 100) + + def test_the_tool_area_still_toggles_on_its_own(self): + """ツールエリアの表示切替を巻き込んでいないこと。""" + w = self.window() + w._toggle_device_list() + self.assertFalse(w.tool_tabs.isHidden()) + w._toggle_tool_area() + self.assertTrue(w.tool_tabs.isHidden()) + self.assertTrue(w.device_tree.isHidden()) + + +class DeviceTreeMenuTest(unittest.TestCase): + """どの右クリックメニューにも非表示が出ること。""" + + @classmethod + def setUpClass(cls): + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PyQt6.QtWidgets import QApplication + cls.app = QApplication.instance() or QApplication([]) + + def test_the_hide_entry_is_appended_to_any_menu(self): + from PyQt6.QtWidgets import QMenu + from ui.device_tree import DeviceTree + tree = DeviceTree() + menu = QMenu() + action = tree._add_hide_action(menu) + self.assertEqual(action.text(), "接続先リストを非表示") + self.assertIn(action, menu.actions()) + + def test_the_console_group_menu_is_no_longer_empty(self): + """コンソール接続グループでも、隠す道は残すこと。 + + 以前は何も出さずに帰っていたので、そこを右クリックすると + メニューが出なかった。 + """ + import inspect + from ui.device_tree import DeviceTree + source = inspect.getsource(DeviceTree._show_group_menu) + self.assertNotIn("return", source.split("コンソール接続")[1][:200], + "コンソール接続で早々に帰っている") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_fixture_sanitisation.py b/tests/test_fixture_sanitisation.py new file mode 100644 index 0000000..8be725d --- /dev/null +++ b/tests/test_fixture_sanitisation.py @@ -0,0 +1,94 @@ +"""採取データが公開できる状態であることを見張る。 + +tests/fixtures/*.bin は実機・実ラボから採った受信バイトそのもの。 +このリポジトリは公開されているので、実環境を指す値が 1 つでも +混ざったまま入ってはいけない。 + +判定は「禁止する形を並べる」ではなく「文書用に割り当てられた範囲か」で +行う。並べる方式は、次に別の形が来たときにまた抜けるうえ、実環境の値を +禁止リストとして書き残すことになる。 + +最初は IPv4 しか見ておらず、`ip -br addr` の出力に混じった IPv6 リンク +ローカルを見落としていた。リンクローカルは EUI-64 で作られるため、 +機器の MAC アドレスがそのまま復元できてしまう。 +""" +import io +import os +import re +import unittest + +FIXTURES = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "fixtures") + +# RFC 5737 / RFC 6890 の文書用・ループバック・ワイルドカードのみ許す +ALLOWED_V4 = re.compile( + r"^(?:192\.0\.2\.|198\.51\.100\.|203\.0\.113\.|127\.|0\.0\.0\.0)") +# RFC 3849 の文書用のみ許す +ALLOWED_V6 = re.compile(r"^(?:2001:db8:|::1$)", re.I) +# RFC 2606 の予約名か、採取時に置き換えた架空名だけ許す +ALLOWED_HOST = re.compile( + r"^[\w.-]+@(?:lab|lab-rtr|localhost|[\w.-]+\.(?:example|test|invalid))$", + re.I) + +ANY_V4 = re.compile(r"\b\d{1,3}(?:\.\d{1,3}){3}\b") +ANY_V6 = re.compile(r"\b(?:[0-9a-f]{1,4}:){3,}[0-9a-f]{0,4}\b", re.I) +MAC_COLON = re.compile(r"\b[0-9a-f]{2}(?::[0-9a-f]{2}){5}\b", re.I) +MAC_CISCO = re.compile(r"\b[0-9a-f]{4}(?:\.[0-9a-f]{4}){2}\b", re.I) +# Cisco の文書用 MAC は 0000.5e00.53xx (RFC 7042) +ALLOWED_MAC_CISCO = re.compile(r"^0000\.5e00\.53", re.I) +USER_AT_HOST = re.compile(r"\b[\w.-]+@[\w.-]+\b") + + +def captures(): + for name in sorted(os.listdir(FIXTURES)): + if name.endswith(".bin"): + raw = io.open(os.path.join(FIXTURES, name), "rb").read() + yield name, raw.decode("utf-8", "replace") + + +class FixtureSanitisationTest(unittest.TestCase): + def test_there_are_captures_to_check(self): + """見張る対象がある (採取データの置き場所が変わっていない)。""" + self.assertGreaterEqual(len(list(captures())), 4) + + def test_every_ipv4_is_a_documentation_address(self): + for name, text in captures(): + with self.subTest(capture=name): + for ip in ANY_V4.findall(text): + self.assertRegex( + ip, ALLOWED_V4, + "%s: 文書用に割り当てられていないアドレス" % name) + + def test_every_ipv6_is_a_documentation_address(self): + for name, text in captures(): + with self.subTest(capture=name): + for addr in ANY_V6.findall(text): + self.assertRegex( + addr, ALLOWED_V6, + "%s: 文書用に割り当てられていないアドレス" % name) + + def test_no_mac_address_survives(self): + for name, text in captures(): + with self.subTest(capture=name): + found = MAC_COLON.search(text) + self.assertIsNone( + found, "%s: MAC アドレスが残っている: %s" + % (name, found.group(0) if found else "")) + for mac in MAC_CISCO.findall(text): + self.assertRegex( + mac, ALLOWED_MAC_CISCO, + "%s: 文書用でない MAC が残っている" % name) + + def test_no_real_host_or_user_name_survives(self): + for name, text in captures(): + with self.subTest(capture=name): + for pair in USER_AT_HOST.findall(text): + if "@" not in pair: + continue + self.assertRegex( + pair, ALLOWED_HOST, + "%s: 実環境を指す名前が残っている" % name) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pty_size.py b/tests/test_pty_size.py new file mode 100644 index 0000000..ff536fc --- /dev/null +++ b/tests/test_pty_size.py @@ -0,0 +1,45 @@ +"""端末サイズの機器への伝達 (RFC 4254)。 + +pty-req (6.2) は接続時に、window-change (6.7) は接続中に使う。 +実機は使わず、channel をモックにして呼び出しだけを検証する。 +""" +import sys +import unittest +from unittest import mock + +sys.path.insert(0, "src") + +from core.ssh_connection import SSHConnection # noqa: E402 + + +class PtySizeTest(unittest.TestCase): + def conn(self): + return SSHConnection("192.0.2.1", 22, "user", "pw") + + def test_the_size_set_before_connecting_feeds_the_pty_request(self): + conn = self.conn() + conn.set_terminal_size(120, 40) + self.assertEqual((conn.term_cols, conn.term_rows), (120, 40)) + + def test_a_live_connection_hears_window_change_at_once(self): + conn = self.conn() + conn.is_connected = True + conn.channel = mock.Mock() + conn.set_terminal_size(132, 43) + conn.channel.resize_pty.assert_called_once_with(width=132, height=43) + + def test_a_dead_channel_does_not_take_the_app_down(self): + conn = self.conn() + conn.is_connected = True + conn.channel = mock.Mock() + conn.channel.resize_pty.side_effect = OSError("Socket is closed") + conn.set_terminal_size(132, 43) # 例外が漏れないこと + self.assertEqual((conn.term_cols, conn.term_rows), (132, 43)) + + def test_the_default_matches_the_advertised_vt100(self): + conn = self.conn() + self.assertEqual((conn.term_cols, conn.term_rows), (80, 24)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_status_bar_terminal_size.py b/tests/test_status_bar_terminal_size.py new file mode 100644 index 0000000..d456b2b --- /dev/null +++ b/tests/test_status_bar_terminal_size.py @@ -0,0 +1,64 @@ +"""ステータスバーに端末の大きさを出す。 + +不具合の切り分けで「そのとき窓が何桁だったか」が分からず往復した +ので、いま機器へ伝えている大きさをその場で見えるようにした。 + +一時メッセージ (showMessage) とは場所を取り合わないこと、他の表示を +壊さないことを確かめる。 +""" +import os +import sys +import unittest + +sys.path.insert(0, "src") + + +class TerminalSizeStatusTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PyQt6.QtWidgets import QApplication + cls.app = QApplication.instance() or QApplication([]) + + def window(self): + from ui.main_window import MainWindow + return MainWindow() + + def test_the_label_lives_in_the_permanent_area(self): + """常設欄に置くこと。showMessage と場所を取り合わない。""" + w = self.window() + w.status_bar.showMessage("接続しました") + w._show_terminal_size("") + self.assertEqual(w.status_bar.currentMessage(), "接続しました") + self.assertIsNotNone(w.terminal_size_label.parent()) + + def test_nothing_is_shown_without_a_terminal(self): + w = self.window() + w._show_terminal_size("") + self.assertEqual(w.terminal_size_label.text(), "") + + def test_the_size_appears_for_an_open_terminal(self): + w = self.window() + w.terminal_widget.create_terminal_tab("dev") + w._show_terminal_size("dev") + cols, rows = w.terminal_widget.grid_size_for("dev") + self.assertEqual(w.terminal_size_label.text(), + "%d x %d" % (cols, rows)) + + def test_a_resize_updates_the_shown_size(self): + w = self.window() + w.terminal_widget.create_terminal_tab("dev") + w._on_terminal_resized("dev", 132, 43) + self.assertEqual(w.terminal_size_label.text(), "132 x 43") + + def test_a_resize_of_a_hidden_tab_does_not_overwrite_it(self): + """見えていないタブの大きさで、表示中のものを上書きしないこと。""" + w = self.window() + w.terminal_widget.create_terminal_tab("shown") + w._on_terminal_resized("shown", 100, 30) + w._on_terminal_resized("other", 40, 10) + self.assertEqual(w.terminal_size_label.text(), "100 x 30") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_terminal_attrs.py b/tests/test_terminal_attrs.py new file mode 100644 index 0000000..088bf10 --- /dev/null +++ b/tests/test_terminal_attrs.py @@ -0,0 +1,79 @@ +"""SGR 表示属性の検証。Qt を使わない。""" +import sys +import unittest + +sys.path.insert(0, "src") + +from core.terminal.attrs import Attr, DEFAULT, apply_sgr # noqa: E402 + + +class SgrTest(unittest.TestCase): + def test_no_params_means_full_reset(self): + dressed = Attr(fg=1, bg=2, bold=True, underline=True, reverse=True) + self.assertEqual(apply_sgr(dressed, ()), DEFAULT) + + def test_zero_resets_everything(self): + dressed = Attr(fg=1, bg=2, bold=True, underline=True, reverse=True) + self.assertEqual(apply_sgr(dressed, (0,)), DEFAULT) + + def test_an_omitted_param_counts_as_reset(self): + self.assertEqual(apply_sgr(Attr(1, 2, True, False, False), (None,)), + DEFAULT) + + def test_bold_and_reverse_pile_up(self): + attr = apply_sgr(DEFAULT, (1,)) + attr = apply_sgr(attr, (7,)) + self.assertEqual(attr, DEFAULT._replace(bold=True, reverse=True)) + + def test_each_attribute_has_its_own_off_switch(self): + dressed = Attr(None, None, True, True, True) + self.assertEqual(apply_sgr(dressed, (22,)).bold, False) + self.assertEqual(apply_sgr(dressed, (24,)).underline, False) + self.assertEqual(apply_sgr(dressed, (27,)).reverse, False) + + def test_basic_and_bright_colours(self): + self.assertEqual(apply_sgr(DEFAULT, (31,)).fg, 1) + self.assertEqual(apply_sgr(DEFAULT, (44,)).bg, 4) + self.assertEqual(apply_sgr(DEFAULT, (91,)).fg, 9) + self.assertEqual(apply_sgr(DEFAULT, (104,)).bg, 12) + + def test_default_colour_is_none_not_a_number(self): + attr = apply_sgr(DEFAULT, (31, 44)) + attr = apply_sgr(attr, (39, 49)) + self.assertIsNone(attr.fg) + self.assertIsNone(attr.bg) + + def test_many_params_apply_in_order(self): + attr = apply_sgr(Attr(1, 2, True, False, False), (0, 1, 33, 40)) + self.assertEqual(attr, Attr(3, 0, True, False, False)) + + def test_256_palette_via_semicolons(self): + self.assertEqual(apply_sgr(DEFAULT, (38, 5, 129)).fg, 129) + self.assertEqual(apply_sgr(DEFAULT, (48, 5, 236)).bg, 236) + + def test_truecolor_via_semicolons(self): + self.assertEqual(apply_sgr(DEFAULT, (38, 2, 10, 20, 30)).fg, + (10, 20, 30)) + + def test_extended_colour_consumes_its_own_params_only(self): + attr = apply_sgr(DEFAULT, (38, 5, 129, 1)) + self.assertEqual(attr.fg, 129) + self.assertTrue(attr.bold) + + def test_a_broken_extended_colour_drops_the_rest(self): + # 38 の後が読めない形なら、それ以降まとめて捨てる (適用しない) + attr = apply_sgr(DEFAULT, (38, 9, 1)) + self.assertEqual(attr, DEFAULT) + + def test_out_of_range_palette_numbers_are_clamped(self): + self.assertEqual(apply_sgr(DEFAULT, (38, 5, 999)).fg, 255) + self.assertEqual(apply_sgr(DEFAULT, (38, 2, 300, 300, 300)).fg, + (255, 255, 255)) + + def test_unknown_codes_are_skipped_silently(self): + attr = apply_sgr(DEFAULT, (3, 1)) # italic は対応しない + self.assertEqual(attr, DEFAULT._replace(bold=True)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_terminal_cisco.py b/tests/test_terminal_cisco.py new file mode 100644 index 0000000..b370e5a --- /dev/null +++ b/tests/test_terminal_cisco.py @@ -0,0 +1,155 @@ +"""ネットワーク機器の出力が正しく描かれることを、実機の受信バイトで検証する。 + +`tests/fixtures/cisco_ios_vt100.bin` は Catalyst 8000v (IOS-XE) へ SSH で +つなぎ、NetBelt と同じ `invoke_shell(term='vt100', width=80, height=24)` で +採った受信バイトそのもの。show version / show ip interface brief / +show ip route / show interfaces(ページャを 3 画面ぶん歩く)/ 打ち間違いを +backspace で消して打ち直す、を含む。実環境を指す値だけ文書用のものへ +置き換えてあり、制御文字は 1 バイトも変えていない。 + +**この 7058 バイトに、エスケープシーケンスは 1 つも含まれていない。** +届くのは `\\r` `\\n` `\\b` だけ。ネットワーク機器の画面はバックスペースの +上書きだけで組み立てられており、それが NetBelt の主用途である以上、 +この経路を壊すことは許容できない。v1.2.0 の描画作り直しの成否も +まずここで測る。 +""" +import io +import os +import re +import sys +import unittest + +sys.path.insert(0, "src") + +FIXTURES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures") +CAPTURE = "cisco_ios_vt100.bin" + + +def capture(): + raw = io.open(os.path.join(FIXTURES, CAPTURE), "rb").read() + return raw.decode("utf-8", "replace") + + +class CiscoRenderingTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PyQt6.QtWidgets import QApplication + cls.app = QApplication.instance() or QApplication([]) + + def _screen(self, *payloads): + from ui.terminal_widget import TerminalWidget + w = TerminalWidget() + w.create_terminal_tab("dev") + for payload in payloads: + w.append_output("dev", payload) + return w._terminals["dev"].toPlainText() + + def test_the_pager_prompt_is_erased(self): + """--More-- が画面に残らないこと。 + + 機器は `\\b` と空白で消しにいく。消し方を取り違えると、 + show 出力のあちこちに --More-- が残る。 + """ + text = self._screen(capture()) + + leftover = [l for l in text.split("\n") if "More" in l] + self.assertEqual(leftover, [], + "ページャの表示が残っている: %r" % leftover[:3]) + + def test_a_typo_corrected_with_backspace_renders_clean(self): + """打ち間違いを消して打ち直した行が、正しく見えること。 + + 採取時に `show verzz` と打ち、backspace 2 回で消して + `sion` と続けた。画面には `show version` だけが見えるべき。 + """ + text = self._screen(capture()) + + self.assertIn("show version", text) + self.assertNotIn("verzz", text, "消したはずの文字が残っている") + + def test_the_output_keeps_its_columns(self): + """桁で読む出力が崩れないこと。 + + 機器の出力は桁で意味を持つ。採取時、直前のページャが次の + コマンドの先頭文字を食べて `how ip interface brief` になり、 + 機器が `^` で誤りの位置を指してきた。この `^` がずれると + どこが悪いのか読めない。あわせて show ip route の凡例の + 字下げも見る。 + """ + lines = self._screen(capture()).split("\n") + + marker = [i for i, l in enumerate(lines) if l.strip() == "^"] + self.assertEqual(len(marker), 1, + "^ の行が %d 行ある" % len(marker)) + at = marker[0] + self.assertIn("Invalid input", lines[at + 1], + "^ の次に理由が続いていない: %r" % lines[at + 1]) + self.assertEqual(lines[at].index("^"), 10, + "^ の桁がずれている: %r" % lines[at]) + + indented = [l for l in lines + if l.startswith(" ") and l.strip()] + self.assertGreaterEqual(len(indented), 10, + "凡例の字下げが失われている") + + def test_every_split_point_renders_the_same(self): + """受信がどこで切れても、同じ画面になること。""" + raw = capture() + whole = self._screen(raw) + for cut in range(1, len(raw), 7): # 7 バイトおきに全体を走査 + split = self._screen(raw[:cut], raw[cut:]) + if split != whole: + self.fail( + "%d バイト目で分けると画面が変わる\n" + " 切れ目: ...%r | %r...\n" + " 差分: %+d 文字" + % (cut, raw[max(0, cut - 8):cut], raw[cut:cut + 8], + len(split) - len(whole))) + + +class CiscoCaptureIntegrityTest(unittest.TestCase): + """採取データが、検証の材料として妥当であること。""" + + def test_the_device_sends_no_escape_sequences_at_all(self): + """ネットワーク機器はエスケープを送ってこないこと。 + + ここが崩れると、この検証の前提が変わる。逆に言えば、 + 機器向けに必要なのは `\\r` `\\n` `\\b` の扱いだけということ。 + """ + text = capture() + + found = re.findall(r"\x1b\[[0-9:;<=>?]*[ -/]*[@-~]", text) + self.assertEqual(found, [], + "エスケープが含まれている: %r" % found[:5]) + self.assertNotIn("\x1b", text) + + def test_the_capture_exercises_backspace_overwriting(self): + """バックスペースでの上書きが実際に含まれていること。""" + text = capture() + + self.assertGreater(text.count("\b"), 50, + "バックスペースが少なすぎる(材料として不足)") + + def test_the_capture_names_no_real_environment(self): + """公開できる状態であること。""" + text = capture() + + for pattern, label in ( + (r"\b10\.\d{1,3}\.\d{1,3}\.\d{1,3}\b", "私用 IPv4"), + (r"\b192\.168\.\d{1,3}\.\d{1,3}\b", "私用 IPv4"), + (r"\b5254\.[0-9a-f]{4}\.[0-9a-f]{4}\b", "実 MAC"), + (r"\b[0-9a-f]{2}(?::[0-9a-f]{2}){5}\b", "MAC アドレス")): + found = re.search(pattern, text, re.I) + self.assertIsNone( + found, "%s が残っている: %s" + % (label, found.group(0) if found else "")) + + for ip in re.findall(r"\b\d{1,3}(?:\.\d{1,3}){3}\b", text): + self.assertRegex( + ip, r"^(?:192\.0\.2\.|198\.51\.100\.|203\.0\.113\.|127\.|0\.0\.0\.0)", + "文書用に割り当てられていないアドレス: %s" % ip) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_terminal_cursor.py b/tests/test_terminal_cursor.py index 8930dd1..a0632eb 100644 --- a/tests/test_terminal_cursor.py +++ b/tests/test_terminal_cursor.py @@ -114,11 +114,13 @@ def test_esc_j_clears_to_end(self): self.assertEqual(self._text(w), "abc") def test_esc_k_variants(self): - # ESC[1K は行頭からカーソルまで、ESC[2K は行全体 + # ESC[1K は行頭からカーソルまで (カーソル位置を含む) を空白に + # する。ECMA-48 の EL は消した分を詰めない。旧実装は削除して + # 左へ詰めており ("def")、桁がずれる方が誤りだった w = self._terminal() w.append_output("dev", "abcdef") w.append_output("dev", (ESC + "[D") * 3 + ESC + "[1K") - self.assertEqual(self._text(w), "def") + self.assertEqual(self._text(w), " ef") if __name__ == "__main__": unittest.main() diff --git a/tests/test_terminal_escape.py b/tests/test_terminal_escape.py index bbe344a..58129bd 100644 --- a/tests/test_terminal_escape.py +++ b/tests/test_terminal_escape.py @@ -23,11 +23,19 @@ def setUpClass(cls): cls.app = QApplication.instance() or QApplication([]) def _screen(self, payload): + """画面に見えている文字を返す。 + + 画面は常に行数ぶんの高さで描かれる (clear で下端に履歴が + せり上がってこないようにするため) ので、判定に関係の無い + 末尾の空行は落とす。ここで見たいのは、エスケープが文字と + して出ていないかどうか。 + """ from ui.terminal_widget import TerminalWidget w = TerminalWidget() w.create_terminal_tab("dev") w.append_output("dev", payload) - return w._terminals["dev"].toPlainText() + text = w._terminals["dev"].toPlainText() + return text.rstrip("\n") # --- DECSET/DECRST (ESC[?h / ESC[?l) --- diff --git a/tests/test_terminal_fullscreen.py b/tests/test_terminal_fullscreen.py deleted file mode 100644 index a70d9bd..0000000 --- a/tests/test_terminal_fullscreen.py +++ /dev/null @@ -1,400 +0,0 @@ -"""全画面アプリと画面消去の扱いを、実機から採った生データで検証する。 - -`tests/fixtures/*.bin` は Ubuntu 24.04 へ SSH でつなぎ、NetBelt と同じ -`invoke_shell(term='vt100', width=80, height=24)` で採った受信バイトそのもの。 -実環境を指す文字列だけ RFC 5737 のアドレスと架空のホスト名へ置き換えてある -(エスケープシーケンスは 1 バイトも変えていない)。 - -合成したシーケンスで検証したために一度取り違えた。端末種別を vt100 と -名乗っている以上、その terminfo に無いものは届かない: - - - nano は ESC[?1049h(代替画面)を **送らない**。届いていたのは - ESC[1;24r(スクロール範囲)と ESC[1;79H(行・桁指定)だった。 - - clear は ESC[2J を **送らない**。ESC[H ESC[J が届く。 - -nano の画面が 1 行に潰れるのは、行・桁を指定して描く仕組みが無いため。 -本対応は次の版。ここでは「なぜ崩れるのか」を伝えることだけを守る。 -""" -import io -import os -import sys -import unittest - -sys.path.insert(0, "src") - -ESC = "\x1b" -FIXTURES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures") - - -def capture(name): - """実機から採った受信バイトを、届いたときと同じ文字列で返す。""" - raw = io.open(os.path.join(FIXTURES, name), "rb").read() - return raw.decode("utf-8", "replace") - - -class TerminalFullScreenTest(unittest.TestCase): - @classmethod - def setUpClass(cls): - os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") - from PyQt6.QtWidgets import QApplication - cls.app = QApplication.instance() or QApplication([]) - - HISTORY = ("lab#show ip route\r\n" - " 192.0.2.0/24 is directly connected, GigabitEthernet0/0\r\n") - - def _widget(self): - from ui.terminal_widget import TerminalWidget - w = TerminalWidget() - w.create_terminal_tab("dev") - return w - - def _screen(self, *payloads, history=False): - w = self._widget() - if history: - w.append_output("dev", self.HISTORY) - for payload in payloads: - w.append_output("dev", payload) - return w._terminals["dev"].toPlainText() - - def _notice(self): - from ui.terminal_widget import TerminalWidget - return TerminalWidget.ALT_SCREEN_NOTICE - - # --- 実機の nano --------------------------------------------------- - - def test_the_real_nano_capture_gets_a_notice(self): - """実機の nano で案内が出ること。 - - 合成した ESC[?1049h では出ていたが、実機では 1 度も出なかった。 - vt100 を名乗っているため nano がそれを送らないのが理由。 - """ - text = self._screen(capture("nano_vt100.bin"), history=True) - - self.assertIn(self._notice(), text, - "実機の nano で案内が出ていない:\n" + text) - - def test_the_notice_comes_before_the_broken_paint(self): - """崩れた画面より前に出ること。後ろだと混ざって読めない。""" - text = self._screen(capture("nano_vt100.bin"), history=True) - lines = text.split("\n") - notice_at = next(i for i, l in enumerate(lines) - if self._notice() in l) - garbled_at = next(i for i, l in enumerate(lines) if "Exit" in l) - - self.assertLess(notice_at, garbled_at, - "案内が崩れた画面の後ろに出ている:\n" + text) - - def test_the_notice_stands_on_its_own_line(self): - """1 行として出ること。崩れた行に紛れ込ませない。""" - text = self._screen(capture("nano_vt100.bin"), history=True) - line = next(l for l in text.split("\n") if self._notice() in l) - - self.assertEqual(line.strip(), self._notice(), - "案内の行に他の出力が混ざっている: %r" % line) - - def test_nothing_of_the_app_is_drawn_before_the_notice(self): - """アプリが 1 文字でも描く前に出ること。 - - nano は目印を 2 つ送る。先に届くのはスクロール範囲の設定 - (ESC[1;24r)で、行・桁指定(ESC[1;79H)はタイトル行を - 描いたあと。後者だけを見ていると、案内が崩れた文字列の - 途中に割り込む。届く順を検証するのはここだけ。 - """ - text = self._screen(capture("nano_vt100.bin"), history=True) - before = text.split(self._notice())[0] - - for marker in ("New Buffer", "Write Out", "Read File", "Exit"): - self.assertNotIn( - marker, before, - "案内より前に nano が描き始めている(%r):\n%s" - % (marker, text)) - - def test_the_history_before_it_survives(self): - """nano を開いても、それまでの記録を消さないこと。""" - text = self._screen(capture("nano_vt100.bin"), history=True) - - self.assertIn("show ip route", text) - self.assertIn("directly connected", text) - - def test_the_notice_is_not_repeated(self): - """開いている間、繰り返し出さないこと。 - - 代替画面を使っていないので抜けた合図も来ない。 - 1 接続につき一度だけにしてある。 - """ - text = self._screen(capture("nano_vt100.bin"), - capture("nano_vt100.bin"), history=True) - - self.assertEqual(text.count(self._notice()), 1) - - def test_reconnecting_lets_it_be_shown_again(self): - """再接続したら、また出せる状態に戻すこと。""" - from ui.terminal_widget import TerminalWidget - w = self._widget() - w.append_output("dev", capture("nano_vt100.bin")) - w.create_terminal_tab("dev") # 同じ機器名で繋ぎ直す - w.append_output("dev", capture("nano_vt100.bin")) - - self.assertEqual( - w._terminals["dev"].toPlainText().count( - TerminalWidget.ALT_SCREEN_NOTICE), 2, - "再接続後に案内が出なくなっている") - - # --- 受信が途中で切れても同じ画面になること ------------------------- - - def test_every_split_point_renders_the_same(self): - """どこで切れても、切れなかったときと同じ画面になること。 - - 本番は recv が返した分だけを渡すので、1 つのエスケープが 2 回の - 呼び出しにまたがる。以前は ESC を捨てて先へ進んでいたため、 - 残りが文字として画面へ漏れていた。採取データを全ての位置で - 2 分割して測ると、527 バイト中 161 箇所で画面が変わっていた。 - """ - for name in ("nano_vt100.bin", "shell_vt100.bin", - "clear_vt100.bin"): - raw = capture(name) - whole = self._screen(raw) - for cut in range(1, len(raw)): - split = self._screen(raw[:cut], raw[cut:]) - if split != whole: - self.fail( - "%s を %d バイト目で分けると画面が変わる\n" - " 切れ目: ...%r | %r...\n" - " 差分: %+d 文字" - % (name, cut, - raw[max(0, cut - 8):cut], raw[cut:cut + 8], - len(split) - len(whole))) - - def test_a_window_title_never_leaks_at_a_split(self): - """ウィンドウタイトルが、どこで切れても画面へ漏れないこと。 - - 終端は BEL の場合と ESC + backslash の場合がある。後者が - ちょうど ESC と backslash の間で割れると、持ち越しの対象から - 外れてタイトル本文が画面に出ていた。採取データに OSC が - 含まれていなかったため、実データのテストでは踏めなかった。 - """ - bel = ESC + "]0;user@lab: ~" + "\x07" - st = ESC + "]0;user@lab: ~" + ESC + "\\" - for label, payload in (("BEL 終端", bel), ("ST 終端", st)): - whole = self._screen("A" + payload + "B") - for cut in range(1, len(payload) + 2): - with self.subTest(terminator=label, cut=cut): - text = "A" + payload + "B" - split = self._screen(text[:cut], text[cut:]) - self.assertEqual( - split, whole, - "%s を %d バイト目で分けるとタイトルが漏れる: %r" - % (label, cut, split)) - - def test_the_notice_survives_a_split_marker(self): - """目印がちょうど切れても、案内が出ること。""" - raw = capture("nano_vt100.bin") - marker = raw.index(ESC + "[1;24r") - for cut in range(marker, marker + 7): - with self.subTest(cut=cut): - text = self._screen(raw[:cut], raw[cut:]) - self.assertIn(self._notice(), text, - "%d バイト目で切れると案内が出ない" % cut) - - def test_a_dangling_escape_is_not_held_for_ever(self): - """完成しない断片を、いつまでも抱え込まないこと。 - - 機器が ESC のあと何も送らなくなることはある。上限を超えたら - 今までどおり読み飛ばして、後続の表示を止めない。 - """ - from ui.terminal_widget import TerminalWidget - w = self._widget() - long_fragment = ESC + "[" + "1;" * TerminalWidget.MAX_PENDING_ESCAPE - w.append_output("dev", long_fragment) - w.append_output("dev", "prompt$ ") - - self.assertIn("prompt$ ", w._terminals["dev"].toPlainText(), - "断片を抱えたまま後続を出していない") - - def test_a_carried_fragment_is_dropped_on_reconnect(self): - """再接続したら、前のセッションの断片を持ち越さないこと。""" - w = self._widget() - w.append_output("dev", "text" + ESC + "[1;2") - w.create_terminal_tab("dev") - self.assertEqual( - getattr(w._terminals["dev"], "_pending_escape", ""), "", - "切断前の断片が残っている") - - # --- 誤爆しないこと ------------------------------------------------- - - def test_ordinary_shell_work_says_nothing(self): - """普通のシェル操作で案内を出さないこと。 - - 実機で ls --color / ip / uname / rev を流したときの受信バイト。 - 行・桁指定もスクロール範囲も 1 度も現れない。 - """ - text = self._screen(capture("shell_vt100.bin"), history=True) - - self.assertNotIn(self._notice(), text, "普通の操作で誤爆している") - - def test_clear_says_nothing(self): - """clear でも案内を出さないこと。""" - text = self._screen(capture("clear_vt100.bin"), history=True) - - self.assertNotIn(self._notice(), text) - - def test_private_modes_say_nothing(self): - """ブラケットペーストやカーソル表示で誤爆しないこと。 - - 機器との普通のやりとりで頻繁に来る。 - """ - for seq in ("[?2004h", "[?25l", "[?25h", "[?1h", "[?7h"): - with self.subTest(seq=seq): - text = self._screen(ESC + seq + "X") - self.assertNotIn(self._notice(), text) - self.assertIn("X", text) - - def test_an_unparameterised_home_says_nothing(self): - """引数なしの ESC[H / ESC[r は画面の初期化。全画面の合図ではない。""" - for seq in ("[H", "[r"): - with self.subTest(seq=seq): - self.assertNotIn(self._notice(), self._screen(ESC + seq + "X")) - - def test_the_alternate_screen_still_counts(self): - """代替画面を使う端末設定なら、そちらでも拾えること。""" - for mode in ("1049", "1047", "47"): - with self.subTest(mode=mode): - self.assertIn(self._notice(), - self._screen(ESC + "[?" + mode + "h")) - - # --- 画面消去で記録を失わない --------------------------------------- - - def test_the_real_clear_keeps_the_session(self): - """実機の clear で、それまでの出力を失わないこと。""" - text = self._screen(capture("clear_vt100.bin"), history=True) - - self.assertIn("show ip route", text) - self.assertIn("directly connected", text) - - def test_erase_whole_screen_does_not_delete_the_session(self): - """ESC[2J が来ても記録ごと消さないこと(備え)。 - - vt100 を名乗っている限り clear からは届かないが、そう送ってくる - 機器があったときに 30 分ぶんの出力を失うのは受け入れられない。 - """ - text = self._screen(ESC + "[2J", "prompt$ ", history=True) - - self.assertIn("show ip route", text, "画面消去で記録が消えた") - - def test_erase_scrollback_does_not_delete_it_either(self): - text = self._screen(ESC + "[3J", "after", history=True) - - self.assertIn("show ip route", text) - - def test_erase_whole_screen_pushes_the_old_output_out_of_sight(self): - """消さない代わりに、上へ送り出すこと。""" - text = self._screen(ESC + "[2J", "prompt$ ", history=True) - tail = text.split("directly connected, GigabitEthernet0/0")[1] - - self.assertGreaterEqual(tail.count("\n"), 10, - "画面から送り出せていない: %r" % text[-80:]) - - # --- 既存の消去は壊さない ------------------------------------------- - - def test_erase_to_end_of_screen_still_erases(self): - """ESC[J(カーソルから末尾まで)は今までどおり消すこと。 - - NX-OS が行編集で使う。 - """ - w = self._widget() - w.append_output("dev", "abcdef") - w.append_output("dev", "\b\b\b" + ESC + "[J") - self.assertEqual(w._terminals["dev"].toPlainText(), "abc") - - def test_erase_to_start_of_line_still_erases(self): - w = self._widget() - w.append_output("dev", "abcdef") - w.append_output("dev", "\b\b\b" + ESC + "[1J") - self.assertEqual(w._terminals["dev"].toPlainText(), "def") - - -class FixtureIntegrityTest(unittest.TestCase): - """採取データが、検証の材料として妥当であることを確かめる。""" - - def test_nano_never_asks_for_the_alternate_screen(self): - """実機の nano が代替画面を使っていないこと。 - - ここが崩れると、この検証全体の前提が変わる。 - """ - text = capture("nano_vt100.bin") - for seq in ("[?1049h", "[?1047h", "[?47h"): - self.assertNotIn(ESC + seq, text) - - def test_nano_does_set_a_scroll_region(self): - """検知の目印が実際に含まれていること。""" - import re - self.assertTrue(re.search(r"\x1b\[\d+;\d+r", capture("nano_vt100.bin"))) - - def test_ordinary_work_has_neither_marker(self): - import re - for name in ("shell_vt100.bin", "clear_vt100.bin"): - with self.subTest(capture=name): - text = capture(name) - self.assertIsNone(re.search(r"\x1b\[\d+;\d+r", text)) - self.assertIsNone(re.search(r"\x1b\[\d+;\d+H", text)) - - def test_the_captures_name_no_real_environment(self): - """公開できる状態であること。 - - 最初は IPv4 しか見ておらず、`ip -br addr` の出力に混じった - IPv6 リンクローカルを見落としていた。リンクローカルは EUI-64 で - 作られるため、機器の MAC アドレスがそのまま復元できてしまう。 - - 判定は「禁止する形を並べる」ではなく「文書用に割り当てられた - 範囲か」で行う。並べる方式は、次に別の形が来たときにまた抜ける - し、実環境の値を禁止リストとして書き残すことになる。 - """ - import re - - forbidden = ( - (re.compile(r"\b[0-9a-f]{2}(?::[0-9a-f]{2}){5}\b", - re.I), "MAC アドレス"), - ) - # RFC 2606 の予約名か、採取時に置き換えた架空名だけ許す - allowed_hosts = re.compile( - r"^[\w.-]+@(?:lab|localhost|[\w.-]+\.(?:example|test|invalid))$", - re.I) - # RFC 5737 / RFC 6890 の文書用・ループバックのみ許す - allowed_v4 = re.compile( - r"^(?:192\.0\.2\.|198\.51\.100\.|203\.0\.113\.|127\.)") - # RFC 3849 の文書用のみ許す - allowed_v6 = re.compile(r"^(?:2001:db8:|::1$)", re.I) - - for name in ("nano_vt100.bin", "shell_vt100.bin", - "clear_vt100.bin"): - with self.subTest(capture=name): - text = capture(name) - for pattern, label in forbidden: - found = pattern.search(text) - self.assertIsNone( - found, - "%s が残っている: %s" - % (label, found.group(0) if found else "")) - for prompt in re.findall(r"[\w.-]+@[\w.-]+", text): - self.assertRegex( - prompt, allowed_hosts, - "実環境のホスト名が残っている: %s" % prompt) - for ip in re.findall( - r"\b\d{1,3}(?:\.\d{1,3}){3}\b", - text): - self.assertRegex( - ip, allowed_v4, - "文書用に割り当てられていないアドレス: %s" % ip) - # コロン区切りの塊を拾ってから振り分ける。時刻表記 - # (20:28:23) は :: を含まずコロンも 2 個なので外れる。 - for token in re.findall(r"[0-9a-f]*(?::[0-9a-f]*)+", - text, re.I): - if "::" not in token and token.count(":") < 7: - continue - self.assertRegex( - token.rstrip(":"), allowed_v6, - "文書用に割り当てられていないアドレス: %s" % token) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_terminal_parser.py b/tests/test_terminal_parser.py new file mode 100644 index 0000000..febd02e --- /dev/null +++ b/tests/test_terminal_parser.py @@ -0,0 +1,262 @@ +"""パーサ状態機械の検証。 + +Paul Williams の遷移図 (vt100.net/emu/dec_ansi_parser) の状態を +そのままテストにする。Qt を一切使わない。 + +いちばん大事なのは「受信がどこで切れても同じ命令列になる」こと。 +旧実装はこれを正規表現の継ぎ足し (_pending_escape) でやっていて、 +そこが不具合の温床だった。状態機械は状態を持ち越すだけでこれを +満たすはずで、実機の採取データ全部で確かめる。 +""" +import io +import os +import sys +import unittest + +sys.path.insert(0, "src") + +from core.terminal.parser import ( # noqa: E402 + Parser, Print, Ctrl, Esc, Csi, Osc, Dcs, MAX_STRING) + +FIXTURES = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "fixtures") + + +def parse(text): + return Parser().feed(text) + + +def merged(events): + """隣り合う Print を繋げる。切れ目の位置だけが違う出力を比べるため。""" + result = [] + for event in events: + if (result and isinstance(event, Print) + and isinstance(result[-1], Print)): + result[-1] = Print(result[-1].text + event.text) + else: + result.append(event) + return result + + +def fixture(name): + raw = io.open(os.path.join(FIXTURES, name), "rb").read() + return raw.decode("utf-8", "replace") + + +class GroundStateTest(unittest.TestCase): + def test_plain_text_comes_out_as_one_print(self): + self.assertEqual(parse("show version"), [Print("show version")]) + + def test_c0_controls_split_the_text(self): + self.assertEqual(parse("ab\r\ncd"), + [Print("ab"), Ctrl("\r"), Ctrl("\n"), Print("cd")]) + + def test_backspace_is_a_control(self): + self.assertEqual(parse("a\bb"), + [Print("a"), Ctrl("\b"), Print("b")]) + + def test_multibyte_text_prints(self): + self.assertEqual(parse("日本語"), [Print("日本語")]) + + def test_del_prints_here_and_is_dropped_by_the_screen(self): + # 図では ground の 20-7F は print。捨てる判断は画面側の仕事 + self.assertEqual(parse("\x7f"), [Print("\x7f")]) + + +class EscapeStateTest(unittest.TestCase): + def test_a_final_byte_dispatches(self): + self.assertEqual(parse("\x1b7"), [Esc("", "7")]) + + def test_an_intermediate_is_collected(self): + self.assertEqual(parse("\x1b(B"), [Esc("(", "B")]) + + def test_keypad_mode_is_a_plain_escape(self): + self.assertEqual(parse("\x1b="), [Esc("", "=")]) + + def test_esc_inside_esc_starts_over(self): + self.assertEqual(parse("\x1b\x1b7"), [Esc("", "7")]) + + def test_a_control_executes_without_leaving_the_sequence(self): + self.assertEqual(parse("\x1b(\rB"), + [Ctrl("\r"), Esc("(", "B")]) + + def test_a_character_outside_the_diagram_aborts_and_prints(self): + self.assertEqual(parse("\x1b日X"), [Print("日X")]) + + +class CsiSequenceTest(unittest.TestCase): + def test_no_params_at_all(self): + self.assertEqual(parse("\x1b[m"), [Csi("", (), "", "m")]) + + def test_params_are_numbers(self): + self.assertEqual(parse("\x1b[1;24r"), [Csi("", (1, 24), "", "r")]) + + def test_an_omitted_param_stays_visible_as_none(self): + self.assertEqual(parse("\x1b[;5H"), [Csi("", (None, 5), "", "H")]) + + def test_private_markers_are_kept_apart(self): + self.assertEqual(parse("\x1b[?1049h"), [Csi("?", (1049,), "", "h")]) + + def test_an_intermediate_is_collected(self): + self.assertEqual(parse("\x1b[4 q"), [Csi("", (4,), " ", "q")]) + + def test_an_unknown_final_still_comes_through(self): + # 何を捨てるかを決めるのはパーサでなく画面 + self.assertEqual(parse("\x1b[999X"), [Csi("", (999,), "", "X")]) + + def test_a_control_executes_without_leaving_the_sequence(self): + self.assertEqual(parse("\x1b[1;\r24r"), + [Ctrl("\r"), Csi("", (1, 24), "", "r")]) + + def test_a_colon_poisons_the_whole_sequence(self): + # 図の通り。サブパラメータ (38:5:1 形式) は列ごと無視する + self.assertEqual(parse("\x1b[38:5:1mX"), [Print("X")]) + + def test_runaway_params_poison_the_whole_sequence(self): + self.assertEqual(parse("\x1b[" + "1" * 300 + "mX"), [Print("X")]) + + def test_cancel_aborts_midway(self): + self.assertEqual(parse("\x1b[12\x18mX"), + [Ctrl("\x18"), Print("mX")]) + + +class OscStringTest(unittest.TestCase): + def test_bel_terminates_like_xterm(self): + self.assertEqual(parse("\x1b]0;title\x07"), [Osc("0;title")]) + + def test_st_terminates_like_the_diagram(self): + self.assertEqual(parse("\x1b]0;hi\x1b\\"), + [Osc("0;hi"), Esc("", "\\")]) + + def test_cancel_discards_the_partial_string(self): + self.assertEqual(parse("\x1b]0;abc\x18Z"), + [Ctrl("\x18"), Print("Z")]) + + def test_an_endless_string_is_capped(self): + events = parse("\x1b]" + "a" * (MAX_STRING + 100) + "\x07") + self.assertEqual(len(events), 1) + self.assertEqual(len(events[0].text), MAX_STRING) + + +class DcsStringTest(unittest.TestCase): + def test_the_whole_sequence_arrives_on_st(self): + # DECRQSS の形。頭 (パラメータ・最終文字) と中身が揃って届く + self.assertEqual(parse("\x1bP1$qm\x1b\\"), + [Dcs("", (1,), "$", "q", "m"), Esc("", "\\")]) + + def test_the_head_and_body_survive_a_feed_boundary(self): + p = Parser() + self.assertEqual(p.feed("\x1bP+q54"), []) + self.assertEqual(p.feed("4e\x1b\\"), + [Dcs("", (), "+", "q", "544e"), Esc("", "\\")]) + + def test_cancel_discards_head_and_body(self): + self.assertEqual(parse("\x1bP0;1|17/ab\x18X"), + [Ctrl("\x18"), Print("X")]) + + +class SosPmApcTest(unittest.TestCase): + def test_the_string_is_consumed_silently(self): + self.assertEqual(parse("\x1bXsecret\x1b\\A"), + [Esc("", "\\"), Print("A")]) + + +class StreamContinuityTest(unittest.TestCase): + """受信の切れ目に関する性質。ここが旧実装の弱点だった。""" + + def test_state_survives_between_feeds(self): + p = Parser() + self.assertEqual(p.feed("\x1b("), []) + self.assertEqual(p.feed("B"), [Esc("(", "B")]) + + def test_every_split_point_gives_the_same_commands(self): + stream = "ab\x1b(B\r\n\x1b]0;t\x07日\x1b7xy" + whole = merged(parse(stream)) + for cut in range(1, len(stream)): + p = Parser() + split = merged(p.feed(stream[:cut]) + p.feed(stream[cut:])) + self.assertEqual(split, whole, + "%d 文字目で切ると命令列が変わる" % cut) + + +class RealDeviceCaptureTest(unittest.TestCase): + """Catalyst 8000v の実データ (7058 バイト)。""" + + def test_a_network_device_yields_only_print_and_ctrl(self): + events = parse(fixture("cisco_ios_vt100.bin")) + kinds = {type(e).__name__ for e in events} + self.assertLessEqual(kinds, {"Print", "Ctrl"}, + "機器の出力から想定外の命令が出た: %s" % kinds) + + def test_the_device_controls_are_just_cr_lf_bs(self): + events = parse(fixture("cisco_ios_vt100.bin")) + chars = {e.char for e in events if isinstance(e, Ctrl)} + self.assertLessEqual(chars, {"\r", "\n", "\b"}) + + def test_nothing_is_lost_or_invented(self): + raw = fixture("cisco_ios_vt100.bin") + events = parse(raw) + rebuilt = "".join( + e.text if isinstance(e, Print) else e.char for e in events) + self.assertEqual(rebuilt, raw) + + def test_every_split_point_gives_the_same_commands(self): + raw = fixture("cisco_ios_vt100.bin") + whole = merged(parse(raw)) + for cut in range(1, len(raw), 7): + p = Parser() + split = merged(p.feed(raw[:cut]) + p.feed(raw[cut:])) + self.assertEqual(split, whole, + "%d バイト目で切ると命令列が変わる" % cut) + + +class RealLinuxCaptureTest(unittest.TestCase): + """Ubuntu の実データ (nano / 普通のシェル / clear)。 + + 値はどれも実測。nano は TERM=vt100 では代替画面 (?1049) を使わず、 + DECSTBM (ESC[1;24r) と CUP で全画面を組む。clear は ESC[H ESC[J。 + どのシェル操作にも OSC は 1 度も現れない。 + """ + LINUX = ("nano_vt100.bin", "shell_vt100.bin", "clear_vt100.bin") + + def test_nano_reaches_the_full_screen_markers(self): + events = parse(fixture("nano_vt100.bin")) + csi = [(e.private, e.params, e.final) + for e in events if isinstance(e, Csi)] + self.assertIn(("", (1, 24), "r"), csi) # DECSTBM + self.assertIn(("", (22, 16), "H"), csi) # CUP 行桁指定 + + def test_bash_toggles_bracketed_paste(self): + events = parse(fixture("shell_vt100.bin")) + csi = [(e.private, e.params, e.final) + for e in events if isinstance(e, Csi)] + self.assertIn(("?", (2004,), "h"), csi) + self.assertIn(("?", (2004,), "l"), csi) + + def test_clear_erases_instead_of_scrolling(self): + events = parse(fixture("clear_vt100.bin")) + csi = [(e.private, e.params, e.final) + for e in events if isinstance(e, Csi)] + self.assertIn(("", (), "H"), csi) + self.assertIn(("", (), "J"), csi) + + def test_no_escape_ever_leaks_into_printed_text(self): + for name in self.LINUX: + for event in parse(fixture(name)): + if isinstance(event, Print): + self.assertNotIn("\x1b", event.text, name) + + def test_every_split_point_gives_the_same_commands(self): + for name in self.LINUX: + raw = fixture(name) + whole = merged(parse(raw)) + for cut in range(1, len(raw)): + p = Parser() + split = merged(p.feed(raw[:cut]) + p.feed(raw[cut:])) + self.assertEqual( + split, whole, + "%s: %d バイト目で切ると命令列が変わる" % (name, cut)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_terminal_screen.py b/tests/test_terminal_screen.py new file mode 100644 index 0000000..1ad549d --- /dev/null +++ b/tests/test_terminal_screen.py @@ -0,0 +1,568 @@ +"""画面モデルの検証。Qt を使わない。 + +前半は遷移図ならぬ ECMA-48 の意味の検証 (印字・折り返し・消去・移動)。 +後半は実機採取データで、旧実装が検証済みの見え方をこのモデルでも +再現できることを固定する。数値はすべて実測。 +""" +import io +import os +import sys +import time +import unittest + +sys.path.insert(0, "src") + +from core.terminal.parser import Parser # noqa: E402 +from core.terminal.screen import Screen # noqa: E402 +from core.terminal.attrs import DEFAULT # noqa: E402 + +FIXTURES = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "fixtures") + + +def fixture(name): + raw = io.open(os.path.join(FIXTURES, name), "rb").read() + return raw.decode("utf-8", "replace") + + +def feed(screen, text): + screen.apply(Parser().feed(text)) + return screen + + +def everything(screen): + """履歴 + 画面。セッションとして人の目に入る全行。""" + scrolled = ["".join(cell[0] for cell in line).rstrip() + for line in screen.history] + return scrolled + screen.text() + + +class PrintingTest(unittest.TestCase): + def test_text_lands_where_the_cursor_is(self): + s = feed(Screen(), "show version") + self.assertEqual(s.text()[0], "show version") + self.assertEqual((s.cursor_row, s.cursor_col), (0, 12)) + + def test_backspace_overwrites_like_a_device_pager(self): + s = feed(Screen(), "abc\b\bX") + self.assertEqual(s.text()[0], "aXc") + + def test_cr_returns_lf_descends(self): + s = feed(Screen(), "one\r\ntwo") + self.assertEqual(s.text()[:2], ["one", "two"]) + + def test_bare_lf_keeps_the_column(self): + s = feed(Screen(), "abc\ndef") + self.assertEqual(s.text()[:2], ["abc", " def"]) + + def test_tab_jumps_to_the_next_stop(self): + s = feed(Screen(), "a\tb") + self.assertEqual(s.text()[0], "a b") + + def test_the_81st_character_wraps(self): + s = feed(Screen(), "x" * 81) + self.assertEqual(s.text()[0], "x" * 80) + self.assertEqual(s.text()[1], "x") + + def test_a_cr_at_the_right_edge_does_not_make_a_phantom_line(self): + # 80 桁ちょうどの行は、右端で折り返しを保留する (VT100 の癖)。 + # 続く CR LF で空行が湧いてはいけない + s = feed(Screen(), "x" * 80 + "\r\nnext") + self.assertEqual(s.text()[:3], ["x" * 80, "next", ""]) + + def test_del_is_invisible(self): + s = feed(Screen(), "a\x7fb") + self.assertEqual(s.text()[0], "ab") + + +class ScrollAndHistoryTest(unittest.TestCase): + def test_lines_pushed_off_the_top_become_history(self): + s = feed(Screen(), "\r\n".join("line%d" % i for i in range(30))) + self.assertEqual(len(s.history), 6) # 30 行 - 画面 24 行 + self.assertEqual("".join(c[0] for c in s.history[0]).rstrip(), + "line0") + self.assertEqual(s.text()[0], "line6") + + def test_new_history_is_handed_over_once(self): + s = feed(Screen(), "\r\n".join("l%d" % i for i in range(26))) + self.assertEqual(len(s.take_new_history()), 2) + self.assertEqual(s.take_new_history(), []) + +class CursorMovementTest(unittest.TestCase): + def test_cup_is_one_based(self): + s = feed(Screen(), "\x1b[3;5HX") + self.assertEqual(s.text()[2], " X") + + def test_cup_without_params_goes_home(self): + s = feed(Screen(), "hello\x1b[HX") + self.assertEqual(s.text()[0], "Xello") + + def test_cup_clamps_to_the_screen(self): + s = feed(Screen(), "\x1b[99;999HX") + self.assertEqual((s.cursor_row, s.cursor_col), (23, 79)) + + def test_relative_moves(self): + s = feed(Screen(), "\x1b[5;5H\x1b[2A\x1b[3C\x1b[1B\x1b[6DX") + # (4,4) -> 上2 (2,4) -> 右3 (2,7) -> 下1 (3,7) -> 左6 (3,1) + self.assertEqual(s.text()[3], " X") + + def test_column_address(self): + s = feed(Screen(), "abcdef\x1b[3G_") + self.assertEqual(s.text()[0], "ab_def") + + +class ErasingTest(unittest.TestCase): + def test_erase_to_end_of_line(self): + s = feed(Screen(), "abcdef\x1b[4G\x1b[K") + self.assertEqual(s.text()[0], "abc") + + def test_erase_to_start_of_line(self): + s = feed(Screen(), "abcdef\x1b[3G\x1b[1K") + self.assertEqual(s.text()[0], " def") + + def test_erase_whole_line_leaves_the_cursor(self): + s = feed(Screen(), "abcdef\x1b[2KX") + self.assertEqual(s.text()[0], " X") + + def test_erase_below(self): + s = feed(Screen(), "aaa\r\nbbb\r\nccc\x1b[2;2H\x1b[J") + self.assertEqual(s.text()[:3], ["aaa", "b", ""]) + + def test_erase_a_run_of_characters(self): + s = feed(Screen(), "abcdef\x1b[2G\x1b[3X") + self.assertEqual(s.text()[0], "a ef") + + def test_erasing_the_display_loses_no_session_record(self): + # clear で記録を失わない (v1.1.1 の方針)。消される直前に + # 見えていた行は履歴へ移る + s = feed(Screen(), "\r\n".join("l%d" % i for i in range(30))) + feed(s, "\x1b[2J") + self.assertEqual(s.text(), [""] * 24) + record = everything(s) + for i in range(30): + self.assertIn("l%d" % i, record) + + def test_erased_cells_are_undressed(self): + s = feed(Screen(), "\x1b[7mabc\x1b[2K") + self.assertEqual(s.lines[0][1], (" ", DEFAULT)) + + +class EditingTest(unittest.TestCase): + def test_inserted_lines_push_the_rest_down(self): + s = feed(Screen(), "aaa\r\nbbb\r\nccc\x1b[2;1H\x1b[L") + self.assertEqual(s.text()[:4], ["aaa", "", "bbb", "ccc"]) + + def test_deleted_lines_pull_the_rest_up(self): + s = feed(Screen(), "aaa\r\nbbb\r\nccc\x1b[2;1H\x1b[M") + self.assertEqual(s.text()[:3], ["aaa", "ccc", ""]) + + def test_inserted_chars_push_the_line_right(self): + s = feed(Screen(), "abcdef\x1b[3G\x1b[2@") + self.assertEqual(s.text()[0], "ab cdef") + + def test_deleted_chars_pull_the_line_left(self): + s = feed(Screen(), "abcdef\x1b[3G\x1b[2P") + self.assertEqual(s.text()[0], "abef") + + def test_the_line_stays_exactly_as_wide(self): + s = feed(Screen(), "abc\x1b[1;1H\x1b[3@") + self.assertEqual(len(s.lines[0]), 80) + feed(s, "\x1b[3P") + self.assertEqual(len(s.lines[0]), 80) + + +class ScrollRegionTest(unittest.TestCase): + def test_decstbm_confines_the_scroll(self): + s = Screen() + feed(s, "top\x1b[2;3r") # 2..3 行目だけがスクロールする + feed(s, "\x1b[3;1Haaa\r\nbbb\r\nccc") + self.assertEqual(s.text()[:4], ["top", "bbb", "ccc", ""]) + + def test_a_partial_region_never_feeds_history(self): + s = Screen() + feed(s, "\x1b[2;3r\x1b[3;1H" + "\r\n".join("x%d" % i + for i in range(10))) + self.assertEqual(len(s.history), 0) + + def test_decstbm_homes_the_cursor(self): + s = feed(Screen(), "\x1b[5;5H\x1b[2;10rX") + self.assertEqual(s.text()[0], "X") + + def test_nonsense_margins_are_refused(self): + s = feed(Screen(), "\x1b[7;3r") + self.assertEqual((s.scroll_top, s.scroll_bottom), (0, 23)) + + +class RunawayParameterTest(unittest.TestCase): + """暴走したパラメータで画面処理が止まらなくならないこと。 + + パラメータは 256 桁まで書けるので、頭打ちにしないと 10^250 回の + ループになる。apply は GUI スレッドから呼ばれるので、そうなると + 強制終了以外に戻る手立てが無い。 + """ + HUGE = "9" * 200 + + def test_every_repeating_command_finishes_at_once(self): + for final in "STLM@PX": + with self.subTest(command=final): + s = Screen() + start = time.time() + feed(s, "\x1b[" + self.HUGE + final) + self.assertLess(time.time() - start, 1.0, + "ESC[<巨大数>%s が終わらない" % final) + + def test_a_huge_scroll_does_not_flood_the_history(self): + s = Screen() + feed(s, "\x1b[" + self.HUGE + "S") + self.assertLessEqual(len(s.take_new_history()), s.rows) + + def test_the_result_still_matches_a_full_screen_operation(self): + # 頭打ちにしても、意味は「画面全部」のままであること + huge = feed(Screen(), "a\r\nb\r\nc\x1b[" + self.HUGE + "S") + full = feed(Screen(), "a\r\nb\r\nc\x1b[24S") + self.assertEqual(huge.text(), full.text()) + + +class ScrollbackEraseTest(unittest.TestCase): + def test_ed3_leaves_the_visible_screen_alone(self): + """ESC[3J は履歴を消す命令で、見えている画面には触らない。 + + NetBelt は記録を消さない方針なので何もしない。画面まで消すと + clear -x を打っただけで表示が飛ぶ。 + """ + s = feed(Screen(), "visible text") + feed(s, "\x1b[3J") + self.assertEqual(s.text()[0], "visible text") + self.assertEqual(len(s.history), 0) + + +class AttributeTest(unittest.TestCase): + def test_sgr_travels_with_the_characters(self): + s = feed(Screen(), "a\x1b[7mb\x1b[mc") + row = s.lines[0] + self.assertEqual(row[0][1], DEFAULT) + self.assertTrue(row[1][1].reverse) + self.assertEqual(row[2][1], DEFAULT) + + +class UnknownSequenceTest(unittest.TestCase): + def test_an_unknown_final_changes_nothing(self): + s = feed(Screen(), "abc\x1b[999Xdef") + self.assertEqual(s.text()[0], "abcdef") + + +class EscDispatchTest(unittest.TestCase): + def test_save_and_restore_cursor_with_attributes(self): + s = feed(Screen(), "\x1b[3;3H\x1b[7m\x1b7\x1b[H\x1b[m\x1b8X") + self.assertEqual(s.text()[2], " X") + self.assertTrue(s.lines[2][2][1].reverse) + + def test_reverse_index_at_the_top_scrolls_down(self): + s = feed(Screen(), "top\x1b[H\x1bMnew") + self.assertEqual(s.text()[:2], ["new", "top"]) + + def test_full_reset_clears_the_screen_but_not_the_history(self): + s = feed(Screen(), "\r\n".join("l%d" % i for i in range(30))) + feed(s, "\x1bc") + self.assertEqual(s.text(), [""] * 24) + self.assertEqual(len(s.history), 6) + + def test_line_drawing_characters(self): + # ESC)0 で G1 に罫線集合を指示し、SO で使い、SI で戻る + s = feed(Screen(), "\x1b)0\x0elqk\x0fx") + self.assertEqual(s.text()[0], "┌─┐x") + + +class ModeTest(unittest.TestCase): + def test_autowrap_off_pins_the_cursor_to_the_edge(self): + s = feed(Screen(), "\x1b[?7l\x1b[76Gabcdef") + self.assertTrue(s.text()[0].endswith("abcdf")) + self.assertEqual(len(s.text()[0]), 80) + + def test_input_side_flags_are_exposed(self): + s = Screen() + self.assertFalse(s.application_cursor_keys) + self.assertFalse(s.bracketed_paste) + feed(s, "\x1b[?1h\x1b[?2004h") + self.assertTrue(s.application_cursor_keys) + self.assertTrue(s.bracketed_paste) + feed(s, "\x1b[?1l\x1b[?2004l") + self.assertFalse(s.application_cursor_keys) + self.assertFalse(s.bracketed_paste) + + def test_cursor_visibility_is_tracked(self): + s = feed(Screen(), "\x1b[?25l") + self.assertFalse(s.cursor_visible) + + +class AlternateScreenTest(unittest.TestCase): + def test_leaving_the_alt_screen_restores_the_shell(self): + s = feed(Screen(), "user@lab:~$ vi") + feed(s, "\x1b[?1049h~~vi screen~~\x1b[?1049l") + self.assertEqual(s.text()[0], "user@lab:~$ vi") + self.assertEqual((s.cursor_row, s.cursor_col), (0, 14)) + + def test_the_alt_screen_starts_blank(self): + s = feed(Screen(), "shell stuff\x1b[?1049h") + self.assertEqual(s.text(), [""] * 24) + + def test_alt_screen_scrolling_never_reaches_history(self): + s = feed(Screen(), "\x1b[?1049h") + feed(s, "\r\n".join("frame%d" % i for i in range(40))) + self.assertEqual(len(s.history), 0) + + +class ResponseTest(unittest.TestCase): + def test_cursor_position_report(self): + s = feed(Screen(), "abc\x1b[6n") + self.assertEqual(s.take_responses(), ["\x1b[1;4R"]) + self.assertEqual(s.take_responses(), []) + + def test_device_attributes(self): + s = feed(Screen(), "\x1b[c") + self.assertEqual(s.take_responses(), ["\x1b[?1;2c"]) + + +class TitleTest(unittest.TestCase): + def test_osc_sets_the_title(self): + s = feed(Screen(), "\x1b]0;user@lab: ~\x07") + self.assertEqual(s.title, "user@lab: ~") + + +class ResizeTest(unittest.TestCase): + def test_wider_lines_keep_their_content(self): + s = feed(Screen(), "show version") + s.set_size(24, 132) + self.assertEqual(s.text()[0], "show version") + self.assertEqual(len(s.lines[0]), 132) + + def test_narrowing_leaves_a_written_line_alone(self): + """桁が狭くなっても、書かれた行には触らないこと。 + + 切り捨てると窓を縮めただけで末尾が永久に失われ、割り直すと + 窓を往復するたびに切れ目が変わって中身が削れていった + (どちらも実機試験で出た)。実端末も書かれた行は組み直さない。 + 狭いときの見た目は表示側が折り返して面倒を見る。 + """ + s = feed(Screen(), "0123456789") + s.set_size(24, 8) + self.assertEqual(s.text()[0], "0123456789") + + def test_a_round_trip_changes_nothing(self): + """縮めて戻す、を繰り返しても中身が一文字も変わらないこと。 + + 実機試験で「最小→戻すを繰り返すたびに 1 行ずつ消えていく」と + 報告された。組み直しをやめたので、往復は何もしないのと同じ。 + """ + key = "ssh-ed25519 " + "A" * 68 + " user@example.com" + s = Screen(24, 120) + feed(s, "$ cat ~/.ssh/authorized_keys\r\n" + key + "\r\n$ ") + before = s.text() + for _ in range(5): + s.set_size(5, 20) + s.set_size(24, 120) + self.assertEqual(s.text(), before) + + def test_repeated_narrowing_still_keeps_everything(self): + key = "ssh-ed25519 " + "A" * 68 + " user@example.com" + s = Screen(24, 120) + feed(s, key) + for cols in (100, 80, 60, 40, 30): + s.set_size(24, cols) + self.assertEqual("".join(s.text()).replace(" ", ""), + key.replace(" ", "")) + + def test_shrinking_rows_prefers_dropping_blank_bottom_lines(self): + s = feed(Screen(), "keep me") + s.set_size(10, 80) + self.assertEqual(s.text()[0], "keep me") + self.assertEqual(len(s.history), 0) + + def test_shrinking_rows_saves_written_lines_to_history(self): + s = feed(Screen(), "\r\n".join("l%d" % i for i in range(24))) + s.set_size(10, 80) + self.assertEqual(len(s.history), 14) + self.assertEqual(s.text()[0], "l14") + self.assertEqual((s.cursor_row, s.cursor_col), (9, 3)) + + def test_a_long_line_stays_one_line_through_a_round_trip(self): + """長い行は、縮めても刻まれず、戻しても元のままであること。 + + 実機試験で ls /etc/ の出力が刻まれたまま戻らなくなった。 + 刻まないので、戻す必要も無い。 + """ + line = "x" * 100 + s = Screen(24, 120) + feed(s, line) + s.set_size(24, 40) + self.assertEqual(s.text()[0], line) + self.assertEqual(s.text()[1], "") + s.set_size(24, 120) + self.assertEqual(s.text()[0], line) + + def test_widening_does_not_pad_a_wrapped_line(self): + """折り返し行を、広げたときに空白で埋めないこと。 + + 行の長さがそのまま「どこで折り返したか」なので、埋めると + 繋いだときに埋め草ぶんの隙間が開く。実機試験で、窓を広げると + 鍵の途中に空白が入ると報告された。 + """ + line = "x" * 100 + s = Screen(24, 73) + feed(s, line) + self.assertTrue(s.wrapped[0]) + s.set_size(24, 120) + self.assertEqual(len(s.lines[0]), 73, "折り返し行が埋められた") + joined = ("".join(c[0] for c in s.lines[0]) + + "".join(c[0] for c in s.lines[1]).rstrip()) + self.assertEqual(joined, line) + + def test_a_short_line_is_still_padded(self): + """折り返していない行は、これまでどおり広げること。""" + s = feed(Screen(24, 40), "short") + s.set_size(24, 100) + self.assertEqual(len(s.lines[0]), 100) + + def test_no_command_crashes_when_the_line_is_not_as_wide_as_the_screen(self): + """行の長さと桁数が食い違っても、どの命令でも落ちないこと。 + + 窓を縮めても行を切らず、折り返しで続く行は広げても埋めないので、 + 行は桁数より長いことも短いこともある。カーソルは桁数まで動ける + ので、行の実際の長さを見ずに触ると IndexError でアプリごと落ちる。 + 実際 ESC[1K と ESC[P で落ちていた。 + """ + finals = "@ABCDEFGHIJKLMPSTXZdfghlmnrst`" + params = ("", "0", "1", "2", "5", "99", "1;99", "99;99") + for label, setup, size in ( + ("短い行", "y" * 60, (24, 120)), # 折り返し行を広げた + ("長い行", "z" * 120, (24, 20))): # 長い行を縮めた + for col in (1, 40, 100, 120): + for final in finals: + for param in params: + with self.subTest(kind=label, col=col, + seq=param + final): + s = Screen(24, 40) + p = Parser() + s.apply(p.feed(setup)) + s.set_size(*size) + s.apply(p.feed("\x1b[1;%dH" % col)) + s.apply(p.feed("\x1b[" + param + final)) + s.apply(p.feed("X")) # 続けて書けること + + def test_writing_past_the_end_of_a_wrapped_line_still_works(self): + """埋めていない行の先へ書いても落ちないこと。""" + s = Screen(24, 40) + feed(s, "y" * 60) # row0 は 40 セルのまま折り返す + s.set_size(24, 100) + feed(s, "\x1b[1;90HZ") # 90 桁目へ書く + self.assertEqual(s.text()[0][89], "Z") + + def test_a_real_line_break_is_never_joined(self): + """機器が送った改行は、広げても繋がないこと。""" + s = Screen(24, 40) + feed(s, "first\r\nsecond") + s.set_size(24, 120) + self.assertEqual(s.text()[:2], ["first", "second"]) + + def test_columns_survive_a_round_trip(self): + """桁で並んだ出力 (ls の多段組) が往復で崩れないこと。""" + rows = ["a.conf".ljust(20) + "b.conf".ljust(20) + "c.conf", + "d.conf".ljust(20) + "e.conf".ljust(20) + "f.conf"] + s = Screen(24, 80) + feed(s, "\r\n".join(rows)) + before = s.text() + s.set_size(24, 30) + s.set_size(24, 80) + self.assertEqual(s.text(), before) + + def test_the_screen_behind_an_alternate_screen_is_not_lost(self): + """vi を開いている間に窓を縮めても、裏のシェル画面を捨てないこと。""" + s = feed(Screen(), "\r\n".join("shell-%02d" % i for i in range(24))) + feed(s, "\x1b[?1049h") + s.set_size(10, 80) + feed(s, "\x1b[?1049l") + seen = everything(s) + for i in range(24): + self.assertIn("shell-%02d" % i, seen) + + def test_the_scroll_region_snaps_back_to_full(self): + s = feed(Screen(), "\x1b[5;10r") + s.set_size(30, 80) + self.assertEqual((s.scroll_top, s.scroll_bottom), (0, 29)) + + +class NanoOnTheScreenModelTest(unittest.TestCase): + """nano の実データ (527 バイト)。旧実装では 1 行に潰れていた画面が + 24×80 に正しく組み上がることを固定する。値は実測。""" + + @classmethod + def setUpClass(cls): + cls.screen = feed(Screen(), fixture("nano_vt100.bin")) + + def test_the_title_bar_sits_on_row_0(self): + self.assertIn("GNU nano", self.screen.text()[0]) + self.assertIn("New Buffer", self.screen.text()[0]) + + def test_the_shortcut_bars_sit_at_the_bottom(self): + self.assertTrue(self.screen.text()[22].startswith("^G Help")) + self.assertTrue(self.screen.text()[23].startswith("^X Exit")) + + def test_the_cursor_waits_in_the_editing_area(self): + self.assertEqual((self.screen.cursor_row, self.screen.cursor_col), + (1, 0)) + + def test_the_title_bar_is_reverse_video(self): + # "GNU nano 7.2" の非空白 10 文字 + "New Buffer" の 9 文字 + reversed_cells = [cell for cell in self.screen.lines[0] + if cell[1].reverse and cell[0] != " "] + self.assertEqual(len(reversed_cells), 19) + + +class ClearOnTheScreenModelTest(unittest.TestCase): + """clear の実データ (255 バイト)。ESC[H ESC[J と NUL パディング。""" + + def test_the_screen_empties_but_the_record_survives(self): + s = feed(Screen(), fixture("clear_vt100.bin")) + self.assertEqual([l for l in s.text() if l], ["user@lab:~$"]) + self.assertIn("clear", everything(s)) + + +class CiscoOnTheScreenModelTest(unittest.TestCase): + """実機採取 7058 バイト。旧実装で検証済みの見え方を固定する。""" + + @classmethod + def setUpClass(cls): + cls.screen = feed(Screen(), fixture("cisco_ios_vt100.bin")) + cls.full = everything(cls.screen) + + def test_the_pager_prompt_is_erased(self): + leftover = [l for l in self.full if "More" in l] + self.assertEqual(leftover, []) + + def test_a_typo_corrected_with_backspace_renders_clean(self): + self.assertTrue(any("show version" in l for l in self.full)) + self.assertFalse(any("verzz" in l for l in self.full)) + + def test_the_output_keeps_its_columns(self): + marker = [i for i, l in enumerate(self.full) if l.strip() == "^"] + self.assertEqual(len(marker), 1) + at = marker[0] + self.assertEqual(self.full[at].index("^"), 10) + self.assertIn("Invalid input", self.full[at + 1]) + indented = [l for l in self.full + if l.startswith(" ") and l.strip()] + self.assertEqual(len(indented), 12) + + def test_every_split_point_shows_the_same_session(self): + raw = fixture("cisco_ios_vt100.bin") + for cut in range(1, len(raw), 7): + p = Parser() + s = Screen() + s.apply(p.feed(raw[:cut])) + s.apply(p.feed(raw[cut:])) + if everything(s) != self.full: + self.fail("%d バイト目で切ると画面が変わる" % cut) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_terminal_tab_reorder.py b/tests/test_terminal_tab_reorder.py new file mode 100644 index 0000000..6fe5792 --- /dev/null +++ b/tests/test_terminal_tab_reorder.py @@ -0,0 +1,74 @@ +"""ターミナルのタブを並べ替えられること。 + +機器との対応付けはタブ名とウィジェットで持っており、タブの位置には +依存しない。並べ替えたあとでも、閉じる・切り替える・出力を流すが +正しい機器に当たることを確かめる。 +""" +import os +import sys +import unittest + +sys.path.insert(0, "src") + + +class TabReorderTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PyQt6.QtWidgets import QApplication + cls.app = QApplication.instance() or QApplication([]) + + def widget_with_tabs(self, *names): + from ui.terminal_widget import TerminalWidget + w = TerminalWidget() + for name in names: + w.create_terminal_tab(name) + return w + + def tab_order(self, w): + return [w.tab_widget.tabText(i) for i in range(w.tab_widget.count())] + + def test_the_tab_bar_allows_dragging(self): + w = self.widget_with_tabs("a") + self.assertTrue(w.tab_widget.isMovable()) + + def test_reordering_moves_the_tab(self): + w = self.widget_with_tabs("rtr1", "rtr2", "rtr3") + w.tab_widget.tabBar().moveTab(2, 0) + self.assertEqual(self.tab_order(w), ["rtr3", "rtr1", "rtr2"]) + + def test_output_still_reaches_the_right_terminal(self): + w = self.widget_with_tabs("rtr1", "rtr2", "rtr3") + w.tab_widget.tabBar().moveTab(2, 0) + w.append_output("rtr2", "hostname rtr2") + self.assertIn("hostname rtr2", w._terminals["rtr2"].toPlainText()) + self.assertNotIn("hostname rtr2", w._terminals["rtr3"].toPlainText()) + + def test_closing_after_a_reorder_closes_the_one_clicked(self): + w = self.widget_with_tabs("rtr1", "rtr2", "rtr3") + w.tab_widget.tabBar().moveTab(2, 0) # rtr3, rtr1, rtr2 + closed = [] + w.tab_closed.connect(closed.append) + w._close_tab(0) # 先頭 = rtr3 + self.assertEqual(closed, ["rtr3"]) + self.assertEqual(self.tab_order(w), ["rtr1", "rtr2"]) + self.assertNotIn("rtr3", w._terminals) + + def test_the_current_tab_name_follows_the_new_order(self): + w = self.widget_with_tabs("rtr1", "rtr2", "rtr3") + w.tab_widget.tabBar().moveTab(2, 0) + w.tab_widget.setCurrentIndex(0) + self.assertEqual(w.get_current_tab_name(), "rtr3") + + def test_reusing_a_tab_finds_it_wherever_it_sits(self): + """再接続はタブ名で探すので、位置が変わっても同じタブを使う。""" + w = self.widget_with_tabs("rtr1", "rtr2", "rtr3") + w.tab_widget.tabBar().moveTab(0, 2) # rtr2, rtr3, rtr1 + before = w._terminals["rtr1"] + again = w.create_terminal_tab("rtr1") + self.assertIs(again, before) + self.assertEqual(w.tab_widget.count(), 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_terminal_widget_render.py b/tests/test_terminal_widget_render.py new file mode 100644 index 0000000..d75a49e --- /dev/null +++ b/tests/test_terminal_widget_render.py @@ -0,0 +1,465 @@ +"""新しい描画経路 (パーサ + 画面モデル + QTextEdit) のウィジェット検証。 + +v1.1.1 まではカーソル位置指定を解釈できず、nano や vi は 1 行に潰れ、 +「対応していません」の案内を出すのが精一杯だった。v1.2.0 は画面を +持ったので、案内ではなく実際に描く。ここでは旧実装の検証で守って +いた性質のうち、残るべきものを新実装の言葉で固定する。 + +文書の構造は [確定した記録] + [今の画面]。記録は二度と書き換えない。 +""" +import io +import os +import sys +import unittest + +sys.path.insert(0, "src") + +FIXTURES = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "fixtures") + + +def fixture(name): + raw = io.open(os.path.join(FIXTURES, name), "rb").read() + return raw.decode("utf-8", "replace") + + +class WidgetRenderTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PyQt6.QtWidgets import QApplication + cls.app = QApplication.instance() or QApplication([]) + + def widget(self, rows=None, cols=None): + from ui.terminal_widget import TerminalWidget + w = TerminalWidget() + if rows is not None: + w._grid_size = lambda t, r=rows, c=cols: (r, c) + w.create_terminal_tab("dev") + if rows is not None: + w._apply_grid_size("dev") + return w + + def visible_screen(self, w): + """いま端末に映っている行 (文書の末尾 rows 行)。""" + terminal = w._terminals["dev"] + return terminal.toPlainText().split("\n")[-terminal._screen.rows:] + + def screen_text(self, w): + return w._terminals["dev"].toPlainText() + + +class NanoRendersForRealTest(WidgetRenderTest): + """実機の nano (527 バイト)。旧実装では 1 行に潰れていた。""" + + def setUp(self): + # 採取したときと同じ 24x80 で描く。nano は端末の大きさに合わせて + # 組むので、違う大きさで流すと下端の位置が合わない + self.w = self.widget(24, 80) + self.w.append_output("dev", "user@lab:~$ ") + self.w.append_output("dev", fixture("nano_vt100.bin")) + self.lines = self.screen_text(self.w).split("\n") + + def test_the_editor_layout_is_actually_drawn(self): + self.assertTrue(any("GNU nano 7.2" in l for l in self.lines)) + self.assertTrue(self.lines[-2].startswith("^G Help")) + self.assertTrue(self.lines[-1].startswith("^X Exit")) + + def test_no_apology_is_shown_any_more(self): + self.assertNotIn("対応していません", self.screen_text(self.w)) + + def test_the_record_before_nano_survives(self): + self.assertTrue(any("user@lab:~$" in l for l in self.lines)) + + +class ClearKeepsTheSessionTest(WidgetRenderTest): + """実機の clear (255 バイト)。記録を失わないこと。""" + + def test_what_was_on_screen_moves_into_the_record(self): + w = self.widget() + w.append_output("dev", "user@lab:~$ ls\r\nfile1 file2\r\n") + w.append_output("dev", fixture("clear_vt100.bin")) + text = self.screen_text(w) + self.assertIn("file1 file2", text) + self.assertTrue(text.rstrip().endswith("user@lab:~$")) + + +class ScreenOccupiesTheViewportTest(WidgetRenderTest): + """画面は必ず行数ぶんの高さを占めること。 + + 実機試験で「clear が効かない」「nano の表示がおかしい」と報告された。 + どちらも原因は同じで、末尾の空行を詰めていたために履歴が下端へ + せり上がり、画面が窓の一部しか占めていなかった。 + """ + + def test_clear_leaves_an_empty_screen(self): + w = self.widget(24, 80) + w.append_output("dev", "\r\n".join("out-%02d" % i for i in range(20)) + + "\r\n$ clear\r\n") + w.append_output("dev", fixture("clear_vt100.bin")) + screen = self.visible_screen(w) + self.assertEqual(len(screen), 24) + self.assertFalse([l for l in screen if "out-" in l], + "clear のあとも直前の出力が画面に残っている") + self.assertIn("user@lab:~$", "".join(screen)) + self.assertIn("out-19", self.screen_text(w)) # 記録には残る + + def test_a_full_screen_app_fills_the_screen(self): + w = self.widget(24, 80) + w.append_output("dev", "\r\n".join("shell-%02d" % i for i in range(20)) + + "\r\n$ nano\r\n") + w.append_output("dev", fixture("nano_vt100.bin")) + screen = self.visible_screen(w) + self.assertEqual(len(screen), 24) + self.assertIn("GNU nano", screen[0]) + self.assertTrue(screen[-1].startswith("^X Exit")) + self.assertFalse([l for l in screen if l.startswith("shell-")], + "画面にシェルの履歴が混ざっている") + + def test_the_view_stays_at_the_bottom(self): + w = self.widget(24, 80) + w.append_output("dev", "\r\n".join("l%02d" % i for i in range(60))) + bar = w._terminals["dev"].verticalScrollBar() + self.assertEqual(bar.value(), bar.maximum()) + + +class ResizeKeepsContentTest(WidgetRenderTest): + """窓を縮めても一文字も失わないこと (実機試験で報告)。""" + + KEY = "ssh-ed25519 " + "A" * 68 + " user@example.com" + + def test_output_printed_wide_survives_a_narrow_spell(self): + """広い窓で出た行は、縮めて戻しても元のまま見えること。 + + 実機試験で「ls /etc/ のあと窓を縮めて戻すと崩れ、以後の + コマンドにも残る」と報告された。組み直しをやめたので、 + 書かれた行は窓の大きさに関係なくそのまま。 + """ + long_line = "x" * 100 + w = self.widget(24, 110) + w.append_output("dev", "\r\n".join([long_line] * 5) + "\r\n$ ") + # カーソルより下の空行は何も持たないので比較から外す + before = self.screen_text(w).rstrip("\n") + for rows, cols in ((5, 20), (24, 110)): + w._grid_size = lambda t, r=rows, c=cols: (r, c) + w._apply_grid_size("dev") + self.assertEqual(self.screen_text(w).rstrip("\n"), before) + + def test_a_wrapped_line_rejoins_without_a_gap(self): + """折り返した行が履歴へ入るとき、隙間なく繋がること。 + + 実機試験で、窓を広げてから流すと鍵の途中に空白の塊が + 入ると報告された。広げたときに折り返し行を埋めていたのが原因。 + """ + line = "x" * 100 + w = self.widget(24, 73) + w.append_output("dev", line + "\r\n") + w._grid_size = lambda t: (24, 120) + w._apply_grid_size("dev") + w.append_output("dev", "\r\n".join("f-%02d" % i for i in range(30))) + + found = [l for l in self.screen_text(w).split("\n") + if l.startswith("x")] + self.assertEqual(found, [line]) + + def test_a_narrow_spell_loses_no_characters(self): + """狭い窓で出た行は、折り返しはそのままでも欠けないこと。 + + 実端末と同じで、既に書かれた行は組み直さない。ただし + 一文字も失ってはいけない。 + """ + long_line = "x" * 100 + w = self.widget(24, 40) + w.append_output("dev", "\r\n".join([long_line] * 30) + "\r\n$ ") + w._grid_size = lambda t: (24, 100) + w._apply_grid_size("dev") + + body = "".join(l for l in self.screen_text(w).split("\n") + if l.strip() and l.rstrip() != "$") + self.assertEqual(body.count("x"), 100 * 30) + + def test_a_long_line_survives_repeated_shrinking(self): + w = self.widget(24, 120) + w.append_output("dev", "$ cat ~/.ssh/authorized_keys\r\n" + + self.KEY + "\r\n$ ") + for cols in (100, 80, 60, 40, 30): + w._grid_size = lambda t, c=cols: (24, c) + w._apply_grid_size("dev") + joined = "".join(self.screen_text(w).split("\n")) + self.assertIn(self.KEY.replace(" ", ""), + joined.replace(" ", ""), + "%d 桁へ縮めたときに欠けた" % cols) + + +class NoticeTest(WidgetRenderTest): + """アプリ自身の案内が階段状にならないこと。 + + 端末の LF は「1 行下へ」で行頭には戻らない。アプリの文言は普通の + 改行で書かれているので、そのまま流すと切断バナーが桁ずれする。 + """ + + def test_a_banner_stays_left_aligned(self): + w = self.widget(24, 80) + w.append_output("dev", "lab-rtr#") # プロンプト表示中 + w.show_notice("dev", + "\n\n====\nセッションが切断されました\n====\n") + printed = [l for l in self.screen_text(w).split("\n") if l.strip()] + for line in printed[1:]: + self.assertFalse(line.startswith(" "), + "案内が字下げされている: %r" % line) + + def test_device_output_is_untouched(self): + # 機器からの LF は端末の意味のまま扱う (show の桁揃えが崩れる) + w = self.widget(24, 80) + w.append_output("dev", "abc\ndef") + self.assertEqual(self.screen_text(w).split("\n")[1], " def") + + +class LogRecordingTest(WidgetRenderTest): + def test_tabs_are_kept_in_the_log(self): + """タブは桁を作る文字なので、落とすと表が潰れる。""" + import io as _io + import tempfile + w = self.widget(24, 80) + path = os.path.join(tempfile.gettempdir(), "netbelt_tab_log.txt") + w._log_files["dev"] = _io.open(path, "w", encoding="utf-8") + try: + w.append_output("dev", "Interface\tStatus\r\nGi0/0\tup\r\n") + w._log_files["dev"].close() + self.assertEqual(_io.open(path, encoding="utf-8").read(), + "Interface\tStatus\nGi0/0\tup\n") + finally: + os.remove(path) + + +class SplitInvarianceTest(WidgetRenderTest): + """受信がどこで切れても同じ画面になること。切れ目対応は + パーサが状態として持つので、ウィジェットに継ぎ足し処理は無い。""" + + def render_split(self, raw, cut): + w = self.widget() + w.append_output("dev", raw[:cut]) + w.append_output("dev", raw[cut:]) + return self.screen_text(w) + + def test_every_split_point_of_nano_renders_the_same(self): + raw = fixture("nano_vt100.bin") + w = self.widget() + w.append_output("dev", raw) + whole = self.screen_text(w) + for cut in range(1, len(raw)): + if self.render_split(raw, cut) != whole: + self.fail("%d バイト目で切ると画面が変わる" % cut) + + def test_a_window_title_never_leaks_at_any_split(self): + raw = "before\x1b]0;user@lab: /home/secret\x07after" + for cut in range(1, len(raw)): + text = self.render_split(raw, cut) + self.assertNotIn("secret", text, "%d バイト目" % cut) + self.assertIn("beforeafter", text) + + +class ReconnectTest(WidgetRenderTest): + def test_terminal_state_starts_over_but_the_record_stays(self): + w = self.widget() + w.append_output("dev", "old session\r\n\x1b[?1h") + self.assertTrue(w._terminals["dev"]._screen.application_cursor_keys) + w.create_terminal_tab("dev") # 再接続はタブを使い回す + self.assertFalse(w._terminals["dev"]._screen.application_cursor_keys) + w.append_output("dev", "new session") + text = self.screen_text(w) + self.assertIn("old session", text) + self.assertIn("new session", text) + + +class SelectionSurvivesOutputTest(WidgetRenderTest): + def test_a_selection_survives_the_screen_scrolling(self): + """画面がスクロールしても範囲選択が消えないこと。 + + v1.1.0 で直した性質。画面が 1 行上がると全行がずれるので、 + 素朴に描き直すと選択が Qt に潰される。show run をコピーしよう + としている最中に機器が syslog を 1 行吐くだけで起きる。 + """ + from PyQt6.QtGui import QTextCursor + w = self.widget() + terminal = w._terminals["dev"] + rows = terminal._screen.rows + w.append_output("dev", + "\r\n".join("line %02d" % i for i in range(rows + 8))) + self.assertGreater(len(terminal._screen.history), 0, + "スクロールが起きていない (前提が崩れている)") + + for word in ("line 05", "line 12", "line %02d" % (rows + 5)): + text = terminal.toPlainText() + cursor = terminal.textCursor() + cursor.setPosition(text.index(word)) + cursor.movePosition(QTextCursor.MoveOperation.Right, + QTextCursor.MoveMode.KeepAnchor, len(word)) + terminal.setTextCursor(cursor) + w.append_output("dev", "\r\nsyslog message") + self.assertEqual(terminal.textCursor().selectedText(), word, + "%s の選択が消えた" % word) + + def test_incoming_output_does_not_break_a_selection(self): + # コピーしようと選択している最中に機器がログを吐いても、 + # 選択が消えたり置き換わったりしないこと (旧実装から守る性質) + from PyQt6.QtGui import QTextCursor + w = self.widget() + w.append_output("dev", "pick me\r\n") + terminal = w._terminals["dev"] + cursor = terminal.textCursor() + cursor.setPosition(0) + cursor.movePosition(QTextCursor.MoveOperation.Right, + QTextCursor.MoveMode.KeepAnchor, 7) + terminal.setTextCursor(cursor) + w.append_output("dev", "more output\r\n") + self.assertEqual(terminal.textCursor().selectedText(), "pick me") + self.assertIn("more output", self.screen_text(w)) + + +class ColourTest(WidgetRenderTest): + """SGR が実際の色になること。番号→実色の対応は xterm の既定値。""" + + def format_at(self, w, needle): + from PyQt6.QtGui import QTextCursor + terminal = w._terminals["dev"] + pos = terminal.toPlainText().index(needle) + cursor = QTextCursor(terminal.document()) + cursor.setPosition(pos + 1) # charFormat は直前の文字の書式 + return cursor.charFormat() + + def test_basic_colours_reach_the_glyphs(self): + w = self.widget() + w.append_output("dev", "\x1b[31mRED\x1b[m plain") + self.assertEqual(self.format_at(w, "RED").foreground().color().name(), + "#cd0000") + + def test_default_text_carries_no_explicit_colour(self): + # 書式を空に戻しておかないと、設定でパレットを変えたとき + # 過去の文字だけ古い色で残ってしまう + from PyQt6.QtGui import QTextFormat + w = self.widget() + w.append_output("dev", "\x1b[31mRED\x1b[m plain") + self.assertFalse(self.format_at(w, "plain").hasProperty( + QTextFormat.Property.ForegroundBrush)) + + def test_reverse_video_swaps_the_palette(self): + w = self.widget() + w.append_output("dev", "\x1b[7m--More--\x1b[m") + fmt = self.format_at(w, "--More--") + settings = w._terminal_settings + self.assertEqual(fmt.foreground().color().name(), + settings["background_color"].lower()) + self.assertEqual(fmt.background().color().name(), + settings["text_color"].lower()) + + def test_bold_is_bold(self): + from PyQt6.QtGui import QFont + w = self.widget() + w.append_output("dev", "\x1b[1mboot system\x1b[m") + self.assertEqual(self.format_at(w, "boot").fontWeight(), + QFont.Weight.Bold) + + def test_the_256_colour_cube(self): + w = self.widget() + w.append_output("dev", "\x1b[38;5;196mX") + self.assertEqual(self.format_at(w, "X").foreground().color().name(), + "#ff0000") + + def test_colour_survives_scrolling_into_the_record(self): + w = self.widget() + w.append_output("dev", "\x1b[32mUP\x1b[m\r\n" + "\r\n" * 30) + self.assertEqual(self.format_at(w, "UP").foreground().color().name(), + "#00cd00") + + def test_nano_title_bar_keeps_its_band(self): + # 反転属性の付いた空白は帯として意味があるので、行末でも + # 削らずに描く + w = self.widget() + w.append_output("dev", fixture("nano_vt100.bin")) + title = [l for l in w._terminals["dev"].toPlainText().split("\n") + if "GNU nano" in l][0] + self.assertEqual(len(title), 80) + fmt = self.format_at(w, "GNU nano") + self.assertEqual(fmt.background().color().name(), "#ffffff") + + +class GridResizeTest(WidgetRenderTest): + """ウィンドウの大きさに格子が追従し、機器へ知らせること。""" + + def test_a_new_size_reaches_screen_and_signal(self): + w = self.widget() + recorded = [] + w.terminal_resized.connect( + lambda d, c, r: recorded.append((d, c, r))) + w._grid_size = lambda terminal: (30, 100) + w._apply_grid_size("dev") + screen = w._terminals["dev"]._screen + self.assertEqual((screen.rows, screen.cols), (30, 100)) + self.assertEqual(recorded, [("dev", 100, 30)]) + + def test_the_same_size_is_not_re_announced(self): + w = self.widget() + recorded = [] + w.terminal_resized.connect( + lambda d, c, r: recorded.append((d, c, r))) + w._grid_size = lambda terminal: (30, 100) + w._apply_grid_size("dev") + w._apply_grid_size("dev") + self.assertEqual(len(recorded), 1) + + def test_what_was_written_survives_the_resize(self): + w = self.widget() + w.append_output("dev", "show run\r\nhostname lab-rtr\r\n") + w._grid_size = lambda terminal: (10, 40) + w._apply_grid_size("dev") + self.assertIn("hostname lab-rtr", self.screen_text(w)) + + def test_grid_size_stays_within_sane_bounds(self): + w = self.widget() + rows, cols = w._grid_size(w._terminals["dev"]) + self.assertTrue(5 <= rows <= 200, rows) + self.assertTrue(20 <= cols <= 500, cols) + + +class InputModeTest(WidgetRenderTest): + """機器が立てた入力モードに、送る側が合わせること。""" + + def sendable(self, w): + terminal = w._terminals["dev"] + terminal.set_input_enabled(True) + return terminal + + def test_arrow_keys_follow_decckm(self): + w = self.widget() + terminal = self.sendable(w) + self.assertEqual(terminal._cursor_key("A"), "\x1b[A") + w.append_output("dev", "\x1b[?1h") # nano が立てる + self.assertEqual(terminal._cursor_key("A"), "\x1bOA") + w.append_output("dev", "\x1b[?1l") + self.assertEqual(terminal._cursor_key("A"), "\x1b[A") + + def test_a_paste_is_bracketed_only_when_asked(self): + w = self.widget() + terminal = self.sendable(w) + sent = [] + terminal.key_pressed.connect(sent.append) + terminal.send_text("conf t\n") + self.assertEqual("".join(sent), "conf t\r") # 機器相手は素のまま + del sent[:] + w.append_output("dev", "\x1b[?2004h") # bash が立てる + terminal.send_text("echo hi\n") + self.assertEqual("".join(sent), "\x1b[200~echo hi\r\x1b[201~") + + +class DeviceQueryTest(WidgetRenderTest): + def test_a_cursor_position_query_is_answered(self): + w = self.widget() + sent = [] + w._terminals["dev"].key_pressed.connect(sent.append) + w.append_output("dev", "ab\x1b[6n") + self.assertEqual(sent, ["\x1b[1;3R"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_update_notes.py b/tests/test_update_notes.py new file mode 100644 index 0000000..8c76e7a --- /dev/null +++ b/tests/test_update_notes.py @@ -0,0 +1,156 @@ +"""更新の通知に、何が変わったかを出す。 + +これまでリリース本文には案内文しか入っておらず、通知を見ても何が +変わったのか分からなかった。いまはリリース本文の先頭に CHANGELOG の +その版の節が入る。加えて、何世代か飛ばしている利用者には、その間の +ぶんも並べる。 + +GitHub API は叩かない。応答を作り物で与える。 +""" +import sys +import unittest +from unittest import mock + +sys.path.insert(0, "src") + + +def release(version, body="", draft=False, prerelease=False): + return { + "tag_name": "v" + version, + "body": body, + "draft": draft, + "prerelease": prerelease, + "assets": [ + {"name": "NetBelt-v%s-Windows-Portable.zip" % version, + "url": "https://example.com/%s.zip" % version}, + {"name": "NetBelt-v%s-Windows-Portable.zip.sha256" % version, + "url": "https://example.com/%s.sha256" % version}, + ], + "published_at": "2026-08-27T00:00:00Z", + } + + +def check(releases, current="1.0.0"): + from core.version_manager import VersionManager + + response = mock.Mock() + response.json.return_value = releases + response.raise_for_status.return_value = None + with mock.patch("core.version_manager.requests.get", + return_value=response), \ + mock.patch.object(VersionManager, "CURRENT_VERSION", current): + return VersionManager().check_for_updates() + + +class NotesSpanTest(unittest.TestCase): + def test_the_notes_cover_every_version_that_was_skipped(self): + info = check([release("1.3.0", "3 の話"), + release("1.2.0", "2 の話"), + release("1.1.0", "1 の話")], current="1.0.0") + notes = info["release_notes"] + for body in ("3 の話", "2 の話", "1 の話"): + self.assertIn(body, notes) + # 新しいものから並べる + self.assertLess(notes.index("3 の話"), notes.index("1 の話")) + + def test_versions_already_installed_are_left_out(self): + info = check([release("1.3.0", "3 の話"), + release("1.2.0", "2 の話"), + release("1.1.0", "1 の話")], current="1.2.0") + notes = info["release_notes"] + self.assertIn("3 の話", notes) + self.assertNotIn("2 の話", notes) + self.assertNotIn("1 の話", notes) + + def test_each_block_says_which_version_it_is(self): + info = check([release("1.3.0", "3 の話"), + release("1.2.0", "2 の話")], current="1.1.0") + self.assertIn("# v1.3.0", info["release_notes"]) + self.assertIn("# v1.2.0", info["release_notes"]) + + def test_too_many_generations_are_capped(self): + from core.version_manager import VersionManager + many = [release("1.%d.0" % n, "%d の話" % n) + for n in range(9, 0, -1)] + info = check(many, current="1.0.0") + notes = info["release_notes"] + shown = sum(1 for n in range(1, 10) if ("# v1.%d.0" % n) in notes) + self.assertEqual(shown, VersionManager.MAX_NOTES_GENERATIONS) + self.assertIn("省略", notes) + + +class ReleaseSelectionTest(unittest.TestCase): + def test_drafts_and_prereleases_are_skipped(self): + info = check([release("2.0.0", "下書き", draft=True), + release("1.9.0", "事前公開", prerelease=True), + release("1.3.0", "本物")], current="1.0.0") + self.assertEqual(info["version"], "1.3.0") + self.assertNotIn("下書き", info["release_notes"]) + self.assertNotIn("事前公開", info["release_notes"]) + + def test_the_newest_wins_even_if_the_list_is_out_of_order(self): + info = check([release("1.1.0", "1 の話"), + release("1.3.0", "3 の話"), + release("1.2.0", "2 の話")], current="1.0.0") + self.assertEqual(info["version"], "1.3.0") + + def test_the_download_still_comes_from_the_newest_release(self): + info = check([release("1.3.0"), release("1.2.0")], current="1.0.0") + self.assertIn("1.3.0", info["download_url"]) + self.assertIn("1.3.0", info["sha256_url"]) + + def test_an_empty_list_is_not_an_update(self): + self.assertIsNone(check([], current="1.0.0")) + + def test_a_single_release_object_still_works(self): + """API が 1 件だけの辞書を返しても壊れないこと。""" + info = check(release("1.3.0", "3 の話"), current="1.0.0") + self.assertEqual(info["version"], "1.3.0") + self.assertIn("3 の話", info["release_notes"]) + + def test_nothing_newer_means_no_update(self): + info = check([release("1.0.0", "同じ")], current="1.0.0") + self.assertFalse(info["available"]) + + +class DialogDisplayTest(unittest.TestCase): + """通知として実際に何が見えるか。""" + + @classmethod + def setUpClass(cls): + import os + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PyQt6.QtWidgets import QApplication + cls.app = QApplication.instance() or QApplication([]) + + def shown(self, releases, current="1.1.0"): + from ui.dialogs.update_dialog import UpdateDialog + info = check(releases, current=current) + # ダイアログを変数で持つ。手放すと Qt 側が中身ごと片付けてしまう + self.dialog = UpdateDialog(None, info) + return self.dialog.notes_text.toPlainText() + + def test_every_skipped_version_reaches_the_dialog(self): + text = self.shown([release("1.3.0", "### 追加\n- 機能C"), + release("1.2.0", "### 追加\n- 機能B"), + release("1.1.1", "### 修正\n- 不具合A")]) + for word in ("1.3.0", "機能C", "1.2.0", "機能B", "1.1.1", "不具合A"): + self.assertIn(word, text) + + def test_markdown_is_rendered_not_shown_as_symbols(self): + """見出しや箇条書きの記号が、そのまま並ばないこと。""" + text = self.shown([release("1.3.0", "### 追加\n- 機能C")]) + self.assertNotIn("###", text) + self.assertNotIn("# v1.3.0", text) + self.assertIn("機能C", text) + + def test_an_empty_body_does_not_leave_the_box_blank(self): + from ui.dialogs.update_dialog import UpdateDialog + info = check([release("1.3.0", "")], current="1.1.0") + info["release_notes"] = "" + self.dialog = UpdateDialog(None, info) + self.assertIn("ありません", self.dialog.notes_text.toPlainText()) + + +if __name__ == "__main__": + unittest.main()