/* bench/plpage_sim.c — 歌单页「懒加载分页 + 加载后列表刷新」离线回归(0 真实 NCM 请求)。 * * 驱动真实 app/ui/playlist.c(页面打开 / 滚动触发 / worker / tick 消费全链路)+ 真实 * appstate.c(滑动窗口)+ 真实 songmeta.c(/tmp 盘缓存)+ 宿主 SDK(meow_listview/draw 全真, * 与 ncm_host 同一批 out/host/sdk_*.o)。网络端点(ncm_api_playlist_detail / * ncm_api_song_detail)为本地合成 stub(计调用次数、可注入单批失败)。 * * Bug1(大歌单懒加载分页)验证点: * ① 打开 400 首歌单:首屏 = 1 detail + 恰 1 批 song_detail(物化 [0,100),第 100 首未物化) * ——不再首屏连拉多批补满窗口(旧行为 ≤3 批连发,5k+ 歌单阻塞 + 风控); * ② 顶部小幅滚动(视口底距已分页底部 >20 首):不发任何新请求; * ③ 滑到已分页底部(余量 ≤20 首):恰拉 1 页;单批失败时 g_paged_hi 不推进(占位保留), * 再滑到底部重试同一页成功(一次一页,不连拉); * ④ 逐页到底(400 = 4 页);全部拉完后滑到尾部:不再发请求(停止); * ⑤ 回滚到顶部:失效槽(窗口挤出)由盘缓存补物化,song_detail 调用数 0 增长(不占网络额度); * Bug2(加载完列表刷新)验证点: * ⑥ 首屏/每页 worker 完成经 playlist_tick 消费后,整帧重画计数(--wrap=meow_ui_paint_full) * 递增,且新物化行经 appstate_song_copy 取到真实歌名/歌手(= pl_src2 将绑定真实数据, * 不再是「... 加载中」占位)。 * 风控纪律:0 次真实 NCM 请求(离线 stub + /tmp 盘缓存)。 * * 编译/运行见 build_ncm_sims.sh「plpage_sim」段(宿主,ncm_app 目录下)。 */ #include "../app/ncm_app_internal.h" #include #include #include #include #include ncm_app_state_t g_st; ncm_play_run_t g_play = { .mu = PTHREAD_MUTEX_INITIALIZER, .state = NCM_PC_STOPPED, .cur_song = -1 }; volatile int g_app_quit = 0; volatile int g_net_ready = 0; int g_host_mock = 0; /* 真实分页链路(不走 mock 跳网分支) */ /* ---- core 符号最小实现(同 playlist_race_sim 口径) ---- */ size_t ncm_strlcpy(char *dst, const char *src, size_t sz) { size_t n = 0; if (sz) { while (src[n] && n < sz - 1) { dst[n] = src[n]; n++; } dst[n] = 0; } while (src[n]) n++; return n; } int64_t ncm_now_ms(void) { struct timespec ts; clock_gettime(CLOCK_REALTIME, &ts); return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000; } /* ---- cover / 播放 stub(本 sim 不测封面与播放;playlist.c 引用,链接需要) ---- */ void cover_request(const char *url, const char *song_id) { (void)url; (void)song_id; } meow_img *cover_get(void) { return NULL; } void cover_put(void) {} int cover_changed(void) { return 0; } void cover_ack(void) {} void cover_cache_reset(void) {} void player_play_song(int idx) { (void)idx; } /* ---- core 符号(appstate.c 引用;运行时不到达,仅链接需要) ---- */ int ncm_login_status_check(const char *device_id, const char *cookie, char *uid, size_t uid_sz, char *nick, size_t nick_sz) { (void)device_id;(void)cookie;(void)uid;(void)uid_sz;(void)nick;(void)nick_sz; return NCM_ERR_AUTH; } int ncm_ctx_clear_cookie(ncm_ctx_t *ctx) { (void)ctx; return NCM_OK; } int ncm_api_user_playlist(const char *device_id, const char *cookie, const char *uid, int limit, int offset, ncm_playlist_t *out, int max, int *out_n) { (void)device_id;(void)cookie;(void)uid;(void)limit;(void)offset;(void)out;(void)max;(void)out_n; return NCM_ERR_NET; } /* ---- 网络端点 stub(离线,合成数据;计调用次数 + 可注入单批失败) ---- */ #define SIM_TOTAL 400 /* 4 页:100 × 4(> NCM_SONG_WINDOW 256,回滚必滑窗) */ static int g_detail_calls; static int g_song_calls; /* song_detail 成功批次数 */ static int g_song_attempts; /* song_detail 发起次数(含失败批) */ static int g_song_fail_n; /* 接下来 N 批 song_detail 网络失败模拟 */ int ncm_api_playlist_detail(const char *device_id, const char *cookie, const char *playlist_id, char *name, size_t name_sz, char *cover, size_t cover_sz, int64_t *ids, int id_cap, int *out_id_n) { (void)device_id; (void)cookie; (void)playlist_id; (void)cover_sz; g_detail_calls++; if (name) snprintf(name, name_sz, "Sim Playlist"); if (cover) cover[0] = 0; int n = (SIM_TOTAL < id_cap) ? SIM_TOTAL : id_cap; for (int i = 0; i < n; i++) ids[i] = 1000000LL + i; *out_id_n = n; return NCM_OK; } int ncm_api_song_detail(const char *device_id, const char *cookie, const int64_t *ids, int n, ncm_song_t *out, int max, int *out_n) { (void)device_id; (void)cookie; g_song_attempts++; if (g_song_fail_n > 0) { /* 单批网络失败模拟(失败不推进分页高水位 → 同页重试) */ g_song_fail_n--; if (out_n) *out_n = 0; return NCM_ERR_NET; } g_song_calls++; int m = (n < max) ? n : max; for (int i = 0; i < m; i++) { snprintf(out[i].id, sizeof out[i].id, "%lld", (long long)ids[i]); snprintf(out[i].name, sizeof out[i].name, "Song%lld", (long long)ids[i]); snprintf(out[i].art_name, sizeof out[i].art_name, "Artist%lld", (long long)ids[i]); snprintf(out[i].album_name, sizeof out[i].album_name, "Album%lld", (long long)ids[i]); out[i].duration_ms = 200000; out[i].fee = 0; } if (out_n) *out_n = m; return NCM_OK; } /* ---- Bug2 证据:整帧重画计数(链接时 --wrap=meow_ui_paint_full 重定向到此) ---- */ extern void __real_meow_ui_paint_full(void); static int g_paint_full_calls; void __wrap_meow_ui_paint_full(void) { g_paint_full_calls++; __real_meow_ui_paint_full(); } /* ---- 驱动辅助:模拟 app_on_tick 周期性消费 worker 完成 ---- */ static int pump_until(int (*cond)(void), int timeout_ms) { for (int t = 0; t < timeout_ms; t += 5) { playlist_tick(0); if (cond()) { /* 再喂若干 tick:确保完成态被消费(g_plthr_on 清零),后续滚动可再触发 */ for (int k = 0; k < 30; k++) { playlist_tick(0); usleep(2000); } return 1; } usleep(5000); } return 0; } static int g_fail = 0; #define CHECK(cond, fmt, ...) do { \ if (cond) printf(" PASS " fmt "\n", ##__VA_ARGS__); \ else { printf(" FAIL " fmt "\n", ##__VA_ARGS__); g_fail = 1; } \ } while (0) /* 条件(C 无闭包,用全局目标值) */ static int g_want_calls; static int cond_calls(void) { return g_song_calls >= g_want_calls; } static int g_want_attempts; static int cond_attempts(void) { return g_song_attempts >= g_want_attempts; } static int cond_first_screen(void) { return g_st.track_ids_count == SIM_TOTAL && g_song_calls >= 1; } /* 滚动到指定首行(行高 APP_ROW_H px;swipe 距离按当前行差换算) */ static void scroll_to_row(int row) { static int cur; int d = row - cur; cur = row; playlist_swipe(d >= 0 ? MEOW_SWIPE_UP : MEOW_SWIPE_DOWN, (d >= 0 ? d : -d) * APP_ROW_H); } int main(void) { printf("== plpage_sim: 懒加载分页 + 加载后列表刷新(%d 首,窗口 %d,批 %d,触底余量 20)==\n", SIM_TOTAL, NCM_SONG_WINDOW, NCM_SONG_BATCH); /* 宿主 SDK 离屏初始化(与 ncm_host 同口径) */ setenv("MEOW_UI_DUMP", "/tmp/plpage_sim.ppm", 0); meow_ui_opts o = { APP_W, APP_H }; if (meow_ui_init(&o) != 0) { fprintf(stderr, "init failed\n"); return 1; } meow_theme_set_mode(MEOW_THEME_DARK); ncm_on_theme(MEOW_THEME_DARK); /* 歌单 mock:1 张 SIM_TOTAL 首歌单;songmeta 盘缓存用临时目录 */ char cdt[] = "/tmp/plpage_cache_XXXXXX"; if (!mkdtemp(cdt)) { fprintf(stderr, "mkdtemp failed\n"); return 1; } snprintf(g_st.cache_dir, sizeof g_st.cache_dir, "%s", cdt); g_st.playlist_count = 1; snprintf(g_st.playlists[0].id, sizeof g_st.playlists[0].id, "pl0"); snprintf(g_st.playlists[0].name, sizeof g_st.playlists[0].name, "SimPlaylist"); g_st.playlists[0].track_count = SIM_TOTAL; ncm_song_t sq; int paint0; /* ---- ① Bug1:打开歌单 → 首屏恰 1 detail + 1 批 song_detail(物化 [0,100)) ---- */ printf("== case 1: 首屏只拉一页 ==\n"); paint0 = g_paint_full_calls; playlist_view_create(); playlist_open(0); CHECK(pump_until(cond_first_screen, 8000), "first-screen loaded (track_ids=%d calls=%d)", g_st.track_ids_count, g_song_calls); CHECK(g_detail_calls == 1, "detail calls=%d (expect 1)", g_detail_calls); CHECK(g_song_calls == 1, "song_detail calls=%d (expect 1, 不再首屏连拉多批)", g_song_calls); CHECK(g_st.track_ids_count == SIM_TOTAL, "track_ids_count=%d (expect %d)", g_st.track_ids_count, SIM_TOTAL); CHECK(appstate_song_copy(99, &sq) == 0 && strcmp(sq.name, "Song1000099") == 0, "row 99 materialized name='%s'", sq.name); CHECK(appstate_song_copy(100, &sq) != 0, "row 100 NOT materialized yet (placeholder)"); /* Bug2:首屏完成 → 重画计数递增 + 行绑定真实歌名/歌手(非占位) */ CHECK(g_paint_full_calls > paint0, "paint_full after first-screen %d -> %d (list repainted)", paint0, g_paint_full_calls); CHECK(appstate_song_copy(5, &sq) == 0 && strcmp(sq.art_name, "Artist1000005") == 0, "row 5 binds real artist='%s' (src2 填真实数据,非「... 加载中」)", sq.art_name); /* ---- ② 顶部小幅滚动:不触发任何新请求(距已分页底部 >20 首) ---- */ printf("== case 2: 顶部滚动不拉页 ==\n"); scroll_to_row(10); int a0 = g_song_attempts; for (int k = 0; k < 40; k++) { playlist_tick(0); usleep(3000); } CHECK(g_song_attempts == a0, "song_detail attempts=%d (unchanged, 顶部滚动不拉页)", g_song_attempts); /* ---- ③ 滑到已分页底部(余量 ≤20 首):恰拉 1 页;单批失败不推进,再滑重试同页成功 ---- */ printf("== case 3: 触底拉下一页(一次一页;失败重试同页) ==\n"); g_song_fail_n = 1; /* 注入:下一次 song_detail 网络失败 */ int att_before = g_song_attempts; scroll_to_row(90); /* 视口底 ≈97 行,余量 ≤20 → 触发第 2 页(失败) */ g_want_attempts = att_before + 1; CHECK(pump_until(cond_attempts, 8000), "page-2 first attempt fired (attempts=%d)", g_song_attempts); CHECK(g_song_calls == 1, "page-2 failed -> calls=%d (g_paged_hi 不推进,占位保留)", g_song_calls); CHECK(appstate_song_copy(199, &sq) != 0, "row 199 still placeholder after failed page"); scroll_to_row(91); /* 再滑(滚动事件)→ 重试同一页 */ g_want_calls = 2; CHECK(pump_until(cond_calls, 8000), "page 2 retried & loaded (calls=%d attempts=%d)", g_song_calls, g_song_attempts); CHECK(g_song_attempts == att_before + 2, "attempts +%d (1 失败 + 1 重试同页 = 一次一页,未连拉)", g_song_attempts - att_before); CHECK(appstate_song_copy(199, &sq) == 0 && strcmp(sq.name, "Song1000199") == 0, "row 199 materialized name='%s'", sq.name); CHECK(g_paint_full_calls > paint0, "paint_full after page-2 consumed (=%d)", g_paint_full_calls); /* ---- ④ 逐页到底;全部拉完后滑到尾部:不再发请求 ---- */ printf("== case 4: 逐页到底 + 拉完即止 ==\n"); att_before = g_song_attempts; scroll_to_row(190); /* 余量 ≤20 → 触发第 3 页 */ g_want_calls = 3; CHECK(pump_until(cond_calls, 8000), "page 3 loaded (calls=%d)", g_song_calls); scroll_to_row(290); /* 余量 ≤20 → 触发第 4 页(末页) */ g_want_calls = 4; CHECK(pump_until(cond_calls, 8000), "page 4 (last) loaded (calls=%d)", g_song_calls); CHECK(g_song_attempts == att_before + 2, "attempts +%d (第 3、4 页恰各 1 批)", g_song_attempts - att_before); CHECK(appstate_song_copy(399, &sq) == 0 && strcmp(sq.name, "Song1000399") == 0, "tail row 399 materialized name='%s'", sq.name); att_before = g_song_attempts; scroll_to_row(390); /* 已在尾部:全部拉完 → 不再发请求 */ for (int k = 0; k < 40; k++) { playlist_tick(0); usleep(3000); } CHECK(g_song_attempts == att_before, "all loaded -> scroll at tail: attempts=%d (unchanged)", g_song_attempts); /* ---- ⑤ 回滚到顶部:失效槽(窗口挤出)盘缓存补物化,song_detail 0 增长 ---- */ printf("== case 5: 回滚补物化走盘缓存(0 网络) ==\n"); att_before = g_song_attempts; scroll_to_row(0); /* 回顶部:窗口已滑走,行 0-35 失效 → 盘缓存补 */ for (int k = 0; k < 400 && !appstate_range_loaded(0, 30); k++) { playlist_tick(0); usleep(5000); } CHECK(appstate_range_loaded(0, 30), "rows 0-29 re-materialized after scroll-back"); CHECK(g_song_attempts == att_before, "scroll-back refill: song_detail attempts=%d " "(unchanged, 盘缓存命中不占网络)", g_song_attempts); CHECK(appstate_song_copy(0, &sq) == 0 && strcmp(sq.name, "Song1000000") == 0, "row 0 re-materialized name='%s'", sq.name); printf("%s\n", g_fail ? "RESULT: FAIL" : "RESULT: PASS"); /* 清理临时盘缓存(≤400 个小文件) */ { char cmd[600]; snprintf(cmd, sizeof cmd, "rm -rf %s", cdt); if (system(cmd) != 0) fprintf(stderr, "cleanup failed: %s\n", cdt); } meow_ui_shutdown(); return g_fail ? 1 : 0; }