M1 공유 타겟(U1)+M2 저장 진행 알림: ACTION_SEND 인텐트 필터+ShareTarget/SharePayload(RED 7건)+루트 타이머 큐 전송(am start 인텐트 E2E로 서버 큐 반영 실증), 오프라인 저장 Android ProgressBar 노티(5% 스로틀)→폰 저장 완료 전환(34MB E2E, Report(1) 되덮어쓰기 방지). 154/154 GREEN
This commit is contained in:
parent
69094595a5
commit
04ff0ff93d
7 changed files with 166 additions and 6 deletions
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> **대상**: `BROWSER_FEATURES_PLAN.md` (v2) + `MOBILE_PLAN.md` (v1) 전 항목
|
> **대상**: `BROWSER_FEATURES_PLAN.md` (v2) + `MOBILE_PLAN.md` (v1) 전 항목
|
||||||
> **감사일**: 2026-08-15 · **방법**: TDD(RED→GREEN) + E2E 검증
|
> **감사일**: 2026-08-15 · **방법**: TDD(RED→GREEN) + E2E 검증
|
||||||
> **요약**: 전 솔루션 Release 빌드 **0 오류**, 단위/E2E 테스트 **147/147 GREEN**, Android 에뮬레이터·iOS 시뮬레이터·LAN 서버 E2E 완료
|
> **요약**: 전 솔루션 Release 빌드 **0 오류**, 단위/E2E 테스트 **154/154 GREEN**, Android 에뮬레이터·iOS 시뮬레이터·LAN 서버 E2E 완료
|
||||||
>
|
>
|
||||||
> **감사 중 발견·수정된 회귀**: `HlsDownloader` 3-인자 ctor가 `_muxer` 미할당(재구성 ② 누락) → 모든 HLS 리먹스 NUL 위험. RED 테스트(`HlsMuxerWiringTests`)로 재현 후 ctor 할당 수정. `DashDownloader`는 정상.
|
> **감사 중 발견·수정된 회귀**: `HlsDownloader` 3-인자 ctor가 `_muxer` 미할당(재구성 ② 누락) → 모든 HLS 리먹스 NUL 위험. RED 테스트(`HlsMuxerWiringTests`)로 재현 후 ctor 할당 수정. `DashDownloader`는 정상.
|
||||||
|
|
||||||
|
|
@ -101,6 +101,7 @@
|
||||||
| **M1** QR 스캔 페어링 + 수동 폴백 | ✅ ZXing(실기기) + 수동 IP:포트+토큰 | 에뮬레이터 E2E에서 수동 페어링으로 실증 (IME 제약) |
|
| **M1** QR 스캔 페어링 + 수동 폴백 | ✅ ZXing(실기기) + 수동 IP:포트+토큰 | 에뮬레이터 E2E에서 수동 페어링으로 실증 (IME 제약) |
|
||||||
| **M1** 큐 목록 실시간 + 제어 | ✅ 2초 폴링(계획 허용안: "v1은 2초 폴링 가능"), 일시정지/재개/취소 버튼 | 에뮬 E2E: 라이브 서버 데이터 렌더 실증 |
|
| **M1** 큐 목록 실시간 + 제어 | ✅ 2초 폴링(계획 허용안: "v1은 2초 폴링 가능"), 일시정지/재개/취소 버튼 | 에뮬 E2E: 라이브 서버 데이터 렌더 실증 |
|
||||||
| **M1** 링크 붙여넣기 추가 | ✅ URL 엔트리 + "PC에서 받기" | 페이지 UI + 서버 API E2E (POST 400 검증 포함) |
|
| **M1** 링크 붙여넣기 추가 | ✅ URL 엔트리 + "PC에서 받기" | 페이지 UI + 서버 API E2E (POST 400 검증 포함) |
|
||||||
|
| **M1** 공유 타겟(U1) | ✅ ACTION_SEND 인텐트 필터("PC에서 받기") + MainActivity OnCreate/OnNewIntent → ShareTarget 채널 → 루트 2s 타이머가 SharePayload.ExtractUrl → POST /api/queue | `SharePayload` 단위 7건(RED→GREEN) · 에뮬 E2E: **`am start -a SEND --es EXTRA_TEXT "…https://youtu.be/E2ESHARE123"` → 서버 큐에 해당 URL 반영 확인** + dumpsys 로 인텐트 필터 등록 확인 |
|
||||||
| **M2** 라이브러리 목록 | ✅ 파일명/크기 | 에뮬 E2E: sample.mp4/415,253 bytes 렌더 |
|
| **M2** 라이브러리 목록 | ✅ 파일명/크기 | 에뮬 E2E: sample.mp4/415,253 bytes 렌더 |
|
||||||
| **M2** MediaElement 스트리밍 재생 | ✅ Range HTTP → 모달 플레이어 | 에뮬 E2E: **실제 MP4 프레임 렌더 실증**(testsrc 패턴 스크린샷) + 서버 ESTABLISHED |
|
| **M2** MediaElement 스트리밍 재생 | ✅ Range HTTP → 모달 플레이어 | 에뮬 E2E: **실제 MP4 프레임 렌더 실증**(testsrc 패턴 스크린샷) + 서버 ESTABLISHED |
|
||||||
| **M2-U3** 이어보기 위치 동기화 | ✅ `POST/GET /api/position/{id}` + PositionStore(positions.json 영속) + 플레이어 5초 주기·종료 시 저장, 재생 시작 시 시크 | RED→GREEN `이어보기_위치_동기화_E2E` · 에뮬 E2E: positions.json 13.3→24.2→**35.2초**(재오픈 시 저장 위치에서 이어 재생 실증) |
|
| **M2-U3** 이어보기 위치 동기화 | ✅ `POST/GET /api/position/{id}` + PositionStore(positions.json 영속) + 플레이어 5초 주기·종료 시 저장, 재생 시작 시 시크 | RED→GREEN `이어보기_위치_동기화_E2E` · 에뮬 E2E: positions.json 13.3→24.2→**35.2초**(재오픈 시 저장 위치에서 이어 재생 실증) |
|
||||||
|
|
@ -124,6 +125,7 @@
|
||||||
| 재생중 미디어 알림 | ✅ 플레이어 열릴 때 ongoing 노티 "재생 중"(채널 vd_playback), 닫히면 취소 | 에뮬 E2E: 게시·소멸 lifecycle 실증(dumpsys). **FGS 미사용** — 재생은 앱 내(포그라운드) 한정이므로 ongoing 노티로 충족, 문서화 |
|
| 재생중 미디어 알림 | ✅ 플레이어 열릴 때 ongoing 노티 "재생 중"(채널 vd_playback), 닫히면 취소 | 에뮬 E2E: 게시·소멸 lifecycle 실증(dumpsys). **FGS 미사용** — 재생은 앱 내(포그라운드) 한정이므로 ongoing 노티로 충족, 문서화 |
|
||||||
| 라이브러리 그리드(썸네일·해상도) | ✅ 서버 Thumbnailer(ffmpeg 1초 프레임 320px JPEG + 해상도 ffprobe, 사이드카 .thumb.jpg/.meta.json 캐시) + `GET /api/library/{id}/thumb` + DTO Resolution + 앱 2열 그리드 | `ThumbnailerTests` 4건(Fake 러너) + 서버 E2E(JPEG 매직/404/401) + 에뮬 E2E: **실제 ffmpeg 썸네일 렌더**(스크린샷)·해상도 "480x270" |
|
| 라이브러리 그리드(썸네일·해상도) | ✅ 서버 Thumbnailer(ffmpeg 1초 프레임 320px JPEG + 해상도 ffprobe, 사이드카 .thumb.jpg/.meta.json 캐시) + `GET /api/library/{id}/thumb` + DTO Resolution + 앱 2열 그리드 | `ThumbnailerTests` 4건(Fake 러너) + 서버 E2E(JPEG 매직/404/401) + 에뮬 E2E: **실제 ffmpeg 썸네일 렌더**(스크린샷)·해상도 "480x270" |
|
||||||
| 외부망 안내(§7) | ✅ ConnectionTroubleshoot(같은 네트워크/Tailscale 안내) → QueuePage 표시 | 단위 3건 + 에뮬 E2E: 죽은 포트 페어링 → "같은 네트워크… Tailscale 등 VPN" 문구 실증 |
|
| 외부망 안내(§7) | ✅ ConnectionTroubleshoot(같은 네트워크/Tailscale 안내) → QueuePage 표시 | 단위 3건 + 에뮬 E2E: 죽은 포트 페어링 → "같은 네트워크… Tailscale 등 VPN" 문구 실증 |
|
||||||
|
| 오프라인 저장 진행 알림(M2) | ✅ 💾 저장 중 Android ProgressBar 노티(5% 스로틀 갱신) → 완료 시 "폰 저장 완료" 노티로 전환 | 에뮬 E2E: 34MB 저장 중 "폰에 저장 중 — N%" 라이브 관측(t=1~12s) → 완료 후 **"폰 저장 완료"(big.mp4) 레코드 실증**(진행→완료 전환, Report(1) 되덮어쓰기 방지 포함) |
|
||||||
| U4 저장소 로직 이관 | ✅ OfflineStore → Mobile.Core(디렉터리 주입, MAUI 비의존) | 재시작 매니페스트 유지 테스트 포함 |
|
| U4 저장소 로직 이관 | ✅ OfflineStore → Mobile.Core(디렉터리 주입, MAUI 비의존) | 재시작 매니페스트 유지 테스트 포함 |
|
||||||
|
|
||||||
### 명시적 미구현 (계획 허용/후순위)
|
### 명시적 미구현 (계획 허용/후순위)
|
||||||
|
|
|
||||||
18
VideoDownloader.Mobile.Core/SharePayload.cs
Normal file
18
VideoDownloader.Mobile.Core/SharePayload.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace VideoDownloader.Mobile;
|
||||||
|
|
||||||
|
/// <summary>M1 공유 타겟(U1): 다른 앱에서 공유받은 텍스트에서 전송할 URL 추출.
|
||||||
|
/// 유튜브 공유 시트는 "제목 + https://youtu.be/..." 형식 — 텍스트 중 첫 http(s) URL.</summary>
|
||||||
|
public static partial class SharePayload
|
||||||
|
{
|
||||||
|
[GeneratedRegex(@"https?://[^\s]+")]
|
||||||
|
private static partial Regex UrlRegex();
|
||||||
|
|
||||||
|
public static string? ExtractUrl(string? sharedText)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(sharedText)) return null;
|
||||||
|
var m = UrlRegex().Match(sharedText);
|
||||||
|
return m.Success ? m.Value.TrimEnd(')', ']', '}', ',', '.', ';') : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -163,6 +163,21 @@ public sealed class QueuePage : ContentPage
|
||||||
_summary.Text = err != null ? $"❌ {err}" : items.Count == 0 ? "대기 중인 항목이 없습니다" : $"{items.Count}건";
|
_summary.Text = err != null ? $"❌ {err}" : items.Count == 0 ? "대기 중인 항목이 없습니다" : $"{items.Count}건";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>U1: 공유받은 링크 처리 결과 표시(루트 타이머가 호출).</summary>
|
||||||
|
public void ShowSharedResult(string message) => _summary.Text = message;
|
||||||
|
|
||||||
|
/// <summary>U1: 공유 타겟으로 받은 텍스트에서 URL 을 추출해 PC 대기열에 추가.</summary>
|
||||||
|
private async Task HandleSharedUrlAsync()
|
||||||
|
{
|
||||||
|
var shared = ShareTarget.Consume();
|
||||||
|
if (shared == null || !ApiClient.Instance.IsConfigured) return;
|
||||||
|
var url = SharePayload.ExtractUrl(shared);
|
||||||
|
if (url == null) { _summary.Text = "🔗 공유받은 텍스트에 URL 이 없습니다"; return; }
|
||||||
|
_summary.Text = "🔗 공유 링크 전송 중…";
|
||||||
|
var ok = await ApiClient.Instance.AddAsync(url);
|
||||||
|
_summary.Text = ok ? $"🔗 공유 링크 전송됨: {url}" : "❌ 공유 링크 전송 실패";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>U5: 완료 로컬 알림(Android 13+ 는 최초 1회 권한 요청).</summary>
|
/// <summary>U5: 완료 로컬 알림(Android 13+ 는 최초 1회 권한 요청).</summary>
|
||||||
private static async Task NotifyCompletionAsync(string title)
|
private static async Task NotifyCompletionAsync(string title)
|
||||||
{
|
{
|
||||||
|
|
@ -234,14 +249,19 @@ public sealed class LibraryPage : ContentPage
|
||||||
if (item == null) return;
|
if (item == null) return;
|
||||||
if (AppOfflineStore.Default.Exists(item.Id)) { button.Text = "✓ 저장됨"; return; }
|
if (AppOfflineStore.Default.Exists(item.Id)) { button.Text = "✓ 저장됨"; return; }
|
||||||
button.Text = "0%";
|
button.Text = "0%";
|
||||||
|
var notifId = 5000 + Math.Abs(item.Id.GetHashCode() % 1000); // M2 진행 알림: 항목별 고정 id 로 갱신
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var dest = AppOfflineStore.Default.PathFor(item.Id);
|
var dest = AppOfflineStore.Default.PathFor(item.Id);
|
||||||
var progress = new Progress<double>(p => MainThread.BeginInvokeOnMainThread(
|
var progress = new Progress<double>(p => MainThread.BeginInvokeOnMainThread(() =>
|
||||||
() => button.Text = p >= 1 ? "✓ 저장됨" : $"{(int)(p * 100)}%"));
|
{
|
||||||
|
button.Text = p >= 1 ? "✓ 저장됨" : $"{(int)(p * 100)}%";
|
||||||
|
if (p < 1) ShowSaveProgress(notifId, item.FileName, p); // 100% 는 완료 노티가 대체(되덮어쓰기 방지)
|
||||||
|
}));
|
||||||
await ApiClient.Instance.DownloadFileAsync(item, dest, progress, CancellationToken.None);
|
await ApiClient.Instance.DownloadFileAsync(item, dest, progress, CancellationToken.None);
|
||||||
AppOfflineStore.Default.Record(item.Id, item.FileName);
|
AppOfflineStore.Default.Record(item.Id, item.FileName);
|
||||||
button.Text = "✓ 저장됨";
|
button.Text = "✓ 저장됨";
|
||||||
|
ShowSaveDone(notifId, item.FileName);
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
|
|
@ -249,6 +269,53 @@ public sealed class LibraryPage : ContentPage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>M2: 오프라인 저장 진행 알림(같은 id 갱신) — 5% 단위 스로틀.</summary>
|
||||||
|
private static int _lastNotifiedPercent = -1;
|
||||||
|
|
||||||
|
private static void ShowSaveProgress(int notifId, string fileName, double p)
|
||||||
|
{
|
||||||
|
var percent = (int)(p * 100);
|
||||||
|
if (p < 1 && percent / 5 == _lastNotifiedPercent) return; // 알림 스팸 방지
|
||||||
|
_lastNotifiedPercent = percent / 5;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Plugin.LocalNotification.LocalNotificationCenter.Current.Show(new Plugin.LocalNotification.NotificationRequest
|
||||||
|
{
|
||||||
|
NotificationId = notifId,
|
||||||
|
Title = "폰에 저장 중",
|
||||||
|
Description = $"{fileName} — {percent}%",
|
||||||
|
Android =
|
||||||
|
{
|
||||||
|
ChannelId = "vd_save",
|
||||||
|
Ongoing = true,
|
||||||
|
AutoCancel = false,
|
||||||
|
ProgressBar = new Plugin.LocalNotification.AndroidOption.AndroidProgressBar
|
||||||
|
{
|
||||||
|
Max = 100,
|
||||||
|
Progress = percent,
|
||||||
|
IsIndeterminate = false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ShowSaveDone(int notifId, string fileName)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Plugin.LocalNotification.LocalNotificationCenter.Current.Show(new Plugin.LocalNotification.NotificationRequest
|
||||||
|
{
|
||||||
|
NotificationId = notifId,
|
||||||
|
Title = "폰 저장 완료",
|
||||||
|
Description = fileName,
|
||||||
|
Android = { ChannelId = "vd_save", Ongoing = false, AutoCancel = true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
private async void OpenPlayer(LibraryItem item)
|
private async void OpenPlayer(LibraryItem item)
|
||||||
{
|
{
|
||||||
// 탭 자식엔 NavigationPage 가 없어 PushAsync 불가 → 루트에서 모달 push (닫기는 상단바 뒤로)
|
// 탭 자식엔 NavigationPage 가 없어 PushAsync 불가 → 루트에서 모달 push (닫기는 상단바 뒤로)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,31 @@
|
||||||
using Android.App;
|
using Android.App;
|
||||||
|
using Android.Content;
|
||||||
using Android.Content.PM;
|
using Android.Content.PM;
|
||||||
using Android.OS;
|
using Android.OS;
|
||||||
|
|
||||||
namespace VideoDownloader.Mobile;
|
namespace VideoDownloader.Mobile;
|
||||||
|
|
||||||
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
|
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
|
||||||
|
// M1 공유 타겟(U1): 다른 앱의 공유 시트에 "PC에서 받기" 로 등록
|
||||||
|
[IntentFilter(new[] { Intent.ActionSend }, Categories = new[] { Intent.CategoryDefault }, DataMimeType = "text/plain", Label = "PC에서 받기")]
|
||||||
public class MainActivity : MauiAppCompatActivity
|
public class MainActivity : MauiAppCompatActivity
|
||||||
{
|
{
|
||||||
|
protected override void OnCreate(Bundle? savedInstanceState)
|
||||||
|
{
|
||||||
|
base.OnCreate(savedInstanceState);
|
||||||
|
ReceiveShare(Intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnNewIntent(Intent? intent)
|
||||||
|
{
|
||||||
|
base.OnNewIntent(intent);
|
||||||
|
ReceiveShare(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ReceiveShare(Intent? intent)
|
||||||
|
{
|
||||||
|
if (intent?.Action != Intent.ActionSend) return;
|
||||||
|
var text = intent.GetStringExtra(Intent.ExtraText);
|
||||||
|
ShareTarget.Deliver(text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
16
VideoDownloader.Mobile/ShareTarget.cs
Normal file
16
VideoDownloader.Mobile/ShareTarget.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
namespace VideoDownloader.Mobile;
|
||||||
|
|
||||||
|
/// <summary>M1 공유 타겟(U1): 플랫폼(안드로이드 인텐트)이 전달한 공유 텍스트 큐.</summary>
|
||||||
|
public static class ShareTarget
|
||||||
|
{
|
||||||
|
private static string? _pending;
|
||||||
|
|
||||||
|
public static void Deliver(string? sharedText)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(sharedText)) return;
|
||||||
|
Volatile.Write(ref _pending, sharedText);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>대기 중 공유 텍스트를 꺼낸다(없으면 null).</summary>
|
||||||
|
public static string? Consume() => Interlocked.Exchange(ref _pending, null);
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,33 @@
|
||||||
namespace VideoDownloader.Mobile;
|
namespace VideoDownloader.Mobile;
|
||||||
|
|
||||||
/// <summary>컴패니언 루트: 연결 / 큐 / 라이브러리 탭.</summary>
|
/// <summary>컴팠니언 루트: 큐 / 라이브러리 / 저장 / 연결 탭 + 공유 타겟 처리.</summary>
|
||||||
public sealed class TabbedRootPage : TabbedPage
|
public sealed class TabbedRootPage : TabbedPage
|
||||||
{
|
{
|
||||||
public TabbedRootPage()
|
public TabbedRootPage()
|
||||||
{
|
{
|
||||||
ApiClient.Instance.Restore();
|
ApiClient.Instance.Restore();
|
||||||
Children.Add(new QueuePage());
|
var queuePage = new QueuePage();
|
||||||
|
Children.Add(queuePage);
|
||||||
Children.Add(new LibraryPage());
|
Children.Add(new LibraryPage());
|
||||||
Children.Add(new DownloadsPage());
|
Children.Add(new DownloadsPage());
|
||||||
Children.Add(new ConnectPage());
|
Children.Add(new ConnectPage());
|
||||||
Title = "VD 원격";
|
Title = "VD 원격";
|
||||||
|
|
||||||
|
// M1 공유 타겟(U1): 어떤 탭에 있든/다른 앱에서 공유받으면 URL 을 PC 큐로 전송 후 큐 탭 표시
|
||||||
|
Device.StartTimer(TimeSpan.FromSeconds(2), () =>
|
||||||
|
{
|
||||||
|
_ = HandleShareAsync(queuePage);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task HandleShareAsync(QueuePage queuePage)
|
||||||
|
{
|
||||||
|
var shared = ShareTarget.Consume();
|
||||||
|
if (shared == null || !ApiClient.Instance.IsConfigured) return;
|
||||||
|
var url = SharePayload.ExtractUrl(shared);
|
||||||
|
if (url == null) { queuePage.ShowSharedResult("🔗 공유받은 텍스트에 URL 이 없습니다"); return; }
|
||||||
|
var ok = await ApiClient.Instance.AddAsync(url);
|
||||||
|
queuePage.ShowSharedResult(ok ? $"🔗 공유 링크 전송됨: {url}" : "❌ 공유 링크 전송 실패");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,24 @@ public class MobileCoreTests
|
||||||
public void 정상_시_빈_문자열()
|
public void 정상_시_빈_문자열()
|
||||||
=> Assert.Equal("", ConnectionTroubleshoot.Message(isConfigured: true, lastError: null));
|
=> Assert.Equal("", ConnectionTroubleshoot.Message(isConfigured: true, lastError: null));
|
||||||
|
|
||||||
|
|
||||||
|
// ── M1 공유 타겟(U1): 공유 텍스트 → URL 추출 ──
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("https://youtu.be/abc123", "https://youtu.be/abc123")] // URL 만
|
||||||
|
[InlineData("재밌는 영상\nhttps://youtu.be/abc123", "https://youtu.be/abc123")] // 제목+URL (유튜브 공유 형식)
|
||||||
|
[InlineData("http://x.com/a?b=1 입니다", "http://x.com/a?b=1")] // http + 쿼리
|
||||||
|
[InlineData("a https://1.com b https://2.com", "https://1.com")] // 첫 번째 우선
|
||||||
|
public void 공유_텍스트에서_URL_추출(string text, string expected)
|
||||||
|
=> Assert.Equal(expected, SharePayload.ExtractUrl(text));
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("URL 없는 그냥 텍스트")]
|
||||||
|
public void URL_없으면_null(string? text)
|
||||||
|
=> Assert.Null(SharePayload.ExtractUrl(text));
|
||||||
|
|
||||||
// ── U4 저장소(임시 디렉터리로 파일 시스템 검증) ──
|
// ── U4 저장소(임시 디렉터리로 파일 시스템 검증) ──
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue