데스크톱 T3 잔여 완성 + 스타일 폴리시: 도메인 라우팅(WorkspaceRoutingRules 자동 워크스페이스 전환), 탭 아카이브(TabArchive 영속·팔레트 복구), 페이지 액션(흑백/세피아 필터), 노트 패널(NotesStore+플라이아웃), 컴팩트 모드(크롬 숨김+엣지 호버). 스타일: 한국어 폰트 폴백, 암시 폰트 통일, 다크 툴팁, 텍스트 대비 상향. UpdateNavState dispose 가드. 164/164 GREEN + 실창 스크린샷 검증

This commit is contained in:
Yun Chan 2026-08-16 08:46:54 +09:00
parent 04ff0ff93d
commit f380518919
12 changed files with 594 additions and 24 deletions

View file

@ -58,7 +58,7 @@
| 항목 | 구현 | 증거 | | 항목 | 구현 | 증거 |
|---|---|---| |---|---|---|
| T2-10 미디어 재생목록 | ✅ 감지 미디어 자동 적재, 플라이아웃, 플레이어 창(native+hls.js+dash.js), 자동 다음, JSON 영속 | `MediaPlaylist.cs` · `PlayerWindow.cs` + 테스트 | | T2-10 미디어 재생목록 | ✅ 감지 미디어 자동 적재, 플라이아웃, 플레이어 창(native+hls.js+dash.js), 자동 다음, JSON 영속 | `MediaPlaylist.cs` · `PlayerWindow.cs` + 테스트 |
| T3-1 워크스페이스 | ✅ 저장/전환 | `TabManagerWorkspaceExtensions` + 테스트 | | T3-1 워크스페이스 + 도메인 라우팅 | ✅ 저장/전환 + WorkspaceRoutingRules(도메인→워크스페이스, 주소창 이동 시 자동 전환, 팔레트로 규칙 등록) | `TabManagerWorkspaceExtensions` + `DesktopRemainderTests` 라우팅 2건(www/서브도메인 매칭·영속) |
| T3-3 컨테이너/InPrivate | ✅ IsInPrivateModeEnabled 탭 | `OpenInPrivateTab` | | T3-3 컨테이너/InPrivate | ✅ IsInPrivateModeEnabled 탭 | `OpenInPrivateTab` |
| T3-4 빠른 찾기 `/` | ✅ | `MainWindow` 단축키 | | T3-4 빠른 찾기 `/` | ✅ | `MainWindow` 단축키 |
| T3-5 컨텍스트 메뉴 전면 커스터마이징 | ✅ WPF 메뉴 + SelectedCommandId | `TabViewService` | | T3-5 컨텍스트 메뉴 전면 커스터마이징 | ✅ WPF 메뉴 + SelectedCommandId | `TabViewService` |
@ -75,11 +75,15 @@
| T3-14 추적 방지 카운터 | ✅ TrackerBlocklist(20개 도메인, 서브도메인 매칭) + WebResourceRequested 차단(204) + TrackerCounter + 실드 배지·도메인 팝업 | `TrackerBlockTests` 8건 + E2E(실제 WebView2 에서 2개 스크립트 차단·집계, 정상 도메인 통과) | | T3-14 추적 방지 카운터 | ✅ TrackerBlocklist(20개 도메인, 서브도메인 매칭) + WebResourceRequested 차단(204) + TrackerCounter + 실드 배지·도메인 팝업 | `TrackerBlockTests` 8건 + E2E(실제 WebView2 에서 2개 스크립트 차단·집계, 정상 도메인 통과) |
| T3-15 주소창 진행 바 + 파라미터 제거 복사 | ✅ NavProgressTracker(전 탭 집계 INPC) → 주소창 하단 인디케이터 + 주소창 우클릭 "파라미터 제거 후 복사"(UrlCleaner) | `UrlCleanerTests`/`NavProgressTrackerTests` 13건 | | T3-15 주소창 진행 바 + 파라미터 제거 복사 | ✅ NavProgressTracker(전 탭 집계 INPC) → 주소창 하단 인디케이터 + 주소창 우클릭 "파라미터 제거 후 복사"(UrlCleaner) | `UrlCleanerTests`/`NavProgressTrackerTests` 13건 |
### T3 중 명시적 미구현 (계획 내 후순위/선택) ### 3차 완성 (T3 잔여 + 스타일)
- T3-2 Auto-Archive 캐던스 — 닫힘 스택(25개)+복원이 커버(아카이브 자동화는 미차용) | 항목 | 구현 | 증거 |
- T3-12 노트 패널 — 계획 자체가 "후순위" 명시 |---|---|---|
- T3-13 컴팩트 모드 — F11 전체화면이 동등 커버 | T3-2 탭 아카이브 | ✅ TabArchive(닫힘 이력 JSON 영속, 500 상한, 연속 중복 제목 갱신, 검색) + 탭 닫힘 배선 + 팔레트 "아카이브에서 열기" | `DesktopRemainderTests` 3건 + 스모크 E2E(URL 탭 닫음 → 아카이브 기록 실증) |
| T3-6 페이지 액션 | ✅ BoostSettings.PageFilter(흑백/세피아) → CSS 필터 주입, 팔레트 명령 | `DesktopRemainderTests` 3건(RED→GREEN) + 스모크 E2E(로컬 HTTP 문서에서 적용→부스트 저장 실증) |
| T3-12 노트 패널 | ✅ NotesStore(추가·검색·삭제·영속) + 노트 플라이아웃(Ctrl+Alt+N) + "현재 페이지 추가" | `DesktopRemainderTests` 2건 + 스모크 E2E + 실창 스크린샷 |
| T3-13 컴팩트 모드 | ✅ Ctrl+Shift+M 크롬 자동 숨김 + 상단 엣지(4px) 호버 복원 | 스모크 E2E(행 높이 0→복원) + 실창 스크린샷 |
| 스타일 폴리시 | ✅ 폰트 폴백(한국어 맑은 고딕 포함) + 암시 TextBlock 폰트 통일 + 다크 ToolTip + 텍스트 계층 대비 상향(Secondary/Muted/Border) + 상태바 가독성 + 다이얼 페이지 타이포 | 실창 스크린샷 검증(다이얼/노트/컴팩트) |
### 재구성 (사용자 지시 "근원부터 뜯어고쳐") ### 재구성 (사용자 지시 "근원부터 뜯어고쳐")

View file

@ -48,8 +48,11 @@ public sealed class CommandPalette
public void Execute(PaletteCommand command) => command.Execute(); public void Execute(PaletteCommand command) => command.Execute();
} }
/// <summary>사이트별 부스트 설정(T2-7, Zen/Arc Boosts 패턴).</summary> /// <summary>페이지 액션 필터(T3-6, Vivaldi Page Actions — Boost 통합).</summary>
public sealed record BoostSettings(bool ForceDark, string CustomCss); public enum PageFilter { None, Grayscale, Sepia }
/// <summary>사이트별 부스트 설정(T2-7, Zen/Arc Boosts 패턴) + 페이지 액션 필터(T3-6).</summary>
public sealed record BoostSettings(bool ForceDark, string CustomCss, PageFilter Filter = PageFilter.None);
/// <summary>도메인별 부스트 영속 저장.</summary> /// <summary>도메인별 부스트 영속 저장.</summary>
public sealed class BoostStore public sealed class BoostStore
@ -77,11 +80,11 @@ public sealed class BoostStore
return host != null && _map.TryGetValue(host, out var v) ? v : new BoostSettings(false, ""); return host != null && _map.TryGetValue(host, out var v) ? v : new BoostSettings(false, "");
} }
public void Set(string? url, bool forceDark, string css) public void Set(string? url, bool forceDark, string css, PageFilter filter = PageFilter.None)
{ {
var host = Normalize(url); var host = Normalize(url);
if (host == null) return; if (host == null) return;
_map[host] = new BoostSettings(forceDark, css); _map[host] = new BoostSettings(forceDark, css, filter);
try try
{ {
Directory.CreateDirectory(Path.GetDirectoryName(_path)!); Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
@ -110,7 +113,7 @@ public static class BoostCssBuilder
{ {
public static string BuildScript(string? url, BoostSettings settings) public static string BuildScript(string? url, BoostSettings settings)
{ {
if (!settings.ForceDark && string.IsNullOrWhiteSpace(settings.CustomCss)) return ""; if (!settings.ForceDark && settings.Filter == PageFilter.None && string.IsNullOrWhiteSpace(settings.CustomCss)) return "";
var css = ""; var css = "";
if (settings.ForceDark) if (settings.ForceDark)
{ {
@ -119,6 +122,14 @@ html { filter: invert(1) hue-rotate(180deg); background: #fff; }
img, video, picture, canvas, svg, iframe, embed, object { filter: invert(1) hue-rotate(180deg); } img, video, picture, canvas, svg, iframe, embed, object { filter: invert(1) hue-rotate(180deg); }
"""; """;
} }
else if (settings.Filter == PageFilter.Grayscale) // T3-6 페이지 액션
{
css += "html { filter: grayscale(1); }";
}
else if (settings.Filter == PageFilter.Sepia)
{
css += "html { filter: sepia(0.7); }";
}
if (!string.IsNullOrWhiteSpace(settings.CustomCss)) css += settings.CustomCss + "\n"; if (!string.IsNullOrWhiteSpace(settings.CustomCss)) css += settings.CustomCss + "\n";
var js = $$""" var js = $$"""
(function(){ try { var s = document.createElement('style'); s.id = 'vd-boost'; s.textContent = {{JsonSerializer.Serialize(css)}}; (document.head || document.documentElement).appendChild(s); } catch(e){} })(); (function(){ try { var s = document.createElement('style'); s.id = 'vd-boost'; s.textContent = {{JsonSerializer.Serialize(css)}}; (document.head || document.documentElement).appendChild(s); } catch(e){} })();

View file

@ -153,7 +153,7 @@
</DataTemplate> </DataTemplate>
</Window.Resources> </Window.Resources>
<Grid> <Grid x:Name="MainGrid">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
@ -459,7 +459,7 @@
<!-- ===== 상태 표시줄 ===== --> <!-- ===== 상태 표시줄 ===== -->
<Border x:Name="StatusBar" Grid.Row="4" Background="{StaticResource PanelBg}" <Border x:Name="StatusBar" Grid.Row="4" Background="{StaticResource PanelBg}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0" Padding="16,5"> BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0" Padding="16,5">
<TextBlock Name="StatusText" Foreground="{StaticResource TextMuted}" FontSize="11" <TextBlock Name="StatusText" Foreground="{StaticResource TextSecondary}" FontSize="11.5"
TextTrimming="CharacterEllipsis" Text="준비"/> TextTrimming="CharacterEllipsis" Text="준비"/>
</Border> </Border>
@ -550,6 +550,26 @@
</Popup> </Popup>
<!-- ===== 다운로드 플라이아웃 (T1-5) ===== --> <!-- ===== 다운로드 플라이아웃 (T1-5) ===== -->
<!-- T3-12 노트 패널 -->
<Popup Name="NotesPopup" PlacementTarget="{Binding ElementName=AddressBox}"
Placement="Bottom" HorizontalOffset="-40" VerticalOffset="6"
StaysOpen="False" AllowsTransparency="True" PopupAnimation="Fade">
<Border Background="{StaticResource CardBg}" BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1" CornerRadius="12" Padding="10" Width="420" MaxHeight="460">
<StackPanel>
<DockPanel Margin="6,2,6,8">
<TextBlock Text="노트" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold" VerticalAlignment="Center"/>
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Style="{StaticResource CardActionButton}" Content="현재 페이지 추가" Click="NotesAddPage_Click" Margin="0,0,6,0"/>
<Button Style="{StaticResource CardActionButton}" Content="삭제" Click="NotesDelete_Click"/>
</StackPanel>
</DockPanel>
<ListBox Name="NotesList" Background="Transparent" BorderThickness="0" Height="340"
Foreground="{StaticResource TextPrimary}" FontSize="12"/>
</StackPanel>
</Border>
</Popup>
<!-- T3-14 추적 방지 실드 팝업 --> <!-- T3-14 추적 방지 실드 팝업 -->
<Popup Name="ShieldPopup" PlacementTarget="{Binding ElementName=ShieldBtn}" <Popup Name="ShieldPopup" PlacementTarget="{Binding ElementName=ShieldBtn}"
Placement="Bottom" HorizontalOffset="-10" VerticalOffset="6" Placement="Bottom" HorizontalOffset="-10" VerticalOffset="6"

View file

@ -50,6 +50,15 @@ public partial class MainWindow : Window
// Phase C: 팔레트/부스트/워크스페이스/레이아웃 // Phase C: 팔레트/부스트/워크스페이스/레이아웃
private readonly CommandPalette _palette = new(); private readonly CommandPalette _palette = new();
private BoostStore _boostStore = null!; private BoostStore _boostStore = null!;
private WorkspaceRoutingRules _routingRules = null!; // T3-1
private TabArchive _tabArchive = null!; // T3-2
internal TabArchive Archive => _tabArchive; // E2E 검증용
internal BoostStore BoostStore => _boostStore; // E2E 검증용
internal NotesStore Notes => _notes; // E2E 검증용
private NotesStore _notes = null!; // T3-12
private string? _currentWorkspaceName; // T3-1 라우팅 기준
private bool _compactMode; // T3-13
private bool _chromeRevealed; // T3-13 호버 복원 상태
private List<WorkspaceSnapshot> _workspaces = new(); private List<WorkspaceSnapshot> _workspaces = new();
private bool _verticalTabs; private bool _verticalTabs;
private TabItem? _splitTab; private TabItem? _splitTab;
@ -101,6 +110,9 @@ public partial class MainWindow : Window
_resolver = new SearchEngineResolver(_config.SearchEngine); _resolver = new SearchEngineResolver(_config.SearchEngine);
_zoomStore = new SiteZoomStore(Path.Combine(AppDataDir, "sitezoom.json")); _zoomStore = new SiteZoomStore(Path.Combine(AppDataDir, "sitezoom.json"));
_boostStore = new BoostStore(Path.Combine(AppDataDir, "boost.json")); _boostStore = new BoostStore(Path.Combine(AppDataDir, "boost.json"));
_routingRules = new WorkspaceRoutingRules(Path.Combine(AppDataDir, "routing.json")); // T3-1
_tabArchive = new TabArchive(Path.Combine(AppDataDir, "archive.json")); // T3-2
_notes = new NotesStore(Path.Combine(AppDataDir, "notes.json")); // T3-12
try try
{ {
var wsPath = Path.Combine(AppDataDir, "workspaces.json"); var wsPath = Path.Combine(AppDataDir, "workspaces.json");
@ -434,16 +446,22 @@ public partial class MainWindow : Window
private void OnTabRemoved(TabItem tab) private void OnTabRemoved(TabItem tab)
{ {
if (ReferenceEquals(_splitTab, tab)) ExitSplit(); if (ReferenceEquals(_splitTab, tab)) ExitSplit();
_tabArchive.Add(tab.Title, tab.Url); // T3-2 닫힘 → 아카이브(영속, 500 상한)
_views?.RemoveView(tab); _views?.RemoveView(tab);
} }
private void UpdateNavState() private void UpdateNavState()
{ {
var cv = Cv;
Dispatcher.BeginInvoke(new Action(() => Dispatcher.BeginInvoke(new Action(() =>
{ {
NavBack.IsEnabled = cv?.CanGoBack == true; try
NavForward.IsEnabled = cv?.CanGoForward == true; {
var cv = Cv; // dispose 이후 이벤트 경로 방어
NavBack.IsEnabled = cv?.CanGoBack == true;
NavForward.IsEnabled = cv?.CanGoForward == true;
}
catch (ObjectDisposedException) { }
catch (InvalidOperationException) { }
})); }));
} }
@ -717,6 +735,16 @@ public partial class MainWindow : Window
// T1-4: URL/검색어 판별 + 기본 엔진·키워드 전환(g, yt, ...)은 리졸버가 담당 // T1-4: URL/검색어 판별 + 기본 엔진·키워드 전환(g, yt, ...)은 리졸버가 담당
var intent = _resolver.Resolve(url); var intent = _resolver.Resolve(url);
if (intent == null) return; if (intent == null) return;
// T3-1: 도메인 라우팅 규칙 — 대상 워크스페이스가 다르면 자동 전환 후 이동(Arc ATC 패턴)
var routeTo = _routingRules.Match(intent.Target);
if (routeTo != null && routeTo != _currentWorkspaceName &&
_workspaces.FirstOrDefault(w => w.Name == routeTo) is { } wsTarget)
{
SwitchWorkspace(wsTarget);
_currentWorkspaceName = routeTo;
Log($"라우팅 규칙 → 워크스페이스 '{routeTo}' 전환");
}
Cv?.Navigate(intent.Target); Cv?.Navigate(intent.Target);
} }
@ -778,6 +806,8 @@ public partial class MainWindow : Window
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.D) { ToggleBoostDark(); e.Handled = true; } else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.D) { ToggleBoostDark(); e.Handled = true; }
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.S) { _ = TakeScreenshot(); e.Handled = true; } else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.S) { _ = TakeScreenshot(); e.Handled = true; }
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.N) { OpenInPrivateTab(); e.Handled = true; } else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.N) { OpenInPrivateTab(); e.Handled = true; }
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.M) { ToggleCompactMode(); e.Handled = true; } // T3-13
else if (mods == (ModifierKeys.Control | ModifierKeys.Alt) && e.Key == Key.N) { ToggleNotes(); e.Handled = true; } // T3-12
else if (mods == (ModifierKeys.Alt | ModifierKeys.Control) && e.Key == Key.V) { ToggleVerticalTabs(); e.Handled = true; } else if (mods == (ModifierKeys.Alt | ModifierKeys.Control) && e.Key == Key.V) { ToggleVerticalTabs(); e.Handled = true; }
else if (mods == (ModifierKeys.Alt | ModifierKeys.Control) && e.Key == Key.H) { ToggleSplit(); e.Handled = true; } else if (mods == (ModifierKeys.Alt | ModifierKeys.Control) && e.Key == Key.H) { ToggleSplit(); e.Handled = true; }
else if (mods == ModifierKeys.Control && e.Key == Key.U) { ViewSource(); e.Handled = true; } else if (mods == ModifierKeys.Control && e.Key == Key.U) { ViewSource(); e.Handled = true; }
@ -1243,6 +1273,20 @@ public partial class MainWindow : Window
_palette.Register("미디어 재생목록", "playlist play media 음악 영상", () => { PlaylistPopup.IsOpen = true; }); _palette.Register("미디어 재생목록", "playlist play media 음악 영상", () => { PlaylistPopup.IsOpen = true; });
_palette.Register("재생목록 재생", "playlist play start", OpenPlayer); _palette.Register("재생목록 재생", "playlist play start", OpenPlayer);
_palette.Register("기기 연결 (QR 페어링)", "remote pair qr mobile phone", ShowPairing); _palette.Register("기기 연결 (QR 페어링)", "remote pair qr mobile phone", ShowPairing);
// ---- T3 잔여(데스크톱) ----
_palette.Register("이 사이트 흑백", "grayscale filter page action 흑백", () => ApplyPageFilter(PageFilter.Grayscale)); // T3-6
_palette.Register("이 사이트 세피아", "sepia filter page action 세피아", () => ApplyPageFilter(PageFilter.Sepia));
_palette.Register("페이지 액션 끄기", "filter off reset", () => ApplyPageFilter(PageFilter.None));
_palette.Register("노트 패널", "notes note 메모", ToggleNotes); // T3-12
_palette.Register("노트: 현재 페이지 추가", "note add", () => NotesAddPage_Click(this, new RoutedEventArgs()));
_palette.Register("컴팩트 모드 토글", "compact chrome hide ui", ToggleCompactMode); // T3-13
_palette.RegisterDynamic(() => _workspaces // T3-1: 사이트→워크스페이스 규칙
.Select(w => new PaletteCommand($"라우팅 규칙: 이 사이트 → '{w.Name}'", "routing rule workspace",
() => AddRoutingRuleForCurrentSite(w.Name))).ToList());
_palette.RegisterDynamic(() => _tabArchive.Search("") // T3-2: 아카이브 복구
.Take(8)
.Select(a => new PaletteCommand($"아카이브에서 열기: {a.Title}", $"archive reopen {a.Url}",
() => { var t = _tabs.NewTab(); Cv2Navigate(t, a.Url); })).ToList());
_palette.Register("원격 접근 켜기/끄기", "remote server toggle lan", async () => await ToggleRemoteAccess()); _palette.Register("원격 접근 켜기/끄기", "remote server toggle lan", async () => await ToggleRemoteAccess());
_palette.RegisterSeparator(); _palette.RegisterSeparator();
_palette.RegisterDynamic(() => _tabs.Tabs _palette.RegisterDynamic(() => _tabs.Tabs
@ -1304,6 +1348,112 @@ public partial class MainWindow : Window
Log(!cur.ForceDark ? "이 사이트 강제 다크 켜짐" : "이 사이트 강제 다크 꺼짐"); Log(!cur.ForceDark ? "이 사이트 강제 다크 켜짐" : "이 사이트 강제 다크 꺼짐");
} }
// ---- T3-6 페이지 액션(흑백/세피아 — Boost 통합) ----
internal void ApplyPageFilter(PageFilter filter)
{
var url = Cv?.Source;
if (string.IsNullOrEmpty(url)) { Log("페이지가 없습니다"); return; }
var cur = _boostStore.Get(url);
_boostStore.Set(url, cur.ForceDark, cur.CustomCss, filter);
_views?.ApplyBoostNow(_tabs.ActiveTab!);
Log(filter switch
{
PageFilter.Grayscale => "이 사이트 흑백 적용",
PageFilter.Sepia => "이 사이트 세피아 적용",
_ => "페이지 액션 해제",
});
}
// ---- T3-1 도메인 라우팅 규칙 ----
private void AddRoutingRuleForCurrentSite(string workspaceName)
{
var url = Cv?.Source;
if (string.IsNullOrWhiteSpace(url) || !Uri.TryCreate(url, UriKind.Absolute, out var u))
{ Log("페이지가 없습니다"); return; }
var host = u.Host;
if (host.StartsWith("www.", StringComparison.OrdinalIgnoreCase)) host = host[4..];
_routingRules.Add(host, workspaceName);
Log($"라우팅 규칙 추가: {host} → '{workspaceName}' (다음 이동부터 자동 전환)");
}
// ---- T3-2 아카이브 복구 보조 ----
private static void Cv2Navigate(TabItem tab, string url)
{
try { tab.View?.CoreWebView2?.Navigate(url); } catch { }
}
// ---- T3-12 노트 패널 ----
internal void ToggleNotes()
{
RefreshNotesList();
NotesPopup.IsOpen = true;
}
private void RefreshNotesList()
{
NotesList.ItemsSource = _notes.List()
.Select(n => $"[{n.CreatedAt:MM-dd HH:mm}] {n.Title} — {n.Body}")
.ToList();
NotesList.DisplayMemberPath = null;
}
internal void NotesAddPage_Click(object sender, RoutedEventArgs e)
{
var url = Cv?.Source ?? "";
var title = _tabs.ActiveTab?.Title ?? url;
_notes.Add(title, url);
Log("노트에 페이지 추가됨");
RefreshNotesList();
}
private void NotesDelete_Click(object sender, RoutedEventArgs e)
{
var idx = NotesList.SelectedIndex;
var list = _notes.List();
if (idx >= 0 && idx < list.Count)
{
_notes.Delete(list[idx].Id);
RefreshNotesList();
}
}
// ---- T3-13 컴팩트 모드(크롬 숨김 + 상단 엣지 호버 복원) ----
internal void ToggleCompactMode()
{
_compactMode = !_compactMode;
_chromeRevealed = false;
ApplyChromeVisibility();
Log(_compactMode ? "컴팩트 모드: 크롤 숨김 (상단 가장자리에 마우스를 올리면 복원)" : "컴팩트 모드 해제");
}
private void ApplyChromeVisibility()
{
var chromeRows = new[] { 0, 1, 2 }; // 툴바/북마크바/탭스트립
foreach (var i in chromeRows)
MainGrid.RowDefinitions[i].Height = (_compactMode && !_chromeRevealed) ? new GridLength(0) : GridLength.Auto;
if (!_compactMode) _chromeRevealed = false;
}
protected override void OnPreviewMouseMove(System.Windows.Input.MouseEventArgs e)
{
if (_compactMode)
{
var p = e.GetPosition(this);
var wantReveal = p.Y <= 4; // 상단 엣지 4px
if (wantReveal != _chromeRevealed)
{
_chromeRevealed = wantReveal;
ApplyChromeVisibility();
}
else if (_chromeRevealed && p.Y > 120) // 크롬 영역 벗어나면 다시 숨김
{
_chromeRevealed = false;
ApplyChromeVisibility();
}
}
base.OnPreviewMouseMove(e);
}
// ---- T2-9 스크린샷 ---- // ---- T2-9 스크린샷 ----
private async Task TakeScreenshot() private async Task TakeScreenshot()
{ {
@ -1490,7 +1640,7 @@ public partial class MainWindow : Window
if (cv == null) return; if (cv == null) return;
var tiles = _bookmarks.Take(18).Select(b => var tiles = _bookmarks.Take(18).Select(b =>
$"<a href='{System.Net.WebUtility.HtmlEncode(b.Url)}' style='display:block;text-decoration:none;background:#262736;color:#e6e6ec;border-radius:12px;padding:18px 12px;text-align:center;font-size:12.5px;min-width:110px' onmouseover='this.style.background=#2f3042' onmouseout='this.style.background=#262736'><div style='font-size:26px;margin-bottom:8px'>&#127760;</div><div style='overflow:hidden;text-overflow:ellipsis;white-space:nowrap'>{System.Net.WebUtility.HtmlEncode(b.Title)}</div></a>").ToList(); $"<a href='{System.Net.WebUtility.HtmlEncode(b.Url)}' style='display:block;text-decoration:none;background:#262736;color:#e6e6ec;border-radius:12px;padding:18px 12px;text-align:center;font-size:12.5px;min-width:110px' onmouseover='this.style.background=#2f3042' onmouseout='this.style.background=#262736'><div style='font-size:26px;margin-bottom:8px'>&#127760;</div><div style='overflow:hidden;text-overflow:ellipsis;white-space:nowrap'>{System.Net.WebUtility.HtmlEncode(b.Title)}</div></a>").ToList();
var dial = "<!DOCTYPE html><html><head><meta charset='utf-8'><style>body{background:#14151d;color:#e6e6ec;font-family:'Segoe UI',sans-serif;margin:0;padding:48px 24px}h1{font-size:20px;text-align:center;margin-bottom:28px;font-weight:600}.grid{display:flex;flex-wrap:wrap;gap:14px;justify-content:center;max-width:760px;margin:0 auto}.hint{text-align:center;color:#8a8aa0;font-size:12px;margin-top:36px}a{color:inherit}</style></head><body><h1>새 탭</h1><div class='grid'>" + string.Join("", tiles) + $"</div><div class='hint'>주소창에 검색어나 URL 을 입력하세요 · 즐겨찾기 {_bookmarks.Count}개 중 {tiles.Count}개 표시</div></body></html>"; var dial = "<!DOCTYPE html><html><head><meta charset='utf-8'><style>body{background:#14151d;color:#e6e6ec;font-family:'Segoe UI Variable Text','Segoe UI','Malgun Gothic',sans-serif;margin:0;padding:48px 24px}h1{font-size:20px;text-align:center;margin-bottom:28px;font-weight:600}.grid{display:flex;flex-wrap:wrap;gap:14px;justify-content:center;max-width:760px;margin:0 auto}.hint{text-align:center;color:#9aa0b4;font-size:12.5px;margin-top:36px}a{color:inherit}</style></head><body><h1>새 탭</h1><div class='grid'>" + string.Join("", tiles) + $"</div><div class='hint'>주소창에 검색어나 URL 을 입력하세요 · 즐겨찾기 {_bookmarks.Count}개 중 {tiles.Count}개 표시</div></body></html>";
cv.NavigateToString(dial); cv.NavigateToString(dial);
} }
@ -1575,6 +1725,7 @@ public partial class MainWindow : Window
{ {
ExitSplit(); ExitSplit();
_tabs.SwitchWorkspace(ws); _tabs.SwitchWorkspace(ws);
_currentWorkspaceName = ws.Name; // T3-1 라우팅 기준
Log($"워크스페이스 '{ws.Name}' 전환 ({ws.Urls.Count} 탭)"); Log($"워크스페이스 '{ws.Name}' 전환 ({ws.Urls.Count} 탭)");
} }

View file

@ -12,11 +12,11 @@
<SolidColorBrush x:Key="GlassBg" Color="#1A1A24"/> <SolidColorBrush x:Key="GlassBg" Color="#1A1A24"/>
<SolidColorBrush x:Key="CardBg" Color="#1C1C26"/> <SolidColorBrush x:Key="CardBg" Color="#1C1C26"/>
<SolidColorBrush x:Key="CardBgHover" Color="#242430"/> <SolidColorBrush x:Key="CardBgHover" Color="#242430"/>
<SolidColorBrush x:Key="BorderBrush" Color="#2C2C3A"/> <SolidColorBrush x:Key="BorderBrush" Color="#343442"/>
<SolidColorBrush x:Key="BorderSubtle" Color="#20202B"/> <SolidColorBrush x:Key="BorderSubtle" Color="#20202B"/>
<SolidColorBrush x:Key="TextPrimary" Color="#F2F2F6"/> <SolidColorBrush x:Key="TextPrimary" Color="#F2F2F6"/>
<SolidColorBrush x:Key="TextSecondary" Color="#9B9BAC"/> <SolidColorBrush x:Key="TextSecondary" Color="#A9B0C5"/>
<SolidColorBrush x:Key="TextMuted" Color="#62626E"/> <SolidColorBrush x:Key="TextMuted" Color="#7C8296"/>
<SolidColorBrush x:Key="TrackBrush" Color="#262631"/> <SolidColorBrush x:Key="TrackBrush" Color="#262631"/>
<SolidColorBrush x:Key="SuccessBrush" Color="#34D399"/> <SolidColorBrush x:Key="SuccessBrush" Color="#34D399"/>
<SolidColorBrush x:Key="ErrorBrush" Color="#F87171"/> <SolidColorBrush x:Key="ErrorBrush" Color="#F87171"/>

View file

@ -15,8 +15,8 @@
<SolidColorBrush x:Key="BorderBrush" Color="#D5D9E1"/> <SolidColorBrush x:Key="BorderBrush" Color="#D5D9E1"/>
<SolidColorBrush x:Key="BorderSubtle" Color="#E3E6EC"/> <SolidColorBrush x:Key="BorderSubtle" Color="#E3E6EC"/>
<SolidColorBrush x:Key="TextPrimary" Color="#1B1C24"/> <SolidColorBrush x:Key="TextPrimary" Color="#1B1C24"/>
<SolidColorBrush x:Key="TextSecondary" Color="#565A6A"/> <SolidColorBrush x:Key="TextSecondary" Color="#4A4E5E"/>
<SolidColorBrush x:Key="TextMuted" Color="#8A8E9C"/> <SolidColorBrush x:Key="TextMuted" Color="#767B8C"/>
<SolidColorBrush x:Key="TrackBrush" Color="#DBDFE6"/> <SolidColorBrush x:Key="TrackBrush" Color="#DBDFE6"/>
<SolidColorBrush x:Key="SuccessBrush" Color="#15A34A"/> <SolidColorBrush x:Key="SuccessBrush" Color="#15A34A"/>
<SolidColorBrush x:Key="ErrorBrush" Color="#DC2626"/> <SolidColorBrush x:Key="ErrorBrush" Color="#DC2626"/>

View file

@ -1,10 +1,23 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- 폰트 (테마 무관) --> <!-- 폰트 (테마 무관) — 한국어 폴백 포함(맑은 고딕) -->
<FontFamily x:Key="UiFont">Segoe UI Variable Text, Segoe UI</FontFamily> <FontFamily x:Key="UiFont">Segoe UI Variable Text, Segoe UI, Malgun Gothic</FontFamily>
<FontFamily x:Key="IconFont">Segoe Fluent Icons</FontFamily> <FontFamily x:Key="IconFont">Segoe Fluent Icons</FontFamily>
<!-- 암시 스타일: 모든 텍스트가 UI 폰트로 통일(Foreground 는 상속 존중) -->
<Style TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
</Style>
<Style TargetType="ToolTip">
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
<Setter Property="Background" Value="{StaticResource CardBg}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Padding" Value="10,6"/>
</Style>
<!-- ===== Primary 버튼 ===== --> <!-- ===== Primary 버튼 ===== -->
<Style x:Key="PrimaryButton" TargetType="Button"> <Style x:Key="PrimaryButton" TargetType="Button">
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/> <Setter Property="FontFamily" Value="{StaticResource UiFont}"/>

View file

@ -0,0 +1,63 @@
using System.Text.Json;
namespace VideoDownloader.Browser;
public sealed record NoteItem(string Id, string Title, string Body, DateTime CreatedAt);
/// <summary>T3-12: 노트 패널 저장소 — 마크다운 텍스트 노트 추가·검색·삭제, JSON 영속.</summary>
public sealed class NotesStore
{
private readonly string _path;
private readonly object _gate = new();
private List<NoteItem> _items;
public NotesStore(string path)
{
_path = path;
try
{
if (File.Exists(path))
_items = JsonSerializer.Deserialize<List<NoteItem>>(File.ReadAllText(path)) ?? new();
}
catch { }
_items ??= new List<NoteItem>();
}
public string Add(string title, string body)
{
var note = new NoteItem(Guid.NewGuid().ToString("N"), title, body, DateTime.Now);
lock (_gate)
{
_items.Insert(0, note);
try
{
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
File.WriteAllText(_path, JsonSerializer.Serialize(_items));
}
catch { }
}
return note.Id;
}
public List<NoteItem> List()
{
lock (_gate) return _items.ToList();
}
public List<NoteItem> Search(string query)
{
var q = query?.Trim() ?? "";
return List().Where(n => q.Length == 0 ||
n.Title.Contains(q, StringComparison.OrdinalIgnoreCase) ||
n.Body.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
}
public void Delete(string id)
{
lock (_gate)
{
_items.RemoveAll(n => n.Id == id);
try { File.WriteAllText(_path, JsonSerializer.Serialize(_items)); } catch { }
}
}
}

View file

@ -0,0 +1,63 @@
using System.Text.Json;
namespace VideoDownloader.Browser;
public sealed record ArchivedTab(string Title, string Url, DateTime ClosedAt);
/// <summary>T3-2: 탭 아카이브 — 닫힘 이력 영속(최대 500, 같은 URL 연속은 제목 갱신), 검색·복구 지원.</summary>
public sealed class TabArchive
{
private const int Cap = 500;
private readonly string _path;
private readonly object _gate = new();
private List<ArchivedTab> _items;
public TabArchive(string path)
{
_path = path;
try
{
if (File.Exists(path))
_items = JsonSerializer.Deserialize<List<ArchivedTab>>(File.ReadAllText(path)) ?? new();
}
catch { }
_items ??= new List<ArchivedTab>();
}
public void Add(string title, string url)
{
if (string.IsNullOrWhiteSpace(url)) return;
lock (_gate)
{
_items.RemoveAll(t => t.Url.Equals(url, StringComparison.OrdinalIgnoreCase));
_items.Insert(0, new ArchivedTab(title, url, DateTime.Now));
if (_items.Count > Cap) _items.RemoveRange(Cap, _items.Count - Cap);
try
{
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
File.WriteAllText(_path, JsonSerializer.Serialize(_items));
}
catch { }
}
}
public List<ArchivedTab> Search(string query)
{
lock (_gate)
{
var q = query?.Trim() ?? "";
return _items.Where(t => q.Length == 0 ||
t.Title.Contains(q, StringComparison.OrdinalIgnoreCase) ||
t.Url.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
}
}
public void Remove(string url)
{
lock (_gate)
{
_items.RemoveAll(t => t.Url.Equals(url, StringComparison.OrdinalIgnoreCase));
try { File.WriteAllText(_path, JsonSerializer.Serialize(_items)); } catch { }
}
}
}

View file

@ -0,0 +1,65 @@
using System.Text.Json;
namespace VideoDownloader.Browser;
/// <summary>T3-1: 도메인 → 워크스페이스 자동 라우팅 규칙(Vivaldi 규칙/Arc ATC).
/// 주소창 이동 시 매칭되면 해당 워크스페이스로 자동 전환한다. JSON 영속.</summary>
public sealed class WorkspaceRoutingRules
{
private readonly string _path;
private Dictionary<string, string> _map = new(StringComparer.OrdinalIgnoreCase);
public WorkspaceRoutingRules(string path)
{
_path = path;
try
{
if (File.Exists(path))
_map = JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(path)) ?? new();
}
catch { }
_map ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
public IReadOnlyDictionary<string, string> Rules => _map;
public void Add(string domain, string workspaceName)
{
var d = domain.Trim().TrimStart('.', 'w', 'w', 'w', '.');
_map[d] = workspaceName;
Save();
}
public void Remove(string domain)
{
_map.Remove(domain.Trim());
Save();
}
/// <summary>URL 이 매칭되는 워크스페이스명(없으면 null). www/서브도메인 허용.</summary>
public string? Match(string? url)
{
if (string.IsNullOrWhiteSpace(url)) return null;
if (!Uri.TryCreate(url, UriKind.Absolute, out var u) ||
(u.Scheme != Uri.UriSchemeHttp && u.Scheme != Uri.UriSchemeHttps)) return null;
var host = u.Host;
if (host.StartsWith("www.", StringComparison.OrdinalIgnoreCase)) host = host[4..];
foreach (var (domain, ws) in _map)
{
if (host.Equals(domain, StringComparison.OrdinalIgnoreCase) ||
host.EndsWith("." + domain, StringComparison.OrdinalIgnoreCase))
return ws;
}
return null;
}
private void Save()
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
File.WriteAllText(_path, JsonSerializer.Serialize(_map));
}
catch { }
}
}

View file

@ -0,0 +1,130 @@
using VideoDownloader.App.Browser.Features;
using VideoDownloader.Browser;
namespace VideoDownloader.Tests.Browser;
/// <summary>데스크톱 T3 잔여 — 워크스페이스 라우팅(T3-1), 탭 아카이브(T3-2), 노트(T3-12).</summary>
public class DesktopRemainderTests : IDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), $"vd_t3_{Guid.NewGuid():N}");
// ── T3-1: 도메인 → 워크스페이스 자동 라우팅 규칙 ──
[Fact]
public void 라우팅_규칙_도메인_매칭()
{
var rules = new WorkspaceRoutingRules(Path.Combine(_dir, "routing.json"));
rules.Add("youtube.com", "영상");
Assert.Equal("영상", rules.Match("https://www.youtube.com/watch?v=1")); // www 정규화
Assert.Equal("영상", rules.Match("https://m.youtube.com/watch?v=1")); // 서브도메인
Assert.Null(rules.Match("https://example.com")); // 무관 도메인
Assert.Null(rules.Match("about:blank")); // 비 HTTP
}
[Fact]
public void 라우팅_규칙_영속_및_제거()
{
var p = Path.Combine(_dir, "routing.json");
new WorkspaceRoutingRules(p).Add("x.com", "SNS");
var reloaded = new WorkspaceRoutingRules(p); // 재시작
Assert.Equal("SNS", reloaded.Match("https://x.com/home"));
reloaded.Remove("x.com");
Assert.Null(reloaded.Match("https://x.com/home"));
}
// ── T3-2: 탭 아카이브(닫힘 이력 영속·검색·중복 제거) ──
[Fact]
public void 아카이브_기록_검색_중복제거()
{
var arc = new TabArchive(Path.Combine(_dir, "archive.json"));
arc.Add("영상1", "https://a.com/1");
arc.Add("영상1 다시", "https://a.com/1"); // 같은 URL 연속 → 제목만 갱신
arc.Add("문서", "https://b.com/doc");
Assert.Single(arc.Search("a.com/1"));
Assert.Equal("영상1 다시", arc.Search("a.com/1")[0].Title);
Assert.Single(arc.Search("문서"));
Assert.Equal(2, arc.Search("").Count);
}
[Fact]
public void 아카이브_영속_삭제()
{
var p = Path.Combine(_dir, "archive.json");
var arc = new TabArchive(p);
arc.Add("t", "https://x.com");
var re = new TabArchive(p);
Assert.Single(re.Search("x.com"));
re.Remove("https://x.com");
Assert.Empty(new TabArchive(p).Search("x.com"));
}
[Fact]
public void 아카이브_최대_500_제한()
{
var arc = new TabArchive(Path.Combine(_dir, "archive.json"));
for (var i = 0; i < 520; i++) arc.Add($"t{i}", $"https://x.com/{i}");
Assert.Equal(500, arc.Search("").Count);
Assert.Equal("t519", arc.Search("")[0].Title); // 최신이 앞
}
// ── T3-12: 노트 저장소 ──
[Fact]
public void 노트_추가_목록_삭제_영속()
{
var p = Path.Combine(_dir, "notes.json");
var notes = new NotesStore(p);
var id = notes.Add("제목", "본문 내용");
Assert.Single(notes.List());
Assert.Equal(("제목", "본문 내용"), (notes.List()[0].Title, notes.List()[0].Body));
var re = new NotesStore(p); // 재시작
Assert.Single(re.List());
re.Delete(id);
Assert.Empty(new NotesStore(p).List());
}
[Fact]
public void 노트_검색()
{
var notes = new NotesStore(Path.Combine(_dir, "notes.json"));
notes.Add("유튜브 모음", "링크들");
notes.Add("회의록", "유튜브 언급됨");
Assert.Equal(2, notes.Search("유튜브").Count);
Assert.Single(notes.Search("회의"));
}
// ── T3-6: 페이지 액션 — 흑백/세피아 CSS 필터(Vivaldi Page Actions, Boost 통합) ──
[Fact]
public void 페이지액션_흑백_CSS_생성()
{
var js = VideoDownloader.App.Browser.Features.BoostCssBuilder.BuildScript(
"https://x.com", new VideoDownloader.App.Browser.Features.BoostSettings(false, "", PageFilter.Grayscale));
Assert.Contains("grayscale(1)", js);
Assert.DoesNotContain("invert(1)", js);
}
[Fact]
public void 페이지액션_세피아_CSS_생성()
{
var js = VideoDownloader.App.Browser.Features.BoostCssBuilder.BuildScript(
"https://x.com", new VideoDownloader.App.Browser.Features.BoostSettings(false, "", PageFilter.Sepia));
Assert.Contains("sepia(", js);
}
[Fact]
public void 페이지액션_미적용_시_빈_스크립트()
{
var js = VideoDownloader.App.Browser.Features.BoostCssBuilder.BuildScript(
"https://x.com", new VideoDownloader.App.Browser.Features.BoostSettings(false, "", PageFilter.None));
Assert.Equal("", js);
}
public void Dispose()
{
try { Directory.Delete(_dir, true); } catch { }
}
}

View file

@ -68,9 +68,53 @@ public class MainWindowSmokeE2ETests
window.ToggleFullscreen(); window.ToggleFullscreen();
var toolbarBack = window.ToolbarRow.Visibility == Visibility.Visible; var toolbarBack = window.ToolbarRow.Visibility == Visibility.Visible;
// ── T3-13 컴팩트 모드: 크롬 행 높이 0 → 해제 시 복원 ──
window.ToggleCompactMode();
var compactRowZero = window.MainGrid.RowDefinitions[0].Height.Value == 0;
window.ToggleCompactMode();
var compactRowBack = window.MainGrid.RowDefinitions[0].Height.IsAuto;
// ── T3-12 노트: 패널 열림 + 현재 페이지 추가 → 목록 1건 ──
window.ToggleNotes();
var notesOpen = window.NotesPopup.IsOpen;
window.NotesAddPage_Click(window, new RoutedEventArgs());
var notesCount = window.Notes.List().Count;
window.NotesPopup.IsOpen = false;
// ── T3-2 아카이브: 위에서 닫았던 탭이 영속 아카이브에 기록 ──
// ── T3-2 아카이브: URL 있는 탭을 닫으면 영속 아카이브에 기록 ──
var tab3 = window.Tabs.NewTab();
await Wait.Until(() => Task.FromResult(tab3.Core), c => c != null, 30000);
tab3.Url = "https://e2e.test/archived-page"; // URL 확정 상태 시뮬레이션(실제는 SourceChanged 가 설정)
window.Tabs.CloseTab(tab3);
await Task.Delay(800); // 진행 중이던 초기 네비게이션 이벤트가 dispose 이후 터지지 않게 여유
var archivedCount = window.Archive.Search("").Count;
var archivedHasUrl = window.Archive.Search("").Any(a => a.Url.Contains("e2e.test"));
// ── T3-6 페이지 액션: 실제 HTTP 문서에서 흑백 적용 → 부스트 저장 ──
var port = Random.Shared.Next(21000, 59000);
using var server = new System.Net.HttpListener();
server.Prefixes.Add($"http://127.0.0.1:{port}/");
server.Start();
var serveTask = Task.Run(async () =>
{
var ctx = await server.GetContextAsync();
var bytes = System.Text.Encoding.UTF8.GetBytes("<html><body><p>page-action-e2e</p></body></html>");
await ctx.Response.OutputStream.WriteAsync(bytes);
ctx.Response.Close();
});
window.Tabs.Activate(first);
cv1.Navigate($"http://127.0.0.1:{port}/");
await Wait.Until(() => Task.FromResult(cv1.Source ?? ""), s => s.Contains("127.0.0.1"), 15000);
await serveTask.WaitAsync(TimeSpan.FromSeconds(10));
window.ApplyPageFilter(VideoDownloader.App.Browser.Features.PageFilter.Grayscale);
var filterSaved = window.BoostStore.Get($"http://127.0.0.1:{port}/").Filter;
server.Stop();
window.Close(); window.Close();
return (init: true, switched, countAfterClose, reopened: reopened != null, countAfterReopen, return (init: true, switched, countAfterClose, reopened: reopened != null, countAfterReopen,
findVisible, findCollapsed, toolbarHidden, toolbarBack); findVisible, findCollapsed, toolbarHidden, toolbarBack,
compactRowZero, compactRowBack, notesOpen, notesCount, archivedCount, archivedHasUrl, filterSaved);
}); });
Assert.True(ok.init, "WebView2 초기화 실패"); Assert.True(ok.init, "WebView2 초기화 실패");
@ -82,6 +126,12 @@ public class MainWindowSmokeE2ETests
Assert.True(ok.findCollapsed, "검색 바 미닫힘"); Assert.True(ok.findCollapsed, "검색 바 미닫힘");
Assert.True(ok.toolbarHidden, "전체화면에서 크롬 미숨김"); Assert.True(ok.toolbarHidden, "전체화면에서 크롬 미숨김");
Assert.True(ok.toolbarBack, "전체화면 복원 실패"); Assert.True(ok.toolbarBack, "전체화면 복원 실패");
Assert.True(ok.compactRowZero, "컴팩트 모드에서 크롬 행이 숨겨지지 않음");
Assert.True(ok.compactRowBack, "컴팩트 모드 해제 후 크롬 행 미복원");
Assert.True(ok.notesOpen, "노트 패널 미열림");
Assert.True(ok.notesCount == 1, $"노트 추가 실패: {ok.notesCount}");
Assert.True(ok.archivedCount >= 1 && ok.archivedHasUrl, $"탭 아카이브 미기록: {ok.archivedCount}");
Assert.Equal(VideoDownloader.App.Browser.Features.PageFilter.Grayscale, ok.filterSaved);
} }
finally finally
{ {