// FILE: _St/IG_StYellow/test_ext_module.js | ROLE: IG_StYellow 회귀 테스트
const fs = require('fs');
const path = require('path');
const assert = require('assert');
const vm = require('vm');
const root = __dirname;
const read = (name) => fs.readFileSync(path.join(root, name), 'utf8');
const manifest = JSON.parse(read('manifest.json'));
assert.strictEqual(manifest.version, '2.3.8.2880');
assert.strictEqual(manifest.version_name, '2.3.8.288');
const refs = [];
for (const item of manifest.content_scripts || []) refs.push(...(item.js || []));
for (const item of manifest.web_accessible_resources || []) refs.push(...(item.resources || []).filter(x => !x.includes('*')));
for (const ref of refs) assert.ok(fs.existsSync(path.join(root, ref)), `missing manifest resource: ${ref}`);
assert.ok(!refs.includes('js/ig_origin_calibration.js'));
const bg = read('background.js');
const side = read('side.js');
assert.ok(side.includes('const IGBLUE_PACKAGE_VERSION = "v288";'));
const content = read('content_instagram.js');

assert.ok(!side.includes('window.close()'), 'Side must never close itself after opening a popup');
assert.ok(bg.includes('msg.type === "IGBLUE_POPUP_READY"') && bg.includes('chrome.sidePanel.close({tabId:readyTabId})'), 'background must close only the confirmed original Side after popup handoff');
assert.ok(bg.includes('function igblueClosePreviousTabAfterReplacement') && bg.includes('function igblueCloseManagedAutoTab') && bg.includes('igblueRequestCdpTargetClose'), 'background must CDP-close only managed automatic tabs after replacement readiness');
assert.ok(!bg.includes('chrome.windows.remove'), 'background must not close Chrome windows');
assert.ok(!side.includes('teArmWsAutoEnter("auto_direct_download"'), 'auto download must not depend on Save As Enter');
assert.ok(!side.includes('teArmWsAutoEnter("main_manual_download_button"'), 'manual download must not depend on Save As Enter');
assert.ok(!bg.includes('api/cdp/download-root/ensure'), 'main download must not depend on Auto8700 CDP root');
assert.ok(bg.includes('function igblueOriginalDownloadFileName'), 'original Instagram filename resolver missing');
assert.ok(bg.includes('const normalizedPrefix = igblueNormalizeDownloadPrefix(msg, ext);'), 'direct download must validate the full message/meta object');
assert.ok(!bg.includes('igblueNormalizeDownloadPrefix(msg.prefix, ext)'), 'v233 string-only prefix regression remains');
assert.ok(bg.includes('const accountFolderMerge = false;'), 'stale account-folder merge must be ignored');
assert.ok(bg.includes('`${igblueSafePath(ownerAccount, "instagram")}/${originalFileName}`'), 'physical path must be account/original filename');
assert.ok(bg.includes('"ig_media_download_direct"'), 'WS direct download action missing');
assert.ok(bg.includes('root:"instagram"') && bg.includes('collection_folder_name:"instagram"'), 'background must normalize direct download root and metadata');
assert.ok(bg.includes('function igbluePollWsQueuedJob'), 'WS queued result polling missing');
assert.ok(bg.includes('function igblueDownloadWithChromeFallback'), 'manual Chrome download fallback missing');
assert.ok(bg.includes('chrome.downloads.download({url, filename, saveAs:false, conflictAction:"uniquify"}'), 'manual fallback must use Background downloads without Save As');
assert.ok(bg.includes('finalStatus = String(result.status || "ok") === "partial" ? "partial" : "ok"'), 'file-saved DB failure must remain partial');
assert.ok(bg.includes('const finalize = result.finalize && typeof result.finalize === "object" ? result.finalize : {}'), 'WS direct finalize result must be flattened');
assert.ok(bg.includes('notify_aiwk:finalize'), 'manual/auto shared finalize parser must receive WS finalize data');
assert.ok(manifest.permissions.includes('downloads'), 'manual fallback requires Chrome downloads permission');
for (const obsolete of ['download_root_folder', 'download_account_folder_merge', 'open_download_folder', '다운위치', '계정.폴더통합', 'Down/10만']) assert.ok(!read('side.html').includes(obsolete), `obsolete Side download setting remains: ${obsolete}`);
assert.ok(read('side.html').includes('igblue_report_rebuild_all') && read('side.html').includes('보고서 재생성'), 'existing account report rebuild button missing');
assert.ok(side.includes('teAiwkWsRequest("ig_report_rebuild_all"'), 'Side report rebuild WS action missing');
assert.ok(side.includes('function teDownloadRoot() { return "instagram"; }'), 'download root must be fixed to instagram');
assert.ok(side.includes('function teDownloadAccountFolderMerge() { return false; }'), 'account folder merge must be retired');
assert.ok(side.includes('top_n: topTarget') && side.includes('const topTarget = Math.max(0, Number(teAutoPyTargetCount'), 'top_n must record the actual Top target');
assert.ok(!bg.includes('IGBLUE_OPEN_DOWNLOAD_FOLDER') && !bg.includes('chrome.downloads.show'), 'old Chrome folder opener must be removed');
assert.ok(bg.includes('if(msg && msg.auto_direct_download)'), 'automatic Top download must remain WS-only');
assert.ok(bg.includes('WS8771 미실행: 확장 수동 다운로드로 전환합니다.'), 'manual WS-off fallback message missing');

assert.ok(manifest.description.includes('two-phase Side acknowledgement') && manifest.description.includes('proven v279c WS8771 direct MP4/JPG path'), 'manifest description must describe replacement-tab navigation and the proven automatic WS direct path');
assert.ok(bg.includes('const precheckOptions = msg && msg.auto_direct_download ? {priority:true, force:true, force_probe:true} : {}'), 'automatic background precheck must force the WS connection probe');
assert.ok(bg.includes('if(options.force_probe === true && options.force === true)') && bg.includes('igblueWsBackoffUntil = 0'), 'forced automatic probe must bypass stale WS backoff');
assert.ok(bg.includes('igblueLocalWsRequest("ig_media_exists"') && bg.includes('8000, precheckOptions'), 'automatic duplicate precheck must use forced options');
for (const id of ['AD-01', 'AD-02', 'AD-03', 'AD-04', 'AD-05', 'AD-06', 'AD-07']) assert.ok(side.includes(`"${id}"`) || bg.includes(`"${id}"`), `missing automatic feature check: ${id}`);
for (const state of ['[체크시작]', '[체크완료]', '[체크실패]']) assert.ok(side.includes(state) || bg.includes(state), `missing Korean check state: ${state}`);
assert.ok(side.includes('const teAutoPyFeatureCheckSeen = new Set()'), 'feature-check dedupe set missing');
assert.ok(side.includes('feature_check_mode: true'), 'legacy detailed cycle logging must be marked for concise WS handling');
assert.ok(side.includes('if (String(e.status || "") === "feature_check") return;'), 'feature-check messages must not pollute the download progress UI');

const inject = read('js/instagram_inject.js');
assert.ok(inject.includes('return n?`IG ${n}`:"IG"'), 'MAIN overlay must show account only without No rank text');
assert.ok(inject.includes('function teMainCompactLabel(e){') && inject.includes('return n?`IG ${n}`:"IG"'), 'account-only compact label missing');
assert.ok(!inject.includes('installIgblueMainIdentitySplitV239'), 'obsolete MAIN rank identity split must be removed');
assert.ok(inject.includes('igblue-main-account-row'), 'MAIN IG account row missing');
assert.ok(!inject.includes('igblue-main-rank-row'), 'MAIN No row must be removed');
assert.ok(!inject.includes('igblue-main-kind-row'), 'rank-derived MAIN media kind row must be removed');

assert.ok(content.includes("id = 'igblue_main_diag_toggle'"), 'CDP-adjacent compact diagnostic toggle missing');
assert.ok(content.includes("id = 'igblue_main_diag_panel'"), 'compact diagnostic panel missing');
assert.ok(content.includes('#IGBLUE_NoTop > .igblue-origin-panel') && content.includes('#IGBLUE_NoTop > .igblue-status-box'), 'legacy MAIN diagnostics must be hidden from the right panel');
assert.ok(content.includes("'탭 제어 v288'") && content.includes("'최근 탭 이력'"), 'diagnostic mirror must show tab owner headings');
assert.ok(content.includes("'OWNER tab_id: '") && content.includes("'인계 상태: '") && content.includes("data-yellow-tab-control-state"), 'diagnostic mirror must load tab ownership values');
assert.ok(inject.includes('if(!i||!1===i.enabled||i.main===false)return;'), 'MAIN metric bars must follow independent Main visibility');
assert.ok(inject.includes('a&&!1!==a.enabled&&a.main!==false&&('), 'MAIN date colors must follow independent Main visibility');
assert.ok(inject.includes('IGBLUE/internal_no_nav_state') && inject.includes('document.querySelectorAll(".ndy_ins_info[data-code]")'), 'internal shortcode navigation state must replace MAIN rank rows');
assert.ok(inject.includes('.ndy_ins_info[data-igblue-active="1"]'), 'Prev/Next current MAIN selection highlight missing');

assert.ok(side.includes('teApplySideAccountLabel(stats[0], media)'), 'Side account label must be independent');
assert.ok(side.includes('teApplySideRankLabel') && side.includes('igblue-side-rank'), 'Side No rank visual must be restored');
assert.ok(side.includes('Side Rank badge / No 000') && side.includes('{ title: "순위"'), 'Side rank settings must be restored');
assert.ok(side.includes('span.textContent = account ? `IG ${account}` : "IG"'), 'Side account format must be IG account');
assert.ok(side.includes('IGBLUE_ACCOUNT_DELETED_PREFIX = "🗑️"'), 'deleted account emoji marker missing');
assert.ok(side.includes('teAutoPySyncDeletedAccountsFromAnalyze8780'), 'Analyze8780 deleted account sync missing');
assert.ok(side.includes('filter((entry) => !entry.deleted && entry.enabled !== false)'), 'deleted accounts must be excluded from queue counting');

assert.ok(side.includes('function teAutoPyNormalizeAccountQueueEntries(entries)'), 'account queue order-preserving normalize helper missing');
assert.ok(side.includes('function teAutoPySortAccountQueueEntries(entries)'), 'explicit account queue sort helper missing');
assert.ok(side.includes('`${IGBLUE_ACCOUNT_DELETED_PREFIX} @${entry.username}`'), 'deleted account display must preserve trash + @account');
assert.ok(side.includes('entry.deleted ? `${IGBLUE_ACCOUNT_DELETED_PREFIX} @${entry.username}` : `@${entry.username}`'), 'saved account list must use @account format');
assert.ok(side.includes('if (teAutoPyDeletedLine(raw)) return "";'), 'deleted account must never produce a navigation URL');
const accountBlockStart = side.indexOf('const IGBLUE_ACCOUNT_RESERVED_PATHS');
const accountBlockEnd = side.indexOf('function teAutoPyAccountQueueIndex()', accountBlockStart);
assert.ok(accountBlockStart >= 0 && accountBlockEnd > accountBlockStart, 'account queue normalization block missing');
const accountTextarea = { value: [
  'https://www.instagram.com/Zeta/',
  '@alpha',
  'alpha',
  '🗑️ @beta',
  'https://www.instagram.com/beta/',
  'Gamma'
].join('\n') };
const accountCtx = {
  URL,
  document: { getElementById: (id) => id === 'igblue_account_url_list' ? accountTextarea : null },
  teLocalGet: () => '',
  teLocalSet: () => {},
};
vm.createContext(accountCtx);
vm.runInContext(`${side.slice(accountBlockStart, accountBlockEnd)}; this.entries=teAutoPyAccountQueueEntries; this.lines=teAutoPyStoredAccountQueueList; this.urls=teAutoPyAccountQueueList; this.toUrl=teAutoPyAccountUrlFromUsername;`, accountCtx);
const normalizedAccounts = JSON.parse(JSON.stringify(accountCtx.entries()));
assert.deepStrictEqual(normalizedAccounts.map(x => [x.username, x.deleted]), [['zeta', false], ['alpha', false], ['beta', true], ['gamma', false]]);
assert.deepStrictEqual(JSON.parse(JSON.stringify(accountCtx.lines(normalizedAccounts))), ['@zeta', '@alpha', '🗑️ @beta', '@gamma']);
assert.deepStrictEqual(JSON.parse(JSON.stringify(accountCtx.urls(true))), [
  'https://www.instagram.com/zeta/',
  'https://www.instagram.com/alpha/',
  'https://www.instagram.com/gamma/'
]);
assert.strictEqual(accountCtx.toUrl('🗑️ @beta'), '', 'deleted tombstone must never be retried as a URL');

const sideHtml = read('side.html');
assert.ok(sideHtml.includes('id="igblue_account_url_save"'), 'explicit account list save button missing');
assert.ok(sideHtml.includes('>💾 저장</button>'), 'save button label missing');
assert.ok(sideHtml.includes('id="igblue_account_url_sort"') && sideHtml.includes('>정렬</button>'), 'explicit account sort button missing');
assert.ok(side.includes('sortBtn && sortBtn.addEventListener("click"'), 'sort action handler missing');
assert.ok(side.includes('const entries = teAutoPyAccountQueueEntries();') && side.includes('const items = entries.map((entry, index)'), 'plain save must use current visible order');
assert.ok(side.includes('teAutoPyNormalizeAccountQueueEntries((Array.isArray(payload.items) ? payload.items : items)'), 'save response must preserve server position order');
assert.ok(side.includes('let teAccountQueueDirty = false'), 'account list dirty state missing');
assert.ok(side.includes('saveBtn && saveBtn.addEventListener("click"'), 'explicit save click handler missing');
assert.ok(side.includes('teAutoPySetAccountQueueDirty(true)'), 'unsaved account list state missing');
assert.ok(side.includes('현재 순서를 유지합니다. 💾 저장을 눌러 확정하세요.'), 'single add must preserve queue order and progress index');
assert.ok(!side.includes('목록 끝에 추가했습니다. 현재 순서를 유지하며'), 'obsolete displayed-account bulk add message remains');
assert.ok(!side.includes('const saveFromBlur ='), 'legacy blur auto-save must be removed');
assert.ok(!side.includes('el.addEventListener("blur", saveFromBlur)'), 'blur auto-save listener remains');
assert.ok(side.includes('if (!el || teAccountQueueDirty) return false'), 'deleted-account polling must not overwrite unsaved edits');
assert.ok(sideHtml.includes('>수집계정 목록</span>'), 'collection account username heading missing');
assert.ok(!sideHtml.includes('id="igblue_account_url_current"') && !sideHtml.includes('>표시계정추가</button>'), 'obsolete displayed-account bulk add button remains');
assert.ok(side.includes('function teSelectSideAccountAddTarget(media, element)') && side.includes('source === "side_account_click"'), 'Side IG account click target selection missing');
assert.ok(sideHtml.includes('disabled>IG 계정 선택</button>'), 'Side-click account selection label missing');
assert.ok(side.includes('`${normalized} 계정추가`'), 'current-account add target label missing');
assert.ok(side.includes('function teAutoPyNormalizeAccountUsername(raw)'), 'username-only queue normalizer missing');
assert.ok(side.includes('const account = teDisplayAccountForMedia ? teDisplayAccountForMedia(media) : "";'), 'Side account label must use the displayed media owner username');
assert.ok(side.includes('http://127.0.0.1:8780/api/collection-accounts'), 'Analyze8780 collection account API missing');
assert.ok(side.includes('method: "PUT"'), 'collection account SQLite save must use PUT');
assert.ok(side.includes('return username ? `https://www.instagram.com/${username}/` : ""'), 'profile URL must be generated only at navigation time');
assert.ok(!sideHtml.includes('계정을URL로변환'), 'legacy URL conversion button remains');
assert.ok(side.includes('function teUpdateDownloadFolderPath()') && side.includes('teActiveFolderAccountName() || "instagram"'), 'fixed _StDown/instagram/account path helper missing');

const unifiedStatusPos = sideHtml.indexOf('id="igblue_auto_py_stats"');
const wsGuidePos = sideHtml.indexOf('id="igblue_aiwk_setup_notice"');
const downListPos = sideHtml.indexOf('id="igblue_down_file_list"');
assert.ok(unifiedStatusPos >= 0 && wsGuidePos > unifiedStatusPos, 'WS installation guide must appear below unified progress');
assert.ok(downListPos >= 0 && wsGuidePos > downListPos, 'WS installation guide must be separated from the completed-download list body');
assert.ok(sideHtml.includes('>_St.zip 다운로드</a>'), '_St.zip download link missing');
assert.ok(sideHtml.includes('_St\\Auto8700\\run_auto_service8700_manager.bat'), 'Auto8700 install/run instruction missing');
assert.ok(sideHtml.includes('id="igblue_min_comments"'), 'minimum comments input missing');
assert.ok(sideHtml.includes('id="igblue_min_likes"'), 'minimum likes input missing');
assert.ok(sideHtml.includes('id="igblue_min_shares"'), 'minimum shares input missing');
assert.ok(sideHtml.includes('id="igblue_min_reposts"'), 'minimum reposts input missing');
assert.ok(sideHtml.includes('value="7" class="d-none" hidden disabled>📤 공유 Shares</option>'), 'share sort option must stay hidden and disabled');
assert.ok(side.includes('<title>Shares</title>'), 'Side share metric icon missing');
assert.ok(side.includes('function teMediaPassesSecondaryFilters(media)'), 'secondary engagement filter helper missing');
assert.ok(side.includes('{ field: "like_count", min: teMinLikesValue(), label: "Likes" }') && side.includes('{ field: "comment_count", min: teMinCommentsValue(), label: "Comments" }') && side.includes('{ field: "media_repost_count", min: teMinRepostsValue(), label: "Reposts" }') && !side.includes('weight: 3, label: "Comments"'), 'private-view participation must use an unweighted sum of active Likes/Comments/Reposts');
assert.ok(side.includes('field === "play_count" && teSecondaryMetricFiltersActive()'), 'missing views must fall back only when engagement filters are active');
assert.ok(side.includes('const scoreDiff = teParticipationScore(b) - teParticipationScore(a)'), 'missing-view fallback sort must use participation score');
assert.ok(side.includes('shares: 0') && side.includes('reposts: teNum(m?.media_repost_count)'), 'share metadata must be forced to zero while repost remains active');
assert.ok(!side.includes('media_repost_count || m?.share_count'), 'share count must not be merged into repost count');
assert.ok(inject.includes('edge_media_to_share?.count'), 'legacy GraphQL share count path missing');
assert.ok(inject.includes('teShareByCode'), 'share count pending cache missing');
assert.ok(sideHtml.includes('id="igblue_stats_sync_status"'), 'statistics status box missing');
assert.ok(side.includes('const TE_STATS_STANDALONE = "standalone"') && side.includes('const TE_STATS_LINKED = "linked"'), 'statistics modes missing');
assert.ok(side.includes('http://127.0.0.1:8780/api/observations/batch'), '10-minute local observation batch endpoint missing');
assert.ok(side.includes('ig_observation_sync_hour'), 'hourly central statistics action missing');
assert.ok(side.includes('teStatsHourStorageKey(account)') && side.includes('same_hour_no_change') && side.includes('teStatsSaveHourState(account, state)'), 'hourly per-account incremental sync gate missing');
assert.ok(side.includes('shares: 0, share_count: 0'), 'statistics payload must force shares to zero');
assert.ok(side.includes('teQueueStatsPayload'), 'injection-independent statistics queue missing');
assert.strictEqual((side.match(/teQueueStatsPayload\(e\.info, e\.source_url \|\| e\.url \|\| \"\"\)/g) || []).length, 1, 'only a real ndy_side_shop injection may queue scoped statistics');
const statsStart = side.indexOf('const TE_STATS_MODE_KEY');
const statsEnd = side.indexOf('function teArmWsAutoEnter', statsStart);
assert.ok(statsStart >= 0 && statsEnd > statsStart, 'statistics helper block missing');
const statsStore = {};
const statsCtx = {
  Promise, Map, Date, Number, Math, String, Array, Error, encodeURIComponent,
  document: {getElementById: () => null, createElement: () => ({style:{}, appendChild(){}, classList:{toggle(){}}})},
  teLocalGet: (key, fallback) => Object.prototype.hasOwnProperty.call(statsStore, key) ? statsStore[key] : fallback,
  teLocalSet: (key, value) => { statsStore[key] = value; },
  teTopMetricField: () => 'play_count', G: ['like_count','comment_count','media_repost_count','created_at','play_count','','engagement_rate','share_count'], Z: 4,
  teServerSortField: (field) => field === 'play_count' ? 'play_count' : field,
  teOwnerAccountForMedia: (media) => String(media.username || ''),
  teMediaPageUrl: (media) => `https://www.instagram.com/p/${media.code}/`,
  teParticipationScore: (media) => Number(media.like_count || 0) + Number(media.comment_count || 0) + Number(media.media_repost_count || 0),
  teViewsAvailable: (media) => { const raw = media && (media.play_count ?? media.view_count ?? media.views); return !(media && Number(media.views_available) === 0) && raw !== null && raw !== undefined && Number.isFinite(Number(raw)) && Number(raw) >= 0; },
  teViewCountOrPrivate: (media) => { const raw = media && (media.play_count ?? media.view_count ?? media.views); return (!(media && Number(media.views_available) === 0) && raw !== null && raw !== undefined && Number.isFinite(Number(raw)) && Number(raw) >= 0) ? Number(raw) : -1; },
  teCurrentTargetUrl: () => 'https://www.instagram.com/example/',
  fetch: async () => ({ok:true, json:async()=>({ok:true,saved_count:1})}),
  teAiwkWsRequest: async () => ({ok:true,sent_count:1,items:[]}),
  dbg: () => {},
  URL
};
vm.createContext(statsCtx);
vm.runInContext(`${side.slice(statsStart, statsEnd)}; this.hourBucket=teStatsHourBucket; this.mode=teStatsMode; this.saveMode=teSaveStatsMode; this.remoteItems=teStatsRemoteItems; this.toObservation=teStatsObservationFromMedia; this.scopeFilter=teStatsScopeFilter;`, statsCtx);
assert.strictEqual(statsCtx.hourBucket('2026-07-25T10:37:11Z'), '2026-07-25T10:00:00Z');
assert.strictEqual(statsCtx.mode(), 'standalone');
statsCtx.saveMode('linked');
assert.strictEqual(statsCtx.mode(), 'linked');
const remoteStats = JSON.parse(JSON.stringify(statsCtx.remoteItems('alpha', [{shortcode:'A1', views:123, views_available:1, likes:5, comments:2, reposts:1, sort_by:'play_count', sort_value:123, thumbnail_url:'https://cdn/x.jpg', video_url:'https://cdn/x.mp4'}], '2026-07-25T10:37:11Z', 'play_count')));
assert.strictEqual(remoteStats[0].sort_value, 123);
assert.strictEqual(remoteStats[0].share_count, 0);
assert.strictEqual(remoteStats[0].thumbnail_url, '');
assert.strictEqual(remoteStats[0].video_url, '');
const localStats = JSON.parse(JSON.stringify(statsCtx.toObservation({username:'alpha',code:'A1',play_count:123,like_count:5,comment_count:2,media_repost_count:1,share_count:999,created_at:1750000000}, '2026-07-25T10:37:11Z')));
assert.strictEqual(localStats.share_count, 0);
assert.strictEqual(localStats.shares, 0);
assert.strictEqual(localStats.source_kind, 'LOCAL');
assert.strictEqual(localStats.views_available, 1);
const missingViews = JSON.parse(JSON.stringify(statsCtx.toObservation({username:'alpha',code:'A2',like_count:5,comment_count:2,media_repost_count:1,created_at:1750000000}, '2026-07-25T10:37:11Z')));
assert.strictEqual(missingViews.views_available, 0);
assert.strictEqual(missingViews.views, -1, 'private views must use the -1 internal sentinel');
const scoped = JSON.parse(JSON.stringify(statsCtx.scopeFilter([
  {username:'old_account',code:'OLD1'},
  {username:'alpha',code:'A1'},
  {username:'alpha',code:'A1',like_count:9}
], 'https://www.instagram.com/alpha/')));
assert.strictEqual(scoped.length, 1);
assert.strictEqual(scoped[0].username, 'alpha');
assert.strictEqual(scoped[0].like_count, 9);
assert.ok(side.includes('let teAutoPyRunTotals = { completed_post_count: 0, completed_shortcodes: [], mp4_count: 0, jpg_count: 0, expected_asset_keys: [], completed_asset_keys: [] };'), 'whole-run auto counter missing');
assert.ok(side.includes('return `${label}: 게시물 ${posts}개 · MP4 ${mp4}개 · JPG ${jpg}개`;'), 'current/run counter text missing');
assert.ok(side.includes('const currentCountLine = () => countLine("현재 계정", teAutoPyStats || {});'), 'current account count line missing');
assert.ok(side.includes('const runCountLine = () => countLine("전체 실행", teAutoPyRunTotals || {});'), 'whole run count line missing');
assert.ok(side.includes('teAutoPyRunTotals.jpg_count = Math.max(0, Number(teAutoPyRunTotals.jpg_count || 0) || 0) + 1;'), 'whole-run JPG count missing');
assert.ok(side.includes('teAutoPyRunTotals.mp4_count = Math.max(0, Number(teAutoPyRunTotals.mp4_count || 0) || 0) + 1;'), 'whole-run MP4 count missing');
assert.ok(side.includes('completedMp4: p.completedMp4'), 'bulk resume state must preserve MP4 count');
assert.ok(side.includes('function teAiwkRefreshSetupNotice(force)'), 'proactive WS setup notice refresh missing');
assert.ok(side.includes('type: "IGBLUE_RTC_STATUS"'), 'WS setup notice must query background status');
assert.ok(side.includes('setInterval(() => teAiwkRefreshSetupNotice(false), 10000)'), 'WS setup notice retry interval missing');
assert.ok(side.includes('teAiwkTrackWsResult(res || {ok:false, connected:false, error_code:"AIWK_OPEN_EMPTY"})'), 'local open failure must surface WS installation guide');

const sideWs = read('side_ws.js');
for (const token of ['IGBLUE_DOWNLOAD_URL', 'ig_media_exists', 'ig_media_download_direct', 'ig_thread_queue_result']) assert.ok(bg.includes(token), `missing direct download token: ${token}`);
for (const token of ['teAutoPyDirectDownloadAsset', 'teAutoPyDownloadMediaPairDirect', 'teAutoPyStartOnceFromUi']) assert.ok(side.includes(token), `missing direct auto token: ${token}`);
for (const token of ['IGBLUE_AUTO_OWNER_CLAIM', 'IGBLUE_RED_OWNER_UPDATED', 'teAutoPyWaitUserMouseIdle', 'teAutoPyRtcClickMedia', 'teAutoPyOriginFindBeforeStart', 'igblue_get_hover_target']) assert.ok(!side.includes(token), `retired side token remains: ${token}`);
for (const token of ['igblue_hover_target', 'igblue_browser_origin', 'igblue_mouse_sync', 'igblue_run_origin_find', 'igblue_get_last_dom_click_ack']) assert.ok(!content.includes(token), `retired content token remains: ${token}`);
assert.ok(content.includes('igblue_auto_py_next'));
assert.ok(content.includes('igblue_get_active_download_target'));
assert.ok(!sideWs.includes('user_mouse'));
assert.ok(!fs.existsSync(path.join(root, 'js/ig_origin_calibration.js')));
for (const token of ["BULK_ALL", "BULK_FILTERED", "TOP_SORTED", "MANUAL_SINGLE", "client_batch_key", "client_event_id"]) assert.ok(side.includes(token) || content.includes(token), `missing MEDIA_SAVED flow token: ${token}`);

assert.ok(side.includes('const normalizedFlowMeta = tePrepareDownloadFlowMeta'), 'manual carousel shared batch normalization missing');
assert.ok(side.includes('const flowEntryKey = `${normalizedFlowMeta.client_batch_key}'), 'per-file event key must use actual batch key');
assert.ok(!side.includes('(flowMeta && flowMeta.client_batch_key) || "batch"'), 'literal fallback batch key remains');
const parserStart = side.indexOf('function teAutoPyDirectReportResultFromResponse(res)');
const parserEnd = side.indexOf('async function teAutoPyDirectDownloadAsset', parserStart);
assert.ok(parserStart >= 0 && parserEnd > parserStart, 'direct finalize parser block missing');
const parserCtx = {};
vm.createContext(parserCtx);
vm.runInContext(`${side.slice(parserStart, parserEnd)}; this.parseDirect = teAutoPyDirectReportResultFromResponse;`, parserCtx);
const parsedDirect = parserCtx.parseDirect({
  status: 'ok', file_path: 'D:/_StDown/instagram/account/original.mp4', file_saved: true, storage_ok: true,
  result: { finalize_ok: true, file_path: 'D:/_StDown/instagram/account/original.mp4', finalize: { ok: true, storage_ok: true, json_path: 'D:/_StDown/instagram/account.json', report_ok: true, report_path: 'D:/_StDown/instagram/account.html' } }
});
assert.strictEqual(parsedDirect.ok, true, 'automatic WS direct response must be accepted as completed');
assert.strictEqual(parsedDirect.file_saved, true);
assert.strictEqual(parsedDirect.json_saved, true);
assert.strictEqual(parsedDirect.report_saved, true);

assert.ok(content.includes('파일 저장 완료\\nDB 처리 실패'), 'MAIN overlay partial failure status missing');

const metricBlockStart = side.indexOf('function teMetricNumber(e)');
const metricBlockEnd = side.indexOf('function teShortNumber(e)', metricBlockStart);
assert.ok(metricBlockStart >= 0 && metricBlockEnd > metricBlockStart, 'metric filter helper block missing');
const metricInputs = {
  top_n_count: {value: '10'},
  igblue_min_comments: {value: '100'},
  igblue_min_likes: {value: '200'},
  igblue_min_shares: {value: '2'},
  igblue_min_reposts: {value: '3'}
};
const metricCtx = {
  document: {getElementById: (id) => metricInputs[id] || null},
  teLocalGet: (_key, fallback) => fallback,
  G: ['like_count','comment_count','media_repost_count','created_at','play_count','','engagement_rate','share_count'],
  Z: 4,
  location: {href: 'https://www.instagram.com/example/'},
  teCurrentTargetUrl: () => 'https://www.instagram.com/example/'
};
vm.createContext(metricCtx);
vm.runInContext(`${side.slice(metricBlockStart, metricBlockEnd)}; this.passSecondary=teMediaPassesSecondaryFilters; this.passTop=teMediaPassesTopConditions; this.score=teParticipationScore; this.sortCandidates=teSortCandidateList; this.hasMetric=teMetricHasValue; this.minComments=teMinCommentsValue; this.minLikes=teMinLikesValue; this.minShares=teMinSharesValue; this.minReposts=teMinRepostsValue;`, metricCtx);
assert.strictEqual(metricCtx.minComments(), 100);
assert.strictEqual(metricCtx.minLikes(), 200);
assert.strictEqual(metricCtx.minShares(), 0);
assert.strictEqual(metricCtx.minReposts(), 3);
const privatePass = {code:'private-pass', comment_count:100, like_count:200, share_count:2, media_repost_count:3};
assert.strictEqual(metricCtx.passSecondary(privatePass), true);
assert.strictEqual(metricCtx.passSecondary({comment_count:99, like_count:999, share_count:2, media_repost_count:3}), false);
assert.strictEqual(metricCtx.passSecondary({comment_count:999, like_count:199, share_count:2, media_repost_count:3}), false);
assert.strictEqual(metricCtx.passSecondary({comment_count:999, like_count:999, share_count:1, media_repost_count:3}), true, 'share count must be ignored');
assert.strictEqual(metricCtx.passSecondary({comment_count:999, like_count:999, share_count:2, media_repost_count:2}), false);
assert.strictEqual(metricCtx.passTop(privatePass, 'play_count', 100000), true, 'missing views must pass via active AND engagement conditions');
assert.strictEqual(metricCtx.passTop({...privatePass, play_count:99999}, 'play_count', 100000), false, 'present views below threshold must not use fallback');
assert.strictEqual(metricCtx.hasMetric({...privatePass, play_count:-1}, 'play_count'), false, 'play_count=-1 must be treated as private views');
assert.strictEqual(metricCtx.passTop({...privatePass, play_count:-1}, 'play_count', 100000), true, 'play_count=-1 must use active engagement conditions');
assert.strictEqual(metricCtx.passTop({...privatePass, play_count:777777, views_available:0}, 'play_count', 100000), true, 'views_available=0 must override a numeric view value and use engagement fallback');
assert.strictEqual(metricCtx.passTop({...privatePass, play_count:100000}, 'play_count', 100000), true);
assert.strictEqual(metricCtx.score(privatePass), 200 + 100 + 3, 'private-view score must be the plain sum of active metrics');
const ranked = JSON.parse(JSON.stringify(metricCtx.sortCandidates([
  {code:'private-low', comment_count:100, like_count:200, share_count:2, media_repost_count:3},
  {code:'view-low', play_count:100000, comment_count:100, like_count:200, share_count:2, media_repost_count:3},
  {code:'private-high', comment_count:200, like_count:500, share_count:10, media_repost_count:5},
  {code:'view-high', play_count:200000, comment_count:100, like_count:200, share_count:2, media_repost_count:3}
], 'play_count')));
assert.deepStrictEqual(ranked.map(x => x.code), ['view-high','view-low','private-high','private-low'], 'viewed posts first, missing-view posts by plain active-metric sum');
metricInputs.top_n_count.value = '0';
const noViewCriterion = JSON.parse(JSON.stringify(metricCtx.sortCandidates([
  {code:'has-view-low-score', play_count:999999, comment_count:100, like_count:200, share_count:2, media_repost_count:3},
  {code:'no-view-high-score', comment_count:200, like_count:500, share_count:10, media_repost_count:5}
], 'play_count')));
assert.deepStrictEqual(noViewCriterion.map(x => x.code), ['no-view-high-score','has-view-low-score'], 'view threshold 0 must exclude views from ranking and use active participation only');
metricInputs.igblue_min_likes.value = '0';
metricInputs.igblue_min_shares.value = '0';
metricInputs.igblue_min_reposts.value = '0';
assert.strictEqual(metricCtx.score({comment_count:10, like_count:9999, share_count:9999, media_repost_count:9999}), 10, 'zero-configured metrics must be excluded from the plain participation sum');


// v251: Injection manual image/video downloads must carry the full shortcode metadata object.
assert.ok(inject.includes('function teMainDownloadMetric(media,rankInfo,keys,rankKey)'), 'Injection metric presence resolver missing');
assert.ok(inject.includes('schema:"ig_local_collect_v3_main_v251"'), 'Injection full download metadata schema missing');
assert.ok(inject.includes('metric_presence:{views:views.present,likes:likes.present,comments:comments.present,reposts:reposts.present}'), 'Injection metric presence flags missing');
assert.ok(inject.includes('media_index:Number(mediaIndex)||0,total_media:Number(totalMedia)||1'), 'Injection carousel identity missing');
assert.ok(inject.includes('sort:sort,sort_snapshot:Object.assign({},sort)'), 'Injection sort metadata parity missing');
assert.ok(inject.includes('o.textContent="✅"'), 'exact completion emoji replacement missing');
assert.ok(inject.includes('if(!t||!n)return;') && inject.includes('if(i===t&&r===n'), 'completion must require exact shortcode and media kind');
assert.ok(inject.includes('done.size<totalMedia') && inject.includes('__IGBLUE_DOWNLOAD_COMPLETE_INDEXES__'), 'carousel completion must wait for all media indexes');
assert.ok(content.includes('postMainDevStatus("✅", "ok", res);'), 'v279a v262 relay must report the direct background result');
const completeStart = inject.indexOf('function teMainMediaFromResult');
const completeEnd = inject.indexOf('window.addEventListener("message"', completeStart);
assert.ok(completeStart >= 0 && completeEnd > completeStart, 'download completion helper block missing');
function makeAction(code, kind) {
  const attrs = {'data-code': code};
  return {
    textContent: '▶', title: '', classList: {add(){}},
    getAttribute(name) { return attrs[name] || ''; },
    setAttribute(name, value) { attrs[name] = String(value); }
  };
}
const exactVideo = makeAction('CODE1', 'mp4');
const sameImage = makeAction('CODE1', 'jpg');
const otherVideo = makeAction('CODE2', 'mp4');
const completeCtx = {
  window: {},
  document: {querySelectorAll: () => [exactVideo, sameImage, otherVideo]},
  teMainMediaTypeForButton: (el) => el === sameImage ? 'jpg' : 'mp4',
  teMainSetDevStatus: () => {},
  Map, Set, Number, String, Math, console
};
vm.createContext(completeCtx);
vm.runInContext(`${inject.slice(completeStart, completeEnd)}; this.markDownload=teMainMarkDownloadedResult;`, completeCtx);
completeCtx.markDownload({shortcode:'CODE1', asset_kind:'video', media_index:0, meta:{shortcode:'CODE1', asset_kind:'video', media_index:0, total_media:1}});
assert.strictEqual(exactVideo.textContent, '✅');
assert.strictEqual(sameImage.textContent, '▶');
assert.strictEqual(otherVideo.textContent, '▶');
const carouselImage = makeAction('CAR1', 'jpg');
completeCtx.document.querySelectorAll = () => [carouselImage];
completeCtx.teMainMediaTypeForButton = () => 'jpg';
completeCtx.markDownload({shortcode:'CAR1', asset_kind:'image', media_index:0, meta:{shortcode:'CAR1', asset_kind:'image', media_index:0, total_media:2}});
assert.strictEqual(carouselImage.textContent, '▶');
completeCtx.markDownload({shortcode:'CAR1', asset_kind:'image', media_index:1, meta:{shortcode:'CAR1', asset_kind:'image', media_index:1, total_media:2}});
assert.strictEqual(carouselImage.textContent, '✅');
assert.ok(bg.includes('shortcode:igblueMetaShortcode(msg && msg.meta)') && bg.includes('meta:msg && msg.meta || null'), 'successful background response must return shortcode metadata');
const manualMetaStart = inject.indexOf('function teMainDownloadMetric');
const manualMetaEnd = inject.indexOf('function teMainOriginKey', manualMetaStart);
assert.ok(manualMetaStart >= 0 && manualMetaEnd > manualMetaStart, 'Injection manual metadata helper block missing');
const manualMetaCtx = {
  teMainRankLabelMap: new Map([['CODE1',{no:2,download_account:'owner1',sort_field:'play_count',sort_value:321}]]),
  S: () => ({code:'CODE1',username:'owner1',play_count:321,like_count:12,comment_count:3,media_repost_count:2,created_at:1750000000,img_origin:'https://cdn/thumb.jpg',video_url:'https://cdn/video.mp4',caption:{text:'caption'},id:'MID1',pk:'PK1',media_type:2}),
  teMainOriginalFileInfo: () => ({original_filename:'orig',original_filename_with_ext:'orig.mp4',original_ext:'mp4'}),
  teMainUseNoForCode: () => true,
  teMainOwnerForCode: () => 'owner1',
  teMainUploadStampForCode: () => '20260725_120000',
  teMainIsoFromStamp: () => '2026-07-25 12:00:00',
  location: {href:'https://www.instagram.com/p/CODE1/'},
  console
};
vm.createContext(manualMetaCtx);
vm.runInContext(`${inject.slice(manualMetaStart, manualMetaEnd)}; this.buildManualMeta=teMainDownloadMeta; this.readManualMetric=teMainDownloadMetric;`, manualMetaCtx);
const builtManual = JSON.parse(JSON.stringify(manualMetaCtx.buildManualMeta('CODE1','https://cdn/video.mp4','owner1_CODE1',0,1).meta));
assert.strictEqual(builtManual.shortcode, 'CODE1');
assert.strictEqual(builtManual.views, 321);
assert.strictEqual(builtManual.likes, 12);
assert.strictEqual(builtManual.comments, 3);
assert.strictEqual(builtManual.reposts, 2);
assert.strictEqual(builtManual.thumbnail_url, 'https://cdn/thumb.jpg');
assert.strictEqual(builtManual.video_url, 'https://cdn/video.mp4');
assert.strictEqual(builtManual.caption, 'caption');
assert.strictEqual(builtManual.sort.views, 321);
assert.strictEqual(builtManual.stable_identity.media_id, 'MID1');
const manualMissingViews = manualMetaCtx.readManualMetric({}, {views:0, metric_presence:{views:false}}, ['play_count','view_count','views'], 'views');
assert.strictEqual(manualMissingViews.present, false);
assert.strictEqual(manualMissingViews.value, 0);
const manualRealZeroViews = manualMetaCtx.readManualMetric({play_count:0}, {views:0, metric_presence:{views:true}}, ['play_count','view_count','views'], 'views');
assert.strictEqual(manualRealZeroViews.present, true);
assert.strictEqual(manualRealZeroViews.value, 0);
assert.ok(side.includes('metric_presence: {') && side.includes('views: teViewsAvailable(media)'), 'rank labels must preserve private/missing-vs-zero metric presence');
console.log('OK test_ext_module v272 memory + v249-auto + v251 injection metadata');

assert.ok(side.includes('function teStatsScopeFilter(info, sourceUrl)'), 'current page statistics scope filter missing');
assert.ok(side.includes('views_available: viewsAvailable ? 1 : 0'), 'views availability flag missing');
assert.ok(inject.includes('function teStatsResetMediaScope(e)'), 'page navigation media cache reset missing');
assert.ok(inject.includes('teStatsResetMediaScope(e);P(e);'), 'page scope reset must run before page parsing');
