migration auto test in tui

This commit is contained in:
rambros 2026-03-30 22:16:32 +05:30
parent 2ef141776c
commit bd80760667
6 changed files with 377 additions and 95 deletions

View file

@ -12,7 +12,7 @@ def setup_logging():
except Exception: except Exception:
level = logging.INFO level = logging.INFO
handlers = [RotatingFileHandler('.reaper.log', mode='a', maxBytes=10*1024*1024, backupCount=3)] handlers = [RotatingFileHandler('.reaper.log', mode='w', maxBytes=10*1024*1024, backupCount=3)]
logging.basicConfig( logging.basicConfig(
format='%(asctime)s,%(msecs)d %(name)s %(levelname)s %(message)s', format='%(asctime)s,%(msecs)d %(name)s %(levelname)s %(message)s',
datefmt='%H:%M:%S', datefmt='%H:%M:%S',

View file

@ -15,41 +15,52 @@ async def sync_channel_state(context: MigrationContext):
channels = await context.discord_reader.get_channels() channels = await context.discord_reader.get_channels()
fluxer_channels = await context.fluxer_writer.get_channels() fluxer_channels = await context.fluxer_writer.get_channels()
# Build name -> id map and ID set for Fluxer for fast lookup # Build maps for Fluxer lookup
fluxer_name_map = {c.get("name"): str(c.get("id")) for c in fluxer_channels if c.get("name")} # {name: id} for categories
fluxer_id_set = {str(c.get("id")) for c in fluxer_channels} fluxer_cats = {c.get("name"): str(c.get("id")) for c in fluxer_channels if c.get("type") == 4}
# {parent_id: {name: id}} for channels
fluxer_structure = {}
for c in fluxer_channels:
if c.get("type") == 4: continue
p_id = str(c.get("parent_id")) if c.get("parent_id") else "root"
if p_id not in fluxer_structure: fluxer_structure[p_id] = {}
fluxer_structure[p_id][c.get("name")] = str(c.get("id"))
fluxer_id_set = {str(c.get("id")) for c in fluxer_channels}
updates = 0 updates = 0
removals = 0 removals = 0
# 1. Verify and Sync Categories # 1. Sync Categories
for cat in categories: for cat in categories:
discord_id = str(cat.id) discord_id = str(cat.id)
fluxer_id = context.state.get_fluxer_category_id(discord_id) fluxer_id = context.state.get_fluxer_category_id(discord_id)
if fluxer_id: if fluxer_id:
if fluxer_id not in fluxer_id_set: if fluxer_id not in fluxer_id_set:
context.state.remove_category_mapping(discord_id) context.state.remove_category_mapping(discord_id)
removals += 1 removals += 1
elif cat.name in fluxer_name_map: elif cat.name in fluxer_cats:
context.state.set_category_mapping(discord_id, fluxer_name_map[cat.name]) context.state.set_category_mapping(discord_id, fluxer_cats[cat.name])
updates += 1 updates += 1
# 2. Verify and Sync Channels # 2. Sync Channels (parent-aware)
for ch in channels: for ch in channels:
discord_id = str(ch.id) discord_id = str(ch.id)
fluxer_id = context.state.get_fluxer_channel_id(discord_id) fluxer_id = context.state.get_fluxer_channel_id(discord_id)
if fluxer_id: if fluxer_id:
if fluxer_id not in fluxer_id_set: if fluxer_id not in fluxer_id_set:
context.state.remove_channel_mapping(discord_id) context.state.remove_channel_mapping(discord_id)
removals += 1 removals += 1
elif ch.name in fluxer_name_map: else:
context.state.set_channel_mapping(discord_id, fluxer_name_map[ch.name]) # Try to match by name within the mapped parent category
updates += 1 p_discord_id = str(ch.category_id) if ch.category_id else "root"
p_fluxer_id = context.state.get_fluxer_category_id(p_discord_id) if p_discord_id != "root" else "root"
if p_fluxer_id in fluxer_structure and ch.name in fluxer_structure[p_fluxer_id]:
context.state.set_channel_mapping(discord_id, fluxer_structure[p_fluxer_id][ch.name])
updates += 1
if updates > 0 or removals > 0: if updates > 0 or removals > 0:
logger.info(f"Channel sync: {updates} mapped, {removals} stale mappings removed") logger.info(f"Fluxer Channel sync: {updates} mapped, {removals} stale mappings removed")
async def migrate_channels(context: MigrationContext, progress_callback: Callable[[str, str, int, int], Awaitable[None]] | None = None, force: bool = False) -> dict: async def migrate_channels(context: MigrationContext, progress_callback: Callable[[str, str, int, int], Awaitable[None]] | None = None, force: bool = False) -> dict:

View file

@ -343,14 +343,18 @@ class FluxerWriter:
logger.debug(f"Fluxer: Sending message via bot for user '{author_name}'") logger.debug(f"Fluxer: Sending message via bot for user '{author_name}'")
try: try:
kwargs = {
"channel_id": channel_id,
"content": final_bot_content,
"embeds": normalized_embeds
}
if fluxer_files:
kwargs["files"] = fluxer_files
if message_reference:
kwargs["message_reference"] = message_reference
msg_data = await asyncio.wait_for( msg_data = await asyncio.wait_for(
self.client.send_message( self.client.send_message(**kwargs),
channel_id=channel_id,
content=final_bot_content,
files=fluxer_files,
embeds=normalized_embeds,
message_reference=message_reference
),
timeout=45.0 timeout=45.0
) )
logger.debug(f"Fluxer: Bot send complete, msg_id={msg_data.get('id') if msg_data else 'None'}") logger.debug(f"Fluxer: Bot send complete, msg_id={msg_data.get('id') if msg_data else 'None'}")
@ -374,19 +378,23 @@ class FluxerWriter:
fluxer_files = None fluxer_files = None
if files: if files:
fluxer_files = [File(io.BytesIO(f["data"]), filename=f["filename"]) for f in files] fluxer_files = [f if hasattr(f, "filename") else File(io.BytesIO(f["data"]), filename=f["filename"]) for f in files]
message_reference = None message_reference = None
if reply_to_message_id: if reply_to_message_id:
message_reference = {"message_id": str(reply_to_message_id), "channel_id": str(channel_id)} message_reference = {"message_id": str(reply_to_message_id), "channel_id": str(channel_id)}
try: try:
msg_data = await self.client.send_message( kwargs = {
channel_id=channel_id, "channel_id": channel_id,
content=content, "content": content
files=fluxer_files, }
message_reference=message_reference if fluxer_files:
) kwargs["files"] = fluxer_files
if message_reference:
kwargs["message_reference"] = message_reference
msg_data = await self.client.send_message(**kwargs)
return str(msg_data["id"]) if msg_data else None return str(msg_data["id"]) if msg_data else None
except Exception as e: except Exception as e:
print(f"Failed to send marker: {e}") print(f"Failed to send marker: {e}")

View file

@ -16,41 +16,52 @@ async def sync_channel_state(context: MigrationContext):
channels = await context.discord_reader.get_channels() channels = await context.discord_reader.get_channels()
target_channels = await context.writer.get_channels() target_channels = await context.writer.get_channels()
# Build name -> id map and ID set for Stoat for fast lookup # Build maps for Stoat lookup
target_name_map = {c.get("name"): str(c.get("id")) for c in target_channels if c.get("name")} # {name: id} for categories (type 4)
target_cats = {c.get("name"): str(c.get("id")) for c in target_channels if c.get("type") == 4}
# {parent_id: {name: id}} for channels
target_structure = {}
for c in target_channels:
if c.get("type") == 4: continue
p_id = str(c.get("parent_id")) if c.get("parent_id") else "root"
if p_id not in target_structure: target_structure[p_id] = {}
target_structure[p_id][c.get("name")] = str(c.get("id"))
target_id_set = {str(c.get("id")) for c in target_channels} target_id_set = {str(c.get("id")) for c in target_channels}
updates = 0 updates = 0
removals = 0 removals = 0
# 1. Verify and Sync Categories # 1. Sync Categories
for cat in categories: for cat in categories:
discord_id = str(cat.id) discord_id = str(cat.id)
target_id = context.state.get_target_category_id(discord_id) target_id = context.state.get_target_category_id(discord_id)
if target_id: if target_id:
if target_id not in target_id_set: if target_id not in target_id_set:
context.state.remove_category_mapping(discord_id) context.state.remove_category_mapping(discord_id)
removals += 1 removals += 1
elif cat.name in target_name_map: elif cat.name in target_cats:
context.state.set_target_category_mapping(discord_id, target_name_map[cat.name]) context.state.set_target_category_mapping(discord_id, target_cats[cat.name])
updates += 1 updates += 1
# 2. Verify and Sync Channels # 2. Sync Channels (parent-aware)
for ch in channels: for ch in channels:
discord_id = str(ch.id) discord_id = str(ch.id)
target_id = context.state.get_target_channel_id(discord_id) target_id = context.state.get_target_channel_id(discord_id)
if target_id: if target_id:
if target_id not in target_id_set: if target_id not in target_id_set:
context.state.remove_channel_mapping(discord_id) context.state.remove_channel_mapping(discord_id)
removals += 1 removals += 1
elif ch.name in target_name_map: else:
context.state.set_target_channel_mapping(discord_id, target_name_map[ch.name]) # Try to match by name within the mapped parent category
updates += 1 p_discord_id = str(ch.category_id) if ch.category_id else "root"
p_target_id = context.state.get_target_category_id(p_discord_id) if p_discord_id != "root" else "root"
if p_target_id in target_structure and ch.name in target_structure[p_target_id]:
context.state.set_target_channel_mapping(discord_id, target_structure[p_target_id][ch.name])
updates += 1
if updates > 0 or removals > 0: if updates > 0 or removals > 0:
logger.info(f"Channel sync: {updates} mapped, {removals} stale mappings removed") logger.info(f"Stoat Channel sync: {updates} mapped, {removals} stale mappings removed")
async def migrate_channels(context: MigrationContext, progress_callback: Callable[[str, str, int, int], Awaitable[None]] | None = None, force: bool = False) -> dict: async def migrate_channels(context: MigrationContext, progress_callback: Callable[[str, str, int, int], Awaitable[None]] | None = None, force: bool = False) -> dict:
@ -107,7 +118,22 @@ async def migrate_channels(context: MigrationContext, progress_callback: Callabl
if total == 0: if total == 0:
return cloned_info return cloned_info
# 1. Create missing channels (unparented for now) # 1. Create missing categories first
for cat in missing_categories:
if not context.is_running: break
state_key = str(cat.id)
target_id = await context.writer.create_channel(cat.name, type=4)
if target_id:
context.state.set_target_category_id(state_key, target_id)
cloned_info["categories_created"].append(cat.name)
if cat.name not in cloned_info["structure"]:
cloned_info["structure"][cat.name] = []
current_idx += 1
if progress_callback: await progress_callback(cat.name, "Copying", current_idx, total)
# 2. Create missing channels (now with parent_id available)
for channel in channels_to_create: for channel in channels_to_create:
if not context.is_running: break if not context.is_running: break
@ -119,7 +145,6 @@ async def migrate_channels(context: MigrationContext, progress_callback: Callabl
logger.debug(f"Creating channel {channel.name}: topic={topic}, nsfw={nsfw}, slowmode={slowmode}") logger.debug(f"Creating channel {channel.name}: topic={topic}, nsfw={nsfw}, slowmode={slowmode}")
# Map Discord-specific types to target-supported types # Map Discord-specific types to target-supported types
# 5 (News) -> 0 (Text), and fallback any unknown non-voice types to text
raw_type = channel.type.value if hasattr(channel.type, 'value') else 0 raw_type = channel.type.value if hasattr(channel.type, 'value') else 0
if raw_type == context.discord_reader.CHANNEL_TYPE_VOICE.value: if raw_type == context.discord_reader.CHANNEL_TYPE_VOICE.value:
ch_type = 2 ch_type = 2
@ -128,20 +153,22 @@ async def migrate_channels(context: MigrationContext, progress_callback: Callabl
ch_type = 0 ch_type = 0
is_voice = False is_voice = False
else: else:
# Fallback for Stage channels (13) etc. to Text for safety
ch_type = 0 ch_type = 0
is_voice = False is_voice = False
# Resolve parent category
parent_id = context.state.get_target_category_id(str(channel.category_id)) if channel.category_id else None
target_id = await context.writer.create_channel( target_id = await context.writer.create_channel(
name=channel.name, name=channel.name,
topic=topic if not is_voice else "", topic=topic if not is_voice else "",
type=ch_type, type=ch_type,
parent_id=None, parent_id=parent_id,
nsfw=nsfw if not is_voice else False, nsfw=nsfw if not is_voice else False,
slowmode_delay=slowmode if not is_voice else 0 slowmode_delay=slowmode if not is_voice else 0
) )
if target_id: if target_id:
context.state.set_target_channel_mapping(state_key, target_id) context.state.set_target_channel_id(state_key, target_id)
cloned_info["channels_created"].append(channel.name) cloned_info["channels_created"].append(channel.name)
parent_name = cat_name_map.get(str(channel.category_id), "No Category") if channel.category_id else "No Category" parent_name = cat_name_map.get(str(channel.category_id), "No Category") if channel.category_id else "No Category"
@ -156,7 +183,8 @@ async def migrate_channels(context: MigrationContext, progress_callback: Callabl
name=channel.name, name=channel.name,
topic=topic, topic=topic,
nsfw=nsfw, nsfw=nsfw,
slowmode_delay=slowmode slowmode_delay=slowmode,
parent_id=parent_id
) )
current_idx += 1 current_idx += 1
@ -172,32 +200,21 @@ async def migrate_channels(context: MigrationContext, progress_callback: Callabl
logger.debug(f"Syncing existing channel {channel.name} ({target_id}): topic={topic}, nsfw={nsfw}, slowmode={slowmode}") logger.debug(f"Syncing existing channel {channel.name} ({target_id}): topic={topic}, nsfw={nsfw}, slowmode={slowmode}")
parent_id = context.state.get_target_category_id(str(channel.category_id)) if channel.category_id else None
await context.writer.modify_channel( await context.writer.modify_channel(
channel_id=target_id, channel_id=target_id,
name=channel.name, name=channel.name,
topic=topic, topic=topic,
nsfw=nsfw, nsfw=nsfw,
slowmode_delay=slowmode slowmode_delay=slowmode,
parent_id=parent_id
) )
cloned_info["channels_synced"].append(channel.name) cloned_info["cloned_info" if "cloned_info" in locals() else "channels_synced"].append(channel.name)
current_idx += 1 current_idx += 1
if progress_callback: await progress_callback(channel.name, "Syncing", current_idx, total) if progress_callback: await progress_callback(channel.name, "Syncing", current_idx, total)
# 3. Create missing categories
for cat in missing_categories:
if not context.is_running: break
state_key = str(cat.id)
target_id = await context.writer.create_channel(cat.name, type=4)
if target_id:
context.state.set_target_category_mapping(state_key, target_id)
cloned_info["categories_created"].append(cat.name)
if cat.name not in cloned_info["structure"]:
cloned_info["structure"][cat.name] = []
current_idx += 1
if progress_callback: await progress_callback(f"Cat: {cat.name}", "Copying", current_idx, total) if progress_callback: await progress_callback(f"Cat: {cat.name}", "Copying", current_idx, total)
# 4. Final step: Parent the channels into categories via mass server.edit() # 4. Final step: Parent the channels into categories via mass server.edit()

View file

@ -163,6 +163,9 @@ class OperationPane(Container):
yield Button("Waterfall Migration", id="op_waterfall", disabled=True, variant="primary", tooltip="Migrate all messages globally in chronological order to prevent broken links.\n(Available for Local Backups)") yield Button("Waterfall Migration", id="op_waterfall", disabled=True, variant="primary", tooltip="Migrate all messages globally in chronological order to prevent broken links.\n(Available for Local Backups)")
yield Rule(id="footer_rule") yield Rule(id="footer_rule")
yield Button("Danger Zone ⚠", id="op_danger", variant="error", disabled=True, flat=True, tooltip="Dangerous operations:\ndelete channels, roles, emojis on target\n(use with caution)") yield Button("Danger Zone ⚠", id="op_danger", variant="error", disabled=True, flat=True, tooltip="Dangerous operations:\ndelete channels, roles, emojis on target\n(use with caution)")
if self.cfg_name == "AutoTest":
yield Button("RUN AUTO TEST", id="op_autotest", variant="warning", flat="false", disabled=True, tooltip="Execute automated test sequence for the AutoTest profile")
def on_mount(self) -> None: def on_mount(self) -> None:
self._rebuild_engine() self._rebuild_engine()
@ -395,7 +398,7 @@ class OperationPane(Container):
lbl.update(f"{t_status}") lbl.update(f"{t_status}")
# Buttons # Buttons
for bid in ("#op_clone", "#op_sync", "#op_messages", "#op_waterfall", "#op_danger"): for bid in ("#op_clone", "#op_sync", "#op_messages", "#op_waterfall", "#op_danger", "#op_autotest"):
for btn in self.query(bid): btn.disabled = not self.tokens_valid for btn in self.query(bid): btn.disabled = not self.tokens_valid
# ── validation ──────────────────────────────────────────────────────── # ── validation ────────────────────────────────────────────────────────
@ -416,10 +419,10 @@ class OperationPane(Container):
# Disable all operation buttons while validation is in progress # Disable all operation buttons while validation is in progress
if self.view_mode == "shuttle": if self.view_mode == "shuttle":
for bid in ("#op_clone", "#op_sync", "#op_messages", "#op_waterfall", "#op_danger"): for bid in ("#op_clone", "#op_sync", "#op_messages", "#op_waterfall", "#op_danger", "#op_autotest"):
for btn in self.query(bid): btn.disabled = True for btn in self.query(bid): btn.disabled = True
elif self.view_mode == "backup": elif self.view_mode == "backup":
for bid in ("#op_backup_msgs", "#op_backup_sync"): for bid in ("#op_backup_msgs", "#op_backup_sync", "#op_autotest"):
for btn in self.query(bid): btn.disabled = True for btn in self.query(bid): btn.disabled = True
except Exception as e: except Exception as e:
logger.error(f"Error in run_validate setup: {e}") logger.error(f"Error in run_validate setup: {e}")
@ -590,6 +593,136 @@ class OperationPane(Container):
from src.ui.backup_stats import BackupStatsScreen from src.ui.backup_stats import BackupStatsScreen
target_dir = Path(self._base_dir()) / f"DISCORD_BACKUP-{self.config.discord_server_id}" target_dir = Path(self._base_dir()) / f"DISCORD_BACKUP-{self.config.discord_server_id}"
self.app.push_screen(BackupStatsScreen(self.cfg_name, target_dir)) self.app.push_screen(BackupStatsScreen(self.cfg_name, target_dir))
elif bid == "op_autotest":
self.run_autotest_sequence()
@work(exclusive=True)
async def run_autotest_sequence(self) -> None:
"""Entry point for the AUTO TEST sequence."""
if not self.tokens_valid:
return
modal = ProgressScreen(log_level=self.config.log_level)
self.app.push_screen(modal)
await asyncio.sleep(0.1)
try:
if self.view_mode == "shuttle":
await self._run_migration_autotest_logic(modal)
elif self.view_mode == "backup":
await self._run_backup_autotest_logic(modal)
modal.phase_report("AUTO TEST Complete", show_back=False)
modal.write("[bold green]Full automated test sequence finished successfully![/bold green]")
except Exception as e:
logger.error(f"Auto-Test Error: {e}\n{traceback.format_exc()}")
modal.write(f"[bold red]Error: {e}[/bold red]")
modal.phase_report("Auto-Test", "error", show_back=False)
finally:
self.engine.is_running = False
await self.engine.close_connections()
async def _run_migration_autotest_logic(self, modal: ProgressScreen) -> None:
"""Executes the full migration test sequence."""
modal.set_status("AUTO TEST: Launching Migration Sequence...")
modal.write("[bold yellow]Starting Migration Auto-Test...[/bold yellow]")
migrate_mod = fluxer_migrate if self.target_platform == "fluxer" else stoat_migrate
# 1. Connect and initialize state
modal.set_status("Connecting and Initializing State...")
await self.engine.start_connections()
self.engine.is_running = True
# Initialize state database early to avoid Warnings
tid = self.config.fluxer_server_id if self.target_platform == "fluxer" else self.config.stoat_server_id
tgt_info = await self.engine.writer.validate()
self.engine.ensure_state_initialized(str(tid or ""), tgt_info.get("community_name", "Target"))
# 2. Danger Zone Clean
modal.write("\n[bold red]Phase 1: Danger Zone Cleanup[/bold red]")
await self._logic_dz_delete_channels(modal)
await self._logic_dz_reset_perms(modal)
await self._logic_dz_delete_roles(modal)
await self._logic_dz_delete_assets(modal)
# 3. Clone Roles & Permissions
modal.write("\n[bold cyan]Phase 2: Cloning Roles & Permissions[/bold cyan]")
await self._logic_clone_roles(modal, force=True)
# 4. Clone Assets (Logo, Banner, Emojis, Stickers)
modal.write("\n[bold cyan]Phase 3: Syncing Metadata & Assets[/bold cyan]")
await self._logic_copy_assets(modal, ["Emoji", "Sticker"], force=True)
await self._logic_sync_metadata(modal, ["name", "icon", "banner"])
# 5. Clone Template (Structure)
modal.write("\n[bold cyan]Phase 4: Cloning Server Structure (1/2)[/bold cyan]")
await self._logic_clone_channels(modal, force=True)
await self._logic_sync_permissions(modal)
# 6. Waterfall Migration (Only for backups)
if self.engine.source_mode == "backup" :
modal.write("\n[bold cyan]Phase 5: Waterfall Message Migration[/bold cyan]")
await self._logic_waterfall_migration(modal=modal, is_autotest=True)
else:
modal.write("\n[bold yellow]Phase 5: Skipping Waterfall (Live mode selected)[/bold yellow]")
# 7. Individual Channel Migration (Automated)
modal.write("\n[bold cyan]Phase 7: Individual Channel Migration[/bold cyan]")
await self._logic_autotest_migrate_all_channels(modal=modal)
async def _run_backup_autotest_logic(self, modal: ProgressScreen) -> None:
"""Executes the full backup test sequence."""
modal.set_status("AUTO TEST: Launching Backup Sequence...")
modal.write("[bold yellow]Starting Backup Auto-Test...[/bold yellow]")
# 1. Clear old backup
modal.set_status("Clearing old backup database...")
db_path = Path(self._base_dir()) / f"DISCORD_BACKUP-{self.config.discord_server_id}" / "backup.db"
if db_path.exists():
modal.write(f"[yellow]Deleting existing database: {db_path.name}[/yellow]")
db_path.unlink()
# 2. Setup exporter
await self.engine.discord_reader.start()
await self.exporter.setup()
self.exporter.is_running = True
# 3. Full Backup
modal.write("\n[bold cyan]Phase 1: Full Server Backup[/bold cyan]")
modal.show_stats()
await self.exporter.export_metadata()
await self.exporter.download_server_assets()
await self.exporter.export_channels_structure()
await self.exporter.export_roles()
await self.exporter.export_assets()
eligible_channels = [
c for c in await self.engine.discord_reader.get_channels()
if c.type in [
self.engine.discord_reader.CHANNEL_TYPE_TEXT,
self.engine.discord_reader.CHANNEL_TYPE_NEWS,
self.engine.discord_reader.CHANNEL_TYPE_FORUM
]
]
total_chans = len(eligible_channels)
for i, chan in enumerate(eligible_channels):
if not self.exporter.is_running: break
modal.set_item_status(f"Backing up ({i+1}/{total_chans}): #{chan.name}")
modal.set_progress(i, total_chans)
async def update_backup(name, count, author=None, preview=None, threads=0, files=0):
modal.update_stats(messages=str(count), threads=str(threads), files=str(files))
await self.exporter.export_channel_messages(
chan.id, progress_callback=update_backup, force=True
)
modal.write(f"[green]Completed: #{chan.name}[/green]")
modal.set_progress(total_chans, total_chans)
# ── (1) clone server template (combined) ───────────────────────────── # ── (1) clone server template (combined) ─────────────────────────────
@ -1012,16 +1145,67 @@ class OperationPane(Container):
# ── (5) message migration ───────────────────────────────────────────── # ── (5) message migration ─────────────────────────────────────────────
@work(exclusive=True) @work(exclusive=True)
async def run_migrate_messages(self) -> None: async def run_migrate_messages(self, modal: ProgressScreen | None = None) -> None:
await self._logic_migrate_messages(modal)
async def _logic_autotest_migrate_all_channels(self, modal: ProgressScreen) -> None:
"""Automated name-based channel migration for Auto-Test."""
migrate_mod = fluxer_migrate if self.target_platform == "fluxer" else stoat_migrate
# 1. Matching
modal.set_status("Auto-matching channels by name...")
await self._perform_auto_matching()
# 2. Get channels
d_channels = await self.engine.discord_reader.get_channels()
text_channels = [c for c in d_channels if c.type in [
self.engine.discord_reader.CHANNEL_TYPE_TEXT,
self.engine.discord_reader.CHANNEL_TYPE_NEWS
]]
modal.write(f"[bold cyan]Auto-Test: Found {len(text_channels)} channels to migrate.[/bold cyan]")
# 3. Migrate loop
for i, ch in enumerate(text_channels):
if not self.engine.is_running: break
tgt_id = self.engine.state.get_target_channel_id(str(ch.id))
if not tgt_id:
modal.write(f"[yellow]Skipping #{ch.name} (no target mapping found)[/yellow]")
continue
modal.write(f"\n[bold]Migrating #{ch.name} ({i+1}/{len(text_channels)}) -> Target ID {tgt_id}[/bold]")
# Analyze (silent)
stats = await migrate_mod.analyze_migration(self.engine, source_channel_id=ch.id, after_message_id=None)
ch_total = stats["messages"]
async def update_indiv(curr):
c = curr["messages"]
modal.set_progress(c, ch_total or 100)
modal.set_item_status(f"#{ch.name}: {c}/{ch_total} messages")
await migrate_mod.migrate_messages(
self.engine,
source_channel_id=ch.id,
target_channel_id=tgt_id,
after_message_id=None,
progress_callback=update_indiv
)
modal.write("\n[bold green]Automated channel migration complete.[/bold green]")
async def _logic_migrate_messages(self, modal: ProgressScreen | None = None, is_autotest: bool = False) -> None:
if not self.tokens_valid: if not self.tokens_valid:
return return
migrate_mod = fluxer_migrate if self.target_platform == "fluxer" else stoat_migrate migrate_mod = fluxer_migrate if self.target_platform == "fluxer" else stoat_migrate
platform_name = self.target_platform.capitalize() platform_name = self.target_platform.capitalize()
modal = ProgressScreen(log_level=self.config.log_level) if not modal:
self.app.push_screen(modal) modal = ProgressScreen(log_level=self.config.log_level)
await asyncio.sleep(0.1) self.app.push_screen(modal)
await asyncio.sleep(0.1)
try: try:
# Show info container # Show info container
@ -1418,22 +1602,29 @@ class OperationPane(Container):
await self.engine.close_connections() await self.engine.close_connections()
@work(exclusive=True) @work(exclusive=True)
async def run_waterfall_migration(self) -> None: async def run_waterfall_migration(self, modal: ProgressScreen | None = None) -> None:
await self._logic_waterfall_migration(modal)
async def _logic_waterfall_migration(self, modal: ProgressScreen | None = None, is_autotest: bool = False) -> None:
if not self.tokens_valid: if not self.tokens_valid:
return return
migrate_mod = fluxer_migrate if self.target_platform == "fluxer" else stoat_migrate migrate_mod = fluxer_migrate if self.target_platform == "fluxer" else stoat_migrate
platform_name = self.target_platform.capitalize() platform_name = self.target_platform.capitalize()
modal = ProgressScreen(log_level=self.config.log_level) if not modal:
self.app.push_screen(modal) modal = ProgressScreen(log_level=self.config.log_level)
await asyncio.sleep(0.1) self.app.push_screen(modal)
await asyncio.sleep(0.1)
try: try:
modal.show_info("[bold cyan]Waterfall Migration Ready[/bold cyan]", "Checking mapping and missing channels...") modal.show_info("[bold cyan]Waterfall Migration Ready[/bold cyan]", "Checking mapping and missing channels...")
modal.set_status("Connecting to Servers...") modal.set_status("Connecting to Servers...")
await self.engine.start_connections() await self.engine.start_connections()
# Ensure writer is validated before auto-matching (fixes NoneType role fetch error)
await self.engine.writer.validate()
modal.set_status("Synchronizing entity mappings...") modal.set_status("Synchronizing entity mappings...")
await self._perform_auto_matching() await self._perform_auto_matching()
@ -1460,15 +1651,19 @@ class OperationPane(Container):
prefix = "[bold cyan]📁[/bold cyan] " if mc.type == 4 else "[bold white]#[/bold white] " prefix = "[bold cyan]📁[/bold cyan] " if mc.type == 4 else "[bold white]#[/bold white] "
modal.write(f" {prefix}{mc.name}") modal.write(f" {prefix}{mc.name}")
choice = await modal.phase_wait_confirm( if is_autotest:
show_continue=False, choice = "btn_start_first"
show_id=True, modal.write("[bold cyan]Auto-Test: Automatically cloning missing channels.[/bold cyan]")
btn_start_label="Clone missing channels", else:
btn_id_label="Skip missing channels", choice = await modal.phase_wait_confirm(
btn_start_variant="primary", show_continue=False,
btn_start_tooltip=f"Automatically create {len(missing_channels)} entities on target", show_id=True,
btn_id_tooltip="Start migration without these channels" btn_start_label="Clone missing channels",
) btn_id_label="Skip missing channels",
btn_start_variant="primary",
btn_start_tooltip=f"Automatically create {len(missing_channels)} entities on target",
btn_id_tooltip="Start migration without these channels"
)
if choice == "btn_back": if choice == "btn_back":
modal.dismiss() modal.dismiss()
@ -1550,7 +1745,7 @@ class OperationPane(Container):
if filtered_tgt_ids: if filtered_tgt_ids:
all_mapped_tgt_ids = filtered_tgt_ids all_mapped_tgt_ids = filtered_tgt_ids
# 2.6 Resume Point: Calculate from global channel minimums # 2.6 Resume Point: Calculate from global channel minimums
min_last_id = self.engine.state.get_global_min_last_message_id(all_mapped_tgt_ids) min_last_id = self.engine.state.get_global_min_last_message_id(all_mapped_tgt_ids)
@ -1560,15 +1755,19 @@ class OperationPane(Container):
else: else:
modal.write("No previous migration state found. Starting from the beginning.") modal.write("No previous migration state found. Starting from the beginning.")
choice = await modal.phase_wait_confirm( if is_autotest:
show_continue=min_last_id is not None, choice = "btn_continue" if min_last_id is not None else "btn_start_first"
show_id=False, modal.write(f"[bold cyan]Auto-Test: Automatically choosing {choice.replace('btn_', '').replace('_', ' ')}.[/bold cyan]")
btn_start_label="Start From Beginning", else:
btn_start_tooltip="Wipes migration progress and restarts from the beginning; may create duplicates", choice = await modal.phase_wait_confirm(
btn_start_variant="default" if min_last_id is not None else "primary", show_continue=min_last_id is not None,
btn_continue_label=f"Continue from ID {min_last_id if min_last_id is not None else 0}" if min_last_id is not None else "Continue Migration", show_id=False,
btn_continue_tooltip="Fastest" btn_start_label="Start From Beginning",
) btn_start_tooltip="Wipes migration progress and restarts from the beginning; may create duplicates",
btn_start_variant="default" if min_last_id is not None else "primary",
btn_continue_label=f"Continue from ID {min_last_id if min_last_id is not None else 0}" if min_last_id is not None else "Continue Migration",
btn_continue_tooltip="Fastest"
)
if choice == "btn_back": if choice == "btn_back":
modal.dismiss() modal.dismiss()

View file

@ -8,7 +8,7 @@ from src.ui.mode_screen import ModeScreen
from src.core.configuration import AppConfig from src.core.configuration import AppConfig
import os import os
from textual.widgets import ListItem, ListView, Input, Button from textual.widgets import ListItem, ListView, Input, Button, Label
@ -18,6 +18,10 @@ def mock_configs(tmp_path, log):
reaper_dir.mkdir() reaper_dir.mkdir()
(reaper_dir / "reaper_config.yaml").write_text("discord_bot_token: 'fake'\ndiscord_server_id: '123'\ntool_mode: 'backup_only'") (reaper_dir / "reaper_config.yaml").write_text("discord_bot_token: 'fake'\ndiscord_server_id: '123'\ntool_mode: 'backup_only'")
autotest_dir = tmp_path / "ReaperFiles-AutoTest"
autotest_dir.mkdir()
(autotest_dir / "reaper_config.yaml").write_text("discord_bot_token: 'fake'\ndiscord_server_id: '123'\ntool_mode: 'backup_transfer'")
old_cwd = os.getcwd() old_cwd = os.getcwd()
os.chdir(tmp_path) os.chdir(tmp_path)
log(f"CWD changed to: {tmp_path}") log(f"CWD changed to: {tmp_path}")
@ -115,3 +119,46 @@ async def test_ui_operation_trigger(mock_configs, log):
except Exception as e: except Exception as e:
log(f"test_ui_operation_trigger FAILED: {e}") log(f"test_ui_operation_trigger FAILED: {e}")
raise raise
@pytest.mark.asyncio
async def test_ui_autotest_button(mock_configs, log):
"""Verify visibility and trigger of the AUTO TEST button."""
log("Running test_ui_autotest_button")
from src.ui.shuttle_ops import OperationPane
try:
with patch("src.ui.main_app.ConfigSelectionScreen.check_updates", AsyncMock()):
with patch.object(OperationPane, "run_validate", AsyncMock()):
app = ReaperApp()
async with app.run_test() as pilot:
await wait_for_screen(app, ConfigSelectionScreen)
# Find the AutoTest item in the list
lv = app.screen.query_one(ListView)
autotest_index = -1
for idx, item in enumerate(lv.children):
if item.name == "AutoTest":
autotest_index = idx
break
assert autotest_index != -1, "AutoTest profile not found in ListView"
target_item = lv.children[autotest_index]
await pilot.click(target_item)
assert await wait_for_screen(app, ModeScreen), "Timed out waiting for ModeScreen"
# Verify button is present
pane = app.screen.query_one(OperationPane)
btn = pane.query_one("#op_autotest", Button)
assert btn.display is True
assert "AUTO TEST" in str(btn.label)
# Mock the sequence and trigger it
with patch.object(OperationPane, "run_autotest_sequence", AsyncMock()) as mock_seq:
btn.disabled = False
await pilot.pause(0.1)
btn.focus()
await pilot.press("enter")
await pilot.pause(0.1)
assert mock_seq.called
log("test_ui_autotest_button PASSED")
except Exception as e:
log(f"test_ui_autotest_button FAILED: {e}")
raise