diff --git a/src/components/DashboardView.tsx b/src/components/DashboardView.tsx index 29dac3e5..96f90d4b 100644 --- a/src/components/DashboardView.tsx +++ b/src/components/DashboardView.tsx @@ -39,6 +39,9 @@ import { getInterfaceLangKey } from '@/i18n'; import { getMxuSpecialTask } from '@/types/specialTasks'; import { isTaskCompatible } from '@/stores/helpers'; import { isPretaskName, getPretaskItem, buildPretaskArgs, buildPretaskDef } from '@/types/pretasks'; +import { getWeekdayOf, filterTasksForRun, createTaskRunSelection } from '@/utils/taskRunFilter'; +import { resolveTaskLabel } from '@/utils/taskLabel'; +import { useCurrentWeekday } from '@/hooks/useCurrentWeekday'; import { startGlobalCallbackListener } from '@/components/connection/callbackCache'; import { stopInstanceTasks } from '@/services/taskStopService'; import { buildPiEnvVars } from '@/utils/piEnv'; @@ -76,6 +79,7 @@ function InstanceCard({ instanceId, instanceName, isActive, onSelect }: Instance // 任务控制相关 updateInstance, setInstanceTaskStatus, + setTaskRunSelection, setInstanceCurrentTaskId, clearScheduleExecution, basePath, @@ -116,8 +120,12 @@ function InstanceCard({ instanceId, instanceName, isActive, onSelect }: Instance const instance = instances.find((i) => i.id === instanceId); const isRunning = instance?.isRunning || false; const tasks = instance?.selectedTasks || []; - const enabledTasks = tasks.filter((t) => t.enabled); - const canRun = isConnected && isResourceLoaded && enabledTasks.length > 0; + // 周计划:只有已勾选、且今天未被周计划跳过的任务参与运行。 + // 本视图的启动流程是独立实现(不走 Toolbar.startTasksForInstance), + // 因此这里必须自行过滤,否则周计划在 Dashboard 视图下会失效。 + const todayWeekday = useCurrentWeekday(); + const runPreview = filterTasksForRun(tasks, todayWeekday); + const canRun = isConnected && isResourceLoaded && runPreview.tasks.length > 0; // 获取当前控制器和资源名(用于 pipeline override 生成) const currentControllerName = @@ -212,7 +220,7 @@ function InstanceCard({ instanceId, instanceName, isActive, onSelect }: Instance async (e: React.MouseEvent) => { e.stopPropagation(); - if (!instance || (!canRun && !isRunning)) return; + if (!instance) return; if (isRunning) { // 停止任务 @@ -230,7 +238,28 @@ function InstanceCard({ instanceId, instanceName, isActive, onSelect }: Instance } } else { // 启动任务 - if (!canRun) return; + if (!isConnected || !isResourceLoaded) return; + + // 以点击启动时刻和最新任务列表构建快照,运行中跨天不再改变本批任务。 + const runWeekday = getWeekdayOf(); + const runTasks = + useAppStore.getState().instances.find((item) => item.id === instanceId)?.selectedTasks || + []; + const runSelection = filterTasksForRun(runTasks, runWeekday); + const enabledTasks = runSelection.tasks; + setTaskRunSelection(instanceId, createTaskRunSelection(runSelection, runWeekday)); + + // 周计划:记录本次被跳过的任务,避免"任务莫名没跑" + runSelection.skippedByWeeklySchedule.forEach((task) => { + addLog(instanceId, { + type: 'info', + message: t('taskList.taskSkippedByWeeklySchedule', { + taskName: resolveTaskLabel(task, projectInterface, translations, t), + }), + }); + }); + + if (enabledTasks.length === 0) return; setIsStarting(true); @@ -433,12 +462,13 @@ function InstanceCard({ instanceId, instanceName, isActive, onSelect }: Instance instanceId, instanceName, isRunning, - canRun, - enabledTasks, + isConnected, + isResourceLoaded, projectInterface, basePath, updateInstance, setInstanceTaskStatus, + setTaskRunSelection, setInstanceCurrentTaskId, clearScheduleExecution, registerTaskIdName, @@ -781,7 +811,9 @@ function InstanceCard({ instanceId, instanceName, isActive, onSelect }: Instance ? t('taskList.stopTasks') : canRun ? t('taskList.startTasks') - : t('dashboard.noEnabledTasks') + : runPreview.skippedByWeeklySchedule.length > 0 + ? t('taskList.allTasksSkippedByWeeklySchedule') + : t('dashboard.noEnabledTasks') } > {isStarting || isStopping ? ( diff --git a/src/components/TaskItem.tsx b/src/components/TaskItem.tsx index e1195426..5dd6a304 100644 --- a/src/components/TaskItem.tsx +++ b/src/components/TaskItem.tsx @@ -12,6 +12,7 @@ import { AlertCircle, Play, CircleDot, + CalendarDays, } from 'lucide-react'; import { useAppStore, type TaskRunStatus } from '@/stores/appStore'; import { maaService } from '@/services/maaService'; @@ -30,10 +31,15 @@ import { isMxuSpecialTask, getMxuSpecialTask, findMxuOptionByKey } from '@/types import { isPretaskName, getPretaskItem, buildPretaskDef } from '@/types/pretasks'; import { getInterfaceLangKey } from '@/i18n'; import { stripInlineRichText } from '@/utils/richText'; +import { isTaskBlockedByWeeklySchedule, normalizeWeeklySchedule } from '@/utils/taskRunFilter'; +import { useCurrentWeekday } from '@/hooks/useCurrentWeekday'; import clsx from 'clsx'; import { loggers } from '@/utils/logger'; import { isPasswordInput } from '@/utils/passwordOptionValues'; +/** 周计划的全部星期(0=周日);"启用周计划"时默认全选,启用后行为与启用前一致,再由用户取消不需要的天 */ +const ALL_WEEKDAYS = [0, 1, 2, 3, 4, 5, 6]; + /** 选项预览标签组件 */ function OptionPreviewTag({ label, @@ -324,6 +330,7 @@ export function TaskItem({ instanceId, task }: TaskItemProps) { projectInterface, toggleTaskEnabled, setTaskRunOnce, + setTaskWeeklySchedule, toggleTaskExpanded, removeTaskFromInstance, confirmBeforeDelete, @@ -338,6 +345,7 @@ export function TaskItem({ instanceId, task }: TaskItemProps) { getActiveInstance, showOptionPreview, instanceTaskRunStatus, + instanceTaskRunSelection, instances, findMaaTaskIdBySelectedTaskId, basePath, @@ -346,12 +354,29 @@ export function TaskItem({ instanceId, task }: TaskItemProps) { removeAnimatingTaskId, } = useAppStore(); - // 获取任务运行状态 - const taskRunStatus: TaskRunStatus = instanceTaskRunStatus[instanceId]?.[task.id] || 'idle'; - // 获取实例运行状态 const instance = instances.find((i) => i.id === instanceId); const isInstanceRunning = instance?.isRunning || false; + const todayWeekday = useCurrentWeekday(); + const runSelection = instanceTaskRunSelection[instanceId]; + const backendTaskRunStatus: TaskRunStatus = + instanceTaskRunStatus[instanceId]?.[task.id] || 'idle'; + // 后端正在排队/执行的事实优先;刷新页面恢复的批次可能没有本地筛选快照。 + const hasCurrentExecution = + backendTaskRunStatus === 'pending' || backendTaskRunStatus === 'running'; + const weeklyScheduleBlockedToday = + !hasCurrentExecution && + (runSelection + ? runSelection.excludedByWeeklyScheduleIds.includes(task.id) + : !(isInstanceRunning && backendTaskRunStatus !== 'idle') && + isTaskBlockedByWeeklySchedule(task, todayWeekday)); + // 历史成功/失败不能冒充本次结果;显式单独运行、跨午夜时以启动快照为准。 + const taskRunStatus: TaskRunStatus = + !hasCurrentExecution && + (weeklyScheduleBlockedToday || + (runSelection && !runSelection.includedTaskIds.includes(task.id))) + ? 'idle' + : backendTaskRunStatus; // 获取任务定义 - 支持 MXU 内置特殊任务与 pretask 前置任务 const isMxuTask = isMxuSpecialTask(task.taskName); @@ -418,7 +443,10 @@ export function TaskItem({ instanceId, task }: TaskItemProps) { ]); // 紧凑模式:实例运行时,未参与运行的任务显示为紧凑样式 - const isCompact = isInstanceRunning && !task.enabled && !task.runOnce && taskRunStatus === 'idle'; + const isCompact = + isInstanceRunning && + (weeklyScheduleBlockedToday || (!task.enabled && !task.runOnce)) && + taskRunStatus === 'idle'; // 判断是否可以编辑选项:实例未运行时始终可以编辑,运行中只有 pending 或 idle 状态的任务可以编辑 const canEditOptions = @@ -550,8 +578,15 @@ export function TaskItem({ instanceId, task }: TaskItemProps) { const hasOptions = !!taskDef?.option && taskDef.option.length > 0; // 判断是否有描述内容(包括正在加载的情况) const hasDescription = !!resolvedDescription.html || resolvedDescription.loading; - // 有选项或有描述时都可以展开 - const canExpand = hasOptions || hasDescription; + // 周计划:0-6(0=周日);缺省或空数组表示未启用 + const weeklySchedule = useMemo( + () => normalizeWeeklySchedule(task.weeklySchedule) ?? [], + [task.weeklySchedule], + ); + const hasWeeklySchedule = weeklySchedule.length > 0; + const weekdayLabels = t('schedule.weekdays', { returnObjects: true }) as string[]; + // 有选项、有描述或启用了周计划时都可以展开 + const canExpand = hasOptions || hasDescription || hasWeeklySchedule; // 生成选项预览信息(最多显示3个) const optionPreviews = useMemo(() => { @@ -651,6 +686,16 @@ export function TaskItem({ instanceId, task }: TaskItemProps) { const checkboxState = getTaskCheckboxState(task.enabled, Boolean(task.runOnce)); + const handleToggleWeeklyScheduleDay = useCallback( + (day: number) => { + const next = weeklySchedule.includes(day) + ? weeklySchedule.filter((d) => d !== day) + : [...weeklySchedule, day]; + setTaskWeeklySchedule(instanceId, task.id, next); + }, + [weeklySchedule, instanceId, task.id, setTaskWeeklySchedule], + ); + const handleCheckboxClick = () => { if (isInstanceRunning || isIncompatible) return; toggleTaskEnabled(instanceId, task.id); @@ -676,6 +721,20 @@ export function TaskItem({ instanceId, task }: TaskItemProps) { disabled: !task.runOnce, onClick: () => setTaskRunOnce(instanceId, task.id, false), }, + { + id: 'weekly-schedule', + label: t('contextMenu.enableWeeklySchedule'), + icon: CalendarDays, + checked: hasWeeklySchedule, + onClick: () => { + const nextEnabled = !hasWeeklySchedule; + setTaskWeeklySchedule(instanceId, task.id, nextEnabled ? ALL_WEEKDAYS : []); + // 启用后展开卡片,否则收起状态下看不到周计划区域、没有任何反馈 + if (nextEnabled && !task.expanded) { + toggleTaskExpanded(instanceId, task.id); + } + }, + }, ]; showMenu(e, menuItems); @@ -683,11 +742,15 @@ export function TaskItem({ instanceId, task }: TaskItemProps) { [ t, task.runOnce, + task.expanded, + hasWeeklySchedule, instanceId, task.id, isInstanceRunning, isIncompatible, setTaskRunOnce, + setTaskWeeklySchedule, + toggleTaskExpanded, showMenu, ], ); @@ -915,9 +978,11 @@ export function TaskItem({ instanceId, task }: TaskItemProps) { title={ isIncompatible ? incompatibleReason - : checkboxState === 'once' - ? t('taskItem.runOnceHint') - : undefined + : weeklyScheduleBlockedToday + ? t('taskItem.weeklyScheduleSkippedTodayHint') + : checkboxState === 'once' + ? t('taskItem.runOnceHint') + : undefined } > {displayName} @@ -972,6 +1039,17 @@ export function TaskItem({ instanceId, task }: TaskItemProps) { )} + {weeklyScheduleBlockedToday && ( + + + {t('taskItem.weeklyScheduleSkippedToday')} + + )} + {/* 不带选项的任务:直接显示不兼容警告 */} {!canExpand && isIncompatible && (
@@ -1085,6 +1163,53 @@ export function TaskItem({ instanceId, task }: TaskItemProps) {
)} + {/* 周计划区域 - 仅在启用时显示 */} + {hasWeeklySchedule && ( +
+
+ + + {t('taskItem.weeklyScheduleTitle')} + + {weeklyScheduleBlockedToday && ( + + · {t('taskItem.weeklyScheduleSkippedToday')} + + )} +
+
+ {[1, 2, 3, 4, 5, 6, 0].map((day) => { + const selected = weeklySchedule.includes(day); + const isToday = day === todayWeekday; + const dayDisabled = isInstanceRunning || isIncompatible; + return ( + + ); + })} +
+
+ )} {/* 选项列表 - 仅在有选项时显示 */} {hasOptions && ( 0 && compatibleTasks.every((t) => t.enabled); }, [tasks, projectInterface, currentControllerName, currentResourceName]); - // 只要有启用的任务就可以运行(连接和资源加载会在 startTasksForInstance 中自动处理) - const canRun = tasks.some(isTaskSelectedForRun); + // 只要有"今天会被执行"的任务就可以运行(连接和资源加载会在 startTasksForInstance 中自动处理) + // 被周计划跳过的任务不计入;跨天或窗口恢复后自动刷新 + const todayWeekday = useCurrentWeekday(); + const runPreview = filterTasksForRun(tasks, todayWeekday); + const canRun = runPreview.tasks.length > 0; + // 已勾选的任务今天全部被周计划跳过:用于解释开始按钮为何不可用 + const allCheckedSkippedByWeeklySchedule = !canRun && tasks.some(isTaskChecked); const handleSelectAll = () => { if (!instance) return; @@ -336,18 +350,52 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr return false; }; - const tasksToRun = filterTasksForRun(targetTasks, { startFromTaskId, singleTaskId }); + // 运行中的批次快照不能被另一次启动请求覆盖。 + if (targetInstance.isRunning || preActionControlledInstanceIdRef.current === targetId) { + return failStart(t('taskList.autoConnect.alreadyRunning')); + } + + // 周计划以本次启动时刻的星期为准,整个运行期不再变化 + const runWeekday = getWeekdayOf(); + const runSelection = filterTasksForRun(targetTasks, runWeekday, { + startFromTaskId, + singleTaskId, + }); + const { tasks: tasksToRun, skippedByWeeklySchedule } = runSelection; + setTaskRunSelection(targetId, createTaskRunSelection(runSelection, runWeekday)); + + // 被周计划跳过的任务逐条记录日志(不算失败,其余任务照常执行) + const logWeeklySkipped = () => { + skippedByWeeklySchedule.forEach((task) => { + addLog(targetId, { + type: 'info', + message: t('taskList.taskSkippedByWeeklySchedule', { + taskName: resolveTaskLabel(task, projectInterface, translations, t), + }), + }); + }); + }; + if (tasksToRun.length === 0) { if (singleTaskId || startFromTaskId) { return failStart(t('taskList.autoConnect.taskNotFound')); } + // 已勾选的任务全部因周计划被跳过:属于正常情况,不按启动失败处理 + if (skippedByWeeklySchedule.length > 0) { + logWeeklySkipped(); + addLog(targetId, { + type: 'info', + message: t('taskList.allTasksSkippedByWeeklySchedule'), + }); + log.info(`实例 ${targetInstance.name}: 今日无任务需执行(已勾选任务全部被周计划跳过)`); + onPhaseChange?.('idle'); + return false; + } return failStart(t('dashboard.noEnabledTasks')); } - // 检查是否正在运行 - if (targetInstance.isRunning || preActionControlledInstanceIdRef.current === targetId) { - return failStart(t('taskList.autoConnect.alreadyRunning')); - } + // 部分任务被周计划跳过:记录日志后继续执行其余任务 + logWeeklySkipped(); // 获取控制器和资源配置:必须按 targetInstance 解析,定时任务启动的实例可能不是当前标签页 const controllerName = @@ -1229,6 +1277,8 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr }) : undefined; + // 连接切换可能清理本地运行缓存;提交前恢复同一份筛选快照。 + setTaskRunSelection(targetId, createTaskRunSelection(runSelection, runWeekday)); updateInstance(targetId, { isRunning: true }); setInstanceTaskStatus(targetId, 'Running'); setShowAddTaskPanel(false); @@ -1340,6 +1390,8 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr }, [ projectInterface, + translations, + setTaskRunSelection, basePath, selectedController, selectedResource, @@ -1399,7 +1451,7 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr if (started) { log.info(`定时任务启动成功: 实例 "${inst.name}"`); } else { - log.warn(`定时任务启动失败或跳过: 实例 "${inst.name}"`); + log.info(`定时策略触发后未启动任务: 实例 "${inst.name}"`); } return started; @@ -1494,8 +1546,13 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr } } else { // 启动任务 - if (!canRun) { - log.warn('无法运行任务:没有启用的任务'); + const runWeekday = getWeekdayOf(); + if (!tasks.some((task) => isTaskSelectedForRun(task, runWeekday))) { + log.warn( + tasks.some(isTaskChecked) + ? '无法运行任务:已勾选的任务今天均被周计划跳过' + : '无法运行任务:没有启用的任务', + ); return; } @@ -1649,7 +1706,7 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr // eslint-disable-next-line react-hooks/exhaustive-deps }, [instance?.id, instance?.isRunning, isStopping]); - // canRun 只检查是否有启用的任务;运行中时按钮用于停止,不应禁用 + // canRun 已计入周计划;运行中时按钮用于停止,不应禁用 const isDisabled = (tasks.length === 0 || !canRun) && !isStartStopRunning; // 获取启动按钮的文本 @@ -1674,6 +1731,10 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr if (autoConnectError) { return autoConnectError; } + // 有勾选任务但今天都不能跑时,说明按钮为何不可用 + if (allCheckedSkippedByWeeklySchedule) { + return t('taskList.allTasksSkippedByWeeklySchedule'); + } return undefined; }; diff --git a/src/hooks/useCurrentWeekday.ts b/src/hooks/useCurrentWeekday.ts new file mode 100644 index 00000000..b6b1582f --- /dev/null +++ b/src/hooks/useCurrentWeekday.ts @@ -0,0 +1,7 @@ +import { useSyncExternalStore } from 'react'; +import { getCurrentWeekday, subscribeCurrentWeekday } from '@/utils/currentWeekday'; + +/** 跨午夜或窗口恢复时刷新,供周计划提示与启动按钮共用。 */ +export function useCurrentWeekday(): number { + return useSyncExternalStore(subscribeCurrentWeekday, getCurrentWeekday, getCurrentWeekday); +} diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index dd052f03..87500139 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -223,6 +223,9 @@ export default { stopTasks: 'Stop Tasks', startingTasks: 'Starting...', stoppingTasks: 'Stopping...', + taskSkippedByWeeklySchedule: + '"{{taskName}}" will not run today: its weekly schedule does not include today', + allTasksSkippedByWeeklySchedule: 'No tasks started: no selected tasks are scheduled for today', tasksSkippedDueToIncompatibility: '{{count}} incompatible task(s) skipped', taskSkippedController: 'Task "{{taskName}}" does not support current controller', taskSkippedResource: 'Task "{{taskName}}" does not support current resource', @@ -281,6 +284,11 @@ export default { rename: 'Rename', clickToToggle: 'Click to toggle', runOnceHint: 'Run once: included in the next start only', + weeklyScheduleTitle: 'Weekly schedule', + weeklyScheduleSkippedToday: 'Not included today', + weeklyScheduleSkippedTodayHint: + 'Still selected, but today is outside its weekly schedule, so a normal start will not add it to the execution queue. Run it individually or select Run Once to include it.', + weeklyScheduleToday: 'Today', renameTask: 'Rename Task', customName: 'Custom Name', originalName: 'Original Name', @@ -522,7 +530,7 @@ export default { taskFailed: 'Task failed: {{name}}', stopTask: 'Stop Task', // Schedule messages - scheduleStarting: 'Scheduled execution started [{{policy}}] {{time}}', + scheduleStarting: 'Schedule triggered [{{policy}}] {{time}}; checking tasks to run', // Agent messages agentStarting: 'Agent starting...', agentStarted: 'Agent started', @@ -871,6 +879,7 @@ export default { runSingleTask: 'Run This Task Only', runOnceTask: 'Run Once', clearRunOnceTask: 'Clear Run Once', + enableWeeklySchedule: 'Enable Weekly Schedule', // Screenshot panel context menu reconnect: 'Reconnect', diff --git a/src/i18n/locales/ja-JP.ts b/src/i18n/locales/ja-JP.ts index 52e6a68e..cb1e8e88 100644 --- a/src/i18n/locales/ja-JP.ts +++ b/src/i18n/locales/ja-JP.ts @@ -221,6 +221,10 @@ export default { stopTasks: '実行停止', startingTasks: '開始中...', stoppingTasks: '停止中...', + taskSkippedByWeeklySchedule: + 'タスク「{{taskName}}」は本日の実行対象外です:週間スケジュールに本日は含まれていません', + allTasksSkippedByWeeklySchedule: + 'タスクは開始されませんでした:選択中のタスクに本日の週間スケジュールに合うものがありません', checkboxTaskScope: 'タスク「{{task}}」', checkboxGlobalScope: 'グローバル設定', checkboxMinimumNotMet: @@ -276,6 +280,11 @@ export default { rename: '名前を変更', clickToToggle: 'クリックで切替', runOnceHint: '単発実行:次回起動時に1回だけ実行', + weeklyScheduleTitle: '週間スケジュール', + weeklyScheduleSkippedToday: '本日は対象外', + weeklyScheduleSkippedTodayHint: + '選択は維持されますが、本日は週間スケジュール外のため、通常の開始操作では実行キューに追加されません。個別に実行するか、単発実行に設定できます。', + weeklyScheduleToday: '今日', renameTask: 'タスク名を変更', customName: 'カスタム名', originalName: '元の名前', @@ -521,7 +530,7 @@ export default { taskFailed: 'タスクが失敗しました: {{name}}', stopTask: 'タスクを停止', // スケジュールメッセージ - scheduleStarting: 'スケジュール実行を開始 [{{policy}}] {{time}}', + scheduleStarting: 'スケジュールが発動 [{{policy}}] {{time}}、実行対象のタスクを確認中', // Agent メッセージ agentStarting: 'Agent を起動中...', agentStarted: 'Agent が起動しました', @@ -873,6 +882,7 @@ export default { runSingleTask: 'このタスクのみ実行', runOnceTask: '単発実行', clearRunOnceTask: '単発実行を解除', + enableWeeklySchedule: '週間スケジュールを有効化', // スクリーンショットパネルのコンテキストメニュー reconnect: '再接続', diff --git a/src/i18n/locales/ko-KR.ts b/src/i18n/locales/ko-KR.ts index 04917402..821be5f6 100644 --- a/src/i18n/locales/ko-KR.ts +++ b/src/i18n/locales/ko-KR.ts @@ -218,6 +218,10 @@ export default { stopTasks: '실행 중지', startingTasks: '시작 중...', stoppingTasks: '중지 중...', + taskSkippedByWeeklySchedule: + '작업 「{{taskName}}」은 오늘 실행 대상이 아닙니다: 주간 일정에 오늘이 포함되어 있지 않습니다', + allTasksSkippedByWeeklySchedule: + '작업을 시작하지 않았습니다: 선택한 작업 중 오늘의 주간 일정에 맞는 작업이 없습니다', checkboxTaskScope: '작업 「{{task}}」', checkboxGlobalScope: '전역 설정', checkboxMinimumNotMet: @@ -271,6 +275,11 @@ export default { rename: '이름 변경', clickToToggle: '클릭하여 전환', runOnceHint: '1회 실행: 다음 시작 시 한 번만 실행', + weeklyScheduleTitle: '주간 일정', + weeklyScheduleSkippedToday: '오늘은 실행 대상 아님', + weeklyScheduleSkippedTodayHint: + '선택은 유지되지만 오늘은 주간 일정에 없어 일반적으로 시작할 때 실행 대기열에 추가되지 않습니다. 개별 실행하거나 1회 실행으로 설정할 수 있습니다.', + weeklyScheduleToday: '오늘', renameTask: '작업 이름 변경', customName: '사용자 지정 이름', originalName: '원래 이름', @@ -512,7 +521,7 @@ export default { taskFailed: '작업 실패: {{name}}', stopTask: '작업 중지', // 예약 메시지 - scheduleStarting: '예약 실행 시작 [{{policy}}] {{time}}', + scheduleStarting: '예약 일정 도달 [{{policy}}] {{time}}, 실행할 작업 확인 중', // Agent 메시지 agentStarting: 'Agent 시작 중...', agentStarted: 'Agent가 시작되었습니다', @@ -860,6 +869,7 @@ export default { runSingleTask: '이 작업만 실행', runOnceTask: '1회 실행', clearRunOnceTask: '1회 실행 취소', + enableWeeklySchedule: '주간 일정 활성화', // 스크린샷 패널 컨텍스트 메뉴 reconnect: '다시 연결', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index cdbca16a..985e7e1f 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -214,6 +214,8 @@ export default { stopTasks: '停止任务', startingTasks: '启动中...', stoppingTasks: '停止中...', + taskSkippedByWeeklySchedule: '任务「{{taskName}}」今日不参与:周计划未安排今天', + allTasksSkippedByWeeklySchedule: '未启动任务:今天没有符合周计划的已选任务', tasksSkippedDueToIncompatibility: '已跳过 {{count}} 个不兼容的任务', taskSkippedController: '任务 "{{taskName}}" 不支持当前控制器', taskSkippedResource: '任务 "{{taskName}}" 不支持当前资源', @@ -270,6 +272,11 @@ export default { rename: '重命名', clickToToggle: '单击选中/取消', runOnceHint: '单次运行:下次启动时执行一次', + weeklyScheduleTitle: '周计划', + weeklyScheduleSkippedToday: '今日不参与', + weeklyScheduleSkippedTodayHint: + '已保持勾选,但今天不在周计划内,常规开始时不会加入执行队列;可单独运行或设为单次运行', + weeklyScheduleToday: '今天', renameTask: '重命名任务', customName: '自定义名称', originalName: '原始名称', @@ -505,7 +512,7 @@ export default { taskFailed: '任务失败: {{name}}', stopTask: '停止任务', // 定时任务消息 - scheduleStarting: '定时执行开始 [{{policy}}] {{time}}', + scheduleStarting: '定时策略已触发 [{{policy}}] {{time}},正在检查待执行任务', // Agent 消息 agentStarting: 'Agent 正在启动...', agentStarted: 'Agent 已启动', @@ -858,6 +865,7 @@ export default { runSingleTask: '单独运行', runOnceTask: '单次运行', clearRunOnceTask: '取消单次运行', + enableWeeklySchedule: '启用周计划', // 截图面板右键菜单 reconnect: '重新连接', diff --git a/src/i18n/locales/zh-TW.ts b/src/i18n/locales/zh-TW.ts index fa4098b9..c3829582 100644 --- a/src/i18n/locales/zh-TW.ts +++ b/src/i18n/locales/zh-TW.ts @@ -214,6 +214,8 @@ export default { stopTasks: '停止任務', startingTasks: '啟動中...', stoppingTasks: '停止中...', + taskSkippedByWeeklySchedule: '任務「{{taskName}}」今日不參與:週計畫未安排今天', + allTasksSkippedByWeeklySchedule: '未啟動任務:今天沒有符合週計畫的已選任務', checkboxTaskScope: '任務「{{task}}」', checkboxGlobalScope: '全域設定', checkboxMinimumNotMet: @@ -266,6 +268,11 @@ export default { rename: '重新命名', clickToToggle: '單擊選中/取消', runOnceHint: '單次執行:下次啟動時執行一次', + weeklyScheduleTitle: '週計畫', + weeklyScheduleSkippedToday: '今日不參與', + weeklyScheduleSkippedTodayHint: + '已保持勾選,但今天不在週計畫內,一般啟動時不會加入執行佇列;可單獨執行或設為單次執行', + weeklyScheduleToday: '今天', renameTask: '重新命名任務', customName: '自訂名稱', originalName: '原始名稱', @@ -501,7 +508,7 @@ export default { taskFailed: '任務失敗: {{name}}', stopTask: '停止任務', // 定時任務訊息 - scheduleStarting: '定時執行開始 [{{policy}}] {{time}}', + scheduleStarting: '定時策略已觸發 [{{policy}}] {{time}},正在檢查待執行任務', // Agent 訊息 agentStarting: 'Agent 正在啟動...', agentStarted: 'Agent 已啟動', @@ -843,6 +850,7 @@ export default { runSingleTask: '單獨執行', runOnceTask: '單次執行', clearRunOnceTask: '取消單次執行', + enableWeeklySchedule: '啟用週計畫', // 截圖面板右鍵選單 reconnect: '重新連接', diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index 7966539d..a00d27bc 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -77,6 +77,7 @@ import { } from './helpers'; import { persistRuntimeLogs } from '@/utils/runtimeLogPersistence'; import { cacheTaskEnabledForController } from '@/utils/taskControllerCache'; +import { normalizeWeeklySchedule } from '@/utils/taskRunFilter'; // 从独立模块导入类型和辅助函数 import type { AppState, LogEntry, TaskRunStatus } from './types'; @@ -509,6 +510,7 @@ export const useAppStore = create()( instanceToClose.controllerName, t.enabled, ), + weeklySchedule: normalizeWeeklySchedule(t.weeklySchedule), optionValues: t.optionValues, expanded: t.expanded, collapsedOptions: t.collapsedOptions, @@ -845,6 +847,21 @@ export const useAppStore = create()( ), })), + setTaskWeeklySchedule: (instanceId, taskId, weekdays) => + set((state) => ({ + instances: state.instances.map((i) => + i.id === instanceId + ? { + ...i, + selectedTasks: i.selectedTasks.map((t) => { + if (t.id !== taskId) return t; + return { ...t, weeklySchedule: normalizeWeeklySchedule(weekdays) }; + }), + } + : i, + ), + })), + toggleTaskExpanded: (instanceId, taskId) => set((state) => ({ instances: state.instances.map((i) => @@ -1329,6 +1346,7 @@ export const useAppStore = create()( customName: t.customName, enabled: t.enabled, enabledByController: t.enabledByController, + weeklySchedule: normalizeWeeklySchedule(t.weeklySchedule), optionValues: t.optionValues, expanded: prevExpandedByTask.get(t.id) ?? t.expanded ?? false, collapsedOptions: prevCollapsedByTask.get(t.id) ?? t.collapsedOptions, @@ -1357,6 +1375,7 @@ export const useAppStore = create()( customName: t.customName, enabled: t.enabled, enabledByController: t.enabledByController, + weeklySchedule: normalizeWeeklySchedule(t.weeklySchedule), optionValues: mergedValues, expanded: prevExpandedByTask.get(t.id) ?? t.expanded ?? false, collapsedOptions: prevCollapsedByTask.get(t.id) ?? t.collapsedOptions, @@ -1385,6 +1404,7 @@ export const useAppStore = create()( customName: t.customName, enabled: t.enabled, enabledByController: t.enabledByController, + weeklySchedule: normalizeWeeklySchedule(t.weeklySchedule), optionValues: mergedValues, expanded: prevExpandedByTask.get(t.id) ?? t.expanded ?? false, collapsedOptions: prevCollapsedByTask.get(t.id) ?? t.collapsedOptions, @@ -1860,6 +1880,7 @@ export const useAppStore = create()( const resourceLoaded: Record = {}; const taskStatus: Record = {}; const instanceTaskRunStatus: Record> = {}; + const instanceTaskRunSelection = { ...currentState.instanceTaskRunSelection }; const maaTaskIdMapping: Record> = {}; const instancePendingTaskIds: Record = {}; const instanceCurrentTaskIndex: Record = {}; @@ -1895,6 +1916,24 @@ export const useAppStore = create()( } maaTaskIdMapping[instanceId] = mappings; + // 另一端可能已启动新批次(甚至瞬时完成);新任务 ID 证明它不是旧批次残留。 + const previousMappings = currentState.maaTaskIdMapping[instanceId] ?? {}; + const hasNewTasks = Object.entries(mappings).some( + ([maaId, selectedId]) => previousMappings[Number(maaId)] !== selectedId, + ); + const selection = instanceTaskRunSelection[instanceId]; + const includedTaskIds = [...new Set(Object.values(mappings))]; + if ( + hasNewTasks && + (!selection || includedTaskIds.some((id) => !selection.includedTaskIds.includes(id))) + ) { + instanceTaskRunSelection[instanceId] = { + weekday: new Date().getDay(), + includedTaskIds, + excludedByWeeklyScheduleIds: [], + }; + } + instancePendingTaskIds[instanceId] = trs.pendingTaskIds ?? []; instanceCurrentTaskIndex[instanceId] = trs.currentTaskIndex ?? 0; @@ -1920,6 +1959,7 @@ export const useAppStore = create()( instanceResourceLoaded: resourceLoaded, instanceTaskStatus: taskStatus, instanceTaskRunStatus, + instanceTaskRunSelection, maaTaskIdMapping, instancePendingTaskIds, instanceCurrentTaskIndex, @@ -2183,6 +2223,7 @@ export const useAppStore = create()( customName: t.customName, enabled: t.enabled, enabledByController: t.enabledByController ? { ...t.enabledByController } : undefined, + weeklySchedule: normalizeWeeklySchedule(t.weeklySchedule), optionValues: restoreOptionValuesFromConfig(t.optionValues, pi, pi?.name), expanded: t.expanded ?? false, collapsedOptions: t.collapsedOptions ? { ...t.collapsedOptions } : undefined, @@ -2222,6 +2263,11 @@ export const useAppStore = create()( // 任务运行状态(只读缓存,由 restoreBackendStates 填充) instanceTaskRunStatus: {}, + instanceTaskRunSelection: {}, + setTaskRunSelection: (instanceId, selection) => + set((state) => ({ + instanceTaskRunSelection: { ...state.instanceTaskRunSelection, [instanceId]: selection }, + })), maaTaskIdMapping: {}, instancePendingTaskIds: {}, instanceCurrentTaskIndex: {}, @@ -2245,6 +2291,9 @@ export const useAppStore = create()( clearTaskRunStatus: (instanceId) => { set((state) => ({ + instanceTaskRunSelection: Object.fromEntries( + Object.entries(state.instanceTaskRunSelection).filter(([id]) => id !== instanceId), + ), instanceTaskRunStatus: Object.fromEntries( Object.entries(state.instanceTaskRunStatus).filter(([id]) => id !== instanceId), ), @@ -2460,6 +2509,7 @@ function generateConfig(): MxuConfig { inst.controllerName, t.enabled, ), + weeklySchedule: normalizeWeeklySchedule(t.weeklySchedule), optionValues: t.optionValues, expanded: t.expanded, collapsedOptions: t.collapsedOptions, diff --git a/src/stores/types.ts b/src/stores/types.ts index 6ba8cf33..97efed83 100644 --- a/src/stores/types.ts +++ b/src/stores/types.ts @@ -24,6 +24,7 @@ import type { GamescopeInstance, } from '@/types/maa'; import type { AccentColor, CustomAccent } from '@/themes'; +import type { TaskRunSelection } from '@/utils/taskRunFilter'; /** 单个任务的运行状态 */ export type TaskRunStatus = 'idle' | 'pending' | 'running' | 'succeeded' | 'failed'; @@ -202,6 +203,8 @@ export interface AppState { reorderTasks: (instanceId: string, oldIndex: number, newIndex: number) => void; toggleTaskEnabled: (instanceId: string, taskId: string) => void; setTaskRunOnce: (instanceId: string, taskId: string, runOnce: boolean) => void; + /** 设置任务的周计划(0-6,0=周日);传空数组表示取消周计划 */ + setTaskWeeklySchedule: (instanceId: string, taskId: string, weekdays: number[]) => void; clearAllTaskRunOnce: (instanceId: string) => void; toggleTaskExpanded: (instanceId: string, taskId: string) => void; toggleOptionCollapsed: (instanceId: string, taskId: string, optionKey: string) => void; @@ -491,6 +494,9 @@ export interface AppState { // 任务运行状态(只读缓存,由 restoreBackendStates 从后端填充) instanceTaskRunStatus: Record>; + /** 本地启动参与快照,与后端执行状态分开,不写入配置。 */ + instanceTaskRunSelection: Record; + setTaskRunSelection: (instanceId: string, selection: TaskRunSelection) => void; maaTaskIdMapping: Record>; instancePendingTaskIds: Record; instanceCurrentTaskIndex: Record; diff --git a/src/types/config.ts b/src/types/config.ts index 0931274b..16a0b7ad 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -20,6 +20,11 @@ export interface SavedTask { taskName: string; // 对应 interface 中的 task.name customName?: string; // 用户自定义名称 enabled: boolean; + /** + * 周计划:仅在选中的星期几参与运行(0-6,0=周日)。 + * 缺省或空数组表示未启用周计划。旧配置中不存在,向后兼容。 + */ + weeklySchedule?: number[]; /** 各控制器独立的勾选状态(旧配置中不存在时按 enabled 初始化) */ enabledByController?: Record; optionValues: Record; diff --git a/src/types/interface.ts b/src/types/interface.ts index 523f5b7d..b9fec37f 100644 --- a/src/types/interface.ts +++ b/src/types/interface.ts @@ -346,6 +346,12 @@ export interface SelectedTask { enabled: boolean; /** 单次运行:下次启动时包含该任务,运行结束后自动清除 */ runOnce?: boolean; + /** + * 周计划:仅在选中的星期几参与运行(0-6,0=周日)。 + * 缺省或空数组表示未启用周计划,即每天都会参与。 + * 显式操作(单次运行 / 从此处运行的锚点)不受周计划约束。 + */ + weeklySchedule?: number[]; /** 各控制器独立的勾选状态;enabled 始终表示当前控制器的状态 */ enabledByController?: Record; optionValues: Record; diff --git a/src/utils/currentWeekday.ts b/src/utils/currentWeekday.ts new file mode 100644 index 00000000..00a30d2a --- /dev/null +++ b/src/utils/currentWeekday.ts @@ -0,0 +1,40 @@ +const listeners = new Set<() => void>(); +let timer: ReturnType | undefined; + +/** 本地自然日的星期,与任务周计划使用同一时间基准。 */ +export function getCurrentWeekday(): number { + return new Date().getDay(); +} + +function scheduleRefresh(): void { + const now = new Date(); + const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); + // 午夜准时更新;每分钟重新校准,兼顾系统时间或时区被修改的情况。 + timer = setTimeout(refresh, Math.min(midnight.getTime() - now.getTime(), 60_000)); +} + +function refresh(): void { + clearTimeout(timer); + for (const listener of listeners) listener(); + if (listeners.size > 0) scheduleRefresh(); +} + +/** 所有组件共享一个时钟;休眠或后台恢复后立即重新读取日期。 */ +export function subscribeCurrentWeekday(listener: () => void): () => void { + listeners.add(listener); + if (listeners.size === 1) { + scheduleRefresh(); + window.addEventListener('focus', refresh); + document.addEventListener('visibilitychange', refresh); + } + + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + clearTimeout(timer); + timer = undefined; + window.removeEventListener('focus', refresh); + document.removeEventListener('visibilitychange', refresh); + } + }; +} diff --git a/src/utils/tabExportImport.ts b/src/utils/tabExportImport.ts index 281cc174..278214e4 100644 --- a/src/utils/tabExportImport.ts +++ b/src/utils/tabExportImport.ts @@ -3,6 +3,7 @@ import type { ActionConfig, Instance, OptionDefinition, OptionValue } from '@/ty import { encryptPasswordOptionValues } from '@/utils/passwordOptionValues'; import { isTauri } from '@/utils/paths'; import { cacheTaskEnabledForController } from '@/utils/taskControllerCache'; +import { normalizeWeeklySchedule } from '@/utils/taskRunFilter'; import { toast } from 'sonner'; const PROTOCOL_SEGMENT = 'tab-sharing'; @@ -38,6 +39,7 @@ interface WireTask { cn?: string; // customName e: boolean; // enabled ec?: Record; // enabledByController + ws?: number[]; // weeklySchedule ov: Record; // optionValues } @@ -94,6 +96,7 @@ function encodeTask(task: SavedTask): WireTask { }; if (task.customName !== undefined) wire.cn = task.customName; if (task.enabledByController !== undefined) wire.ec = task.enabledByController; + if (task.weeklySchedule !== undefined) wire.ws = task.weeklySchedule; return wire; } @@ -151,6 +154,7 @@ function decodeTask(w: WireTask): SavedTask { customName: w.cn, enabled: w.e, enabledByController: w.ec, + weeklySchedule: normalizeWeeklySchedule(w.ws), optionValues: Object.fromEntries( Object.entries(w.ov).map(([k, v]) => [k, decodeOptionValue(v)]), ), @@ -271,6 +275,7 @@ export async function buildTabConfigExportText( taskName: t.taskName, customName: t.customName, enabled: t.enabled, + weeklySchedule: normalizeWeeklySchedule(t.weeklySchedule), enabledByController: cacheTaskEnabledForController( t.enabledByController, instance.controllerName, diff --git a/src/utils/taskLabel.ts b/src/utils/taskLabel.ts new file mode 100644 index 00000000..9f1de131 --- /dev/null +++ b/src/utils/taskLabel.ts @@ -0,0 +1,39 @@ +import type { ProjectInterface, SelectedTask } from '@/types/interface'; +import { getMxuSpecialTask } from '@/types/specialTasks'; +import { resolveI18nText } from '@/services/contentResolver'; +import { isPretaskName, getPretaskItem, buildPretaskDef } from '@/types/pretasks'; + +/** + * 解析任务在运行日志 / UI 中的显示标签。 + * + * 优先级:自定义名 → MXU 特殊任务 i18n → pretask / interface 任务 i18n → taskName 兜底。 + * MXU 存在两套独立的启动流程(Toolbar 与 DashboardView),显示名解析逻辑放在这里共用。 + */ +export function resolveTaskLabel( + task: Pick, + projectInterface: ProjectInterface | null | undefined, + translations: Record | undefined, + translate: (key: string) => string, +): string { + if (task.customName) return task.customName; + + const specialTask = getMxuSpecialTask(task.taskName); + if (specialTask?.taskDef.label) { + return translate(specialTask.taskDef.label); + } + + if (isPretaskName(task.taskName)) { + const item = getPretaskItem(projectInterface, task.taskName); + const pretaskDef = item ? buildPretaskDef(item) : undefined; + if (pretaskDef?.label) { + return resolveI18nText(pretaskDef.label, translations); + } + } + + const taskDef = projectInterface?.task.find((item) => item.name === task.taskName); + if (taskDef?.label) { + return resolveI18nText(taskDef.label, translations); + } + + return task.taskName; +} diff --git a/src/utils/taskRunFilter.ts b/src/utils/taskRunFilter.ts index a334e762..7817403a 100644 --- a/src/utils/taskRunFilter.ts +++ b/src/utils/taskRunFilter.ts @@ -7,35 +7,128 @@ export interface TaskRunFilterOptions { singleTaskId?: string; } -/** 判断任务是否应在常规启动时被包含 */ -export function isTaskSelectedForRun(task: SelectedTask): boolean { +/** 获取指定时刻的星期(0-6,0=周日),用于周计划判断 */ +export function getWeekdayOf(date: Date = new Date()): number { + return date.getDay(); +} + +/** 任务是否被用户选中参与运行(勾选或单次运行) */ +export function isTaskChecked(task: SelectedTask): boolean { return task.enabled || Boolean(task.runOnce); } -/** 根据运行模式筛选待执行任务 */ +/** 清理配置或共享数据中的周计划;没有有效星期时视为未启用。 */ +export function normalizeWeeklySchedule(value: unknown): number[] | undefined { + if (!Array.isArray(value)) return undefined; + const weekdays = value.filter( + (day): day is number => + typeof day === 'number' && Number.isInteger(day) && day >= 0 && day <= 6, + ); + const normalized = [...new Set(weekdays)].sort((a, b) => a - b); + return normalized.length > 0 ? normalized : undefined; +} + +/** + * 判断任务是否被周计划放行。 + * 缺省或空数组表示未启用周计划,一律放行。 + */ +export function isTaskAllowedByWeeklySchedule(task: SelectedTask, todayWeekday: number): boolean { + const schedule = normalizeWeeklySchedule(task.weeklySchedule); + if (!schedule) { + return true; + } + return schedule.includes(todayWeekday); +} + +/** + * 任务是否因周计划而在今天被跳过。 + * - 单次运行(runOnce)是用户的显式意图,优先级高于周计划,不会被跳过; + * - 未勾选的任务不属于"被跳过"。 + */ +export function isTaskBlockedByWeeklySchedule(task: SelectedTask, todayWeekday: number): boolean { + if (task.runOnce) return false; + if (!task.enabled) return false; + return !isTaskAllowedByWeeklySchedule(task, todayWeekday); +} + +/** + * 判断任务是否应在常规启动时被包含。 + * 显式操作路径(单次运行、从此处运行的锚点)不受周计划约束。 + */ +export function isTaskSelectedForRun(task: SelectedTask, todayWeekday: number): boolean { + return isTaskChecked(task) && !isTaskBlockedByWeeklySchedule(task, todayWeekday); +} + +export interface TaskRunFilterResult { + /** 本次实际参与运行的任务 */ + tasks: SelectedTask[]; + /** 已勾选、但因周计划不含今天而被跳过的任务(仅常规路径会产生) */ + skippedByWeeklySchedule: SelectedTask[]; +} + +/** 启动时的参与快照;未纳入不是一种任务执行结果。 */ +export interface TaskRunSelection { + weekday: number; + includedTaskIds: string[]; + excludedByWeeklyScheduleIds: string[]; +} + +export function createTaskRunSelection( + result: TaskRunFilterResult, + weekday: number, +): TaskRunSelection { + return { + weekday, + includedTaskIds: result.tasks.map((task) => task.id), + excludedByWeeklyScheduleIds: result.skippedByWeeklySchedule.map((task) => task.id), + }; +} + +/** 提取"已勾选但今天不在周计划内"的任务(未勾选、单次运行的任务不算) */ +function collectWeeklySkipped(tasks: SelectedTask[], todayWeekday: number): SelectedTask[] { + return tasks.filter((task) => isTaskBlockedByWeeklySchedule(task, todayWeekday)); +} + +/** + * 根据运行模式筛选待执行任务。 + * + * 周计划仅在常规启动路径生效: + * - `singleTaskId`(单独运行)为显式操作,不做周计划过滤; + * - `startFromTaskId`(从此处运行)的锚点任务为显式操作,不做周计划过滤, + * 锚点之后的任务仍按周计划筛选。 + */ export function filterTasksForRun( tasks: SelectedTask[], + todayWeekday: number, options?: TaskRunFilterOptions, -): SelectedTask[] { +): TaskRunFilterResult { + // 仅运行指定任务:显式操作,绕过周计划 if (options?.singleTaskId) { const task = tasks.find((t) => t.id === options.singleTaskId); - return task ? [task] : []; + return { tasks: task ? [task] : [], skippedByWeeklySchedule: [] }; } let startIndex = 0; if (options?.startFromTaskId) { const idx = tasks.findIndex((t) => t.id === options.startFromTaskId); - if (idx < 0) return []; + if (idx < 0) return { tasks: [], skippedByWeeklySchedule: [] }; startIndex = idx; } const sliced = tasks.slice(startIndex); - if (sliced.length === 0) return []; + if (sliced.length === 0) return { tasks: [], skippedByWeeklySchedule: [] }; + // 从此处运行:锚点任务为显式操作,绕过周计划 if (options?.startFromTaskId) { const [anchor, ...rest] = sliced; - return [anchor, ...rest.filter(isTaskSelectedForRun)]; + return { + tasks: [anchor, ...rest.filter((t) => isTaskSelectedForRun(t, todayWeekday))], + skippedByWeeklySchedule: collectWeeklySkipped(rest, todayWeekday), + }; } - return sliced.filter(isTaskSelectedForRun); + return { + tasks: sliced.filter((t) => isTaskSelectedForRun(t, todayWeekday)), + skippedByWeeklySchedule: collectWeeklySkipped(sliced, todayWeekday), + }; } diff --git a/tests/currentWeekday.test.ts b/tests/currentWeekday.test.ts new file mode 100644 index 00000000..8f955ad3 --- /dev/null +++ b/tests/currentWeekday.test.ts @@ -0,0 +1,68 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { getCurrentWeekday, subscribeCurrentWeekday } from '../src/utils/currentWeekday.ts'; +import { isTaskSelectedForRun } from '../src/utils/taskRunFilter.ts'; + +function setup(t, date) { + t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: date.getTime() }); + const oldWindow = globalThis.window; + const oldDocument = globalThis.document; + globalThis.window = new EventTarget(); + globalThis.document = new EventTarget(); + const unsubscribes = []; + t.after(() => { + for (const unsubscribe of unsubscribes) unsubscribe(); + if (oldWindow === undefined) delete globalThis.window; + else globalThis.window = oldWindow; + if (oldDocument === undefined) delete globalThis.document; + else globalThis.document = oldDocument; + }); + return (listener) => { + const unsubscribe = subscribeCurrentWeekday(listener); + unsubscribes.push(unsubscribe); + return unsubscribe; + }; +} + +test('跨午夜通知所有订阅者,启动资格切换到新的一天', (t) => { + const subscribe = setup(t, new Date(2026, 9, 14, 23, 59, 59)); + const task = { enabled: true, weeklySchedule: [4] }; + let canRun = isTaskSelectedForRun(task, getCurrentWeekday()); + let weekday = getCurrentWeekday(); + subscribe(() => { + canRun = isTaskSelectedForRun(task, getCurrentWeekday()); + }); + subscribe(() => { + weekday = getCurrentWeekday(); + }); + assert.equal(canRun, false); + t.mock.timers.tick(1_000); + assert.equal(weekday, 4); + assert.equal(canRun, true); +}); + +test('休眠跨天后,窗口聚焦或恢复可见会立即刷新日期', (t) => { + const subscribe = setup(t, new Date(2026, 9, 14, 12)); + const days = []; + subscribe(() => days.push(getCurrentWeekday())); + t.mock.timers.setTime(new Date(2026, 9, 15, 12).getTime()); + window.dispatchEvent(new Event('focus')); + assert.deepEqual(days, [4]); + t.mock.timers.setTime(new Date(2026, 9, 16, 12).getTime()); + document.dispatchEvent(new Event('visibilitychange')); + assert.deepEqual(days, [4, 5]); +}); + +test('取消最后一个订阅后清理时钟和窗口监听,可以重新订阅', (t) => { + const subscribe = setup(t, new Date(2026, 9, 14, 23, 59, 59)); + let calls = 0; + const unsubscribe = subscribe(() => calls++); + unsubscribe(); + t.mock.timers.tick(1_000); + window.dispatchEvent(new Event('focus')); + document.dispatchEvent(new Event('visibilitychange')); + assert.equal(calls, 0); + subscribe(() => calls++); + window.dispatchEvent(new Event('focus')); + assert.equal(calls, 1); +}); diff --git a/tests/tabExportImport.test.ts b/tests/tabExportImport.test.ts new file mode 100644 index 00000000..bd712457 --- /dev/null +++ b/tests/tabExportImport.test.ts @@ -0,0 +1,80 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; +import { filterTasksForRun } from '../src/utils/taskRunFilter.ts'; + +// Node 测试运行器不读取 tsconfig paths;只补齐解析,使用真实的业务模块。 +const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier.startsWith('@/')) { + return nextResolve(new URL(`../src/${specifier.slice(2)}.ts`, import.meta.url).href, context); + } + return nextResolve(specifier, context); + }, +}); +const { buildTabConfigExportText, importTabConfigFromText } = + await import('../src/utils/tabExportImport.ts'); +hooks.deregister(); + +function makeInstance(weeklySchedule) { + return { + id: 'instance', + name: '周计划配置', + controllerName: 'controller', + selectedTasks: [ + { + id: 'weekly', + taskName: 'weekly', + enabled: true, + weeklySchedule, + optionValues: {}, + expanded: true, + }, + ], + }; +} + +test('标签页导出导入保留任务周计划', async () => { + const instance = makeInstance([0, 3]); + const text = await buildTabConfigExportText(instance, 'demo'); + const result = await importTabConfigFromText('demo', text); + assert.equal(result.tabName, instance.name); + assert.deepEqual(result.payload.selectedTasks[0].weeklySchedule, [0, 3]); +}); + +test('标签页导出导入兼容缺省与空数组周计划', async () => { + for (const weeklySchedule of [undefined, []]) { + const text = await buildTabConfigExportText(makeInstance(weeklySchedule), 'demo'); + const result = await importTabConfigFromText('demo', text); + assert.equal(result.payload.selectedTasks[0].weeklySchedule, undefined); + } +}); + +async function importWireSchedule(ws) { + const wire = { t: [{ i: 'weekly', tn: 'weekly', e: true, ov: {}, ws }] }; + const compressed = new Blob([JSON.stringify(wire)]) + .stream() + .pipeThrough(new CompressionStream('deflate-raw')); + const data = Buffer.from(await new Response(compressed).arrayBuffer()).toString('base64url'); + return importTabConfigFromText('demo', `demo://tab-sharing/v1/test/${data}`); +} + +test('共享负载中的非数组或全无效周计划不会存入任务或阻止启动', async () => { + for (const ws of [{ day: 0 }, '0', null, 0, [7, -1, 1.5, '3']]) { + const result = await importWireSchedule(ws); + const tasks = result.payload.selectedTasks; + assert.equal(tasks[0].weeklySchedule, undefined); + assert.deepEqual( + filterTasksForRun(tasks, 3).tasks.map((task) => task.id), + ['weekly'], + ); + } +}); + +test('共享负载中的混合周计划保留有效星期,维持有效日期的限制', async () => { + const result = await importWireSchedule([3, 0, 3, 6, -1, 7, 1.5, '1']); + const tasks = result.payload.selectedTasks; + assert.deepEqual(tasks[0].weeklySchedule, [0, 3, 6]); + assert.equal(filterTasksForRun(tasks, 3).tasks.length, 1); + assert.equal(filterTasksForRun(tasks, 1).tasks.length, 0); +}); diff --git a/tests/taskParticipation.test.ts b/tests/taskParticipation.test.ts new file mode 100644 index 00000000..e04d532e --- /dev/null +++ b/tests/taskParticipation.test.ts @@ -0,0 +1,191 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { registerHooks } from 'node:module'; + +// 用真实组件和 store 做无 DOM 渲染:补齐 Vite 环境,并让 SSR 读取测试注入的当前状态。 +const hooks = registerHooks({ + load(url, context, nextLoad) { + const result = nextLoad(url, context); + if (!result.source) return result; + let source = result.source.toString(); + if (url.includes('/src/')) source = source.replaceAll('import.meta.env', '({})'); + if (url.includes('/zustand/') && (url.endsWith('/react.mjs') || url.endsWith('/react.js'))) { + source = source.replaceAll('api.getInitialState()', 'api.getState()'); + } + return { ...result, source }; + }, +}); +globalThis.localStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {} }; +const { useAppStore } = await import('../src/stores/appStore.ts'); +const { TaskItem } = await import('../src/components/TaskItem.tsx'); +const { Toolbar } = await import('../src/components/Toolbar.tsx'); +const { DashboardView } = await import('../src/components/DashboardView.tsx'); +const { filterTasksForRun, createTaskRunSelection } = await import('../src/utils/taskRunFilter.ts'); +const i18n = (await import('../src/i18n/index.ts')).default; +await i18n.changeLanguage('zh-CN'); +hooks.deregister(); + +function renderTask(t, { isRunning = false, status = 'succeeded', selection } = {}) { + t.mock.timers.enable({ apis: ['Date'], now: new Date(2026, 9, 14, 12).getTime() }); + const task = { + id: 'weekly', + taskName: 'weekly', + enabled: true, + weeklySchedule: [0], + optionValues: {}, + expanded: false, + }; + useAppStore.setState({ + projectInterface: { + task: [{ name: 'weekly', entry: 'weekly', label: '周常任务' }], + controller: [], + resource: [], + }, + instances: [{ id: 'fixture', name: 'fixture', selectedTasks: [task], isRunning }], + activeInstanceId: 'fixture', + instanceTaskRunStatus: { fixture: { weekly: status } }, + maaTaskIdMapping: {}, + instanceTaskRunSelection: selection ? { fixture: selection } : {}, + }); + return renderToStaticMarkup(React.createElement(TaskItem, { instanceId: 'fixture', task })); +} + +test('折叠卡片在启动前明确显示今日不参与,保留勾选并隐藏旧成功结果', (t) => { + const html = renderTask(t); + const header = html.split('grid transition-')[0]; + assert.match(header, /今日不参与/); + assert.match(header, /aria-checked="true"/); + assert.doesNotMatch(html, /bg-success/); +}); + +test('运行中周计划排除项复用未勾选任务的紧凑样式,不另加未纳入标签', (t) => { + const html = renderTask(t, { + isRunning: true, + selection: { weekday: 0, includedTaskIds: ['daily'], excludedByWeeklyScheduleIds: ['weekly'] }, + }); + assert.match(html, /px-3 py-1\.5/); + assert.doesNotMatch(html, /本次未纳入|今日不参与/); + assert.doesNotMatch(html, /bg-success/); + assert.doesNotMatch(html, /task-item-running/); + const instance = useAppStore.getState().instances[0]; + const uncheckedTask = { ...instance.selectedTasks[0], enabled: false, weeklySchedule: undefined }; + useAppStore.setState({ + instances: [{ ...instance, selectedTasks: [uncheckedTask] }], + instanceTaskRunStatus: { fixture: { weekly: 'idle' } }, + instanceTaskRunSelection: {}, + }); + const uncheckedHtml = renderToStaticMarkup( + React.createElement(TaskItem, { instanceId: 'fixture', task: uncheckedTask }), + ); + assert.equal(html, uncheckedHtml); +}); + +test('单独运行的任务以实际纳入快照为准,不误标为周计划排除', (t) => { + const html = renderTask(t, { + isRunning: true, + status: 'running', + selection: { weekday: 3, includedTaskIds: ['weekly'], excludedByWeeklyScheduleIds: [] }, + }); + const header = html.split('grid transition-')[0]; + assert.doesNotMatch(header, /今日不参与|本次未纳入/); + assert.match(html, /task-item-running/); +}); + +test('运行中跨午夜后,不把已纳入且完成的任务改标为未参与', (t) => { + const html = renderTask(t, { + isRunning: true, + selection: { weekday: 0, includedTaskIds: ['weekly'], excludedByWeeklyScheduleIds: [] }, + }); + const header = html.split('grid transition-')[0]; + assert.doesNotMatch(header, /今日不参与|本次未纳入/); + assert.match(html, /bg-success/); +}); + +test('显式执行结束后保留真实成功结果,不按今天计划抹掉结果', (t) => { + const html = renderTask(t, { + selection: { weekday: 3, includedTaskIds: ['weekly'], excludedByWeeklyScheduleIds: [] }, + }); + const header = html.split('grid transition-')[0]; + assert.doesNotMatch(header, /今日不参与|本次未纳入/); + assert.match(html, /bg-success/); +}); + +test('刷新恢复正在运行的任务时,后端事实优先于没有快照的今日预览', (t) => { + const html = renderTask(t, { isRunning: true, status: 'running' }); + const header = html.split('grid transition-')[0]; + assert.doesNotMatch(header, /今日不参与|本次未纳入/); + assert.match(html, /task-item-running/); +}); + +test('重新启动全部排除时,不再显示上一批成功;下一批纳入后恢复真实状态', (t) => { + renderTask(t); + const { instances } = useAppStore.getState(); + const tasks = instances[0].selectedTasks; + useAppStore + .getState() + .setTaskRunSelection('fixture', createTaskRunSelection(filterTasksForRun(tasks, 3), 3)); + let html = renderToStaticMarkup( + React.createElement(TaskItem, { instanceId: 'fixture', task: tasks[0] }), + ); + assert.doesNotMatch(html, /bg-success/); + const selection = createTaskRunSelection(filterTasksForRun(tasks, 0), 0); + useAppStore.getState().setTaskRunSelection('fixture', selection); + useAppStore.setState({ instances: [{ ...instances[0], isRunning: true }] }); + html = renderToStaticMarkup( + React.createElement(TaskItem, { instanceId: 'fixture', task: tasks[0] }), + ); + assert.match(html, /bg-success/); + useAppStore.getState().clearTaskRunStatus('fixture'); + assert.equal(useAppStore.getState().instanceTaskRunSelection.fixture, undefined); +}); + +test('工具栏移除参与统计小字,仍禁用空运行;仪表盘保留未启动原因', (t) => { + renderTask(t); + const toolbar = renderToStaticMarkup( + React.createElement(Toolbar, { showAddPanel: false, onToggleAddPanel: () => {} }), + ); + assert.doesNotMatch(toolbar, /本次计划参与|周计划排除|weeklySchedulePreview/); + assert.match(toolbar, /data-role="start-stop-button" disabled=""/); + useAppStore.setState({ + instanceConnectionStatus: { fixture: 'Connected' }, + instanceResourceLoaded: { fixture: true }, + }); + const dashboard = renderToStaticMarkup(React.createElement(DashboardView, { onClose: () => {} })); + assert.match(dashboard, /未启动任务:今天没有符合周计划的已选任务/); +}); + +test('跨端新批次的真实完成结果不会被旧排除快照遮盖,旧批次回填仍被区分', (t) => { + renderTask(t, { + selection: { weekday: 3, includedTaskIds: ['daily'], excludedByWeeklyScheduleIds: ['weekly'] }, + }); + useAppStore.setState({ maaTaskIdMapping: { fixture: { 1: 'weekly' } } }); + const states = { + instances: { + fixture: { + connected: true, + resourceLoaded: true, + isRunning: false, + taskRunState: { + statuses: { weekly: 'succeeded' }, + mappings: { 1: 'weekly' }, + overallStatus: 'Succeeded', + }, + }, + }, + cachedAdbDevices: [], + cachedWin32Windows: [], + cachedWlrootsSockets: [], + cachedGamescopeInstances: [], + }; + const task = useAppStore.getState().instances[0].selectedTasks[0]; + useAppStore.getState().restoreBackendStates(states); + let html = renderToStaticMarkup(React.createElement(TaskItem, { instanceId: 'fixture', task })); + assert.doesNotMatch(html, /bg-success/); + states.instances.fixture.taskRunState.mappings = { 2: 'weekly' }; + useAppStore.getState().restoreBackendStates(states); + html = renderToStaticMarkup(React.createElement(TaskItem, { instanceId: 'fixture', task })); + assert.match(html, /bg-success/); + assert.doesNotMatch(html.split('grid transition-')[0], /本次未纳入/); +}); diff --git a/tests/taskRunFilter.test.ts b/tests/taskRunFilter.test.ts new file mode 100644 index 00000000..adb8902f --- /dev/null +++ b/tests/taskRunFilter.test.ts @@ -0,0 +1,229 @@ +/** + * 任务周计划(weeklySchedule)过滤逻辑的单测。 + * + * 与 richText.test.ts 同样的理由:仓库没有前端测试框架,这里用 Node 内置 + * test runner,零依赖、可直接跑 `pnpm test`。 + * 被测模块 src/utils/taskRunFilter.ts 只 import type,因此无需 DOM / Tauri 环境。 + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + filterTasksForRun, + getWeekdayOf, + isTaskAllowedByWeeklySchedule, + isTaskBlockedByWeeklySchedule, + isTaskChecked, + isTaskSelectedForRun, + normalizeWeeklySchedule, + createTaskRunSelection, +} from '../src/utils/taskRunFilter.ts'; + +const SUN = 0; +const MON = 1; +const WED = 3; + +/** 构造最小可用的 SelectedTask(只填过滤逻辑关心的字段) */ +function mk(id, opts = {}) { + return { + id, + taskName: id, + enabled: false, + runOnce: false, + optionValues: {}, + expanded: false, + ...opts, + }; +} + +const ids = (tasks) => tasks.map((t) => t.id); + +test('启动参与快照只记录纳入和未纳入,不生成未纳入任务的执行结果', () => { + const tasks = [ + mk('weekly', { enabled: true, weeklySchedule: [SUN] }), + mk('daily', { enabled: true }), + ]; + const result = filterTasksForRun(tasks, WED); + const snapshot = createTaskRunSelection(result, WED); + assert.deepEqual(snapshot, { + weekday: WED, + includedTaskIds: ['daily'], + excludedByWeeklyScheduleIds: ['weekly'], + }); + // 日期或列表变化不会改变已生成批次的参与范围。 + tasks[0].weeklySchedule = [WED]; + result.tasks.length = 0; + assert.deepEqual(snapshot.includedTaskIds, ['daily']); + assert.deepEqual(snapshot.excludedByWeeklyScheduleIds, ['weekly']); +}); + +test('周计划清理:保留有效整数、去重排序,且不修改输入', () => { + const input = [WED, SUN, WED, 6, -1, 7, 1.5, '1', null, NaN, Infinity]; + assert.deepEqual(normalizeWeeklySchedule(input), [SUN, WED, 6]); + assert.deepEqual(input, [WED, SUN, WED, 6, -1, 7, 1.5, '1', null, NaN, Infinity]); + const tasks = [mk('mixed', { enabled: true, weeklySchedule: input })]; + assert.deepEqual(ids(filterTasksForRun(tasks, WED).tasks), ['mixed']); + assert.deepEqual(ids(filterTasksForRun(tasks, MON).tasks), []); +}); + +test('无效周计划不抛异常或造成整周跳过,沿用未启用规则', () => { + for (const weeklySchedule of [undefined, null, {}, '0', 0, [], [7, -1, 1.5, '3']]) { + assert.equal(normalizeWeeklySchedule(weeklySchedule), undefined); + const task = mk('invalid', { enabled: true, weeklySchedule }); + for (let day = 0; day <= 6; day++) { + const result = filterTasksForRun([task], day); + assert.deepEqual(ids(result.tasks), ['invalid']); + assert.deepEqual(result.skippedByWeeklySchedule, []); + } + } +}); + +test('isTaskAllowedByWeeklySchedule:缺省 / 空数组视为未启用,一律放行', () => { + assert.equal(isTaskAllowedByWeeklySchedule(mk('a'), WED), true); + assert.equal(isTaskAllowedByWeeklySchedule(mk('a', { weeklySchedule: [] }), WED), true); + assert.equal(isTaskAllowedByWeeklySchedule(mk('a', { weeklySchedule: [SUN, WED] }), WED), true); + assert.equal(isTaskAllowedByWeeklySchedule(mk('a', { weeklySchedule: [SUN] }), WED), false); +}); + +test('isTaskChecked:enabled 与 runOnce 都算选中', () => { + assert.equal(isTaskChecked(mk('a', { enabled: true })), true); + assert.equal(isTaskChecked(mk('a', { runOnce: true })), true); + assert.equal(isTaskChecked(mk('a')), false); +}); + +test('isTaskBlockedByWeeklySchedule:仅在「已勾选 + 今天不在计划内」时为真', () => { + assert.equal( + isTaskBlockedByWeeklySchedule(mk('a', { enabled: true, weeklySchedule: [SUN] }), WED), + true, + ); + // 今天在计划内 + assert.equal( + isTaskBlockedByWeeklySchedule(mk('a', { enabled: true, weeklySchedule: [WED] }), WED), + false, + ); + // 未勾选的任务不属于「被跳过」 + assert.equal( + isTaskBlockedByWeeklySchedule(mk('a', { enabled: false, weeklySchedule: [SUN] }), WED), + false, + ); + // 单次运行是显式意图,优先级高于周计划 + assert.equal( + isTaskBlockedByWeeklySchedule(mk('a', { runOnce: true, weeklySchedule: [SUN] }), WED), + false, + ); + // 未启用周计划 + assert.equal(isTaskBlockedByWeeklySchedule(mk('a', { enabled: true }), WED), false); +}); + +test('isTaskSelectedForRun:勾选且周计划放行才执行', () => { + assert.equal(isTaskSelectedForRun(mk('a', { enabled: true }), WED), true); + assert.equal(isTaskSelectedForRun(mk('a', { enabled: true, weeklySchedule: [SUN] }), WED), false); + assert.equal( + isTaskSelectedForRun(mk('a', { enabled: false, weeklySchedule: [WED] }), WED), + false, + ); +}); + +test('常规启动:部分任务被周计划跳过,其余照常执行', () => { + const tasks = [ + mk('sun-only', { enabled: true, weeklySchedule: [SUN] }), + mk('daily', { enabled: true }), + ]; + const r = filterTasksForRun(tasks, WED); + assert.deepEqual(ids(r.tasks), ['daily']); + assert.deepEqual(ids(r.skippedByWeeklySchedule), ['sun-only']); +}); + +test('常规启动:全部被跳过时 tasks 为空且 skipped 非空(供调用方走「正常结束」分支)', () => { + const tasks = [ + mk('a', { enabled: true, weeklySchedule: [SUN] }), + mk('b', { enabled: true, weeklySchedule: [MON] }), + ]; + const r = filterTasksForRun(tasks, WED); + assert.deepEqual(ids(r.tasks), []); + assert.deepEqual(ids(r.skippedByWeeklySchedule), ['a', 'b']); +}); + +test('常规启动:保持原始顺序', () => { + const tasks = [ + mk('1', { enabled: true }), + mk('2', { enabled: true, weeklySchedule: [SUN] }), + mk('3', { enabled: true }), + ]; + const r = filterTasksForRun(tasks, WED); + assert.deepEqual(ids(r.tasks), ['1', '3']); +}); + +test('「从此处运行」:锚点为显式操作绕过周计划,锚点之后仍受约束', () => { + const tasks = [ + mk('before', { enabled: true }), + mk('before-skipped', { enabled: true, weeklySchedule: [SUN] }), + mk('anchor', { enabled: true, weeklySchedule: [SUN] }), + mk('mid', { enabled: true, weeklySchedule: [SUN] }), + mk('daily', { enabled: true }), + ]; + const r = filterTasksForRun(tasks, WED, { startFromTaskId: 'anchor' }); + assert.deepEqual(ids(r.tasks), ['anchor', 'daily']); + assert.deepEqual(ids(r.skippedByWeeklySchedule), ['mid']); +}); + +test('「从此处运行」:锚点不存在时返回空结果', () => { + const r = filterTasksForRun([mk('a', { enabled: true })], WED, { startFromTaskId: 'zzz' }); + assert.deepEqual(ids(r.tasks), []); + assert.deepEqual(ids(r.skippedByWeeklySchedule), []); +}); + +test('「单独运行」:绕过周计划,且不产生跳过记录', () => { + const tasks = [mk('a', { runOnce: true, weeklySchedule: [SUN] })]; + const r = filterTasksForRun(tasks, WED, { singleTaskId: 'a' }); + assert.deepEqual(ids(r.tasks), ['a']); + assert.deepEqual(ids(r.skippedByWeeklySchedule), []); +}); + +test('单次运行(runOnce):常规启动与锚点之后的段落都绕过周计划', () => { + const solo = filterTasksForRun([mk('a', { runOnce: true, weeklySchedule: [SUN] })], WED); + assert.deepEqual(ids(solo.tasks), ['a']); + assert.deepEqual(ids(solo.skippedByWeeklySchedule), []); + + // enabled 与 runOnce 同时为真 + const both = filterTasksForRun( + [mk('b', { enabled: true, runOnce: true, weeklySchedule: [SUN] })], + WED, + ); + assert.deepEqual(ids(both.tasks), ['b']); + + // 锚点之后的 runOnce 任务同样绕过 + const afterAnchor = filterTasksForRun( + [mk('anchor', { enabled: true }), mk('once', { runOnce: true, weeklySchedule: [SUN] })], + WED, + { startFromTaskId: 'anchor' }, + ); + assert.deepEqual(ids(afterAnchor.tasks), ['anchor', 'once']); +}); + +test('兼容性:未勾选 / 无字段 / 空数组均不产生跳过记录', () => { + const tasks = [ + mk('off', { enabled: false, weeklySchedule: [SUN] }), + mk('plain', { enabled: true }), + ]; + const r = filterTasksForRun(tasks, WED); + assert.deepEqual(ids(r.tasks), ['plain']); + assert.deepEqual(ids(r.skippedByWeeklySchedule), []); + + const empty = filterTasksForRun([mk('a', { enabled: true, weeklySchedule: [] })], WED); + assert.deepEqual(ids(empty.tasks), ['a']); + assert.deepEqual(ids(empty.skippedByWeeklySchedule), []); +}); + +test('边界:空任务列表', () => { + const r = filterTasksForRun([], WED); + assert.deepEqual(ids(r.tasks), []); + assert.deepEqual(ids(r.skippedByWeeklySchedule), []); +}); + +test('getWeekdayOf:返回 Date.getDay()(0=周日)', () => { + // 2026-10-14 是周三 + assert.equal(getWeekdayOf(new Date(2026, 9, 14, 12, 0, 0)), WED); + assert.equal(getWeekdayOf(new Date(2026, 9, 18, 12, 0, 0)), SUN); +}); diff --git a/tests/weekdayLocales.test.ts b/tests/weekdayLocales.test.ts new file mode 100644 index 00000000..cc0bb743 --- /dev/null +++ b/tests/weekdayLocales.test.ts @@ -0,0 +1,30 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import i18next from 'i18next'; +import zhCN from '../src/i18n/locales/zh-CN.ts'; +import zhTW from '../src/i18n/locales/zh-TW.ts'; +import enUS from '../src/i18n/locales/en-US.ts'; +import jaJP from '../src/i18n/locales/ja-JP.ts'; +import koKR from '../src/i18n/locales/ko-KR.ts'; + +const locales = { 'zh-CN': zhCN, 'zh-TW': zhTW, 'en-US': enUS, 'ja-JP': jaJP, 'ko-KR': koKR }; + +test('所有语言的周计划星期键均返回七个本地化标签', async () => { + for (const [language, locale] of Object.entries(locales)) { + const instance = i18next.createInstance(); + // 禁用回退,确保每种语言自身已定义该键。 + await instance.init({ + lng: language, + fallbackLng: false, + resources: { [language]: { translation: locale } }, + }); + const labels = instance.t('schedule.weekdays', { returnObjects: true }); + assert.ok(Array.isArray(labels), language); + assert.equal(labels.length, 7, language); + assert.deepEqual(labels, locale.schedule.weekdays, language); + assert.ok( + labels.every((label) => typeof label === 'string' && label.length > 0), + language, + ); + } +});