pwn 0.5.776 → 0.5.778

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/pwn/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PWN
4
- VERSION = '0.5.776'
4
+ VERSION = '0.5.778'
5
5
  end
@@ -3,8 +3,46 @@
3
3
  require 'spec_helper'
4
4
  require 'fileutils'
5
5
  require 'tmpdir'
6
+ require 'open3'
6
7
 
7
8
  describe PWN::ModuleSkills do
9
+ it 'saves legacy generated edits and merges without discarding unrelated edits' do
10
+ script = File.read(File.expand_path('../../../build_gem.sh', __dir__))
11
+ recovery = script.split('generated_skills=()', 2).last.split('git checkout master', 2).first
12
+ Dir.mktmpdir('pwn_upgrade') do |dir|
13
+ git = lambda do |*args|
14
+ out, status = Open3.capture2e('git', *args, chdir: dir)
15
+ expect(status.success?).to be(true), out
16
+ out
17
+ end
18
+ git.call('init', '-b', 'master')
19
+ git.call('config', 'user.email', 'test@example.invalid')
20
+ git.call('config', 'user.name', 'Upgrade Test')
21
+ skill = 'etc/default_skills/pwn/json_text/SKILL.md'
22
+ path = File.join(dir, skill)
23
+ FileUtils.mkdir_p(File.dirname(path))
24
+ original = "---\ngenerated: true\n---\noriginal\n"
25
+ File.write(path, original)
26
+ File.write(File.join(dir, 'notes'), 'original')
27
+ git.call('add', '.')
28
+ git.call('commit', '-m', 'base')
29
+ git.call('checkout', '-b', 'incoming')
30
+ File.write(path, "#{original}upstream\n")
31
+ git.call('commit', '-am', 'update')
32
+ git.call('checkout', 'master')
33
+ File.write(path, "#{original}local regeneration\n")
34
+ File.write(File.join(dir, 'notes'), 'keep my edit')
35
+ _, blocked = Open3.capture2e('git', 'merge', '--ff-only', 'incoming', chdir: dir)
36
+ expect(blocked.success?).to be(false)
37
+ out, status = Open3.capture2e('bash', '-ec', "generated_skills=()#{recovery}", chdir: dir)
38
+ expect(status.success?).to be(true), out
39
+ git.call('merge', '--ff-only', 'incoming')
40
+ expect(File.read(path)).to eq("#{original}upstream\n")
41
+ expect(File.read(File.join(dir, 'notes'))).to eq('keep my edit')
42
+ expect(git.call('show', "stash@{0}:#{skill}")).to include('local regeneration')
43
+ end
44
+ end
45
+
8
46
  it 'should display information for authors' do
9
47
  expect(described_class).to respond_to :authors
10
48
  end
@@ -23,48 +61,50 @@ describe PWN::ModuleSkills do
23
61
  expect(described_class.relpath(source: 'pwn.rb')).to eq('pwn/SKILL.md')
24
62
  end
25
63
 
26
- it 'refreshes etc/default_skills/pwn from every lib/pwn module' do
27
- report = described_class.refresh!
28
- expect(report[:modules]).to be > 50
29
- tb = File.join(PWN::ModuleSkills::GEM_SKILLS, 'plugins', 'transparent_browser', 'SKILL.md')
30
- expect(File.file?(tb)).to eq(true), tb
31
- expect(File).not_to exist(File.join(PWN::ModuleSkills::GEM_SKILLS, 'plugins', 'transparent_browser.md'))
32
- body = File.read(tb)
33
- expect(body).to include('PWN::Plugins::TransparentBrowser')
34
- expect(body).to include('open')
35
- expect(body).not_to match(/hermes/i)
64
+ it 'refreshes a temp skill tree and does not rewrite the checkout' do
65
+ checkout = File.join(PWN::ModuleSkills::GEM_SKILLS, 'json_text', 'SKILL.md')
66
+ before = File.read(checkout)
67
+ Dir.mktmpdir('pwn_mod_refresh') do |dir|
68
+ report = described_class.refresh!(dest: dir)
69
+ expect(report[:modules]).to be > 50
70
+ tb = File.join(dir, 'pwn', 'plugins', 'transparent_browser', 'SKILL.md')
71
+ expect(File.file?(tb)).to eq(true), tb
72
+ expect(File).not_to exist(File.join(dir, 'pwn', 'plugins', 'transparent_browser.md'))
73
+ body = File.read(tb)
74
+ expect(body).to include('PWN::Plugins::TransparentBrowser')
75
+ expect(body).to include('open')
76
+ expect(body).not_to match(/hermes/i)
36
77
 
37
- pw = File.join(PWN::ModuleSkills::GEM_SKILLS, 'sast', 'password', 'SKILL.md')
38
- expect(File.file?(pw)).to eq(true), pw
39
- expect(File.file?(File.join(PWN::ModuleSkills::GEM_SKILLS, 'sast', 'password', 'references', 'security.md'))).to eq(true)
78
+ pw = File.join(dir, 'pwn', 'sast', 'password', 'SKILL.md')
79
+ expect(File.file?(pw)).to eq(true), pw
80
+ expect(File.file?(File.join(dir, 'pwn', 'sast', 'password', 'references', 'security.md'))).to eq(true)
81
+
82
+ described_class.enumerate.each do |rec|
83
+ path = File.join(dir, described_class.relpath(source: rec[:source], const: rec[:const]))
84
+ expect(File.file?(path)).to eq(true), path
85
+ expect(File.read(path)).to include(rec[:const])
86
+ expect(File.read(path)).not_to match(/hermes/i)
87
+ end
88
+ drift = described_class.drift(dest: dir)
89
+ expect(drift[:missing]).to eq([])
90
+ expect(drift[:stale]).to eq([])
91
+ expect(drift[:orphans]).to eq([])
92
+ end
93
+ expect(File.read(checkout)).to eq(before)
40
94
 
41
95
  gw = File.join(PWN::ModuleSkills::GEM_SKILLS, 'plugins', 'google_workspace', 'SKILL.md')
42
96
  expect(File.read(gw)).to include('preserve: true')
43
97
  expect(File.read(gw)).to include('gmail_search')
44
98
  expect(File.read(gw)).to include('daily-brief.md')
45
99
  expect(File).not_to exist(File.join(PWN::Config.default_skills_dir, 'productivity', 'google-workspace', 'SKILL.md'))
46
-
47
- described_class.enumerate.each do |rec|
48
- path = File.join(
49
- File.dirname(PWN::ModuleSkills::GEM_SKILLS),
50
- described_class.relpath(source: rec[:source], const: rec[:const])
51
- )
52
- expect(File.file?(path)).to eq(true), path
53
- expect(File.read(path)).to include(rec[:const])
54
- expect(File.read(path)).not_to match(/hermes/i)
55
- end
56
- drift = described_class.drift
57
- expect(drift[:missing]).to eq([])
58
- expect(drift[:stale]).to eq([])
59
- expect(drift[:orphans]).to eq([])
60
100
  end
61
101
 
62
- it 'exposes drift so rake/spec can refuse a stale gem skill tree' do
63
- expect(described_class).to respond_to(:drift)
102
+ it 'does not rewrite the checkout before spec' do
64
103
  rake = File.read(File.expand_path('../../../Rakefile', __dir__))
65
- expect(rake).to match(/module_skills/)
104
+ expect(rake).to match(/task :module_skills do/)
66
105
  expect(rake).to match(/ModuleSkills\.refresh!/)
67
- expect(rake).to match(/Task\[:spec\]\.enhance|task spec: :module_skills/)
106
+ expect(rake).not_to match(/Task\[:spec\]\.enhance\(\[:module_skills\]\)/)
107
+ expect(rake).not_to match(/module_skills_drift/)
68
108
  end
69
109
 
70
110
  it 'install overwrites existing generated module skills' do
@@ -83,7 +123,6 @@ describe PWN::ModuleSkills do
83
123
  end
84
124
 
85
125
  it 'ships no credential-shaped artifacts under etc/default_skills' do
86
- described_class.refresh!
87
126
  rx = described_class.const_get(:ARTIFACT_RX)
88
127
  leftovers = []
89
128
  Dir.glob(File.join(PWN::Config.default_skills_dir, '**', '*')).each do |path|
@@ -116,7 +116,8 @@ describe PWN::Plugins::REPL, 'mesh terminal integration' do
116
116
  end
117
117
  expect(status.exitstatus).to eq(0), transcript
118
118
  transcript.force_encoding(Encoding::UTF_8)
119
- expect(transcript).to include('╭', '─', 'COMPOSE', 'CONVERSATION')
119
+ expect(transcript).to include('╭', '─', 'COMPOSE', 'CONVERSATION', 'SENDER', 'RECEIVER')
120
+ expect(transcript).not_to include('MSG DETAILS', 'SNDR INFO', 'RCVR INFO')
120
121
  expect(File.read(File.join(dir, 'topic'))).to eq('msh/US/UT/2/e/LongFast/#')
121
122
  path = File.join(dir, 'pwn.yaml')
122
123
  expect(PWN::Plugins::Vault.file_encrypted?(file: path)).to be(true)
@@ -26,4 +26,122 @@ describe PWN::Plugins::REPL, 'mesh terminal layout' do
26
26
  expect(layout[:tx_top]).to be < 10
27
27
  expect(layout[:tx_top] + layout[:tx]).to eq(10)
28
28
  end
29
+
30
+ it 'keeps a 32-column MSG DETAILS pane beside conversation when the terminal is wide enough' do
31
+ layout = described_class.send(:mesh_layout, lines: 24, cols: 110)
32
+ expect(layout[:details]).to eq(32)
33
+ expect(layout[:conversation]).to eq(78)
34
+ expect(layout[:header] + layout[:body] + layout[:tx]).to eq(24)
35
+ end
36
+
37
+ it 'omits MSG DETAILS when the terminal is too narrow for both panes' do
38
+ layout = described_class.send(:mesh_layout, lines: 24, cols: 40)
39
+ expect(layout[:details]).to eq(0)
40
+ expect(layout[:conversation]).to eq(40)
41
+ end
42
+
43
+ it 'resizes from the terminal dup the same way pwn-ai does' do
44
+ source = double('terminal dup', tty?: true, winsize: [30, 100])
45
+ stub_const('PWN::MeshTerm', source)
46
+ allow(Curses).to receive(:lines).and_return(24)
47
+ allow(Curses).to receive(:cols).and_return(80)
48
+ allow(Curses).to receive(:resizeterm)
49
+ allow(described_class).to receive(:mesh_relayout!)
50
+ _input, pipe = IO.pipe
51
+ original = $stdout
52
+ $stdout = pipe
53
+ described_class.send(:mesh_apply_resize)
54
+ expect(Curses).to have_received(:resizeterm).with(30, 100)
55
+ expect(described_class).to have_received(:mesh_relayout!).with(lines: 30, cols: 100)
56
+ ensure
57
+ $stdout = original
58
+ pipe&.close
59
+ end
60
+
61
+ it 'does not read winsize when getch is a test proc' do
62
+ source = double('terminal dup', tty?: true)
63
+ allow(source).to receive(:winsize)
64
+ stub_const('PWN::MeshTerm', source)
65
+ described_class.send(:mesh_apply_resize, getch: proc {})
66
+ expect(source).not_to have_received(:winsize)
67
+ end
68
+
69
+ def pane_double
70
+ win = double('pane')
71
+ allow(win).to receive(:resize)
72
+ allow(win).to receive(:move)
73
+ allow(win).to receive(:erase)
74
+ allow(win).to receive(:clear)
75
+ allow(win).to receive(:close)
76
+ allow(win).to receive(:maxx).and_return(40)
77
+ allow(win).to receive(:maxy).and_return(10)
78
+ %i[scrollok nodelay= keypad timeout= setpos addstr attron attroff refresh noutrefresh touch].each do |message|
79
+ allow(win).to receive(message)
80
+ end
81
+ win
82
+ end
83
+
84
+ it 'keeps the same panes on resize and does not clear stdscr' do
85
+ old = pane_double
86
+ %i[MeshRxHeaderWin MeshRxFrameWin MeshRxBodyWin MeshSndrWin MeshRcvrWin MeshTxWin].each do |name|
87
+ stub_const("PWN::#{name}", old)
88
+ end
89
+ stub_const('PWN::MeshMutex', Mutex.new)
90
+ stub_const('PWN::MeshRxState', { rows: [{ header: '2026 · PEER · path · dest · RX via Serial', body: 'hello', color: 21 }], focus: :compose })
91
+ screen = double('stdscr', erase: nil, noutrefresh: nil)
92
+ allow(Curses).to receive(:stdscr).and_return(screen)
93
+ allow(Curses).to receive(:doupdate)
94
+ allow(Curses).to receive(:clear)
95
+ allow(Curses).to receive(:refresh)
96
+ allow(Curses).to receive(:color_pair).and_return(0)
97
+ described_class.send(:mesh_relayout!, lines: 30, cols: 120)
98
+ expect(old).to have_received(:resize).with(anything, 120).at_least(:once)
99
+ expect(old).not_to have_received(:close)
100
+ expect(PWN::MeshTxWin).to equal(old)
101
+ expect(Curses).not_to have_received(:clear)
102
+ expect(Curses).not_to have_received(:refresh)
103
+ expect(PWN::MeshRxBodyWin).to have_received(:addstr).with(a_string_including('RX via Serial'))
104
+ expect(PWN::MeshRxBodyWin).to have_received(:addstr).with(a_string_including('hello'))
105
+ expect(old).to have_received(:touch).at_least(4).times
106
+ expect(old).to have_received(:noutrefresh).at_least(4).times
107
+ expect(Curses).to have_received(:doupdate)
108
+ end
109
+
110
+ it 'does not raise when a resize arrives before any message exists' do
111
+ hide_const('PWN::MeshRxState')
112
+ old = pane_double
113
+ %i[MeshRxHeaderWin MeshRxFrameWin MeshRxBodyWin MeshTxWin].each do |name|
114
+ stub_const("PWN::#{name}", old)
115
+ end
116
+ stub_const('PWN::MeshMutex', Mutex.new)
117
+ allow(Curses).to receive(:color_pair).and_return(0)
118
+ allow(Curses).to receive(:stdscr).and_return(double('stdscr', erase: nil, noutrefresh: nil))
119
+ allow(Curses).to receive(:doupdate)
120
+ expect { described_class.send(:mesh_relayout!, lines: 24, cols: 100) }.not_to raise_error
121
+ expect(PWN::MeshTxWin).to equal(old)
122
+ expect(old).not_to have_received(:close)
123
+ ensure
124
+ %i[MeshSndrWin MeshRcvrWin].each do |name|
125
+ PWN.send(:remove_const, name) if PWN.const_defined?(name)
126
+ end
127
+ end
128
+
129
+ it 'drops side-pane focus when a resize hides SENDER and RECEIVER' do
130
+ old = pane_double
131
+ %i[MeshRxHeaderWin MeshRxFrameWin MeshRxBodyWin MeshSndrWin MeshRcvrWin MeshTxWin].each do |name|
132
+ stub_const("PWN::#{name}", old)
133
+ end
134
+ stub_const('PWN::MeshMutex', Mutex.new)
135
+ stub_const('PWN::MeshRxState', { focus: :sndr, rows: [] })
136
+ allow(Curses).to receive(:color_pair).and_return(0)
137
+ allow(Curses).to receive(:stdscr).and_return(double('stdscr', erase: nil, noutrefresh: nil))
138
+ allow(Curses).to receive(:doupdate)
139
+ described_class.send(:mesh_relayout!, lines: 24, cols: 40)
140
+ expect(PWN.const_defined?(:MeshSndrWin)).to be(false)
141
+ expect(described_class.send(:mesh_focus)).to eq(:conversation)
142
+ expect(PWN::MeshTxWin).to equal(old)
143
+ ensure
144
+ PWN.const_set(:MeshSndrWin, old) unless PWN.const_defined?(:MeshSndrWin)
145
+ PWN.const_set(:MeshRcvrWin, old) unless PWN.const_defined?(:MeshRcvrWin)
146
+ end
29
147
  end
@@ -224,7 +224,9 @@ describe PWN::Plugins::REPL, 'additive MQTT receive ownership' do
224
224
  stub_const('PWN::MeshObj', {})
225
225
  stub_const('PWN::MeshTransport', :mqtt)
226
226
  described_class.send(:mesh_handle_rx, env: env, msg: messages.first)
227
- expect(win).to have_received(:addstr).with(a_string_including('FIRST', '/LongFast/', 'RX via Serial'))
227
+ expect(win).to have_received(:addstr).with(a_string_including('FIRST', '/LongFast/'))
228
+ expect(win).to have_received(:addstr).with(a_string_including('RX via Serial'))
229
+ expect(described_class.send(:mesh_details_lines, message: PWN::MeshRxState[:detail_rows].last, width: 28).values.flatten.map { |line| line[:text] }.join("\n")).not_to include('VIA')
228
230
  expect(described_class).to have_received(:mesh_maybe_dispatch_to_pwn_ai).with(hash_including(obj: radio, channel_name: 'LongFast'))
229
231
  end
230
232
 
@@ -36,6 +36,39 @@ describe PWN::Plugins::REPL, 'mesh primary display, MQTT sender, and GPS link' d
36
36
  end
37
37
  end.new
38
38
  end
39
+ let(:details) do
40
+ Class.new do
41
+ attr_reader :lines
42
+ attr_accessor :width
43
+
44
+ def initialize
45
+ @lines = []
46
+ @width = 32
47
+ end
48
+
49
+ def maxx
50
+ @width
51
+ end
52
+
53
+ def maxy
54
+ 20
55
+ end
56
+
57
+ def attron(*) end
58
+ def attroff(*) end
59
+ def refresh; end
60
+ def setpos(*); end
61
+
62
+ def addstr(text)
63
+ @lines << text
64
+ end
65
+
66
+ def clear
67
+ @lines.clear
68
+ end
69
+ end.new
70
+ end
71
+ let(:rcvr) { details.class.new }
39
72
  let(:packet) { { id: 123, from: 0x421f6f22, to: 0xffffffff, decoded: { portnum: :TEXT_MESSAGE_APP, payload: 'hello' } } }
40
73
 
41
74
  before do
@@ -46,6 +79,8 @@ describe PWN::Plugins::REPL, 'mesh primary display, MQTT sender, and GPS link' d
46
79
  stub_const('PWN::MeshLastTx', nil)
47
80
  stub_const('PWN::MeshMutex', Mutex.new)
48
81
  stub_const('PWN::MeshRxBodyWin', win)
82
+ stub_const('PWN::MeshSndrWin', details)
83
+ stub_const('PWN::MeshRcvrWin', rcvr)
49
84
  allow(Curses).to receive(:color_pair) { |color| color }
50
85
  allow(described_class).to receive(:mesh_env_hash).and_return(env)
51
86
  allow(described_class).to receive(:mesh_maybe_dispatch_to_pwn_ai)
@@ -60,17 +95,30 @@ describe PWN::Plugins::REPL, 'mesh primary display, MQTT sender, and GPS link' d
60
95
  described_class.send(:mesh_handle_rx, env: env, obj: broker, msg: { packet: packet, mesh_transport: :mqtt, topic: 'msh/US/2/e/LongFast/!421f6f22' })
61
96
  described_class.send(:mesh_handle_rx, env: env, obj: radio, msg: { packet: packet, mesh_transport: :serial })
62
97
 
63
- expect(headers).to eq(headers.grep(/RX via Serial/))
64
- expect(headers.join).to include('RX via Serial')
65
- expect(headers.join).not_to include('RX via MQTT')
98
+ painted = win.lines.join
99
+ expect(painted).to include('RX via Serial')
100
+ expect(painted).not_to include('RX via MQTT')
101
+ expect(rcvr.lines.join).not_to include('VIA')
102
+ expect(details.lines.join).not_to include('VIA')
66
103
  expect(described_class).to have_received(:mesh_maybe_dispatch_to_pwn_ai).once.with(hash_including(transport: :mqtt))
67
104
  end
68
105
 
106
+ it 'does not index an MQTT client while painting node details' do
107
+ require 'mqtt'
108
+ client = MQTT::Client.new(host: 'offline.invalid')
109
+ stub_const('PWN::MeshReceivers', [{ obj: client, transport: :mqtt, secondary: true, active: true }])
110
+ described_class.send(:mesh_handle_rx, env: env, obj: radio, msg: { packet: packet, mesh_transport: :serial })
111
+ described_class.send(:mesh_handle_rx, env: env, obj: client, msg: { packet: packet.merge(id: 124), mesh_transport: :mqtt, topic: 'msh/US/2/e/LongFast/!421f6f22' })
112
+ expect(described_class).not_to have_received(:mesh_ui_puts).with(hash_including(text: a_string_including('RX display failed')))
113
+ expect(details.lines.join).to include('Node ID: !421f6f22')
114
+ end
115
+
69
116
  it 'keeps RX via MQTT when that is the only observation' do
70
117
  described_class.send(:mesh_handle_rx, env: env, obj: broker, msg: { packet: packet, mesh_transport: :mqtt, topic: 'msh/US/2/e/LongFast/!421f6f22' })
71
118
 
72
- expect(headers.join).to include('RX via MQTT')
73
- expect(headers.join).not_to include('RX via Serial')
119
+ expect(win.lines.join).to include('RX via MQTT')
120
+ expect(win.lines.join).not_to include('RX via Serial')
121
+ expect(rcvr.lines.join).not_to include('VIA')
74
122
  end
75
123
 
76
124
  it 'publishes the configured channel sender instead of the MQTT library default' do
@@ -88,7 +136,7 @@ describe PWN::Plugins::REPL, 'mesh primary display, MQTT sender, and GPS link' d
88
136
  described_class.send(:mesh_send_text, env: radio_env, obj: broker, transport: :mqtt, from: '!00000b0b', text: 'sent', channel_name: 'LongFast')
89
137
  end
90
138
 
91
- it 'links a known sender position and leaves unknown positions unlabeled' do
139
+ it 'links a known sender position in MSG DETAILS and leaves unknown positions unlabeled' do
92
140
  lat_i = 10_000_000
93
141
  lon_i = -20_000_000
94
142
  coord = format('%<lat>.7f,%<lon>.7f', lat: lat_i / 10_000_000.0, lon: lon_i / 10_000_000.0)
@@ -96,20 +144,114 @@ describe PWN::Plugins::REPL, 'mesh primary display, MQTT sender, and GPS link' d
96
144
  packet: { id: 1, from: 0x421f6f22, decoded: { portnum: :POSITION_APP, payload: { latitude_i: lat_i, longitude_i: lon_i, location_source: :LOC_INTERNAL } } }
97
145
  })
98
146
  described_class.send(:mesh_handle_rx, env: env, obj: radio, msg: { packet: packet, mesh_transport: :serial })
99
- linked = headers.join
100
- expect(linked).to include("#{coord} >> https://maps.google.com/?q=#{coord}")
147
+ linked = details.lines.join
148
+ expect(details.lines.join).to include('SENDER', 'Node ID: !421f6f22', coord)
149
+ expect(rcvr.lines.join).to include('RECEIVER', 'Node ID: LongFast', 'GPS: N/A')
150
+ expect(rcvr.lines.join).not_to include('broadcast')
151
+ expect(linked).not_to include('maps.google.com')
101
152
  expect(linked).not_to include("\e")
102
- expect(win.lines.join).not_to include("\n https://")
103
- win.lines.clear
104
- allow(win).to receive(:maxx).and_return(120)
105
- described_class.send(:mesh_handle_rx, env: env, obj: radio, msg: { packet: packet.merge(id: 9), mesh_transport: :serial })
106
- expect(win.lines.map(&:strip).grep(Regexp.new(Regexp.escape(coord.split(',').first)))).to all(include(coord))
153
+ hit = PWN::MeshRxState[:gps_hits].find { |row| row[:url].include?(coord) }
154
+ expect(hit[:url]).to eq("https://maps.google.com/?q=#{coord}")
155
+ allow(described_class).to receive(:mesh_maps_opener).and_return(['/usr/bin/xdg-open'])
156
+ expect(Process).to receive(:spawn).with('/usr/bin/xdg-open', hit[:url], hash_including(out: File::NULL, err: File::NULL)).and_return(0)
157
+ expect(Process).to receive(:detach).with(0)
158
+ described_class.send(:mesh_open_gps_at, x: hit[:x], y: hit[:y])
159
+ expect(details.lines.join).not_to include('maps.google.com')
160
+ expect(headers.join).not_to include('GPS N/A')
161
+ expect(headers.join).not_to include('maps.google.com')
162
+ details.lines.clear
163
+ allow(details).to receive(:maxx).and_return(20)
164
+ described_class.send(:mesh_paint_details, obj: radio)
165
+ expect(details.lines.grep(Regexp.new(Regexp.escape(coord.split(',').first)))).to all(include(coord))
107
166
 
108
167
  described_class.send(:mesh_handle_rx, env: env, obj: radio, msg: {
109
168
  packet: { id: 2, from: 0x99aabbcc, to: 0xffffffff, decoded: { portnum: 1, payload: 'no fix' } }, mesh_transport: :serial
110
169
  })
111
- expect(headers.last).to include('!99aabbcc')
112
- expect(headers.last).to end_with(" · GPS N/A\n")
170
+ expect(details.lines.join).to include('!99aabbcc', 'GPS: N/A')
171
+ expect(details.lines.join).not_to include('maps.google.com')
172
+ end
173
+
174
+ it 'shows the highlighted conversation message, and the last received message while composing' do
175
+ first = '1.0000000,2.0000000'
176
+ second = '3.0000000,4.0000000'
177
+ described_class.send(:mesh_store_position, id: '!421f6f22', position: { latitude_i: 10_000_000, longitude_i: 20_000_000, location_source: :LOC_INTERNAL })
178
+ described_class.send(:mesh_store_position, id: '!99aabbcc', position: { latitude_i: 30_000_000, longitude_i: 40_000_000, location_source: :LOC_INTERNAL })
179
+ described_class.send(:mesh_handle_rx, env: env, obj: radio, msg: { packet: packet, mesh_transport: :serial })
180
+ described_class.send(:mesh_handle_rx, env: env, obj: radio, msg: {
181
+ packet: packet.merge(id: 9, from: 0x99aabbcc), mesh_transport: :serial
182
+ })
183
+ expect(details.lines.join).to include(second)
184
+ expect(details.lines.join).not_to include(first)
185
+ allow(win).to receive(:maxy).and_return(1)
186
+ described_class.send(:mesh_set_focus, focus: :conversation)
187
+ described_class.send(:mesh_scroll_conversation, key: :home)
188
+ expect(details.lines.join).to include(first)
189
+ expect(details.lines.join).not_to include(second)
190
+ described_class.send(:mesh_set_focus, focus: :compose)
191
+ expect(details.lines.join).to include(second)
192
+ expect(details.lines.join).not_to include(first)
193
+ described_class.send(:mesh_handle_rx, env: env, obj: radio, local: true, transport: :serial, msg: {
194
+ packet: packet.merge(id: 11, from: 0x1337, to: 0x99aabbcc), decoded: { portnum: 1, payload: 'sent' }
195
+ })
196
+ expect(details.lines.join).to include(second)
197
+ expect(details.lines.join).not_to include('!00001337')
198
+ end
199
+
200
+ it 'includes the receiver fix for a direct message and not for broadcast' do
201
+ described_class.send(:mesh_store_position, id: '!421f6f22', position: { latitude_i: 10_000_000, longitude_i: -20_000_000, location_source: :LOC_INTERNAL })
202
+ described_class.send(:mesh_store_position, id: '!00001337', position: { latitude_i: 11_000_000, longitude_i: -21_000_000, location_source: :LOC_MANUAL })
203
+ described_class.send(:mesh_handle_rx, env: env, obj: radio, msg: {
204
+ packet: packet.merge(to: 0x1337), mesh_transport: :serial
205
+ })
206
+ sender = '1.0000000,-2.0000000'
207
+ receiver = '1.1000000,-2.1000000'
208
+ painted = details.lines.join
209
+ expect(details.lines.join).to include('SENDER', 'Node ID: !421f6f22', sender)
210
+ expect(rcvr.lines.join).to include('RECEIVER', 'Node ID: !00001337', receiver)
211
+ expect(rcvr.lines.join).not_to include('broadcast')
212
+ end
213
+
214
+ it 'shows node inventory fields under cyan SENDER and RECEIVER titles' do
215
+ heard = Time.at(1_700_000_000).getlocal.strftime('%Y-%m-%d %H:%M:%S%z')
216
+ key = 'k' * 32
217
+ radio[:proto_data] << {
218
+ node_info: {
219
+ num: 0x421f6f22, last_heard: 1_700_000_000, hops_away: 3, is_key_manually_verified: true,
220
+ device_metrics: { uptime_seconds: 3661 },
221
+ user: { short_name: 'PEER', role: :ROUTER, public_key: key }
222
+ }
223
+ }
224
+ colors = []
225
+ allow(details).to receive(:attron) { |attr| colors << attr }
226
+ described_class.send(:mesh_handle_rx, env: env, obj: radio, msg: { packet: packet, mesh_transport: :serial })
227
+ painted = details.lines.join("\n")
228
+ expect(painted).to include('SENDER', 'Node ID: !421f6f22', 'GPS:')
229
+ expect(painted).not_to include('VIA')
230
+ node_num = 0x421f6f22
231
+ expect(painted).to include('Short Name: PEER', 'Device Role: ROUTER', "Node #: #{node_num}")
232
+ expect(painted).to include('Last Heard:', heard, 'Hops Away: 3', 'Uptime: 1h 1m 1s')
233
+ expect(painted).to include('Security: PKI verified', 'Public Key:')
234
+ expect(painted.gsub(/\s+/, '')).to include(key.unpack1('H*'))
235
+ expect(colors).to include(Curses.color_pair(20) | Curses::A_BOLD)
236
+ expect(rcvr.lines.join).to include('RECEIVER', 'Node ID: LongFast', 'Short Name: —', 'Device Role: —')
237
+ expect(rcvr.lines.join).not_to include('VIA', 'PEER', 'broadcast')
238
+ end
239
+
240
+ it 'does not refresh MSG DETAILS from the radio position cache' do
241
+ refreshes = 0
242
+ allow(details).to receive(:refresh) { refreshes += 1 }
243
+ 20.times do |index|
244
+ described_class.send(:mesh_store_position, id: format('!%08x', index), position: { latitude_i: 10_000_000, longitude_i: -20_000_000, location_source: :LOC_INTERNAL })
245
+ end
246
+ expect(refreshes).to eq(0)
247
+ end
248
+
249
+ it 'does not warn when reading a curses mouse event' do
250
+ allow(Curses).to receive(:getmouse) do
251
+ warn 'undefining the allocator of T_DATA class Curses::MouseEvent'
252
+ nil
253
+ end
254
+ expect { described_class.send(:mesh_open_gps_click) }.not_to output(/MouseEvent/).to_stderr
113
255
  end
114
256
 
115
257
  it 'treats unset 0,0 as unknown and an explicit source at 0,0 as a real fix' do
@@ -46,4 +46,47 @@ describe PWN::Plugins::REPL, 'mesh conversation scroll' do
46
46
  described_class.send(:mesh_scroll_conversation, key: :down)
47
47
  expect(PWN::MeshRxState[:scroll]).to be_nil
48
48
  end
49
+
50
+ it 'moves between messages that already fit in the pane' do
51
+ stub_const('PWN::MeshRxState', {
52
+ lines: [
53
+ { text: 'one', color: 21, bold: true, message: 0 },
54
+ { text: 'two', color: 21, bold: true, message: 1 }
55
+ ],
56
+ detail_rows: [
57
+ { index: 0, from_id: '!00000001', to_id: '!ffffffff', received: true },
58
+ { index: 1, from_id: '!00000002', to_id: '!ffffffff', received: true }
59
+ ],
60
+ focus: :conversation
61
+ })
62
+ allow(PWN::MeshRxBodyWin).to receive(:maxy).and_return(20)
63
+ described_class.send(:mesh_scroll_conversation, key: :up)
64
+ expect(PWN::MeshRxState[:selected]).to eq(0)
65
+ expect(described_class.send(:mesh_highlighted_message)[:from_id]).to eq('!00000001')
66
+ described_class.send(:mesh_scroll_conversation, key: :down)
67
+ expect(PWN::MeshRxState[:selected]).to be_nil
68
+ expect(described_class.send(:mesh_highlighted_message)[:from_id]).to eq('!00000002')
69
+ described_class.send(:mesh_scroll_conversation, key: :home)
70
+ expect(PWN::MeshRxState[:selected]).to eq(0)
71
+ described_class.send(:mesh_scroll_conversation, key: :page_down)
72
+ expect(PWN::MeshRxState[:selected]).to be_nil
73
+ described_class.send(:mesh_scroll_conversation, key: :page_up)
74
+ expect(PWN::MeshRxState[:selected]).to eq(0)
75
+ described_class.send(:mesh_scroll_conversation, key: :end)
76
+ expect(PWN::MeshRxState[:selected]).to be_nil
77
+ end
78
+
79
+ it 'cycles Ctrl+T through compose, conversation, sndr, and rcvr' do
80
+ stub_const('PWN::MeshSndrWin', double('sndr', setpos: nil, addstr: nil, attron: nil, attroff: nil, refresh: nil, maxx: 32, erase: nil, maxy: 6))
81
+ stub_const('PWN::MeshRcvrWin', double('rcvr', setpos: nil, addstr: nil, attron: nil, attroff: nil, refresh: nil, maxx: 32, erase: nil, maxy: 6))
82
+ allow(described_class).to receive(:mesh_paint_details)
83
+ described_class.send(:mesh_toggle_focus)
84
+ expect(described_class.send(:mesh_focus)).to eq(:conversation)
85
+ described_class.send(:mesh_toggle_focus)
86
+ expect(described_class.send(:mesh_focus)).to eq(:sndr)
87
+ described_class.send(:mesh_toggle_focus)
88
+ expect(described_class.send(:mesh_focus)).to eq(:rcvr)
89
+ described_class.send(:mesh_toggle_focus)
90
+ expect(described_class.send(:mesh_focus)).to eq(:compose)
91
+ end
49
92
  end
@@ -52,7 +52,8 @@ describe PWN::Plugins::REPL, 'mesh per-message transport labels' do
52
52
  obj[:proto_data].last[:node_info][:user][:short_name] = 'LATE'
53
53
  described_class.send(:mesh_drain_events)
54
54
 
55
- expect(win).to have_received(:addstr).with(" 2026-01-02 03:04:05+0000 🔍 · PEER · US/CA/2/e/LongFast/!bbccddee · US/CA/2/e/LongFast/# · RX via #{label} · GPS N/A\n")
55
+ expect(win).to have_received(:addstr).with(" 2026-01-02 03:04:05+0000 🔍 · PEER · US/CA/2/e/LongFast/!bbccddee · US/CA/2/e/LongFast/# · RX via #{label}\n")
56
+ expect(described_class.send(:mesh_details_lines, message: PWN::MeshRxState[:detail_rows].last, width: 28).values.flatten.map { |line| line[:text] }.join("\n")).not_to include('VIA')
56
57
  expect(win).to have_received(:attron).with(21 | Curses::A_BOLD)
57
58
  expect(win).to have_received(:addstr).with(" hello\n")
58
59
  expect(message).not_to have_key(:mesh_transport)
@@ -75,7 +76,8 @@ describe PWN::Plugins::REPL, 'mesh per-message transport labels' do
75
76
  end
76
77
  described_class.send(:mesh_send_text, env: env, obj: obj, text: 'sent', channel_name: 'LongFast', channel: 0)
77
78
 
78
- expect(win).to have_received(:addstr).with(" 2026-01-02 03:04:05+0000 🔍 · SELF · US/CA/2/e/LongFast/!aabbccdd (ME) · US/CA/2/e/LongFast/# · TX via #{label} · GPS N/A\n")
79
+ expect(win).to have_received(:addstr).with(" 2026-01-02 03:04:05+0000 🔍 · SELF · US/CA/2/e/LongFast/!aabbccdd (ME) · US/CA/2/e/LongFast/# · TX via #{label}\n")
80
+ expect(described_class.send(:mesh_details_lines, message: PWN::MeshRxState[:detail_rows].last, width: 28).values.flatten.map { |line| line[:text] }.join("\n")).not_to include('VIA')
79
81
  expect(win).to have_received(:attron).with(23 | Curses::A_BOLD)
80
82
  expect(described_class).not_to have_received(:mesh_maybe_dispatch_to_pwn_ai)
81
83
  end
@@ -101,7 +103,7 @@ describe PWN::Plugins::REPL, 'mesh per-message transport labels' do
101
103
  described_class.send(:mesh_handle_rx, msg: message.merge(mesh_transport: :tcp))
102
104
  reply = message[:packet].merge(id: 124, decoded: { portnum: 1, reply_id: 123, payload: '@ai reply' })
103
105
  described_class.send(:mesh_handle_rx, msg: { packet: reply, mesh_transport: :tcp })
104
- expect(win).to have_received(:addstr).with(a_string_matching(%r{🔒 · PEER · .*!bbccddee.*!aabbccdd \(ME\) · RX via TCP · GPS N/A\n})).twice
106
+ expect(win).to have_received(:addstr).with(a_string_matching(/🔒 · PEER · .*!bbccddee.*!aabbccdd \(ME\) · RX via TCP\n/)).twice
105
107
  expect(win).to have_received(:addstr).with(" Replied to: \"!bbccddee >> hello\" with: @ai reply\n")
106
108
  expect(described_class).to have_received(:mesh_maybe_dispatch_to_pwn_ai).with(hash_including(text: '@ai reply', radio: 0))
107
109
  end
@@ -109,7 +111,8 @@ describe PWN::Plugins::REPL, 'mesh per-message transport labels' do
109
111
  it 'does not invent a known transport for an untagged event' do
110
112
  stub_const('PWN::MeshTransport', :mqtt)
111
113
  described_class.send(:mesh_handle_rx, msg: message)
112
- expect(win).to have_received(:addstr).with(a_string_ending_with(" · RX via Unknown · GPS N/A\n"))
114
+ expect(win).to have_received(:addstr).with(a_string_including('RX via Unknown'))
115
+ expect(described_class.send(:mesh_details_lines, message: PWN::MeshRxState[:detail_rows].last, width: 28).values.flatten.map { |line| line[:text] }.join("\n")).not_to include('VIA')
113
116
  end
114
117
 
115
118
  it 'learns MQTT sender names from real NODEINFO envelopes, not gateway or destination names' do
@@ -140,7 +143,7 @@ describe PWN::Plugins::REPL, 'mesh per-message transport labels' do
140
143
  it "omits absent/blank short names without substituting the long name: #{name.inspect}" do
141
144
  obj[:proto_data].each { |row| row[:node_info][:user][:short_name] = name }
142
145
  described_class.send(:mesh_handle_rx, msg: message.merge(mesh_transport: :serial))
143
- expect(win).to have_received(:addstr).with(" 2026-01-02 03:04:05+0000 🔍 · US/CA/2/e/LongFast/!bbccddee · US/CA/2/e/LongFast/# · RX via Serial · GPS N/A\n")
146
+ expect(win).to have_received(:addstr).with(" 2026-01-02 03:04:05+0000 🔍 · US/CA/2/e/LongFast/!bbccddee · US/CA/2/e/LongFast/# · RX via Serial\n")
144
147
  end
145
148
  end
146
149
 
@@ -188,9 +191,10 @@ describe PWN::Plugins::REPL, 'mesh per-message transport labels' do
188
191
  allow(win).to receive(:addstr) { |text| lines << text }
189
192
  described_class.send(:mesh_handle_rx, msg: message.merge(mesh_transport: :serial))
190
193
  header_lines = lines.take_while { |text| !text.include?('hello') }.map(&:strip).reject(&:empty?)
191
- expect(header_lines.join(' · ').split(' · ').size).to eq(6)
194
+ expect(header_lines.join(' · ').split(' · ').size).to eq(5)
192
195
  expect(header_lines.join("\n")).not_to match(/ ·|· /)
193
- expect(header_lines.join("\n")).to include('US/CA/2/e/LongFast/!bbccddee', 'US/CA/2/e/LongFast/#', 'RX via Serial', 'GPS N/A')
196
+ expect(header_lines.join("\n")).to include('US/CA/2/e/LongFast/!bbccddee', 'US/CA/2/e/LongFast/#', 'RX via Serial')
197
+ expect(described_class.send(:mesh_details_lines, message: PWN::MeshRxState[:detail_rows].last, width: 28).values.flatten.map { |line| line[:text] }.join("\n")).not_to include('VIA')
194
198
  end
195
199
  end
196
200